Skip to content

Commit 98e0330

Browse files
committed
fix: Stop enforcing enum values dropped by input-schema truncation
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).
1 parent e03c9d8 commit 98e0330

3 files changed

Lines changed: 185 additions & 2 deletions

File tree

src/tools/actor_input_schema.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -365,6 +365,46 @@ export function decodeDotPropertyNames(properties: Record<string, unknown>): Rec
365365
return decodedProperties;
366366
}
367367

368+
/**
369+
* True if filterAndShortenEnum() truncated the enum — compared against the non-empty raw count,
370+
* not raw length, since it also drops blank entries regardless of the character cap.
371+
*/
372+
function wasEnumTruncated(displayEnum: string[], rawEnum: string[]): boolean {
373+
const nonEmptyRawCount = rawEnum.filter((value) => value !== '').length;
374+
return displayEnum.length < nonEmptyRawCount;
375+
}
376+
377+
/**
378+
* Clones properties and drops `enum`/`items.enum` wherever shortenProperties() truncated it —
379+
* AJV must not enforce an incomplete list (#1253), but the schema shown to the LLM keeps it.
380+
* A property missing from one side (e.g. the injected `waitSecs`) is left untouched.
381+
*/
382+
export function stripTruncatedEnumsForValidation(
383+
displayProperties: Record<string, SchemaProperties>,
384+
rawProperties: Record<string, SchemaProperties>,
385+
): Record<string, SchemaProperties> {
386+
const rawEncoded = encodeDotPropertyNames(rawProperties);
387+
const validationProperties = structuredClone(displayProperties);
388+
389+
for (const [key, property] of Object.entries(validationProperties)) {
390+
const rawProperty = rawEncoded[key];
391+
if (!rawProperty) continue;
392+
393+
if (property.enum && rawProperty.enum && wasEnumTruncated(property.enum, rawProperty.enum)) {
394+
delete property.enum;
395+
}
396+
if (
397+
property.items?.enum &&
398+
rawProperty.items?.enum &&
399+
wasEnumTruncated(property.items.enum, rawProperty.items.enum)
400+
) {
401+
delete property.items.enum;
402+
}
403+
}
404+
405+
return validationProperties;
406+
}
407+
368408
export function transformActorInputSchemaProperties(input: Readonly<ActorInputSchema>): ActorInputSchemaProperties {
369409
// Deep clone input to avoid mutating the original object
370410
const inputClone: ActorInputSchema = structuredClone(input);

src/tools/actors/actor_tools_factory.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
type ActorStore,
2121
type ActorTool,
2222
type ApifyToken,
23+
type SchemaProperties,
2324
type ToolEntry,
2425
type ToolInputSchema,
2526
TOOL_TYPE,
@@ -28,7 +29,7 @@ import { getActorDefinitionCached } from '../../utils/actor.js';
2829
import { ajv } from '../../utils/ajv.js';
2930
import { stripQuoteWrappers } from '../../utils/generic.js';
3031
import { logHttpError } from '../../utils/logging.js';
31-
import { buildActorInputSchema, fixedAjvCompile } from '../actor_input_schema.js';
32+
import { buildActorInputSchema, fixedAjvCompile, stripTruncatedEnumsForValidation } from '../actor_input_schema.js';
3233
import { actorNameToToolName, isActorBlockedUnderPaymentProvider, isActorInfoMcpServer } from '../actor_tool_naming.js';
3334
import { buildEnrichedDirectActorOutputSchema, actorRunOutputSchema } from '../structured_output_schemas.js';
3435
import { CALL_ACTOR_WAIT_SECS_DEFAULT, WAIT_SECS_MAX } from './actor_run_response.js';
@@ -133,12 +134,21 @@ Actor description: ${definition.description}`;
133134
ACTOR_MAX_MEMORY_MBYTES,
134135
);
135136

137+
// AJV must not enforce a truncated enum (#1253) — definition.input still has the full one.
138+
const validationSchema = {
139+
...inputSchema,
140+
properties: stripTruncatedEnumsForValidation(
141+
(inputSchemaWithWaitSecs.properties ?? {}) as Record<string, SchemaProperties>,
142+
definition.input?.properties ?? {},
143+
),
144+
};
145+
136146
let ajvValidate;
137147
try {
138148
// Unknown properties are silently stripped by AJV's removeAdditional option.
139149
// Dynamic Actor input fields are part of the Actor's own inputSchema, so they
140150
// are declared properties and won't be stripped.
141-
ajvValidate = fixedAjvCompile(ajv, inputSchema);
151+
ajvValidate = fixedAjvCompile(ajv, validationSchema);
142152
} catch (e) {
143153
// SchemaTooLargeError logs as a soft fail; a genuine AJV compile error stays an error.
144154
logHttpError(e, 'Failed to compile schema', {

tests/unit/tools.utils.test.ts

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
MAX_UNTRUSTED_SCHEMA_BYTES,
1515
markInputPropertiesAsRequired,
1616
shortenProperties,
17+
stripTruncatedEnumsForValidation,
1718
transformActorInputSchemaProperties,
1819
} from '../../src/tools/actor_input_schema.js';
1920
import { isActorBlockedUnderPaymentProvider } from '../../src/tools/actor_tool_naming.js';
@@ -499,6 +500,95 @@ describe('shortenProperties', () => {
499500
});
500501
});
501502

503+
describe('stripTruncatedEnumsForValidation', () => {
504+
it('keeps enum unchanged when it fits comfortably under the cap on both sides', () => {
505+
const display: Record<string, SchemaProperties> = {
506+
prop1: { type: 'string', title: 'Prop 1', description: 'desc', enum: ['a', 'b'] },
507+
};
508+
const raw: Record<string, SchemaProperties> = {
509+
prop1: { type: 'string', title: 'Prop 1', description: 'desc', enum: ['a', 'b'] },
510+
};
511+
512+
const result = stripTruncatedEnumsForValidation(display, raw);
513+
514+
expect(result.prop1.enum).toEqual(['a', 'b']);
515+
});
516+
517+
it('strips enum entirely when it was truncated by shortenProperties', () => {
518+
const rawEnum = Array.from({ length: 300 }, (_, i) => `value-${i}`);
519+
const display: Record<string, SchemaProperties> = {
520+
prop1: { type: 'string', title: 'Prop 1', description: 'desc', enum: filterAndShortenEnum(rawEnum) },
521+
};
522+
const raw: Record<string, SchemaProperties> = {
523+
prop1: { type: 'string', title: 'Prop 1', description: 'desc', enum: rawEnum },
524+
};
525+
526+
const result = stripTruncatedEnumsForValidation(display, raw);
527+
528+
expect(result.prop1.enum).toBeUndefined();
529+
});
530+
531+
it('strips items.enum entirely when it was truncated (the #1253 shape), leaving other items fields untouched', () => {
532+
const rawEnum = Array.from({ length: 300 }, (_, i) => `value-${i}`);
533+
const display: Record<string, SchemaProperties> = {
534+
prop1: {
535+
type: 'array',
536+
title: 'Prop 1',
537+
description: 'desc',
538+
items: { type: 'string', title: 'Item', description: 'Item desc', enum: filterAndShortenEnum(rawEnum) },
539+
},
540+
};
541+
const raw: Record<string, SchemaProperties> = {
542+
prop1: {
543+
type: 'array',
544+
title: 'Prop 1',
545+
description: 'desc',
546+
items: { type: 'string', title: 'Item', description: 'Item desc', enum: rawEnum },
547+
},
548+
};
549+
550+
const result = stripTruncatedEnumsForValidation(display, raw);
551+
552+
expect(result.prop1.items?.enum).toBeUndefined();
553+
expect(result.prop1.items?.type).toBe('string');
554+
expect(result.prop1.items?.title).toBe('Item');
555+
});
556+
557+
it('does not treat empty-string removal as truncation when non-empty count never exceeds the cap', () => {
558+
const rawEnum = ['a', 'b', '', '', 'c'];
559+
const display: Record<string, SchemaProperties> = {
560+
prop1: { type: 'string', title: 'Prop 1', description: 'desc', enum: filterAndShortenEnum(rawEnum) },
561+
};
562+
const raw: Record<string, SchemaProperties> = {
563+
prop1: { type: 'string', title: 'Prop 1', description: 'desc', enum: rawEnum },
564+
};
565+
566+
const result = stripTruncatedEnumsForValidation(display, raw);
567+
568+
expect(result.prop1.enum).toEqual(['a', 'b', 'c']);
569+
});
570+
571+
it('leaves a property with no counterpart in rawProperties untouched (e.g. the injected waitSecs)', () => {
572+
const display: Record<string, SchemaProperties> = {
573+
waitSecs: { type: 'integer', title: 'Wait seconds', description: 'desc' },
574+
};
575+
576+
const result = stripTruncatedEnumsForValidation(display, {});
577+
578+
expect(result).toEqual(display);
579+
});
580+
581+
it('is a safe no-op when rawProperties is empty', () => {
582+
const display: Record<string, SchemaProperties> = {
583+
prop1: { type: 'string', title: 'Prop 1', description: 'desc', enum: ['a', 'b'] },
584+
};
585+
586+
const result = stripTruncatedEnumsForValidation(display, {});
587+
588+
expect(result).toEqual(display);
589+
});
590+
});
591+
502592
describe('encodeDotPropertyNames', () => {
503593
it('should replace dots in property names with -dot-', () => {
504594
const input = {
@@ -1035,6 +1125,49 @@ describe('buildActorInputSchema + getToolPublicFieldOnly pipeline', () => {
10351125
expect(schema.properties?.query?.description).toMatch(/^\*\*REQUIRED\*\*/);
10361126
expect(schema.properties?.maxResults?.description).not.toMatch(/^\*\*REQUIRED\*\*/);
10371127
});
1128+
1129+
// Regression: #1253 — a value cut only by display truncation must still pass AJV.
1130+
it('accepts an enum value dropped by display truncation, while the displayed schema keeps the truncated list', () => {
1131+
// 'kept-0' is short and sorts first, so it always survives filterAndShortenEnum's cap;
1132+
// 'dropped-value' is padded long enough to land well past the cap.
1133+
const keptValue = 'kept-0';
1134+
const droppedValue = 'dropped-value-cut-by-truncation';
1135+
const rawEnum = [
1136+
keptValue,
1137+
...Array.from({ length: 300 }, (_, i) => `kept-padding-${i}-${'x'.repeat(20)}`),
1138+
droppedValue,
1139+
];
1140+
const upstream: ActorInputSchema = {
1141+
type: 'object',
1142+
properties: {
1143+
categoryFilterWords: {
1144+
type: 'string',
1145+
title: 'Category',
1146+
description: 'Category filter word.',
1147+
enum: rawEnum,
1148+
},
1149+
},
1150+
required: [],
1151+
};
1152+
1153+
const { inputSchema } = buildActorInputSchema('compass/crawler-google-places', upstream, false);
1154+
const displayProperties = inputSchema.properties as Record<string, SchemaProperties>;
1155+
1156+
// Display schema still shows the truncated enum, unaffected by the fix.
1157+
const displayEnum = displayProperties.categoryFilterWords.enum;
1158+
expect(displayEnum).toBeDefined();
1159+
expect(displayEnum!.length).toBeGreaterThan(0);
1160+
expect(displayEnum!.length).toBeLessThan(rawEnum.length);
1161+
expect(displayEnum).not.toContain(droppedValue);
1162+
1163+
// AJV accepts the dropped value — the #1253 repro (categoryFilterWords: ['restaurant']).
1164+
const validationSchema = {
1165+
...inputSchema,
1166+
properties: stripTruncatedEnumsForValidation(displayProperties, upstream.properties),
1167+
};
1168+
const validate = fixedAjvCompile(ajv, validationSchema);
1169+
expect(validate({ categoryFilterWords: droppedValue })).toBe(true);
1170+
});
10381171
});
10391172

10401173
describe('isActorBlockedUnderPaymentProvider', () => {

0 commit comments

Comments
 (0)