From ac4b0f297569f8c4d53333f43b8fa34f1ed77c83 Mon Sep 17 00:00:00 2001 From: Code Queue Review Date: Sun, 24 May 2026 03:13:48 +0000 Subject: [PATCH] =?UTF-8?q?fix:=20=E8=AF=86=E5=88=AB=20Code=20Agent=20M3?= =?UTF-8?q?=20IO=20=E9=97=AD=E7=8E=AF=E6=8B=93=E6=89=91=E6=84=8F=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/cloud/code-agent-chat.mjs | 41 +++- internal/cloud/server.test.mjs | 219 ++++++++++++++++++ .../scripts/src/m3-io-skill-client.mjs | 2 +- 3 files changed, 252 insertions(+), 10 deletions(-) diff --git a/internal/cloud/code-agent-chat.mjs b/internal/cloud/code-agent-chat.mjs index 92b8f0fc..21ea1476 100644 --- a/internal/cloud/code-agent-chat.mjs +++ b/internal/cloud/code-agent-chat.mjs @@ -1396,15 +1396,17 @@ function detectM3IoIntent(text) { if (!/\bM3\b|DO1|DI1|数字输出|数字输入|回读|读回|状态|status/u.test(normalized)) { return null; } - if (/(?:直接|direct|gateway-simu|box-simu|patch-panel|hwlab-patch-panel|\/invoke|\/sync\/tick)/iu.test(normalized)) { + if (isDirectM3HardwareAccessRequest(normalized)) { return null; } if (/DEV-LIVE|验收|acceptance|(?:M3|M4|M5).{0,20}(?:通过|pass|green|accept)/iu.test(normalized)) { return null; } - const wantsRead = /(?:DI1|数字输入).{0,16}(?:read|readback|读取|回读|读回|查询)|(?:read|readback|读取|回读|读回|查询).{0,16}(?:DI1|数字输入)/iu.test(normalized); - const wantsWrite = /(?:DO1|数字输出).{0,20}(?:write|set|置|写|打开|关闭|true|false|高|低)|(?:write|set|置|写|打开|关闭).{0,20}(?:DO1|数字输出)/iu.test(normalized); + const topologyMentioned = isM3TopologyRouteMention(normalized); + const wantsRead = /(?:DI1|数字输入).{0,24}(?:read|readback|读取|回读|读回|查询|状态|当前)|(?:read|readback|读取|回读|读回|查询|状态|当前).{0,24}(?:DI1|数字输入)/iu.test(normalized); + const wantsWrite = /(?:DO1|数字输出).{0,30}(?:write|set|置|写|打开|关闭|true|false|高|低)|(?:write|set|置|写|打开|关闭).{0,30}(?:DO1|数字输出)/iu.test(normalized) || + (topologyMentioned && /(?:执行|run|闭环|loop|控制|触发)/iu.test(normalized)); const wantsStatus = /(?:M3).{0,16}(?:status|状态|聚合|health|readiness)|(?:status|状态|聚合|health|readiness).{0,16}(?:M3)/iu.test(normalized); if (wantsStatus && !wantsRead && !wantsWrite) { return { @@ -1428,9 +1430,9 @@ function detectM3IoIntent(text) { const value = parseM3WriteValue(normalized); if (typeof value !== "boolean") { return { - kind: "unsupported", + kind: "m3_io", toolName: HWLAB_M3_IO_SKILL_NAME, - reason: "M3 DO1 write requires an explicit true/false value." + action: "do.write" }; } return { @@ -1441,6 +1443,23 @@ function detectM3IoIntent(text) { }; } +function isDirectM3HardwareAccessRequest(text) { + const value = String(text ?? ""); + if (/(?:https?:\/\/[^\s"']*(?:gateway(?:-simu)?|box(?:-simu)?|patch-panel|hwlab-patch-panel)|\/invoke\b|\/sync\/tick\b|:7101\b|:7201\b|:7301\b)/iu.test(value)) { + return true; + } + if (/(?:直接|direct).{0,24}(?:gateway-simu|box-simu|patch-panel|hwlab-patch-panel|gateway|box|\/invoke|\/sync\/tick)|(?:gateway-simu|box-simu|patch-panel|hwlab-patch-panel).{0,24}(?:直接|direct|\/invoke|\/sync\/tick)/iu.test(value)) { + return true; + } + return false; +} + +function isM3TopologyRouteMention(text) { + const value = String(text ?? ""); + return /res_boxsimu_1\s*:?\s*DO1[\s\S]{0,80}hwlab-patch-panel[\s\S]{0,80}res_boxsimu_2\s*:?\s*DI1/iu.test(value) || + /DO1\s*[-=]*>\s*(?:hwlab-)?patch-panel\s*[-=]*>\s*DI1/iu.test(value); +} + function parseM3WriteValue(text) { if (/\btrue\b|\b1\b|\bon\b|打开|置高|高电平|拉高|闭合/iu.test(text)) return true; if (/\bfalse\b|\b0\b|\boff\b|关闭|置低|低电平|拉低|断开/iu.test(text)) return false; @@ -1527,9 +1546,10 @@ function m3IoSkillArgsForIntent(intent, { env, traceId }) { args.push("--api-base-url", apiBaseUrl); } if (intent.action === "do.write") { + if (typeof intent.value === "boolean") { + args.push("--value", String(intent.value)); + } args.push( - "--value", - String(intent.value), "--approved", "--policy", "hwlab-api-control-with-approval", @@ -1706,8 +1726,11 @@ function structuredCompletionBlocker(result, context = {}) { blockers: result.longLivedSessionGate?.blockers }); } - if (result.provider === M3_IO_SKILL_PROVIDER && result.capabilityLevel === HWLAB_M3_IO_CAPABILITY_LEVELS.blocked) { - const primary = result.toolCalls?.[0]?.blocker ?? result.skills?.blockers?.[0] ?? result.runnerTrace?.blocker ?? null; + const m3SkillTool = Array.isArray(result.toolCalls) + ? result.toolCalls.find((toolCall) => toolCall?.name === HWLAB_M3_IO_SKILL_NAME) + : null; + if ((result.provider === M3_IO_SKILL_PROVIDER || m3SkillTool) && result.capabilityLevel === HWLAB_M3_IO_CAPABILITY_LEVELS.blocked) { + const primary = m3SkillTool?.blocker ?? result.skills?.blockers?.[0] ?? result.runnerTrace?.blocker ?? null; return structuredBlocker({ code: primary?.code === "hwlab_api_unavailable" ? "hwlab_api_unavailable" : "m3_readiness_blocked", layer: primary?.code === "hwlab_api_unavailable" ? "hwlab-api" : "m3-readiness", diff --git a/internal/cloud/server.test.mjs b/internal/cloud/server.test.mjs index 8c047e9c..dc02e4eb 100644 --- a/internal/cloud/server.test.mjs +++ b/internal/cloud/server.test.mjs @@ -2533,6 +2533,225 @@ test("cloud api /v1/agent/chat blocks M3 IO when Codex stdio is unavailable", as } }); +test("cloud api /v1/agent/chat recognizes M3 patch-panel topology text before Codex free chat", async () => { + const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-m3-topology-skill-")); + const codexHome = path.join(workspace, "codex-home"); + const fakeCodex = await createFakeCodexCommand(); + await mkdir(codexHome, { recursive: true }); + const calls = []; + const codexStdioCalls = []; + const manager = createCodexStdioSessionManager({ + idFactory: () => "ses_server_m3_topology_skill", + createRpcClient: async () => ({ + async initialize() { + codexStdioCalls.push("initialize"); + return { tools: ["codex", "codex-reply"] }; + }, + async listTools() { + codexStdioCalls.push("listTools"); + return ["codex", "codex-reply"]; + }, + async callTool() { + codexStdioCalls.push("callTool"); + return { + structuredContent: { + threadId: "thread_server_m3_topology_skill", + content: "stdio session ready for controlled topology M3 skill." + } + }; + }, + close() {} + }) + }); + const server = createCloudApiServer({ + env: { + PATH: process.env.PATH, + OPENAI_API_KEY: "test-openai-key-material", + CODEX_HOME: codexHome, + HWLAB_CODE_AGENT_PROVIDER: "codex-stdio", + HWLAB_CODE_AGENT_MODEL: "gpt-test", + HWLAB_CODE_AGENT_CODEX_COMMAND: fakeCodex.command, + HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED: "1", + HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR: "repo-owned", + HWLAB_CODE_AGENT_WORKSPACE: workspace, + HWLAB_CODE_AGENT_CODEX_WORKSPACE: workspace, + HWLAB_CODE_AGENT_HWLAB_API_BASE_URL: "http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667" + }, + codexStdioManager: manager, + m3IoSkillRequestJson: async (url, request) => { + calls.push({ url, request }); + return { + ok: true, + status: 200, + body: { + serviceId: "hwlab-cloud-api", + contractVersion: "m3-io-control-v1", + status: "succeeded", + accepted: true, + action: "do.write", + traceId: "trc_server-test-m3-topology", + operationId: "op_m3_do_write_topology_skill", + auditId: "aud_m3_do_write_topology_skill", + evidenceId: "evd_m3_do_write_topology_skill", + auditState: { + status: "written_non_durable" + }, + evidenceState: { + status: "blocked", + sourceKind: "BLOCKED", + blocker: "runtime_durable_not_green", + writeStatus: "written_non_durable" + }, + durableStatus: { + status: "degraded", + durable: false, + blocker: "runtime_durable_not_green" + }, + result: { + value: true, + targetReadback: { + status: "succeeded", + value: true, + resourceId: "res_boxsimu_2", + port: "DI1" + } + }, + controlPath: { + status: "succeeded", + cloudApi: true, + gatewaySimu: true, + boxSimu: true, + patchPanel: true, + frontendBypass: false + } + } + }; + } + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + + try { + const { port } = server.address(); + const payload = await postAgent(port, { + conversationId: "cnv_server-test-m3-topology", + traceId: "trc_server-test-m3-topology", + message: "执行 res_boxsimu_1:DO1 -> hwlab-patch-panel -> res_boxsimu_2:DI1 基本闭环,把 DO1 置为 true。" + }); + validateCodeAgentChatSchema(payload); + assert.equal(payload.status, "completed"); + assert.equal(payload.provider, "codex-stdio"); + assert.equal(payload.backend, "hwlab-cloud-api/codex-mcp-stdio"); + const skillTool = payload.toolCalls.find((toolCall) => toolCall.name === "hwlab-agent-runtime.m3-io"); + assert.ok(skillTool); + assert.equal(skillTool.route, "/v1/m3/io"); + assert.equal(skillTool.method, "POST"); + assert.equal(skillTool.accepted, true); + assert.equal(skillTool.operationId, "op_m3_do_write_topology_skill"); + assert.equal(skillTool.traceId, "trc_server-test-m3-topology"); + assert.equal(skillTool.readback.value, true); + assert.equal(payload.providerTrace.fallbackUsed, false); + assert.equal(payload.providerTrace.transport, "stdio+skill-cli"); + assert.ok(payload.runnerTrace.events.includes("tool:skill-cli:started")); + assert.ok(payload.runnerTrace.events.includes("route:/v1/m3/io")); + assert.match(payload.reply.content, /Code Agent -> Skill CLI -> HWLAB API \/v1\/m3\/io/u); + assert.match(payload.reply.content, /res_boxsimu_1:DO1=true/u); + assert.match(payload.reply.content, /res_boxsimu_2:DI1=true/u); + assert.equal(calls.length, 1); + assert.equal(calls[0].url, "http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667/v1/m3/io"); + assert.equal(calls[0].request.body.action, "do.write"); + assert.equal(calls[0].request.body.value, true); + assert.ok(codexStdioCalls.includes("initialize")); + assert.ok(codexStdioCalls.includes("callTool")); + assert.equal(/https?:\/\/[^\s"']*(?:gateway(?:-simu)?|box(?:-simu)?|patch-panel)|:7101\b|:7201\b|:7301\b|\/invoke\b|\/sync\/tick\b/iu.test(JSON.stringify(payload.toolCalls)), false); + } finally { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + await rm(workspace, { recursive: true, force: true }); + await rm(fakeCodex.root, { recursive: true, force: true }); + } +}); + +test("cloud api /v1/agent/chat returns Skill CLI blocker for M3 topology without DO1 value", async () => { + const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-m3-topology-blocker-")); + const codexHome = path.join(workspace, "codex-home"); + const fakeCodex = await createFakeCodexCommand(); + await mkdir(codexHome, { recursive: true }); + const calls = []; + const manager = createCodexStdioSessionManager({ + idFactory: () => "ses_server_m3_topology_blocker", + createRpcClient: async () => ({ + async initialize() { + return { tools: ["codex", "codex-reply"] }; + }, + async listTools() { + return ["codex", "codex-reply"]; + }, + async callTool() { + return { + structuredContent: { + threadId: "thread_server_m3_topology_blocker", + content: "stdio session ready for blocked topology M3 skill." + } + }; + }, + close() {} + }) + }); + const server = createCloudApiServer({ + env: { + PATH: process.env.PATH, + OPENAI_API_KEY: "test-openai-key-material", + CODEX_HOME: codexHome, + HWLAB_CODE_AGENT_PROVIDER: "codex-stdio", + HWLAB_CODE_AGENT_MODEL: "gpt-test", + HWLAB_CODE_AGENT_CODEX_COMMAND: fakeCodex.command, + HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED: "1", + HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR: "repo-owned", + HWLAB_CODE_AGENT_WORKSPACE: workspace, + HWLAB_CODE_AGENT_CODEX_WORKSPACE: workspace, + HWLAB_CODE_AGENT_HWLAB_API_BASE_URL: "http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667" + }, + codexStdioManager: manager, + m3IoSkillRequestJson: async (url, request) => { + calls.push({ url, request }); + throw new Error("missing DO1 value must block before HWLAB API request"); + } + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + + try { + const { port } = server.address(); + const payload = await postAgent(port, { + conversationId: "cnv_server-test-m3-topology-blocker", + traceId: "trc_server-test-m3-topology-blocker", + message: "执行 res_boxsimu_1:DO1 -> hwlab-patch-panel -> res_boxsimu_2:DI1 基本闭环。" + }); + validateCodeAgentChatSchema(payload); + assert.equal(payload.status, "completed"); + assert.equal(payload.provider, "codex-stdio"); + assert.equal(payload.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.blocked); + const skillTool = payload.toolCalls.find((toolCall) => toolCall.name === "hwlab-agent-runtime.m3-io"); + assert.ok(skillTool); + assert.equal(skillTool.status, "blocked"); + assert.equal(skillTool.route, "/v1/m3/io"); + assert.equal(skillTool.blocker.code, "invalid_boolean_value"); + assert.equal(skillTool.accepted, false); + assert.equal(skillTool.operationId, null); + assert.equal(payload.blocker.code, "m3_readiness_blocked"); + assert.match(payload.reply.content, /M3 DO1 写入需要 --value true 或 --value false/u); + assert.equal(payload.providerTrace.fallbackUsed, false); + assert.equal(payload.providerTrace.transport, "stdio+skill-cli"); + assert.equal(calls.length, 0); + } finally { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + await rm(workspace, { recursive: true, force: true }); + await rm(fakeCodex.root, { recursive: true, force: true }); + } +}); + test("cloud api /v1/agent/chat routes M3 through Codex stdio then in-process HWLAB API handler", async () => { const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-stdio-m3-skill-")); const fakeCodex = await createFakeCodexCommand(); diff --git a/skills/hwlab-agent-runtime/scripts/src/m3-io-skill-client.mjs b/skills/hwlab-agent-runtime/scripts/src/m3-io-skill-client.mjs index d45ce106..f40bc50c 100644 --- a/skills/hwlab-agent-runtime/scripts/src/m3-io-skill-client.mjs +++ b/skills/hwlab-agent-runtime/scripts/src/m3-io-skill-client.mjs @@ -91,7 +91,7 @@ export async function runM3IoSkillCommand(argv = [], options = {}) { actorId, apiTarget, code: "invalid_boolean_value", - message: "M3 DO1 write requires --value true or --value false." + message: "M3 DO1 写入需要 --value true 或 --value false。" }); } const approval = approvalForParsed(parsed, command);