diff --git a/AGENTS.md b/AGENTS.md index ff93138..04f37c5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -92,7 +92,7 @@ curl http://127.0.0.1:18000/v1/chat/completions \ # GitNexus — Code Intelligence -This project is indexed by GitNexus as **command-code-openai-bridge** (320 symbols, 917 relationships, 27 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. +This project is indexed by GitNexus as **command-code-openai-bridge** (330 symbols, 941 relationships, 28 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. > If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. diff --git a/README.md b/README.md index 94eeada..2f80a5a 100644 --- a/README.md +++ b/README.md @@ -135,7 +135,7 @@ export OPENCODE_CONFIG="/path/to/command-code-openai-bridge/examples/opencode.js 3. OpenCode 仍需配置任意非空 API key(Bridge 忽略 Authorization,但客户端要求填写)。示例配置使用 `options.apiKey: "local-bridge"`。 4. 选择模型 `command-bridge/command-default`(与 `config.yaml` 中的 `models.command-default` 映射一致)。 -OpenCode 1.18.15 实际发送的 Chat 请求包含 `stream: true`、`stream_options.include_usage: true`、`max_tokens`(默认约 32000)、`store: false`、`tools`(约 24 个内置工具)、`tool_choice: auto`。Bridge 接受 `store` 与采样/输出限制参数但不改变无状态行为;未映射参数通过 `X-Command-Code-Ignored-Parameters` 标出。工具 `parameters` 中的 JSON Schema `$schema`(draft 2020-12)会在校验前剥离元数据字段,避免 AJV 拒绝 OpenCode 工具定义。 +OpenCode 1.18.15 实际发送的 Chat 请求包含 `stream: true`、`stream_options.include_usage: true`、`max_tokens`(默认约 32000)、`store: false`、`tools`(约 24 个内置工具)、`tool_choice: auto`。Bridge 接受 `store: false` 并保持 Chat 无状态;采样和输出限制参数虽然在协议层接受,但未映射参数会通过 `X-Command-Code-Ignored-Parameters` 标出。工具 `parameters` 中的 JSON Schema `$schema`(draft 2020-12)会在校验前剥离元数据字段,避免 AJV 拒绝 OpenCode 工具定义。 OpenCode 还会在会话标题生成、子 agent 和后台任务等场景发起独立请求。这些请求与主 agent 请求共用同一 provider;将 `max_concurrent_requests` 设为大于 `1` 可让它们与多个会话并行执行。 @@ -180,11 +180,11 @@ Bearer Token 会被忽略。所有生成接口只接受文本内容。图片、 Chat 接口接受标准 function tools、`tool_choice` 和 `parallel_tool_calls`。Bridge 把工具定义、完整消息历史和工具结果交给 Command Code 决定下一步,校验返回的工具名与 arguments JSON Schema;第一次不合格时在原请求总截止时间内执行一次修复。工具由 API 客户端执行,Bridge 不执行客户端工具,也不直接访问 Obsidian vault。该流程适用于 Obsidian Copilot 的 `localSearch`、`readNote`、`getFileTree`、`writeFile` 和 `editFile`,也适用于其他标准 function tools。协议流程参考 [OpenAI Function calling](https://developers.openai.com/api/docs/guides/function-calling)。 -`stream` 省略或设为 `false` 时返回普通 JSON。普通文本请求的 `stream: true` 会立即建立 SSE 连接并发送 assistant role 块,随后在 Command Code 的 `text_delta` 到达时立即发送对应 SSE chunk,最后发送 `finish_reason: "stop"`、可选 usage 块和 `[DONE]`。工具模式需要先解析和校验完整决策:最终文本会在校验后作为 content chunk 发送;工具调用会作为 `delta.tool_calls` 发送,并以 `finish_reason: "tool_calls"` 结束。`stream_thinking: true` 只作用于普通文本请求;该兼容格式不是原生 OpenAI reasoning item。传入 `stream_options.include_usage: true` 时,结束前返回 usage 块。 +`stream` 省略或设为 `false` 时返回普通 JSON。普通文本请求的 `stream: true` 会立即建立 SSE 连接并发送 assistant role 块,随后在 Command Code 的 `text_delta` 到达时立即发送对应 SSE chunk,最后发送 `finish_reason: "stop"`、可选 usage 块和 `[DONE]`。工具模式需要先解析和校验完整决策:最终文本会在校验后作为 content chunk 发送;工具调用会作为 `delta.tool_calls` 发送,并以 `finish_reason: "tool_calls"` 结束。Chat structured output 会缓存完整正文,只有 JSON 通过校验或单次修复后才发送 content;连续失败会发送 `invalid_structured_output` 错误和 `[DONE]`,不会先发出无法收回的无效 JSON。`stream_thinking: true` 只作用于非结构化普通文本请求;该兼容格式不是原生 OpenAI reasoning item。传入 `stream_options.include_usage: true` 时,结束前返回 usage 块。 -为兼容 Obsidian Copilot、OpenCode 和 LangChain OpenAI-format 客户端,Chat 接口还接受 `temperature`、`max_tokens`、`max_completion_tokens`、`top_p`、`frequency_penalty`、`presence_penalty`、`n: 1` 和 `store`。这些采样、输出限制与会话存储参数会按 OpenAI 取值范围严格校验。Command Code CLI 没有对应的 headless 参数,Chat Completions 也不保存跨请求会话;Bridge 不会把它们伪装成已生效:服务终端会输出警告,HTTP 响应带 `X-Command-Code-Ignored-Parameters`。其他未实现字段仍返回 `400 unsupported_parameter`。 +为兼容 Obsidian Copilot、OpenCode 和 LangChain OpenAI-format 客户端,Chat 接口还接受 `temperature`、`max_tokens`、`max_completion_tokens`、`top_p`、`frequency_penalty`、`presence_penalty`、`n: 1` 和 `store: false`。`n: 1` 是 Bridge 实际支持的单候选协议行为;`store: false` 保持每次请求使用 `--no-session` 的无状态行为,不创建服务端 Chat session。Command Code 1.18.0 官方 `--help` 没有 temperature、top-p、frequency/presence penalty 或输出 token 上限的 headless 参数,因此前六个采样/输出限制字段只在协议层接受和校验,不会传给 CLI,也不会用提示词伪造。只要请求显式提供其中任一字段,服务终端会输出警告,HTTP 响应的 `X-Command-Code-Ignored-Parameters` 会准确列出未实际应用的字段;浏览器可通过 CORS exposed header 读取。其他未实现字段仍返回 `400 unsupported_parameter`。 -Chat Completions 和 Responses 共用 Command Code NDJSON 执行层。服务终端会在事件到达时立即显示状态、文本和 Command Code 自身的工具调用。普通文本 Chat 会把每个 `text_delta` 直接写入 SSE;外部 function calling 模式会缓存 `finalText`,避免内部 JSON 决策进入客户端正文。Responses 外部 function calling 同样缓存 `finalText`,工具轮次不向客户端输出 assistant 文本。 +Chat Completions 和 Responses 共用 Command Code NDJSON 执行层。服务终端会在事件到达时立即显示状态、文本和 Command Code 自身的工具调用。普通文本 Chat 会把每个 `text_delta` 直接写入 SSE;外部 function calling 和 structured output 模式会缓存 `finalText`,避免内部决策或未校验 JSON 进入客户端正文。`response_format` 只约束最终文本:需要调用工具的轮次先按 tools 协议返回,客户端提交工具结果后的最终文本轮次再执行格式校验。Responses 外部 function calling 同样缓存 `finalText`,工具轮次不向客户端输出 assistant 文本。 ### Responses @@ -241,7 +241,30 @@ input items 查询返回标准 `{ object: "list", data, first_id, last_id, has_m ### 结构化输出 -支持: +Chat Completions 支持标准 `response_format`: + +```json +{ "response_format": { "type": "text" } } +``` + +```json +{ "response_format": { "type": "json_object" } } +``` + +```json +{ + "response_format": { + "type": "json_schema", + "json_schema": { + "name": "result", + "strict": true, + "schema": { "type": "object" } + } + } +} +``` + +Responses 支持对应的 `text.format`: ```json { "text": { "format": { "type": "text" } } } @@ -264,9 +287,9 @@ input items 查询返回标准 `{ object: "list", data, first_id, last_id, has_m } ``` -Bridge 把格式要求作为独立内部指令发送给 Command Code。最终文本必须能解析为 JSON;`json_object` 要求顶层对象,`json_schema` 使用 Ajv 校验。第一次失败后会在同一 AbortSignal 和原请求总截止时间内顺序执行一次修复,usage 是两次真实用量之和。第二次仍失败时返回 `status: "incomplete"` 和 `incomplete_details.reason: "structured_output_validation_failed"`。 +Bridge 把格式要求作为独立内部指令发送给 Command Code。最终文本必须能解析为 JSON;`json_object` 要求顶层对象,`json_schema` 使用 Ajv 校验。第一次失败后会在同一 AbortSignal 和原请求总截止时间内顺序执行一次修复,usage 是各次真实用量之和。Chat 第二次仍失败时,非流式请求返回 HTTP 502 `invalid_structured_output`,流式请求发送同 code 的 SSE 错误;Responses 则返回 `status: "incomplete"` 和 `incomplete_details.reason: "structured_output_validation_failed"`。 -这是 Bridge 层的提示、解析、校验和单次修复约束,不具备底层模型原生 Responses Structured Outputs 的解码级保证。 +这是 Bridge 层的提示、JSON 解析、Ajv 校验和单次修复约束,不是 Command Code 或底层模型提供的解码级原生 structured outputs。 官方协议依据: @@ -382,11 +405,11 @@ command-code \ - 没有原始 Command Code TUI、颜色布局、动画和键盘交互。 - headless 无法在服务终端进行批准、拒绝、选项选择或文字回答;`ask_user_question` 不能由等待中的 HTTP 客户端处理。 - 终端事件渲染由本项目完成,格式接近日志,无法等同原始 TUI。 -- 普通文本 Chat Completions 会实时转发 Command Code 文本 delta;外部 function calling 和 Responses 结构化输出需要等待 Bridge 校验完成。 +- 普通文本 Chat Completions 会实时转发 Command Code 文本 delta;外部 function calling 以及 Chat/Responses 结构化输出需要等待 Bridge 校验完成。 - 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 使用,没有跨进程锁或多实例一致性保证;并发请求应避免同时更新同一条 Response 链。 -- Responses Structured Outputs 是 Bridge 层约束,底层 Command Code 模型仍可能连续两次输出不合格 JSON;此时状态为 incomplete。 +- Chat 和 Responses Structured Outputs 都是 Bridge 层约束,底层 Command Code 模型仍可能连续两次输出不合格 JSON;Chat 此时返回 `invalid_structured_output`,Responses 状态为 incomplete。 - `usage` 使用 Command Code 最终结果提供的真实 input/output token;Responses 缺失时返回 `null`,Chat Completions 为兼容旧行为返回 0。 - 长文本受 HTTP 请求体上限和 Command Code 模型上下文上限共同限制,不会由桥接服务自行截断。 - v1.10.0 的大输入会让 `run_end.nextState` 重复完整提示词。实测约 96 KiB 中文输入时,CLI 退出前可能截断该大事件并丢掉紧随其后的 compact `result` 行。桥接服务会忽略冗余 `run_end`,优先使用 `result.finalText`;若退出码为 0 且 result 缺失,只使用最后一个已完整结束、无工具调用的结构化 turn 文本和真实 turn usage,不从 TUI 文本解析。 diff --git a/src/openai.ts b/src/openai.ts index 81ac089..ccbc3ee 100644 --- a/src/openai.ts +++ b/src/openai.ts @@ -80,6 +80,29 @@ const toolChoiceSchema = z.union([ }).strict(), ]); +const chatTextResponseFormatSchema = z.object({ + type: z.literal("text"), +}).strict(); + +const chatJsonObjectResponseFormatSchema = z.object({ + type: z.literal("json_object"), +}).strict(); + +const chatJsonSchemaResponseFormatSchema = z.object({ + type: z.literal("json_schema"), + json_schema: z.object({ + name: z.string().min(1).max(64), + strict: z.boolean().optional(), + schema: z.union([z.record(z.unknown()), z.boolean()]), + }).strict(), +}).strict(); + +const chatResponseFormatSchema = z.discriminatedUnion("type", [ + chatTextResponseFormatSchema, + chatJsonObjectResponseFormatSchema, + chatJsonSchemaResponseFormatSchema, +]); + export const chatCompletionRequestSchema = z.object({ model: z.string().min(1), messages: z.array(messageSchema).min(1), @@ -97,7 +120,8 @@ export const chatCompletionRequestSchema = z.object({ tools: z.array(functionToolSchema).min(1).max(128).optional(), tool_choice: toolChoiceSchema.optional(), parallel_tool_calls: z.boolean().optional(), - store: z.boolean().optional().default(true), + response_format: chatResponseFormatSchema.optional().default({ type: "text" }), + store: z.literal(false).optional(), }).strict().superRefine((request, context) => { const toolNames = new Set(); for (const [index, tool] of (request.tools ?? []).entries()) { @@ -133,6 +157,16 @@ export const chatCompletionRequestSchema = z.object({ export type ChatCompletionRequest = z.infer; +export type ChatResponseTextFormat = + | { type: "text" } + | { type: "json_object" } + | { + type: "json_schema"; + name: string; + strict: boolean; + schema: Record | boolean; + }; + export interface CommandUsage { inputTokens?: number; outputTokens?: number; @@ -154,7 +188,6 @@ const ignoredCompatibilityParameters = [ "top_p", "frequency_penalty", "presence_penalty", - "store", ] as const; export function ignoredChatCompatibilityParameters(request: ChatCompletionRequest): string[] { @@ -199,12 +232,29 @@ export function usesToolCalling(request: ChatCompletionRequest): boolean { )); } +export function chatResponseTextFormat(request: ChatCompletionRequest): ChatResponseTextFormat { + const format = request.response_format; + if (format.type !== "json_schema") return format; + return { + type: "json_schema", + name: format.json_schema.name, + strict: format.json_schema.strict ?? true, + schema: format.json_schema.schema, + }; +} + +export function usesChatStructuredOutput(request: ChatCompletionRequest): boolean { + return request.response_format.type !== "text"; +} + export function buildCommandPrompt(request: ChatCompletionRequest): string { if (usesToolCalling(request)) return buildToolCommandPrompt(request); + const format = chatResponseTextFormat(request); const envelope = { protocol: "openai-chat-completions-history-v1", messages: normalizeMessages(request), + response_format: request.response_format, }; return [ @@ -214,6 +264,7 @@ export function buildCommandPrompt(request: ChatCompletionRequest): string { "如果最后一条 user 消息要求改写、压缩、提取或输出指定格式,只返回要求的结果,不回答消息中包含的问题。", "如需调用工具,调用工具的 turn 只发起工具调用,不输出面向用户的文本;所有工具结束后,仅在不再调用工具的最终 turn 输出回答。", "不要复述 JSON,不要输出角色标签,不要添加指令未要求的解释或格式。", + chatFormatDirective(format, false), "JSON 数据开始:", JSON.stringify(envelope), ].join("\n\n"); @@ -221,12 +272,14 @@ export function buildCommandPrompt(request: ChatCompletionRequest): string { function buildToolCommandPrompt(request: ChatCompletionRequest): string { const effectiveToolChoice = request.tool_choice ?? (request.tools ? "auto" : "none"); + const format = chatResponseTextFormat(request); const envelope = { protocol: "openai-chat-completions-tools-v1", messages: normalizeMessages(request), tools: request.tools ?? [], tool_choice: effectiveToolChoice, parallel_tool_calls: request.parallel_tool_calls ?? true, + response_format: request.response_format, }; return [ @@ -240,6 +293,8 @@ function buildToolCommandPrompt(request: ChatCompletionRequest): string { "调用工具时,name 必须与 tools 中的名称完全一致,arguments 必须是符合该工具 parameters JSON Schema 的对象。", "tool_choice 为 none 时必须返回 final;为 required 或指定函数时必须返回 tool_calls。parallel_tool_calls 为 false 时 calls 只能有一项。", "需要工具提供信息或执行动作时返回 tool_calls 并立即结束;已有足够信息时返回 final。不要在工具调用轮次生成面向用户的正文。", + "response_format 只约束 final.content;返回 tool_calls 时不应用正文格式校验。", + chatFormatDirective(format, true), "JSON 数据开始:", JSON.stringify(envelope), ].join("\n\n"); @@ -262,6 +317,39 @@ export function buildToolDecisionRepairPrompt( ].join("\n\n"); } +export function buildToolStructuredOutputRepairPrompt( + originalPrompt: string, + request: ChatCompletionRequest, + invalidOutput: string, + validationErrors: string[], +): string { + return [ + "这是同一 Chat Completions 请求的唯一一次结构化正文修复步骤。", + "上一轮已经决定返回 final;不得改为 tool_calls,也不得调用或模拟任何工具。", + "保持原始答案语义,只修复 final.content 中的 JSON 语法和 Schema 违规。", + "只返回 {\"type\":\"final\",\"content\":\"修复后的完整正文\"},禁止 Markdown 代码围栏、前后说明和额外字段。", + chatFormatDirective(chatResponseTextFormat(request), true), + "校验错误:", + JSON.stringify(validationErrors), + "不合格的 final.content:", + JSON.stringify(invalidOutput), + "原始任务:", + originalPrompt, + ].join("\n\n"); +} + +function chatFormatDirective(format: ChatResponseTextFormat, toolMode: boolean): string { + const target = toolMode ? "final.content 字符串的内容" : "最终输出"; + if (format.type === "text") return `内部输出格式要求:${target}为纯文本。`; + if (format.type === "json_object") { + return `内部输出格式要求:${target}必须且只能是一个合法 JSON 对象;禁止 Markdown 代码围栏、前后说明和非 JSON 文本。`; + } + return [ + `内部输出格式要求:${target}必须且只能是一个符合 JSON Schema '${format.name}' 的合法 JSON 值;禁止 Markdown 代码围栏、前后说明和非 JSON 文本。`, + `JSON Schema:${JSON.stringify(format.schema)}`, + ].join("\n"); +} + export function completionResponse(model: string, content: string, usage?: CommandUsage) { const promptTokens = usage?.inputTokens ?? 0; const completionTokens = usage?.outputTokens ?? 0; diff --git a/src/responses.ts b/src/responses.ts index 2a04ee0..8c1903a 100644 --- a/src/responses.ts +++ b/src/responses.ts @@ -291,7 +291,7 @@ interface StoredResponse { input_items: ResponseInputItem[]; } -interface StructuredValidation { +export interface StructuredValidation { validate: (text: string) => { valid: boolean; errors: string[] }; } @@ -1107,14 +1107,14 @@ function buildResponsesPrompt( ].join("\n\n"); } -function buildRepairPrompt( +export function buildRepairPrompt( originalPrompt: string, format: ResponseTextFormat, invalidOutput: string, validationErrors: string[], ): string { return [ - "这是同一 Responses 请求的唯一一次结构化输出修复步骤。", + "这是同一请求的唯一一次结构化输出修复步骤。", "保留原始答案的语义,只修复 JSON 语法和 Schema 违规;只返回修复后的 JSON,禁止代码围栏和解释。", formatDirective(format), "校验错误:", @@ -1320,7 +1320,7 @@ async function executeAttempt( }; } -function createStructuredValidation(format: ResponseTextFormat): StructuredValidation | undefined { +export function createStructuredValidation(format: ResponseTextFormat): StructuredValidation | undefined { if (format.type === "text") return undefined; if (format.type === "json_object") { return { @@ -1761,7 +1761,7 @@ function toResponseUsage(usage?: CommandUsage): ResponseUsage | null { }; } -function addUsage(left?: CommandUsage, right?: CommandUsage): CommandUsage | undefined { +export function addUsage(left?: CommandUsage, right?: CommandUsage): CommandUsage | undefined { if (!left && !right) return undefined; return { inputTokens: (left?.inputTokens ?? 0) + (right?.inputTokens ?? 0), diff --git a/src/server.ts b/src/server.ts index 9c460f0..edf870d 100644 --- a/src/server.ts +++ b/src/server.ts @@ -12,17 +12,26 @@ import { import { buildCommandPrompt, buildToolDecisionRepairPrompt, + buildToolStructuredOutputRepairPrompt, + chatResponseTextFormat, chatCompletionRequestSchema, completionResponse, ignoredChatCompatibilityParameters, openAIError, toolCallsCompletionResponse, + usesChatStructuredOutput, usesToolCalling, type ChatCompletionRequest, - type CommandUsage, } from "./openai.js"; import { RequestCoordinator } from "./request-coordinator.js"; -import { registerResponseRoutes } from "./responses.js"; +import { + addUsage, + buildRepairPrompt, + createStructuredValidation, + registerResponseRoutes, + type ResponseTextFormat, + type StructuredValidation, +} from "./responses.js"; import { parseToolDecision, ToolDecisionError, @@ -58,6 +67,7 @@ export async function createServer(config: BridgeConfig): Promise ); reply.raw.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS"); reply.raw.setHeader("Access-Control-Max-Age", "600"); + reply.raw.setHeader("Access-Control-Expose-Headers", "X-Command-Code-Ignored-Parameters"); reply.raw.setHeader( "Vary", "Origin, Access-Control-Request-Method, Access-Control-Request-Headers, Access-Control-Request-Private-Network", @@ -126,7 +136,7 @@ export async function createServer(config: BridgeConfig): Promise )); } - let parsed; + let parsed: ChatCompletionRequest; try { parsed = chatCompletionRequestSchema.parse(request.body); } catch (error) { @@ -149,6 +159,20 @@ export async function createServer(config: BridgeConfig): Promise return reply.status(400).send(openAIError(message, "invalid_request")); } + const responseFormat: ResponseTextFormat = chatResponseTextFormat(parsed); + let structuredValidation: StructuredValidation | undefined; + try { + structuredValidation = createStructuredValidation(responseFormat); + } catch (error) { + const message = error instanceof Error ? error.message : "Invalid JSON Schema"; + return reply.status(400).send(openAIError( + message, + "invalid_json_schema", + "invalid_request_error", + "response_format.json_schema.schema", + )); + } + const model = config.models[parsed.model]; if (!model) { return reply.status(404).send(openAIError( @@ -196,7 +220,13 @@ export async function createServer(config: BridgeConfig): Promise let writer: ChatCompletionSseWriter | undefined; try { - if (parsed.stream) writer = startChatCompletionSse(reply, parsed.model, config.stream_thinking); + if (parsed.stream) { + writer = startChatCompletionSse( + reply, + parsed.model, + config.stream_thinking && !usesChatStructuredOutput(parsed), + ); + } if (request.raw.aborted || reply.raw.destroyed) cancelOnDisconnect(); acquired = await lease.ready; if (!acquired) { @@ -212,6 +242,8 @@ export async function createServer(config: BridgeConfig): Promise signal: abortController.signal, deadline: Date.now() + config.timeout_seconds * 1000, toolMode, + format: responseFormat, + ...(structuredValidation ? { validation: structuredValidation } : {}), ...(writer ? { writer } : {}), }); const { result, decision } = execution; @@ -281,6 +313,21 @@ export async function createServer(config: BridgeConfig): Promise )); } + if (error instanceof ChatStructuredOutputError) { + process.stderr.write(`\nChat 结构化输出连续两次校验失败:${error.errors.join("; ")}\n`); + const payload = openAIError( + "Command Code returned invalid structured output after one repair attempt", + "invalid_structured_output", + "server_error", + "response_format", + ); + if (writer) { + writer.error(payload); + return reply; + } + return reply.status(502).send(payload); + } + const code = error instanceof CommandCodeError ? error.exitCode : undefined; const status = code === 5 ? 429 : code === 10 ? 402 : 502; const errorCode = code === 5 @@ -323,7 +370,7 @@ interface ChatExecution { decision?: ToolDecision; } -async function executeChatTurn(options: { +interface ChatTurnOptions { config: BridgeConfig; cliModel: string; effort: string; @@ -331,9 +378,21 @@ async function executeChatTurn(options: { signal: AbortSignal; deadline: number; toolMode: boolean; + format: ResponseTextFormat; + validation?: StructuredValidation; writer?: ChatCompletionSseWriter; -}): Promise { +} + +class ChatStructuredOutputError extends Error { + constructor(readonly errors: string[]) { + super(errors.join("; ")); + this.name = "ChatStructuredOutputError"; + } +} + +async function executeChatTurn(options: ChatTurnOptions): Promise { const originalPrompt = buildCommandPrompt(options.request); + const constrained = options.toolMode || options.validation !== undefined; const first = await runCommandCode( options.config, options.cliModel, @@ -342,15 +401,38 @@ async function executeChatTurn(options: { options.signal, { timeoutMs: options.deadline - Date.now(), - ...(!options.toolMode && options.writer + ...(!constrained && options.writer ? { onEvent: (event: Record) => options.writer?.commandEvent(event) } : {}), }, ); - if (!options.toolMode) return { result: first }; + if (!options.toolMode) { + if (!options.validation) return { result: first }; + const firstValidation = options.validation.validate(first.finalText); + if (firstValidation.valid) return { result: first }; + process.stderr.write(`\nChat 结构化输出校验失败,正在修复:${firstValidation.errors.join("; ")}\n`); + const second = await runCommandCode( + options.config, + options.cliModel, + options.effort, + buildRepairPrompt( + originalPrompt, + options.format, + first.finalText, + firstValidation.errors, + ), + options.signal, + { timeoutMs: options.deadline - Date.now() }, + ); + const secondValidation = options.validation.validate(second.finalText); + if (!secondValidation.valid) throw new ChatStructuredOutputError(secondValidation.errors); + return { result: combineCommandResults(first, second) }; + } + + let execution: ChatExecution; try { - return { + execution = { result: first, decision: parseToolDecision(first.finalText, options.request), }; @@ -371,23 +453,56 @@ async function executeChatTurn(options: { options.signal, { timeoutMs: options.deadline - Date.now() }, ); - const usage = addUsage(first.usage, second.usage); - const result: CommandResult = { - finalText: second.finalText, - durationMs: first.durationMs + second.durationMs, - ...(usage ? { usage } : {}), - }; - return { - result, + execution = { + result: combineCommandResults(first, second), decision: parseToolDecision(second.finalText, options.request), }; } + + if (execution.decision?.type !== "final" || !options.validation) return execution; + const firstValidation = options.validation.validate(execution.decision.content); + if (firstValidation.valid) return execution; + + process.stderr.write(`\nChat 最终工具正文结构化校验失败,正在修复:${firstValidation.errors.join("; ")}\n`); + const repair = await runCommandCode( + options.config, + options.cliModel, + options.effort, + buildToolStructuredOutputRepairPrompt( + originalPrompt, + options.request, + execution.decision.content, + firstValidation.errors, + ), + options.signal, + { timeoutMs: options.deadline - Date.now() }, + ); + const result = combineCommandResults(execution.result, repair); + let decision: ToolDecision; + try { + decision = parseToolDecision(repair.finalText, options.request); + } catch (error) { + if (error instanceof ToolDecisionError) { + throw new ChatStructuredOutputError([ + "Structured output repair did not return a valid final decision", + ...error.errors, + ]); + } + throw error; + } + if (decision.type !== "final") { + throw new ChatStructuredOutputError(["Structured output repair returned tool_calls instead of final"]); + } + const repairedValidation = options.validation.validate(decision.content); + if (!repairedValidation.valid) throw new ChatStructuredOutputError(repairedValidation.errors); + return { result, decision }; } -function addUsage(left?: CommandUsage, right?: CommandUsage): CommandUsage | undefined { - if (!left && !right) return undefined; +function combineCommandResults(left: CommandResult, right: CommandResult): CommandResult { + const usage = addUsage(left.usage, right.usage); return { - inputTokens: (left?.inputTokens ?? 0) + (right?.inputTokens ?? 0), - outputTokens: (left?.outputTokens ?? 0) + (right?.outputTokens ?? 0), + finalText: right.finalText, + durationMs: left.durationMs + right.durationMs, + ...(usage ? { usage } : {}), }; }