fix(agent): stopped arg coercion from corrupting subagent yield payloads
- Added Tool.coerceArguments (default true); false skips every LLM-quirk repair pass (object->string stringification, unrecognized-key deletion, JSON-string parsing) so validation runs verbatim. - YieldTool opts out: its args are the deliverable, and the repair layer silently stringified object payloads into string-typed schema fields while bypassing yield's own validate-and-retry loop. - Losslessly salvaged weak-caller envelopes observed in session traces: type:"result" without the result wrapper finalizes as last-turn, top-level data/error are wrapped, and JSON-string result/data parse before consuming a schema retry.
This commit is contained in:
@@ -76,6 +76,142 @@ describe("YieldTool", () => {
|
||||
useLastTurn: true,
|
||||
});
|
||||
});
|
||||
it("finalizes type:'result' with the result wrapper omitted entirely as a last-turn yield", async () => {
|
||||
// Gemini-flash traces: the description invites omitting `data`, and weak
|
||||
// callers omit the whole `result` wrapper with it. Must not bounce with
|
||||
// a retryable format error.
|
||||
const tool = new YieldTool(createSession());
|
||||
const result = await tool.execute("call-wrapperless-last-turn", { type: "result" } as never);
|
||||
expect(result.details).toEqual({
|
||||
data: undefined,
|
||||
status: "success",
|
||||
error: undefined,
|
||||
type: "result",
|
||||
useLastTurn: true,
|
||||
});
|
||||
});
|
||||
it("rejects a schema-bound last-turn finalize with no accumulated sections as retryable", async () => {
|
||||
// Instruction-followed punt observed in Gemini traces: `{type:"result"}`
|
||||
// with a declared output schema. Accepting it terminates the child and
|
||||
// finalization then fails post-mortem with an uncorrectable
|
||||
// schema_violation; the tool must bounce it in-band instead.
|
||||
const tool = new YieldTool(
|
||||
createSession({
|
||||
outputSchema: {
|
||||
type: "object",
|
||||
properties: { summary: { type: "string" } },
|
||||
required: ["summary"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
await expect(tool.execute("call-schema-punt", { type: "result" } as never)).rejects.toThrow(
|
||||
/structured output matching the declared schema/,
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts a data-less finalize after incremental sections even when schema-bound", async () => {
|
||||
const tool = new YieldTool(
|
||||
createSession({
|
||||
outputSchema: {
|
||||
type: "object",
|
||||
properties: { findings: { type: "array", items: { type: "string" } } },
|
||||
required: ["findings"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
const section = await tool.execute("call-section", {
|
||||
type: ["findings"],
|
||||
result: { data: "one finding" },
|
||||
} as never);
|
||||
expect(section.details?.status).toBe("success");
|
||||
const finalize = await tool.execute("call-finalize", { type: "result" } as never);
|
||||
expect(finalize.details).toEqual({
|
||||
data: undefined,
|
||||
status: "success",
|
||||
error: undefined,
|
||||
type: "result",
|
||||
useLastTurn: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("salvages a top-level data payload missing the result wrapper", async () => {
|
||||
const tool = new YieldTool(createSession());
|
||||
const result = await tool.execute("call-unwrapped-data", { data: { ok: true } } as never);
|
||||
expect(result.details).toEqual({ data: { ok: true }, status: "success", error: undefined });
|
||||
});
|
||||
|
||||
it("salvages a top-level error missing the result wrapper", async () => {
|
||||
const tool = new YieldTool(createSession());
|
||||
const result = await tool.execute("call-unwrapped-error", { error: "blocked" } as never);
|
||||
expect(result.details).toEqual({ data: undefined, status: "aborted", error: "blocked" });
|
||||
});
|
||||
|
||||
it("parses a JSON-string result envelope losslessly", async () => {
|
||||
const tool = new YieldTool(createSession());
|
||||
const result = await tool.execute("call-string-envelope", {
|
||||
result: '{"data":{"ok":true}}',
|
||||
} as never);
|
||||
expect(result.details).toEqual({ data: { ok: true }, status: "success", error: undefined });
|
||||
});
|
||||
|
||||
it("parses JSON-string data when the schema rejects the string form", async () => {
|
||||
const tool = new YieldTool(
|
||||
createSession({
|
||||
outputSchema: {
|
||||
type: "object",
|
||||
properties: { n: { type: "number" } },
|
||||
required: ["n"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
const result = await tool.execute("call-string-data", { result: { data: '{"n":4}' } } as never);
|
||||
expect(result.details).toEqual({ data: { n: 4 }, status: "success", error: undefined });
|
||||
});
|
||||
|
||||
it("verbatim arg validation rejects object payloads in string-typed fields instead of stringifying", () => {
|
||||
// Regression: the generic tool-arg repair layer used to JSON.stringify an
|
||||
// object submitted for a string-typed schema field, so validation
|
||||
// "passed" and the parent received `summary: "{\"purge\":13,…}"` instead
|
||||
// of a retry prompt. `coerceArguments = false` must surface the mismatch.
|
||||
const tool = new YieldTool(
|
||||
createSession({
|
||||
outputSchema: {
|
||||
type: "object",
|
||||
properties: { summary: { type: "string" } },
|
||||
required: ["summary"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(tool.coerceArguments).toBe(false);
|
||||
expect(() =>
|
||||
validateToolArguments(tool as never, {
|
||||
type: "toolCall",
|
||||
id: "call-dict-summary",
|
||||
name: "yield",
|
||||
arguments: { result: { data: { summary: { purge: 13, keep: 20 } } } },
|
||||
}),
|
||||
).toThrow(/summary/);
|
||||
});
|
||||
|
||||
it("verbatim arg validation passes conforming args through unmodified", () => {
|
||||
const tool = new YieldTool(
|
||||
createSession({
|
||||
outputSchema: {
|
||||
type: "object",
|
||||
properties: { summary: { type: "string" } },
|
||||
required: ["summary"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
const args = { result: { data: { summary: "all good" } } };
|
||||
const validated = validateToolArguments(tool as never, {
|
||||
type: "toolCall",
|
||||
id: "call-clean",
|
||||
name: "yield",
|
||||
arguments: args,
|
||||
});
|
||||
expect(validated).toEqual(args);
|
||||
});
|
||||
|
||||
it("passes array-typed success through as an incremental result", async () => {
|
||||
const tool = new YieldTool(createSession());
|
||||
|
||||
Reference in New Issue
Block a user