feat: upgrade investigation and presentation runtime
This commit is contained in:
@@ -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 ?? "")))
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user