From 3b9454c787d0a0b4b2b69e52c1e0c93668989c4d Mon Sep 17 00:00:00 2001 From: Sirius Date: Wed, 12 Aug 2026 15:01:43 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=AF=E6=8C=81responses?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 53 +++-- config.yaml | 2 +- src/responses.ts | 546 ++++++++++++++++++++++++++++++++++++++++---- src/tool-calling.ts | 150 +++++++++--- 4 files changed, 662 insertions(+), 89 deletions(-) diff --git a/README.md b/README.md index 6de1c03..9045875 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Command Code OpenAI Bridge -这是一个只绑定本机地址的 OpenAI 兼容转发服务。它实现 Chat Completions 文本与 function calling,并提供 Responses API 文本子集。每个生成请求启动一次独立的 Command Code headless agent,将完整消息历史通过 stdin 发送给 CLI,在当前终端显示运行过程,再把结构化事件和最终 `finalText` 转换成 OpenAI 风格的 JSON 或 SSE。 +这是一个只绑定本机地址的 OpenAI 兼容转发服务。它实现 Chat Completions 与 Responses API 的文本对话和外部 function calling。每个生成请求启动一次独立的 Command Code headless agent,将完整消息历史通过 stdin 发送给 CLI,在当前终端显示运行过程,再把结构化事件和最终 `finalText` 转换成 OpenAI 风格的 JSON 或 SSE。 默认地址:`http://127.0.0.1:18000/v1` @@ -162,7 +162,7 @@ Chat 接口接受标准 function tools、`tool_choice` 和 `parallel_tool_calls` 为兼容 Obsidian Copilot 和 LangChain OpenAI-format 客户端,Chat 接口还接受 `temperature`、`max_tokens`、`max_completion_tokens`、`top_p`、`frequency_penalty`、`presence_penalty` 和 `n: 1`。这些采样和输出限制参数会按 OpenAI 取值范围严格校验。Command Code CLI 1.12.0 没有对应的 headless 参数,因此 Bridge 不会把它们伪装成已生效:服务终端会输出警告,HTTP 响应带 `X-Command-Code-Ignored-Parameters`。其他未实现字段仍返回 `400 unsupported_parameter`。 -Chat Completions 和 Responses 共用 Command Code NDJSON 执行层。服务终端会在事件到达时立即显示状态、文本和 Command Code 自身的工具调用。普通文本 Chat 会把每个 `text_delta` 直接写入 SSE;外部 function calling 模式会缓存 `finalText`,避免内部 JSON 决策进入客户端正文。 +Chat Completions 和 Responses 共用 Command Code NDJSON 执行层。服务终端会在事件到达时立即显示状态、文本和 Command Code 自身的工具调用。普通文本 Chat 会把每个 `text_delta` 直接写入 SSE;外部 function calling 模式会缓存 `finalText`,避免内部 JSON 决策进入客户端正文。Responses 外部 function calling 同样缓存 `finalText`,工具轮次不向客户端输出 assistant 文本。 ### Responses @@ -177,32 +177,43 @@ Chat Completions 和 Responses 共用 Command Code NDJSON 执行层。服务终 - `metadata` - `reasoning.effort` - `text.format` +- `tools` +- `tool_choice` +- `parallel_tool_calls` -`input` 可以是字符串,也可以是 message item 数组。message 支持 `system`、`developer`、`user`、`assistant` 角色、字符串 content,以及 `input_text`、`output_text` part。 +`input` 可以是字符串,也可以是 item 数组。message 支持 `system`、`developer`、`user`、`assistant` 角色、字符串 content,以及 `input_text`、`output_text` part。工具轮次还支持 `function_call` 和 `function_call_output` item,分别携带 `call_id`、工具名、`arguments` 和工具执行结果 `output`。 -普通响应包含 `id`、`object`、时间、状态、错误、instructions、model、标准 assistant message output item、`output_text`、响应链 ID、effective reasoning effort、store、metadata、text format 和 usage。usage 只使用 Command Code 实际提供的 input/output token;没有 usage 时返回 `null`,不生成 token 明细。 +Responses 接受标准 function tools、`tool_choice` 和 `parallel_tool_calls`。Bridge 把工具定义、完整历史(含先前 response 的完整 output items)和工具结果交给 Command Code 决定下一步,校验返回的工具名与 arguments JSON Schema;第一次不合格时在原请求总截止时间内执行一次修复。工具由 API 客户端执行,Bridge 不执行客户端工具,也不会让 Command Code 用自身文件、终端、网络等工具替代外部 tools。该流程适用于 OpenCode 等使用 `@ai-sdk/openai` 或 OpenAI Node.js SDK 的 Responses 客户端。 -`stream: true` 会立即建立 SSE 连接并发送: +普通文本响应包含标准 assistant message output item 和 `output_text`。工具调用轮次返回 `function_call` output item,包含 `id`、`call_id`、`name`、`arguments` 和 `status`,`output_text` 为空。最终文本轮次在有足够 `function_call_output` 后返回 message。响应还包含 `id`、`object`、时间、状态、错误、instructions、model、响应链 ID、effective reasoning effort、store、metadata、text format、tools、tool_choice、parallel_tool_calls 和 usage。usage 只使用 Command Code 实际提供的 input/output token;没有 usage 时返回 `null`,不生成 token 明细。 -1. `response.created` -2. `response.in_progress` -3. `response.output_item.added` -4. `response.content_part.added` -5. 一个或多个 `response.output_text.delta` -6. `response.output_text.done` -7. `response.content_part.done` -8. `response.output_item.done` -9. `response.completed` 或 `response.incomplete` +`stream: true` 会立即建立 SSE 连接并发送 `response.created` 和 `response.in_progress`。文本响应随后发送: + +1. `response.output_item.added` +2. `response.content_part.added` +3. 一个或多个 `response.output_text.delta` +4. `response.output_text.done` +5. `response.content_part.done` +6. `response.output_item.done` +7. `response.completed` 或 `response.incomplete` + +工具调用轮次发送: + +1. `response.output_item.added` +2. `response.function_call_arguments.delta` +3. `response.function_call_arguments.done` +4. `response.output_item.done` +5. `response.completed` 所有事件都包含递增的 `sequence_number`。连接使用 `no-cache, no-transform`,收到客户端断开后会终止 Command Code 子进程组。 -Responses API 仍按 turn 缓冲文本,丢弃 `hadToolCalls: true` 的中间轮次,在 `hadToolCalls: false` 时按原 delta 边界发送。文本 delta 会延迟到最终 turn 边界。结构化输出还会延迟到 JSON 校验完成,避免把随后需要修复的无效 JSON 发给客户端。 +Responses API 仍按 turn 缓冲文本,丢弃 `hadToolCalls: true` 的中间轮次,在 `hadToolCalls: false` 时按原 delta 边界发送。文本 delta 会延迟到最终 turn 边界。外部 function calling 和结构化输出还会延迟到 JSON 校验完成,避免把随后需要修复的无效 JSON 或内部决策发给客户端。 ### 本地存储和响应链 `store` 默认是 `true`。完成、incomplete、failed 和 cancelled Response 会写入配置的本地目录;`store: false` 不写入当前 Response,因此它不能在后续作为 `previous_response_id` 使用。 -收到 `previous_response_id` 时,Bridge 从本地文件沿链向前读取每个 Response,按原顺序重放历史 input 和 assistant output,再追加当前 input。可以从任意仍存在的旧 Response 创建分支。删除某个祖先后,依赖该祖先的链会返回 404。Response ID 使用固定本地格式并在拼接文件路径前校验。 +收到 `previous_response_id` 时,Bridge 从本地文件沿链向前读取每个 Response,按原顺序重放历史 input 和完整 assistant output items(含 `function_call` 与 message,不仅重放 `output_text`),再追加当前 input。可以从任意仍存在的旧 Response 创建分支。删除某个祖先后,依赖该祖先的链会返回 404。Response ID 使用固定本地格式并在拼接文件路径前校验。 input items 查询返回标准 `{ object: "list", data, first_id, last_id, has_more }`,支持 `after`、`limit` 和 `order`。删除成功返回 `{ id, object: "response", deleted: true }`。 @@ -304,7 +315,7 @@ command-code \ ## 本机实际检查结果 -检查日期:Chat Completions 原有检查为 2026-08-04;Responses 新增检查为 2026-08-05;function calling 协议检查为 2026-08-06。没有编写测试用例。原有条目来自真实 CLI 和真实 HTTP/SDK 客户端;有限队列改动完成了类型检查、生产构建和协调器运行时冒烟检查,尚未重新运行真实 CLI 并发检查。function calling 完成了内部协议冒烟检查、Obsidian Copilot 当前依赖 `@langchain/openai 1.2.2` 的双轮 wire compatibility 检查,以及真实 Command Code 与 OpenAI Node.js SDK 的双轮 HTTP/SSE 调用;尚未在 Obsidian UI 中运行完整检查。 +检查日期:Chat Completions 原有检查为 2026-08-04;Responses 文本子集检查为 2026-08-05;Chat function calling 协议检查为 2026-08-06;Responses function calling 检查为 2026-08-12。没有编写测试用例。原有条目来自真实 CLI 和真实 HTTP/SDK 客户端;有限队列改动完成了类型检查、生产构建和协调器运行时冒烟检查,尚未重新运行真实 CLI 并发检查。Chat function calling 完成了内部协议冒烟检查、Obsidian Copilot 当前依赖 `@langchain/openai 1.2.2` 的双轮 wire compatibility 检查,以及真实 Command Code 与 OpenAI Node.js SDK 的双轮 HTTP/SSE 调用;尚未在 Obsidian UI 中运行完整检查。Responses function calling 完成了类型检查、生产构建、真实 HTTP 双轮检查、流式 function call SSE 检查和 OpenAI Node.js SDK 双轮检查。 - TypeScript 严格类型检查和生产构建通过。 - npm 生产依赖审计:0 个已知漏洞。 @@ -337,6 +348,10 @@ command-code \ - `input_image` 返回 400 `unsupported_parameter`,并包含准确的参数路径。 - 真实 `max_turns: 1` 工具请求返回 200 `incomplete/max_turns`;1 秒总超时返回 200 `incomplete/timeout`;主动取消返回 499 `cancelled`。 - 取消后紧接着的旧 Chat Completions 请求返回 `Chat恢复成功`,证明共享执行状态和错误恢复正常。 +- Responses 第一轮 `tool_choice: required` 返回 `function_call`,`output_text` 为空;第二轮提交 `function_call_output` 和 `previous_response_id` 后返回最终 message 文本。 +- Responses 流式 function call 发送 `output_item.added`、`function_call_arguments.delta/done`、`output_item.done` 和 `completed`,无 `output_text.delta`。 +- OpenAI Node.js SDK 的 Responses 双轮检查:第一轮返回 `multiply` function call,第二轮消费 `function_call_output` 后返回最终文本。 +- Responses 无效工具名、无效 arguments JSON、孤立 `function_call_output` 和 hosted tool 类型分别返回带 `param` 的 400 错误。 ## 已知限制 @@ -344,8 +359,8 @@ command-code \ - headless 无法在服务终端进行批准、拒绝、选项选择或文字回答;`ask_user_question` 不能由等待中的 HTTP 客户端处理。 - 终端事件渲染由本项目完成,格式接近日志,无法等同原始 TUI。 - 普通文本 Chat Completions 会实时转发 Command Code 文本 delta;外部 function calling 和 Responses 结构化输出需要等待 Bridge 校验完成。 -- Chat Completions 只实现 function tools,不实现图片、音频、文件输入、Computer Use 或托管工具。Responses 仍是文本子集,不实现 function calling、原生 reasoning item、加密 reasoning 或隐藏思维过程。 -- Chat function calling 是 Bridge 通过提示协议、JSON 解析、工具参数 Schema 校验和一次修复实现的兼容层;Command Code CLI 没有公开原生 function calling 输出接口,因此连续两次输出不合格时返回 HTTP 502 `invalid_tool_decision`。 +- Chat Completions 和 Responses 都只实现外部 function tools,不实现图片、音频、文件输入、Computer Use、web search 等 hosted tools 或 MCP hosted tool。非 function 工具类型返回 400 `unsupported_parameter`。Responses 不实现原生 reasoning item、加密 reasoning 或隐藏思维过程。 +- Chat 与 Responses 的外部 function calling 都是 Bridge 通过提示协议、JSON 解析、工具参数 Schema 校验和一次修复实现的兼容层;Command Code CLI 没有公开原生 function calling 输出接口,因此连续两次输出不合格时返回 HTTP 502 `invalid_tool_decision`。 - Responses 的本地文件存储只供本 Bridge 使用,没有跨进程锁、队列或多实例一致性保证。项目本身仍严格单并发。 - Responses Structured Outputs 是 Bridge 层约束,底层 Command Code 模型仍可能连续两次输出不合格 JSON;此时状态为 incomplete。 - `usage` 使用 Command Code 最终结果提供的真实 input/output token;Responses 缺失时返回 `null`,Chat Completions 为兼容旧行为返回 0。 diff --git a/config.yaml b/config.yaml index 98c8d2a..5b99acb 100644 --- a/config.yaml +++ b/config.yaml @@ -13,7 +13,7 @@ dangerously_skip_permissions: true models: command-default: - cli_model: gpt-5.6-luna + cli_model: deepseek/deepseek-v4-flash effort: max command-flash: cli_model: deepseek/deepseek-v4-flash diff --git a/src/responses.ts b/src/responses.ts index 206bfca..c8345c8 100644 --- a/src/responses.ts +++ b/src/responses.ts @@ -15,10 +15,23 @@ import type { BridgeConfig } from "./config.js"; import { FinalTurnAccumulator } from "./final-turn.js"; import { openAIError, type CommandUsage } from "./openai.js"; import type { RequestCoordinator } from "./request-coordinator.js"; +import { + parseFunctionToolDecision, + parseFunctionToolArguments, + ToolDecisionError, + validateFunctionToolArguments, + validateFunctionToolSchemas, + type FunctionToolCall, + type FunctionToolChoice, + type FunctionToolDecisionRequest, + type FunctionToolDefinition, +} from "./tool-calling.js"; const responseIdPattern = /^resp_[A-Za-z0-9_-]{1,128}$/; const responseIdSchema = z.string().regex(responseIdPattern, "Invalid response ID"); const roleSchema = z.enum(["system", "developer", "user", "assistant"]); +const functionNameSchema = z.string().min(1).max(64).regex(/^[A-Za-z0-9_-]+$/); +const responseItemIdSchema = z.string().min(1).max(128); const inputTextPartSchema = z.object({ type: z.literal("input_text"), @@ -39,6 +52,42 @@ const inputMessageSchema = z.object({ ]), }).strict(); +const inputFunctionCallSchema = z.object({ + id: responseItemIdSchema.optional(), + type: z.literal("function_call"), + call_id: responseItemIdSchema, + name: functionNameSchema, + arguments: z.string(), + status: z.enum(["in_progress", "completed", "incomplete"]).optional(), +}).strict(); + +const inputFunctionCallOutputSchema = z.object({ + id: responseItemIdSchema.optional(), + type: z.literal("function_call_output"), + call_id: responseItemIdSchema, + output: z.string(), + status: z.enum(["in_progress", "completed", "incomplete"]).optional(), +}).strict(); + +const responseInputItemSchema = z.union([ + inputMessageSchema, + inputFunctionCallSchema, + inputFunctionCallOutputSchema, +]); + +const responseFunctionToolSchema = z.object({ + type: z.literal("function"), + name: functionNameSchema, + description: z.string().nullable().optional(), + parameters: z.union([z.record(z.unknown()), z.null()]).optional(), + strict: z.boolean().nullable().optional(), +}).strict(); + +const responseToolChoiceSchema = z.union([ + z.enum(["none", "auto", "required"]), + z.object({ type: z.literal("function"), name: functionNameSchema }).strict(), +]); + const textFormatSchema = z.object({ type: z.literal("text"), }).strict(); @@ -74,7 +123,7 @@ const metadataSchema = z.record(z.string().max(512)).superRefine((metadata, cont export const responseRequestSchema = z.object({ model: z.string().min(1), - input: z.union([z.string(), z.array(inputMessageSchema).min(1)]), + input: z.union([z.string(), z.array(responseInputItemSchema).min(1)]), instructions: z.string().nullable().optional().default(null), stream: z.boolean().optional().default(false), store: z.boolean().optional().default(true), @@ -86,7 +135,38 @@ export const responseRequestSchema = z.object({ text: z.object({ format: responseTextFormatSchema, }).strict().optional().default({ format: { type: "text" } }), -}).strict(); + tools: z.array(responseFunctionToolSchema).min(1).max(128).optional(), + tool_choice: responseToolChoiceSchema.optional(), + parallel_tool_calls: z.boolean().optional(), +}).strict().superRefine((request, context) => { + const toolNames = new Set(); + for (const [index, tool] of (request.tools ?? []).entries()) { + if (toolNames.has(tool.name)) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["tools", index, "name"], + message: `Duplicate tool name '${tool.name}'`, + }); + } + toolNames.add(tool.name); + } + + if (request.tool_choice !== undefined && request.tools === undefined) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["tool_choice"], + message: "tool_choice requires tools", + }); + } + + if (typeof request.tool_choice === "object" && !toolNames.has(request.tool_choice.name)) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["tool_choice", "name"], + message: `Unknown forced tool '${request.tool_choice.name}'`, + }); + } +}); type ResponseRequest = z.infer; export type ResponseTextFormat = z.infer; @@ -98,13 +178,32 @@ interface ResponseInputPart { text: string; } -interface ResponseInputItem { +interface ResponseInputMessage { id: string; type: "message"; role: ResponseRole; content: ResponseInputPart[]; } +interface ResponseInputFunctionCall { + id: string; + type: "function_call"; + call_id: string; + name: string; + arguments: string; + status: "in_progress" | "completed" | "incomplete"; +} + +interface ResponseInputFunctionCallOutput { + id: string; + type: "function_call_output"; + call_id: string; + output: string; + status: "in_progress" | "completed" | "incomplete"; +} + +type ResponseInputItem = ResponseInputMessage | ResponseInputFunctionCall | ResponseInputFunctionCallOutput; + interface ResponseOutputText { type: "output_text"; text: string; @@ -119,6 +218,17 @@ interface ResponseOutputMessage { content: ResponseOutputText[]; } +interface ResponseOutputFunctionCall { + id: string; + type: "function_call"; + call_id: string; + name: string; + arguments: string; + status: "completed" | "incomplete"; +} + +type ResponseOutputItem = ResponseOutputMessage | ResponseOutputFunctionCall; + interface ResponseUsage { input_tokens: number; output_tokens: number; @@ -140,26 +250,34 @@ interface LocalResponse { incomplete_details: { reason: string } | null; instructions: string | null; model: string; - output: ResponseOutputMessage[]; + output: ResponseOutputItem[]; output_text: string; previous_response_id: string | null; reasoning: { effort: string | null; summary: null }; store: boolean; metadata: Record; text: { format: ResponseTextFormat }; + tools: ResponseFunctionTool[]; + tool_choice: ResponseToolChoice; + parallel_tool_calls: boolean; usage: ResponseUsage | null; } +interface ResponseFunctionTool { + type: "function"; + name: string; + description?: string | null; + parameters: Record; + strict: boolean | null; +} + +type ResponseToolChoice = "none" | "auto" | "required" | { type: "function"; name: string }; + interface StoredResponse { response: LocalResponse; input_items: ResponseInputItem[]; } -interface PromptMessage { - role: ResponseRole; - content: string; -} - interface StructuredValidation { validate: (text: string) => { valid: boolean; errors: string[] }; } @@ -174,6 +292,7 @@ interface ExecutionResult { chunks: string[]; usage?: CommandUsage; validationErrors?: string[]; + toolCalls?: FunctionToolCall[]; } class ResponseApiError extends Error { @@ -347,10 +466,17 @@ class ResponsesSseWriter { } finish(response: LocalResponse, terminalEvent: "response.completed" | "response.incomplete"): void { + if (response.output.some((item) => item.type === "function_call")) { + this.addToolCalls(response); + this.event(terminalEvent, { response }); + return; + } this.ensureFinalText(response.output_text); if (this.outputStarted) { const outputMessage = response.output[0]; - if (!outputMessage) throw new Error("Response output message is missing"); + if (!outputMessage || outputMessage.type !== "message") { + throw new Error("Response output message is missing"); + } const part = outputMessage.content[0]; if (!part) throw new Error("Response output text part is missing"); @@ -371,6 +497,27 @@ class ResponsesSseWriter { this.event(terminalEvent, { response }); } + private addToolCalls(response: LocalResponse): void { + for (const [outputIndex, item] of response.output.entries()) { + if (item.type !== "function_call") continue; + const addedItem = { ...item, arguments: "", status: "in_progress" as const }; + this.event("response.output_item.added", { output_index: outputIndex, item: addedItem }); + if (item.arguments !== "") { + this.event("response.function_call_arguments.delta", { + item_id: item.id, + output_index: outputIndex, + delta: item.arguments, + }); + } + this.event("response.function_call_arguments.done", { + item_id: item.id, + output_index: outputIndex, + arguments: item.arguments, + }); + this.event("response.output_item.done", { output_index: outputIndex, item }); + } + } + failed(response: LocalResponse): void { this.event("response.failed", { response }); } @@ -479,6 +626,7 @@ export async function registerResponseRoutes( try { parsed = parseResponseRequest(request.body); validation = createStructuredValidation(parsed.text.format); + validateResponseToolRequest(parsed); } catch (error) { return sendRouteError(reply, error); } @@ -535,8 +683,10 @@ export async function registerResponseRoutes( const chain = parsed.previous_response_id ? await store.loadChain(parsed.previous_response_id) : []; + validateResponseFunctionHistory(parsed, chain, inputItems); const prompt = buildResponsesPrompt(parsed, chain, inputItems); const deadline = Date.now() + config.timeout_seconds * 1000; + const toolRequest = responseFunctionToolDecisionRequest(parsed); const execution = await executeResponse({ config, @@ -544,6 +694,7 @@ export async function registerResponseRoutes( effort: effectiveEffort, prompt, format: parsed.text.format, + ...(toolRequest ? { toolRequest } : {}), signal: abortController.signal, deadline, ...(validation ? { validation } : {}), @@ -558,6 +709,7 @@ export async function registerResponseRoutes( effort: effectiveEffort, status: execution.validationErrors ? "incomplete" : "completed", finalText: execution.finalText, + ...(execution.toolCalls ? { toolCalls: execution.toolCalls } : {}), ...(execution.usage ? { usage: execution.usage } : {}), ...(execution.validationErrors ? { incompleteReason: "structured_output_validation_failed" } : {}), }); @@ -668,6 +820,7 @@ function findUnsupportedParameter(body: unknown): string | undefined { const supported = new Set([ "model", "input", "instructions", "stream", "store", "previous_response_id", "metadata", "reasoning", "text", + "tools", "tool_choice", "parallel_tool_calls", ]); const unknownTopLevel = Object.keys(body).find((key) => !supported.has(key)); if (unknownTopLevel) return unknownTopLevel; @@ -679,10 +832,27 @@ function findUnsupportedParameter(body: unknown): string | undefined { } } + if (Array.isArray(body.tools)) { + for (const [toolIndex, tool] of body.tools.entries()) { + if (!isRecord(tool)) continue; + if (tool.type !== "function") return `tools.${toolIndex}.type`; + } + } + + if (isRecord(body.tool_choice) && body.tool_choice.type !== "function") { + return "tool_choice.type"; + } + if (!Array.isArray(body.input)) return undefined; for (const [itemIndex, item] of body.input.entries()) { if (!isRecord(item)) continue; - if (item.type !== undefined && item.type !== "message") return `input.${itemIndex}.type`; + if ( + item.type !== undefined + && item.type !== "message" + && item.type !== "function_call" + && item.type !== "function_call_output" + ) return `input.${itemIndex}.type`; + if (item.type === "function_call" || item.type === "function_call_output") continue; if (!Array.isArray(item.content)) continue; for (const [partIndex, part] of item.content.entries()) { if (!isRecord(part)) continue; @@ -704,14 +874,35 @@ function normalizeInput(input: ResponseRequest["input"]): ResponseInputItem[] { }]; } - return input.map((message) => ({ - id: newMessageId(), - type: "message", - role: message.role, - content: typeof message.content === "string" - ? [{ type: message.role === "assistant" ? "output_text" : "input_text", text: message.content }] - : message.content.map((part) => ({ type: part.type, text: part.text })), - })); + return input.map((item) => { + if (item.type === "function_call") { + return { + id: item.id ?? newFunctionCallItemId(), + type: "function_call", + call_id: item.call_id, + name: item.name, + arguments: item.arguments, + status: item.status ?? "completed", + }; + } + if (item.type === "function_call_output") { + return { + id: item.id ?? newFunctionCallOutputItemId(), + type: "function_call_output", + call_id: item.call_id, + output: item.output, + status: item.status ?? "completed", + }; + } + return { + id: newMessageId(), + type: "message", + role: item.role, + content: typeof item.content === "string" + ? [{ type: item.role === "assistant" ? "output_text" : "input_text", text: item.content }] + : item.content.map((part) => ({ type: part.type, text: part.text })), + }; + }); } function buildResponsesPrompt( @@ -719,24 +910,44 @@ function buildResponsesPrompt( chain: StoredResponse[], currentInput: ResponseInputItem[], ): string { - const messages: PromptMessage[] = []; + const history: Array = []; for (const stored of chain) { - messages.push(...stored.input_items.map(inputItemToPromptMessage)); - if (stored.response.output_text !== "") { - messages.push({ role: "assistant", content: stored.response.output_text }); - } + history.push(...stored.input_items, ...stored.response.output); } - messages.push(...currentInput.map(inputItemToPromptMessage)); + history.push(...currentInput); const envelope = { - protocol: "openai-responses-text-history-v1", + protocol: usesResponseToolCalling(request, history) + ? "openai-responses-tools-history-v1" + : "openai-responses-text-history-v1", instructions: request.instructions, - messages, + input: history, + tools: normalizeResponseTools(request.tools), + tool_choice: effectiveResponseToolChoice(request), + parallel_tool_calls: request.parallel_tool_calls ?? true, }; + if (usesResponseToolCalling(request, history)) { + return [ + "下面 JSON 对象是外部客户端提交的一次 OpenAI Responses 工具调用任务,input 包含完整历史。", + "你只负责决定当前这一轮应该调用外部工具还是返回最终回答。外部工具由客户端执行,你不能模拟工具结果。", + "不要使用 Command Code 自身的文件、终端、搜索、网络或其他工具替代外部 tools;只依据 input、tools 和 function_call_output 作出决定。", + "严格执行 input 中的 system、developer、user 指令和当前 instructions,并结合 message、function_call、function_call_output 及其他已有 output item 理解完整历史。", + "只返回以下两个 JSON 对象之一,禁止 Markdown 代码围栏、前后说明和额外字段:", + "最终回答:{\"type\":\"final\",\"content\":\"面向用户的完整回答\"}", + "调用工具:{\"type\":\"tool_calls\",\"calls\":[{\"name\":\"工具名称\",\"arguments\":{}}]}", + "调用工具时,name 必须与 tools 中的名称完全一致,arguments 必须是符合该工具 parameters JSON Schema 的对象。", + "tool_choice 为 none 时必须返回 final;为 required 或指定函数时必须返回 tool_calls。parallel_tool_calls 为 false 时 calls 只能有一项。", + "需要工具提供信息或执行动作时返回 tool_calls 并立即结束;已有 function_call_output 且信息足够时返回 final。工具调用轮次禁止生成面向用户的正文。", + formatDirective(request.text.format), + "JSON 数据开始:", + JSON.stringify(envelope), + ].join("\n\n"); + } + return [ "下面 JSON 对象是外部客户端提交的完整 Responses 文本任务和本地重建的响应链。", - "按 system、developer、user、assistant 的角色与顺序理解上下文;当前 instructions 是本次请求的高优先级开发者指令。", + "按 input 中的 system、developer、user、assistant 角色与顺序理解上下文;当前 instructions 是本次请求的高优先级开发者指令。", "严格执行最后一个用户任务。不要复述 JSON,不要输出角色标签,不要暴露内部思考。", formatDirective(request.text.format), "JSON 数据开始:", @@ -780,12 +991,17 @@ async function executeResponse(options: { effort: string; prompt: string; format: ResponseTextFormat; + toolRequest?: FunctionToolDecisionRequest; validation?: StructuredValidation; signal: AbortSignal; deadline: number; writer?: ResponsesSseWriter; }): Promise { - const first = await executeAttempt(options, options.prompt, options.validation ? undefined : options.writer); + const constrained = options.validation !== undefined || options.toolRequest !== undefined; + const first = await executeAttempt(options, options.prompt, constrained ? undefined : options.writer); + if (options.toolRequest) { + return executeToolDecision({ ...options, toolRequest: options.toolRequest }, first); + } if (!options.validation) { return { finalText: first.result.finalText, @@ -821,6 +1037,91 @@ async function executeResponse(options: { }; } +async function executeToolDecision( + options: { + config: BridgeConfig; + cliModel: string; + effort: string; + prompt: string; + format: ResponseTextFormat; + toolRequest: FunctionToolDecisionRequest; + validation?: StructuredValidation; + signal: AbortSignal; + deadline: number; + }, + first: AttemptResult, +): Promise { + try { + return executionFromToolDecision( + parseFunctionToolDecision(first.result.finalText, options.toolRequest), + first, + ); + } catch (error) { + if (!(error instanceof ToolDecisionError)) throw error; + process.stderr.write(`\nResponses 工具调用决策校验失败,正在修复:${error.errors.join("; ")}\n`); + + const repairPrompt = buildToolDecisionRepairPrompt( + options.prompt, + first.result.finalText, + error.errors, + ); + const second = await executeAttempt(options, repairPrompt); + let decision; + try { + decision = parseFunctionToolDecision(second.result.finalText, options.toolRequest); + } catch (repairError) { + if (repairError instanceof ToolDecisionError) { + throw new ResponseApiError( + `Invalid tool decision after repair: ${repairError.errors.join("; ")}`, + 502, + "invalid_tool_decision", + "server_error", + ); + } + throw repairError; + } + const result = executionFromToolDecision(decision, second); + const usage = addUsage(first.result.usage, second.result.usage); + return { ...result, ...(usage ? { usage } : {}) }; + } +} + +function executionFromToolDecision( + decision: ReturnType, + attempt: AttemptResult, +): ExecutionResult { + if (decision.type === "tool_calls") { + return { + finalText: "", + chunks: [], + toolCalls: decision.toolCalls, + ...(attempt.result.usage ? { usage: attempt.result.usage } : {}), + }; + } + return { + finalText: decision.content, + chunks: [decision.content], + ...(attempt.result.usage ? { usage: attempt.result.usage } : {}), + }; +} + +function buildToolDecisionRepairPrompt( + originalPrompt: string, + invalidOutput: string, + errors: string[], +): string { + return [ + "上一次输出不符合 Responses 外部工具调用传输协议。保持原来的决策意图,只修复 JSON 结构、工具名称或参数。", + "只返回原任务要求的 final 或 tool_calls JSON 对象,禁止 Markdown 代码围栏、前后说明和额外字段。", + "校验错误:", + errors.join("\n"), + "上一次输出:", + invalidOutput, + "原始任务:", + originalPrompt, + ].join("\n\n"); +} + async function executeAttempt( options: { config: BridgeConfig; @@ -910,14 +1211,32 @@ function createResponse(options: { effort: string; status: ResponseStatus; finalText?: string; + toolCalls?: FunctionToolCall[]; usage?: CommandUsage; error?: ResponseError; incompleteReason?: string; }): LocalResponse { const finalText = options.finalText ?? ""; const terminal = options.status !== "in_progress"; - const hasOutput = options.finalText !== undefined; const outputStatus = options.status === "completed" ? "completed" : "incomplete"; + const output: ResponseOutputItem[] = options.toolCalls + ? options.toolCalls.map((call) => ({ + id: newFunctionCallItemId(), + type: "function_call", + call_id: call.callId, + name: call.name, + arguments: call.arguments, + status: outputStatus, + })) + : options.finalText !== undefined + ? [{ + id: options.messageId, + type: "message", + role: "assistant", + status: outputStatus, + content: [{ type: "output_text", text: finalText, annotations: [] }], + }] + : []; return { id: options.id, object: "response", @@ -928,19 +1247,16 @@ function createResponse(options: { incomplete_details: options.incompleteReason ? { reason: options.incompleteReason } : null, instructions: options.request.instructions, model: options.request.model, - output: hasOutput ? [{ - id: options.messageId, - type: "message", - role: "assistant", - status: outputStatus, - content: [{ type: "output_text", text: finalText, annotations: [] }], - }] : [], + output, output_text: finalText, previous_response_id: options.request.previous_response_id, reasoning: { effort: options.effort, summary: null }, store: options.request.store, metadata: options.request.metadata, text: { format: options.request.text.format }, + tools: normalizeResponseTools(options.request.tools), + tool_choice: effectiveResponseToolChoice(options.request), + parallel_tool_calls: options.request.parallel_tool_calls ?? true, usage: toResponseUsage(options.usage), }; } @@ -1115,8 +1431,152 @@ function responseNotFound(id: string): ResponseApiError { ); } -function inputItemToPromptMessage(item: ResponseInputItem): PromptMessage { - return { role: item.role, content: item.content.map((part) => part.text).join("") }; +function validateResponseToolRequest(request: ResponseRequest): void { + const errors = validateFunctionToolSchemas(responseFunctionTools(request)); + if (errors.length > 0) { + throw new ResponseApiError( + errors.join("; "), + 400, + "invalid_tool_schema", + "invalid_request_error", + "tools", + ); + } +} + +function validateResponseFunctionHistory( + request: ResponseRequest, + chain: StoredResponse[], + currentInput: ResponseInputItem[], +): void { + const tools = new Map(responseFunctionTools(request).map((tool) => [tool.name, tool])); + const callIds = new Set(); + const outputCallIds = new Set(); + const items: Array<{ item: ResponseInputItem | ResponseOutputItem; param: string }> = []; + + for (const stored of chain) { + for (const item of stored.input_items) items.push({ item, param: "previous_response_id" }); + for (const item of stored.response.output) items.push({ item, param: "previous_response_id" }); + } + for (const [index, item] of currentInput.entries()) items.push({ item, param: `input.${index}` }); + + for (const { item, param } of items) { + if (item.type === "function_call") { + if (callIds.has(item.call_id)) { + throw new ResponseApiError( + `Duplicate function call_id '${item.call_id}'`, + 400, + "invalid_function_call", + "invalid_request_error", + `${param}.call_id`, + ); + } + callIds.add(item.call_id); + const tool = tools.get(item.name); + if (!tool) { + throw new ResponseApiError( + `Function call references unknown tool '${item.name}'`, + 400, + "invalid_tool_name", + "invalid_request_error", + `${param}.name`, + ); + } + + let args: Record; + try { + args = parseFunctionToolArguments(item.arguments, `${param}.arguments`); + } catch (error) { + if (error instanceof ToolDecisionError) { + throw new ResponseApiError( + error.errors.join("; "), + 400, + "invalid_tool_arguments", + "invalid_request_error", + `${param}.arguments`, + ); + } + throw error; + } + const errors = validateFunctionToolArguments(tool, item.name, args); + if (errors.length > 0) { + throw new ResponseApiError( + errors.join("; "), + 400, + "invalid_tool_arguments", + "invalid_request_error", + `${param}.arguments`, + ); + } + continue; + } + + if (item.type === "function_call_output") { + if (!callIds.has(item.call_id)) { + throw new ResponseApiError( + `No preceding function_call found for call_id '${item.call_id}'`, + 400, + "invalid_function_call_output", + "invalid_request_error", + `${param}.call_id`, + ); + } + if (outputCallIds.has(item.call_id)) { + throw new ResponseApiError( + `Duplicate function_call_output for call_id '${item.call_id}'`, + 400, + "invalid_function_call_output", + "invalid_request_error", + `${param}.call_id`, + ); + } + outputCallIds.add(item.call_id); + } + } +} + +function responseFunctionTools(request: ResponseRequest): FunctionToolDefinition[] { + return normalizeResponseTools(request.tools).map((tool) => ({ + name: tool.name, + ...(tool.description !== undefined ? { description: tool.description } : {}), + parameters: tool.parameters, + strict: tool.strict, + })); +} + +function responseFunctionToolDecisionRequest( + request: ResponseRequest, +): FunctionToolDecisionRequest | undefined { + if (!request.tools) return undefined; + const toolChoice = effectiveResponseToolChoice(request); + return { + tools: responseFunctionTools(request), + toolChoice: typeof toolChoice === "string" ? toolChoice : { name: toolChoice.name }, + parallelToolCalls: request.parallel_tool_calls ?? true, + }; +} + +function normalizeResponseTools(tools: ResponseRequest["tools"]): ResponseFunctionTool[] { + return (tools ?? []).map((tool) => ({ + type: "function", + name: tool.name, + ...(tool.description !== undefined ? { description: tool.description } : {}), + parameters: tool.parameters ?? { type: "object", properties: {} }, + strict: tool.strict ?? null, + })); +} + +function effectiveResponseToolChoice(request: ResponseRequest): ResponseToolChoice { + return request.tool_choice ?? (request.tools ? "auto" : "none"); +} + +function usesResponseToolCalling( + request: ResponseRequest, + history: Array, +): boolean { + return request.tools !== undefined || history.some((item) => ( + item.type === "function_call" || item.type === "function_call_output" + )); } function toResponseUsage(usage?: CommandUsage): ResponseUsage | null { @@ -1155,6 +1615,14 @@ function newMessageId(): string { return `msg_local_${randomHexId()}`; } +function newFunctionCallItemId(): string { + return `fc_local_${randomHexId()}`; +} + +function newFunctionCallOutputItemId(): string { + return `fco_local_${randomHexId()}`; +} + function randomHexId(): string { return randomUUID().replaceAll("-", ""); } diff --git a/src/tool-calling.ts b/src/tool-calling.ts index eca62f1..cbbb4cb 100644 --- a/src/tool-calling.ts +++ b/src/tool-calling.ts @@ -9,6 +9,31 @@ export type ToolDecision = | { type: "final"; content: string } | { type: "tool_calls"; toolCalls: ChatCompletionToolCall[] }; +export interface FunctionToolDefinition { + name: string; + description?: string | null; + parameters: Record; + strict?: boolean | null; +} + +export type FunctionToolChoice = "none" | "auto" | "required" | { name: string }; + +export interface FunctionToolDecisionRequest { + tools?: FunctionToolDefinition[]; + toolChoice?: FunctionToolChoice; + parallelToolCalls?: boolean; +} + +export interface FunctionToolCall { + callId: string; + name: string; + arguments: string; +} + +export type FunctionToolDecision = + | { type: "final"; content: string } + | { type: "tool_calls"; toolCalls: FunctionToolCall[] }; + export class ToolDecisionError extends Error { constructor(readonly errors: string[]) { super(errors.join("; ")); @@ -17,12 +42,18 @@ export class ToolDecisionError extends Error { } export function validateToolSchemas(request: ChatCompletionRequest): string[] { + return validateFunctionToolSchemas(toFunctionTools(request)).map((error) => ( + error.replace(/^(tools\.\d+)\.parameters:/, "$1.function.parameters:") + )); +} + +export function validateFunctionToolSchemas(tools: FunctionToolDefinition[] | undefined): string[] { const errors: string[] = []; - for (const [index, tool] of (request.tools ?? []).entries()) { + for (const [index, tool] of (tools ?? []).entries()) { try { - createValidator(tool.function.parameters); + createValidator(tool.parameters); } catch (error) { - errors.push(`tools.${index}.function.parameters: ${formatUnknownError(error)}`); + errors.push(`tools.${index}.parameters: ${formatUnknownError(error)}`); } } return errors; @@ -32,6 +63,30 @@ export function parseToolDecision( output: string, request: ChatCompletionRequest, ): ToolDecision { + const tools = toFunctionTools(request); + const toolChoice = toFunctionToolChoice(request); + const decision = parseFunctionToolDecision(output, { + ...(tools ? { tools } : {}), + ...(toolChoice ? { toolChoice } : {}), + ...(request.parallel_tool_calls !== undefined + ? { parallelToolCalls: request.parallel_tool_calls } + : {}), + }); + if (decision.type === "final") return decision; + return { + type: "tool_calls", + toolCalls: decision.toolCalls.map((call) => ({ + id: call.callId, + type: "function", + function: { name: call.name, arguments: call.arguments }, + })), + }; +} + +export function parseFunctionToolDecision( + output: string, + request: FunctionToolDecisionRequest, +): FunctionToolDecision { const value = parseJsonValue(output); if (!isRecord(value)) { throw new ToolDecisionError(["Output must be a JSON object"]); @@ -44,8 +99,8 @@ export function parseToolDecision( function parseFinalDecision( value: Record, - request: ChatCompletionRequest, -): ToolDecision { + request: FunctionToolDecisionRequest, +): FunctionToolDecision { const errors: string[] = []; const extraKeys = Object.keys(value).filter((key) => key !== "type" && key !== "content"); if (extraKeys.length > 0) errors.push(`Unexpected final fields: ${extraKeys.join(", ")}`); @@ -61,8 +116,8 @@ function parseFinalDecision( function parseCallsDecision( value: Record, - request: ChatCompletionRequest, -): ToolDecision { + request: FunctionToolDecisionRequest, +): FunctionToolDecision { const errors: string[] = []; const extraKeys = Object.keys(value).filter((key) => key !== "type" && key !== "calls"); if (extraKeys.length > 0) errors.push(`Unexpected tool_calls fields: ${extraKeys.join(", ")}`); @@ -73,12 +128,12 @@ function parseCallsDecision( const toolChoice = effectiveToolChoice(request); if (toolChoice === "none") errors.push("tool_choice is none"); - if (request.parallel_tool_calls === false && value.calls.length > 1) { + if (request.parallelToolCalls === false && value.calls.length > 1) { errors.push("parallel_tool_calls is false, so calls may contain only one item"); } - const tools = new Map((request.tools ?? []).map((tool) => [tool.function.name, tool])); - const toolCalls: ChatCompletionToolCall[] = []; + const tools = new Map((request.tools ?? []).map((tool) => [tool.name, tool])); + const toolCalls: FunctionToolCall[] = []; for (const [index, rawCall] of value.calls.entries()) { if (!isRecord(rawCall)) { errors.push(`calls.${index} must be an object`); @@ -98,32 +153,22 @@ function parseCallsDecision( errors.push(`calls.${index}.name references unknown tool '${rawCall.name}'`); continue; } - if (typeof toolChoice === "object" && rawCall.name !== toolChoice.function.name) { - errors.push(`calls.${index}.name must be forced tool '${toolChoice.function.name}'`); + if (typeof toolChoice === "object" && rawCall.name !== toolChoice.name) { + errors.push(`calls.${index}.name must be forced tool '${toolChoice.name}'`); } const args = normalizeArguments(rawCall.arguments, index, errors); if (args === undefined) continue; - let validator: ValidateFunction; - try { - validator = createValidator(tool.function.parameters); - } catch (error) { - errors.push(`Invalid schema for tool '${rawCall.name}': ${formatUnknownError(error)}`); + const validationErrors = validateFunctionToolArguments(tool, rawCall.name, args); + if (validationErrors.length > 0) { + errors.push(...validationErrors); continue; } - if (!validator(args)) { - errors.push(...formatAjvErrors(rawCall.name, validator.errors)); - continue; - } - toolCalls.push({ - id: `call_${randomUUID().replaceAll("-", "")}`, - type: "function", - function: { - name: rawCall.name, - arguments: JSON.stringify(args), - }, + callId: `call_${randomUUID().replaceAll("-", "")}`, + name: rawCall.name, + arguments: JSON.stringify(args), }); } @@ -131,6 +176,36 @@ function parseCallsDecision( return { type: "tool_calls", toolCalls }; } +export function validateFunctionToolArguments( + tool: FunctionToolDefinition, + toolName: string, + argumentsValue: unknown, +): string[] { + let validator: ValidateFunction; + try { + validator = createValidator(tool.parameters); + } catch (error) { + return [`Invalid schema for tool '${toolName}': ${formatUnknownError(error)}`]; + } + return validator(argumentsValue) ? [] : formatAjvErrors(toolName, validator.errors); +} + +export function parseFunctionToolArguments( + value: string, + label: string, +): Record { + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch (error) { + throw new ToolDecisionError([`${label} is invalid JSON: ${formatUnknownError(error)}`]); + } + if (!isRecord(parsed)) { + throw new ToolDecisionError([`${label} must be a JSON object`]); + } + return parsed; +} + function parseJsonValue(output: string): unknown { const trimmed = output.trim(); const candidates = [trimmed]; @@ -178,8 +253,8 @@ function createValidator(schema: Record): ValidateFunction { return new Ajv({ allErrors: true, strict: false }).compile(schema); } -function effectiveToolChoice(request: ChatCompletionRequest) { - return request.tool_choice ?? (request.tools ? "auto" as const : "none" as const); +function effectiveToolChoice(request: FunctionToolDecisionRequest): FunctionToolChoice { + return request.toolChoice ?? ((request.tools?.length ?? 0) > 0 ? "auto" : "none"); } function formatAjvErrors(toolName: string, errors: ErrorObject[] | null | undefined): string[] { @@ -196,3 +271,18 @@ function isRecord(value: unknown): value is Record { function formatUnknownError(error: unknown): string { return error instanceof Error ? error.message : String(error); } + +function toFunctionTools(request: ChatCompletionRequest): FunctionToolDefinition[] | undefined { + return request.tools?.map((tool) => ({ + name: tool.function.name, + ...(tool.function.description ? { description: tool.function.description } : {}), + parameters: tool.function.parameters, + ...(tool.function.strict !== undefined ? { strict: tool.function.strict } : {}), + })); +} + +function toFunctionToolChoice(request: ChatCompletionRequest): FunctionToolChoice | undefined { + if (request.tool_choice === undefined) return undefined; + if (typeof request.tool_choice === "string") return request.tool_choice; + return { name: request.tool_choice.function.name }; +}