feat: upgrade investigation and presentation runtime
Generic Container CI/CD / test-build-publish (push) Successful in 1m6s
Agent CI/CD / build-test-publish-and-deploy (push) Successful in 1m7s

This commit is contained in:
2026-10-30 17:30:19 +08:00
parent 3b52a0ddab
commit 6336157ff4
104 changed files with 6541 additions and 961 deletions
+19 -1
View File
@@ -3,6 +3,14 @@ import { supplyLayerSchema } from "./task-presentation.js";
import { answerUiBlockSchema } from "../chat/answer-ui.js";
const text = z.string().trim().min(1);
export function mermaidDeclaresNodeId(source: string, nodeId: string): boolean {
const escaped = nodeId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return new RegExp(
`(?:^|[^A-Za-z0-9_-])${escaped}(?=\\s*(?:[\\[({@;]|--|==|-\\.|$))`,
"m",
).test(source);
}
export const investigationDiagramSchema = z.object({
title: text,
mermaid: text.max(20000).refine((source) =>
@@ -14,7 +22,7 @@ export const investigationDiagramSchema = z.object({
label: text,
evidence_refs: z.array(text).default([]),
action_refs: z.array(z.string().uuid()).default([]),
status: z.enum(["pending", "running", "completed", "waiting_human", "failed", "skipped", "cancelled"]).nullish(),
status: z.enum(["pending", "running", "completed", "waiting", "waiting_human", "failed", "skipped", "cancelled"]).nullish().describe("Evidence-confirmed node state; waiting means prerequisites/external/system result, waiting_human requires manual authorization/approval/feedback. Unknown stays omitted/null; host controls colors."),
map_targets: z.array(z.object({
source_id: supplyLayerSchema,
feature_id: text.max(128),
@@ -24,6 +32,15 @@ export const investigationDiagramSchema = z.object({
if (new Set(diagram.node_refs.map((node) => node.node_id)).size !== diagram.node_refs.length) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "调查图节点引用重复" });
}
diagram.node_refs.forEach((node, index) => {
if (!mermaidDeclaresNodeId(diagram.mermaid, node.node_id)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["node_refs", index, "node_id"],
message: `调查图节点引用 ${node.node_id} 未在 Mermaid 源码中声明`,
});
}
});
});
export const taskProgressSchema = z.object({
summary: text.max(20000),
@@ -65,6 +82,7 @@ export const taskReportSchema = z.object({
if (Buffer.byteLength(JSON.stringify(blocks), "utf8") > 400000) invalid("报告交互内容超过 400 KB");
if (new Set(blocks.map((item) => item.block.block_id)).size !== blocks.length) invalid("报告界面块编号重复");
for (const item of blocks) {
if (item.block.phase === "process") invalid("正式报告只能包含 final 界面块;旧版未标注阶段的块保持兼容");
if (item.evidence_refs.some((ref) => !report.evidence_refs.includes(ref))) invalid("界面依据必须属于报告依据");
for (const node of Object.values(item.block.spec.elements)) {
if (node.type === "Evidence" && !item.evidence_refs.includes(String(node.props.evidence_id ?? "")))
+5 -5
View File
@@ -1,5 +1,5 @@
import { Client, StreamableHTTPClientTransport, type Tool, UnauthorizedError } from "@modelcontextprotocol/client";
import { queryTimeouts } from "../mcp/query-timeouts.js";
import { boundedQuery, modelQueryTool, queryTimeouts } from "../mcp/query-timeouts.js";
import type { components } from "../generated/server-api.js";
import { config } from "../config.js";
import { requestWithSessionCredential, onSessionCredentialReleased } from "./credential-refresh.js";
@@ -132,7 +132,7 @@ export class McpQueryClient {
promise.then(resolve, reject).finally(() => signal.removeEventListener("abort", abort));
});
}
private async withClient<T>(token: string, project: string | undefined, signal: AbortSignal | undefined, run: (client: Client) => Promise<T>, timeoutMs = 30_000) {
private async withClient<T>(token: string, project: string | undefined, signal: AbortSignal | undefined, run: (client: Client) => Promise<T>, timeoutMs = 180_000) {
const client = new Client({ name: "tjwater-agent-host", version: "1.0.0" }, { versionNegotiation: { mode: { pin: "2026-07-28" } }, capabilities: { extensions: { "io.modelcontextprotocol/oauth-client-credentials": {} } } });
const transport = new StreamableHTTPClientTransport(new URL(this.settings.resource), {
requestInit: { headers: { Authorization: `Bearer ${token}`, ...(project ? { "X-Project-Id": project } : {}) }, redirect: "error" },
@@ -154,7 +154,7 @@ export class McpQueryClient {
const input = structuredClone(tool.inputSchema);
delete input.properties?.context_handle;
input.required = input.required?.filter((key) => key !== "context_handle");
return { ...tool, inputSchema: input };
return modelQueryTool({ ...tool, inputSchema: input });
});
}
async query(sessionId: string, owner: QueryOwner, name: string, args: Record<string, unknown>, callId: string, signal?: AbortSignal) {
@@ -167,7 +167,7 @@ export class McpQueryClient {
const combined = AbortSignal.any([controller.signal, AbortSignal.timeout(budget.requestMs), ...(signal ? [signal] : [])]);
try {
for (let attempt = 0; attempt < 2; attempt++) {
try { return await this.queryOnce(sessionId, owner, name, args, callId, combined); }
try { return await boundedQuery(() => this.queryOnce(sessionId, owner, name, args, callId, combined), combined); }
catch (cause) {
const status = cause && typeof cause === "object" && "status" in cause ? cause.status : undefined;
const code = cause && typeof cause === "object" && "code" in cause ? cause.code : undefined;
@@ -208,7 +208,7 @@ export class McpQueryClient {
try {
return await this.withClient(token.token, project, signal, async (client) => {
await client.listTools(undefined, { signal, timeout: 5000 });
return client.callTool({ name, arguments: { ...args, context_handle: handle }, _meta: { "io.tjwater/callId": callId } }, { signal, timeout: budget.callMs });
return client.callTool({ name, arguments: { ...args, ...(this.tools.find((tool) => tool.name === name)!.inputSchema.properties?.timeout_seconds ? { timeout_seconds: budget.serverMs / 1000 } : {}), context_handle: handle }, _meta: { "io.tjwater/callId": callId } }, { signal, timeout: budget.callMs });
}, budget.callMs);
} finally {
// Expiry remains authoritative if transport cancellation prevents revocation.
+21
View File
@@ -3,6 +3,7 @@ import { taskReportSchema, taskProgressSchema, type TaskProgress } from "./inves
import { taskPresentationCommandSchema, taskPresentationResultSchema, type TaskPresentationCommand } from "./task-presentation.js";
export { taskReportSchema } from "./investigation-report.js";
import { z } from "zod";
import { interactionQuestionsSchema, type InteractionQuestion } from "../chat/interaction-request-contract.js";
import { config } from "../config.js";
import {
@@ -32,6 +33,7 @@ const operationalTaskRunSchema = z.object({
status: z.enum(["queued", "claimed", "running", "succeeded", "failed", "timed_out", "cancelled"]),
revision: z.number().int().positive(),
agent_session_id: z.string().min(1).nullable(),
agent_instance_id: z.string().min(1).nullable().default(null),
lease_expires_at: z.string().datetime({ offset: true }).nullable(),
heartbeat_at: z.string().datetime({ offset: true }).nullable(),
started_at: z.string().datetime({ offset: true }).nullable(),
@@ -180,6 +182,7 @@ export class OperationalTaskClient {
request_id: input.requestId,
expected_revision: input.expectedRevision,
source_session_id: input.sourceSessionId,
agent_instance_id: config.AGENT_INSTANCE_ID,
}),
});
const result = operationalTaskRunSchema.parse(raw);
@@ -203,6 +206,7 @@ export class OperationalTaskClient {
request_id: crypto.randomUUID(),
expected_revision: state.revision,
source_session_id: sourceSessionId,
agent_instance_id: config.AGENT_INSTANCE_ID,
}),
});
const result = operationalTaskRunSchema.parse(raw);
@@ -290,6 +294,7 @@ export class OperationalTaskClient {
request_id: crypto.randomUUID(),
expected_revision: state.revision,
source_session_id: sourceSessionId,
agent_instance_id: config.AGENT_INSTANCE_ID,
...extra,
}),
},
@@ -326,6 +331,22 @@ export class OperationalTaskClient {
});
}
async askTaskUser(runtimeSessionId: string, taskId: string, input: {
request_id: string; questions: InteractionQuestion[]; sourceSessionId: string;
}) {
return this.mutateRun(runtimeSessionId, async () => {
const state = taskRunStates.get(runtimeSessionId);
if (!state) throw new OperationalTaskClientError(409, "task run context unavailable");
const raw = await this.request(runtimeSessionId, `/api/v1/operational-tasks/${encodeURIComponent(taskId)}/questions`, {
method: "POST", body: JSON.stringify({ request_id: input.request_id,
source_session_id: input.sourceSessionId, run_id: state.runId,
questions: interactionQuestionsSchema.parse(input.questions) }),
});
return z.object({ question_request_id: z.string().uuid(), status: z.enum(["pending", "answered", "cancelled"]),
version: z.number().int().positive() }).passthrough().parse(raw);
});
}
async proposeAction(runtimeSessionId: string, taskId: string, input: ProposedTaskAction) {
const result = await this.request(runtimeSessionId, `/api/v1/operational-tasks/${encodeURIComponent(taskId)}/actions`, {
method: "POST",
+3 -30
View File
@@ -1,34 +1,7 @@
import { z } from "zod";
import { safeChartInput } from "../frontendAction/registry.js";
export const supplyLayerSchema = z.enum(["junctions", "pipes", "valves", "pumps", "tanks", "reservoirs", "scada"]);
const identifier = z.string().trim().min(1).max(128);
export const taskPresentationCommandSchema = z.discriminatedUnion("action", [
z.object({
action: z.literal("highlight_features"),
layer: supplyLayerSchema,
feature_ids: z.array(identifier).min(1).max(100),
effect: z.enum(["steady", "pulse"]).default("steady"),
}).strict(),
z.object({ action: z.literal("clear_highlights") }).strict(),
z.object({
action: z.literal("open_chart"),
chart_id: identifier,
chart: safeChartInput,
evidence_refs: z.array(z.string().trim().min(1)).min(1).max(100),
}).strict(),
z.object({ action: z.literal("close_chart"), chart_id: identifier }).strict(),
z.object({
action: z.literal("set_overlay"),
overlay_id: identifier,
title: z.string().trim().min(1).max(200),
mode: z.enum(["geometry", "heatmap"]),
evidence_id: z.string().trim().min(1),
}).strict(),
z.object({ action: z.literal("clear_overlay"), overlay_id: identifier }).strict(),
]);
import { createViewCommandSchema } from "../frontendAction/command.js";
export { supplyLayerSchema } from "../frontendAction/command.js";
export const taskPresentationCommandSchema = createViewCommandSchema({ publication: true });
export type TaskPresentationCommand = z.infer<typeof taskPresentationCommandSchema>;