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.
This commit is contained in:
Lyon
2026-05-23 23:17:13 +08:00
committed by GitHub
parent e86cbecd1d
commit 997b96dc65
10 changed files with 1104 additions and 101 deletions
+433 -32
View File
@@ -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;
}
@@ -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");
+19 -27
View File
@@ -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;
}
+101 -1
View File
@@ -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() {
@@ -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",
@@ -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 {
+1 -1
View File
@@ -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`,
@@ -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");
});
@@ -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);
+98 -6
View File
@@ -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 回复。`;