Skip to content

Commit daa3581

Browse files
committed
fix(agents): project cron tool schemas for providers
1 parent 98b8e85 commit daa3581

11 files changed

Lines changed: 262 additions & 70 deletions

packages/llm-core/src/validation.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { Type } from "typebox";
12
import { describe, expect, it } from "vitest";
23
import type { Tool } from "./types.js";
34
import { validateToolArguments } from "./validation.js";
@@ -16,6 +17,20 @@ const decimalTool = {
1617
},
1718
} as Tool;
1819

20+
const nullableTypeBoxTool = {
21+
name: "nullable-typebox-tool",
22+
description: "test nullable TypeBox tool",
23+
parameters: Type.Object({
24+
agentId: Type.Optional(Type.Union([Type.String(), Type.Null()])),
25+
toolsAllow: Type.Optional(Type.Union([Type.Array(Type.String()), Type.Null()])),
26+
nested: Type.Optional(
27+
Type.Object({
28+
sessionKey: Type.Optional(Type.Union([Type.String(), Type.Null()])),
29+
}),
30+
),
31+
}),
32+
} satisfies Tool;
33+
1934
describe("validateToolArguments", () => {
2035
it("coerces strict decimal numeric strings for plain JSON schemas", () => {
2136
expect(
@@ -38,4 +53,23 @@ describe("validateToolArguments", () => {
3853
}),
3954
).toThrow(/Validation failed for tool "decimal-tool"/);
4055
});
56+
57+
it("preserves explicit null values accepted by TypeBox unions", () => {
58+
expect(
59+
validateToolArguments(nullableTypeBoxTool, {
60+
type: "toolCall",
61+
id: "call-1",
62+
name: "nullable-typebox-tool",
63+
arguments: {
64+
agentId: null,
65+
toolsAllow: null,
66+
nested: { sessionKey: null },
67+
},
68+
}),
69+
).toEqual({
70+
agentId: null,
71+
toolsAllow: null,
72+
nested: { sessionKey: null },
73+
});
74+
});
4175
});

packages/llm-core/src/validation.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,13 @@ function applySchemaArrayCoercion(value: unknown[], schema: JsonSchemaObject): v
204204
}
205205

