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:
can1357
2026-08-20 04:28:35 +02:00
parent 7e54061cbb
commit 042f27f5e4
9 changed files with 319 additions and 14 deletions
@@ -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());