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:
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 回复。`;
|
||||
|
||||
Reference in New Issue
Block a user