fix: 识别 Code Agent M3 IO 闭环拓扑意图
This commit is contained in:
@@ -1396,15 +1396,17 @@ function detectM3IoIntent(text) {
|
||||
if (!/\bM3\b|DO1|DI1|数字输出|数字输入|回读|读回|状态|status/u.test(normalized)) {
|
||||
return null;
|
||||
}
|
||||
if (/(?:直接|direct|gateway-simu|box-simu|patch-panel|hwlab-patch-panel|\/invoke|\/sync\/tick)/iu.test(normalized)) {
|
||||
if (isDirectM3HardwareAccessRequest(normalized)) {
|
||||
return null;
|
||||
}
|
||||
if (/DEV-LIVE|验收|acceptance|(?:M3|M4|M5).{0,20}(?:通过|pass|green|accept)/iu.test(normalized)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const wantsRead = /(?:DI1|数字输入).{0,16}(?:read|readback|读取|回读|读回|查询)|(?:read|readback|读取|回读|读回|查询).{0,16}(?:DI1|数字输入)/iu.test(normalized);
|
||||
const wantsWrite = /(?:DO1|数字输出).{0,20}(?:write|set|置|写|打开|关闭|true|false|高|低)|(?:write|set|置|写|打开|关闭).{0,20}(?:DO1|数字输出)/iu.test(normalized);
|
||||
const topologyMentioned = isM3TopologyRouteMention(normalized);
|
||||
const wantsRead = /(?:DI1|数字输入).{0,24}(?:read|readback|读取|回读|读回|查询|状态|当前)|(?:read|readback|读取|回读|读回|查询|状态|当前).{0,24}(?:DI1|数字输入)/iu.test(normalized);
|
||||
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);
|
||||
if (wantsStatus && !wantsRead && !wantsWrite) {
|
||||
return {
|
||||
@@ -1428,9 +1430,9 @@ function detectM3IoIntent(text) {
|
||||
const value = parseM3WriteValue(normalized);
|
||||
if (typeof value !== "boolean") {
|
||||
return {
|
||||
kind: "unsupported",
|
||||
kind: "m3_io",
|
||||
toolName: HWLAB_M3_IO_SKILL_NAME,
|
||||
reason: "M3 DO1 write requires an explicit true/false value."
|
||||
action: "do.write"
|
||||
};
|
||||
}
|
||||
return {
|
||||
@@ -1441,6 +1443,23 @@ function detectM3IoIntent(text) {
|
||||
};
|
||||
}
|
||||
|
||||
function isDirectM3HardwareAccessRequest(text) {
|
||||
const value = String(text ?? "");
|
||||
if (/(?:https?:\/\/[^\s"']*(?:gateway(?:-simu)?|box(?:-simu)?|patch-panel|hwlab-patch-panel)|\/invoke\b|\/sync\/tick\b|:7101\b|:7201\b|:7301\b)/iu.test(value)) {
|
||||
return true;
|
||||
}
|
||||
if (/(?:直接|direct).{0,24}(?:gateway-simu|box-simu|patch-panel|hwlab-patch-panel|gateway|box|\/invoke|\/sync\/tick)|(?:gateway-simu|box-simu|patch-panel|hwlab-patch-panel).{0,24}(?:直接|direct|\/invoke|\/sync\/tick)/iu.test(value)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isM3TopologyRouteMention(text) {
|
||||
const value = String(text ?? "");
|
||||
return /res_boxsimu_1\s*:?\s*DO1[\s\S]{0,80}hwlab-patch-panel[\s\S]{0,80}res_boxsimu_2\s*:?\s*DI1/iu.test(value) ||
|
||||
/DO1\s*[-=]*>\s*(?:hwlab-)?patch-panel\s*[-=]*>\s*DI1/iu.test(value);
|
||||
}
|
||||
|
||||
function parseM3WriteValue(text) {
|
||||
if (/\btrue\b|\b1\b|\bon\b|打开|置高|高电平|拉高|闭合/iu.test(text)) return true;
|
||||
if (/\bfalse\b|\b0\b|\boff\b|关闭|置低|低电平|拉低|断开/iu.test(text)) return false;
|
||||
@@ -1527,9 +1546,10 @@ function m3IoSkillArgsForIntent(intent, { env, traceId }) {
|
||||
args.push("--api-base-url", apiBaseUrl);
|
||||
}
|
||||
if (intent.action === "do.write") {
|
||||
if (typeof intent.value === "boolean") {
|
||||
args.push("--value", String(intent.value));
|
||||
}
|
||||
args.push(
|
||||
"--value",
|
||||
String(intent.value),
|
||||
"--approved",
|
||||
"--policy",
|
||||
"hwlab-api-control-with-approval",
|
||||
@@ -1706,8 +1726,11 @@ function structuredCompletionBlocker(result, context = {}) {
|
||||
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;
|
||||
const m3SkillTool = Array.isArray(result.toolCalls)
|
||||
? result.toolCalls.find((toolCall) => toolCall?.name === HWLAB_M3_IO_SKILL_NAME)
|
||||
: null;
|
||||
if ((result.provider === M3_IO_SKILL_PROVIDER || m3SkillTool) && result.capabilityLevel === HWLAB_M3_IO_CAPABILITY_LEVELS.blocked) {
|
||||
const primary = m3SkillTool?.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",
|
||||
|
||||
@@ -2533,6 +2533,225 @@ test("cloud api /v1/agent/chat blocks M3 IO when Codex stdio is unavailable", as
|
||||
}
|
||||
});
|
||||
|
||||
test("cloud api /v1/agent/chat recognizes M3 patch-panel topology text before Codex free chat", async () => {
|
||||
const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-m3-topology-skill-"));
|
||||
const codexHome = path.join(workspace, "codex-home");
|
||||
const fakeCodex = await createFakeCodexCommand();
|
||||
await mkdir(codexHome, { recursive: true });
|
||||
const calls = [];
|
||||
const codexStdioCalls = [];
|
||||
const manager = createCodexStdioSessionManager({
|
||||
idFactory: () => "ses_server_m3_topology_skill",
|
||||
createRpcClient: async () => ({
|
||||
async initialize() {
|
||||
codexStdioCalls.push("initialize");
|
||||
return { tools: ["codex", "codex-reply"] };
|
||||
},
|
||||
async listTools() {
|
||||
codexStdioCalls.push("listTools");
|
||||
return ["codex", "codex-reply"];
|
||||
},
|
||||
async callTool() {
|
||||
codexStdioCalls.push("callTool");
|
||||
return {
|
||||
structuredContent: {
|
||||
threadId: "thread_server_m3_topology_skill",
|
||||
content: "stdio session ready for controlled topology M3 skill."
|
||||
}
|
||||
};
|
||||
},
|
||||
close() {}
|
||||
})
|
||||
});
|
||||
const server = createCloudApiServer({
|
||||
env: {
|
||||
PATH: process.env.PATH,
|
||||
OPENAI_API_KEY: "test-openai-key-material",
|
||||
CODEX_HOME: codexHome,
|
||||
HWLAB_CODE_AGENT_PROVIDER: "codex-stdio",
|
||||
HWLAB_CODE_AGENT_MODEL: "gpt-test",
|
||||
HWLAB_CODE_AGENT_CODEX_COMMAND: fakeCodex.command,
|
||||
HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED: "1",
|
||||
HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR: "repo-owned",
|
||||
HWLAB_CODE_AGENT_WORKSPACE: workspace,
|
||||
HWLAB_CODE_AGENT_CODEX_WORKSPACE: workspace,
|
||||
HWLAB_CODE_AGENT_HWLAB_API_BASE_URL: "http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667"
|
||||
},
|
||||
codexStdioManager: manager,
|
||||
m3IoSkillRequestJson: async (url, request) => {
|
||||
calls.push({ url, request });
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
body: {
|
||||
serviceId: "hwlab-cloud-api",
|
||||
contractVersion: "m3-io-control-v1",
|
||||
status: "succeeded",
|
||||
accepted: true,
|
||||
action: "do.write",
|
||||
traceId: "trc_server-test-m3-topology",
|
||||
operationId: "op_m3_do_write_topology_skill",
|
||||
auditId: "aud_m3_do_write_topology_skill",
|
||||
evidenceId: "evd_m3_do_write_topology_skill",
|
||||
auditState: {
|
||||
status: "written_non_durable"
|
||||
},
|
||||
evidenceState: {
|
||||
status: "blocked",
|
||||
sourceKind: "BLOCKED",
|
||||
blocker: "runtime_durable_not_green",
|
||||
writeStatus: "written_non_durable"
|
||||
},
|
||||
durableStatus: {
|
||||
status: "degraded",
|
||||
durable: false,
|
||||
blocker: "runtime_durable_not_green"
|
||||
},
|
||||
result: {
|
||||
value: true,
|
||||
targetReadback: {
|
||||
status: "succeeded",
|
||||
value: true,
|
||||
resourceId: "res_boxsimu_2",
|
||||
port: "DI1"
|
||||
}
|
||||
},
|
||||
controlPath: {
|
||||
status: "succeeded",
|
||||
cloudApi: true,
|
||||
gatewaySimu: true,
|
||||
boxSimu: true,
|
||||
patchPanel: true,
|
||||
frontendBypass: false
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
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-topology",
|
||||
traceId: "trc_server-test-m3-topology",
|
||||
message: "执行 res_boxsimu_1:DO1 -> hwlab-patch-panel -> res_boxsimu_2:DI1 基本闭环,把 DO1 置为 true。"
|
||||
});
|
||||
validateCodeAgentChatSchema(payload);
|
||||
assert.equal(payload.status, "completed");
|
||||
assert.equal(payload.provider, "codex-stdio");
|
||||
assert.equal(payload.backend, "hwlab-cloud-api/codex-mcp-stdio");
|
||||
const skillTool = payload.toolCalls.find((toolCall) => toolCall.name === "hwlab-agent-runtime.m3-io");
|
||||
assert.ok(skillTool);
|
||||
assert.equal(skillTool.route, "/v1/m3/io");
|
||||
assert.equal(skillTool.method, "POST");
|
||||
assert.equal(skillTool.accepted, true);
|
||||
assert.equal(skillTool.operationId, "op_m3_do_write_topology_skill");
|
||||
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.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);
|
||||
assert.match(payload.reply.content, /res_boxsimu_1:DO1=true/u);
|
||||
assert.match(payload.reply.content, /res_boxsimu_2:DI1=true/u);
|
||||
assert.equal(calls.length, 1);
|
||||
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.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) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
await rm(workspace, { recursive: true, force: true });
|
||||
await rm(fakeCodex.root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("cloud api /v1/agent/chat returns Skill CLI blocker for M3 topology without DO1 value", async () => {
|
||||
const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-m3-topology-blocker-"));
|
||||
const codexHome = path.join(workspace, "codex-home");
|
||||
const fakeCodex = await createFakeCodexCommand();
|
||||
await mkdir(codexHome, { recursive: true });
|
||||
const calls = [];
|
||||
const manager = createCodexStdioSessionManager({
|
||||
idFactory: () => "ses_server_m3_topology_blocker",
|
||||
createRpcClient: async () => ({
|
||||
async initialize() {
|
||||
return { tools: ["codex", "codex-reply"] };
|
||||
},
|
||||
async listTools() {
|
||||
return ["codex", "codex-reply"];
|
||||
},
|
||||
async callTool() {
|
||||
return {
|
||||
structuredContent: {
|
||||
threadId: "thread_server_m3_topology_blocker",
|
||||
content: "stdio session ready for blocked topology M3 skill."
|
||||
}
|
||||
};
|
||||
},
|
||||
close() {}
|
||||
})
|
||||
});
|
||||
const server = createCloudApiServer({
|
||||
env: {
|
||||
PATH: process.env.PATH,
|
||||
OPENAI_API_KEY: "test-openai-key-material",
|
||||
CODEX_HOME: codexHome,
|
||||
HWLAB_CODE_AGENT_PROVIDER: "codex-stdio",
|
||||
HWLAB_CODE_AGENT_MODEL: "gpt-test",
|
||||
HWLAB_CODE_AGENT_CODEX_COMMAND: fakeCodex.command,
|
||||
HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED: "1",
|
||||
HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR: "repo-owned",
|
||||
HWLAB_CODE_AGENT_WORKSPACE: workspace,
|
||||
HWLAB_CODE_AGENT_CODEX_WORKSPACE: workspace,
|
||||
HWLAB_CODE_AGENT_HWLAB_API_BASE_URL: "http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667"
|
||||
},
|
||||
codexStdioManager: manager,
|
||||
m3IoSkillRequestJson: async (url, request) => {
|
||||
calls.push({ url, request });
|
||||
throw new Error("missing DO1 value must block before HWLAB API request");
|
||||
}
|
||||
});
|
||||
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-topology-blocker",
|
||||
traceId: "trc_server-test-m3-topology-blocker",
|
||||
message: "执行 res_boxsimu_1:DO1 -> hwlab-patch-panel -> res_boxsimu_2:DI1 基本闭环。"
|
||||
});
|
||||
validateCodeAgentChatSchema(payload);
|
||||
assert.equal(payload.status, "completed");
|
||||
assert.equal(payload.provider, "codex-stdio");
|
||||
assert.equal(payload.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.blocked);
|
||||
const skillTool = payload.toolCalls.find((toolCall) => toolCall.name === "hwlab-agent-runtime.m3-io");
|
||||
assert.ok(skillTool);
|
||||
assert.equal(skillTool.status, "blocked");
|
||||
assert.equal(skillTool.route, "/v1/m3/io");
|
||||
assert.equal(skillTool.blocker.code, "invalid_boolean_value");
|
||||
assert.equal(skillTool.accepted, false);
|
||||
assert.equal(skillTool.operationId, null);
|
||||
assert.equal(payload.blocker.code, "m3_readiness_blocked");
|
||||
assert.match(payload.reply.content, /M3 DO1 写入需要 --value true 或 --value false/u);
|
||||
assert.equal(payload.providerTrace.fallbackUsed, false);
|
||||
assert.equal(payload.providerTrace.transport, "stdio+skill-cli");
|
||||
assert.equal(calls.length, 0);
|
||||
} finally {
|
||||
await new Promise((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
await rm(workspace, { recursive: true, force: true });
|
||||
await rm(fakeCodex.root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("cloud api /v1/agent/chat routes M3 through Codex stdio then in-process HWLAB API handler", async () => {
|
||||
const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-stdio-m3-skill-"));
|
||||
const fakeCodex = await createFakeCodexCommand();
|
||||
|
||||
@@ -91,7 +91,7 @@ export async function runM3IoSkillCommand(argv = [], options = {}) {
|
||||
actorId,
|
||||
apiTarget,
|
||||
code: "invalid_boolean_value",
|
||||
message: "M3 DO1 write requires --value true or --value false."
|
||||
message: "M3 DO1 写入需要 --value true 或 --value false。"
|
||||
});
|
||||
}
|
||||
const approval = approvalForParsed(parsed, command);
|
||||
|
||||
Reference in New Issue
Block a user