fix: route code agent m3 io through skill cli
This commit is contained in:
@@ -144,7 +144,7 @@ export async function handleCodeAgentChat(params = {}, options = {}) {
|
||||
status: "accepted",
|
||||
label: "request:accepted",
|
||||
promptSummary: message.length > 160 ? `${message.slice(0, 157)}...` : message,
|
||||
waitingFor: "codex-stdio-readiness"
|
||||
waitingFor: runnerIntent.kind === "m3_io" ? HWLAB_M3_IO_API_ROUTE : "codex-stdio-readiness"
|
||||
});
|
||||
|
||||
const securityIntent = runnerIntent.kind === "security" ? runnerIntent : null;
|
||||
@@ -163,7 +163,12 @@ export async function handleCodeAgentChat(params = {}, options = {}) {
|
||||
backend: CODEX_STDIO_BACKEND,
|
||||
workspace: options.workspace ?? repoRoot,
|
||||
sandbox: "workspace-write",
|
||||
toolCalls: [],
|
||||
toolCalls: [securityBlockedToolCall({
|
||||
traceId,
|
||||
toolName: securityIntent.toolName,
|
||||
reason: securityIntent.reason,
|
||||
cwd: options.workspace ?? repoRoot
|
||||
})],
|
||||
skills: notRequestedSkills(),
|
||||
runner: codexStdioBlockedRunnerDescriptor({ workspace: options.workspace, session: null }),
|
||||
runnerTrace: traceRecorder.runnerTrace({
|
||||
@@ -188,6 +193,22 @@ export async function handleCodeAgentChat(params = {}, options = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
if (runnerIntent.kind === "m3_io") {
|
||||
const runnerResult = await callM3IoSkillRunner({
|
||||
intent: runnerIntent,
|
||||
conversationId,
|
||||
sessionId: requestedSessionId,
|
||||
traceId,
|
||||
env: options.env ?? process.env,
|
||||
now: options.now,
|
||||
workspace: options.workspace,
|
||||
sessionRegistry,
|
||||
requestJson: options.m3IoSkillRequestJson,
|
||||
traceRecorder
|
||||
});
|
||||
return completedRunnerPayload({ base, runnerResult, messageId, now: options.now, sessionRegistry });
|
||||
}
|
||||
|
||||
if (runnerIntent.kind === "session_context") {
|
||||
traceRecorder.append({
|
||||
type: "session",
|
||||
@@ -905,6 +926,396 @@ async function inspectReadOnlyRunnerAvailability(env, options = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
async function callM3IoSkillRunner({ intent, conversationId, sessionId, traceId, env, now, workspace, sessionRegistry, requestJson, traceRecorder }) {
|
||||
const resolvedWorkspace = resolveRunnerWorkspace(env, { workspace });
|
||||
const registry = resolveCodeAgentSessionRegistry({ sessionRegistry });
|
||||
const startedAt = nowIso(now);
|
||||
const sessionCapabilityLevel = configuredCloudApiBaseUrl(env) && m3IoIntentHasSupportedAction(intent)
|
||||
? m3SessionCapabilityLevelForIntent(intent)
|
||||
: HWLAB_M3_IO_CAPABILITY_LEVELS.blocked;
|
||||
const sessionAcquire = registry.acquire({
|
||||
conversationId,
|
||||
sessionId,
|
||||
workspace: resolvedWorkspace,
|
||||
sandbox: M3_IO_SKILL_SANDBOX,
|
||||
runnerKind: M3_IO_SKILL_RUNNER_KIND,
|
||||
sessionMode: M3_IO_SKILL_SESSION_MODE,
|
||||
capabilityLevel: sessionCapabilityLevel,
|
||||
implementationType: M3_IO_SKILL_IMPLEMENTATION_TYPE,
|
||||
traceId,
|
||||
now
|
||||
});
|
||||
const codexStdioFeasibility = skippedCodexStdioFeasibilityForM3Io({ workspace: resolvedWorkspace });
|
||||
|
||||
if (!sessionAcquire.ok) {
|
||||
const blockedSession = sessionAcquire.session;
|
||||
throw runnerError(sessionAcquire.code, sessionAcquire.message, {
|
||||
provider: M3_IO_SKILL_PROVIDER,
|
||||
model: M3_IO_SKILL_MODEL,
|
||||
backend: M3_IO_SKILL_BACKEND,
|
||||
workspace: resolvedWorkspace ?? null,
|
||||
sandbox: M3_IO_SKILL_SANDBOX,
|
||||
session: blockedSession,
|
||||
toolCalls: [],
|
||||
skills: notRequestedSkills(),
|
||||
runner: m3IoSkillRunnerDescriptor({ workspace: resolvedWorkspace, session: blockedSession }),
|
||||
runnerTrace: m3IoSkillRunnerTrace({
|
||||
traceId,
|
||||
workspace: resolvedWorkspace ?? repoRoot,
|
||||
session: blockedSession,
|
||||
events: ["request:accepted", `session:${sessionAcquire.code}`],
|
||||
startedAt,
|
||||
outputTruncated: false
|
||||
}),
|
||||
capabilityLevel: HWLAB_M3_IO_CAPABILITY_LEVELS.blocked,
|
||||
sessionMode: M3_IO_SKILL_SESSION_MODE,
|
||||
sessionReuse: blockedSession ? sessionReuseEvidence(blockedSession) : null,
|
||||
implementationType: M3_IO_SKILL_IMPLEMENTATION_TYPE,
|
||||
runnerLimitations: [...M3_IO_SKILL_LIMITATION_FLAGS],
|
||||
codexStdioFeasibility,
|
||||
longLivedSessionGate: longLivedSessionGate({
|
||||
provider: M3_IO_SKILL_PROVIDER,
|
||||
runnerKind: M3_IO_SKILL_RUNNER_KIND,
|
||||
session: blockedSession,
|
||||
sessionMode: M3_IO_SKILL_SESSION_MODE,
|
||||
implementationType: M3_IO_SKILL_IMPLEMENTATION_TYPE,
|
||||
codexStdioFeasibility
|
||||
}),
|
||||
route: HWLAB_M3_IO_API_ROUTE,
|
||||
toolName: HWLAB_M3_IO_SKILL_NAME
|
||||
});
|
||||
}
|
||||
|
||||
let session = sessionAcquire.session;
|
||||
const requestId = `req_${randomUUID()}`;
|
||||
const actorId = "usr_code_agent";
|
||||
traceRecorder?.append({
|
||||
type: "tool_call",
|
||||
status: "started",
|
||||
label: "tool:hwlab-m3-io:started",
|
||||
toolName: HWLAB_M3_IO_SKILL_NAME,
|
||||
waitingFor: HWLAB_M3_IO_API_ROUTE
|
||||
});
|
||||
const { commandArgs, skillResult } = await executeM3IoSkillIntent({
|
||||
intent,
|
||||
traceId,
|
||||
requestId,
|
||||
actorId,
|
||||
env,
|
||||
now,
|
||||
requestJson
|
||||
});
|
||||
const finishedAt = nowIso(now);
|
||||
session = releaseReadOnlySession(registry, session, { now, traceId, conversationId });
|
||||
|
||||
const capabilityLevel = skillResult.capabilityLevel ?? (
|
||||
skillResult.ok ? m3SessionCapabilityLevelForIntent(intent) : HWLAB_M3_IO_CAPABILITY_LEVELS.blocked
|
||||
);
|
||||
const route = m3SkillRoute(skillResult);
|
||||
const method = m3SkillMethod(skillResult);
|
||||
const directRunnerSeed = {
|
||||
session,
|
||||
sessionMode: M3_IO_SKILL_SESSION_MODE,
|
||||
sessionReuse: sessionReuseEvidence(session),
|
||||
runner: {
|
||||
kind: M3_IO_SKILL_RUNNER_KIND,
|
||||
codexStdio: false,
|
||||
durableSession: false,
|
||||
sessionReused: session?.reused ?? false,
|
||||
turn: session?.turn ?? null
|
||||
}
|
||||
};
|
||||
const m3Io = m3IoStructuredResult({
|
||||
skillResult,
|
||||
stdioResult: directRunnerSeed,
|
||||
commandArgs,
|
||||
route,
|
||||
method
|
||||
});
|
||||
const toolCall = m3IoSkillToolCall({
|
||||
skillResult,
|
||||
commandArgs,
|
||||
cwd: resolvedWorkspace,
|
||||
capabilityLevel,
|
||||
route,
|
||||
method,
|
||||
responseType: m3Io.type
|
||||
});
|
||||
const events = [
|
||||
"request:accepted",
|
||||
"runner:m3-skill-cli:selected",
|
||||
"tool:skill-cli:started",
|
||||
`route:${route}`,
|
||||
`tool:${toolCall.status}`
|
||||
];
|
||||
const runnerTrace = m3IoSkillRunnerTrace({
|
||||
traceId,
|
||||
events,
|
||||
startedAt,
|
||||
finishedAt,
|
||||
outputTruncated: toolCall.outputTruncated,
|
||||
workspace: resolvedWorkspace,
|
||||
session,
|
||||
skillResult
|
||||
});
|
||||
traceRecorder?.append({
|
||||
type: "tool_call",
|
||||
status: skillResult.ok ? "completed" : "blocked",
|
||||
label: `tool:hwlab-m3-io:${skillResult.ok ? "completed" : "blocked"}`,
|
||||
toolName: HWLAB_M3_IO_SKILL_NAME,
|
||||
outputSummary: `route=${route}; status=${skillResult.status}; operationId=${skillResult.operationId ?? "null"}`,
|
||||
terminal: false
|
||||
});
|
||||
|
||||
const blockers = skillResult.blockers ?? (skillResult.blocker ? [skillResult.blocker] : []);
|
||||
const runner = m3IoSkillRunnerDescriptor({ workspace: resolvedWorkspace, session, skillResult });
|
||||
return {
|
||||
provider: M3_IO_SKILL_PROVIDER,
|
||||
model: M3_IO_SKILL_MODEL,
|
||||
backend: M3_IO_SKILL_BACKEND,
|
||||
content: m3IoSkillReply(skillResult, m3Io),
|
||||
responseType: m3Io.type,
|
||||
m3Io,
|
||||
workspace: resolvedWorkspace,
|
||||
sandbox: M3_IO_SKILL_SANDBOX,
|
||||
session,
|
||||
sessionMode: M3_IO_SKILL_SESSION_MODE,
|
||||
sessionReuse: sessionReuseEvidence(session),
|
||||
implementationType: M3_IO_SKILL_IMPLEMENTATION_TYPE,
|
||||
runnerLimitations: [...M3_IO_SKILL_LIMITATION_FLAGS],
|
||||
codexStdioFeasibility,
|
||||
longLivedSessionGate: longLivedSessionGate({
|
||||
provider: M3_IO_SKILL_PROVIDER,
|
||||
runnerKind: M3_IO_SKILL_RUNNER_KIND,
|
||||
session,
|
||||
sessionMode: M3_IO_SKILL_SESSION_MODE,
|
||||
implementationType: M3_IO_SKILL_IMPLEMENTATION_TYPE,
|
||||
codexStdioFeasibility
|
||||
}),
|
||||
toolCalls: [toolCall],
|
||||
skills: {
|
||||
status: "used",
|
||||
items: [m3IoSkillItem({ capabilityLevel, route })],
|
||||
count: 1,
|
||||
blockers
|
||||
},
|
||||
runner,
|
||||
runnerTrace,
|
||||
capabilityLevel,
|
||||
blockers,
|
||||
route,
|
||||
toolName: HWLAB_M3_IO_SKILL_NAME,
|
||||
providerTrace: {
|
||||
...m3IoSkillProviderTrace({ skillResult, route, method, capabilityLevel, responseType: m3Io.type }),
|
||||
runnerKind: M3_IO_SKILL_RUNNER_KIND,
|
||||
codexStdio: false,
|
||||
transport: "skill-cli",
|
||||
fallbackUsed: false
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function executeM3IoSkillIntent({ intent, traceId, requestId, actorId, env, now, requestJson }) {
|
||||
if (!m3IoIntentHasSupportedAction(intent)) {
|
||||
const blocker = m3IoIntentActionMissingBlocker({ traceId });
|
||||
return {
|
||||
commandArgs: [
|
||||
"m3",
|
||||
"io",
|
||||
"--trace-id",
|
||||
traceId,
|
||||
"--request-id",
|
||||
requestId,
|
||||
"--actor-id",
|
||||
actorId
|
||||
],
|
||||
skillResult: m3IoSkillBlockedBeforeRequestResult({
|
||||
traceId,
|
||||
requestId,
|
||||
actorId,
|
||||
blocker,
|
||||
route: HWLAB_M3_IO_API_ROUTE,
|
||||
method: "POST",
|
||||
action: "m3.io",
|
||||
hwlabApi: {
|
||||
route: HWLAB_M3_IO_API_ROUTE,
|
||||
redactedUrl: null,
|
||||
source: "intent-validation",
|
||||
baseUrlConfigured: Boolean(configuredCloudApiBaseUrl(env)),
|
||||
requiredEnv: [...HWLAB_M3_IO_API_BASE_URL_ENVS],
|
||||
recommendedEnv: HWLAB_M3_IO_API_BASE_URL_ENV,
|
||||
missingConfig: [],
|
||||
cloudApiOnly: true,
|
||||
directGatewayCalls: false,
|
||||
directBoxCalls: false,
|
||||
directPatchPanelCalls: false
|
||||
},
|
||||
startedAt: nowIso(now),
|
||||
finishedAt: nowIso(now)
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
const commandArgs = m3IoSkillArgsForIntent(intent, { env, traceId });
|
||||
if (!commandArgs.includes("--request-id")) {
|
||||
commandArgs.push("--request-id", requestId);
|
||||
}
|
||||
const skillResult = await runM3IoSkillCommand(commandArgs, {
|
||||
env,
|
||||
now,
|
||||
requestJson
|
||||
});
|
||||
return { commandArgs, skillResult };
|
||||
}
|
||||
|
||||
function m3IoIntentHasSupportedAction(intent = {}) {
|
||||
return ["status", "do.write", "di.read"].includes(intent.action);
|
||||
}
|
||||
|
||||
function skippedCodexStdioFeasibilityForM3Io({ workspace } = {}) {
|
||||
return {
|
||||
checked: true,
|
||||
skipped: true,
|
||||
reason: "m3_io_skill_cli_deterministic_route",
|
||||
sourceIssue: "pikasTech/HWLAB#334",
|
||||
status: "skipped",
|
||||
ready: false,
|
||||
canStartLongLivedCodexStdio: false,
|
||||
commandProbe: {
|
||||
ready: false,
|
||||
skipped: true,
|
||||
reason: "m3_io_skill_cli_deterministic_route"
|
||||
},
|
||||
workspace: workspace ?? repoRoot,
|
||||
sandbox: M3_IO_SKILL_SANDBOX,
|
||||
blockers: [],
|
||||
blockerCodes: [],
|
||||
summary: `M3 IO requests use deterministic Skill CLI -> HWLAB API ${HWLAB_M3_IO_API_ROUTE}; Codex stdio chat is not part of this control decision.`
|
||||
};
|
||||
}
|
||||
|
||||
function m3IoIntentActionMissingBlocker({ traceId }) {
|
||||
return {
|
||||
code: "m3_io_intent_action_missing",
|
||||
layer: "intent",
|
||||
category: "needs_confirmation",
|
||||
retryable: false,
|
||||
source: "code-agent-m3-skill-cli",
|
||||
summary: "M3 IO request did not specify status, DI read, or a DO write value.",
|
||||
message: "M3 IO intent requires an explicit action: m3 status, DI1 read, or DO1 write with true/false.",
|
||||
zh: "M3 IO 请求缺少明确动作:请指定读取 M3 status、读取 DI1,或把 DO1 写成 true/false。",
|
||||
userMessage: "M3 IO 请求缺少明确动作,需要指定 status、DI1 读取,或 DO1=true/false 写入。",
|
||||
traceId,
|
||||
route: HWLAB_M3_IO_API_ROUTE,
|
||||
toolName: HWLAB_M3_IO_SKILL_NAME
|
||||
};
|
||||
}
|
||||
|
||||
function m3IoSkillBlockedBeforeRequestResult({
|
||||
traceId,
|
||||
requestId,
|
||||
actorId,
|
||||
blocker,
|
||||
route = HWLAB_M3_IO_API_ROUTE,
|
||||
method = "POST",
|
||||
action = "m3.io",
|
||||
hwlabApi,
|
||||
startedAt,
|
||||
finishedAt
|
||||
}) {
|
||||
return {
|
||||
ok: false,
|
||||
service: HWLAB_M3_IO_SKILL_NAME,
|
||||
contractVersion: HWLAB_AGENT_RUNTIME_SKILL_CLI_VERSION,
|
||||
route,
|
||||
method,
|
||||
hwlabApi,
|
||||
capabilityLevel: HWLAB_M3_IO_CAPABILITY_LEVELS.blocked,
|
||||
controlReady: false,
|
||||
action,
|
||||
accepted: false,
|
||||
status: "blocked",
|
||||
traceId,
|
||||
requestId,
|
||||
actorId,
|
||||
operationId: null,
|
||||
auditId: null,
|
||||
evidenceId: null,
|
||||
audit: {
|
||||
auditId: null,
|
||||
status: "not_written",
|
||||
durableStatus: null,
|
||||
summary: "blocked before HWLAB API request"
|
||||
},
|
||||
evidence: {
|
||||
evidenceId: null,
|
||||
status: "blocked",
|
||||
sourceKind: "BLOCKED",
|
||||
blocker: blocker.code,
|
||||
writeStatus: "not_written",
|
||||
summary: "blocked before HWLAB API request"
|
||||
},
|
||||
durable: {
|
||||
status: "blocked",
|
||||
durable: false,
|
||||
blocker: blocker.code,
|
||||
category: blocker.category,
|
||||
summary: blocker.message
|
||||
},
|
||||
blocker,
|
||||
capabilityBlocker: blocker,
|
||||
trustBlocker: null,
|
||||
blockers: [blocker],
|
||||
readiness: {
|
||||
status: "blocked",
|
||||
controlReady: false,
|
||||
capabilityLevel: HWLAB_M3_IO_CAPABILITY_LEVELS.blocked,
|
||||
route,
|
||||
blocker,
|
||||
trustBlocker: null
|
||||
},
|
||||
command: null,
|
||||
result: {
|
||||
value: null,
|
||||
targetReadback: null
|
||||
},
|
||||
readback: null,
|
||||
controlPath: {
|
||||
cloudApi: false,
|
||||
gatewaySimu: false,
|
||||
boxSimu: false,
|
||||
patchPanel: false,
|
||||
frontendBypass: false
|
||||
},
|
||||
safety: {
|
||||
cloudApiRouteOnly: true,
|
||||
allowedRoute: route,
|
||||
directGatewayCalls: false,
|
||||
directBoxCalls: false,
|
||||
directPatchPanelCalls: false,
|
||||
fallbackUsed: false,
|
||||
openAiFallbackUsed: false
|
||||
},
|
||||
httpStatus: 0,
|
||||
error: {
|
||||
code: blocker.code,
|
||||
layer: blocker.layer,
|
||||
category: blocker.category,
|
||||
blocker,
|
||||
retryable: blocker.retryable,
|
||||
userMessage: blocker.userMessage,
|
||||
message: blocker.message,
|
||||
traceId,
|
||||
route,
|
||||
toolName: HWLAB_M3_IO_SKILL_NAME
|
||||
},
|
||||
rawStatus: null,
|
||||
startedAt,
|
||||
finishedAt,
|
||||
response: null
|
||||
};
|
||||
}
|
||||
|
||||
async function callCodexStdioRunner({ message, conversationId, sessionId, traceId, env, now, workspace, timeoutMs, model, codexStdioManager, traceRecorder, conversationFacts, externalNetworkIntent = null }) {
|
||||
const manager = resolveCodexStdioSessionManager({ codexStdioManager });
|
||||
try {
|
||||
@@ -1446,6 +1857,7 @@ function m3SkillMethod(skillResult = {}) {
|
||||
|
||||
function m3SessionCapabilityLevelForIntent(intent = {}) {
|
||||
if (intent.action === "status") return HWLAB_M3_IO_CAPABILITY_LEVELS.readonly;
|
||||
if (!m3IoIntentHasSupportedAction(intent)) return HWLAB_M3_IO_CAPABILITY_LEVELS.blocked;
|
||||
return HWLAB_M3_IO_CAPABILITY_LEVELS.ready;
|
||||
}
|
||||
|
||||
@@ -1634,6 +2046,7 @@ function detectM3IoIntent(text) {
|
||||
const wantsWrite = /(?:DO1|数字输出).{0,30}(?:write|set|置|写|打开|关闭|true|false|高|低)|(?:write|set|置|写|打开|关闭).{0,30}(?:DO1|数字输出)/iu.test(normalized) ||
|
||||
(topologyMentioned && /(?:执行|run|闭环|loop|控制|触发)/iu.test(normalized));
|
||||
const wantsStatus = /(?:M3).{0,16}(?:status|状态|聚合|health|readiness)|(?:status|状态|聚合|health|readiness).{0,16}(?:M3)/iu.test(normalized);
|
||||
const wantsM3ControlButMissingAction = /(?:HWLAB API|Skill CLI|M3).{0,32}(?:控制|control|IO)|(?:控制|control).{0,32}(?:HWLAB API|Skill CLI|M3 IO|M3)/iu.test(normalized);
|
||||
if (wantsStatus && !wantsRead && !wantsWrite) {
|
||||
return {
|
||||
kind: "m3_io",
|
||||
@@ -1642,6 +2055,13 @@ function detectM3IoIntent(text) {
|
||||
};
|
||||
}
|
||||
if (!wantsRead && !wantsWrite) {
|
||||
if (wantsM3ControlButMissingAction) {
|
||||
return {
|
||||
kind: "m3_io",
|
||||
toolName: HWLAB_M3_IO_SKILL_NAME,
|
||||
action: null
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1836,7 +2256,7 @@ function m3IoSkillReply(skillResult, m3Io = null) {
|
||||
lines.push(`执行路径:Code Agent -> Skill CLI -> HWLAB API ${route};没有使用 OpenAI fallback,也没有由 Code Agent 直连硬件服务。`);
|
||||
lines.push(`排查字段:traceId=${skillResult.traceId ?? "null"};operationId=${skillResult.operationId ?? "null"};auditId=${skillResult.auditId ?? skillResult.audit?.auditId ?? "null"};evidenceId=${skillResult.evidenceId ?? skillResult.evidence?.evidenceId ?? "null"}。`);
|
||||
if (m3Io?.blocker?.code) {
|
||||
lines.push(`blocker=${m3Io.blocker.code};layer=${m3Io.blocker.layer ?? "unknown"}。`);
|
||||
lines.push(`Blocker: ${m3Io.blocker.code};blocker=${m3Io.blocker.code};layer=${m3Io.blocker.layer ?? "unknown"}。`);
|
||||
}
|
||||
lines.push(`可信状态:trusted=${m3Io?.trust?.trusted === true ? "true" : "false"};durable=${m3Io?.trust?.durable === true ? "true" : "false"};这不是 M3 DEV-LIVE 验收结论。`);
|
||||
return boundToolOutput(lines.join("\n"), READONLY_TOOL_OUTPUT_LIMIT).text;
|
||||
@@ -1959,9 +2379,12 @@ function structuredCompletionBlocker(result, context = {}) {
|
||||
: null;
|
||||
if ((result.provider === M3_IO_SKILL_PROVIDER || m3SkillTool || result.m3Io?.type === "m3_io_blocker") && result.capabilityLevel === HWLAB_M3_IO_CAPABILITY_LEVELS.blocked) {
|
||||
const primary = result.m3Io?.blocker ?? m3SkillTool?.blocker ?? result.skills?.blockers?.[0] ?? result.runnerTrace?.blocker ?? null;
|
||||
const topCode = m3CompletionBlockerCode(primary);
|
||||
return structuredBlocker({
|
||||
code: primary?.code ?? "m3_io_blocker",
|
||||
layer: primary?.layer ?? (primary?.code === "hwlab_api_unavailable" ? "hwlab-api" : "m3-readiness"),
|
||||
code: topCode,
|
||||
layer: topCode === "m3_readiness_blocked"
|
||||
? "m3-readiness"
|
||||
: primary?.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?.userMessage ?? primary?.zh ?? "M3 控制链路仍受阻,前端应显示为能力未就绪,而不是发送失败。",
|
||||
retryable: primary?.code === "hwlab_api_unavailable",
|
||||
@@ -1978,6 +2401,13 @@ function structuredCompletionBlocker(result, context = {}) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function m3CompletionBlockerCode(primary = {}) {
|
||||
if (["m3_gateway_session_unavailable", "runtime_durable_not_green", "skill_cli_api_base_missing", "m3_io_intent_action_missing"].includes(primary?.code)) {
|
||||
return "m3_readiness_blocked";
|
||||
}
|
||||
return primary?.code ?? "m3_io_blocker";
|
||||
}
|
||||
|
||||
function sessionReuseEvidence(session) {
|
||||
return {
|
||||
conversationId: session.conversationId,
|
||||
@@ -2042,6 +2472,31 @@ function runnerSafetyContract() {
|
||||
};
|
||||
}
|
||||
|
||||
function securityBlockedToolCall({ traceId, toolName = "security.boundary", cwd = repoRoot } = {}) {
|
||||
return {
|
||||
id: `tool_${randomUUID()}`,
|
||||
type: "security-policy",
|
||||
name: toolName,
|
||||
status: "blocked",
|
||||
cwd,
|
||||
exitCode: 1,
|
||||
stdout: "",
|
||||
stderrSummary: "security_blocked",
|
||||
outputTruncated: false,
|
||||
traceId,
|
||||
route: null,
|
||||
blocker: {
|
||||
code: "security_blocked",
|
||||
layer: "security",
|
||||
category: "security_blocked",
|
||||
retryable: false,
|
||||
traceId,
|
||||
toolName,
|
||||
userMessage: "该请求被安全边界阻断,不能绕过 HWLAB API。"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function inspectCodexStdioFeasibility(env = process.env, options = {}) {
|
||||
const manager = resolveCodexStdioSessionManager(options);
|
||||
const descriptor = {
|
||||
|
||||
@@ -2738,7 +2738,7 @@ test("cloud api /v1/agent/chat can answer skills prompt without local skills man
|
||||
}
|
||||
});
|
||||
|
||||
test("cloud api /v1/agent/chat blocks M3 IO when Codex stdio is unavailable", async () => {
|
||||
test("cloud api /v1/agent/chat routes M3 IO through Skill CLI even when Codex stdio is unavailable", async () => {
|
||||
const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-m3-skill-"));
|
||||
const calls = [];
|
||||
const server = createCloudApiServer({
|
||||
@@ -2814,13 +2814,19 @@ test("cloud api /v1/agent/chat blocks M3 IO when Codex stdio is unavailable", as
|
||||
assert.equal(response.status, 200);
|
||||
const payload = await response.json();
|
||||
validateCodeAgentChatSchema(payload);
|
||||
assert.equal(payload.status, "failed");
|
||||
assert.equal(payload.provider, "codex-stdio");
|
||||
assert.equal(payload.backend, "hwlab-cloud-api/codex-mcp-stdio");
|
||||
assert.equal(payload.capabilityLevel, "blocked");
|
||||
assert.deepEqual(payload.toolCalls, []);
|
||||
assert.equal(Object.hasOwn(payload, "reply"), false);
|
||||
assert.equal(calls.length, 0);
|
||||
assert.equal(payload.status, "completed");
|
||||
assert.equal(payload.provider, "hwlab-skill-cli");
|
||||
assert.equal(payload.backend, "hwlab-cloud-api/hwlab-agent-runtime-skill-cli");
|
||||
assert.equal(payload.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.ready);
|
||||
assert.equal(payload.runner.kind, "hwlab-m3-io-skill-cli");
|
||||
assert.equal(payload.codexStdioFeasibility.skipped, true);
|
||||
assert.equal(payload.responseType, "m3_io_result");
|
||||
assert.equal(payload.toolCalls.length, 1);
|
||||
assert.equal(payload.toolCalls[0].name, "hwlab-agent-runtime.m3-io");
|
||||
assert.equal(payload.toolCalls[0].route, "/v1/m3/io");
|
||||
assert.equal(payload.toolCalls[0].accepted, true);
|
||||
assert.equal(Object.hasOwn(payload, "reply"), true);
|
||||
assert.equal(calls.length, 1);
|
||||
} finally {
|
||||
await new Promise((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
@@ -2934,8 +2940,11 @@ test("cloud api /v1/agent/chat recognizes M3 patch-panel topology text before Co
|
||||
});
|
||||
validateCodeAgentChatSchema(payload);
|
||||
assert.equal(payload.status, "completed");
|
||||
assert.equal(payload.provider, "codex-stdio");
|
||||
assert.equal(payload.backend, "hwlab-cloud-api/codex-mcp-stdio");
|
||||
assert.equal(payload.provider, "hwlab-skill-cli");
|
||||
assert.equal(payload.backend, "hwlab-cloud-api/hwlab-agent-runtime-skill-cli");
|
||||
assert.equal(payload.runner.kind, "hwlab-m3-io-skill-cli");
|
||||
assert.equal(payload.sessionMode, "controlled-m3-io-skill-cli");
|
||||
assert.equal(payload.codexStdioFeasibility.skipped, true);
|
||||
assert.equal(payload.responseType, "m3_io_result");
|
||||
assert.equal(payload.m3Io.type, "m3_io_result");
|
||||
assert.equal(payload.m3Io.action, "do.write");
|
||||
@@ -2945,7 +2954,7 @@ test("cloud api /v1/agent/chat recognizes M3 patch-panel topology text before Co
|
||||
assert.equal(payload.m3Io.path.summary, "Code Agent -> Skill CLI -> HWLAB API /v1/m3/io");
|
||||
assert.equal(payload.m3Io.operation.operationId, "op_m3_do_write_topology_skill");
|
||||
assert.equal(payload.m3Io.trace.traceId, "trc_server-test-m3-topology");
|
||||
assert.equal(payload.m3Io.session.sessionId, "ses_server_m3_topology_skill");
|
||||
assert.equal(payload.m3Io.session.sessionId, payload.session.sessionId);
|
||||
assert.equal(payload.m3Io.trust.trusted, false);
|
||||
assert.equal(payload.m3Io.trust.durable, false);
|
||||
const skillTool = payload.toolCalls.find((toolCall) => toolCall.name === "hwlab-agent-runtime.m3-io");
|
||||
@@ -2957,7 +2966,7 @@ test("cloud api /v1/agent/chat recognizes M3 patch-panel topology text before Co
|
||||
assert.equal(skillTool.traceId, "trc_server-test-m3-topology");
|
||||
assert.equal(skillTool.readback.value, true);
|
||||
assert.equal(payload.providerTrace.fallbackUsed, false);
|
||||
assert.equal(payload.providerTrace.transport, "stdio+skill-cli");
|
||||
assert.equal(payload.providerTrace.transport, "skill-cli");
|
||||
assert.ok(payload.runnerTrace.events.includes("tool:skill-cli:started"));
|
||||
assert.ok(payload.runnerTrace.events.includes("route:/v1/m3/io"));
|
||||
assert.match(payload.reply.content, /Code Agent -> Skill CLI -> HWLAB API \/v1\/m3\/io/u);
|
||||
@@ -2967,8 +2976,7 @@ test("cloud api /v1/agent/chat recognizes M3 patch-panel topology text before Co
|
||||
assert.equal(calls[0].url, "http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667/v1/m3/io");
|
||||
assert.equal(calls[0].request.body.action, "do.write");
|
||||
assert.equal(calls[0].request.body.value, true);
|
||||
assert.ok(codexStdioCalls.includes("initialize"));
|
||||
assert.ok(codexStdioCalls.includes("callTool"));
|
||||
assert.deepEqual(codexStdioCalls, []);
|
||||
assert.equal(/https?:\/\/[^\s"']*(?:gateway(?:-simu)?|box(?:-simu)?|patch-panel)|:7101\b|:7201\b|:7301\b|\/invoke\b|\/sync\/tick\b/iu.test(JSON.stringify(payload.toolCalls)), false);
|
||||
} finally {
|
||||
await new Promise((resolve, reject) => {
|
||||
@@ -3036,7 +3044,11 @@ test("cloud api /v1/agent/chat returns Skill CLI blocker for M3 topology without
|
||||
});
|
||||
validateCodeAgentChatSchema(payload);
|
||||
assert.equal(payload.status, "completed");
|
||||
assert.equal(payload.provider, "codex-stdio");
|
||||
assert.equal(payload.provider, "hwlab-skill-cli");
|
||||
assert.equal(payload.backend, "hwlab-cloud-api/hwlab-agent-runtime-skill-cli");
|
||||
assert.equal(payload.runner.kind, "hwlab-m3-io-skill-cli");
|
||||
assert.equal(payload.sessionMode, "controlled-m3-io-skill-cli");
|
||||
assert.equal(payload.codexStdioFeasibility.skipped, true);
|
||||
assert.equal(payload.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.blocked);
|
||||
assert.equal(payload.responseType, "m3_io_blocker");
|
||||
assert.equal(payload.m3Io.type, "m3_io_blocker");
|
||||
@@ -3057,7 +3069,7 @@ test("cloud api /v1/agent/chat returns Skill CLI blocker for M3 topology without
|
||||
assert.match(payload.reply.content, /M3 DO1 写入需要 --value true 或 --value false/u);
|
||||
assert.match(payload.reply.content, /Code Agent -> Skill CLI -> HWLAB API \/v1\/m3\/io/u);
|
||||
assert.equal(payload.providerTrace.fallbackUsed, false);
|
||||
assert.equal(payload.providerTrace.transport, "stdio+skill-cli");
|
||||
assert.equal(payload.providerTrace.transport, "skill-cli");
|
||||
assert.equal(calls.length, 0);
|
||||
} finally {
|
||||
await new Promise((resolve, reject) => {
|
||||
@@ -3196,7 +3208,7 @@ test("cloud api /v1/agent/chat promotes /v1/m3/io blocker into Chinese M3 IO rep
|
||||
}
|
||||
});
|
||||
|
||||
test("cloud api /v1/agent/chat routes M3 through Codex stdio then in-process HWLAB API handler", async () => {
|
||||
test("cloud api /v1/agent/chat routes M3 Skill CLI through in-process HWLAB API handler", async () => {
|
||||
const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-stdio-m3-skill-"));
|
||||
const fakeCodex = await createFakeCodexCommand();
|
||||
const gatewayCalls = [];
|
||||
@@ -3258,12 +3270,13 @@ test("cloud api /v1/agent/chat routes M3 through Codex stdio then in-process HWL
|
||||
|
||||
validateCodeAgentChatSchema(payload);
|
||||
assert.equal(payload.status, "completed");
|
||||
assert.equal(payload.provider, "codex-stdio");
|
||||
assert.equal(payload.backend, "hwlab-cloud-api/codex-mcp-stdio");
|
||||
assert.equal(payload.runner.kind, "codex-mcp-stdio-runner");
|
||||
assert.equal(payload.sessionMode, "codex-mcp-stdio-long-lived");
|
||||
assert.equal(payload.runner.codexStdio, true);
|
||||
assert.equal(payload.codexStdioFeasibility.ready, true);
|
||||
assert.equal(payload.provider, "hwlab-skill-cli");
|
||||
assert.equal(payload.backend, "hwlab-cloud-api/hwlab-agent-runtime-skill-cli");
|
||||
assert.equal(payload.runner.kind, "hwlab-m3-io-skill-cli");
|
||||
assert.equal(payload.sessionMode, "controlled-m3-io-skill-cli");
|
||||
assert.equal(payload.runner.codexStdio, false);
|
||||
assert.equal(payload.codexStdioFeasibility.skipped, true);
|
||||
assert.equal(payload.codexStdioFeasibility.reason, "m3_io_skill_cli_deterministic_route");
|
||||
assert.equal(payload.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.ready);
|
||||
assert.equal(payload.responseType, "m3_io_result");
|
||||
assert.equal(payload.m3Io.type, "m3_io_result");
|
||||
@@ -3271,7 +3284,7 @@ test("cloud api /v1/agent/chat routes M3 through Codex stdio then in-process HWL
|
||||
assert.equal(payload.m3Io.do1.targetValue, true);
|
||||
assert.equal(payload.m3Io.di1.observedValue, true);
|
||||
assert.equal(payload.m3Io.operation.operationId.startsWith("op_m3_do_write_"), true);
|
||||
assert.equal(payload.m3Io.session.sessionId, "ses_server_stdio_m3_skill");
|
||||
assert.equal(payload.m3Io.session.sessionId, payload.session.sessionId);
|
||||
assert.equal(payload.m3Io.path.summary, "Code Agent -> Skill CLI -> HWLAB API /v1/m3/io");
|
||||
assert.equal(payload.m3Io.trust.trusted, false);
|
||||
assert.equal(payload.m3Io.trust.durable, false);
|
||||
@@ -3287,12 +3300,11 @@ test("cloud api /v1/agent/chat routes M3 through Codex stdio then in-process HWL
|
||||
assert.match(skillTool.auditId, /^aud_m3_do_write_/u);
|
||||
assert.match(skillTool.evidenceId, /^evd_m3_do_write_/u);
|
||||
assert.equal(payload.runnerTrace.route, "/v1/m3/io");
|
||||
assert.ok(payload.runnerTrace.eventLabels.includes("prompt:sent"));
|
||||
assert.ok(payload.runnerTrace.events.includes("tool:skill-cli:started"));
|
||||
assert.ok(payload.runnerTrace.events.includes("route:/v1/m3/io"));
|
||||
assert.equal(payload.providerTrace.fallbackUsed, false);
|
||||
assert.equal(payload.providerTrace.codexStdio, true);
|
||||
assert.equal(payload.providerTrace.transport, "stdio+skill-cli");
|
||||
assert.equal(payload.providerTrace.codexStdio, false);
|
||||
assert.equal(payload.providerTrace.transport, "skill-cli");
|
||||
assert.match(payload.reply.content, /HWLAB API \/v1\/m3\/io/u);
|
||||
assert.match(payload.reply.content, /res_boxsimu_1:DO1=true/u);
|
||||
assert.match(payload.reply.content, /res_boxsimu_2:DI1=true/u);
|
||||
@@ -3304,8 +3316,7 @@ test("cloud api /v1/agent/chat routes M3 through Codex stdio then in-process HWL
|
||||
"/status",
|
||||
"/invoke"
|
||||
]);
|
||||
assert.ok(codexStdioCalls.includes("initialize"));
|
||||
assert.ok(codexStdioCalls.includes("callTool"));
|
||||
assert.deepEqual(codexStdioCalls, []);
|
||||
assert.equal(/https?:\/\/[^\s"']*(?:gateway(?:-simu)?|box(?:-simu)?|patch-panel)|:7101\b|:7201\b|:7301\b|\/invoke\b|\/sync\/tick\b/iu.test(JSON.stringify(payload.toolCalls)), false);
|
||||
} finally {
|
||||
await new Promise((resolve, reject) => {
|
||||
@@ -3316,7 +3327,7 @@ test("cloud api /v1/agent/chat routes M3 through Codex stdio then in-process HWL
|
||||
}
|
||||
});
|
||||
|
||||
test("cloud api /v1/agent/chat blocks direct M3 API base targets only after Codex stdio session is ready", async () => {
|
||||
test("cloud api /v1/agent/chat blocks direct M3 API base targets before Codex stdio", async () => {
|
||||
const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-m3-direct-target-"));
|
||||
const codexHome = path.join(workspace, "codex-home");
|
||||
const fakeCodex = await createFakeCodexCommand();
|
||||
@@ -3373,14 +3384,17 @@ test("cloud api /v1/agent/chat blocks direct M3 API base targets only after Code
|
||||
});
|
||||
validateCodeAgentChatSchema(payload);
|
||||
assert.equal(payload.status, "completed");
|
||||
assert.equal(payload.provider, "codex-stdio");
|
||||
assert.equal(payload.provider, "hwlab-skill-cli");
|
||||
assert.equal(payload.runner.kind, "hwlab-m3-io-skill-cli");
|
||||
assert.equal(payload.sessionMode, "controlled-m3-io-skill-cli");
|
||||
assert.equal(payload.codexStdioFeasibility.skipped, true);
|
||||
assert.equal(payload.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.blocked);
|
||||
assert.equal(payload.responseType, "m3_io_blocker");
|
||||
assert.equal(payload.m3Io.type, "m3_io_blocker");
|
||||
assert.equal(payload.m3Io.blocker.code, "direct_hardware_target_blocked");
|
||||
assert.equal(payload.toolCalls[0].name, "codex");
|
||||
assert.equal(payload.toolCalls[0].status, "completed");
|
||||
const skillTool = payload.toolCalls.find((toolCall) => toolCall.name === "hwlab-agent-runtime.m3-io");
|
||||
assert.equal(payload.toolCalls.length, 1);
|
||||
const skillTool = payload.toolCalls[0];
|
||||
assert.equal(skillTool.name, "hwlab-agent-runtime.m3-io");
|
||||
assert.equal(skillTool.status, "blocked");
|
||||
assert.equal(skillTool.route, "/v1/m3/io");
|
||||
assert.equal(skillTool.method, "POST");
|
||||
@@ -3398,7 +3412,7 @@ test("cloud api /v1/agent/chat blocks direct M3 API base targets only after Code
|
||||
}
|
||||
});
|
||||
|
||||
test("cloud api /v1/agent/chat reports M3 Skill CLI missing API base after Codex stdio session is ready", async () => {
|
||||
test("cloud api /v1/agent/chat reports M3 Skill CLI missing API base before Codex stdio", async () => {
|
||||
const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-m3-api-base-missing-"));
|
||||
const codexHome = path.join(workspace, "codex-home");
|
||||
const fakeCodex = await createFakeCodexCommand();
|
||||
@@ -3452,15 +3466,18 @@ test("cloud api /v1/agent/chat reports M3 Skill CLI missing API base after Codex
|
||||
message: "读取 M3 DI1"
|
||||
});
|
||||
assert.equal(payload.status, "completed");
|
||||
assert.equal(payload.provider, "codex-stdio");
|
||||
assert.equal(payload.provider, "hwlab-skill-cli");
|
||||
assert.equal(payload.runner.kind, "hwlab-m3-io-skill-cli");
|
||||
assert.equal(payload.sessionMode, "controlled-m3-io-skill-cli");
|
||||
assert.equal(payload.codexStdioFeasibility.skipped, true);
|
||||
assert.equal(payload.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.blocked);
|
||||
assert.equal(payload.responseType, "m3_io_blocker");
|
||||
assert.equal(payload.m3Io.type, "m3_io_blocker");
|
||||
assert.equal(payload.m3Io.blocker.code, "skill_cli_api_base_missing");
|
||||
assert.equal(payload.skills.blockers[0].code, "skill_cli_api_base_missing");
|
||||
assert.equal(payload.toolCalls[0].name, "codex");
|
||||
assert.equal(payload.toolCalls[0].status, "completed");
|
||||
const skillTool = payload.toolCalls.find((toolCall) => toolCall.name === "hwlab-agent-runtime.m3-io");
|
||||
assert.equal(payload.toolCalls.length, 1);
|
||||
const skillTool = payload.toolCalls[0];
|
||||
assert.equal(skillTool.name, "hwlab-agent-runtime.m3-io");
|
||||
assert.equal(skillTool.blocker.code, "skill_cli_api_base_missing");
|
||||
assert.deepEqual(skillTool.blocker.missingConfig, [
|
||||
"HWLAB_CODE_AGENT_HWLAB_API_BASE_URL",
|
||||
|
||||
Reference in New Issue
Block a user