From 47c4ce2d971f2f896418247ca955a48e8f70698f Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 2 Jun 2026 12:08:38 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=98=8E=E7=A1=AE=20AgentRun=20tool-cal?= =?UTF-8?q?l=20=E9=94=99=E8=AF=AF=E5=BD=92=E5=9B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/cloud/code-agent-agentrun-adapter.ts | 56 +++++++-- internal/cloud/server-agent-chat.test.ts | 106 ++++++++++++++++++ web/hwlab-cloud-web/app-conversation.ts | 1 + web/hwlab-cloud-web/code-agent-facts.test.ts | 37 ++++++ web/hwlab-cloud-web/code-agent-facts.ts | 17 ++- web/hwlab-cloud-web/code-agent-status.test.ts | 32 ++++++ web/hwlab-cloud-web/code-agent-status.ts | 3 + 7 files changed, 240 insertions(+), 12 deletions(-) diff --git a/internal/cloud/code-agent-agentrun-adapter.ts b/internal/cloud/code-agent-agentrun-adapter.ts index 8d3c824d..c3a825a3 100644 --- a/internal/cloud/code-agent-agentrun-adapter.ts +++ b/internal/cloud/code-agent-agentrun-adapter.ts @@ -744,10 +744,45 @@ function agentRunProviderTrace({ base = {}, result = {}, terminalStatus = null } threadId: result?.sessionRef?.threadId ?? base.agentRun?.threadId ?? base.threadId ?? null, turnId: result?.turnId ?? null, terminalStatus: terminalStatus ?? result?.terminalStatus ?? null, + failureKind: result?.failureKind ?? null, + failureMessage: result?.failureMessage ?? result?.blocker?.message ?? null, valuesPrinted: false }; } +function agentRunFailureAttribution({ code, message, canceled = false } = {}) { + if (canceled) { + return { + category: "canceled", + retryable: true, + userMessage: "AgentRun 请求已取消。", + summary: message || "AgentRun command was canceled." + }; + } + if (code === "provider-invalid-tool-call" || /invalid function arguments json string|invalid_prompt|tool_call_id/iu.test(String(message ?? ""))) { + return { + category: "provider_invalid_tool_call", + retryable: true, + userMessage: "Codex app-server/provider 返回了无效 tool-call arguments JSON;这不是 HWLAB device-pod 或 Cloud API 端点失败,请查看 providerTrace.failureKind 和 runnerTrace 定位上游 tool_call_id。", + summary: message || "Codex app-server/provider returned invalid tool-call arguments JSON." + }; + } + if (code === "thread-resume-failed") { + return { + category: "thread_resume_failed", + retryable: true, + userMessage: "AgentRun 复用的 Codex thread 已失效;当前标准是终止本轮并保留原 session 指针,不自动创建 replacement thread。请重新发起新会话。", + summary: message || "AgentRun thread/resume failed for an existing thread." + }; + } + return { + category: "agentrun_failed", + retryable: true, + userMessage: "AgentRun 请求失败;请查看 runnerTrace、providerTrace 和 agentRun 字段定位 runId/commandId/jobName。", + summary: message || "AgentRun command failed." + }; +} + function agentRunLongLivedSessionGate(base = {}) { return longLivedSessionGate({ provider: CODEX_STDIO_PROVIDER, @@ -823,20 +858,21 @@ function agentRunResultToCodeAgentPayload({ base, result, traceStore, traceId }) }; } const canceled = terminalStatus === "cancelled"; + const code = canceled ? "agentrun_canceled" : result?.failureKind ?? (terminalStatus === "blocked" ? "agentrun_blocked" : "agentrun_failed"); + const message = result?.failureMessage ?? result?.blocker?.message ?? (canceled ? "AgentRun command was canceled" : "AgentRun command failed"); + const attribution = agentRunFailureAttribution({ code, message, canceled }); traceStore.append(traceId, { type: "result", status: canceled ? "canceled" : "failed", label: `agentrun:result:${canceled ? "canceled" : terminalStatus || "failed"}`, createdAt: terminalEventCreatedAt, - errorCode: canceled ? "agentrun_canceled" : result?.failureKind ?? "agentrun_failed", - message: result?.failureMessage ?? result?.blocker?.message ?? (canceled ? "AgentRun command was canceled." : "AgentRun command failed without a completed reply."), + errorCode: code, + message: attribution.summary, runId: base.agentRun.runId, commandId: base.agentRun.commandId, terminal: true, valuesPrinted: false }); - const code = canceled ? "agentrun_canceled" : result?.failureKind ?? (terminalStatus === "blocked" ? "agentrun_blocked" : "agentrun_failed"); - const message = result?.failureMessage ?? result?.blocker?.message ?? (canceled ? "AgentRun command was canceled" : "AgentRun command failed"); const partialContext = partialAgentRunContext(runnerTrace); return { ...base, @@ -853,10 +889,10 @@ function agentRunResultToCodeAgentPayload({ base, result, traceStore, traceId }) error: { code, layer: "agentrun", - category: canceled ? "canceled" : "agentrun_failed", - retryable: true, + category: attribution.category, + retryable: attribution.retryable, message, - userMessage: canceled ? "AgentRun 请求已取消。" : "AgentRun 请求失败;请查看 runnerTrace 和 agentRun 字段定位 runId/commandId/jobName。", + userMessage: attribution.userMessage, traceId, route: "/v1/agent/chat", toolName: "agentrun.manual-dispatch" @@ -864,9 +900,9 @@ function agentRunResultToCodeAgentPayload({ base, result, traceStore, traceId }) blocker: canceled ? null : { code, layer: "agentrun", - category: "agentrun_failed", - retryable: true, - summary: message, + category: attribution.category, + retryable: attribution.retryable, + summary: attribution.summary, traceId, route: "/v1/agent/chat", toolName: "agentrun.manual-dispatch" diff --git a/internal/cloud/server-agent-chat.test.ts b/internal/cloud/server-agent-chat.test.ts index 99be8231..7100c09b 100644 --- a/internal/cloud/server-agent-chat.test.ts +++ b/internal/cloud/server-agent-chat.test.ts @@ -454,6 +454,112 @@ test("cloud api /v1/agent/chat delegates v0.2 turns to AgentRun v0.1 over adapte } }); +test("cloud api AgentRun adapter exposes invalid tool-call attribution in result payload", async () => { + const agentRunServer = createHttpServer(async (request, response) => { + const url = new URL(request.url || "/", "http://127.0.0.1"); + const chunks = []; + for await (const chunk of request) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + const body = chunks.length ? JSON.parse(Buffer.concat(chunks).toString("utf8")) : null; + const send = (data) => { + response.writeHead(200, { "content-type": "application/json" }); + response.end(`${JSON.stringify({ ok: true, data, traceId: "trc_fake_agentrun_invalid_tool" })}\n`); + }; + if (request.method === "POST" && url.pathname === "/api/v1/runs") { + return send({ id: "run_invalid_tool", status: "pending", backendProfile: "deepseek", sessionRef: body.sessionRef, resourceBundleRef: body.resourceBundleRef }); + } + if (request.method === "POST" && url.pathname === "/api/v1/runs/run_invalid_tool/commands") { + return send({ id: "cmd_invalid_tool", runId: "run_invalid_tool", state: "pending", type: "turn", seq: 1 }); + } + if (request.method === "POST" && url.pathname === "/api/v1/runs/run_invalid_tool/runner-jobs") { + return send({ + action: "create-kubernetes-job", + runId: "run_invalid_tool", + commandId: "cmd_invalid_tool", + attemptId: "attempt_invalid_tool", + runnerId: "runner_invalid_tool", + namespace: "agentrun-v01", + jobName: "agentrun-v01-runner-invalid-tool", + runner: { attemptId: "attempt_invalid_tool", runnerId: "runner_invalid_tool" } + }); + } + if (request.method === "GET" && url.pathname === "/api/v1/runs/run_invalid_tool/events") { + return send({ items: [ + { id: "evt_invalid_tool", runId: "run_invalid_tool", seq: 1, type: "error", payload: { commandId: "cmd_invalid_tool", failureKind: "provider-invalid-tool-call", message: "invalid params, invalid function arguments json string, tool_call_id: call_function_selftest_2 (2013)" }, createdAt: "2026-06-02T00:00:00.000Z" }, + { id: "evt_invalid_tool_terminal", runId: "run_invalid_tool", seq: 2, type: "terminal_status", payload: { commandId: "cmd_invalid_tool", terminalStatus: "failed", failureKind: "provider-invalid-tool-call" }, createdAt: "2026-06-02T00:00:01.000Z" } + ] }); + } + if (request.method === "GET" && url.pathname === "/api/v1/runs/run_invalid_tool/commands/cmd_invalid_tool/result") { + return send({ + runId: "run_invalid_tool", + commandId: "cmd_invalid_tool", + attemptId: "attempt_invalid_tool", + runnerId: "runner_invalid_tool", + jobName: "agentrun-v01-runner-invalid-tool", + namespace: "agentrun-v01", + status: "failed", + runStatus: "failed", + commandState: "failed", + terminalStatus: "failed", + failureKind: "provider-invalid-tool-call", + failureMessage: "invalid params, invalid function arguments json string, tool_call_id: call_function_selftest_2 (2013)", + completed: false, + lastSeq: 2, + eventCount: 2, + sessionRef: { sessionId: "ses_agentrun_invalid_tool", conversationId: "cnv_invalid_tool", threadId: "thread_invalid_tool" } + }); + } + response.writeHead(404, { "content-type": "application/json" }); + response.end(`${JSON.stringify({ ok: false, failureKind: "schema-invalid", message: `unexpected ${request.method} ${url.pathname}`, traceId: "trc_fake_agentrun_invalid_tool" })}\n`); + }); + await new Promise((resolve) => agentRunServer.listen(0, "127.0.0.1", resolve)); + const agentRunPort = agentRunServer.address().port; + const server = createCloudApiServer({ + env: { + HWLAB_CODE_AGENT_ADAPTER: "agentrun-v01", + AGENTRUN_MGR_URL: `http://127.0.0.1:${agentRunPort}`, + HWLAB_CODE_AGENT_AGENTRUN_ALLOW_NON_K3S_URL: "1", + HWLAB_CODE_AGENT_AGENTRUN_SOURCE_COMMIT: "0123456789abcdef0123456789abcdef01234567", + HWLAB_CODE_AGENT_DEFAULT_PROVIDER_PROFILE: "deepseek", + HWLAB_ENVIRONMENT: "v02", + HWLAB_GITOPS_PROFILE: "v02" + }, + accessController: { + required: false, + async authenticate() { + return { ok: true, actor: null, session: null }; + }, + async createCodeAgentDevicePodSession() { + return { token: "test-device-pod-session-token" }; + } + } + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + + try { + const { port } = server.address(); + const traceId = "trc_server-test-agentrun-invalid-tool"; + const submit = await fetch(`http://127.0.0.1:${port}/v1/agent/chat`, { + method: "POST", + headers: { "content-type": "application/json", "x-trace-id": traceId }, + body: JSON.stringify({ conversationId: "cnv_invalid_tool", message: "invalid tool-call attribution" }) + }); + assert.equal(submit.status, 202); + const payload = await pollAgentResult(port, traceId); + validateCodeAgentChatSchema(payload); + assert.equal(payload.status, "failed"); + assert.equal(payload.providerTrace.failureKind, "provider-invalid-tool-call"); + assert.equal(payload.error.code, "provider-invalid-tool-call"); + assert.equal(payload.error.category, "provider_invalid_tool_call"); + assert.match(payload.error.userMessage, /无效 tool-call arguments JSON/u); + assert.match(payload.error.userMessage, /不是 HWLAB device-pod/u); + assert.equal(payload.blocker.category, "provider_invalid_tool_call"); + assert.match(payload.blocker.summary, /invalid function arguments json string/u); + } finally { + await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); + await new Promise((resolve, reject) => agentRunServer.close((error) => (error ? reject(error) : resolve()))); + } +}); + test("cloud api AgentRun adapter maps minimax-m3 provider profile to AgentRun backend", async () => { const calls = []; const agentRunServer = createHttpServer(async (request, response) => { diff --git a/web/hwlab-cloud-web/app-conversation.ts b/web/hwlab-cloud-web/app-conversation.ts index 0f464818..345ade89 100644 --- a/web/hwlab-cloud-web/app-conversation.ts +++ b/web/hwlab-cloud-web/app-conversation.ts @@ -448,6 +448,7 @@ function codeAgentSummaryRows(summary) { codeAgentSummaryRow("implementationType", summary.implementationType, summary.runtimePathTone), codeAgentSummaryRow("providerTrace.command", summary.providerTraceCommand, summary.runtimePathTone), codeAgentSummaryRow("providerTrace.terminalStatus", summary.providerTraceTerminalStatus, summary.runtimePathTone), + codeAgentSummaryRow("providerTrace.failureKind", summary.providerTraceFailureKind, summary.runtimePathTone), codeAgentSummaryRow("运行路径语义", summary.runtimePathLabel, summary.runtimePathTone), codeAgentSummaryRow("toolCalls", summary.toolCalls, summary.toolCalls === "none" ? "source" : summary.tone), codeAgentSummaryRow("skills", summary.skills, summary.skills === "none" ? "source" : summary.tone), diff --git a/web/hwlab-cloud-web/code-agent-facts.test.ts b/web/hwlab-cloud-web/code-agent-facts.test.ts index 07ec7d72..48368ec6 100644 --- a/web/hwlab-cloud-web/code-agent-facts.test.ts +++ b/web/hwlab-cloud-web/code-agent-facts.test.ts @@ -222,6 +222,43 @@ test("Codex app-server without providerTrace is degraded with structured missing assert.deepEqual(runtimePath.missingFields, ["protocol", "providerTrace.command", "providerTrace.terminalStatus"]); }); +test("AgentRun invalid tool-call failure is attributed to Codex provider tool-call JSON", () => { + const message = { + role: "agent", + status: "failed", + provider: "codex-stdio", + backend: "hwlab-cloud-api/codex-app-server-stdio", + capabilityLevel: "long-lived-codex-stdio-session", + sessionMode: "codex-app-server-stdio-long-lived", + implementationType: "repo-owned-codex-app-server-stdio-session", + runner: { kind: "codex-app-server-stdio-runner" }, + providerTrace: { + transport: "stdio", + protocol: "codex-app-server-jsonrpc-stdio", + wireApi: "responses", + command: "codex app-server --listen stdio://", + terminalStatus: "failed", + failureKind: "provider-invalid-tool-call", + failureMessage: "invalid params, invalid function arguments json string, tool_call_id: call_function_selftest_2 (2013)" + }, + error: { + code: "provider-invalid-tool-call", + category: "provider_invalid_tool_call" + }, + traceId: "trc_invalid_tool_call" + }; + const facts = codeAgentFactsFromMessage(message); + const runtimePath = codeAgentRuntimePathFromMessage(message); + + assert.equal(facts.kind, "codex-stdio-blocked"); + assert.equal(runtimePath.kind, "codex-app-server-stdio"); + assert.equal(runtimePath.terminalStatusOk, false); + assert.equal(runtimePath.failureKind, "provider-invalid-tool-call"); + assert.equal(runtimePath.label, "BLOCKED:Codex tool-call JSON 无效"); + assert.match(runtimePath.summary, /不是 HWLAB device-pod/u); + assert.match(runtimePath.summary, /tool-call arguments JSON/u); +}); + test("openai-responses fallback is explicitly text-chat-only", () => { const message = { status: "source", diff --git a/web/hwlab-cloud-web/code-agent-facts.ts b/web/hwlab-cloud-web/code-agent-facts.ts index feba55a4..30acf2dc 100644 --- a/web/hwlab-cloud-web/code-agent-facts.ts +++ b/web/hwlab-cloud-web/code-agent-facts.ts @@ -211,6 +211,8 @@ export function codeAgentRuntimePathFromMessage(message) { const implementationType = firstText(message.implementationType, message.runner?.implementationType, runnerTrace?.implementationType); const command = firstText(providerTrace?.command); const terminalStatus = firstText(providerTrace?.terminalStatus); + const failureKind = firstText(providerTrace?.failureKind, message.error?.code, message.blocker?.code); + const failureMessage = firstText(providerTrace?.failureMessage, message.error?.message, message.blocker?.summary); const wireApi = firstText(providerTrace?.wireApi, isCodexAppServerPath({ provider, runnerKind, protocol, implementationType, command }) ? "responses" : null); const providerTraceMissing = !providerTrace; const required = { @@ -244,8 +246,16 @@ export function codeAgentRuntimePathFromMessage(message) { label = "DEGRADED:运行路径字段不完整"; summary = `repo-owned Codex app-server stdio 已暴露部分字段,但仍缺少 ${missingFields.join(", ")}。`; } else if (!terminalStatusOk) { - label = "BLOCKED:Codex app-server 终态未完成"; - summary = `repo-owned Codex app-server stdio 返回 terminalStatus=${terminalStatus};本次不标为完整完成。`; + if (failureKind === "provider-invalid-tool-call") { + label = "BLOCKED:Codex tool-call JSON 无效"; + summary = `Codex app-server/provider 返回 failureKind=provider-invalid-tool-call,表示上游 tool-call arguments JSON 无效;不是 HWLAB device-pod 或 Cloud API 端点失败。${failureMessage ? ` 原始摘要:${failureMessage}` : ""}`; + } else if (failureKind === "thread-resume-failed") { + label = "BLOCKED:Codex thread resume 失败"; + summary = `AgentRun 复用的 Codex thread 已失效,当前标准是不自动创建 replacement thread;本轮以 thread-resume-failed 终止。${failureMessage ? ` 原始摘要:${failureMessage}` : ""}`; + } else { + label = "BLOCKED:Codex app-server 终态未完成"; + summary = `repo-owned Codex app-server stdio 返回 terminalStatus=${terminalStatus};本次不标为完整完成。`; + } tone = "blocked"; } else { label = "repo-owned Codex app-server stdio"; @@ -279,6 +289,8 @@ export function codeAgentRuntimePathFromMessage(message) { implementationType, command, terminalStatus, + failureKind, + failureMessage, wireApi, providerTraceMissing, criticalMissing, @@ -291,6 +303,7 @@ export function codeAgentRuntimePathFromMessage(message) { runtimePathRow("implementationType", implementationType), runtimePathRow("providerTrace.command", command), runtimePathRow("providerTrace.terminalStatus", terminalStatus), + runtimePathRow("providerTrace.failureKind", failureKind), runtimePathRow("wireApi", wireApi) ] }; diff --git a/web/hwlab-cloud-web/code-agent-status.test.ts b/web/hwlab-cloud-web/code-agent-status.test.ts index c5724f50..b3355af1 100644 --- a/web/hwlab-cloud-web/code-agent-status.test.ts +++ b/web/hwlab-cloud-web/code-agent-status.test.ts @@ -119,6 +119,38 @@ test("summarizes Codex app-server stdio thread as reusable long-lived session", assert.equal(summary.fields.threadId, "thread_app_server"); }); +test("summarizes AgentRun invalid tool-call JSON as provider-side blocked status", () => { + const summary = classifyCodeAgentStatusSummary({ + latestMessage: { + status: "failed", + provider: "codex-stdio", + backend: "hwlab-cloud-api/codex-app-server-stdio", + capabilityLevel: "long-lived-codex-stdio-session", + sessionMode: "codex-app-server-stdio-long-lived", + implementationType: "repo-owned-codex-app-server-stdio-session", + runner: { kind: "codex-app-server-stdio-runner" }, + providerTrace: { + protocol: "codex-app-server-jsonrpc-stdio", + command: "codex app-server --listen stdio://", + terminalStatus: "failed", + failureKind: "provider-invalid-tool-call", + failureMessage: "invalid params, invalid function arguments json string, tool_call_id: call_function_selftest_2 (2013)" + }, + error: { + code: "provider-invalid-tool-call", + category: "provider_invalid_tool_call" + }, + traceId: "trc_status_invalid_tool_call" + } + }); + + assert.equal(summary.kind, "blocked"); + assert.equal(summary.tone, "blocked"); + assert.equal(summary.providerTraceFailureKind, "provider-invalid-tool-call"); + assert.equal(summary.runtimePathLabel, "BLOCKED:Codex tool-call JSON 无效"); + assert.match(summary.runtimePathSummary, /不是 HWLAB device-pod/u); +}); + test("maps degraded readonly session tools to warning, not green", () => { const summary = classifyCodeAgentStatusSummary({ availability: { diff --git a/web/hwlab-cloud-web/code-agent-status.ts b/web/hwlab-cloud-web/code-agent-status.ts index 7d6b62be..2fe2ad5b 100644 --- a/web/hwlab-cloud-web/code-agent-status.ts +++ b/web/hwlab-cloud-web/code-agent-status.ts @@ -43,6 +43,7 @@ export function classifyCodeAgentStatusSummary({ availability = null, latestMess const implementationType = safeField(runtimePath?.implementationType); const providerTraceCommand = safeField(runtimePath?.command); const providerTraceTerminalStatus = safeField(runtimePath?.terminalStatus); + const providerTraceFailureKind = safeField(runtimePath?.failureKind); const runtimePathLabel = runtimePath?.label ?? "运行路径证据不足"; const runtimePathSummary = runtimePath?.summary ?? "providerTrace 未观测;不会推断完整 Code Agent。"; const runtimePathTone = runtimePath?.tone ?? "warn"; @@ -82,6 +83,7 @@ export function classifyCodeAgentStatusSummary({ availability = null, latestMess implementationType, providerTraceCommand, providerTraceTerminalStatus, + providerTraceFailureKind, runtimePathLabel, runtimePathSummary, runtimePathTone, @@ -112,6 +114,7 @@ export function classifyCodeAgentStatusSummary({ availability = null, latestMess implementationType, providerTraceCommand, providerTraceTerminalStatus, + providerTraceFailureKind, runtimePathLabel, runtimePathSummary, runtimePathTone,