From 98e033083af7f18211004960e4c1204215c65d84 Mon Sep 17 00:00:00 2001 From: MQ37 Date: Fri, 14 Aug 2026 16:19:10 +0200 Subject: [PATCH 1/5] fix: Stop enforcing enum values dropped by input-schema truncation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit shortenProperties() truncates a large enum for display, but the same truncated array was also compiled straight into the AJV validator — rejecting Actor input values that were cut only because the display list has a character cap, not because the Actor's real schema forbids them (e.g. compass/crawler-google-places' categoryFilterWords). Strip enum/items.enum from the AJV-compiled schema only for properties where truncation actually happened, comparing against the raw Actor schema. The schema shown to the LLM keeps the truncated list unchanged — only local enforcement of the incomplete list is removed. Closes #1253. Written with AI assistance (Claude). --- src/tools/actor_input_schema.ts | 40 +++++++ src/tools/actors/actor_tools_factory.ts | 14 ++- tests/unit/tools.utils.test.ts | 133 ++++++++++++++++++++++++ 3 files changed, 185 insertions(+), 2 deletions(-) diff --git a/src/tools/actor_input_schema.ts b/src/tools/actor_input_schema.ts index e19cc8cbc..9075d9c22 100644 --- a/src/tools/actor_input_schema.ts +++ b/src/tools/actor_input_schema.ts @@ -365,6 +365,46 @@ export function decodeDotPropertyNames(properties: Record): Rec return decodedProperties; } +/** + * True if filterAndShortenEnum() truncated the enum — compared against the non-empty raw count, + * not raw length, since it also drops blank entries regardless of the character cap. + */ +function wasEnumTruncated(displayEnum: string[], rawEnum: string[]): boolean { + const nonEmptyRawCount = rawEnum.filter((value) => value !== '').length; + return displayEnum.length < nonEmptyRawCount; +} + +/** + * Clones properties and drops `enum`/`items.enum` wherever shortenProperties() truncated it — + * AJV must not enforce an incomplete list (#1253), but the schema shown to the LLM keeps it. + * A property missing from one side (e.g. the injected `waitSecs`) is left untouched. + */ +export function stripTruncatedEnumsForValidation( + displayProperties: Record, + rawProperties: Record, +): Record { + const rawEncoded = encodeDotPropertyNames(rawProperties); + const validationProperties = structuredClone(displayProperties); + + for (const [key, property] of Object.entries(validationProperties)) { + const rawProperty = rawEncoded[key]; + if (!rawProperty) continue; + + if (property.enum && rawProperty.enum && wasEnumTruncated(property.enum, rawProperty.enum)) { + delete property.enum; + } + if ( + property.items?.enum && + rawProperty.items?.enum && + wasEnumTruncated(property.items.enum, rawProperty.items.enum) + ) { + delete property.items.enum; + } + } + + return validationProperties; +} + export function transformActorInputSchemaProperties(input: Readonly): ActorInputSchemaProperties { // Deep clone input to avoid mutating the original object const inputClone: ActorInputSchema = structuredClone(input); diff --git a/src/tools/actors/actor_tools_factory.ts b/src/tools/actors/actor_tools_factory.ts index 08a92547d..030517c3f 100644 --- a/src/tools/actors/actor_tools_factory.ts +++ b/src/tools/actors/actor_tools_factory.ts @@ -20,6 +20,7 @@ import { type ActorStore, type ActorTool, type ApifyToken, + type SchemaProperties, type ToolEntry, type ToolInputSchema, TOOL_TYPE, @@ -28,7 +29,7 @@ import { getActorDefinitionCached } from '../../utils/actor.js'; import { ajv } from '../../utils/ajv.js'; import { stripQuoteWrappers } from '../../utils/generic.js'; import { logHttpError } from '../../utils/logging.js'; -import { buildActorInputSchema, fixedAjvCompile } from '../actor_input_schema.js'; +import { buildActorInputSchema, fixedAjvCompile, stripTruncatedEnumsForValidation } from '../actor_input_schema.js'; import { actorNameToToolName, isActorBlockedUnderPaymentProvider, isActorInfoMcpServer } from '../actor_tool_naming.js'; import { buildEnrichedDirectActorOutputSchema, actorRunOutputSchema } from '../structured_output_schemas.js'; import { CALL_ACTOR_WAIT_SECS_DEFAULT, WAIT_SECS_MAX } from './actor_run_response.js'; @@ -133,12 +134,21 @@ Actor description: ${definition.description}`; ACTOR_MAX_MEMORY_MBYTES, ); + // AJV must not enforce a truncated enum (#1253) — definition.input still has the full one. + const validationSchema = { + ...inputSchema, + properties: stripTruncatedEnumsForValidation( + (inputSchemaWithWaitSecs.properties ?? {}) as Record, + definition.input?.properties ?? {}, + ), + }; + let ajvValidate; try { // Unknown properties are silently stripped by AJV's removeAdditional option. // Dynamic Actor input fields are part of the Actor's own inputSchema, so they // are declared properties and won't be stripped. - ajvValidate = fixedAjvCompile(ajv, inputSchema); + ajvValidate = fixedAjvCompile(ajv, validationSchema); } catch (e) { // SchemaTooLargeError logs as a soft fail; a genuine AJV compile error stays an error. logHttpError(e, 'Failed to compile schema', { diff --git a/tests/unit/tools.utils.test.ts b/tests/unit/tools.utils.test.ts index 511fb1d88..7e5bee532 100644 --- a/tests/unit/tools.utils.test.ts +++ b/tests/unit/tools.utils.test.ts @@ -14,6 +14,7 @@ import { MAX_UNTRUSTED_SCHEMA_BYTES, markInputPropertiesAsRequired, shortenProperties, + stripTruncatedEnumsForValidation, transformActorInputSchemaProperties, } from '../../src/tools/actor_input_schema.js'; import { isActorBlockedUnderPaymentProvider } from '../../src/tools/actor_tool_naming.js'; @@ -499,6 +500,95 @@ describe('shortenProperties', () => { }); }); +describe('stripTruncatedEnumsForValidation', () => { + it('keeps enum unchanged when it fits comfortably under the cap on both sides', () => { + const display: Record = { + prop1: { type: 'string', title: 'Prop 1', description: 'desc', enum: ['a', 'b'] }, + }; + const raw: Record = { + prop1: { type: 'string', title: 'Prop 1', description: 'desc', enum: ['a', 'b'] }, + }; + + const result = stripTruncatedEnumsForValidation(display, raw); + + expect(result.prop1.enum).toEqual(['a', 'b']); + }); + + it('strips enum entirely when it was truncated by shortenProperties', () => { + const rawEnum = Array.from({ length: 300 }, (_, i) => `value-${i}`); + const display: Record = { + prop1: { type: 'string', title: 'Prop 1', description: 'desc', enum: filterAndShortenEnum(rawEnum) }, + }; + const raw: Record = { + prop1: { type: 'string', title: 'Prop 1', description: 'desc', enum: rawEnum }, + }; + + const result = stripTruncatedEnumsForValidation(display, raw); + + expect(result.prop1.enum).toBeUndefined(); + }); + + it('strips items.enum entirely when it was truncated (the #1253 shape), leaving other items fields untouched', () => { + const rawEnum = Array.from({ length: 300 }, (_, i) => `value-${i}`); + const display: Record = { + prop1: { + type: 'array', + title: 'Prop 1', + description: 'desc', + items: { type: 'string', title: 'Item', description: 'Item desc', enum: filterAndShortenEnum(rawEnum) }, + }, + }; + const raw: Record = { + prop1: { + type: 'array', + title: 'Prop 1', + description: 'desc', + items: { type: 'string', title: 'Item', description: 'Item desc', enum: rawEnum }, + }, + }; + + const result = stripTruncatedEnumsForValidation(display, raw); + + expect(result.prop1.items?.enum).toBeUndefined(); + expect(result.prop1.items?.type).toBe('string'); + expect(result.prop1.items?.title).toBe('Item'); + }); + + it('does not treat empty-string removal as truncation when non-empty count never exceeds the cap', () => { + const rawEnum = ['a', 'b', '', '', 'c']; + const display: Record = { + prop1: { type: 'string', title: 'Prop 1', description: 'desc', enum: filterAndShortenEnum(rawEnum) }, + }; + const raw: Record = { + prop1: { type: 'string', title: 'Prop 1', description: 'desc', enum: rawEnum }, + }; + + const result = stripTruncatedEnumsForValidation(display, raw); + + expect(result.prop1.enum).toEqual(['a', 'b', 'c']); + }); + + it('leaves a property with no counterpart in rawProperties untouched (e.g. the injected waitSecs)', () => { + const display: Record = { + waitSecs: { type: 'integer', title: 'Wait seconds', description: 'desc' }, + }; + + const result = stripTruncatedEnumsForValidation(display, {}); + + expect(result).toEqual(display); + }); + + it('is a safe no-op when rawProperties is empty', () => { + const display: Record = { + prop1: { type: 'string', title: 'Prop 1', description: 'desc', enum: ['a', 'b'] }, + }; + + const result = stripTruncatedEnumsForValidation(display, {}); + + expect(result).toEqual(display); + }); +}); + describe('encodeDotPropertyNames', () => { it('should replace dots in property names with -dot-', () => { const input = { @@ -1035,6 +1125,49 @@ describe('buildActorInputSchema + getToolPublicFieldOnly pipeline', () => { expect(schema.properties?.query?.description).toMatch(/^\*\*REQUIRED\*\*/); expect(schema.properties?.maxResults?.description).not.toMatch(/^\*\*REQUIRED\*\*/); }); + + // Regression: #1253 — a value cut only by display truncation must still pass AJV. + it('accepts an enum value dropped by display truncation, while the displayed schema keeps the truncated list', () => { + // 'kept-0' is short and sorts first, so it always survives filterAndShortenEnum's cap; + // 'dropped-value' is padded long enough to land well past the cap. + const keptValue = 'kept-0'; + const droppedValue = 'dropped-value-cut-by-truncation'; + const rawEnum = [ + keptValue, + ...Array.from({ length: 300 }, (_, i) => `kept-padding-${i}-${'x'.repeat(20)}`), + droppedValue, + ]; + const upstream: ActorInputSchema = { + type: 'object', + properties: { + categoryFilterWords: { + type: 'string', + title: 'Category', + description: 'Category filter word.', + enum: rawEnum, + }, + }, + required: [], + }; + + const { inputSchema } = buildActorInputSchema('compass/crawler-google-places', upstream, false); + const displayProperties = inputSchema.properties as Record; + + // Display schema still shows the truncated enum, unaffected by the fix. + const displayEnum = displayProperties.categoryFilterWords.enum; + expect(displayEnum).toBeDefined(); + expect(displayEnum!.length).toBeGreaterThan(0); + expect(displayEnum!.length).toBeLessThan(rawEnum.length); + expect(displayEnum).not.toContain(droppedValue); + + // AJV accepts the dropped value — the #1253 repro (categoryFilterWords: ['restaurant']). + const validationSchema = { + ...inputSchema, + properties: stripTruncatedEnumsForValidation(displayProperties, upstream.properties), + }; + const validate = fixedAjvCompile(ajv, validationSchema); + expect(validate({ categoryFilterWords: droppedValue })).toBe(true); + }); }); describe('isActorBlockedUnderPaymentProvider', () => { From 18495a20dd839273b94dd284f34b21c62fd9240a Mon Sep 17 00:00:00 2001 From: MQ37 Date: Fri, 4 Sep 2026 16:08:39 +0200 Subject: [PATCH 2/5] fix: Drop oversized enums entirely instead of enforcing a truncated list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reworks #1258 per review (jirispilka): fold the fix into the single shared shortenProperties() instead of a separate AJV-only schema. An enum that fits ACTOR_ENUM_MAX_LENGTH whole is kept as-is; one that doesn't is dropped entirely (not partially truncated) from both display and validation, with a generic note + a few examples appended to the description instead. - Kept enum: no more duplicate "Possible values" line (the enum field already carries it). - Dropped enum: description stays generic (no tool name) by default; only when fetch-actor-details is present in the session's tools/list does it additionally name it as where to find the Actor's full input schema. New buildInputSchema(ctx) on ToolBase renders that per-session, mirroring buildDescription, since Actor input-schema field text isn't otherwise session-rendered (src/tools/AGENTS.md). - fetch-actor-details inherits the fix for free (same shared function). - Fixes a regression this rework introduced along the way: Skyfire's decorateToolSchema() only patched the static inputSchema, so an injected skyfire-pay-id vanished whenever the gated render was used instead — now composes with buildInputSchema. actor_tools_factory's buildInputSchema also now always clones its render, never handing back the live schema object shared across sessions. Written with AI assistance (Claude). --- src/const.ts | 2 +- src/payments/skyfire.ts | 33 ++-- src/tools/AGENTS.md | 10 +- src/tools/actor_input_schema.ts | 109 ++++++----- src/tools/actors/actor_tools_factory.ts | 39 ++-- src/types.ts | 7 + src/utils/tools.ts | 10 +- ...ayments.skyfire.build_input_schema.test.ts | 89 +++++++++ ...ls.actor_tools_factory.enum_gating.test.ts | 140 +++++++++++++++ tests/unit/tools.clone.test.ts | 16 ++ tests/unit/tools.utils.test.ts | 170 +++++++++--------- 11 files changed, 456 insertions(+), 169 deletions(-) create mode 100644 tests/unit/payments.skyfire.build_input_schema.test.ts create mode 100644 tests/unit/tools.actor_tools_factory.enum_gating.test.ts diff --git a/src/const.ts b/src/const.ts index 4bcce0baa..bf02b2763 100644 --- a/src/const.ts +++ b/src/const.ts @@ -1,6 +1,6 @@ // Actor input const export const ACTOR_README_MAX_LENGTH = 5_000; -// Actor enum property max length, we need to make sure that most of the enum values fit into the input (such as geocodes) +// Max total chars for an enum/items.enum to show in full; over this it's dropped entirely (#1253). export const ACTOR_ENUM_MAX_LENGTH = 2000; export const ACTOR_MAX_DESCRIPTION_LENGTH = 500; diff --git a/src/payments/skyfire.ts b/src/payments/skyfire.ts index 0cf3e2046..9c851efbd 100644 --- a/src/payments/skyfire.ts +++ b/src/payments/skyfire.ts @@ -1,4 +1,4 @@ -import type { ToolEntry } from '../types.js'; +import type { ToolEntry, ToolInputSchema } from '../types.js'; import { redactSkyfirePayId } from '../utils/logging.js'; import { appendToolDescription, cloneToolEntry } from '../utils/tools.js'; import { @@ -10,6 +10,18 @@ import { } from './const.js'; import type { PaymentHeaders, PaymentProvider } from './types.js'; +/** Adds `skyfire-pay-id` to a schema's properties in place, if not already present. */ +function injectPayIdProperty(inputSchema: ToolInputSchema): void { + if (!inputSchema || !('properties' in inputSchema)) return; + const props = inputSchema.properties as Record; + if (!props[SKYFIRE_PAY_ID_KEY]) { + props[SKYFIRE_PAY_ID_KEY] = { + type: 'string', + description: SKYFIRE_PAY_ID_PROPERTY_DESCRIPTION, + }; + } +} + /** * Skyfire payment provider. * @@ -31,15 +43,16 @@ export class SkyfirePaymentProvider implements PaymentProvider { appendToolDescription(cloned, SKYFIRE_TOOL_INSTRUCTIONS); - // Add skyfire-pay-id property to inputSchema (idempotent) - if (cloned.inputSchema && 'properties' in cloned.inputSchema) { - const props = cloned.inputSchema.properties as Record; - if (!props[SKYFIRE_PAY_ID_KEY]) { - props[SKYFIRE_PAY_ID_KEY] = { - type: 'string', - description: SKYFIRE_PAY_ID_PROPERTY_DESCRIPTION, - }; - } + injectPayIdProperty(cloned.inputSchema); + // A per-session inputSchema render bypasses the static field above — wrap it too, or + // pay-id vanishes whenever it's used instead. + if (cloned.buildInputSchema) { + const renderBase = cloned.buildInputSchema; + cloned.buildInputSchema = (ctx) => { + const schema = renderBase(ctx); + injectPayIdProperty(schema); + return schema; + }; } return Object.freeze(cloned); diff --git a/src/tools/AGENTS.md b/src/tools/AGENTS.md index 7eee28f03..776a1f24f 100644 --- a/src/tools/AGENTS.md +++ b/src/tools/AGENTS.md @@ -55,8 +55,14 @@ by `tests/unit/tools.mode_contract.test.ts`, which scans `description` only. Input-schema field text (`.describe()` on a Zod field) reaches `tools/list` verbatim — nothing renders it per session — so never name a tool there; put the guidance in `buildDescription` behind -`hasTool`. `actors/actor_tools_factory.ts`'s `waitSecs` is the one exception: an Actor tool always -auto-injects the `get-actor-run` it names. +`hasTool`. Two exceptions: `actors/actor_tools_factory.ts`'s `waitSecs` always auto-injects the +`get-actor-run` it names; and a dynamically-built Actor `inputSchema` (not a Zod field) can define +`buildInputSchema(ctx)` on the entry — same `ToolDescriptionContext`/`hasTool` contract as +`buildDescription`, rendered at the same `getToolPublicFieldOnly` boundary — see +`actor_tools_factory.ts`'s dropped-enum addendum, gated on `fetch-actor-details`. A payment +decorator that clones a tool (`payments/skyfire.ts`) must wrap an existing `buildInputSchema` +rather than only mutating the static `inputSchema`, or an injected field (`skyfire-pay-id`) +disappears whenever the gated render is used instead. Result text (`summary` / `nextStep` in `content[1]`) has no `hasTool`, but it does have a per-session gate: `InternalToolArgs.loadedToolNames` (see `suggestTool` in `storage/storage_helpers.ts`). Name a diff --git a/src/tools/actor_input_schema.ts b/src/tools/actor_input_schema.ts index 0669fda69..bae9d6862 100644 --- a/src/tools/actor_input_schema.ts +++ b/src/tools/actor_input_schema.ts @@ -266,22 +266,11 @@ export function inferArrayItemType(property: SchemaProperties): string | null { } } -/** - * Add enum values as string to property descriptions, guarding against libraries/agent - * frameworks that don't handle enums or examples via JSON Schema annotations. - * - * https://json-schema.org/understanding-json-schema/reference/enum - * https://json-schema.org/understanding-json-schema/reference/annotations - * - * @param properties - */ -export function addEnumsToDescriptionsWithExamples( +/** Adds prefill/default values to descriptions as examples, for clients that ignore JSON Schema `examples`. */ +export function addExampleValuesToDescriptions( properties: Record, ): Record { for (const property of Object.values(properties)) { - if (property.enum && property.enum.length > 0) { - property.description = `${property.description}\nPossible values: ${property.enum.slice(0, 20).join(',')}`; - } const value = property.prefill ?? property.default; if (value && !(Array.isArray(value) && value.length === 0)) { property.examples = Array.isArray(value) ? value : [value]; @@ -291,30 +280,34 @@ export function addEnumsToDescriptionsWithExamples( return properties; } +const ENUM_DROPPED_NOTE_EXAMPLE_COUNT = 5; +const ENUM_DROPPED_NOTE_EXAMPLE_MAX_LENGTH = 60; + +/** Note for a dropped enum — the field still accepts these values, just too many to list. */ +function buildEnumDroppedNote(rawValues: string[]): string { + const examples = rawValues + .filter((value) => value !== '') + .slice(0, ENUM_DROPPED_NOTE_EXAMPLE_COUNT) + .map((value) => + value.length > ENUM_DROPPED_NOTE_EXAMPLE_MAX_LENGTH + ? `${value.slice(0, ENUM_DROPPED_NOTE_EXAMPLE_MAX_LENGTH)}...` + : value, + ); + return `\nAccepts more values than shown here (e.g. ${examples.join(', ')}, ...) — full list omitted, too long to include.`; +} + /** - * Helper function to filter and shorten the enum list. - * Removes empty strings and truncates if the total character count exceeds the limit. - * - * @param {string[]} enumList - The list of enum values to be filtered and shortened. - * @returns {string[] | undefined} - The filtered and shortened enum list or undefined if the list is too long. + * Blanks removed, kept whole if it fits ACTOR_ENUM_MAX_LENGTH; otherwise dropped entirely (#1253) + * — a partially-cut enum falsely implies exhaustiveness to both the LLM and AJV. */ export function filterAndShortenEnum(enumList: string[]): string[] | undefined { - let charCount = 0; - const resultEnumList = enumList.filter((enumValue) => { - if (enumValue === '') return false; - charCount += enumValue.length; - return charCount <= ACTOR_ENUM_MAX_LENGTH; - }); - - return resultEnumList.length > 0 ? resultEnumList : undefined; + const nonEmpty = enumList.filter((value) => value !== ''); + if (nonEmpty.length === 0) return undefined; + const charCount = nonEmpty.reduce((sum, value) => sum + value.length, 0); + return charCount <= ACTOR_ENUM_MAX_LENGTH ? nonEmpty : undefined; } -/** - * Shortens the description, enum, and items.enum properties of the schema properties. - * This is mostly problem with compass/crawler-google-places, which has large number of categories - * such as ( 'abbey', 'accountant', 'accounting', 'acupuncturist', .... ) - * @param properties - */ +/** Caps description length; drops (not truncates) an oversized enum/items.enum, noting examples instead. */ export function shortenProperties(properties: { [key: string]: SchemaProperties }): { [key: string]: SchemaProperties; } { @@ -323,12 +316,20 @@ export function shortenProperties(properties: { [key: string]: SchemaProperties property.description = `${property.description.slice(0, ACTOR_MAX_DESCRIPTION_LENGTH)}...`; } - if (property.enum && property.enum?.length > 0) { - property.enum = filterAndShortenEnum(property.enum); + if (property.enum && property.enum.length > 0) { + const rawEnum = property.enum; + property.enum = filterAndShortenEnum(rawEnum); + if (property.enum === undefined) { + property.description += buildEnumDroppedNote(rawEnum); + } } if (property.items?.enum && property.items.enum.length > 0) { - property.items.enum = filterAndShortenEnum(property.items.enum); + const rawEnum = property.items.enum; + property.items.enum = filterAndShortenEnum(rawEnum); + if (property.items.enum === undefined) { + property.description += buildEnumDroppedNote(rawEnum); + } } } @@ -372,44 +373,36 @@ export function decodeDotPropertyNames(properties: Record): Rec return decodedProperties; } -/** - * True if filterAndShortenEnum() truncated the enum — compared against the non-empty raw count, - * not raw length, since it also drops blank entries regardless of the character cap. - */ -function wasEnumTruncated(displayEnum: string[], rawEnum: string[]): boolean { - const nonEmptyRawCount = rawEnum.filter((value) => value !== '').length; - return displayEnum.length < nonEmptyRawCount; +/** True if a non-empty enum existed on the raw side but shortenProperties() dropped it entirely. */ +function enumWasDropped(displayEnum: string[] | undefined, rawEnum: string[] | undefined): boolean { + if (displayEnum !== undefined) return false; + return (rawEnum ?? []).some((value) => value !== ''); } /** - * Clones properties and drops `enum`/`items.enum` wherever shortenProperties() truncated it — - * AJV must not enforce an incomplete list (#1253), but the schema shown to the LLM keeps it. - * A property missing from one side (e.g. the injected `waitSecs`) is left untouched. + * Keys (post dot-encoding) whose enum/items.enum shortenProperties() dropped — used to gate the + * per-session "see full schema" addendum. A key missing from `rawProperties` (e.g. `waitSecs`) is skipped. */ -export function stripTruncatedEnumsForValidation( +export function findDroppedEnumProperties( displayProperties: Record, rawProperties: Record, -): Record { +): string[] { const rawEncoded = encodeDotPropertyNames(rawProperties); - const validationProperties = structuredClone(displayProperties); + const dropped: string[] = []; - for (const [key, property] of Object.entries(validationProperties)) { + for (const [key, property] of Object.entries(displayProperties)) { const rawProperty = rawEncoded[key]; if (!rawProperty) continue; - if (property.enum && rawProperty.enum && wasEnumTruncated(property.enum, rawProperty.enum)) { - delete property.enum; - } if ( - property.items?.enum && - rawProperty.items?.enum && - wasEnumTruncated(property.items.enum, rawProperty.items.enum) + enumWasDropped(property.enum, rawProperty.enum) || + enumWasDropped(property.items?.enum, rawProperty.items?.enum) ) { - delete property.items.enum; + dropped.push(key); } } - return validationProperties; + return dropped; } export function transformActorInputSchemaProperties(input: Readonly): ActorInputSchemaProperties { @@ -420,7 +413,7 @@ export function transformActorInputSchemaProperties(input: Readonly, - definition.input?.properties ?? {}, - ), - }; + // shortenProperties() already drops an oversized enum — single schema, no AJV-only copy. + const droppedEnumKeys = findDroppedEnumProperties( + (inputSchemaWithWaitSecs.properties ?? {}) as Record, + definition.input?.properties ?? {}, + ); let ajvValidate; try { // Unknown properties are silently stripped by AJV's removeAdditional option. // Dynamic Actor input fields are part of the Actor's own inputSchema, so they // are declared properties and won't be stripped. - ajvValidate = fixedAjvCompile(ajv, validationSchema); + ajvValidate = fixedAjvCompile(ajv, inputSchema); } catch (e) { // SchemaTooLargeError logs as a soft fail; a genuine AJV compile error stays an error. logHttpError(e, 'Failed to compile schema', { @@ -164,6 +162,24 @@ Actor description: ${definition.description}`; continue; } + // Generic note by default; names fetch-actor-details only when this session's tools/list + // has it (see src/tools/AGENTS.md). Clones every render — callers may mutate it in place. + const buildInputSchema: ((ctx: ToolDescriptionContext) => ToolInputSchema) | undefined = + droppedEnumKeys.length > 0 + ? (ctx) => { + const rendered = structuredClone(inputSchema) as { properties: Record }; + if (ctx.hasTool(HELPER_TOOLS.ACTOR_GET_DETAILS)) { + for (const key of droppedEnumKeys) { + const property = rendered.properties[key]; + if (property) { + property.description += ` See ${HELPER_TOOLS.ACTOR_GET_DETAILS} for the Actor's full input schema.`; + } + } + } + return rendered as ToolInputSchema; + } + : undefined; + tools.push({ type: TOOL_TYPE.ACTOR, name: actorNameToToolName(definition.actorFullName), @@ -172,6 +188,7 @@ Actor description: ${definition.description}`; actorFullName: definition.actorFullName, description, inputSchema: inputSchema as ToolInputSchema, + ...(buildInputSchema ? { buildInputSchema } : {}), // Canonical RunResponse shape — same as call-actor and get-actor-run. outputSchema: actorRunOutputSchema, ajvValidate, diff --git a/src/types.ts b/src/types.ts index 876cf0e2a..73adf3996 100644 --- a/src/types.ts +++ b/src/types.ts @@ -108,6 +108,13 @@ export type ToolBase = z.infer & { * session tool set. Resolved at the tools/list boundary in `getToolPublicFieldOnly`. */ buildDescription?: (ctx: ToolDescriptionContext) => string; + /** + * Session-exact inputSchema builder (e.g. a dropped-enum property naming fetch-actor-details + * only when it's present). Unlike `buildDescription`, `inputSchema` must hold the tool-absent + * (generic) render — a bare tool name in schema text isn't otherwise session-rendered. + * Resolved at the tools/list boundary in `getToolPublicFieldOnly`. + */ + buildInputSchema?: (ctx: ToolDescriptionContext) => ToolInputSchema; }; /** diff --git a/src/utils/tools.ts b/src/utils/tools.ts index 43fdb6a6b..802c4cd7d 100644 --- a/src/utils/tools.ts +++ b/src/utils/tools.ts @@ -105,12 +105,16 @@ export function getToolPublicFieldOnly(tool: ToolBase, options: ToolPublicFieldO tool.buildDescription && presentTools ? tool.buildDescription({ hasTool: (name) => presentTools.has(name) }) : tool.description; + const inputSchema = + tool.buildInputSchema && presentTools + ? tool.buildInputSchema({ hasTool: (name) => presentTools.has(name) }) + : tool.inputSchema; return { name: tool.name, title: tool.title, description, - inputSchema: fixZodInputSchemaRequired(tool.inputSchema), + inputSchema: fixZodInputSchemaRequired(inputSchema), outputSchema: tool.outputSchema, annotations: tool.annotations, icons: tool.icons, @@ -138,6 +142,7 @@ export function cloneToolEntry(toolEntry: ToolEntry): ToolEntry { const originalAjvValidate = toolEntry.ajvValidate; const originalCall = toolEntry.type === TOOL_TYPE.INTERNAL ? toolEntry.call : undefined; const originalBuildDescription = toolEntry.buildDescription; + const originalBuildInputSchema = toolEntry.buildInputSchema; // Create a deep copy using JSON serialization (excluding functions) const cloned = JSON.parse( @@ -155,6 +160,9 @@ export function cloneToolEntry(toolEntry: ToolEntry): ToolEntry { if (originalBuildDescription) { cloned.buildDescription = originalBuildDescription; } + if (originalBuildInputSchema) { + cloned.buildInputSchema = originalBuildInputSchema; + } return cloned; } diff --git a/tests/unit/payments.skyfire.build_input_schema.test.ts b/tests/unit/payments.skyfire.build_input_schema.test.ts new file mode 100644 index 000000000..4a91dfd92 --- /dev/null +++ b/tests/unit/payments.skyfire.build_input_schema.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest'; + +import { HELPER_TOOLS } from '../../src/const.js'; +import { SKYFIRE_PAY_ID_KEY } from '../../src/payments/const.js'; +import { SkyfirePaymentProvider } from '../../src/payments/skyfire.js'; +import { getNormalActorsAsTools } from '../../src/tools/actors/actor_tools_factory.js'; +import type { ActorInfo, SchemaProperties } from '../../src/types.js'; +import { getToolPublicFieldOnly } from '../../src/utils/tools.js'; + +/** ActorInfo whose one input property has an enum too large to fit ACTOR_ENUM_MAX_LENGTH whole. */ +function createActorInfoWithOversizedEnum(): ActorInfo { + const enumValues = Array.from({ length: 250 }, (_, i) => `category-${i}-${'x'.repeat(10)}`); + return { + webServerMcpPath: null, + definition: { + id: 'test-id', + actorFullName: 'test/actor-with-big-enum', + readme: '', + description: 'Test actor with an oversized enum', + defaultRunOptions: { memoryMbytes: 1024, timeoutSecs: 300, build: 'latest' }, + input: { + type: 'object', + title: 'Test Input', + description: 'Test input schema', + properties: { + category: { + type: 'string', + title: 'Category', + description: 'Category filter', + enum: enumValues, + }, + }, + schemaVersion: 1, + }, + }, + actor: { id: 'test-actor-id', name: 'actor-with-big-enum', username: 'test' } as ActorInfo['actor'], + }; +} + +// Regression: pay-id must survive whichever inputSchema render reaches the client, not just the static one. +describe('SkyfirePaymentProvider.decorateToolSchema — composes with buildInputSchema', () => { + it('injects skyfire-pay-id into both the ungated and the fetch-actor-details-gated render', async () => { + const tools = await getNormalActorsAsTools([createActorInfoWithOversizedEnum()]); + const decorated = new SkyfirePaymentProvider().decorateToolSchema(tools[0]); + + expect(decorated.buildInputSchema).toBeDefined(); + + const withoutDetails = decorated.buildInputSchema!({ hasTool: () => false }) as { + properties: Record; + }; + const withDetails = decorated.buildInputSchema!({ + hasTool: (name) => name === HELPER_TOOLS.ACTOR_GET_DETAILS, + }) as { properties: Record }; + + expect(withoutDetails.properties[SKYFIRE_PAY_ID_KEY]).toBeDefined(); + expect(withDetails.properties[SKYFIRE_PAY_ID_KEY]).toBeDefined(); + // The addendum still applies alongside the injected payment field. + expect(withDetails.properties.category.description).toContain(HELPER_TOOLS.ACTOR_GET_DETAILS); + }); + + it('survives the full tools/list boundary (getToolPublicFieldOnly) for both session sets', async () => { + const tools = await getNormalActorsAsTools([createActorInfoWithOversizedEnum()]); + const decorated = new SkyfirePaymentProvider().decorateToolSchema(tools[0]); + + const listedWithout = getToolPublicFieldOnly(decorated, { presentTools: new Set([decorated.name]) }); + const listedWith = getToolPublicFieldOnly(decorated, { + presentTools: new Set([decorated.name, HELPER_TOOLS.ACTOR_GET_DETAILS]), + }); + + const propsWithout = (listedWithout.inputSchema as { properties: Record }).properties; + const propsWith = (listedWith.inputSchema as { properties: Record }).properties; + + expect(propsWithout[SKYFIRE_PAY_ID_KEY]).toBeDefined(); + expect(propsWith[SKYFIRE_PAY_ID_KEY]).toBeDefined(); + }); + + it('does not mutate the shared base inputSchema across repeated buildInputSchema calls', async () => { + const tools = await getNormalActorsAsTools([createActorInfoWithOversizedEnum()]); + const decorated = new SkyfirePaymentProvider().decorateToolSchema(tools[0]); + + decorated.buildInputSchema!({ hasTool: () => false }); + decorated.buildInputSchema!({ hasTool: () => false }); + + const { properties } = decorated.inputSchema as { properties: Record }; + // The dropped-enum note must appear exactly once, not accumulate across renders. + const occurrences = properties.category.description.split('Accepts more values than shown here').length - 1; + expect(occurrences).toBe(1); + }); +}); diff --git a/tests/unit/tools.actor_tools_factory.enum_gating.test.ts b/tests/unit/tools.actor_tools_factory.enum_gating.test.ts new file mode 100644 index 000000000..5d7aa61f3 --- /dev/null +++ b/tests/unit/tools.actor_tools_factory.enum_gating.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it } from 'vitest'; + +import { HELPER_TOOLS } from '../../src/const.js'; +import { getNormalActorsAsTools } from '../../src/tools/actors/actor_tools_factory.js'; +import type { ActorInfo, SchemaProperties, ToolBase } from '../../src/types.js'; +import { getToolPublicFieldOnly } from '../../src/utils/tools.js'; + +/** ActorInfo whose one input property has an enum too large to fit ACTOR_ENUM_MAX_LENGTH whole. */ +function createActorInfoWithOversizedEnum(): ActorInfo { + const enumValues = Array.from({ length: 250 }, (_, i) => `category-${i}-${'x'.repeat(10)}`); + return { + webServerMcpPath: null, + definition: { + id: 'test-id', + actorFullName: 'test/actor-with-big-enum', + readme: '', + description: 'Test actor with an oversized enum', + defaultRunOptions: { memoryMbytes: 1024, timeoutSecs: 300, build: 'latest' }, + input: { + type: 'object', + title: 'Test Input', + description: 'Test input schema', + properties: { + category: { + type: 'string', + title: 'Category', + description: 'Category filter', + enum: enumValues, + }, + }, + schemaVersion: 1, + }, + }, + actor: { id: 'test-actor-id', name: 'actor-with-big-enum', username: 'test' } as ActorInfo['actor'], + }; +} + +/** ActorInfo whose enum comfortably fits — no property should be flagged as dropped. */ +function createActorInfoWithSmallEnum(): ActorInfo { + return { + webServerMcpPath: null, + definition: { + id: 'test-id', + actorFullName: 'test/actor-small-enum', + readme: '', + description: 'Test actor with a small enum', + defaultRunOptions: { memoryMbytes: 1024, timeoutSecs: 300, build: 'latest' }, + input: { + type: 'object', + title: 'Test Input', + description: 'Test input schema', + properties: { + category: { type: 'string', title: 'Category', description: 'Category filter', enum: ['a', 'b'] }, + }, + schemaVersion: 1, + }, + }, + actor: { id: 'test-actor-id', name: 'actor-small-enum', username: 'test' } as ActorInfo['actor'], + }; +} + +describe('getNormalActorsAsTools — dropped-enum buildInputSchema gating', () => { + it('keeps the base inputSchema generic (no tool name) regardless of session', async () => { + const tools = await getNormalActorsAsTools([createActorInfoWithOversizedEnum()]); + const tool = tools[0]; + const { properties } = tool.inputSchema as { properties: Record }; + + expect(properties.category.enum).toBeUndefined(); + expect(properties.category.description).toContain('Accepts more values than shown here'); + expect(properties.category.description).not.toContain(HELPER_TOOLS.ACTOR_GET_DETAILS); + }); + + it('buildInputSchema renders the same generic text when fetch-actor-details is absent from the session', async () => { + const tools = await getNormalActorsAsTools([createActorInfoWithOversizedEnum()]); + const tool = tools[0]; + + expect(tool.buildInputSchema).toBeDefined(); + const rendered = tool.buildInputSchema!({ hasTool: () => false }) as { + properties: Record; + }; + expect(rendered.properties.category.description).not.toContain(HELPER_TOOLS.ACTOR_GET_DETAILS); + expect(rendered.properties.category.description).toContain('Accepts more values than shown here'); + }); + + it('buildInputSchema names fetch-actor-details in the addendum when it is present in the session', async () => { + const tools = await getNormalActorsAsTools([createActorInfoWithOversizedEnum()]); + const tool = tools[0]; + + const rendered = tool.buildInputSchema!({ + hasTool: (name) => name === HELPER_TOOLS.ACTOR_GET_DETAILS, + }) as { properties: Record }; + + expect(rendered.properties.category.description).toContain(HELPER_TOOLS.ACTOR_GET_DETAILS); + expect(rendered.properties.category.description).toContain('full input schema'); + }); + + it('leaves other properties (e.g. the injected waitSecs) untouched by the addendum', async () => { + const tools = await getNormalActorsAsTools([createActorInfoWithOversizedEnum()]); + const tool = tools[0]; + + const rendered = tool.buildInputSchema!({ + hasTool: (name) => name === HELPER_TOOLS.ACTOR_GET_DETAILS, + }) as { properties: Record }; + + expect(rendered.properties.waitSecs.description).not.toContain(HELPER_TOOLS.ACTOR_GET_DETAILS); + }); + + it('AJV enforces type on the dropped-enum property but not membership in the original list', async () => { + const tools = await getNormalActorsAsTools([createActorInfoWithOversizedEnum()]); + const tool = tools[0]; + + expect(tool.ajvValidate({ category: 'not-in-the-original-enum-list' })).toBe(true); + // AJV coerces scalars (coerceTypes: 'array'), so use an object — never coercible to string. + expect(tool.ajvValidate({ category: { nested: true } })).toBe(false); + }); + + it('does not attach buildInputSchema when no property had its enum dropped', async () => { + const tools = await getNormalActorsAsTools([createActorInfoWithSmallEnum()]); + const tool = tools[0]; + + expect(tool.buildInputSchema).toBeUndefined(); + }); + + it('tools/list boundary (getToolPublicFieldOnly) renders the addendum only when fetch-actor-details shares the session', async () => { + const tools = await getNormalActorsAsTools([createActorInfoWithOversizedEnum()]); + const tool = tools[0] as ToolBase; + + const withoutDetails = getToolPublicFieldOnly(tool, { presentTools: new Set([tool.name]) }); + const withDetails = getToolPublicFieldOnly(tool, { + presentTools: new Set([tool.name, HELPER_TOOLS.ACTOR_GET_DETAILS]), + }); + + const propsWithout = (withoutDetails.inputSchema as { properties: Record }) + .properties; + const propsWith = (withDetails.inputSchema as { properties: Record }).properties; + + expect(propsWithout.category.description).not.toContain(HELPER_TOOLS.ACTOR_GET_DETAILS); + expect(propsWith.category.description).toContain(HELPER_TOOLS.ACTOR_GET_DETAILS); + }); +}); diff --git a/tests/unit/tools.clone.test.ts b/tests/unit/tools.clone.test.ts index 61c167fc6..34c2142f1 100644 --- a/tests/unit/tools.clone.test.ts +++ b/tests/unit/tools.clone.test.ts @@ -106,4 +106,20 @@ describe('cloneToolEntry', () => { // Original should be unaffected expect((original.inputSchema.properties as Record).newProp).toBeUndefined(); }); + + it('should preserve buildInputSchema function reference when present', () => { + const buildInputSchema = vi.fn(() => ({ type: 'object' as const, properties: {} })); + const original = makeActorTool({ buildInputSchema }); + const cloned = cloneToolEntry(original); + + expect(cloned.buildInputSchema).toBe(original.buildInputSchema); + expect(typeof cloned.buildInputSchema).toBe('function'); + }); + + it('should leave buildInputSchema undefined when absent from the original', () => { + const original = makeActorTool(); + const cloned = cloneToolEntry(original); + + expect(cloned.buildInputSchema).toBeUndefined(); + }); }); diff --git a/tests/unit/tools.utils.test.ts b/tests/unit/tools.utils.test.ts index 4eedf263d..18520ae61 100644 --- a/tests/unit/tools.utils.test.ts +++ b/tests/unit/tools.utils.test.ts @@ -8,13 +8,13 @@ import { decodeDotPropertyNames, encodeDotPropertyNames, filterAndShortenEnum, + findDroppedEnumProperties, fixedAjvCompile, inferArrayItemsTypeIfMissing, inferArrayItemType, MAX_UNTRUSTED_SCHEMA_BYTES, markInputPropertiesAsRequired, shortenProperties, - stripTruncatedEnumsForValidation, transformActorInputSchemaProperties, } from '../../src/tools/actor_input_schema.js'; import { isActorBlockedUnderPaymentProvider } from '../../src/tools/actor_tool_naming.js'; @@ -377,7 +377,23 @@ describe('shortenProperties', () => { expect(result.prop1.description).toBe(description); }); - it('should shorten enum values if they exceed the limit', () => { + it('keeps the enum in full, with no note, when every value fits under the cap', () => { + const properties: Record = { + prop1: { + type: 'string', + title: 'Property 1', + description: 'Property with enum', + enum: ['a', 'b', 'c'], + }, + }; + + const result = shortenProperties(properties); + + expect(result.prop1.enum).toEqual(['a', 'b', 'c']); + expect(result.prop1.description).toBe('Property with enum'); + }); + + it('drops the enum entirely (not partially) when the values don\u2019t fit the cap as a whole, noting a few examples', () => { // Create an enum with many values to exceed the character limit const value = 'enum-value-'; const enumValues = Array.from( @@ -395,22 +411,33 @@ describe('shortenProperties', () => { const result = shortenProperties(properties); - // Check that enum was shortened - expect(result.prop1.enum).toBeDefined(); - if (result.prop1.enum) { - expect(result.prop1.enum.length).toBeLessThan(enumValues.length); - const totalEnumLen = result.prop1.enum.reduce((sum, v) => sum + v.length, 0); - expect(totalEnumLen).toBeLessThanOrEqual(ACTOR_ENUM_MAX_LENGTH); + expect(result.prop1.enum).toBeUndefined(); + expect(result.prop1.description).toContain('Accepts more values than shown here'); + expect(result.prop1.description).toContain(enumValues[0]); + }); - // Calculate total character length of enum values - const totalLength = result.prop1.enum.reduce((sum, val) => sum + val.length, 0); - expect(totalLength).toBeLessThanOrEqual(ACTOR_ENUM_MAX_LENGTH); - } else { - expect(result.prop1.enum).toBeUndefined(); - } + it('keeps items.enum in full, with no note, when every value fits under the cap', () => { + const properties: Record = { + prop1: { + type: 'array', + title: 'Property 1', + description: 'Property with items.enum', + items: { + type: 'string', + title: 'Item', + description: 'Item description', + enum: ['a', 'b', 'c'], + }, + }, + }; + + const result = shortenProperties(properties); + + expect(result.prop1.items?.enum).toEqual(['a', 'b', 'c']); + expect(result.prop1.description).toBe('Property with items.enum'); }); - it('should shorten items.enum values if they exceed the limit', () => { + it('drops items.enum entirely (not partially) when the values don\u2019t fit the cap as a whole, noting a few examples', () => { // Create an enum with many values to exceed the character limit const value = 'enum-value-'; const enumValues = Array.from( @@ -433,18 +460,9 @@ describe('shortenProperties', () => { const result = shortenProperties(properties); - // Check that items.enum was shortened - expect(result.prop1.items?.enum).toBeDefined(); - if (result.prop1.items?.enum) { - expect(result.prop1.items.enum.length).toBeLessThan(enumValues.length); - const totalLength = result.prop1.items.enum.reduce((sum, val) => sum + val.length, 0); - expect(totalLength).toBeLessThanOrEqual(ACTOR_ENUM_MAX_LENGTH); - - // Calculate total character length of enum values - expect(totalLength).toBeLessThanOrEqual(ACTOR_ENUM_MAX_LENGTH); - } else { - expect(result.prop1.items?.enum).toBeUndefined(); - } + expect(result.prop1.items?.enum).toBeUndefined(); + expect(result.prop1.description).toContain('Accepts more values than shown here'); + expect(result.prop1.description).toContain(enumValues[0]); }); it('should handle properties without enum or items.enum', () => { @@ -500,8 +518,8 @@ describe('shortenProperties', () => { }); }); -describe('stripTruncatedEnumsForValidation', () => { - it('keeps enum unchanged when it fits comfortably under the cap on both sides', () => { +describe('findDroppedEnumProperties', () => { + it('does not flag a property whose enum fits comfortably under the cap', () => { const display: Record = { prop1: { type: 'string', title: 'Prop 1', description: 'desc', enum: ['a', 'b'] }, }; @@ -509,12 +527,10 @@ describe('stripTruncatedEnumsForValidation', () => { prop1: { type: 'string', title: 'Prop 1', description: 'desc', enum: ['a', 'b'] }, }; - const result = stripTruncatedEnumsForValidation(display, raw); - - expect(result.prop1.enum).toEqual(['a', 'b']); + expect(findDroppedEnumProperties(display, raw)).toEqual([]); }); - it('strips enum entirely when it was truncated by shortenProperties', () => { + it('flags a property whose enum shortenProperties() dropped for not fitting the cap', () => { const rawEnum = Array.from({ length: 300 }, (_, i) => `value-${i}`); const display: Record = { prop1: { type: 'string', title: 'Prop 1', description: 'desc', enum: filterAndShortenEnum(rawEnum) }, @@ -523,12 +539,10 @@ describe('stripTruncatedEnumsForValidation', () => { prop1: { type: 'string', title: 'Prop 1', description: 'desc', enum: rawEnum }, }; - const result = stripTruncatedEnumsForValidation(display, raw); - - expect(result.prop1.enum).toBeUndefined(); + expect(findDroppedEnumProperties(display, raw)).toEqual(['prop1']); }); - it('strips items.enum entirely when it was truncated (the #1253 shape), leaving other items fields untouched', () => { + it('flags a property whose items.enum shortenProperties() dropped (the #1253 shape)', () => { const rawEnum = Array.from({ length: 300 }, (_, i) => `value-${i}`); const display: Record = { prop1: { @@ -547,14 +561,10 @@ describe('stripTruncatedEnumsForValidation', () => { }, }; - const result = stripTruncatedEnumsForValidation(display, raw); - - expect(result.prop1.items?.enum).toBeUndefined(); - expect(result.prop1.items?.type).toBe('string'); - expect(result.prop1.items?.title).toBe('Item'); + expect(findDroppedEnumProperties(display, raw)).toEqual(['prop1']); }); - it('does not treat empty-string removal as truncation when non-empty count never exceeds the cap', () => { + it('does not flag blank-entry removal as a drop when the non-empty count never exceeds the cap', () => { const rawEnum = ['a', 'b', '', '', 'c']; const display: Record = { prop1: { type: 'string', title: 'Prop 1', description: 'desc', enum: filterAndShortenEnum(rawEnum) }, @@ -563,19 +573,15 @@ describe('stripTruncatedEnumsForValidation', () => { prop1: { type: 'string', title: 'Prop 1', description: 'desc', enum: rawEnum }, }; - const result = stripTruncatedEnumsForValidation(display, raw); - - expect(result.prop1.enum).toEqual(['a', 'b', 'c']); + expect(findDroppedEnumProperties(display, raw)).toEqual([]); }); - it('leaves a property with no counterpart in rawProperties untouched (e.g. the injected waitSecs)', () => { + it('skips a property with no counterpart in rawProperties (e.g. the injected waitSecs)', () => { const display: Record = { waitSecs: { type: 'integer', title: 'Wait seconds', description: 'desc' }, }; - const result = stripTruncatedEnumsForValidation(display, {}); - - expect(result).toEqual(display); + expect(findDroppedEnumProperties(display, {})).toEqual([]); }); it('is a safe no-op when rawProperties is empty', () => { @@ -583,9 +589,7 @@ describe('stripTruncatedEnumsForValidation', () => { prop1: { type: 'string', title: 'Prop 1', description: 'desc', enum: ['a', 'b'] }, }; - const result = stripTruncatedEnumsForValidation(display, {}); - - expect(result).toEqual(display); + expect(findDroppedEnumProperties(display, {})).toEqual([]); }); }); @@ -772,10 +776,10 @@ describe('transformActorInputSchemaProperties', () => { expect(result.simpleString.type).toBe('string'); expect(result.simpleString.description).toContain('**REQUIRED**'); - // Enum String + // Enum String — kept in full (fits the cap); no "Possible values" duplication with `enum`. expect(result.enumString).toBeDefined(); - expect(result.enumString.enum).toBeDefined(); - expect(result.enumString.description).toContain('Possible values:'); + expect(result.enumString.enum).toEqual(['A', 'B', 'C']); + expect(result.enumString.description).not.toContain('Possible values:'); expect(result.enumString.description).toContain('Example values:'); expect(result.enumString.description).toContain('**REQUIRED**'); @@ -851,8 +855,8 @@ describe('transformActorInputSchemaProperties', () => { // If enum is too long, it may be set to undefined expect(result.enumProp.enum).toBeUndefined(); } - // 5. addEnumsToDescriptionsWithExamples: enum values in description - expect(result.enumProp.description).toMatch(/Possible values:/); + // 5. Enum fits the cap (30 short values) — kept in full, no "Possible values" duplication. + expect(result.enumProp.description).not.toMatch(/Possible values:/); // 6. encodeDotPropertyNames: foo.bar becomes foo-dot-bar expect(result['foo-dot-bar']).toBeDefined(); expect(result['foo.bar']).toBeUndefined(); @@ -1006,18 +1010,21 @@ describe('inferArrayItemsTypeIfMissing', () => { }); describe('filterAndShortenEnum', () => { - it('shorten enum list', () => { - const enumList: string[] = []; - const wordLength = 100; - const wordCount = ACTOR_ENUM_MAX_LENGTH / wordLength + 1; // exceed the limit + it('keeps the full list (blanks removed) when it fits the cap', () => { + const enumList = ['a', '', 'b', 'c']; + expect(filterAndShortenEnum(enumList)).toEqual(['a', 'b', 'c']); + }); - for (let i = 1; i < wordCount; i++) { - enumList.push('a'.repeat(wordLength)); - } + it('drops the list entirely \u2014 not partially \u2014 when it exceeds the cap', () => { + const wordLength = 100; + const wordCount = Math.ceil(ACTOR_ENUM_MAX_LENGTH / wordLength) + 1; // total exceeds the cap + const enumList = Array.from({ length: wordCount }, () => 'a'.repeat(wordLength)); - const shortenedList = filterAndShortenEnum(enumList); + expect(filterAndShortenEnum(enumList)).toBeUndefined(); + }); - expect(shortenedList?.length || 0).toBe(ACTOR_ENUM_MAX_LENGTH / wordLength); + it('returns undefined for an all-blank list', () => { + expect(filterAndShortenEnum(['', ''])).toBeUndefined(); }); }); @@ -1115,14 +1122,11 @@ describe('buildActorInputSchema + getToolPublicFieldOnly pipeline', () => { expect(schema.properties?.query?.prefill).toBe('web browser for RAG pipelines'); }); - // Regression: #1253 — a value cut only by display truncation must still pass AJV. - it('accepts an enum value dropped by display truncation, while the displayed schema keeps the truncated list', () => { - // 'kept-0' is short and sorts first, so it always survives filterAndShortenEnum's cap; - // 'dropped-value' is padded long enough to land well past the cap. - const keptValue = 'kept-0'; + // Regression: #1253 — an oversized enum is dropped entirely (not partially truncated), one schema for both display and AJV. + it('drops an oversized enum from the displayed schema entirely, noting examples, and AJV accepts any value it would have cut', () => { const droppedValue = 'dropped-value-cut-by-truncation'; const rawEnum = [ - keptValue, + 'kept-0', ...Array.from({ length: 300 }, (_, i) => `kept-padding-${i}-${'x'.repeat(20)}`), droppedValue, ]; @@ -1142,19 +1146,13 @@ describe('buildActorInputSchema + getToolPublicFieldOnly pipeline', () => { const { inputSchema } = buildActorInputSchema('compass/crawler-google-places', upstream, false); const displayProperties = inputSchema.properties as Record; - // Display schema still shows the truncated enum, unaffected by the fix. - const displayEnum = displayProperties.categoryFilterWords.enum; - expect(displayEnum).toBeDefined(); - expect(displayEnum!.length).toBeGreaterThan(0); - expect(displayEnum!.length).toBeLessThan(rawEnum.length); - expect(displayEnum).not.toContain(droppedValue); - - // AJV accepts the dropped value — the #1253 repro (categoryFilterWords: ['restaurant']). - const validationSchema = { - ...inputSchema, - properties: stripTruncatedEnumsForValidation(displayProperties, upstream.properties), - }; - const validate = fixedAjvCompile(ajv, validationSchema); + // No partial/incomplete list shown as if exhaustive. + expect(displayProperties.categoryFilterWords.enum).toBeUndefined(); + expect(displayProperties.categoryFilterWords.description).toContain('Accepts more values than shown here'); + expect(displayProperties.categoryFilterWords.description).toContain('kept-0'); + + // #1253 repro (categoryFilterWords: ['restaurant']) — only `type` is enforced now. + const validate = fixedAjvCompile(ajv, inputSchema); expect(validate({ categoryFilterWords: droppedValue })).toBe(true); }); }); From 9c77ea776bf8b446224f643d5f5d38e4fde05881 Mon Sep 17 00:00:00 2001 From: MQ37 Date: Fri, 4 Sep 2026 16:47:51 +0200 Subject: [PATCH 3/5] fix: Trim the dropped-enum note wording MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shorter note text (3 examples instead of 5, tighter phrasing), and the gated fetch-actor-details addendum no longer claims 'full input schema' — fetch-actor-details shares the same shortenProperties() cap, so it drops the same oversized enum and doesn't actually return the complete list either (confirmed live against a real oversized-enum Actor). Rewords to 'more on this Actor' — still a genuinely useful pointer (README, other properties, defaults), without overclaiming enum completeness. Written with AI assistance (Claude). --- src/tools/actor_input_schema.ts | 4 ++-- src/tools/actors/actor_tools_factory.ts | 2 +- tests/unit/payments.skyfire.build_input_schema.test.ts | 2 +- tests/unit/tools.actor_tools_factory.enum_gating.test.ts | 6 +++--- tests/unit/tools.utils.test.ts | 6 +++--- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/tools/actor_input_schema.ts b/src/tools/actor_input_schema.ts index bae9d6862..31d63173b 100644 --- a/src/tools/actor_input_schema.ts +++ b/src/tools/actor_input_schema.ts @@ -280,7 +280,7 @@ export function addExampleValuesToDescriptions( return properties; } -const ENUM_DROPPED_NOTE_EXAMPLE_COUNT = 5; +const ENUM_DROPPED_NOTE_EXAMPLE_COUNT = 3; const ENUM_DROPPED_NOTE_EXAMPLE_MAX_LENGTH = 60; /** Note for a dropped enum — the field still accepts these values, just too many to list. */ @@ -293,7 +293,7 @@ function buildEnumDroppedNote(rawValues: string[]): string { ? `${value.slice(0, ENUM_DROPPED_NOTE_EXAMPLE_MAX_LENGTH)}...` : value, ); - return `\nAccepts more values than shown here (e.g. ${examples.join(', ')}, ...) — full list omitted, too long to include.`; + return `\nMore values accepted than shown (e.g. ${examples.join(', ')}, ...) — list too long to include.`; } /** diff --git a/src/tools/actors/actor_tools_factory.ts b/src/tools/actors/actor_tools_factory.ts index 79d648f22..4cec8f17d 100644 --- a/src/tools/actors/actor_tools_factory.ts +++ b/src/tools/actors/actor_tools_factory.ts @@ -172,7 +172,7 @@ Actor description: ${definition.description}`; for (const key of droppedEnumKeys) { const property = rendered.properties[key]; if (property) { - property.description += ` See ${HELPER_TOOLS.ACTOR_GET_DETAILS} for the Actor's full input schema.`; + property.description += ` See ${HELPER_TOOLS.ACTOR_GET_DETAILS} for more on this Actor.`; } } } diff --git a/tests/unit/payments.skyfire.build_input_schema.test.ts b/tests/unit/payments.skyfire.build_input_schema.test.ts index 4a91dfd92..e730b0756 100644 --- a/tests/unit/payments.skyfire.build_input_schema.test.ts +++ b/tests/unit/payments.skyfire.build_input_schema.test.ts @@ -83,7 +83,7 @@ describe('SkyfirePaymentProvider.decorateToolSchema — composes with buildInput const { properties } = decorated.inputSchema as { properties: Record }; // The dropped-enum note must appear exactly once, not accumulate across renders. - const occurrences = properties.category.description.split('Accepts more values than shown here').length - 1; + const occurrences = properties.category.description.split('More values accepted than shown').length - 1; expect(occurrences).toBe(1); }); }); diff --git a/tests/unit/tools.actor_tools_factory.enum_gating.test.ts b/tests/unit/tools.actor_tools_factory.enum_gating.test.ts index 5d7aa61f3..3c5e1efe8 100644 --- a/tests/unit/tools.actor_tools_factory.enum_gating.test.ts +++ b/tests/unit/tools.actor_tools_factory.enum_gating.test.ts @@ -66,7 +66,7 @@ describe('getNormalActorsAsTools — dropped-enum buildInputSchema gating', () = const { properties } = tool.inputSchema as { properties: Record }; expect(properties.category.enum).toBeUndefined(); - expect(properties.category.description).toContain('Accepts more values than shown here'); + expect(properties.category.description).toContain('More values accepted than shown'); expect(properties.category.description).not.toContain(HELPER_TOOLS.ACTOR_GET_DETAILS); }); @@ -79,7 +79,7 @@ describe('getNormalActorsAsTools — dropped-enum buildInputSchema gating', () = properties: Record; }; expect(rendered.properties.category.description).not.toContain(HELPER_TOOLS.ACTOR_GET_DETAILS); - expect(rendered.properties.category.description).toContain('Accepts more values than shown here'); + expect(rendered.properties.category.description).toContain('More values accepted than shown'); }); it('buildInputSchema names fetch-actor-details in the addendum when it is present in the session', async () => { @@ -91,7 +91,7 @@ describe('getNormalActorsAsTools — dropped-enum buildInputSchema gating', () = }) as { properties: Record }; expect(rendered.properties.category.description).toContain(HELPER_TOOLS.ACTOR_GET_DETAILS); - expect(rendered.properties.category.description).toContain('full input schema'); + expect(rendered.properties.category.description).toContain('more on this Actor'); }); it('leaves other properties (e.g. the injected waitSecs) untouched by the addendum', async () => { diff --git a/tests/unit/tools.utils.test.ts b/tests/unit/tools.utils.test.ts index 18520ae61..a7d8f6604 100644 --- a/tests/unit/tools.utils.test.ts +++ b/tests/unit/tools.utils.test.ts @@ -412,7 +412,7 @@ describe('shortenProperties', () => { const result = shortenProperties(properties); expect(result.prop1.enum).toBeUndefined(); - expect(result.prop1.description).toContain('Accepts more values than shown here'); + expect(result.prop1.description).toContain('More values accepted than shown'); expect(result.prop1.description).toContain(enumValues[0]); }); @@ -461,7 +461,7 @@ describe('shortenProperties', () => { const result = shortenProperties(properties); expect(result.prop1.items?.enum).toBeUndefined(); - expect(result.prop1.description).toContain('Accepts more values than shown here'); + expect(result.prop1.description).toContain('More values accepted than shown'); expect(result.prop1.description).toContain(enumValues[0]); }); @@ -1148,7 +1148,7 @@ describe('buildActorInputSchema + getToolPublicFieldOnly pipeline', () => { // No partial/incomplete list shown as if exhaustive. expect(displayProperties.categoryFilterWords.enum).toBeUndefined(); - expect(displayProperties.categoryFilterWords.description).toContain('Accepts more values than shown here'); + expect(displayProperties.categoryFilterWords.description).toContain('More values accepted than shown'); expect(displayProperties.categoryFilterWords.description).toContain('kept-0'); // #1253 repro (categoryFilterWords: ['restaurant']) — only `type` is enforced now. From 144271b4f5364c213c14c83cb7140b81c0cb0176 Mon Sep 17 00:00:00 2001 From: MQ37 Date: Fri, 4 Sep 2026 17:08:47 +0200 Subject: [PATCH 4/5] docs: Fix stale comment referencing removed "full schema" wording findDroppedEnumProperties's doc still called it the "see full schema" addendum after the addendum was reworded (9c77ea77) precisely because fetch-actor-details doesn't return the full schema/enum either. Written with AI assistance (Claude). --- src/tools/actor_input_schema.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tools/actor_input_schema.ts b/src/tools/actor_input_schema.ts index 31d63173b..6e9f7fdd2 100644 --- a/src/tools/actor_input_schema.ts +++ b/src/tools/actor_input_schema.ts @@ -381,7 +381,7 @@ function enumWasDropped(displayEnum: string[] | undefined, rawEnum: string[] | u /** * Keys (post dot-encoding) whose enum/items.enum shortenProperties() dropped — used to gate the - * per-session "see full schema" addendum. A key missing from `rawProperties` (e.g. `waitSecs`) is skipped. + * per-session fetch-actor-details addendum. A key missing from `rawProperties` (e.g. `waitSecs`) is skipped. */ export function findDroppedEnumProperties( displayProperties: Record, From 42e9ad279b6d92d0e2bca15d99132eaea1456559 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Spilka?= Date: Wed, 9 Sep 2026 11:56:45 +0200 Subject: [PATCH 5/5] fix: Simplify enum and description truncation (#1342) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What changed This PR is stacked on #1258 and should merge after it. Actor input schemas now use one shared size rule: - Blank enum values are removed. - Enums whose remaining values total at most 2,000 characters stay in the schema. Their descriptions retain the `Possible values` fallback for clients that do not render JSON Schema enums. - Larger enums are removed from `enum` or `items.enum`. The description says that the complete list is too long and includes up to ten complete examples of at most 60 characters each. - An enum containing only blank values is removed without adding a misleading size warning. - Descriptions longer than 500 characters end at the last complete sentence, or the last complete word when no sentence fits, followed by `[Description truncated]`. The shared `shortenProperties()` path applies this behavior to direct Actor tools, `call-actor`, and `fetch-actor-details`. ## Examples For a large category enum, the schema omits the enum and appends: > The complete list of accepted values is too long to include. Examples: abbey, accountant, accounting, acupuncturist, aeroclub, agriculture, airline, airport, airstrip, allergist. Long example values are skipped instead of being shortened into strings that are not valid enum values. For the Google Maps category description, truncation now stops after the last complete sentence: > Using categories might filter out places that you’d like to scrape. > > [Description truncated] > > The complete list of accepted values is too long to include. Examples: abbey, accountant, accounting, acupuncturist, aeroclub, agriculture, airline, airport, airstrip, allergist. This avoids the previous incomplete ending: `To avoid…`. ## Why #1258 added an optional `buildInputSchema` callback so tool listings could point users to `fetch-actor-details` when an enum was too large. That tool uses the same shortened schema, so it cannot provide the missing enum. Removing the callback also removes the related tool type, cloning, Actor factory, and Skyfire plumbing. Deleting an oversized enum matches the requested behavior directly. It also avoids keeping an in-memory `enum` property with an `undefined` value. Complete examples remain useful without presenting shortened strings as accepted inputs. ## Verification - `pnpm run format` - `pnpm run type-check` - `pnpm run lint` — 0 warnings and 0 errors - `pnpm run test:unit` — 1,565 passed, 1 skipped - `pnpm run check:agents` - `git diff --check` Written with AI assistance (Codex and an additional coding agent). --------- Co-authored-by: MQ37 --- README.md | 4 +- src/const.ts | 2 +- src/payments/skyfire.ts | 33 +-- src/tools/AGENTS.md | 10 +- src/tools/actor_input_schema.ts | 98 +++---- src/tools/actors/actor_tools_factory.ts | 29 +-- src/types.ts | 7 - src/utils/tools.ts | 10 +- ...ayments.skyfire.build_input_schema.test.ts | 89 ------- ...ls.actor_tools_factory.enum_gating.test.ts | 140 ---------- tests/unit/tools.clone.test.ts | 16 -- tests/unit/tools.utils.test.ts | 241 +++++++++--------- 12 files changed, 177 insertions(+), 502 deletions(-) delete mode 100644 tests/unit/payments.skyfire.build_input_schema.test.ts delete mode 100644 tests/unit/tools.actor_tools_factory.enum_gating.test.ts diff --git a/README.md b/README.md index c9ce41304..5c554cbc8 100644 --- a/README.md +++ b/README.md @@ -541,8 +541,8 @@ For step-by-step troubleshooting, see the [Claude Desktop integration guide](htt ## 💡 Limitations The Actor input schema is processed to be compatible with most MCP clients while adhering to [JSON Schema](https://json-schema.org/) standards. The processing includes: -- **Descriptions** are truncated to 500 characters (as defined in `MAX_DESCRIPTION_LENGTH`). -- **Enum fields** are truncated to a maximum combined length of 2000 characters for all elements (as defined in `ACTOR_ENUM_MAX_LENGTH`). +- **Descriptions** longer than 500 characters (as defined in `ACTOR_MAX_DESCRIPTION_LENGTH`) are cut at the last complete sentence in that window, or the last complete word if there is no sentence break, and marked `[Description truncated]`. +- **Enum fields** that fit within 2000 combined characters (as defined in `ACTOR_ENUM_MAX_LENGTH`) are kept in full; larger enums are omitted, with a short note and a few example values in the description instead. - **Required fields** are explicitly marked with a `REQUIRED` prefix in their descriptions for compatibility with frameworks that may not handle the JSON schema properly. - **Nested properties** are built for special cases like proxy configuration and request list sources to ensure the correct input structure. - **Array item types** are inferred when not explicitly defined in the schema, using a priority order: explicit type in items > prefill type > default value type > editor type. diff --git a/src/const.ts b/src/const.ts index bf02b2763..4a5259de8 100644 --- a/src/const.ts +++ b/src/const.ts @@ -1,6 +1,6 @@ // Actor input const export const ACTOR_README_MAX_LENGTH = 5_000; -// Max total chars for an enum/items.enum to show in full; over this it's dropped entirely (#1253). +// Max total chars for an enum/items.enum to show in full; over this it's dropped entirely. export const ACTOR_ENUM_MAX_LENGTH = 2000; export const ACTOR_MAX_DESCRIPTION_LENGTH = 500; diff --git a/src/payments/skyfire.ts b/src/payments/skyfire.ts index 9c851efbd..0cf3e2046 100644 --- a/src/payments/skyfire.ts +++ b/src/payments/skyfire.ts @@ -1,4 +1,4 @@ -import type { ToolEntry, ToolInputSchema } from '../types.js'; +import type { ToolEntry } from '../types.js'; import { redactSkyfirePayId } from '../utils/logging.js'; import { appendToolDescription, cloneToolEntry } from '../utils/tools.js'; import { @@ -10,18 +10,6 @@ import { } from './const.js'; import type { PaymentHeaders, PaymentProvider } from './types.js'; -/** Adds `skyfire-pay-id` to a schema's properties in place, if not already present. */ -function injectPayIdProperty(inputSchema: ToolInputSchema): void { - if (!inputSchema || !('properties' in inputSchema)) return; - const props = inputSchema.properties as Record; - if (!props[SKYFIRE_PAY_ID_KEY]) { - props[SKYFIRE_PAY_ID_KEY] = { - type: 'string', - description: SKYFIRE_PAY_ID_PROPERTY_DESCRIPTION, - }; - } -} - /** * Skyfire payment provider. * @@ -43,16 +31,15 @@ export class SkyfirePaymentProvider implements PaymentProvider { appendToolDescription(cloned, SKYFIRE_TOOL_INSTRUCTIONS); - injectPayIdProperty(cloned.inputSchema); - // A per-session inputSchema render bypasses the static field above — wrap it too, or - // pay-id vanishes whenever it's used instead. - if (cloned.buildInputSchema) { - const renderBase = cloned.buildInputSchema; - cloned.buildInputSchema = (ctx) => { - const schema = renderBase(ctx); - injectPayIdProperty(schema); - return schema; - }; + // Add skyfire-pay-id property to inputSchema (idempotent) + if (cloned.inputSchema && 'properties' in cloned.inputSchema) { + const props = cloned.inputSchema.properties as Record; + if (!props[SKYFIRE_PAY_ID_KEY]) { + props[SKYFIRE_PAY_ID_KEY] = { + type: 'string', + description: SKYFIRE_PAY_ID_PROPERTY_DESCRIPTION, + }; + } } return Object.freeze(cloned); diff --git a/src/tools/AGENTS.md b/src/tools/AGENTS.md index 776a1f24f..7eee28f03 100644 --- a/src/tools/AGENTS.md +++ b/src/tools/AGENTS.md @@ -55,14 +55,8 @@ by `tests/unit/tools.mode_contract.test.ts`, which scans `description` only. Input-schema field text (`.describe()` on a Zod field) reaches `tools/list` verbatim — nothing renders it per session — so never name a tool there; put the guidance in `buildDescription` behind -`hasTool`. Two exceptions: `actors/actor_tools_factory.ts`'s `waitSecs` always auto-injects the -`get-actor-run` it names; and a dynamically-built Actor `inputSchema` (not a Zod field) can define -`buildInputSchema(ctx)` on the entry — same `ToolDescriptionContext`/`hasTool` contract as -`buildDescription`, rendered at the same `getToolPublicFieldOnly` boundary — see -`actor_tools_factory.ts`'s dropped-enum addendum, gated on `fetch-actor-details`. A payment -decorator that clones a tool (`payments/skyfire.ts`) must wrap an existing `buildInputSchema` -rather than only mutating the static `inputSchema`, or an injected field (`skyfire-pay-id`) -disappears whenever the gated render is used instead. +`hasTool`. `actors/actor_tools_factory.ts`'s `waitSecs` is the one exception: an Actor tool always +auto-injects the `get-actor-run` it names. Result text (`summary` / `nextStep` in `content[1]`) has no `hasTool`, but it does have a per-session gate: `InternalToolArgs.loadedToolNames` (see `suggestTool` in `storage/storage_helpers.ts`). Name a diff --git a/src/tools/actor_input_schema.ts b/src/tools/actor_input_schema.ts index 6e9f7fdd2..1b826ff66 100644 --- a/src/tools/actor_input_schema.ts +++ b/src/tools/actor_input_schema.ts @@ -266,7 +266,11 @@ export function inferArrayItemType(property: SchemaProperties): string | null { } } -/** Adds prefill/default values to descriptions as examples, for clients that ignore JSON Schema `examples`. */ +/** + * Adds prefill/default values to descriptions as examples, for clients that ignore JSON Schema + * `examples`. Never duplicates `enum` here — a kept enum already carries its values in the + * schema; only `shortenProperties()`'s dropped-enum note needs to spell them out in text. + */ export function addExampleValuesToDescriptions( properties: Record, ): Record { @@ -280,56 +284,70 @@ export function addExampleValuesToDescriptions( return properties; } -const ENUM_DROPPED_NOTE_EXAMPLE_COUNT = 3; +const ENUM_DROPPED_NOTE_EXAMPLE_COUNT = 10; const ENUM_DROPPED_NOTE_EXAMPLE_MAX_LENGTH = 60; -/** Note for a dropped enum — the field still accepts these values, just too many to list. */ +/** Note for a dropped enum, with complete examples that remain valid inputs. */ function buildEnumDroppedNote(rawValues: string[]): string { const examples = rawValues - .filter((value) => value !== '') - .slice(0, ENUM_DROPPED_NOTE_EXAMPLE_COUNT) - .map((value) => - value.length > ENUM_DROPPED_NOTE_EXAMPLE_MAX_LENGTH - ? `${value.slice(0, ENUM_DROPPED_NOTE_EXAMPLE_MAX_LENGTH)}...` - : value, - ); - return `\nMore values accepted than shown (e.g. ${examples.join(', ')}, ...) — list too long to include.`; + .filter((value) => value !== '' && value.length <= ENUM_DROPPED_NOTE_EXAMPLE_MAX_LENGTH) + .slice(0, ENUM_DROPPED_NOTE_EXAMPLE_COUNT); + const exampleText = examples.length > 0 ? ` Examples: ${examples.join(', ')}.` : ''; + return `\n\nThe complete list of accepted values is too long to include.${exampleText}`; } /** - * Blanks removed, kept whole if it fits ACTOR_ENUM_MAX_LENGTH; otherwise dropped entirely (#1253) + * Blanks removed, kept whole if it fits ACTOR_ENUM_MAX_LENGTH; otherwise dropped entirely * — a partially-cut enum falsely implies exhaustiveness to both the LLM and AJV. */ -export function filterAndShortenEnum(enumList: string[]): string[] | undefined { +function getEnumIfFits(enumList: string[]): string[] | undefined { const nonEmpty = enumList.filter((value) => value !== ''); if (nonEmpty.length === 0) return undefined; const charCount = nonEmpty.reduce((sum, value) => sum + value.length, 0); return charCount <= ACTOR_ENUM_MAX_LENGTH ? nonEmpty : undefined; } +/** Cut at the last complete sentence in the cap, or the last complete word if there is none. */ +function shortenDescription(description: string): string { + const truncated = description.slice(0, ACTOR_MAX_DESCRIPTION_LENGTH); + const remainder = description.slice(ACTOR_MAX_DESCRIPTION_LENGTH); + const sentenceEnd = + [...truncated.matchAll(/[.!?](?=\s)/g)].at(-1)?.index ?? + (/[.!?]$/.test(truncated) && /^\s/.test(remainder) ? truncated.length - 1 : undefined); + const shortened = + sentenceEnd === undefined + ? `${/^\s/.test(remainder) ? truncated.trimEnd() : truncated.replace(/\s+\S*$/, '')}…` + : truncated.slice(0, sentenceEnd + 1); + return `${shortened}\n\n[Description truncated]`; +} + +function applyEnumLimit(holder: { enum?: string[] }, descriptionHost: SchemaProperties, rawEnum: string[]): void { + const enumValues = getEnumIfFits(rawEnum); + if (enumValues) { + holder.enum = enumValues; + return; + } + delete holder.enum; + if (rawEnum.some((value) => value !== '')) { + descriptionHost.description += buildEnumDroppedNote(rawEnum); + } +} + /** Caps description length; drops (not truncates) an oversized enum/items.enum, noting examples instead. */ export function shortenProperties(properties: { [key: string]: SchemaProperties }): { [key: string]: SchemaProperties; } { for (const property of Object.values(properties)) { if (property.description.length > ACTOR_MAX_DESCRIPTION_LENGTH) { - property.description = `${property.description.slice(0, ACTOR_MAX_DESCRIPTION_LENGTH)}...`; + property.description = shortenDescription(property.description); } if (property.enum && property.enum.length > 0) { - const rawEnum = property.enum; - property.enum = filterAndShortenEnum(rawEnum); - if (property.enum === undefined) { - property.description += buildEnumDroppedNote(rawEnum); - } + applyEnumLimit(property, property, property.enum); } if (property.items?.enum && property.items.enum.length > 0) { - const rawEnum = property.items.enum; - property.items.enum = filterAndShortenEnum(rawEnum); - if (property.items.enum === undefined) { - property.description += buildEnumDroppedNote(rawEnum); - } + applyEnumLimit(property.items, property, property.items.enum); } } @@ -373,38 +391,6 @@ export function decodeDotPropertyNames(properties: Record): Rec return decodedProperties; } -/** True if a non-empty enum existed on the raw side but shortenProperties() dropped it entirely. */ -function enumWasDropped(displayEnum: string[] | undefined, rawEnum: string[] | undefined): boolean { - if (displayEnum !== undefined) return false; - return (rawEnum ?? []).some((value) => value !== ''); -} - -/** - * Keys (post dot-encoding) whose enum/items.enum shortenProperties() dropped — used to gate the - * per-session fetch-actor-details addendum. A key missing from `rawProperties` (e.g. `waitSecs`) is skipped. - */ -export function findDroppedEnumProperties( - displayProperties: Record, - rawProperties: Record, -): string[] { - const rawEncoded = encodeDotPropertyNames(rawProperties); - const dropped: string[] = []; - - for (const [key, property] of Object.entries(displayProperties)) { - const rawProperty = rawEncoded[key]; - if (!rawProperty) continue; - - if ( - enumWasDropped(property.enum, rawProperty.enum) || - enumWasDropped(property.items?.enum, rawProperty.items?.enum) - ) { - dropped.push(key); - } - } - - return dropped; -} - export function transformActorInputSchemaProperties(input: Readonly): ActorInputSchemaProperties { // Deep clone input to avoid mutating the original object const inputClone: ActorInputSchema = structuredClone(input); diff --git a/src/tools/actors/actor_tools_factory.ts b/src/tools/actors/actor_tools_factory.ts index 4cec8f17d..8b3b669e3 100644 --- a/src/tools/actors/actor_tools_factory.ts +++ b/src/tools/actors/actor_tools_factory.ts @@ -23,8 +23,6 @@ import { type ActorStore, type ActorTool, type ApifyToken, - type SchemaProperties, - type ToolDescriptionContext, type ToolEntry, type ToolInputSchema, ACTOR_TOOL_MODE, @@ -34,7 +32,7 @@ import { getActorDefinitionCached } from '../../utils/actor.js'; import { ajv } from '../../utils/ajv.js'; import { stripQuoteWrappers } from '../../utils/generic.js'; import { logHttpError } from '../../utils/logging.js'; -import { buildActorInputSchema, findDroppedEnumProperties, fixedAjvCompile } from '../actor_input_schema.js'; +import { buildActorInputSchema, fixedAjvCompile } from '../actor_input_schema.js'; import { actorNameToToolName, isActorBlockedUnderPaymentProvider, resolveActorToolMode } from '../actor_tool_naming.js'; import { buildEnrichedDirectActorOutputSchema, actorRunOutputSchema } from '../structured_output_schemas.js'; import { CALL_ACTOR_WAIT_SECS_DEFAULT, WAIT_SECS_MAX } from './actor_run_response.js'; @@ -141,12 +139,6 @@ Actor description: ${definition.description}`; ACTOR_MAX_MEMORY_MBYTES, ); - // shortenProperties() already drops an oversized enum — single schema, no AJV-only copy. - const droppedEnumKeys = findDroppedEnumProperties( - (inputSchemaWithWaitSecs.properties ?? {}) as Record, - definition.input?.properties ?? {}, - ); - let ajvValidate; try { // Unknown properties are silently stripped by AJV's removeAdditional option. @@ -162,24 +154,6 @@ Actor description: ${definition.description}`; continue; } - // Generic note by default; names fetch-actor-details only when this session's tools/list - // has it (see src/tools/AGENTS.md). Clones every render — callers may mutate it in place. - const buildInputSchema: ((ctx: ToolDescriptionContext) => ToolInputSchema) | undefined = - droppedEnumKeys.length > 0 - ? (ctx) => { - const rendered = structuredClone(inputSchema) as { properties: Record }; - if (ctx.hasTool(HELPER_TOOLS.ACTOR_GET_DETAILS)) { - for (const key of droppedEnumKeys) { - const property = rendered.properties[key]; - if (property) { - property.description += ` See ${HELPER_TOOLS.ACTOR_GET_DETAILS} for more on this Actor.`; - } - } - } - return rendered as ToolInputSchema; - } - : undefined; - tools.push({ type: TOOL_TYPE.ACTOR, name: actorNameToToolName(definition.actorFullName), @@ -188,7 +162,6 @@ Actor description: ${definition.description}`; actorFullName: definition.actorFullName, description, inputSchema: inputSchema as ToolInputSchema, - ...(buildInputSchema ? { buildInputSchema } : {}), // Canonical RunResponse shape — same as call-actor and get-actor-run. outputSchema: actorRunOutputSchema, ajvValidate, diff --git a/src/types.ts b/src/types.ts index 73adf3996..876cf0e2a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -108,13 +108,6 @@ export type ToolBase = z.infer & { * session tool set. Resolved at the tools/list boundary in `getToolPublicFieldOnly`. */ buildDescription?: (ctx: ToolDescriptionContext) => string; - /** - * Session-exact inputSchema builder (e.g. a dropped-enum property naming fetch-actor-details - * only when it's present). Unlike `buildDescription`, `inputSchema` must hold the tool-absent - * (generic) render — a bare tool name in schema text isn't otherwise session-rendered. - * Resolved at the tools/list boundary in `getToolPublicFieldOnly`. - */ - buildInputSchema?: (ctx: ToolDescriptionContext) => ToolInputSchema; }; /** diff --git a/src/utils/tools.ts b/src/utils/tools.ts index 802c4cd7d..43fdb6a6b 100644 --- a/src/utils/tools.ts +++ b/src/utils/tools.ts @@ -105,16 +105,12 @@ export function getToolPublicFieldOnly(tool: ToolBase, options: ToolPublicFieldO tool.buildDescription && presentTools ? tool.buildDescription({ hasTool: (name) => presentTools.has(name) }) : tool.description; - const inputSchema = - tool.buildInputSchema && presentTools - ? tool.buildInputSchema({ hasTool: (name) => presentTools.has(name) }) - : tool.inputSchema; return { name: tool.name, title: tool.title, description, - inputSchema: fixZodInputSchemaRequired(inputSchema), + inputSchema: fixZodInputSchemaRequired(tool.inputSchema), outputSchema: tool.outputSchema, annotations: tool.annotations, icons: tool.icons, @@ -142,7 +138,6 @@ export function cloneToolEntry(toolEntry: ToolEntry): ToolEntry { const originalAjvValidate = toolEntry.ajvValidate; const originalCall = toolEntry.type === TOOL_TYPE.INTERNAL ? toolEntry.call : undefined; const originalBuildDescription = toolEntry.buildDescription; - const originalBuildInputSchema = toolEntry.buildInputSchema; // Create a deep copy using JSON serialization (excluding functions) const cloned = JSON.parse( @@ -160,9 +155,6 @@ export function cloneToolEntry(toolEntry: ToolEntry): ToolEntry { if (originalBuildDescription) { cloned.buildDescription = originalBuildDescription; } - if (originalBuildInputSchema) { - cloned.buildInputSchema = originalBuildInputSchema; - } return cloned; } diff --git a/tests/unit/payments.skyfire.build_input_schema.test.ts b/tests/unit/payments.skyfire.build_input_schema.test.ts deleted file mode 100644 index e730b0756..000000000 --- a/tests/unit/payments.skyfire.build_input_schema.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { HELPER_TOOLS } from '../../src/const.js'; -import { SKYFIRE_PAY_ID_KEY } from '../../src/payments/const.js'; -import { SkyfirePaymentProvider } from '../../src/payments/skyfire.js'; -import { getNormalActorsAsTools } from '../../src/tools/actors/actor_tools_factory.js'; -import type { ActorInfo, SchemaProperties } from '../../src/types.js'; -import { getToolPublicFieldOnly } from '../../src/utils/tools.js'; - -/** ActorInfo whose one input property has an enum too large to fit ACTOR_ENUM_MAX_LENGTH whole. */ -function createActorInfoWithOversizedEnum(): ActorInfo { - const enumValues = Array.from({ length: 250 }, (_, i) => `category-${i}-${'x'.repeat(10)}`); - return { - webServerMcpPath: null, - definition: { - id: 'test-id', - actorFullName: 'test/actor-with-big-enum', - readme: '', - description: 'Test actor with an oversized enum', - defaultRunOptions: { memoryMbytes: 1024, timeoutSecs: 300, build: 'latest' }, - input: { - type: 'object', - title: 'Test Input', - description: 'Test input schema', - properties: { - category: { - type: 'string', - title: 'Category', - description: 'Category filter', - enum: enumValues, - }, - }, - schemaVersion: 1, - }, - }, - actor: { id: 'test-actor-id', name: 'actor-with-big-enum', username: 'test' } as ActorInfo['actor'], - }; -} - -// Regression: pay-id must survive whichever inputSchema render reaches the client, not just the static one. -describe('SkyfirePaymentProvider.decorateToolSchema — composes with buildInputSchema', () => { - it('injects skyfire-pay-id into both the ungated and the fetch-actor-details-gated render', async () => { - const tools = await getNormalActorsAsTools([createActorInfoWithOversizedEnum()]); - const decorated = new SkyfirePaymentProvider().decorateToolSchema(tools[0]); - - expect(decorated.buildInputSchema).toBeDefined(); - - const withoutDetails = decorated.buildInputSchema!({ hasTool: () => false }) as { - properties: Record; - }; - const withDetails = decorated.buildInputSchema!({ - hasTool: (name) => name === HELPER_TOOLS.ACTOR_GET_DETAILS, - }) as { properties: Record }; - - expect(withoutDetails.properties[SKYFIRE_PAY_ID_KEY]).toBeDefined(); - expect(withDetails.properties[SKYFIRE_PAY_ID_KEY]).toBeDefined(); - // The addendum still applies alongside the injected payment field. - expect(withDetails.properties.category.description).toContain(HELPER_TOOLS.ACTOR_GET_DETAILS); - }); - - it('survives the full tools/list boundary (getToolPublicFieldOnly) for both session sets', async () => { - const tools = await getNormalActorsAsTools([createActorInfoWithOversizedEnum()]); - const decorated = new SkyfirePaymentProvider().decorateToolSchema(tools[0]); - - const listedWithout = getToolPublicFieldOnly(decorated, { presentTools: new Set([decorated.name]) }); - const listedWith = getToolPublicFieldOnly(decorated, { - presentTools: new Set([decorated.name, HELPER_TOOLS.ACTOR_GET_DETAILS]), - }); - - const propsWithout = (listedWithout.inputSchema as { properties: Record }).properties; - const propsWith = (listedWith.inputSchema as { properties: Record }).properties; - - expect(propsWithout[SKYFIRE_PAY_ID_KEY]).toBeDefined(); - expect(propsWith[SKYFIRE_PAY_ID_KEY]).toBeDefined(); - }); - - it('does not mutate the shared base inputSchema across repeated buildInputSchema calls', async () => { - const tools = await getNormalActorsAsTools([createActorInfoWithOversizedEnum()]); - const decorated = new SkyfirePaymentProvider().decorateToolSchema(tools[0]); - - decorated.buildInputSchema!({ hasTool: () => false }); - decorated.buildInputSchema!({ hasTool: () => false }); - - const { properties } = decorated.inputSchema as { properties: Record }; - // The dropped-enum note must appear exactly once, not accumulate across renders. - const occurrences = properties.category.description.split('More values accepted than shown').length - 1; - expect(occurrences).toBe(1); - }); -}); diff --git a/tests/unit/tools.actor_tools_factory.enum_gating.test.ts b/tests/unit/tools.actor_tools_factory.enum_gating.test.ts deleted file mode 100644 index 3c5e1efe8..000000000 --- a/tests/unit/tools.actor_tools_factory.enum_gating.test.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { HELPER_TOOLS } from '../../src/const.js'; -import { getNormalActorsAsTools } from '../../src/tools/actors/actor_tools_factory.js'; -import type { ActorInfo, SchemaProperties, ToolBase } from '../../src/types.js'; -import { getToolPublicFieldOnly } from '../../src/utils/tools.js'; - -/** ActorInfo whose one input property has an enum too large to fit ACTOR_ENUM_MAX_LENGTH whole. */ -function createActorInfoWithOversizedEnum(): ActorInfo { - const enumValues = Array.from({ length: 250 }, (_, i) => `category-${i}-${'x'.repeat(10)}`); - return { - webServerMcpPath: null, - definition: { - id: 'test-id', - actorFullName: 'test/actor-with-big-enum', - readme: '', - description: 'Test actor with an oversized enum', - defaultRunOptions: { memoryMbytes: 1024, timeoutSecs: 300, build: 'latest' }, - input: { - type: 'object', - title: 'Test Input', - description: 'Test input schema', - properties: { - category: { - type: 'string', - title: 'Category', - description: 'Category filter', - enum: enumValues, - }, - }, - schemaVersion: 1, - }, - }, - actor: { id: 'test-actor-id', name: 'actor-with-big-enum', username: 'test' } as ActorInfo['actor'], - }; -} - -/** ActorInfo whose enum comfortably fits — no property should be flagged as dropped. */ -function createActorInfoWithSmallEnum(): ActorInfo { - return { - webServerMcpPath: null, - definition: { - id: 'test-id', - actorFullName: 'test/actor-small-enum', - readme: '', - description: 'Test actor with a small enum', - defaultRunOptions: { memoryMbytes: 1024, timeoutSecs: 300, build: 'latest' }, - input: { - type: 'object', - title: 'Test Input', - description: 'Test input schema', - properties: { - category: { type: 'string', title: 'Category', description: 'Category filter', enum: ['a', 'b'] }, - }, - schemaVersion: 1, - }, - }, - actor: { id: 'test-actor-id', name: 'actor-small-enum', username: 'test' } as ActorInfo['actor'], - }; -} - -describe('getNormalActorsAsTools — dropped-enum buildInputSchema gating', () => { - it('keeps the base inputSchema generic (no tool name) regardless of session', async () => { - const tools = await getNormalActorsAsTools([createActorInfoWithOversizedEnum()]); - const tool = tools[0]; - const { properties } = tool.inputSchema as { properties: Record }; - - expect(properties.category.enum).toBeUndefined(); - expect(properties.category.description).toContain('More values accepted than shown'); - expect(properties.category.description).not.toContain(HELPER_TOOLS.ACTOR_GET_DETAILS); - }); - - it('buildInputSchema renders the same generic text when fetch-actor-details is absent from the session', async () => { - const tools = await getNormalActorsAsTools([createActorInfoWithOversizedEnum()]); - const tool = tools[0]; - - expect(tool.buildInputSchema).toBeDefined(); - const rendered = tool.buildInputSchema!({ hasTool: () => false }) as { - properties: Record; - }; - expect(rendered.properties.category.description).not.toContain(HELPER_TOOLS.ACTOR_GET_DETAILS); - expect(rendered.properties.category.description).toContain('More values accepted than shown'); - }); - - it('buildInputSchema names fetch-actor-details in the addendum when it is present in the session', async () => { - const tools = await getNormalActorsAsTools([createActorInfoWithOversizedEnum()]); - const tool = tools[0]; - - const rendered = tool.buildInputSchema!({ - hasTool: (name) => name === HELPER_TOOLS.ACTOR_GET_DETAILS, - }) as { properties: Record }; - - expect(rendered.properties.category.description).toContain(HELPER_TOOLS.ACTOR_GET_DETAILS); - expect(rendered.properties.category.description).toContain('more on this Actor'); - }); - - it('leaves other properties (e.g. the injected waitSecs) untouched by the addendum', async () => { - const tools = await getNormalActorsAsTools([createActorInfoWithOversizedEnum()]); - const tool = tools[0]; - - const rendered = tool.buildInputSchema!({ - hasTool: (name) => name === HELPER_TOOLS.ACTOR_GET_DETAILS, - }) as { properties: Record }; - - expect(rendered.properties.waitSecs.description).not.toContain(HELPER_TOOLS.ACTOR_GET_DETAILS); - }); - - it('AJV enforces type on the dropped-enum property but not membership in the original list', async () => { - const tools = await getNormalActorsAsTools([createActorInfoWithOversizedEnum()]); - const tool = tools[0]; - - expect(tool.ajvValidate({ category: 'not-in-the-original-enum-list' })).toBe(true); - // AJV coerces scalars (coerceTypes: 'array'), so use an object — never coercible to string. - expect(tool.ajvValidate({ category: { nested: true } })).toBe(false); - }); - - it('does not attach buildInputSchema when no property had its enum dropped', async () => { - const tools = await getNormalActorsAsTools([createActorInfoWithSmallEnum()]); - const tool = tools[0]; - - expect(tool.buildInputSchema).toBeUndefined(); - }); - - it('tools/list boundary (getToolPublicFieldOnly) renders the addendum only when fetch-actor-details shares the session', async () => { - const tools = await getNormalActorsAsTools([createActorInfoWithOversizedEnum()]); - const tool = tools[0] as ToolBase; - - const withoutDetails = getToolPublicFieldOnly(tool, { presentTools: new Set([tool.name]) }); - const withDetails = getToolPublicFieldOnly(tool, { - presentTools: new Set([tool.name, HELPER_TOOLS.ACTOR_GET_DETAILS]), - }); - - const propsWithout = (withoutDetails.inputSchema as { properties: Record }) - .properties; - const propsWith = (withDetails.inputSchema as { properties: Record }).properties; - - expect(propsWithout.category.description).not.toContain(HELPER_TOOLS.ACTOR_GET_DETAILS); - expect(propsWith.category.description).toContain(HELPER_TOOLS.ACTOR_GET_DETAILS); - }); -}); diff --git a/tests/unit/tools.clone.test.ts b/tests/unit/tools.clone.test.ts index 34c2142f1..61c167fc6 100644 --- a/tests/unit/tools.clone.test.ts +++ b/tests/unit/tools.clone.test.ts @@ -106,20 +106,4 @@ describe('cloneToolEntry', () => { // Original should be unaffected expect((original.inputSchema.properties as Record).newProp).toBeUndefined(); }); - - it('should preserve buildInputSchema function reference when present', () => { - const buildInputSchema = vi.fn(() => ({ type: 'object' as const, properties: {} })); - const original = makeActorTool({ buildInputSchema }); - const cloned = cloneToolEntry(original); - - expect(cloned.buildInputSchema).toBe(original.buildInputSchema); - expect(typeof cloned.buildInputSchema).toBe('function'); - }); - - it('should leave buildInputSchema undefined when absent from the original', () => { - const original = makeActorTool(); - const cloned = cloneToolEntry(original); - - expect(cloned.buildInputSchema).toBeUndefined(); - }); }); diff --git a/tests/unit/tools.utils.test.ts b/tests/unit/tools.utils.test.ts index a7d8f6604..8b5dc5009 100644 --- a/tests/unit/tools.utils.test.ts +++ b/tests/unit/tools.utils.test.ts @@ -7,8 +7,6 @@ import { buildApifySpecificProperties, decodeDotPropertyNames, encodeDotPropertyNames, - filterAndShortenEnum, - findDroppedEnumProperties, fixedAjvCompile, inferArrayItemsTypeIfMissing, inferArrayItemType, @@ -356,9 +354,55 @@ describe('shortenProperties', () => { const result = shortenProperties(properties); - // Check that description was truncated - expect(result.prop1.description.length).toBeLessThanOrEqual(ACTOR_MAX_DESCRIPTION_LENGTH + 3); // +3 for "..." - expect(result.prop1.description.endsWith('...')).toBe(true); + expect(result.prop1.description.endsWith('…\n\n[Description truncated]')).toBe(true); + }); + + it('keeps the last complete sentence before a partial URL', () => { + const properties: Record = { + prop1: { + type: 'string', + title: 'Property 1', + description: `Complete sentence. ${'https://example.com/path?query=value'.repeat(20)}`, + }, + }; + + const result = shortenProperties(properties); + + expect(result.prop1.description).toBe('Complete sentence.\n\n[Description truncated]'); + }); + + it('keeps a complete last word when the overflow is the next word', () => { + const description = `${'x'.repeat(494)} hello more`; + const properties: Record = { + prop1: { + type: 'string', + title: 'Property 1', + description, + }, + }; + + const result = shortenProperties(properties); + + expect(result.prop1.description).toContain('hello'); + expect(result.prop1.description).not.toContain('more'); + expect(result.prop1.description.endsWith('…\n\n[Description truncated]')).toBe(true); + }); + + it('keeps a sentence that ends at the exact cap', () => { + const description = `${'x'.repeat(ACTOR_MAX_DESCRIPTION_LENGTH - 1)}. more text that exceeds the cap`; + const properties: Record = { + prop1: { + type: 'string', + title: 'Property 1', + description, + }, + }; + + const result = shortenProperties(properties); + + expect(result.prop1.description).toBe( + `${'x'.repeat(ACTOR_MAX_DESCRIPTION_LENGTH - 1)}.\n\n[Description truncated]`, + ); }); it('should not modify descriptions that are within limits', () => { @@ -383,7 +427,7 @@ describe('shortenProperties', () => { type: 'string', title: 'Property 1', description: 'Property with enum', - enum: ['a', 'b', 'c'], + enum: ['a', '', 'b', 'c'], }, }; @@ -393,13 +437,58 @@ describe('shortenProperties', () => { expect(result.prop1.description).toBe('Property with enum'); }); + it('deletes an all-blank enum without adding a dropped-enum note', () => { + const properties: Record = { + prop1: { + type: 'string', + title: 'Property 1', + description: 'Property with blank enum', + enum: ['', ''], + }, + }; + + const result = shortenProperties(properties); + + expect(result.prop1).not.toHaveProperty('enum'); + expect(result.prop1.description).toBe('Property with blank enum'); + }); + it('drops the enum entirely (not partially) when the values don\u2019t fit the cap as a whole, noting a few examples', () => { // Create an enum with many values to exceed the character limit const value = 'enum-value-'; + const description = + 'You can limit the places that are scraped based on the Category filter; you can choose as many categories ' + + 'for one flat fee for the whole field. ⚠️ Using categories can sometimes lead to false negatives, as many ' + + "places do not properly categorize themselves, and there are over 4,000 available categories which Google Maps " + + 'has. Using categories might filter out places that you’d like to scrape. To avoid this problem, use categories carefully.'; const enumValues = Array.from( { length: Math.ceil(ACTOR_ENUM_MAX_LENGTH / value.length) + 1 }, (_, i) => `${value}${i}`, ); + const properties: Record = { + prop1: { + type: 'string', + title: 'Property 1', + description, + enum: enumValues, + }, + }; + + const result = shortenProperties(properties); + + expect(result.prop1).not.toHaveProperty('enum'); + expect(result.prop1.description).toContain( + `scrape.\n\n[Description truncated]\n\nThe complete list of accepted values is too long to include. Examples: ${enumValues[0]}`, + ); + expect(result.prop1.description).toContain(`Examples: ${enumValues.slice(0, 10).join(', ')}.`); + expect(result.prop1.description).not.toContain(enumValues[10]); + expect(result.prop1.description).not.toContain('To avoid'); + }); + + it('only shows complete accepted values as examples when dropping an enum', () => { + const longValue = 'a'.repeat(61); + const enumValues = Array.from({ length: 40 }, (_, i) => `${longValue}${i}`); const properties: Record = { prop1: { type: 'string', @@ -411,9 +500,9 @@ describe('shortenProperties', () => { const result = shortenProperties(properties); - expect(result.prop1.enum).toBeUndefined(); - expect(result.prop1.description).toContain('More values accepted than shown'); - expect(result.prop1.description).toContain(enumValues[0]); + expect(result.prop1).not.toHaveProperty('enum'); + expect(result.prop1.description).toContain('The complete list of accepted values is too long to include.'); + expect(result.prop1.description).not.toContain('Examples:'); }); it('keeps items.enum in full, with no note, when every value fits under the cap', () => { @@ -460,8 +549,8 @@ describe('shortenProperties', () => { const result = shortenProperties(properties); - expect(result.prop1.items?.enum).toBeUndefined(); - expect(result.prop1.description).toContain('More values accepted than shown'); + expect(result.prop1.items).not.toHaveProperty('enum'); + expect(result.prop1.description).toContain('The complete list of accepted values is too long to include.'); expect(result.prop1.description).toContain(enumValues[0]); }); @@ -518,81 +607,6 @@ describe('shortenProperties', () => { }); }); -describe('findDroppedEnumProperties', () => { - it('does not flag a property whose enum fits comfortably under the cap', () => { - const display: Record = { - prop1: { type: 'string', title: 'Prop 1', description: 'desc', enum: ['a', 'b'] }, - }; - const raw: Record = { - prop1: { type: 'string', title: 'Prop 1', description: 'desc', enum: ['a', 'b'] }, - }; - - expect(findDroppedEnumProperties(display, raw)).toEqual([]); - }); - - it('flags a property whose enum shortenProperties() dropped for not fitting the cap', () => { - const rawEnum = Array.from({ length: 300 }, (_, i) => `value-${i}`); - const display: Record = { - prop1: { type: 'string', title: 'Prop 1', description: 'desc', enum: filterAndShortenEnum(rawEnum) }, - }; - const raw: Record = { - prop1: { type: 'string', title: 'Prop 1', description: 'desc', enum: rawEnum }, - }; - - expect(findDroppedEnumProperties(display, raw)).toEqual(['prop1']); - }); - - it('flags a property whose items.enum shortenProperties() dropped (the #1253 shape)', () => { - const rawEnum = Array.from({ length: 300 }, (_, i) => `value-${i}`); - const display: Record = { - prop1: { - type: 'array', - title: 'Prop 1', - description: 'desc', - items: { type: 'string', title: 'Item', description: 'Item desc', enum: filterAndShortenEnum(rawEnum) }, - }, - }; - const raw: Record = { - prop1: { - type: 'array', - title: 'Prop 1', - description: 'desc', - items: { type: 'string', title: 'Item', description: 'Item desc', enum: rawEnum }, - }, - }; - - expect(findDroppedEnumProperties(display, raw)).toEqual(['prop1']); - }); - - it('does not flag blank-entry removal as a drop when the non-empty count never exceeds the cap', () => { - const rawEnum = ['a', 'b', '', '', 'c']; - const display: Record = { - prop1: { type: 'string', title: 'Prop 1', description: 'desc', enum: filterAndShortenEnum(rawEnum) }, - }; - const raw: Record = { - prop1: { type: 'string', title: 'Prop 1', description: 'desc', enum: rawEnum }, - }; - - expect(findDroppedEnumProperties(display, raw)).toEqual([]); - }); - - it('skips a property with no counterpart in rawProperties (e.g. the injected waitSecs)', () => { - const display: Record = { - waitSecs: { type: 'integer', title: 'Wait seconds', description: 'desc' }, - }; - - expect(findDroppedEnumProperties(display, {})).toEqual([]); - }); - - it('is a safe no-op when rawProperties is empty', () => { - const display: Record = { - prop1: { type: 'string', title: 'Prop 1', description: 'desc', enum: ['a', 'b'] }, - }; - - expect(findDroppedEnumProperties(display, {})).toEqual([]); - }); -}); - describe('encodeDotPropertyNames', () => { it('should replace dots in property names with -dot-', () => { const input = { @@ -845,16 +859,9 @@ describe('transformActorInputSchemaProperties', () => { // 3. filterSchemaProperties: only allowed fields present // 'foo.bar' upstream only declares title/description/type — no default, prefill, enum, etc. expect(Object.keys(result['foo-dot-bar']).sort()).toEqual(['description', 'title', 'type']); - // 4. shortenProperties: longDesc is truncated, enumProp.enum is shortened - expect(result.longDesc.description.length).toBeLessThanOrEqual(ACTOR_MAX_DESCRIPTION_LENGTH + 3); - if (result.enumProp.enum) { - expect(result.enumProp.enum.length).toBeLessThanOrEqual(30); - const totalEnumLen = result.enumProp.enum.reduce((sum, v) => sum + v.length, 0); - expect(totalEnumLen).toBeLessThanOrEqual(ACTOR_ENUM_MAX_LENGTH); - } else { - // If enum is too long, it may be set to undefined - expect(result.enumProp.enum).toBeUndefined(); - } + // 4. shortenProperties: longDesc is truncated and the fitting enum stays whole. + expect(result.longDesc.description.endsWith('…\n\n[Description truncated]')).toBe(true); + expect(result.enumProp.enum).toEqual(input.properties.enumProp.enum); // 5. Enum fits the cap (30 short values) — kept in full, no "Possible values" duplication. expect(result.enumProp.description).not.toMatch(/Possible values:/); // 6. encodeDotPropertyNames: foo.bar becomes foo-dot-bar @@ -1009,25 +1016,6 @@ describe('inferArrayItemsTypeIfMissing', () => { }); }); -describe('filterAndShortenEnum', () => { - it('keeps the full list (blanks removed) when it fits the cap', () => { - const enumList = ['a', '', 'b', 'c']; - expect(filterAndShortenEnum(enumList)).toEqual(['a', 'b', 'c']); - }); - - it('drops the list entirely \u2014 not partially \u2014 when it exceeds the cap', () => { - const wordLength = 100; - const wordCount = Math.ceil(ACTOR_ENUM_MAX_LENGTH / wordLength) + 1; // total exceeds the cap - const enumList = Array.from({ length: wordCount }, () => 'a'.repeat(wordLength)); - - expect(filterAndShortenEnum(enumList)).toBeUndefined(); - }); - - it('returns undefined for an all-blank list', () => { - expect(filterAndShortenEnum(['', ''])).toBeUndefined(); - }); -}); - describe('getToolFullName', () => { it('returns actorFullName for actor tools', () => { const tool = { @@ -1122,7 +1110,7 @@ describe('buildActorInputSchema + getToolPublicFieldOnly pipeline', () => { expect(schema.properties?.query?.prefill).toBe('web browser for RAG pipelines'); }); - // Regression: #1253 — an oversized enum is dropped entirely (not partially truncated), one schema for both display and AJV. + // An oversized enum is dropped entirely (not partially truncated), one schema for both display and AJV. it('drops an oversized enum from the displayed schema entirely, noting examples, and AJV accepts any value it would have cut', () => { const droppedValue = 'dropped-value-cut-by-truncation'; const rawEnum = [ @@ -1134,10 +1122,15 @@ describe('buildActorInputSchema + getToolPublicFieldOnly pipeline', () => { type: 'object', properties: { categoryFilterWords: { - type: 'string', + type: 'array', title: 'Category', - description: 'Category filter word.', - enum: rawEnum, + description: 'Category filter words.', + items: { + type: 'string', + title: 'Category', + description: 'Category filter word.', + enum: rawEnum, + }, }, }, required: [], @@ -1147,13 +1140,15 @@ describe('buildActorInputSchema + getToolPublicFieldOnly pipeline', () => { const displayProperties = inputSchema.properties as Record; // No partial/incomplete list shown as if exhaustive. - expect(displayProperties.categoryFilterWords.enum).toBeUndefined(); - expect(displayProperties.categoryFilterWords.description).toContain('More values accepted than shown'); + expect(displayProperties.categoryFilterWords.items).not.toHaveProperty('enum'); + expect(displayProperties.categoryFilterWords.description).toContain( + 'The complete list of accepted values is too long to include.', + ); expect(displayProperties.categoryFilterWords.description).toContain('kept-0'); - // #1253 repro (categoryFilterWords: ['restaurant']) — only `type` is enforced now. + // A value that a partial enum would have cut is now accepted; only `type` is enforced. const validate = fixedAjvCompile(ajv, inputSchema); - expect(validate({ categoryFilterWords: droppedValue })).toBe(true); + expect(validate({ categoryFilterWords: [droppedValue] })).toBe(true); }); });