fix: 明确 AgentRun tool-call 错误归因

This commit is contained in:
Codex
2026-06-02 12:08:38 +08:00
parent feb52fbe0a
commit 47c4ce2d97
7 changed files with 240 additions and 12 deletions
+46 -10
View File
@@ -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"
+106
View File
@@ -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) => {
+1
View File
@@ -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),
@@ -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, "BLOCKEDCodex 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",
+15 -2
View File
@@ -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 = "BLOCKEDCodex app-server 终态未完成";
summary = `repo-owned Codex app-server stdio 返回 terminalStatus=${terminalStatus};本次不标为完整完成。`;
if (failureKind === "provider-invalid-tool-call") {
label = "BLOCKEDCodex 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 = "BLOCKEDCodex thread resume 失败";
summary = `AgentRun 复用的 Codex thread 已失效,当前标准是不自动创建 replacement thread;本轮以 thread-resume-failed 终止。${failureMessage ? ` 原始摘要:${failureMessage}` : ""}`;
} else {
label = "BLOCKEDCodex 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)
]
};
@@ -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, "BLOCKEDCodex 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: {
+3
View File
@@ -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,