206206
function coerceWithUnionSchema(value: unknown, schemas: JsonSchemaObject[]): unknown {
207+
for (const schema of schemas) {
208+
const validator = getSubSchemaValidator(schema);
209+
if (validator?.Check(value)) {
210+
return value;
211+
}
212+
}
213+
207214
for (const schema of schemas) {
208215
const candidate = structuredClone(value);
209216
const coerced = coerceWithJsonSchema(candidate, schema);

src/agents/tool-schema-projection.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,56 @@ describe("runtime tool input schema projection", () => {
8282
).toEqual([]);
8383
});
8484

85+
it("projects nullable and literal unions to OpenAPI-friendly provider schemas", () => {
86+
expect(
87+
projectRuntimeToolInputSchema({
88+
type: "object",
89+
properties: {
90+
agentId: {
91+
anyOf: [{ type: "string" }, { type: "null" }],
92+
description: "Agent id, or null to clear",
93+
},
94+
toolsAllow: {
95+
anyOf: [{ type: "array", items: { type: "string" } }, { type: "null" }],
96+
description: "Allowed tools, or null to clear",
97+
},
98+
mode: {
99+
anyOf: [
100+
{ type: "string", const: "announce" },
101+
{ type: "string", const: "webhook" },
102+
{ type: "null" },
103+
],
104+
},
105+
threadId: {
106+
anyOf: [{ type: "string" }, { type: "number" }, { type: "null" }],
107+
description: "Thread/topic id",
108+
},
109+
},
110+
}).schema,
111+
).toEqual({
112+
type: "object",
113+
properties: {
114+
agentId: {
115+
type: "string",
116+
description: "Agent id, or null to clear",
117+
},
118+
toolsAllow: {
119+
type: "array",
120+
items: { type: "string" },
121+
description: "Allowed tools, or null to clear",
122+
},
123+
mode: {
124+
type: "string",
125+
enum: ["announce", "webhook"],
126+
},
127+
threadId: {
128+
type: "string",
129+
description: "Thread/topic id",
130+
},
131+
},
132+
});
133+
});
134+
85135
it("filters unsupported schemas without dropping healthy tools", () => {
86136
const healthy = {
87137
name: "healthy",

src/agents/tool-schema-projection.ts

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,11 +132,87 @@ function serializeToolInputSchema(value: unknown, path: string): RuntimeToolInpu
132132
};
133133
}
134134
return {
135-
schema: parsed,
135+
schema: normalizeProviderToolInputSchema(parsed),
136136
violations: [],
137137
};
138138
}
139139

140+
function isNullSchema(schema: RuntimeToolInputSchemaJson): boolean {
141+
return isJsonObject(schema) && schema.type === "null";
142+
}
143+
144+
function isStringConstSchema(
145+
schema: RuntimeToolInputSchemaJson,
146+
): schema is { type: "string"; const: string } {
147+
return isJsonObject(schema) && schema.type === "string" && typeof schema.const === "string";
148+
}
149+
150+
function mergeCompositionWrapper(
151+
wrapper: { [key: string]: RuntimeToolInputSchemaJson },
152+
replacement: { [key: string]: RuntimeToolInputSchemaJson },
153+
keyword: "anyOf" | "oneOf",
154+
): { [key: string]: RuntimeToolInputSchemaJson } {
155+
const { [keyword]: _composition, ...metadata } = wrapper;
156+
return {
157+
...replacement,
158+
...metadata,
159+
};
160+
}
161+
162+
function normalizeSchemaComposition(
163+
schema: { [key: string]: RuntimeToolInputSchemaJson },
164+
keyword: "anyOf" | "oneOf",
165+
): { [key: string]: RuntimeToolInputSchemaJson } | undefined {
166+
const variants = schema[keyword];
167+
if (!Array.isArray(variants)) {
168+
return undefined;
169+
}
170+
const hasNullVariant = variants.some(isNullSchema);
171+
const nonNullVariants = variants.filter((variant) => !isNullSchema(variant));
172+
if (nonNullVariants.length === 0) {
173+
return undefined;
174+
}
175+
if (hasNullVariant && nonNullVariants.length === 1 && isJsonObject(nonNullVariants[0])) {
176+
return mergeCompositionWrapper(schema, nonNullVariants[0], keyword);
177+
}
178+
if (nonNullVariants.every(isStringConstSchema)) {
179+
return mergeCompositionWrapper(
180+
schema,
181+
{
182+
type: "string",
183+
enum: nonNullVariants.map((variant) => variant.const),
184+
},
185+
keyword,
186+
);
187+
}
188+
const stringVariant = nonNullVariants.find(
189+
(variant) => isJsonObject(variant) && variant.type === "string",
190+
);
191+
if (stringVariant !== undefined && isJsonObject(stringVariant)) {
192+
return mergeCompositionWrapper(schema, stringVariant, keyword);
193+
}
194+
return undefined;
195+
}
196+
197+
function normalizeProviderToolInputSchema(
198+
schema: RuntimeToolInputSchemaJson,
199+
): RuntimeToolInputSchemaJson {
200+
if (Array.isArray(schema)) {
201+
return schema.map(normalizeProviderToolInputSchema);
202+
}
203+
if (!isJsonObject(schema)) {
204+
return schema;
205+
}
206+
const normalized = Object.fromEntries(
207+
Object.entries(schema).map(([key, value]) => [key, normalizeProviderToolInputSchema(value)]),
208+
);
209+
return (
210+
normalizeSchemaComposition(normalized, "anyOf") ??
211+
normalizeSchemaComposition(normalized, "oneOf") ??
212+
normalized
213+
);
214+
}
215+
140216
function findDynamicSchemaKeywordViolations(
141217
schema: RuntimeToolInputSchemaJson,
142218
path: string,

src/agents/tools/cron-tool.schema.test.ts

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
import { validateToolArguments, type Tool } from "openclaw/plugin-sdk/llm";
12
import { describe, expect, it } from "vitest";
3+
import { projectRuntimeToolInputSchema } from "../tool-schema-projection.js";
24
import { CronToolSchema } from "./cron-tool.js";
35

46
/** Walk a TypeBox schema by dot-separated property path and return sorted keys. */
@@ -25,7 +27,13 @@ function propertyAt(
2527
}
2628

2729
describe("CronToolSchema", () => {
28-
const schemaRecord = CronToolSchema as unknown as Record<string, unknown>;
30+
const schemaRecord = projectRuntimeToolInputSchema(CronToolSchema, "cron.parameters")
31+
.schema as Record<string, unknown>;
32+
const cronTool = {
33+
name: "cron",
34+
description: "Manage scheduled jobs",
35+
parameters: CronToolSchema,
36+
} satisfies Tool;
2937

3038
// Regression: models like GPT-5.4 rely on these fields to populate job/patch.
3139
// If a field is removed from this list the test must be updated intentionally.
@@ -221,13 +229,51 @@ describe("CronToolSchema", () => {
221229
expect(patchProps?.payload?.properties?.toolsAllow?.type).toBe("array");
222230
});
223231

232+
it("raw validation preserves null clear sentinels before provider projection", () => {
233+
const validated = validateToolArguments(cronTool, {
234+
type: "toolCall",
235+
id: "call-1",
236+
name: "cron",
237+
arguments: {
238+
action: "update",
239+
id: "job-1",
240+
patch: {
241+
agentId: null,
242+
sessionKey: null,
243+
payload: {
244+
kind: "agentTurn",
245+
message: "refresh status",
246+
toolsAllow: null,
247+
},
248+
},
249+
},
250+
}) as {
251+
patch: {
252+
agentId: unknown;
253+
sessionKey: unknown;
254+
payload: { toolsAllow: unknown };
255+
};
256+
};
257+
258+
expect(validated.patch.agentId).toBeNull();
259+
expect(validated.patch.sessionKey).toBeNull();
260+
expect(validated.patch.payload.toolsAllow).toBeNull();
261+
});
262+
224263
// Regression guard: ensure no OpenAPI 3.0 incompatible keywords leak into the
225264
// serialized cron tool schema. This catches future regressions at the source.
226-
it("serialized schema contains no type-array or not/const keywords", () => {
227-
const json = JSON.stringify(CronToolSchema);
265+
it("projected schema contains no OpenAPI 3.0 incompatible composition/null keywords", () => {
266+
const json = JSON.stringify(schemaRecord);
228267
// type arrays like ["string","null"] are not valid in OpenAPI 3.0
229268
expect(json).not.toMatch(/"type"\s*:\s*\[/);
269+
// null-type composition is also rejected by strict OpenAPI 3.0 tool adapters.
270+
expect(json).not.toMatch(/"type"\s*:\s*"null"/);
271+
expect(json).not.toMatch(/"anyOf"\s*:/);
272+
expect(json).not.toMatch(/"oneOf"\s*:/);
273+
expect(json).not.toMatch(/"allOf"\s*:/);
230274
// "not" composition keyword is not supported by OpenAPI 3.0
231275
expect(json).not.toMatch(/"not"\s*:\s*\{/);
276+
// "const" is not part of the OpenAPI 3.0 schema subset.
277+
expect(json).not.toMatch(/"const"\s*:/);
232278
});
233279
});

test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.discord-group.json

Lines changed: 11 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -208,16 +208,19 @@
208208
"additionalProperties": true,
209209
"properties": {
210210
"accountId": {
211+
"description": "Failure delivery account",
211212
"type": "string"
212213
},
213214
"channel": {
215+
"description": "Failure delivery channel",
214216
"type": "string"
215217
},
216218
"mode": {
217219
"enum": ["announce", "webhook"],
218220
"type": "string"
219221
},
220222
"to": {
223+
"description": "Failure delivery target",
221224
"type": "string"
222225
}
223226
},
@@ -229,15 +232,8 @@
229232
"type": "string"
230233
},
231234
"threadId": {
232-
"anyOf": [
233-
{
234-
"type": "string"
235-
},
236-
{
237-
"type": "number"
238-
}
239-
],
240-
"description": "Thread/topic id"
235+
"description": "Thread/topic id",
236+
"type": "string"
241237
},
242238
"to": {
243239
"description": "Delivery target",
@@ -431,18 +427,22 @@
431427
},
432428
"failureDestination": {
433429
"additionalProperties": true,
430+
"description": "Failure destination, or null to clear",
434431
"properties": {
435432
"accountId": {
433+
"description": "Failure delivery account",
436434
"type": "string"
437435
},
438436
"channel": {
437+
"description": "Failure delivery channel",
439438
"type": "string"
440439
},
441440
"mode": {
442441
"enum": ["announce", "webhook"],
443442
"type": "string"
444443
},
445444
"to": {
445+
"description": "Failure delivery target",
446446
"type": "string"
447447
}
448448
},
@@ -454,15 +454,8 @@
454454
"type": "string"
455455
},
456456
"threadId": {
457-
"anyOf": [
458-
{
459-
"type": "string"
460-
},
461-
{
462-
"type": "number"
463-
}
464-
],
465-
"description": "Thread/topic id"
457+
"description": "Thread/topic id",
458+
"type": "string"
466459
},
467460
"to": {
468461
"description": "Delivery target",

0 commit comments

Comments
 (0)