From 384a32afe22b713fc7170e331a89b6a3558abb51 Mon Sep 17 00:00:00 2001 From: yzl Date: Mon, 16 Feb 2026 14:12:44 +0800 Subject: [PATCH] fix: enforce responses strict tool schema and resilient python runner --- README.md | 2 +- docs/PROVIDERS.md | 2 +- .../byok/core/augment-chat/shared/tools.js | 16 +----- test/provider-augment-chat.test.js | 2 +- tools/build/build-vsix.js | 4 +- tools/lib/run.js | 56 ++++++++++++++++++- tools/lib/upstream-vsix.js | 5 +- 7 files changed, 64 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 4366b76..1f7965e 100644 --- a/README.md +++ b/README.md @@ -432,7 +432,7 @@ - [x] chat-stream:解析 responses SSE 并输出 Augment chunks(RAW_RESPONSE/THINKING/TOOL_USE/TOKEN_USAGE/final) - [x] `status=incomplete` + `incomplete_details.reason`:映射为 Augment stop_reason(`max_output_tokens`→MAX_TOKENS;`content_filter`→SAFETY;其余→UNSPECIFIED) - [x] 结束兜底:`response.completed`/final JSON 到来时补齐未完整输出的尾部文本(兼容部分网关缺失 done 事件) -- [x] 工具 schema 严格化:补齐 `additionalProperties=false`;`required` 若缺省则兜底为全 required,若已提供则保留原值(Responses 对 schema 更严格) +- [x] 工具 schema 严格化:补齐 `additionalProperties=false`;对象 schema 的 `required` 强制覆盖全部 `properties`(Responses 对 schema 更严格) #### 8.4 `anthropic`(Anthropic Messages API 兼容) diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md index 51c2139..0512433 100644 --- a/docs/PROVIDERS.md +++ b/docs/PROVIDERS.md @@ -43,7 +43,7 @@ BYOK 接受上游 Augment 的多种字段命名,最终会归一到 `normalizeA - SSE:聚合 `response.output_item.*` + `response.function_call_arguments.*`(兼容缺失 added/done 的变体) - JSON:从 `response.output[]` 提取 `function_call` - **并行工具**:同上(注入 `parallel_tool_calls=false`;并兼容 `parallelToolCalls`) -- **tools schema**:使用 strict JSON schema(补齐 `additionalProperties=false`;保留原 schema 的 `required`) +- **tools schema**:使用 strict JSON schema(补齐 `additionalProperties=false`;对象 schema 的 `required` 强制覆盖全部 `properties`) - **stop_reason**:解析 `status=incomplete` + `incomplete_details.reason`(`max_output_tokens/content_filter`)映射到 Augment - **非流式兜底**:部分网关即使 `stream=false` 也只支持 SSE,会自动做一次 stream fallback 拼接文本 diff --git a/payload/extension/out/byok/core/augment-chat/shared/tools.js b/payload/extension/out/byok/core/augment-chat/shared/tools.js index 78ad3bd..5d9f92f 100644 --- a/payload/extension/out/byok/core/augment-chat/shared/tools.js +++ b/payload/extension/out/byok/core/augment-chat/shared/tools.js @@ -49,20 +49,8 @@ function coerceOpenAiStrictJsonSchema(schema, depth) { out.additionalProperties = false; const props = out.properties && typeof out.properties === "object" && !Array.isArray(out.properties) ? out.properties : {}; out.properties = props; - if (Array.isArray(out.required)) { - const cleaned = []; - for (const k of out.required) { - const key = normalizeString(k); - if (!key) continue; - if (!Object.prototype.hasOwnProperty.call(props, key)) continue; - if (cleaned.includes(key)) continue; - cleaned.push(key); - } - out.required = cleaned; - } else { - // 兼容:当 schema 未给 required 时,延续旧行为(默认全 required)。 - out.required = Object.keys(props); - } + // OpenAI Responses strict schema: required 必须覆盖全部 properties。 + out.required = Object.keys(props); } if (out.properties && typeof out.properties === "object" && !Array.isArray(out.properties)) { diff --git a/test/provider-augment-chat.test.js b/test/provider-augment-chat.test.js index 683e3f1..e9ca1d5 100644 --- a/test/provider-augment-chat.test.js +++ b/test/provider-augment-chat.test.js @@ -38,7 +38,7 @@ test("provider-augment-chat: convertToolDefinitionsByProviderType returns provid assert.equal(responses[0].parameters.additionalProperties, false); assert.ok(Array.isArray(responses[0].parameters.required)); assert.ok(responses[0].parameters.required.includes("text")); - assert.deepEqual(responses[1].parameters.required, ["required"]); + assert.deepEqual(responses[1].parameters.required, ["required", "optional"]); const anthropic = convertToolDefinitionsByProviderType("anthropic", toolDefs); assert.equal(anthropic.length, 2); diff --git a/tools/build/build-vsix.js b/tools/build/build-vsix.js index 8bd156a..9680d28 100644 --- a/tools/build/build-vsix.js +++ b/tools/build/build-vsix.js @@ -6,7 +6,7 @@ const path = require("path"); const { getArgValue, hasFlag } = require("../lib/cli-args"); const { sha256FileHex } = require("../lib/hash"); const { ensureDir, rmDir, readJson, writeJson } = require("../lib/fs"); -const { run } = require("../lib/run"); +const { runPython } = require("../lib/run"); const { applyByokPatches, runByokContractChecks } = require("../lib/byok-workflow"); const { DEFAULT_UPSTREAM_VSIX_URL, DEFAULT_UPSTREAM_VSIX_REL_PATH, ensureUpstreamVsix, unpackVsixToWorkDir } = require("../lib/upstream-vsix"); @@ -51,7 +51,7 @@ async function main() { const outName = `augment.vscode-augment.${upstreamVersion}.byok.vsix`; const outPath = path.join(distDir, outName); console.log(`[build] repack VSIX -> ${path.relative(repoRoot, outPath)}`); - run("python3", [path.join(repoRoot, "tools", "lib", "zip-dir.py"), "--src", workDir, "--out", outPath], { cwd: repoRoot }); + runPython([path.join(repoRoot, "tools", "lib", "zip-dir.py"), "--src", workDir, "--out", outPath], { cwd: repoRoot }); const outSha = sha256FileHex(outPath); const lockPath = path.join(distDir, "upstream.lock.json"); diff --git a/tools/lib/run.js b/tools/lib/run.js index a852995..44b7390 100644 --- a/tools/lib/run.js +++ b/tools/lib/run.js @@ -1,6 +1,7 @@ "use strict"; const { spawnSync } = require("child_process"); +let cachedPythonSpec = null; function run(cmd, args, { cwd } = {}) { const r = spawnSync(cmd, args, { cwd, stdio: "inherit" }); @@ -8,5 +9,58 @@ function run(cmd, args, { cwd } = {}) { if (typeof r.status === "number" && r.status !== 0) throw new Error(`command failed: ${cmd} ${args.join(" ")}`); } -module.exports = { run }; +function runWithFallback(candidates, args, { cwd } = {}) { + const list = Array.isArray(candidates) ? candidates : []; + const baseArgs = Array.isArray(args) ? args : []; + const notFound = []; + for (const item of list) { + const spec = typeof item === "string" ? { cmd: item, argsPrefix: [] } : item; + const cmd = typeof spec?.cmd === "string" ? spec.cmd : ""; + if (!cmd) continue; + + const argsPrefix = Array.isArray(spec.argsPrefix) ? spec.argsPrefix : []; + const finalArgs = [...argsPrefix, ...baseArgs]; + const r = spawnSync(cmd, finalArgs, { cwd, stdio: "inherit" }); + if (r.error) { + if (r.error && r.error.code === "ENOENT") { + notFound.push(cmd); + continue; + } + throw r.error; + } + if (typeof r.status === "number" && r.status !== 0) throw new Error(`command failed: ${cmd} ${finalArgs.join(" ")}`); + return; + } + + const names = [...new Set(notFound)].join(", "); + throw new Error(`command not found: ${names || "no candidates"}`); +} + +function runPython(args, { cwd } = {}) { + const spec = resolvePythonSpec({ cwd }); + const argsPrefix = Array.isArray(spec.argsPrefix) ? spec.argsPrefix : []; + run(spec.cmd, [...argsPrefix, ...(Array.isArray(args) ? args : [])], { cwd }); +} + +function resolvePythonSpec({ cwd } = {}) { + if (cachedPythonSpec && typeof cachedPythonSpec.cmd === "string") return cachedPythonSpec; + const candidates = [{ cmd: "python3" }, { cmd: "py", argsPrefix: ["-3"] }, { cmd: "python" }]; + + for (const spec of candidates) { + const cmd = spec.cmd; + const argsPrefix = Array.isArray(spec.argsPrefix) ? spec.argsPrefix : []; + const probe = spawnSync(cmd, [...argsPrefix, "--version"], { cwd, stdio: "ignore" }); + if (probe.error) { + if (probe.error.code === "ENOENT") continue; + continue; + } + if (probe.status === 0) { + cachedPythonSpec = spec; + return spec; + } + } + throw new Error("python runtime not found (tried: python3, py -3, python)"); +} + +module.exports = { run, runWithFallback, runPython }; diff --git a/tools/lib/upstream-vsix.js b/tools/lib/upstream-vsix.js index aea23c5..272b0d6 100644 --- a/tools/lib/upstream-vsix.js +++ b/tools/lib/upstream-vsix.js @@ -4,7 +4,7 @@ const fs = require("fs"); const path = require("path"); const { ensureDir, rmDir } = require("./fs"); -const { run } = require("./run"); +const { runPython } = require("./run"); const { downloadFile } = require("./http"); const DEFAULT_UPSTREAM_VSIX_URL = @@ -41,7 +41,7 @@ function unpackVsixToWorkDir({ repoRoot, vsixPath, workDir, clean }) { if (shouldClean) rmDir(workAbs); ensureDir(workAbs); - run("python3", [path.join(root, "tools", "lib", "unzip-dir.py"), "--in", vsixAbs, "--out", workAbs], { cwd: root }); + runPython([path.join(root, "tools", "lib", "unzip-dir.py"), "--in", vsixAbs, "--out", workAbs], { cwd: root }); const extensionDir = path.join(workAbs, "extension"); const pkgPath = path.join(extensionDir, "package.json"); @@ -59,4 +59,3 @@ module.exports = { ensureUpstreamVsix, unpackVsixToWorkDir }; -