diff --git a/contracts/agents-api/index.md b/contracts/agents-api/index.md index cfe8cd77c..a49688af5 100644 --- a/contracts/agents-api/index.md +++ b/contracts/agents-api/index.md @@ -91,6 +91,7 @@ Each item is Core's deliberate or native behavior where the official service beh - The Items list includes in-progress and incomplete output Items, and keeps a failed function result's submitted `output`. - Error messages omit the call and executor IDs that official messages include. - An empty text part beside other text is accepted and stored. +- Claude Code shows the model its own placeholder text for a function result with no image and no non-blank text, and trims surrounding whitespace from function error text. - Empty input returns 400 `invalid_request` with a generic message and a null param; the official response is `invalid_request_error` with param `input`. - Session usage is available as soon as every root Turn has settled; official reads lag by seconds. - U+0000 in Harness output, such as command output, reads as U+FFFD; the official service keeps it. diff --git a/contracts/agents-api/zh/index.md b/contracts/agents-api/zh/index.md index 009710471..0e9701b0b 100644 --- a/contracts/agents-api/zh/index.md +++ b/contracts/agents-api/zh/index.md @@ -1,7 +1,7 @@ --- title: "OpenAI 兼容性" source: contracts/agents-api/index.md -source_hash: 535fce4a958c4176e98e0b59a6995fd9d72c874cb4a6493b797545652c37321b +source_hash: 1dbd446a9974495b9bb19cf19a8dd2ae1ce7706561366d3d985c42815e4d97ee --- Core 实现下方固定版本的 OpenAI Agents API。本台账列出各项资源的覆盖情况、相对于官方服务的所有差异以及已知缺口。 @@ -94,6 +94,7 @@ Core 自身字段位于 `x_agents_core` 中([Core extensions](../../../docs/zh - Items 列表会包含进行中和未完成的输出 Items,并保留失败函数结果中已提交的 `output`。 - 错误消息会省略官方消息包含的 call 和 executor ID。 - 与其他文本并存的空文本部分可被接受并存储。 +- 对于既没有图像、也没有非空白文本的函数结果,Claude Code 会向模型显示其自身的占位文本;函数错误文本的首尾空白会被去除。 - 空输入会返回 400 `invalid_request`,附带通用消息和值为 null 的 param;官方响应为 `invalid_request_error`,param 为 `input`。 - 一旦所有根级 Turn 均已进入终态,Session 用量即可用;官方读取会滞后数秒。 - Harness 输出(例如命令输出)中的 U+0000 读取时为 U+FFFD;官方服务会保留该字符。 diff --git a/packages/claude-sdk-adapter/README.md b/packages/claude-sdk-adapter/README.md index ce6fa2535..364f72ce5 100644 --- a/packages/claude-sdk-adapter/README.md +++ b/packages/claude-sdk-adapter/README.md @@ -80,7 +80,7 @@ The `none` profile accepts only text, explicit model/system instructions, manage The internal SDK function-server helper uses the maintained MCP server's public request handlers and standard Tool/CallToolResult types. It snapshots definitions and forwards JSON Schema without a JSON Schema-to-Zod conversion; supplied tools are always loaded. Native call identity comes from the pinned harness's `claudecode/toolUseId` MCP metadata, independently of request IDs, names or arrival order. Missing identities and undeclared tools fail before invoking the host. Return content/error fields unchanged over MCP and forward its per-request abort signal. The private Go factory connects declared functions through this helper and reuses the daemon function-call/result interface and neutral observations. The native function-server registry must contain exactly those functions. SDK allowlisting admits only these host callbacks; the host still owns result decisions and any business permission checks. It grants no runtime-token business authority. -Function results remain pending after stdin/MCP delivery. A matching live native user tool_result of the calling agent confirms application only when its Session/call identity, error flag and ordered content match the submission. Text matches exactly; each submitted image position must hold a native image, or the text note native substitutes for an image it cannot process. Native resizing or re-encoding may change image bytes. This acknowledges incorporation into native history, not byte/pixel fidelity or completed provider consumption. Public Items of a root call retain the original caller content, and a child's Items come from its native history; real image-dependent model responses separately qualify usability. Ignore replayed and synthetic messages. Native error text joins the submitted text parts with newlines; neutral observations retain their original order and separate failure status. Missing/mismatched receipts fail the execution; do not replay unknown delivery. Result submission waits at most ten seconds for a receipt and cancels uncertain execution on timeout. Invalid or unsupported image results fail before consuming a pending call. Function state belongs to one live Run and ends with it; the router owns receipt retry/conflict handling. This does not establish crash recovery or exactly-once effects. +Function results remain pending after stdin/MCP delivery. A matching live native user tool_result of the calling agent confirms application only when its Session/call identity, error flag and ordered content match the submission. Text matches exactly, except as noted for blank results and error text below; each submitted image position must hold a native image, or the text note native substitutes for an image it cannot process. Native resizing or re-encoding may change image bytes. This acknowledges incorporation into native history, not byte/pixel fidelity or completed provider consumption. Public Items of a root call retain the original caller content, and a child's Items come from its native history; real image-dependent model responses separately qualify usability. Ignore replayed and synthetic messages. A result with no image and no non-blank text is confirmed by any native text tool_result with the same error flag, because the native harness replaces it with its own placeholder. Native error text joins the submitted text parts with newlines and trims surrounding whitespace, so the receipt compares the trimmed joined text; neutral observations retain their original order and separate failure status. Missing/mismatched receipts fail the execution; do not replay unknown delivery. Result submission waits at most ten seconds for a receipt and cancels uncertain execution on timeout. Invalid or unsupported image results fail before consuming a pending call. Function state belongs to one live Run and ends with it; the router owns receipt retry/conflict handling. This does not establish crash recovery or exactly-once effects. ### Usage diff --git a/packages/claude-sdk-adapter/src/function_bridge.ts b/packages/claude-sdk-adapter/src/function_bridge.ts index 354f455ea..ae747d156 100644 --- a/packages/claude-sdk-adapter/src/function_bridge.ts +++ b/packages/claude-sdk-adapter/src/function_bridge.ts @@ -100,9 +100,12 @@ export class FunctionBridge { this.cancelUnanswered(block.tool_use_id); continue; } - // Native error results join MCP text blocks; retain the original parts in the host. - const matches = result.success ? matchesNativeContent(result.content, block.content) : - block.content === result.content.map(part => part.type === "input_text" ? part.text : "").join("\n"); + // Native error results join and trim MCP text blocks; retain the original parts in the host. + // The native harness replaces a result without an image or visible text with its own + // placeholder, so the outcome alone confirms a blank result. + const matches = isBlankResult(result.content) ? isNativeText(block.content) : + result.success ? matchesNativeContent(result.content, block.content) : + block.content === result.content.map(part => part.type === "input_text" ? part.text : "").join("\n").trim(); if (!!block.is_error !== !result.success || !matches) { throw new Error("Native function response differs from submitted result."); } @@ -146,3 +149,12 @@ function matchesNativeContent(content: InputContent[], actual: unknown): boolean ? actual[index]?.type === "image" || actual[index]?.type === "text" : isDeepStrictEqual(part, actual[index])); } + +function isBlankResult(content: InputContent[]): boolean { + return content.every(part => part.type === "input_text" && !part.text.trim()); +} + +function isNativeText(actual: unknown): boolean { + return typeof actual === "string" || + Array.isArray(actual) && actual.every(part => part?.type === "text" && typeof part.text === "string"); +} diff --git a/packages/claude-sdk-adapter/tests/function_bridge.test.mjs b/packages/claude-sdk-adapter/tests/function_bridge.test.mjs index a45c10c80..260ce173e 100644 --- a/packages/claude-sdk-adapter/tests/function_bridge.test.mjs +++ b/packages/claude-sdk-adapter/tests/function_bridge.test.mjs @@ -138,6 +138,52 @@ test("native cancellation preserves a submitted result's missing receipt", async bridge.close(); }); +test("blank results accept the native placeholder and error text is compared as natively trimmed", async () => { + const text = value => ({ type: "input_text", text: value }); + const blank = [ + ["empty", true, [], "(mcp__functions__lookup completed with no output)"], + ["empty-text", true, [text("")], "(mcp__functions__lookup completed with no output)"], + ["whitespace", true, [text(" \n")], "(mcp__functions__lookup completed with no output)"], + ["empty-error", false, [], "Unknown error"], + ["empty-text-error", false, [text("")], "Command failed with no output"], + ["whitespace-error", false, [text(" \n")], "Command failed with no output"], + ]; + const events = []; + const bridge = new FunctionBridge(async event => { events.push(event); }); + for (const [id, success, content, placeholder] of blank) { + const value = { ...result(id, success), content }; + const waiting = bridge.invoke(call(id), new AbortController().signal); + bridge.submit(JSON.stringify(value)); + assert.equal((await waiting).isError, !success); + await assert.rejects(bridge.consume(native({ ...value, success: !success }, placeholder), "native"), /differs/); + await bridge.consume(native(value, placeholder), "native"); + } + assert.deepEqual(events.filter(event => event.type === "function_applied").map(event => event.call_id), blank.map(([id]) => id)); + bridge.assertComplete(); + + for (const [id, content, received] of [["trailing-empty", [text("x"), text("")], "x"], + ["trailing-newline", [text("boom\n")], "boom"], ["leading-space", [text(" boom")], "boom"], + ["leading-empty", [text(""), text("y")], "y"], ["empty-between", [text("x"), text(""), text("y")], "x\n\ny"]]) { + const value = { ...result(id, false), content }; + const waiting = bridge.invoke(call(id), new AbortController().signal); + bridge.submit(JSON.stringify(value)); + await waiting; + await bridge.consume(native(value, received), "native"); + } + assert.equal(events.at(-1).call_id, "empty-between"); + bridge.assertComplete(); + + for (const [id, success, received] of [["text", true, [{ type: "text", text: "43" }]], ["error", false, "43"]]) { + const value = { ...result(id, success), content: [text("42")] }; + const waiting = bridge.invoke(call(id), new AbortController().signal); + bridge.submit(JSON.stringify(value)); + await waiting; + await assert.rejects(bridge.consume(native(value, received), "native"), /differs/); + } + assert.throws(() => bridge.assertComplete(), /unconfirmed/); + bridge.close(); +}); + test("a Subagent call carries its child, is confirmed by the child's own result and is withdrawn when native abandons it unanswered", { timeout: 5000 }, async () => { const events = [], child = { native_id: "child", turn_id: "child-turn" }; const bridge = new FunctionBridge(async event => { events.push(event); });