Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions contracts/agents-api/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
3 changes: 2 additions & 1 deletion contracts/agents-api/zh/index.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
title: "OpenAI 兼容性"
source: contracts/agents-api/index.md
source_hash: fd77d3617bd8a124382a8d7e8c95adb824fc0da73c31fce2ff475c920e3b0626
source_hash: 050403bd0008b17cbe601b3b7cbbe171df15fc1a046e4a30030ab5a19d02133a
---

Core 实现下方固定版本的 OpenAI Agents API。本台账列出各项资源的覆盖情况、相对于官方服务的所有差异以及已知缺口。
Expand Down Expand Up @@ -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 用量即可用;官方读取会滞后数秒。

Expand Down
2 changes: 1 addition & 1 deletion packages/claude-sdk-adapter/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
18 changes: 15 additions & 3 deletions packages/claude-sdk-adapter/src/function_bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.");
}
Expand Down Expand Up @@ -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");
}
46 changes: 46 additions & 0 deletions packages/claude-sdk-adapter/tests/function_bridge.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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); });
Expand Down