From 997b96dc65be01522008afc9b24dd02667bedcad Mon Sep 17 00:00:00 2001 From: Lyon <88232613+pikasTech@users.noreply.github.com> Date: Sat, 23 May 2026 23:17:13 +0800 Subject: [PATCH] fix: add structured Code Agent blockers Add structured Code Agent blocker taxonomy across /v1/agent/chat, runner/session/tool, and HWLAB M3 Skill CLI paths.\n\nValidated with npm run check and focused server/session/Skill CLI/web tests. --- internal/cloud/code-agent-chat.mjs | 465 ++++++++++++++++-- .../code-agent-session-registry.test.mjs | 114 ++++- internal/cloud/server.mjs | 46 +- internal/cloud/server.test.mjs | 102 +++- scripts/dev-cloud-workbench-smoke.test.mjs | 81 +++ scripts/src/code-agent-response-contract.mjs | 102 ++++ skills/hwlab-agent-runtime/SKILL.md | 2 +- .../scripts/m3-io-skill-client.test.mjs | 63 ++- .../scripts/src/m3-io-skill-client.mjs | 126 ++++- web/hwlab-cloud-web/app.mjs | 104 +++- 10 files changed, 1104 insertions(+), 101 deletions(-) diff --git a/internal/cloud/code-agent-chat.mjs b/internal/cloud/code-agent-chat.mjs index e661d51c..123c879c 100644 --- a/internal/cloud/code-agent-chat.mjs +++ b/internal/cloud/code-agent-chat.mjs @@ -213,6 +213,28 @@ export async function handleCodeAgentChat(params = {}, options = {}) { }); } const completedAt = nowIso(options.now); + const providerRunner = providerResult.runner ?? openAiFallbackRunnerEvidence({ providerResult, traceId, conversationId, sessionId }); + const providerLongLivedGate = providerResult.longLivedSessionGate ?? longLivedSessionGate({ + provider: providerResult.provider ?? base.provider, + runnerKind: providerRunner?.kind ?? OPENAI_FALLBACK_RUNNER_KIND, + session: providerResult.session ?? null, + sessionMode: providerResult.sessionMode ?? "provider-text-request", + implementationType: providerResult.implementationType ?? OPENAI_FALLBACK_RUNNER_KIND, + codexStdioFeasibility: providerResult.codexStdioFeasibility ?? inspectCodexStdioFeasibility(options.env ?? process.env, options) + }); + const providerCompletionBlocker = structuredCompletionBlocker({ + provider: providerResult.provider ?? base.provider, + backend: providerResult.backend ?? base.backend, + runner: providerRunner, + capabilityLevel: providerResult.capabilityLevel ?? "text-chat-only", + longLivedSessionGate: providerLongLivedGate + }, { + traceId, + provider: providerResult.provider ?? base.provider, + backend: providerResult.backend ?? base.backend, + runner: providerRunner, + capabilityLevel: providerResult.capabilityLevel ?? "text-chat-only" + }); return { ...base, status: "completed", @@ -233,21 +255,14 @@ export async function handleCodeAgentChat(params = {}, options = {}) { "not-durable-session" ], codexStdioFeasibility: providerResult.codexStdioFeasibility ?? inspectCodexStdioFeasibility(options.env ?? process.env, options), - longLivedSessionGate: providerResult.longLivedSessionGate ?? longLivedSessionGate({ - provider: providerResult.provider ?? base.provider, - runnerKind: providerResult.runner?.kind ?? OPENAI_FALLBACK_RUNNER_KIND, - session: providerResult.session ?? null, - sessionMode: providerResult.sessionMode ?? "provider-text-request", - implementationType: providerResult.implementationType ?? OPENAI_FALLBACK_RUNNER_KIND, - codexStdioFeasibility: providerResult.codexStdioFeasibility ?? inspectCodexStdioFeasibility(options.env ?? process.env, options) - }), + longLivedSessionGate: providerLongLivedGate, toolCalls: Array.isArray(providerResult.toolCalls) ? providerResult.toolCalls : [], skills: providerResult.skills ?? { status: "not_requested", items: [], blockers: [] }, - runner: providerResult.runner ?? openAiFallbackRunnerEvidence({ providerResult, traceId, conversationId, sessionId }), + runner: providerRunner, runnerTrace: providerResult.runnerTrace ?? { traceId, runnerKind: OPENAI_FALLBACK_RUNNER_KIND, @@ -262,7 +277,8 @@ export async function handleCodeAgentChat(params = {}, options = {}) { createdAt: completedAt }, usage: providerResult.usage ?? null, - providerTrace: providerResult.providerTrace ?? null + providerTrace: providerResult.providerTrace ?? null, + ...(providerCompletionBlocker ? { blocker: providerCompletionBlocker, blockers: [providerCompletionBlocker] } : {}) }; } catch (error) { const failedAt = nowIso(options.now); @@ -270,8 +286,19 @@ export async function handleCodeAgentChat(params = {}, options = {}) { ...base, status: "failed", updatedAt: failedAt, - error: normalizeChatError(error) + error: normalizeChatError(error, { + traceId, + provider: error.provider ?? base.provider, + model: error.model ?? base.model, + backend: error.backend ?? base.backend, + runner: error.runner, + capabilityLevel: error.capabilityLevel ?? "blocked", + route: error.route, + toolName: error.toolName + }) }; + if (payload.error?.blocker) payload.blocker = payload.error.blocker; + if (payload.error?.blockers) payload.blockers = payload.error.blockers; if (error.provider) payload.provider = error.provider; if (error.model) payload.model = error.model; if (error.backend) payload.backend = error.backend; @@ -302,6 +329,13 @@ export async function handleCodeAgentChat(params = {}, options = {}) { function completedRunnerPayload({ base, runnerResult, messageId, now }) { const completedAt = nowIso(now); + const blocker = structuredCompletionBlocker(runnerResult, { + traceId: base.traceId, + provider: runnerResult.provider, + backend: runnerResult.backend, + runner: runnerResult.runner, + capabilityLevel: runnerResult.capabilityLevel + }); return { ...base, sessionId: runnerResult.session?.sessionId ?? base.sessionId, @@ -330,6 +364,7 @@ function completedRunnerPayload({ base, runnerResult, messageId, now }) { content: runnerResult.content, createdAt: completedAt }, + ...(blocker ? { blocker, blockers: [blocker, ...(runnerResult.blockers ?? [])].filter(Boolean) } : {}), usage: null, providerTrace: runnerResult.providerTrace }; @@ -656,7 +691,6 @@ function inspectReadOnlyRunnerAvailability(env, options = {}) { baseUrlConfigured: Boolean(m3IoApiBaseUrl), redactedBaseUrl: m3IoApiBaseUrl ? redactUrl(m3IoApiBaseUrl) : null, recommendedEnv: HWLAB_M3_IO_API_BASE_URL_ENV, - recommendedDevValue: HWLAB_M3_IO_DEV_SERVICE_BASE_URL, requiredEnv: [...HWLAB_M3_IO_API_BASE_URL_ENVS] }, blocker: m3IoApiBaseUrl ? null : m3IoApiBaseUrlMissingBlocker(), @@ -737,6 +771,8 @@ async function callCodexStdioRunner({ message, conversationId, sessionId, traceI codexStdioFeasibility: availability }), blockers: error.blockers ?? availability.blockers, + route: null, + toolName: error.missingTools?.length ? "codex-stdio.required-tools" : "codex-stdio.session", availability: describeCodeAgentAvailability(env, { codexStdioManager: manager, workspace }) }); } @@ -787,7 +823,9 @@ async function callReadOnlyRunner({ intent, conversationId, sessionId, traceId, implementationType: READONLY_IMPLEMENTATION_TYPE, codexStdioFeasibility: inspectCodexStdioFeasibility(env, { codexStdioManager, workspace }) }), - blockers: [sessionAcquire.blocker] + blockers: [sessionAcquire.blocker], + route: null, + toolName: intent.toolName ?? null }); } let session = sessionAcquire.session; @@ -833,6 +871,8 @@ async function callReadOnlyRunner({ intent, conversationId, sessionId, traceId, runner, runnerTrace: runnerTrace({ traceId, workspace: resolvedWorkspace ?? repoRoot, session, events: [...events, "blocked:workspace_unreadable"], startedAt, outputTruncated: false }), capabilityLevel: "blocked", + route: null, + toolName: intent.toolName ?? null, ...baseEvidence }); } @@ -863,6 +903,8 @@ async function callReadOnlyRunner({ intent, conversationId, sessionId, traceId, runner, runnerTrace: runnerTrace({ traceId, workspace: resolvedWorkspace, session, events: [...events, `blocked:${intent.toolName ?? "security.guard"}`], startedAt, outputTruncated: false }), capabilityLevel: "blocked", + route: null, + toolName: intent.toolName ?? "security.guard", ...baseEvidence, blockers: [{ code: "security_blocked", @@ -1012,6 +1054,8 @@ async function callReadOnlyRunner({ intent, conversationId, sessionId, traceId, runner, runnerTrace: runnerTrace({ traceId, workspace: resolvedWorkspace, session, events: [...events, "tool:skills.discover:blocked"], startedAt, outputTruncated: false }), capabilityLevel: "blocked", + route: null, + toolName: "skills.discover", ...baseEvidence, blockers: skills.blockers }); @@ -1065,6 +1109,8 @@ async function callReadOnlyRunner({ intent, conversationId, sessionId, traceId, runner, runnerTrace: runnerTrace({ traceId, workspace: resolvedWorkspace, session, events: [...events, `tool:${intent.toolName ?? "unsupported"}:blocked`], startedAt, outputTruncated: false }), capabilityLevel: "blocked", + route: null, + toolName: intent.toolName ?? "unsupported", ...baseEvidence }); } @@ -1123,7 +1169,9 @@ async function callM3IoSkillRunner({ intent, conversationId, sessionId, traceId, implementationType: M3_IO_SKILL_IMPLEMENTATION_TYPE, codexStdioFeasibility }), - blockers: [sessionAcquire.blocker] + blockers: [sessionAcquire.blocker], + route: HWLAB_M3_IO_API_ROUTE, + toolName: HWLAB_M3_IO_SKILL_NAME }); } @@ -1286,6 +1334,9 @@ function m3IoSkillRunnerResult({ runner, runnerTrace: runnerTracePayload, capabilityLevel, + blockers: skillResult.blockers ?? (skillResult.blocker ? [skillResult.blocker] : []), + route: HWLAB_M3_IO_API_ROUTE, + toolName: HWLAB_M3_IO_SKILL_NAME, providerTrace: { runnerKind: M3_IO_SKILL_RUNNER_KIND, skill: HWLAB_M3_IO_SKILL_NAME, @@ -1309,13 +1360,15 @@ function m3IoSkillMissingApiBaseUrlResult({ traceId, requestId, actorId, blocker route: HWLAB_M3_IO_API_ROUTE, hwlabApi: { route: HWLAB_M3_IO_API_ROUTE, - url: null, redactedUrl: null, source: "missing-config", baseUrlConfigured: false, requiredEnv: [...HWLAB_M3_IO_API_BASE_URL_ENVS], recommendedEnv: HWLAB_M3_IO_API_BASE_URL_ENV, - recommendedDevValue: HWLAB_M3_IO_DEV_SERVICE_BASE_URL, + missingConfig: [ + ...HWLAB_M3_IO_API_BASE_URL_ENVS, + "contract:hwlab-agent-runtime.m3-io.apiBaseUrl" + ], cloudApiOnly: true, directGatewayCalls: false, directBoxCalls: false, @@ -1387,7 +1440,16 @@ function m3IoSkillMissingApiBaseUrlResult({ traceId, requestId, actorId, blocker httpStatus: 0, error: { code: blocker.code, - message: blocker.message + layer: blocker.layer, + category: blocker.category, + blocker, + retryable: blocker.retryable, + userMessage: blocker.userMessage, + message: blocker.message, + traceId, + route: HWLAB_M3_IO_API_ROUTE, + toolName: HWLAB_M3_IO_SKILL_NAME, + missingConfig: blocker.missingConfig }, rawStatus: null, startedAt, @@ -1743,6 +1805,8 @@ function assertReadOnlyToolCompleted(toolCall, { workspace, skills, runner, runn sourceIssue: "pikasTech/HWLAB#275", summary: toolCall.stderrSummary || "Read-only runner blocked the requested file path." }], + route: null, + toolName: toolCall.name, ...baseEvidence }); } @@ -2084,12 +2148,22 @@ function m3IoSkillApiBaseUrlSource(env = process.env) { function m3IoApiBaseUrlMissingBlocker() { return { - code: "hwlab_api_base_url_missing", - category: "hwlab_api_configuration", + code: "skill_cli_api_base_missing", + layer: "skill-cli-config", + category: "needs_config", + retryable: false, source: "code-agent-m3-skill-cli", summary: `${HWLAB_M3_IO_API_BASE_URL_ENV} is required so the Skill CLI can reach cloud-api from inside the cloud-api runtime container.`, - message: `Set ${HWLAB_M3_IO_API_BASE_URL_ENV}=${HWLAB_M3_IO_DEV_SERVICE_BASE_URL} in the cloud-api runtime; the runner will not fall back to a loopback URL or direct hardware services.`, - zh: `cloud-api 运行时缺少 ${HWLAB_M3_IO_API_BASE_URL_ENV},Skill CLI 无法从容器内访问 HWLAB API;不会回退到 loopback URL 或直连硬件服务。` + message: `Set ${HWLAB_M3_IO_API_BASE_URL_ENV} or another supported HWLAB API base URL contract in the cloud-api runtime; the runner will not fall back to a loopback URL or direct hardware services.`, + zh: `cloud-api 运行时缺少 ${HWLAB_M3_IO_API_BASE_URL_ENV},Skill CLI 无法从容器内访问 HWLAB API;不会回退到 loopback URL 或直连硬件服务。`, + userMessage: "M3 Skill CLI 缺少 HWLAB API base URL 配置,需要补齐安全 env 或 contract。", + traceId: null, + route: HWLAB_M3_IO_API_ROUTE, + toolName: HWLAB_M3_IO_SKILL_NAME, + missingConfig: [ + ...HWLAB_M3_IO_API_BASE_URL_ENVS, + "contract:hwlab-agent-runtime.m3-io.apiBaseUrl" + ] }; } @@ -2201,6 +2275,44 @@ function m3IoSkillRunnerTrace({ traceId, events, startedAt, finishedAt = started }; } +function structuredCompletionBlocker(result, context = {}) { + if (!result || typeof result !== "object") return null; + if (result.provider === OPENAI_FALLBACK_RUNNER_KIND || result.runner?.kind === OPENAI_FALLBACK_RUNNER_KIND || result.capabilityLevel === "text-chat-only") { + return structuredBlocker({ + code: "text_chat_only_fallback", + layer: "provider", + message: "OpenAI Responses fallback is text chat only and cannot satisfy Code Agent runner/session/tool capability.", + userMessage: "当前仍是文本 fallback,只能回答普通问题,不能当作真实 Code Agent runner/session/tool 能力。", + retryable: false, + traceId: context.traceId, + provider: result.provider ?? context.provider, + backend: result.backend ?? context.backend, + runner: result.runner ?? context.runner, + capabilityLevel: result.capabilityLevel ?? context.capabilityLevel, + 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; + return structuredBlocker({ + code: primary?.code === "hwlab_api_unavailable" ? "hwlab_api_unavailable" : "m3_readiness_blocked", + layer: primary?.code === "hwlab_api_unavailable" ? "hwlab-api" : "m3-readiness", + message: primary?.message ?? "M3 IO Skill CLI did not reach a ready HWLAB API control path.", + userMessage: primary?.zh ?? "M3 控制链路仍受阻,前端应显示为能力未就绪,而不是发送失败。", + retryable: primary?.code === "hwlab_api_unavailable", + traceId: context.traceId, + provider: result.provider, + backend: result.backend, + runner: result.runner, + capabilityLevel: result.capabilityLevel, + route: HWLAB_M3_IO_API_ROUTE, + toolName: HWLAB_M3_IO_SKILL_NAME, + blockers: result.skills?.blockers + }); + } + return null; +} + function sessionReuseEvidence(session) { return { conversationId: session.conversationId, @@ -2482,9 +2594,11 @@ async function callOpenAiResponses({ providerPlan, message, conversationId, trac } catch (error) { if (error.name === "AbortError") { throw providerUnavailable(`OpenAI Responses request timed out after ${effectiveTimeout(timeoutMs)}ms`, { + code: "provider_timeout", provider: providerPlan.provider, model: providerPlan.model, - backend: providerPlan.backend + backend: providerPlan.backend, + timeoutMs: effectiveTimeout(timeoutMs) }); } throw providerUnavailable(`OpenAI Responses request failed: ${error.message}`, { @@ -2693,31 +2807,318 @@ function normalizeUserMessage(value) { return message; } -function normalizeChatError(error) { +export function createCodeAgentErrorPayload({ + code = "code_agent_failed", + message = "Code Agent request failed", + reason, + traceId = "trc_unassigned", + conversationId = "cnv_unassigned", + sessionId = "cnv_unassigned", + messageId = "msg_unassigned", + provider = "unassigned", + model = "unknown", + backend = "hwlab-cloud-api/code-agent-chat", + layer, + retryable, + capabilityLevel = "blocked", + route = "/v1/agent/chat", + toolName = null, + now +} = {}) { + const timestamp = nowIso(now); + const error = new Error(message); + Object.assign(error, { code, reason, layer, retryable, provider, model, backend, capabilityLevel, route, toolName }); + const normalized = normalizeChatError(error, { traceId, provider, model, backend, capabilityLevel, route, toolName }); + return { + conversationId, + sessionId, + messageId, + status: "failed", + createdAt: timestamp, + updatedAt: timestamp, + traceId, + provider, + model, + backend, + capabilityLevel, + error: normalized, + blocker: normalized.blocker, + blockers: normalized.blockers + }; +} + +function normalizeChatError(error, context = {}) { + const code = String(error.code || "code_agent_failed"); + const taxonomy = errorTaxonomy(code, error); + const provider = error.provider ?? context.provider ?? READONLY_RUNNER_PROVIDER; + const model = error.model ?? context.model ?? READONLY_RUNNER_MODEL; + const backend = error.backend ?? context.backend ?? READONLY_RUNNER_BACKEND; + const runnerKind = error.runner?.kind ?? error.runnerKind ?? context.runner?.kind ?? null; + const traceId = error.traceId ?? context.traceId ?? null; + const capabilityLevel = error.capabilityLevel ?? context.capabilityLevel ?? "blocked"; + const route = error.route ?? context.route ?? routeForError(code, error); + const toolName = error.toolName ?? context.toolName ?? toolNameForError(code, error); + const message = redactText(error.message || taxonomy.message || "Code Agent request failed"); + const blockers = normalizeBlockers(error.blockers, { + fallbackCode: code, + layer: taxonomy.layer, + message, + userMessage: taxonomy.userMessage, + retryable: taxonomy.retryable + }); + const blocker = structuredBlocker({ + code, + layer: taxonomy.layer, + message, + userMessage: error.userMessage ?? taxonomy.userMessage, + retryable: error.retryable ?? taxonomy.retryable, + traceId, + provider, + backend, + runner: error.runner ?? context.runner, + capabilityLevel, + route, + toolName, + category: taxonomy.category, + blockers + }); const normalized = { - code: error.code || "code_agent_failed", - message: error.message || "Code Agent request failed" + code, + layer: taxonomy.layer, + category: taxonomy.category, + blocker, + retryable: blocker.retryable, + userMessage: blocker.userMessage, + message, + traceId, + provider, + model, + backend, + runner: runnerKind, + runnerKind, + capabilityLevel, + route, + toolName, + missingConfig: safeMissingConfig(error), + blockers }; for (const key of [ "missingEnv", "missingCommands", - "provider", - "model", - "backend", "command", "exitCode", "providerStatus", "stderrSummary", - "blockers", - "missingTools" + "missingTools", + "reason" ]) { if (error[key] !== undefined) { - normalized[key] = error[key]; + normalized[key] = sanitizeErrorField(key, error[key]); } } return normalized; } +function errorTaxonomy(code, error = {}) { + const catalog = { + invalid_params: { + layer: "api", + category: "api_error", + retryable: true, + userMessage: "请求参数不完整或格式不正确,请修正后重试。" + }, + parse_error: { + layer: "api", + category: "api_error", + retryable: true, + userMessage: "请求 JSON 无法解析,请修正后重试。" + }, + provider_unavailable: { + layer: error.missingEnv?.length || error.missingCommands?.length ? "provider-config" : "provider", + category: error.missingEnv?.length || error.missingCommands?.length ? "needs_config" : "provider", + retryable: !(error.missingEnv?.length || error.missingCommands?.length), + userMessage: error.missingEnv?.length || error.missingCommands?.length + ? "Code Agent provider 配置缺失,需要维护者补齐后才能使用。" + : "Code Agent provider 暂不可用,可稍后重试。" + }, + provider_timeout: { + layer: "provider", + category: "timeout", + retryable: true, + userMessage: "Code Agent provider 响应超时,输入已保留,可稍后重试。" + }, + session_busy: { + layer: "session", + category: "runner_busy", + retryable: true, + userMessage: "Code Agent session 正在处理上一轮请求,请稍后重试。" + }, + session_expired: { + layer: "session", + category: "session_blocked", + retryable: true, + userMessage: "Code Agent session 已过期,可重新发送建立新的 session。" + }, + session_reuse_conflict: { + layer: "session", + category: "session_blocked", + retryable: true, + userMessage: "当前 conversation 与 session 绑定不一致,可新建会话后重试。" + }, + runner_unavailable: { + layer: "runner", + category: "runner_blocked", + retryable: true, + userMessage: "Code Agent runner 暂不可用,可稍后重试或等待工作区挂载恢复。" + }, + tool_unavailable: { + layer: "tool", + category: "capability_unavailable", + retryable: false, + userMessage: "当前 Code Agent 未开放该工具能力。" + }, + skills_unavailable: { + layer: "skill-cli", + category: "needs_config", + retryable: false, + userMessage: "Code Agent Skill 清单未挂载或不可读,需要补齐运行时配置。" + }, + security_blocked: { + layer: "security", + category: "security_blocked", + retryable: false, + userMessage: "该请求被安全边界阻断,不能读取敏感信息或绕过 HWLAB API。" + }, + skill_cli_api_base_missing: { + layer: "skill-cli-config", + category: "needs_config", + retryable: false, + userMessage: "M3 Skill CLI 缺少 HWLAB API base URL 配置,需要补齐安全 env 或 contract。" + }, + hwlab_api_unavailable: { + layer: "hwlab-api", + category: "retryable", + retryable: true, + userMessage: "HWLAB API 当前不可达,M3 控制未执行,可稍后重试。" + }, + m3_readiness_blocked: { + layer: "m3-readiness", + category: "capability_unavailable", + retryable: false, + userMessage: "M3 控制链路尚未就绪,不能把本次结果标记为真实可控。" + }, + text_chat_only_fallback: { + layer: "provider", + category: "fallback", + retryable: false, + userMessage: "当前仍是文本 fallback,不具备 runner/session/tool/HWLAB API 控制能力。" + }, + codex_stdio_blocked: { + layer: "runner", + category: "capability_unavailable", + retryable: false, + userMessage: "Codex stdio 长会话能力仍受阻,不能作为完整 Code Agent。" + }, + codex_stdio_protocol_blocked: { + layer: "runner", + category: "capability_unavailable", + retryable: false, + userMessage: "Codex stdio 协议工具不完整,不能作为完整 Code Agent。" + }, + codex_stdio_empty_response: { + layer: "runner", + category: "runner_blocked", + retryable: true, + userMessage: "Codex stdio 未返回有效回复,可稍后重试。" + }, + codex_stdio_failed: { + layer: "runner", + category: "runner_blocked", + retryable: true, + userMessage: "Codex stdio runner 执行失败,可稍后重试。" + } + }; + return catalog[code] ?? { + layer: "api", + category: "api_error", + retryable: true, + userMessage: "Code Agent 请求失败,输入已保留,可稍后重试。" + }; +} + +function structuredBlocker({ code, layer, message, userMessage, retryable, traceId, provider, backend, runner, capabilityLevel, route, toolName, category, blockers }) { + return { + code, + layer, + category: category ?? layer, + retryable: Boolean(retryable), + summary: redactText(message), + userMessage, + traceId: traceId ?? null, + provider: provider ?? null, + backend: backend ?? null, + runner: runner?.kind ?? runner ?? null, + capabilityLevel: capabilityLevel ?? null, + route: route ?? null, + toolName: toolName ?? null, + blockerCodes: Array.isArray(blockers) ? blockers.map((blocker) => blocker.code).filter(Boolean) : [] + }; +} + +function normalizeBlockers(blockers, fallback) { + const items = Array.isArray(blockers) ? blockers : []; + const normalized = items + .filter((blocker) => blocker && typeof blocker === "object") + .map((blocker) => ({ + code: String(blocker.code ?? fallback.fallbackCode), + layer: blocker.layer ?? fallback.layer, + category: blocker.category ?? fallback.layer, + retryable: Boolean(blocker.retryable ?? fallback.retryable), + summary: redactText(blocker.summary ?? blocker.message ?? fallback.message), + userMessage: blocker.userMessage ?? blocker.zh ?? fallback.userMessage, + sourceIssue: blocker.sourceIssue, + route: blocker.route, + toolName: blocker.toolName + })); + if (normalized.length > 0) return normalized; + return [{ + code: fallback.fallbackCode, + layer: fallback.layer, + category: fallback.layer, + retryable: Boolean(fallback.retryable), + summary: redactText(fallback.message), + userMessage: fallback.userMessage + }]; +} + +function safeMissingConfig(error) { + return [ + ...(Array.isArray(error.missingEnv) ? error.missingEnv : []), + ...(Array.isArray(error.missingCommands) ? error.missingCommands.map((command) => `command:${command}`) : []), + ...(Array.isArray(error.missingTools) ? error.missingTools.map((tool) => `tool:${tool}`) : []) + ].filter((item) => typeof item === "string" && /^[A-Za-z0-9_./:-]+$/u.test(item)); +} + +function sanitizeErrorField(key, value) { + if (key === "missingEnv" || key === "missingCommands" || key === "missingTools") { + return Array.isArray(value) ? value.filter((item) => typeof item === "string").map((item) => redactText(item)) : []; + } + if (key === "providerStatus" || key === "exitCode") return value; + return redactText(value); +} + +function routeForError(code, error = {}) { + if (error.route !== undefined) return error.route; + if (["skill_cli_api_base_missing", "hwlab_api_unavailable", "m3_readiness_blocked"].includes(code)) return HWLAB_M3_IO_API_ROUTE; + return null; +} + +function toolNameForError(code, error = {}) { + if (error.toolName !== undefined) return error.toolName; + if (["skill_cli_api_base_missing", "hwlab_api_unavailable", "m3_readiness_blocked"].includes(code)) return HWLAB_M3_IO_SKILL_NAME; + return null; +} + function badRequest(message) { const error = new Error(message); error.code = "invalid_params"; @@ -2726,7 +3127,7 @@ function badRequest(message) { function providerUnavailable(message, details = {}) { const error = new Error(message); - error.code = "provider_unavailable"; + error.code = details.code ?? "provider_unavailable"; Object.assign(error, details); return error; } diff --git a/internal/cloud/code-agent-session-registry.test.mjs b/internal/cloud/code-agent-session-registry.test.mjs index a8d788b5..910b42ef 100644 --- a/internal/cloud/code-agent-session-registry.test.mjs +++ b/internal/cloud/code-agent-session-registry.test.mjs @@ -198,6 +198,11 @@ test("expired and busy session states are surfaced as structured blockers", asyn ); assert.equal(expired.status, "failed"); assert.equal(expired.error.code, "session_expired"); + assert.equal(expired.error.layer, "session"); + assert.equal(expired.error.retryable, true); + assert.match(expired.error.userMessage, /session 已过期/u); + assert.equal(expired.error.blocker.code, "session_expired"); + assert.equal(expired.error.blocker.traceId, "trc_expired_request"); assert.equal(expired.session.status, "expired"); assert.equal(expired.capabilityLevel, "blocked"); assert.equal(expired.longLivedSessionGate.status, "blocked"); @@ -231,6 +236,10 @@ test("expired and busy session states are surfaced as structured blockers", asyn ); assert.equal(busy.status, "failed"); assert.equal(busy.error.code, "session_busy"); + assert.equal(busy.error.layer, "session"); + assert.equal(busy.error.retryable, true); + assert.match(busy.error.userMessage, /正在处理上一轮/u); + assert.equal(busy.error.blocker.code, "session_busy"); assert.equal(busy.session.status, "busy"); assert.equal(busy.capabilityLevel, "blocked"); assert.equal(busy.error.blockers[0].sourceIssue, "pikasTech/HWLAB#317"); @@ -260,6 +269,10 @@ test("OpenAI fallback and codex one-shot do not pass the long-lived session gate assert.equal(fallback.status, "completed"); assert.equal(fallback.runner.kind, "openai-responses-fallback"); assert.equal(fallback.capabilityLevel, "text-chat-only"); + assert.equal(fallback.blocker.code, "text_chat_only_fallback"); + assert.equal(fallback.blocker.layer, "provider"); + assert.equal(fallback.blocker.retryable, false); + assert.match(fallback.blocker.userMessage, /文本 fallback/u); assert.equal(fallback.longLivedSessionGate.status, "blocked"); assert.ok(fallback.longLivedSessionGate.blockers.some((blocker) => blocker.code === "openai_responses_fallback_not_session")); assert.equal(classifyCodexRunnerCapability(fallback, { httpStatus: 200 }).capabilityPass, false); @@ -476,6 +489,11 @@ test("Code Agent M3 DI read returns structured blocker from HWLAB API without fa assert.equal(payload.status, "completed"); assert.equal(payload.provider, "hwlab-skill-cli"); assert.equal(payload.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.blocked); + assert.equal(payload.blocker.code, "m3_readiness_blocked"); + assert.equal(payload.blocker.layer, "m3-readiness"); + assert.equal(payload.blocker.retryable, false); + assert.equal(payload.blocker.route, HWLAB_M3_IO_API_ROUTE); + assert.equal(payload.blocker.toolName, "hwlab-agent-runtime.m3-io"); assert.equal(payload.toolCalls[0].status, "blocked"); assert.equal(payload.toolCalls[0].route, HWLAB_M3_IO_API_ROUTE); assert.equal(payload.toolCalls[0].capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.blocked); @@ -494,6 +512,77 @@ test("Code Agent M3 DI read returns structured blocker from HWLAB API without fa assert.equal(calls[0].request.body.port, "DI1"); }); +test("Code Agent M3 Skill CLI missing API base returns structured config blocker", async () => { + const payload = await handleCodeAgentChat( + { + conversationId: "cnv_m3_skill_api_base_missing", + traceId: "trc_m3_skill_api_base_missing", + message: "读取 M3 DI1 readback" + }, + { + now: () => "2026-05-23T00:06:30.000Z", + env: { + PATH: process.env.PATH, + HWLAB_CODE_AGENT_WORKSPACE: process.cwd(), + HWLAB_CODE_AGENT_REQUIRE_HWLAB_API_BASE_URL: "1" + }, + m3IoSkillRequestJson: async () => { + throw new Error("missing API base must not issue a network request"); + } + } + ); + + validateCodeAgentChatSchema(payload); + assert.equal(payload.status, "completed"); + assert.equal(payload.provider, "hwlab-skill-cli"); + assert.equal(payload.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.blocked); + assert.equal(payload.blocker.code, "m3_readiness_blocked"); + assert.equal(payload.toolCalls[0].blocker.code, "skill_cli_api_base_missing"); + assert.equal(payload.toolCalls[0].blocker.layer, "skill-cli-config"); + assert.equal(payload.toolCalls[0].blocker.retryable, false); + assert.deepEqual(payload.toolCalls[0].blocker.missingConfig, [ + "HWLAB_CODE_AGENT_HWLAB_API_BASE_URL", + "HWLAB_API_BASE_URL", + "HWLAB_CLOUD_API_BASE_URL", + "contract:hwlab-agent-runtime.m3-io.apiBaseUrl" + ]); + assert.equal(JSON.stringify(payload).includes("://"), false); +}); + +test("Code Agent M3 Skill CLI HWLAB API unavailable returns retryable structured blocker", async () => { + const payload = await handleCodeAgentChat( + { + conversationId: "cnv_m3_skill_hwlab_api_unavailable", + traceId: "trc_m3_skill_hwlab_api_unavailable", + message: "读取 M3 DI1 readback" + }, + { + now: () => "2026-05-23T00:06:40.000Z", + env: { + PATH: process.env.PATH, + HWLAB_CODE_AGENT_WORKSPACE: process.cwd(), + HWLAB_API_BASE_URL: "http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667" + }, + m3IoSkillRequestJson: async () => ({ + ok: false, + status: 503, + body: null, + error: "service unavailable" + }) + } + ); + + validateCodeAgentChatSchema(payload); + assert.equal(payload.status, "completed"); + assert.equal(payload.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.blocked); + assert.equal(payload.blocker.code, "hwlab_api_unavailable"); + assert.equal(payload.blocker.layer, "hwlab-api"); + assert.equal(payload.blocker.retryable, true); + assert.equal(payload.blocker.route, HWLAB_M3_IO_API_ROUTE); + assert.equal(payload.toolCalls[0].blocker.code, "hwlab_api_unavailable"); + assert.equal(payload.toolCalls[0].blocker.retryable, true); +}); + test("Code Agent blocks direct gateway or patch-panel requests instead of using M3 Skill CLI", async () => { const direct = await handleCodeAgentChat( { @@ -517,6 +606,10 @@ test("Code Agent blocks direct gateway or patch-panel requests instead of using validateCodeAgentChatSchema(direct); assert.equal(direct.status, "failed"); assert.equal(direct.error.code, "security_blocked"); + assert.equal(direct.error.layer, "security"); + assert.equal(direct.error.retryable, false); + assert.match(direct.error.userMessage, /安全边界/u); + assert.equal(direct.error.blocker.toolName, "security.hardware-boundary"); assert.equal(direct.toolCalls[0].name, "security.hardware-boundary"); assert.match(direct.error.message, /Skill CLI -> HWLAB API \/v1\/m3\/io/u); }); @@ -550,18 +643,25 @@ test("Code Agent M3 Skill CLI blocks missing service-local HWLAB API base URL be assert.equal(payload.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.blocked); assert.equal(payload.toolCalls.length, 1); assert.equal(payload.toolCalls[0].status, "blocked"); - assert.equal(payload.toolCalls[0].blocker.code, "hwlab_api_base_url_missing"); - assert.equal(payload.toolCalls[0].hwlabApi.url, null); + assert.equal(payload.toolCalls[0].blocker.code, "skill_cli_api_base_missing"); + assert.equal(payload.toolCalls[0].blocker.layer, "skill-cli-config"); + assert.equal(payload.toolCalls[0].blocker.retryable, false); + assert.deepEqual(payload.toolCalls[0].blocker.missingConfig, [ + "HWLAB_CODE_AGENT_HWLAB_API_BASE_URL", + "HWLAB_API_BASE_URL", + "HWLAB_CLOUD_API_BASE_URL", + "contract:hwlab-agent-runtime.m3-io.apiBaseUrl" + ]); assert.equal(payload.toolCalls[0].hwlabApi.source, "missing-config"); - assert.equal(payload.toolCalls[0].hwlabApi.recommendedEnv, HWLAB_M3_IO_API_BASE_URL_ENV); + assert.equal(payload.toolCalls[0].hwlabApi.redactedUrl, null); assert.equal(payload.toolCalls[0].command.includes("127.0.0.1:6667"), false); assert.equal(payload.toolCalls[0].accepted, false); assert.equal(payload.toolCalls[0].operationId, null); assert.equal(payload.session.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.blocked); - assert.equal(payload.runnerTrace.blocker.code, "hwlab_api_base_url_missing"); + assert.equal(payload.runnerTrace.blocker.code, "skill_cli_api_base_missing"); assert.equal(payload.runnerTrace.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.blocked); assert.equal(payload.providerTrace.fallbackUsed, false); - assert.equal(payload.skills.blockers[0].code, "hwlab_api_base_url_missing"); + assert.equal(payload.skills.blockers[0].code, "skill_cli_api_base_missing"); }); test("Codex stdio manager reports concrete blockers without falling back to readonly", async () => { @@ -592,6 +692,10 @@ test("Codex stdio manager reports concrete blockers without falling back to read assert.equal(blocked.provider, "codex-stdio"); assert.equal(blocked.backend, "hwlab-cloud-api/codex-mcp-stdio"); assert.equal(blocked.error.code, "codex_stdio_blocked"); + assert.equal(blocked.error.layer, "runner"); + assert.equal(blocked.error.retryable, false); + assert.match(blocked.error.userMessage, /Codex stdio/u); + assert.equal(blocked.error.blocker.runner, "codex-mcp-stdio-runner"); assert.ok(blocked.error.blockers.some((blocker) => blocker.code === "codex_cli_binary_missing")); assert.ok(blocked.error.blockers.some((blocker) => blocker.code === "provider_token_boundary")); assert.equal(blocked.capabilityLevel, "blocked"); diff --git a/internal/cloud/server.mjs b/internal/cloud/server.mjs index 1f53f19f..26800cb1 100644 --- a/internal/cloud/server.mjs +++ b/internal/cloud/server.mjs @@ -12,7 +12,11 @@ import { createErrorEnvelope, handleJsonRpcRequest } from "./json-rpc.mjs"; -import { describeCodeAgentAvailability, handleCodeAgentChat } from "./code-agent-chat.mjs"; +import { + createCodeAgentErrorPayload, + describeCodeAgentAvailability, + handleCodeAgentChat +} from "./code-agent-chat.mjs"; import { buildGateDiagnosticsRows } from "./gate-diagnostics.mjs"; import { applyRuntimeDbReadinessLayers, @@ -293,41 +297,29 @@ async function handleCodeAgentChatHttp(request, response, options) { params = body ? JSON.parse(body) : {}; } catch (error) { sendJson(response, 400, { - conversationId: "cnv_unassigned", - sessionId: "cnv_unassigned", - messageId: "msg_unassigned", - status: "failed", - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - traceId: getHeader(request, "x-trace-id") || "trc_unassigned", - provider: "unassigned", - model: process.env.HWLAB_CODE_AGENT_MODEL || process.env.OPENAI_MODEL || "unknown", - backend: "hwlab-cloud-api/code-agent-chat", - error: { + ...createCodeAgentErrorPayload({ code: "parse_error", message: "Invalid JSON body", - reason: error.message - } + reason: error.message, + traceId: getHeader(request, "x-trace-id") || "trc_unassigned", + model: options.env?.HWLAB_CODE_AGENT_MODEL || options.env?.OPENAI_MODEL || process.env.HWLAB_CODE_AGENT_MODEL || process.env.OPENAI_MODEL || "unknown", + layer: "api", + retryable: true + }) }); return; } if (!params || typeof params !== "object" || Array.isArray(params)) { sendJson(response, 400, { - conversationId: "cnv_unassigned", - sessionId: "cnv_unassigned", - messageId: "msg_unassigned", - status: "failed", - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - traceId: getHeader(request, "x-trace-id") || "trc_unassigned", - provider: "unassigned", - model: process.env.HWLAB_CODE_AGENT_MODEL || process.env.OPENAI_MODEL || "unknown", - backend: "hwlab-cloud-api/code-agent-chat", - error: { + ...createCodeAgentErrorPayload({ code: "invalid_params", - message: "Code Agent chat body must be a JSON object" - } + message: "Code Agent chat body must be a JSON object", + traceId: getHeader(request, "x-trace-id") || "trc_unassigned", + model: options.env?.HWLAB_CODE_AGENT_MODEL || options.env?.OPENAI_MODEL || process.env.HWLAB_CODE_AGENT_MODEL || process.env.OPENAI_MODEL || "unknown", + layer: "api", + retryable: true + }) }); return; } diff --git a/internal/cloud/server.test.mjs b/internal/cloud/server.test.mjs index 01ce7f20..a6c4c6eb 100644 --- a/internal/cloud/server.test.mjs +++ b/internal/cloud/server.test.mjs @@ -15,6 +15,7 @@ import { RUNTIME_DURABLE_ADAPTER_SCHEMA_BLOCKED, RUNTIME_DURABLE_ADAPTER_SSL_BLOCKED } from "../db/runtime-store.mjs"; +import { HWLAB_M3_IO_CAPABILITY_LEVELS } from "../../skills/hwlab-agent-runtime/scripts/src/m3-io-skill-client.mjs"; test("cloud api exposes /health, /health/live, and /live probes", async () => { const originalUrl = process.env.HWLAB_CLOUD_DB_URL; @@ -955,6 +956,39 @@ test("cloud api /v1/agent/chat returns structured completed Code Agent payload", } }); +test("cloud api /v1/agent/chat parse and params errors use structured blocker envelope", async () => { + const server = createCloudApiServer({ + env: { + HWLAB_CODE_AGENT_MODEL: "gpt-test" + } + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + + try { + const { port } = server.address(); + const parse = await postAgentRaw(port, "{", { "x-trace-id": "trc_parse_error" }); + assert.equal(parse.status, 400); + assert.equal(parse.body.status, "failed"); + assert.equal(parse.body.error.code, "parse_error"); + assert.equal(parse.body.error.layer, "api"); + assert.equal(parse.body.error.retryable, true); + assert.equal(parse.body.error.traceId, "trc_parse_error"); + assert.equal(parse.body.error.route, "/v1/agent/chat"); + assert.match(parse.body.error.userMessage, /JSON/u); + + const invalid = await postAgentRaw(port, JSON.stringify([]), { "x-trace-id": "trc_invalid_params" }); + assert.equal(invalid.status, 400); + assert.equal(invalid.body.error.code, "invalid_params"); + assert.equal(invalid.body.error.layer, "api"); + assert.equal(invalid.body.error.retryable, true); + assert.equal(invalid.body.error.blocker.code, "invalid_params"); + } finally { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + } +}); + test("cloud api /v1/agent/chat runs read-only runner pwd with workspace evidence", async () => { const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-workspace-")); const server = createCloudApiServer({ @@ -1127,6 +1161,46 @@ test("cloud api /v1/agent/chat routes M3 IO through Skill CLI to /v1/m3/io only" } }); +test("cloud api /v1/agent/chat reports M3 Skill CLI missing API base as structured blocker", async () => { + const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-m3-api-base-missing-")); + const server = createCloudApiServer({ + env: { + PATH: process.env.PATH, + HWLAB_CODE_AGENT_WORKSPACE: workspace, + HWLAB_CODE_AGENT_REQUIRE_HWLAB_API_BASE_URL: "1" + }, + m3IoSkillRequestJson: async () => { + throw new Error("missing API base must not call requestJson"); + } + }); + 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-api-base-missing", + traceId: "trc_server-test-m3-api-base-missing", + message: "读取 M3 DI1" + }); + assert.equal(payload.status, "completed"); + assert.equal(payload.provider, "hwlab-skill-cli"); + assert.equal(payload.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.blocked); + assert.equal(payload.blocker.code, "m3_readiness_blocked"); + assert.equal(payload.toolCalls[0].blocker.code, "skill_cli_api_base_missing"); + assert.deepEqual(payload.toolCalls[0].blocker.missingConfig, [ + "HWLAB_CODE_AGENT_HWLAB_API_BASE_URL", + "HWLAB_API_BASE_URL", + "HWLAB_CLOUD_API_BASE_URL", + "contract:hwlab-agent-runtime.m3-io.apiBaseUrl" + ]); + assert.equal(JSON.stringify(payload).includes("test-openai-key-material"), false); + } finally { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + } +}); + test("cloud api /v1/agent/chat reuses controlled read-only session and exposes bounded file tools", async () => { const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-file-tools-")); await writeFile(path.join(workspace, "alpha.txt"), "alpha\nbeta\n", "utf8"); @@ -1594,7 +1668,10 @@ test("cloud api /v1/agent/chat reports provider timeout as failed without a repl const payload = await response.json(); assert.equal(payload.status, "failed"); assert.equal(payload.traceId, "trc_server-test-agent-chat-provider-timeout"); - assert.equal(payload.error.code, "provider_unavailable"); + assert.equal(payload.error.code, "provider_timeout"); + assert.equal(payload.error.layer, "provider"); + assert.equal(payload.error.retryable, true); + assert.match(payload.error.userMessage, /超时/u); assert.match(payload.error.message, /timed out after 50ms/u); assert.equal(Object.hasOwn(payload, "reply"), false); } finally { @@ -1649,6 +1726,9 @@ test("cloud api /v1/agent/chat reports OpenAI provider 502 and 503 as failed blo assert.equal(payload.status, "failed"); assert.equal(payload.traceId, `trc_server-test-agent-chat-provider-${status}`); assert.equal(payload.error.code, "provider_unavailable"); + assert.equal(payload.error.layer, "provider"); + assert.equal(payload.error.retryable, true); + assert.match(payload.error.userMessage, /provider/u); assert.equal(payload.error.providerStatus, status); assert.match(payload.error.message, new RegExp(`HTTP ${status}`)); assert.equal(Object.hasOwn(payload, "reply"), false); @@ -1695,9 +1775,14 @@ test("cloud api /v1/agent/chat reports provider gaps without faking a reply", as assert.equal(Number.isNaN(Date.parse(payload.createdAt)), false); assert.equal(Number.isNaN(Date.parse(payload.updatedAt)), false); assert.equal(payload.error.code, "provider_unavailable"); + assert.equal(payload.error.layer, "provider-config"); + assert.equal(payload.error.retryable, false); + assert.match(payload.error.userMessage, /配置缺失/u); assert.match(payload.error.message, /Codex CLI command is not available/); assert.deepEqual(payload.error.missingCommands, ["codex"]); assert.ok(payload.error.missingEnv.includes("OPENAI_API_KEY")); + assert.ok(payload.error.missingConfig.includes("OPENAI_API_KEY")); + assert.ok(payload.error.missingConfig.includes("command:codex")); assert.equal(payload.availability.status, "partial"); assert.match(payload.availability.blocker, /Codex stdio|long-lived/u); assert.equal(payload.availability.reason, "codex_stdio_supervisor_disabled"); @@ -1985,6 +2070,21 @@ async function postAgent(port, body) { return response.json(); } +async function postAgentRaw(port, body, headers = {}) { + const response = await fetch(`http://127.0.0.1:${port}/v1/agent/chat`, { + method: "POST", + headers: { + "content-type": "application/json", + ...headers + }, + body + }); + return { + status: response.status, + body: await response.json() + }; +} + function createGreenM3StatusRuntimeStore({ blocker = null } = {}) { return { async readiness() { diff --git a/scripts/dev-cloud-workbench-smoke.test.mjs b/scripts/dev-cloud-workbench-smoke.test.mjs index 0fe75424..3ac58b9b 100644 --- a/scripts/dev-cloud-workbench-smoke.test.mjs +++ b/scripts/dev-cloud-workbench-smoke.test.mjs @@ -463,6 +463,87 @@ test("Code Agent browser classifier distinguishes runner busy, session blocked, assert.equal(apiError.category, "api_error"); }); +test("Code Agent browser classifier consumes structured blocker taxonomy", () => { + const base = { + status: "failed", + provider: "hwlab-skill-cli", + model: "controlled-m3-io", + backend: "hwlab-cloud-api/hwlab-agent-runtime-skill-cli", + traceId: "trc_structured_blocker" + }; + for (const [code, expected] of [ + ["skill_cli_api_base_missing", ["needs-config", "needs_config"]], + ["hwlab_api_unavailable", ["retryable", "retryable"]], + ["m3_readiness_blocked", ["capability-unavailable", "capability_unavailable"]], + ["text_chat_only_fallback", ["text-chat-only-fallback", "fallback"]] + ]) { + const [blocker, category] = expected; + const classification = classifyCodeAgentBrowserJourney({ + responseSummary: sanitizeAgentChatBody({ + ...base, + error: { + code, + layer: code === "hwlab_api_unavailable" ? "hwlab-api" : "m3-readiness", + category, + blocker: { + code, + layer: code === "hwlab_api_unavailable" ? "hwlab-api" : "m3-readiness", + category, + retryable: category === "retryable", + userMessage: "结构化中文提示" + }, + retryable: category === "retryable", + userMessage: "结构化中文提示", + route: "/v1/m3/io", + toolName: "hwlab-agent-runtime.m3-io" + } + }, { httpStatus: 200 }), + httpOk: true, + httpStatus: 200, + ui: { + agentChatStatus: "服务受阻", + completedMessageVisible: false, + failedMessageVisible: true + } + }); + assert.equal(classification.blocker, blocker); + assert.equal(classification.category, category); + } +}); + +test("Code Agent browser classifier consumes completed blocked M3 payload blocker", () => { + const classification = classifyCodeAgentBrowserJourney({ + responseSummary: sanitizeAgentChatBody({ + status: "completed", + provider: "hwlab-skill-cli", + model: "controlled-m3-io", + backend: "hwlab-cloud-api/hwlab-agent-runtime-skill-cli", + traceId: "trc_completed_m3_blocked", + capabilityLevel: "hwlab-api-control-blocked", + reply: { + content: "M3 IO Skill CLI result blocked." + }, + blocker: { + code: "m3_readiness_blocked", + layer: "m3-readiness", + category: "capability_unavailable", + retryable: false, + userMessage: "M3 控制链路尚未就绪。" + } + }, { httpStatus: 200 }), + httpOk: true, + httpStatus: 200, + ui: { + agentChatStatus: "能力未开放", + completedMessageVisible: false, + failedMessageVisible: true + } + }); + assert.equal(classification.status, "blocked"); + assert.equal(classification.blocker, "capability-unavailable"); + assert.equal(classification.category, "capability_unavailable"); +}); + test("Code Agent readiness classifier blocks completed payloads over any non-2xx HTTP status", () => { const completedPayload = { conversationId: "cnv_completed_non_2xx", diff --git a/scripts/src/code-agent-response-contract.mjs b/scripts/src/code-agent-response-contract.mjs index 7afa175b..049f872f 100644 --- a/scripts/src/code-agent-response-contract.mjs +++ b/scripts/src/code-agent-response-contract.mjs @@ -12,6 +12,10 @@ const blockedCodeAgentUiLabels = new Set([ "Session 受阻", "Runner 受阻", "API 错误", + "需要配置", + "能力未开放", + "安全阻断", + "仍是 fallback", "BLOCKED 凭证缺口" ]); const timeoutPattern = /\b(?:timeout|timed out|abort|aborted|超时|请求超过)\b/iu; @@ -311,6 +315,7 @@ export function summarizeCodeAgentPayload(payload, { httpStatus = null, traceId traceId: observedTraceId, hasReply: false, error: blockedError, + blocker: summarizeBlocker(payload.blocker ?? payload.error?.blocker), runner: summarizeRunner(payload), workspace: stringOrNull(payload.workspace), sandbox: stringOrNull(payload.sandbox), @@ -338,6 +343,7 @@ export function summarizeCodeAgentPayload(payload, { httpStatus = null, traceId traceId: observedTraceId, hasReply: assistantReply.length > 0, error, + blocker: summarizeBlocker(payload.blocker ?? payload.error?.blocker), runner: summarizeRunner(payload), workspace: stringOrNull(payload.workspace), sandbox: stringOrNull(payload.sandbox), @@ -406,8 +412,55 @@ export function classifyCodeAgentBrowserJourney({ responseSummary, httpOk = fals export function classifyCodeAgentRuntimeBlock(payload, { httpStatus = null } = {}) { const errorCode = stringOrNull(payload?.error?.code); + const blockerCode = stringOrNull(payload?.blocker?.code ?? payload?.error?.blocker?.code); + const layer = stringOrNull(payload?.error?.layer ?? payload?.blocker?.layer ?? payload?.error?.blocker?.layer); + const category = stringOrNull(payload?.error?.category ?? payload?.blocker?.category ?? payload?.error?.blocker?.category); const payloadStatus = stringOrNull(payload?.status); + if (["skill_cli_api_base_missing"].includes(errorCode ?? blockerCode)) { + return { + blocked: true, + status: "blocked", + blocker: "needs-config", + category: "needs_config", + layer, + reason: "M3 Skill CLI is missing a safe HWLAB API base URL contract; do not show generic send failure." + }; + } + + if (["hwlab_api_unavailable"].includes(errorCode ?? blockerCode)) { + return { + blocked: true, + status: "blocked", + blocker: "retryable", + category: "retryable", + layer, + reason: "HWLAB API is unavailable; the UI should show retryable blocker semantics." + }; + } + + if (["text_chat_only_fallback"].includes(errorCode ?? blockerCode) || category === "fallback") { + return { + blocked: true, + status: "blocked", + blocker: "text-chat-only-fallback", + category: "fallback", + layer, + reason: "Code Agent response is still text chat fallback and does not satisfy runner/session/tool control." + }; + } + + if (["m3_readiness_blocked"].includes(errorCode ?? blockerCode) || layer === "m3-readiness") { + return { + blocked: true, + status: "blocked", + blocker: "capability-unavailable", + category: category ?? "capability_unavailable", + layer, + reason: "M3 readiness is blocked; the UI must not treat this as generic send failure or completed control." + }; + } + if (errorCode === "session_busy") { return { blocked: true, @@ -464,6 +517,15 @@ export function classifyCodeAgentRuntimeBlock(payload, { httpStatus = null } = { export function classifyCodeAgentBrowserFailure(error, { traceId = null } = {}) { const message = error instanceof Error ? error.message : String(error ?? ""); const observedTraceId = traceId ?? error?.traceId ?? null; + if (error?.userMessage) { + return { + status: "blocked", + blocker: blockerForStructuredCategory(error.category, error.code), + category: error.category ?? "runner_blocked", + chineseSummary: `${error.userMessage}${observedTraceId ? `;traceId=${observedTraceId}` : ""}`, + reason: message + }; + } if (timeoutPattern.test(message)) { return { status: "blocked", @@ -518,6 +580,18 @@ export function classifyCodeAgentBrowserFailure(error, { traceId = null } = {}) }; } +function blockerForStructuredCategory(category, code) { + if (category === "needs_config") return "needs-config"; + if (category === "security_blocked" || code === "security_blocked") return "security-blocked"; + if (category === "fallback" || code === "text_chat_only_fallback") return "text-chat-only-fallback"; + if (category === "capability_unavailable") return "capability-unavailable"; + if (category === "retryable") return "retryable"; + if (category === "runner_busy") return "runner-busy"; + if (category === "session_blocked") return "session-blocked"; + if (category === "timeout") return "timeout"; + return "runtime"; +} + export function inspectRealCompletionEvidence(payload) { const provider = String(payload?.provider ?? "").trim().toLowerCase(); const backend = String(payload?.backend ?? "").trim().toLowerCase(); @@ -559,6 +633,15 @@ export function normalizeCodeAgentError(error) { if (!error || typeof error !== "object") return null; return { code: stringOrNull(error.code), + layer: stringOrNull(error.layer), + category: stringOrNull(error.category), + retryable: error.retryable === true, + userMessage: stringOrNull(error.userMessage), + traceId: stringOrNull(error.traceId), + route: stringOrNull(error.route), + toolName: stringOrNull(error.toolName), + blocker: summarizeBlocker(error.blocker), + missingConfig: Array.isArray(error.missingConfig) ? error.missingConfig.filter((item) => typeof item === "string") : [], missingEnv: Array.isArray(error.missingEnv) ? error.missingEnv.filter((item) => typeof item === "string") : [], missingCommands: Array.isArray(error.missingCommands) ? error.missingCommands.filter((item) => typeof item === "string") : [], providerStatus: numericStatus(error.providerStatus) @@ -618,6 +701,9 @@ function summarizeToolCalls(value) { name: stringOrNull(tool?.name), type: stringOrNull(tool?.type), status: stringOrNull(tool?.status), + route: stringOrNull(tool?.route), + capabilityLevel: stringOrNull(tool?.capabilityLevel), + blocker: summarizeBlocker(tool?.blocker), exitCode: numericStatus(tool?.exitCode), outputTruncated: tool?.outputTruncated === true })); @@ -642,6 +728,9 @@ function summarizeRunnerTrace(value) { if (!value || typeof value !== "object") return null; return { runnerKind: stringOrNull(value.runnerKind), + route: stringOrNull(value.route), + capabilityLevel: stringOrNull(value.capabilityLevel), + blocker: summarizeBlocker(value.blocker), sandbox: stringOrNull(value.sandbox), sessionMode: stringOrNull(value.sessionMode), implementationType: stringOrNull(value.implementationType), @@ -652,6 +741,19 @@ function summarizeRunnerTrace(value) { }; } +function summarizeBlocker(value) { + if (!value || typeof value !== "object") return null; + return { + code: stringOrNull(value.code), + layer: stringOrNull(value.layer), + category: stringOrNull(value.category), + retryable: value.retryable === true, + userMessage: stringOrNull(value.userMessage), + route: stringOrNull(value.route), + toolName: stringOrNull(value.toolName) + }; +} + function summarizeSessionReuse(value) { if (!value || typeof value !== "object") return null; return { diff --git a/skills/hwlab-agent-runtime/SKILL.md b/skills/hwlab-agent-runtime/SKILL.md index 12b04eea..a66d0bdb 100644 --- a/skills/hwlab-agent-runtime/SKILL.md +++ b/skills/hwlab-agent-runtime/SKILL.md @@ -52,7 +52,7 @@ endpoint. In the cloud-api runtime, configure so the Skill CLI can reach the service-local HWLAB API from inside the container. The CLI appends `/v1/m3/io` itself and rejects direct gateway/box/patch-panel targets. If no API base URL is configured, the CLI -returns `hwlab_api_base_url_missing` and does not fall back to `127.0.0.1` or +returns `skill_cli_api_base_missing` and does not fall back to `127.0.0.1` or any direct hardware service. The JSON response includes `route`, `traceId`, `operationId`, `audit`, diff --git a/skills/hwlab-agent-runtime/scripts/m3-io-skill-client.test.mjs b/skills/hwlab-agent-runtime/scripts/m3-io-skill-client.test.mjs index cdfe4496..024b0e0d 100644 --- a/skills/hwlab-agent-runtime/scripts/m3-io-skill-client.test.mjs +++ b/skills/hwlab-agent-runtime/scripts/m3-io-skill-client.test.mjs @@ -148,7 +148,7 @@ test("M3 Skill CLI validates exact API route contract", () => { assert.equal(invalid.code, "invalid_hwlab_api_route"); }); -test("M3 Skill CLI blocks missing API base URL instead of falling back to loopback", async () => { +test("M3 Skill CLI reports missing API base as safe structured config blocker", async () => { const result = await runM3IoSkillCommand( [ "m3", @@ -156,31 +156,66 @@ test("M3 Skill CLI blocks missing API base URL instead of falling back to loopba "--action", "di.read", "--trace-id", - "trc_skill_cli_missing_api_base" + "trc_skill_cli_api_base_missing" ], { env: { PATH: process.env.PATH }, requestJson: async () => { - throw new Error("missing HWLAB API base URL must block before network call"); + throw new Error("missing API base must be blocked before request"); } } ); - assert.equal(result.ok, false); assert.equal(result.status, "blocked"); - assert.equal(result.accepted, false); - assert.equal(result.blocker.code, "hwlab_api_base_url_missing"); - assert.equal(result.blocker.category, "hwlab_api_configuration"); + assert.equal(result.blocker.code, "skill_cli_api_base_missing"); + assert.equal(result.blocker.layer, "skill-cli-config"); + assert.equal(result.blocker.retryable, false); + assert.equal(result.error.code, "skill_cli_api_base_missing"); assert.equal(result.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.blocked); - assert.equal(result.hwlabApi.url, null); - assert.equal(result.hwlabApi.source, "missing-config"); - assert.equal(result.hwlabApi.recommendedEnv, HWLAB_M3_IO_API_BASE_URL_ENV); - assert.equal(result.hwlabApi.recommendedDevValue, HWLAB_M3_IO_DEV_SERVICE_BASE_URL); + assert.equal(result.accepted, false); assert.equal(result.operationId, null); assert.equal(result.audit.status, "not_written"); - assert.equal(result.safety.directGatewayCalls, false); - assert.equal(result.safety.directBoxCalls, false); - assert.equal(result.safety.directPatchPanelCalls, false); + assert.equal(result.hwlabApi.source, "missing-config"); + assert.equal(result.hwlabApi.redactedUrl, null); + assert.deepEqual(result.error.missingConfig, [ + "HWLAB_CODE_AGENT_HWLAB_API_BASE_URL", + "HWLAB_API_BASE_URL", + "HWLAB_CLOUD_API_BASE_URL", + "contract:hwlab-agent-runtime.m3-io.apiBaseUrl" + ]); + assert.equal(JSON.stringify(result).includes("://"), false); +}); + +test("M3 Skill CLI reports HWLAB API unavailable as retryable structured blocker", async () => { + const result = await runM3IoSkillCommand( + [ + "m3", + "io", + "--action", + "di.read", + "--api-base-url", + "http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667", + "--trace-id", + "trc_skill_cli_hwlab_api_unavailable" + ], + { + requestJson: async () => ({ + ok: false, + status: 503, + body: null, + error: "service unavailable" + }) + } + ); + assert.equal(result.ok, false); + assert.equal(result.status, "blocked"); + assert.equal(result.blocker.code, "hwlab_api_unavailable"); + assert.equal(result.blocker.layer, "hwlab-api"); + assert.equal(result.blocker.retryable, true); + assert.equal(result.error.code, "hwlab_api_unavailable"); + assert.equal(result.error.retryable, true); + assert.equal(result.error.route, HWLAB_M3_IO_API_ROUTE); + assert.equal(result.error.toolName, "hwlab-agent-runtime.m3-io"); }); 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 2e21b954..5cc77ed4 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 @@ -18,6 +18,11 @@ export const HWLAB_M3_IO_CAPABILITY_LEVELS = Object.freeze({ const allowedActions = new Set(["do.write", "di.read"]); const forbiddenDirectTarget = /(?:gateway(?:-simu)?|box(?:-simu)?|patch-panel|hwlab-patch-panel|\/invoke\b|\/sync\/tick\b|:7101\b|:7201\b|:7301\b)/iu; const defaultTimeoutMs = 30000; +const safeApiBaseEnvNames = Object.freeze([ + "HWLAB_CODE_AGENT_HWLAB_API_BASE_URL", + "HWLAB_API_BASE_URL", + "HWLAB_CLOUD_API_BASE_URL" +]); export async function runM3IoSkillCommand(argv = [], options = {}) { const env = options.env ?? process.env; @@ -37,7 +42,8 @@ export async function runM3IoSkillCommand(argv = [], options = {}) { requestId, apiTarget, code: validation.code, - message: validation.message + message: validation.message, + missingConfig: validation.missingConfig }); } @@ -151,7 +157,8 @@ export function resolveCloudApiTarget(apiBaseUrl, env = process.env) { source, url: null, redactedUrl: null, - baseUrlConfigured: false + baseUrlConfigured: false, + missingConfig: [...safeApiBaseEnvNames, "contract:hwlab-agent-runtime.m3-io.apiBaseUrl"] }); } @@ -176,7 +183,7 @@ export function resolveCloudApiTarget(apiBaseUrl, env = process.env) { }); } -function cloudApiTargetEnvelope({ source, url, redactedUrl, baseUrlConfigured, invalidBaseUrl = false }) { +function cloudApiTargetEnvelope({ source, url, redactedUrl, baseUrlConfigured, invalidBaseUrl = false, missingConfig = [] }) { return { route: HWLAB_M3_IO_API_ROUTE, url, @@ -185,8 +192,8 @@ function cloudApiTargetEnvelope({ source, url, redactedUrl, baseUrlConfigured, i baseUrlConfigured, requiredEnv: [...HWLAB_M3_IO_API_BASE_URL_ENVS], recommendedEnv: HWLAB_M3_IO_API_BASE_URL_ENV, - recommendedDevValue: HWLAB_M3_IO_DEV_SERVICE_BASE_URL, invalidBaseUrl, + missingConfig: sanitizeMissingConfig(missingConfig), cloudApiOnly: true, directGatewayCalls: false, directBoxCalls: false, @@ -195,10 +202,11 @@ function cloudApiTargetEnvelope({ source, url, redactedUrl, baseUrlConfigured, i } export function validateCloudApiTarget(apiTarget) { - if (!apiTarget?.url) { + if (!apiTarget?.url || apiTarget.missingConfig?.length > 0) { return { - code: "hwlab_api_base_url_missing", - message: `HWLAB API base URL is missing; set ${HWLAB_M3_IO_API_BASE_URL_ENV}=${HWLAB_M3_IO_DEV_SERVICE_BASE_URL} in the cloud-api runtime or pass --api-base-url explicitly.` + code: "skill_cli_api_base_missing", + message: "M3 Skill CLI requires a configured HWLAB API base URL contract.", + missingConfig: apiTarget?.missingConfig ?? [...safeApiBaseEnvNames, "contract:hwlab-agent-runtime.m3-io.apiBaseUrl"] }; } @@ -368,7 +376,7 @@ function normalizeSkillResponse({ response, command, payload, apiTarget, traceId service: HWLAB_M3_IO_SKILL_NAME, contractVersion: HWLAB_AGENT_RUNTIME_SKILL_CLI_VERSION, route: HWLAB_M3_IO_API_ROUTE, - hwlabApi: apiTarget, + hwlabApi: publicCloudApiTarget(apiTarget), capabilityLevel, controlReady, action: command.action, @@ -429,7 +437,14 @@ function normalizeSkillResponse({ response, command, payload, apiTarget, traceId httpStatus: response.status, error: response.ok ? body.error ?? null : { code: "hwlab_api_unavailable", - message: response.error ?? "HWLAB API request failed" + layer: "hwlab-api", + blocker: capabilityBlocker, + retryable: true, + userMessage: "HWLAB API 当前不可达,M3 控制未执行,可稍后重试。", + message: response.error ?? "HWLAB API request failed", + traceId, + route: HWLAB_M3_IO_API_ROUTE, + toolName: HWLAB_M3_IO_SKILL_NAME }, rawStatus: body.status ?? null, startedAt, @@ -438,19 +453,26 @@ function normalizeSkillResponse({ response, command, payload, apiTarget, traceId }; } -function blockedPayload({ traceId, requestId, apiTarget, code, message }) { +function blockedPayload({ traceId, requestId, apiTarget, code, message, missingConfig = [] }) { const capabilityBlocker = { code, + layer: blockerLayer(code), message, zh: message, - category: blockerCategory(code) + category: blockerCategory(code), + retryable: retryableBlocker(code), + userMessage: userMessageForBlocker(code, message), + traceId, + route: HWLAB_M3_IO_API_ROUTE, + toolName: HWLAB_M3_IO_SKILL_NAME, + missingConfig: sanitizeMissingConfig(missingConfig) }; return { ok: false, service: HWLAB_M3_IO_SKILL_NAME, contractVersion: HWLAB_AGENT_RUNTIME_SKILL_CLI_VERSION, route: HWLAB_M3_IO_API_ROUTE, - hwlabApi: apiTarget ?? null, + hwlabApi: publicCloudApiTarget(apiTarget), capabilityLevel: HWLAB_M3_IO_CAPABILITY_LEVELS.blocked, controlReady: false, accepted: false, @@ -497,6 +519,18 @@ function blockedPayload({ traceId, requestId, apiTarget, code, message }) { directPatchPanelCalls: false, fallbackUsed: false, openAiFallbackUsed: false + }, + error: { + code, + layer: capabilityBlocker.layer, + blocker: capabilityBlocker, + retryable: capabilityBlocker.retryable, + userMessage: capabilityBlocker.userMessage, + message, + traceId, + route: HWLAB_M3_IO_API_ROUTE, + toolName: HWLAB_M3_IO_SKILL_NAME, + missingConfig: capabilityBlocker.missingConfig } }; } @@ -507,17 +541,28 @@ function normalizeBlocker(body, response) { const code = raw.code ?? body.blockerClassification?.code ?? "m3_io_blocked"; return { code, + layer: blockerLayer(code), message: raw.message ?? raw.reason ?? raw.zh ?? body.blockerClassification?.reason ?? "M3 IO request was blocked.", zh: raw.zh ?? raw.message ?? raw.reason ?? "M3 IO request was blocked.", - category: body.blockerClassification?.category ?? blockerCategory(code) + category: body.blockerClassification?.category ?? blockerCategory(code), + retryable: retryableBlocker(code), + userMessage: raw.userMessage ?? raw.zh ?? userMessageForBlocker(code, raw.message ?? raw.reason), + traceId: body.traceId ?? null, + route: HWLAB_M3_IO_API_ROUTE, + toolName: HWLAB_M3_IO_SKILL_NAME }; } if (!response.ok) { return { code: "hwlab_api_unavailable", + layer: "hwlab-api", message: response.error ?? `HWLAB API HTTP ${response.status}`, zh: response.error ?? `HWLAB API HTTP ${response.status}`, - category: "hwlab_api" + category: "hwlab_api", + retryable: true, + userMessage: "HWLAB API 当前不可达,M3 控制未执行,可稍后重试。", + route: HWLAB_M3_IO_API_ROUTE, + toolName: HWLAB_M3_IO_SKILL_NAME }; } return null; @@ -527,9 +572,14 @@ function normalizeCapabilityBlocker(blocker, response) { if (blocker) return blocker; return { code: response.ok ? "hwlab_api_control_blocked" : "hwlab_api_unavailable", + layer: response.ok ? "m3-readiness" : "hwlab-api", message: response.ok ? "HWLAB API did not accept the M3 IO control request." : response.error ?? "HWLAB API request failed", zh: response.ok ? "HWLAB API 未接受 M3 IO 控制请求。" : response.error ?? "HWLAB API 请求失败", - category: response.ok ? "readiness_blocked" : "hwlab_api" + category: response.ok ? "readiness_blocked" : "hwlab_api", + retryable: !response.ok, + userMessage: response.ok ? "M3 控制链路尚未就绪,不能标记为真实可控。" : "HWLAB API 当前不可达,M3 控制未执行,可稍后重试。", + route: HWLAB_M3_IO_API_ROUTE, + toolName: HWLAB_M3_IO_SKILL_NAME }; } @@ -542,6 +592,10 @@ function normalizeTrustBlocker(body) { zh: `可信持久化仍 blocked:${code}`, category: blockerCategory(code), layer: "runtime-durable", + retryable: false, + userMessage: "M3 可信持久化仍受阻,不能作为 DEV-LIVE 可信闭环通过。", + route: HWLAB_M3_IO_API_ROUTE, + toolName: HWLAB_M3_IO_SKILL_NAME, source: body.evidenceState?.blocker ? "evidenceState.blocker" : "durableStatus.blocker" }; } @@ -571,6 +625,34 @@ function blockerCategory(code) { return "m3_io_blocked"; } +function blockerLayer(code) { + const value = String(code ?? ""); + if (value === "skill_cli_api_base_missing") return "skill-cli-config"; + if (value === "hwlab_api_unavailable") return "hwlab-api"; + if (/direct_hardware|invalid_hwlab_api_route|invalid_hwlab_api_url/u.test(value)) return "security"; + if (/durable|runtime/u.test(value)) return "runtime-durable"; + if (/readiness|control|gateway|box|resource|wiring|patch/u.test(value)) return "m3-readiness"; + return "skill-cli"; +} + +function retryableBlocker(code) { + return ["hwlab_api_unavailable"].includes(String(code ?? "")); +} + +function userMessageForBlocker(code, message) { + if (code === "skill_cli_api_base_missing") return "M3 Skill CLI 缺少 HWLAB API base URL 配置,需要补齐安全 env 或 contract。"; + if (code === "hwlab_api_unavailable") return "HWLAB API 当前不可达,M3 控制未执行,可稍后重试。"; + if (code === "direct_hardware_target_blocked") return "该请求被安全边界阻断,不能绕过 cloud-api/HWLAB API 直接调用硬件服务。"; + if (/durable|runtime/u.test(String(code ?? ""))) return "M3 可信持久化仍受阻,不能作为 DEV-LIVE 可信闭环通过。"; + return message || "M3 控制链路仍受阻,前端应显示为能力未就绪。"; +} + +function sanitizeMissingConfig(values) { + return Array.isArray(values) + ? values.filter((value) => typeof value === "string" && /^[A-Za-z0-9_./:-]+$/u.test(value)) + : []; +} + function durableSummary(body) { const status = body.durableStatus?.status ?? body.evidenceState?.status ?? null; const blocker = body.durableStatus?.blocker ?? body.evidenceState?.blocker ?? null; @@ -681,6 +763,20 @@ function redactUrl(value) { } } +function publicCloudApiTarget(apiTarget) { + if (!apiTarget || typeof apiTarget !== "object") return null; + return { + route: apiTarget.route ?? HWLAB_M3_IO_API_ROUTE, + redactedUrl: apiTarget.redactedUrl ?? (apiTarget.url ? redactUrl(apiTarget.url) : null), + source: apiTarget.source ?? null, + missingConfig: sanitizeMissingConfig(apiTarget.missingConfig ?? []), + cloudApiOnly: apiTarget.cloudApiOnly === true, + directGatewayCalls: false, + directBoxCalls: false, + directPatchPanelCalls: false + }; +} + function parseJsonOrNull(value) { try { return JSON.parse(value); diff --git a/web/hwlab-cloud-web/app.mjs b/web/hwlab-cloud-web/app.mjs index 03aceae6..9e44facd 100644 --- a/web/hwlab-cloud-web/app.mjs +++ b/web/hwlab-cloud-web/app.mjs @@ -658,11 +658,14 @@ function initCommandBar() { const index = state.chatMessages.findIndex((message) => message.id === pendingMessage.id); const completion = classifyCodeAgentCompletion(result); const status = completion.status; + const structuredBlockedError = structuredBlockedErrorFromResult(result); state.chatMessages[index] = { ...pendingMessage, title: completion.title, text: completion.replied ? result.reply?.content || "Code Agent 没有返回文本。" + : structuredBlockedError + ? failureMessage({ ...result, error: structuredBlockedError }) : result.status === "completed" ? untrustedCompletionMessage(result) : failureMessage(result), @@ -689,8 +692,10 @@ function initCommandBar() { capabilityLevel: result.capabilityLevel, sourceKind: completion.sourceKind, providerTrace: result.providerTrace, + blocker: result.blocker, + blockers: result.blockers, updatedAt: result.updatedAt, - error: result.error ?? (result.status === "completed" && !completion.replied + error: structuredBlockedError ?? result.error ?? (result.status === "completed" && !completion.replied ? { code: "untrusted_completion", message: "completed 回复缺少真实 provider/model/trace/conversation 证据,或 provider 属于 echo/mock/stub。" @@ -719,8 +724,16 @@ function initCommandBar() { error: { code: error.code || "request_failed", category: presentation.category, + layer: error.layer, + blocker: error.blocker, + retryable: error.retryable, + userMessage: error.userMessage, message: error.message, - timeoutMs: error.timeoutMs + timeoutMs: error.timeoutMs, + providerStatus: error.providerStatus, + missingConfig: error.missingConfig, + route: error.route, + toolName: error.toolName } }; el.commandInput.value = value; @@ -2289,7 +2302,12 @@ function agentStatusLabel(status, result, labels) { runner_busy: "Runner 忙碌", session_blocked: "Session 受阻", runner_blocked: "Runner 受阻", - api_error: "API 错误" + api_error: "API 错误", + needs_config: "需要配置", + capability_unavailable: "能力未开放", + security_blocked: "安全阻断", + fallback: "仍是 fallback", + retryable: "可重试" }[presentation.category] ?? labels.failed ); } @@ -2307,6 +2325,14 @@ function textFallbackTitle(result) { } function classifyCodeAgentCompletion(result) { + if (isStructuredBlockedChatResult(result)) { + return { + status: "failed", + replied: false, + sourceKind: "BLOCKED", + title: agentFailurePresentation(structuredBlockedErrorFromResult(result), { result }).title + }; + } if (isRealCompletedChatResult(result)) { return { status: "completed", @@ -2335,12 +2361,45 @@ function classifyCodeAgentCompletion(result) { status: "failed", replied: false, sourceKind: "BLOCKED", - title: result?.status === "completed" ? "Code Agent 完成证据不足" : agentFailurePresentation(result?.error, { result }).title + title: result?.status === "completed" && !result?.blocker ? "Code Agent 完成证据不足" : agentFailurePresentation(result?.error, { result }).title + }; +} + +function isStructuredBlockedChatResult(result) { + const error = result?.error && typeof result.error === "object" ? result.error : {}; + return Boolean( + result && + typeof result === "object" && + ( + result.blocker?.code || + error.blocker?.code || + result.capabilityLevel === "hwlab-api-control-blocked" + ) + ); +} + +function structuredBlockedErrorFromResult(result) { + if (!isStructuredBlockedChatResult(result)) return null; + const error = result?.error && typeof result.error === "object" ? result.error : {}; + const blocker = error.blocker ?? result?.blocker ?? {}; + const code = normalizeErrorCode(error.code ?? blocker.code ?? "code_agent_blocked"); + return { + ...error, + code, + layer: error.layer ?? blocker.layer, + category: error.category ?? blocker.category, + blocker, + retryable: error.retryable ?? blocker.retryable, + userMessage: error.userMessage ?? blocker.userMessage, + message: error.message ?? blocker.summary ?? code, + traceId: error.traceId ?? result?.traceId, + route: error.route ?? blocker.route, + toolName: error.toolName ?? blocker.toolName }; } function failureMessage(result) { - const presentation = agentFailurePresentation(result?.error, { result }); + const presentation = agentFailurePresentation(structuredBlockedErrorFromResult(result) ?? result?.error, { result }); if (presentation.category !== "unknown") { return presentation.text; } @@ -2370,9 +2429,16 @@ function agentErrorFromHttpResponse(response, traceId) { ); const error = new Error(response.timeout ? `Code Agent 请求超过 ${response.timeoutMs}ms 未完成;已保留输入,可稍后重试。` - : response.error || "Code Agent 请求失败"); + : payloadError?.userMessage || response.error || "Code Agent 请求失败"); error.traceId = response.data?.traceId || traceId; error.code = code; + error.layer = payloadError && typeof payloadError === "object" ? payloadError.layer : undefined; + error.blocker = payloadError && typeof payloadError === "object" ? payloadError.blocker : undefined; + error.retryable = payloadError && typeof payloadError === "object" ? payloadError.retryable : response.timeout ? true : undefined; + error.userMessage = payloadError && typeof payloadError === "object" ? payloadError.userMessage : undefined; + error.missingConfig = payloadError && typeof payloadError === "object" ? payloadError.missingConfig : undefined; + error.route = payloadError && typeof payloadError === "object" ? payloadError.route : undefined; + error.toolName = payloadError && typeof payloadError === "object" ? payloadError.toolName : undefined; error.timeoutMs = response.timeoutMs ?? (payloadError && typeof payloadError === "object" ? payloadError.timeoutMs : undefined); error.providerStatus = payloadError && typeof payloadError === "object" ? payloadError.providerStatus : response.status; error.category = payloadError && typeof payloadError === "object" ? payloadError.category : undefined; @@ -2382,6 +2448,8 @@ function agentErrorFromHttpResponse(response, traceId) { function agentFailurePresentation(error, { result = null, traceId = null } = {}) { const code = normalizeErrorCode(error?.code); const message = String(error?.message ?? result?.error?.message ?? "").trim(); + const structuredBlocker = error?.blocker ?? result?.error?.blocker ?? result?.blocker ?? null; + const userMessage = String(error?.userMessage ?? result?.error?.userMessage ?? structuredBlocker?.userMessage ?? "").trim(); const observedTraceId = result?.traceId || error?.traceId || traceId || null; const traceSuffix = observedTraceId ? ` trace=${observedTraceId}` : ""; const timeoutMs = error?.timeoutMs ?? result?.error?.timeoutMs ?? timeoutMsFromText(message); @@ -2395,6 +2463,15 @@ function agentFailurePresentation(error, { result = null, traceId = null } = {}) }; } + if (userMessage) { + const category = error?.category ?? result?.error?.category ?? structuredBlocker?.category ?? "runner_blocked"; + return { + category, + title: titleForBlockerCategory(category, code), + text: `${userMessage}${traceSuffix}` + }; + } + if (code === "session_busy") { return { category: "runner_busy", @@ -2444,6 +2521,19 @@ function agentFailurePresentation(error, { result = null, traceId = null } = {}) }; } +function titleForBlockerCategory(category, code) { + const value = String(category ?? "").trim(); + if (value === "needs_config") return "Code Agent 需要配置"; + if (value === "security_blocked" || code === "security_blocked") return "Code Agent 安全阻断"; + if (value === "fallback" || code === "text_chat_only_fallback") return "Code Agent 仍是 fallback"; + if (value === "timeout") return "Code Agent 等待超时"; + if (value === "runner_busy") return "Code Agent Runner 忙碌"; + if (value === "session_blocked") return "Code Agent Session 受阻"; + if (value === "capability_unavailable") return "Code Agent 能力未开放"; + if (value === "retryable") return "Code Agent 可重试"; + return "Code Agent 服务受阻"; +} + function normalizeErrorCode(code) { const normalized = String(code ?? "").trim(); return normalized || "code_agent_failed"; @@ -2857,6 +2947,8 @@ function codeAgentControlSummary(availability) { } function codeAgentBlockedSummary(availability) { + const structured = availability?.error?.userMessage ?? availability?.blocker?.userMessage; + if (structured) return structured; const providerStatus = availability?.error?.providerStatus ?? availability?.providerStatus; if (providerStatus) { return `真实后端已接入,但当前上游响应 HTTP ${providerStatus};工作台保持只读和可重试,不会冒充真实 Code Agent 回复。`;