diff --git a/AGENTS.md b/AGENTS.md index 30ac4c5..f428be0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -95,25 +95,56 @@ curl http://127.0.0.1:18000/v1/chat/completions \ # GitNexus — Code Intelligence -This project is indexed by GitNexus as **command-code-openai-bridge** (461 symbols, 1291 relationships, 29 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** (359 symbols, 1057 relationships, 31 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. -> Index stale? Run `node .gitnexus/run.cjs analyze` from the project root — it auto-selects an available runner. No `.gitnexus/run.cjs` yet? `npx gitnexus analyze` (npm 11 crash → `npm i -g gitnexus`; #1939). +> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. ## Always Do -- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user. -- **MUST run `detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows. For regression review, compare against the default branch: `detect_changes({scope: "compare", base_ref: "main"})`. +- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user. +- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows. - **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits. -- When exploring unfamiliar code, use `query({search_query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance. -- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `context({name: "symbolName"})`. -- For security review, `explain({target: "fileOrSymbol"})` lists taint findings (source→sink flows; needs `analyze --pdg`). +- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance. +- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`. + +## When Debugging + +1. `gitnexus_query({query: ""})` — find execution flows related to the issue +2. `gitnexus_context({name: ""})` — see all callers, callees, and process participation +3. `READ gitnexus://repo/command-code-openai-bridge/process/{processName}` — trace the full execution flow step by step +4. For regressions: `gitnexus_detect_changes({scope: "compare", base_ref: "main"})` — see what your branch changed + +## When Refactoring + +- **Renaming**: MUST use `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` first. Review the preview — graph edits are safe, text_search edits need manual review. Then run with `dry_run: false`. +- **Extracting/Splitting**: MUST run `gitnexus_context({name: "target"})` to see all incoming/outgoing refs, then `gitnexus_impact({target: "target", direction: "upstream"})` to find all external callers before moving code. +- After any refactor: run `gitnexus_detect_changes({scope: "all"})` to verify only expected files changed. ## Never Do -- NEVER edit a function, class, or method without first running `impact` on it. +- NEVER edit a function, class, or method without first running `gitnexus_impact` on it. - NEVER ignore HIGH or CRITICAL risk warnings from impact analysis. -- NEVER rename symbols with find-and-replace — use `rename` which understands the call graph. -- NEVER commit changes without running `detect_changes()` to check affected scope. +- NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph. +- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope. + +## Tools Quick Reference + +| Tool | When to use | Command | +|------|-------------|---------| +| `query` | Find code by concept | `gitnexus_query({query: "auth validation"})` | +| `context` | 360-degree view of one symbol | `gitnexus_context({name: "validateUser"})` | +| `impact` | Blast radius before editing | `gitnexus_impact({target: "X", direction: "upstream"})` | +| `detect_changes` | Pre-commit scope check | `gitnexus_detect_changes({scope: "staged"})` | +| `rename` | Safe multi-file rename | `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` | +| `cypher` | Custom graph queries | `gitnexus_cypher({query: "MATCH ..."})` | + +## Impact Risk Levels + +| Depth | Meaning | Action | +|-------|---------|--------| +| d=1 | WILL BREAK — direct callers/importers | MUST update these | +| d=2 | LIKELY AFFECTED — indirect deps | Should test | +| d=3 | MAY NEED TESTING — transitive | Test if critical path | ## Resources @@ -124,6 +155,32 @@ This project is indexed by GitNexus as **command-code-openai-bridge** (461 symbo | `gitnexus://repo/command-code-openai-bridge/processes` | All execution flows | | `gitnexus://repo/command-code-openai-bridge/process/{name}` | Step-by-step execution trace | +## Self-Check Before Finishing + +Before completing any code modification task, verify: +1. `gitnexus_impact` was run for all modified symbols +2. No HIGH/CRITICAL risk warnings were ignored +3. `gitnexus_detect_changes()` confirms changes match expected scope +4. All d=1 (WILL BREAK) dependents were updated + +## Keeping the Index Fresh + +After committing code changes, the GitNexus index becomes stale. Re-run analyze to update it: + +```bash +npx gitnexus analyze +``` + +If the index previously included embeddings, preserve them by adding `--embeddings`: + +```bash +npx gitnexus analyze --embeddings +``` + +To check whether embeddings exist, inspect `.gitnexus/meta.json` — the `stats.embeddings` field shows the count (0 means no embeddings). **Running analyze without `--embeddings` will delete any previously generated embeddings.** + +> Claude Code users: A PostToolUse hook handles this automatically after `git commit` and `git merge`. + ## CLI | Task | Read this skill file | diff --git a/README.md b/README.md index 3d8800d..57fe34e 100644 --- a/README.md +++ b/README.md @@ -210,7 +210,7 @@ Chat 接口接受标准 function tools、`tool_choice` 和 `parallel_tool_calls` `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: 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`。 +为兼容 Obsidian Copilot、OpenCode 和 LangChain OpenAI-format 客户端,Chat 接口还接受 `temperature`、`max_tokens`、`max_completion_tokens`、`top_p`、`frequency_penalty`、`presence_penalty`、`n: 1`、`store: false` 和 `reasoning_effort`。`n: 1` 是 Bridge 实际支持的单候选协议行为;`store: false` 保持每次请求使用 `--no-session` 的无状态行为,不创建服务端 Chat session。`reasoning_effort` 会覆盖模型配置中的 `effort` 并传给 Command Code 的 `--effort`,与 Responses 的 `reasoning.effort` 行为一致。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 和 structured output 模式会缓存 `finalText`,避免内部决策或未校验 JSON 进入客户端正文。`response_format` 只约束最终文本:需要调用工具的轮次先按 tools 协议返回,客户端提交工具结果后的最终文本轮次再执行格式校验。Responses 外部 function calling 同样缓存 `finalText`,工具轮次不向客户端输出 assistant 文本。 diff --git a/src/openai.ts b/src/openai.ts index 0822688..9127ba8 100644 --- a/src/openai.ts +++ b/src/openai.ts @@ -123,6 +123,10 @@ const chatResponseFormatSchema = z.discriminatedUnion("type", [ chatJsonSchemaResponseFormatSchema, ]); +export const reasoningEffortSchema = z.enum([ + "none", "minimal", "low", "medium", "high", "xhigh", "max", +]); + export const chatCompletionRequestSchema = z.object({ model: z.string().min(1), messages: z.array(messageSchema).min(1), @@ -142,6 +146,7 @@ export const chatCompletionRequestSchema = z.object({ parallel_tool_calls: z.boolean().optional(), response_format: chatResponseFormatSchema.optional().default({ type: "text" }), store: z.literal(false).optional(), + reasoning_effort: reasoningEffortSchema.optional(), }).strict().superRefine((request, context) => { const toolNames = new Set(); for (const [index, tool] of (request.tools ?? []).entries()) { @@ -214,6 +219,13 @@ export function ignoredChatCompatibilityParameters(request: ChatCompletionReques return ignoredCompatibilityParameters.filter((key) => request[key] !== undefined); } +export function chatEffectiveEffort( + request: ChatCompletionRequest, + defaultEffort: string, +): string { + return request.reasoning_effort ?? defaultEffort; +} + export function chatInputAttachments(request: ChatCompletionRequest): PendingAttachment[] { const attachments: PendingAttachment[] = []; for (const [messageIndex, message] of request.messages.entries()) { @@ -354,7 +366,8 @@ function buildToolCommandPrompt( "只返回以下两个 JSON 对象之一,禁止 Markdown 代码围栏、前后说明和额外字段:", "最终回答:{\"type\":\"final\",\"content\":\"面向用户的完整回答\"}", "调用工具:{\"type\":\"tool_calls\",\"calls\":[{\"name\":\"工具名称\",\"arguments\":{}}]}", - "调用工具时,name 必须与 tools 中的名称完全一致,arguments 必须是符合该工具 parameters JSON Schema 的对象。", + "调用工具时,name 必须与 tools 中的名称完全一致,arguments 必须是符合该工具 parameters JSON Schema 的对象;不要把 command、query、path 等参数放在 calls 顶层。", + "错误示例:{\"name\":\"bash\",\"command\":\"echo hi\"}。正确示例:{\"name\":\"bash\",\"arguments\":{\"command\":\"echo hi\"}}。", "tool_choice 为 none 时必须返回 final;为 required 或指定函数时必须返回 tool_calls。parallel_tool_calls 为 false 时 calls 只能有一项。", "需要工具提供信息或执行动作时返回 tool_calls 并立即结束;已有足够信息时返回 final。不要在工具调用轮次生成面向用户的正文。", "response_format 只约束 final.content;返回 tool_calls 时不应用正文格式校验。", @@ -373,6 +386,7 @@ export function buildToolDecisionRepairPrompt( return [ "上一次输出不符合外部工具调用传输协议。保持原来的决策意图,只修复 JSON 结构、工具名称或参数。", "只返回原任务要求的 final 或 tool_calls JSON 对象,禁止 Markdown 代码围栏、前后说明和额外字段。", + "若参数被误放在 calls 顶层,必须移入 arguments 对象,例如把 {\"name\":\"bash\",\"command\":\"...\"} 改为 {\"name\":\"bash\",\"arguments\":{\"command\":\"...\"}}。", "校验错误:", errors.join("\n"), "上一次输出:", diff --git a/src/responses.ts b/src/responses.ts index cc34fe3..6449b76 100644 --- a/src/responses.ts +++ b/src/responses.ts @@ -24,7 +24,7 @@ import { } from "./command-code.js"; import type { BridgeConfig } from "./config.js"; import { FinalTurnAccumulator } from "./final-turn.js"; -import { openAIError, type CommandUsage } from "./openai.js"; +import { openAIError, reasoningEffortSchema, type CommandUsage } from "./openai.js"; import type { RequestCoordinator } from "./request-coordinator.js"; import { parseFunctionToolDecision, @@ -184,7 +184,7 @@ export const responseRequestSchema = z.object({ previous_response_id: responseIdSchema.nullable().optional().default(null), metadata: metadataSchema.optional().default({}), reasoning: z.object({ - effort: z.enum(["none", "minimal", "low", "medium", "high", "xhigh", "max"]), + effort: reasoningEffortSchema, }).strict().optional(), text: z.object({ format: responseTextFormatSchema, @@ -1230,7 +1230,8 @@ function buildResponsesPrompt( "只返回以下两个 JSON 对象之一,禁止 Markdown 代码围栏、前后说明和额外字段:", "最终回答:{\"type\":\"final\",\"content\":\"面向用户的完整回答\"}", "调用工具:{\"type\":\"tool_calls\",\"calls\":[{\"name\":\"工具名称\",\"arguments\":{}}]}", - "调用工具时,name 必须与 tools 中的名称完全一致,arguments 必须是符合该工具 parameters JSON Schema 的对象。", + "调用工具时,name 必须与 tools 中的名称完全一致,arguments 必须是符合该工具 parameters JSON Schema 的对象;不要把 command、query、path 等参数放在 calls 顶层。", + "错误示例:{\"name\":\"bash\",\"command\":\"echo hi\"}。正确示例:{\"name\":\"bash\",\"arguments\":{\"command\":\"echo hi\"}}。", "tool_choice 为 none 时必须返回 final;为 required 或指定函数时必须返回 tool_calls。parallel_tool_calls 为 false 时 calls 只能有一项。", "需要工具提供信息或执行动作时返回 tool_calls 并立即结束;已有 function_call_output 且信息足够时返回 final。工具调用轮次禁止生成面向用户的正文。", formatDirective(request.text.format), @@ -1415,6 +1416,7 @@ function buildToolDecisionRepairPrompt( return [ "上一次输出不符合 Responses 外部工具调用传输协议。保持原来的决策意图,只修复 JSON 结构、工具名称或参数。", "只返回原任务要求的 final 或 tool_calls JSON 对象,禁止 Markdown 代码围栏、前后说明和额外字段。", + "若参数被误放在 calls 顶层,必须移入 arguments 对象,例如把 {\"name\":\"bash\",\"command\":\"...\"} 改为 {\"name\":\"bash\",\"arguments\":{\"command\":\"...\"}}。", "校验错误:", errors.join("\n"), "上一次输出:", diff --git a/src/server.ts b/src/server.ts index ecbd516..b7dd636 100644 --- a/src/server.ts +++ b/src/server.ts @@ -22,6 +22,7 @@ import { buildToolStructuredOutputRepairPrompt, chatResponseTextFormat, chatCompletionRequestSchema, + chatEffectiveEffort, chatInputAttachments, completionResponse, ignoredChatCompatibilityParameters, @@ -276,7 +277,7 @@ export async function createServer(config: BridgeConfig): Promise const execution = await executeChatTurn({ config, cliModel: model.cli_model, - effort: model.effort, + effort: chatEffectiveEffort(parsed, model.effort), request: parsed, signal: abortController.signal, deadline: Date.now() + config.timeout_seconds * 1000, diff --git a/src/tool-calling.ts b/src/tool-calling.ts index 3023e86..e3d99a3 100644 --- a/src/tool-calling.ts +++ b/src/tool-calling.ts @@ -139,25 +139,27 @@ function parseCallsDecision( errors.push(`calls.${index} must be an object`); continue; } - const callExtraKeys = Object.keys(rawCall).filter((key) => key !== "name" && key !== "arguments"); - if (callExtraKeys.length > 0) { - errors.push(`calls.${index} has unexpected fields: ${callExtraKeys.join(", ")}`); - } if (typeof rawCall.name !== "string") { errors.push(`calls.${index}.name must be a string`); continue; } const tool = tools.get(rawCall.name); + const normalizedCall = normalizeToolCallRecord(rawCall, tool); + const callExtraKeys = Object.keys(normalizedCall).filter((key) => key !== "name" && key !== "arguments"); + if (callExtraKeys.length > 0) { + errors.push(`calls.${index} has unexpected fields: ${callExtraKeys.join(", ")}`); + } + if (!tool) { errors.push(`calls.${index}.name references unknown tool '${rawCall.name}'`); continue; } - if (typeof toolChoice === "object" && rawCall.name !== toolChoice.name) { + if (typeof toolChoice === "object" && normalizedCall.name !== toolChoice.name) { errors.push(`calls.${index}.name must be forced tool '${toolChoice.name}'`); } - const args = normalizeArguments(rawCall.arguments, index, errors); + const args = normalizeArguments(normalizedCall.arguments, index, errors); if (args === undefined) continue; const validationErrors = validateFunctionToolArguments(tool, rawCall.name, args); @@ -167,7 +169,7 @@ function parseCallsDecision( } toolCalls.push({ callId: `call_${randomUUID().replaceAll("-", "")}`, - name: rawCall.name, + name: normalizedCall.name as string, arguments: JSON.stringify(args), }); } @@ -249,6 +251,42 @@ function normalizeArguments( return parsed; } +function normalizeToolCallRecord( + rawCall: Record, + tool: FunctionToolDefinition | undefined, +): Record { + if (!tool) return rawCall; + + const propertyNames = toolParameterPropertyNames(tool.parameters); + if (propertyNames.size === 0) return rawCall; + + const normalized = { ...rawCall }; + const existingArgs = isRecord(normalized.arguments) ? { ...normalized.arguments } : {}; + let moved = false; + + for (const key of Object.keys(normalized)) { + if (key === "name" || key === "arguments") continue; + if (!propertyNames.has(key)) continue; + if (!(key in existingArgs)) existingArgs[key] = normalized[key]; + delete normalized[key]; + moved = true; + } + + if (moved || normalized.arguments !== undefined) { + normalized.arguments = Object.keys(existingArgs).length > 0 + ? existingArgs + : normalized.arguments; + } + + return normalized; +} + +function toolParameterPropertyNames(parameters: Record): Set { + const properties = parameters.properties; + if (!isRecord(properties)) return new Set(); + return new Set(Object.keys(properties)); +} + function createValidator(schema: Record): ValidateFunction { return new Ajv({ allErrors: true, strict: false }).compile(sanitizeToolParametersSchema(schema)); }