feat(agent): improve investigation contracts and runtime readiness
Generic Container CI/CD / test-build-publish (push) Successful in 1m53s
Agent CI/CD / build-test-publish-and-deploy (push) Successful in 1m53s

This commit is contained in:
2026-11-07 19:43:57 +08:00
parent c7b750b099
commit b8f7edb3ee
29 changed files with 1465 additions and 119 deletions
+42 -1
View File
@@ -313,6 +313,14 @@
"bar"
]
},
"x_axis_name": {
"type": "string",
"maxLength": 128
},
"y_axis_name": {
"type": "string",
"maxLength": 128
},
"x_data": {
"type": "array",
"items": {
@@ -331,10 +339,43 @@
"type": "string",
"maxLength": 200
},
"type": {
"type": "string",
"enum": [
"line",
"bar",
"scatter"
]
},
"data": {
"type": "array",
"items": {
"type": "number"
"anyOf": [
{
"type": "number"
},
{
"type": "null"
}
]
},
"minItems": 1,
"maxItems": 100
},
"sample_times": {
"type": "array",
"items": {
"anyOf": [
{
"type": "string",
"maxLength": 64,
"format": "date-time",
"pattern": "^(?:[1-9][0-9]{3}|0[1-9][0-9]{2}|00[1-9][0-9]|000[1-9])-[0-9]{2}-[0-9]{2}T(?:[01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9](?:\\.[0-9]+)?(?:Z|[+-](?:[01][0-9]|2[0-3]):[0-5][0-9])$"
},
{
"type": "null"
}
]
},
"minItems": 1,
"maxItems": 100
+7 -2
View File
@@ -1,8 +1,11 @@
import { Ajv } from "ajv";
import { z } from "zod";
import { createChartSampleTimeSchema } from "../frontendAction/command.js";
import catalog from "./answer-ui-catalog.json" with { type: "json" };
const ajv = new Ajv({ allErrors: false, strict: true });
const sampleTimeSchema = createChartSampleTimeSchema();
ajv.addFormat("date-time", (value: string) => sampleTimeSchema.safeParse(value).success);
const propertyValidators = Object.fromEntries(Object.entries(catalog.props).map(([name, schema]) => [name, ajv.compile(schema)]));
const key = z.string().regex(/^[a-zA-Z0-9_-]{1,80}$/);
export const answerUiSpecSchema = z.object({ root: key, elements: z.record(z.object({
@@ -15,8 +18,10 @@ export const answerUiSpecSchema = z.object({ root: key, elements: z.record(z.obj
const validate = Object.hasOwn(propertyValidators, node.type) ? propertyValidators[node.type] : undefined;
if (!validate?.(node.props) || (!["Stack", "Section"].includes(node.type) && node.children.length)) { fail(); return; }
if (node.type === "Chart") {
const props = node.props as { x_data: unknown[]; series: Array<{ data: unknown[] }> };
if (props.series.some((series) => series.data.length !== props.x_data.length)) { fail(); return; }
const props = node.props as { x_data: unknown[]; series: Array<{ data: unknown[]; sample_times?: Array<string | null> }> };
if (props.series.some((series) => series.data.length !== props.x_data.length
|| (series.sample_times !== undefined && (series.sample_times.length !== props.x_data.length
|| series.sample_times.some((at, index) => at !== null && series.data[index] === null))))) { fail(); return; }
}
if (node.type === "Table") {
const props = node.props as { columns: unknown[]; rows: unknown[][] };
+15 -2
View File
@@ -1,6 +1,13 @@
// Generated by next-tjwater-frontend/scripts/export-view-command-contract.mjs.
import { z } from "zod";
/** Sample timestamps retain their original spelling within the shared RFC3339 subset. */
export function createChartSampleTimeSchema(zod: typeof z = z) {
return zod.string().max(64).datetime({ offset: true }).regex(
/^(?:[1-9][0-9]{3}|0[1-9][0-9]{2}|00[1-9][0-9]|000[1-9])-[0-9]{2}-[0-9]{2}T(?:[01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9](?:\.[0-9]+)?(?:Z|[+-](?:[01][0-9]|2[0-3]):[0-5][0-9])$/
);
}
/** Shared host/native schema factory. Native OpenCode supplies its own Zod instance. */
export function createViewSchemas(zod: typeof z = z, publication = false) {
const z = zod;
@@ -10,13 +17,17 @@ export function createViewSchemas(zod: typeof z = z, publication = false) {
.object({
title: z.string().max(200),
chart_type: z.enum(["line", "bar"]),
x_axis_name: z.string().max(128).optional(),
y_axis_name: z.string().max(128).optional(),
x_data: z.array(z.string().max(128)).min(1).max(100),
series: z
.array(
z
.object({
name: z.string().max(128),
data: z.array(z.number().finite()).min(1).max(100)
type: z.enum(["line", "bar", "scatter"]).optional(),
data: z.array(z.number().finite().nullable()).min(1).max(100),
sample_times: z.array(createChartSampleTimeSchema(z).nullable()).min(1).max(100).optional()
})
.strict()
)
@@ -25,7 +36,9 @@ export function createViewSchemas(zod: typeof z = z, publication = false) {
})
.strict()
.refine(
(value) => value.series.every((series) => series.data.length === value.x_data.length),
(value) => value.series.every((series) => series.data.length === value.x_data.length &&
(!series.sample_times || series.sample_times.length === value.x_data.length &&
series.sample_times.every((time, index) => time === null || series.data[index] !== null))),
"series must align with x_data"
);
const position = z
+42 -1
View File
@@ -214,6 +214,14 @@
"bar"
]
},
"x_axis_name": {
"type": "string",
"maxLength": 128
},
"y_axis_name": {
"type": "string",
"maxLength": 128
},
"x_data": {
"type": "array",
"items": {
@@ -232,10 +240,43 @@
"type": "string",
"maxLength": 128
},
"type": {
"type": "string",
"enum": [
"line",
"bar",
"scatter"
]
},
"data": {
"type": "array",
"items": {
"type": "number"
"anyOf": [
{
"type": "number"
},
{
"type": "null"
}
]
},
"minItems": 1,
"maxItems": 100
},
"sample_times": {
"type": "array",
"items": {
"anyOf": [
{
"type": "string",
"maxLength": 64,
"format": "date-time",
"pattern": "^(?:[1-9][0-9]{3}|0[1-9][0-9]{2}|00[1-9][0-9]|000[1-9])-[0-9]{2}-[0-9]{2}T(?:[01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9](?:\\.[0-9]+)?(?:Z|[+-](?:[01][0-9]|2[0-3]):[0-5][0-9])$"
},
{
"type": "null"
}
]
},
"minItems": 1,
"maxItems": 100
+195 -3
View File
@@ -2378,6 +2378,23 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/v1/operational-tasks/recovery/reconcile": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Reconcile Task Recoveries */
post: operations["post_operational_tasks_recovery_reconcile"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/operational-tasks/schedules": {
parameters: {
query?: never;
@@ -7490,6 +7507,10 @@ export interface components {
* Format: uuid
*/
execution_request_id: string;
/** Handling Context */
handling_context?: {
[key: string]: components["schemas"]["JsonValue"];
};
latest_plan: components["schemas"]["ExecutionPlanSnapshot"] | null;
latest_progress?: components["schemas"]["InvestigationProgressSnapshot"] | null;
latest_report: components["schemas"]["InvestigationReportRevision"] | null;
@@ -7842,6 +7863,10 @@ export interface components {
* Format: uuid
*/
execution_request_id: string;
/** Handling Context */
handling_context?: {
[key: string]: components["schemas"]["JsonValue"];
};
latest_run?: components["schemas"]["OperationalTaskRun"] | null;
/**
* Lifecycle Status
@@ -8497,8 +8522,42 @@ export interface components {
/** Total */
total: number;
};
/** ScadaMonitoringIncident */
ScadaMonitoringIncident: {
/**
* Action
* @enum {string}
*/
action: "raised" | "ongoing" | "resolved";
/**
* Incident Id
* Format: uuid
*/
incident_id: string;
/**
* Last Observed At
* Format: date-time
*/
last_observed_at: string;
/** Recovery Count */
recovery_count: number;
/**
* Started At
* Format: date-time
*/
started_at: string;
/**
* Status
* @enum {string}
*/
status: "active" | "resolved";
};
/** ScadaMonitoringResult */
ScadaMonitoringResult: {
/** Anomaly Signals */
anomaly_signals?: {
[key: string]: unknown;
}[];
/** Batch Sequence */
batch_sequence: number;
/** Burst Detection */
@@ -8507,6 +8566,7 @@ export interface components {
};
/** Detail */
detail: string;
incident?: components["schemas"]["ScadaMonitoringIncident"] | null;
/**
* Knowledge Cutoff
* Format: date-time
@@ -8546,21 +8606,55 @@ export interface components {
status: "normal" | "attention" | "insufficient_data" | "suspected_burst";
/** Task Id */
task_id?: string | null;
/** Weighted */
weighted?: {
[key: string]: unknown;
} | null;
};
/**
* ScadaMonitoringSettings
* @description Project-owned device roles used by the manual SCADA monitoring demo.
*/
ScadaMonitoringSettings: {
/**
* Alert Threshold
* @default 0.8
*/
alert_threshold: number;
/**
* Decision Policy
* @default weighted
* @enum {string}
*/
decision_policy: "all_signals" | "weighted";
/** Outlet Flow Device Ids */
outlet_flow_device_ids?: string[];
/** Pressure Device Ids */
pressure_device_ids?: string[];
/**
* Rule Version
* @default burst-monitor-v1
* @default burst-monitor-v2
*/
rule_version: string;
weights?: components["schemas"]["ScadaMonitoringWeights"];
};
/** ScadaMonitoringWeights */
ScadaMonitoringWeights: {
/**
* Burst Detection
* @default 0.5
*/
burst_detection: number;
/**
* Outlet Flow
* @default 0.1
*/
outlet_flow: number;
/**
* Pressure
* @default 0.4
*/
pressure: number;
};
/** ScadaQualityRange */
ScadaQualityRange: {
@@ -8627,6 +8721,7 @@ export interface components {
/** Can Write */
can_write: boolean;
current?: components["schemas"]["ScadaReplaySnapshot"] | null;
monitoring_config?: components["schemas"]["ScadaMonitoringSettings"] | null;
/** Monitoring Interval Seconds */
monitoring_interval_seconds?: number | null;
/** Reason */
@@ -8638,7 +8733,9 @@ export interface components {
* Action
* @enum {string}
*/
action: "step" | "stop" | "retry";
action: "step" | "monitor" | "stop" | "retry";
/** At Time */
at_time?: string | null;
/** Expected Revision */
expected_revision: number;
/**
@@ -8784,6 +8881,11 @@ export interface components {
latest: {
[key: string]: unknown;
}[];
/** Monitoring Calibration */
monitoring_calibration?: {
[key: string]: unknown;
} | null;
monitoring_config?: components["schemas"]["ScadaMonitoringSettings"] | null;
/**
* Monitoring Count
* @default 0
@@ -9503,15 +9605,23 @@ export interface components {
series: components["schemas"]["TaskSafeChartSeries"][];
/** Title */
title: string;
/** X Axis Name */
x_axis_name?: string | null;
/** X Data */
x_data: string[];
/** Y Axis Name */
y_axis_name?: string | null;
};
/** TaskSafeChartSeries */
TaskSafeChartSeries: {
/** Data */
data: number[];
data: (number | null)[];
/** Name */
name: string;
/** Sample Times */
sample_times?: (string | null)[] | null;
/** Type */
type?: ("line" | "bar" | "scatter") | null;
};
/** TaskScadaAnalysisItem */
TaskScadaAnalysisItem: {
@@ -21988,6 +22098,88 @@ export interface operations {
};
};
};
post_operational_tasks_recovery_reconcile: {
parameters: {
query?: never;
header: {
"X-Project-Id": string;
};
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["RecoverExpiredLeasesCommand"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
[key: string]: number;
};
};
};
/** @description Authentication required */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ProblemDetails"];
};
};
/** @description Insufficient permission */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ProblemDetails"];
};
};
/** @description Resource not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ProblemDetails"];
};
};
/** @description Resource conflict */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ProblemDetails"];
};
};
/** @description Validation error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ProblemDetails"];
};
};
/** @description Dependency unavailable */
503: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ProblemDetails"];
};
};
};
};
get_operational_tasks_schedules: {
parameters: {
query?: never;
+9 -3
View File
@@ -1,10 +1,11 @@
import { discoverAuthorizationServerMetadata, discoverOAuthProtectedResourceMetadata, extractWWWAuthenticateParams } from "@modelcontextprotocol/client";
import { secureMcpUrl, validateDevHttpIssuer, type McpUrlPolicy } from "./mcp-url-policy.js";
import { isTransientMcpFailure, transientMcpHttpStatus } from "./mcp-query-retry.js";
export type McpHttpFetch = (input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) => ReturnType<typeof fetch>;
type Settings = McpUrlPolicy & { resource: string; issuer: string };
type Endpoint = { tokenEndpoint: string; authMethod: "client_secret_basic" | "client_secret_post" };
const unavailable = (message: string) => Object.assign(new Error(message), { status: 503 });
const unavailable = (message: string, retryable = false) => Object.assign(new Error(message), { status: 503, retryable });
function oauthChallenge(response: Response) {
const header = response.headers.get("WWW-Authenticate") ?? "";
@@ -61,6 +62,10 @@ export class McpAuthDiscovery {
const headers = new Headers(init?.headers);
headers.delete("Authorization"); headers.delete("Cookie");
const response = await this.http(url, { ...init, headers, credentials: "omit", redirect: "error", signal: deadline });
if (transientMcpHttpStatus(response.status)) {
await response.body?.cancel();
throw unavailable("MCP OAuth metadata temporarily unavailable", true);
}
// SDK parsers receive bounded public documents; no token or client secret is attached.
const reader = response.body?.getReader();
const chunks: Uint8Array[] = [];
@@ -107,8 +112,9 @@ export class McpAuthDiscovery {
const authMethod = methods.includes("client_secret_post") ? "client_secret_post" : methods.includes("client_secret_basic") ? "client_secret_basic" : undefined;
if (!authMethod) throw unavailable("MCP OAuth server does not support the configured client secret");
return { tokenEndpoint: tokenEndpoint.href, authMethod };
} catch {
throw unavailable("MCP OAuth discovery failed or is incompatible with the configured resource and issuer");
} catch (cause) {
const transient = isTransientMcpFailure(cause);
throw unavailable(transient ? "MCP OAuth discovery temporarily unavailable" : "MCP OAuth discovery failed or is incompatible with the configured resource and issuer", transient);
}
}
}
+26 -10
View File
@@ -7,12 +7,13 @@ import type { RuntimeSessionContext } from "../runtime/sessionContext.js";
import { PRODUCT_MCP_TOOLS } from "../runtime/product-mcp-tools.js";
import { McpAuthDiscovery, type McpHttpFetch } from "./mcp-auth-discovery.js";
import { secureMcpUrl, validateDevHttpIssuer, type McpUrlPolicy } from "./mcp-url-policy.js";
import { isMcpAuthenticationFailure, isTransientMcpFailure, mayRetryMcpRead, mcpToolFailure, transientMcpHttpStatus, waitForMcpRetry } from "./mcp-query-retry.js";
export type { McpHttpFetch } from "./mcp-auth-discovery.js";
export type QueryOwner = { context: RuntimeSessionContext; runtimeSessionId: string; taskId?: string };
export type McpQuerySettings = McpUrlPolicy & { issuer: string; resource: string; audience: string; clientId: string; clientSecret: string };
type Token = { token: string; expires: number; source: string };
const error = (status: number, message: string) => Object.assign(new Error(message), { status });
const error = (status: number, message: string, retryable = false) => Object.assign(new Error(message), { status, retryable });
export function assertExchangedIdentity(source: string, exchanged: string, settings: McpQuerySettings) {
const payload = (token: string): Record<string, unknown> => {
@@ -78,7 +79,7 @@ export class McpQueryClient {
return response;
}
private async parseToken(response: Response, source: string): Promise<Token> {
if (!response.ok) { await response.body?.cancel(); throw error(503, "MCP token exchange unavailable"); }
if (!response.ok) { await response.body?.cancel(); throw error(503, "MCP token exchange unavailable", transientMcpHttpStatus(response.status)); }
const data = await response.json() as Record<string, unknown>;
if (typeof data.access_token !== "string" || !data.access_token || data.access_token.length > 16384 ||
typeof data.expires_in !== "number" || data.expires_in < 30 || typeof data.token_type !== "string" || data.token_type.toLowerCase() !== "bearer") throw error(502, "MCP token response invalid");
@@ -165,18 +166,33 @@ export class McpQueryClient {
const group = this.active.get(owner.runtimeSessionId) ?? new Set<AbortController>();
group.add(controller); this.active.set(owner.runtimeSessionId, group);
const combined = AbortSignal.any([controller.signal, AbortSignal.timeout(budget.requestMs), ...(signal ? [signal] : [])]);
const retryRead = mayRetryMcpRead(definition);
let refreshed = false;
try {
for (let attempt = 0; attempt < 2; attempt++) {
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;
if (attempt || !(status === 401 || code === "UNAUTHORIZED" || cause instanceof UnauthorizedError)) throw cause;
for (let attempt = 0; ; attempt++) {
let result: Awaited<ReturnType<typeof this.queryOnce>> | undefined;
let failure: unknown;
let returned = false;
try {
result = await boundedQuery(() => this.queryOnce(sessionId, owner, name, args, callId, combined), combined);
returned = true;
failure = mcpToolFailure(result);
} catch (cause) { failure = cause; }
combined.throwIfAborted();
if (returned && !failure) return result!;
const renew = !refreshed && (isMcpAuthenticationFailure(failure) || failure instanceof UnauthorizedError);
if (attempt >= 2 || !renew && !(retryRead && isTransientMcpFailure(failure))) {
if (returned) return result!;
throw failure;
}
if (renew) {
refreshed = true;
this.tokens.delete(owner.runtimeSessionId);
this.authDiscovery.invalidate();
} else {
await waitForMcpRetry(attempt, combined);
}
}
throw error(401, "MCP authentication required");
} finally {
group.delete(controller);
if (!group.size) this.active.delete(owner.runtimeSessionId);
@@ -201,7 +217,7 @@ export class McpQueryClient {
const binding = await this.http(bindUrl, { method: "POST", headers, redirect: "error",
signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(5000)]) : AbortSignal.timeout(5000),
body: JSON.stringify(command) });
if (!binding.ok) { await binding.body?.cancel(); if (binding.status === 401) this.tokens.delete(owner.runtimeSessionId); throw error(binding.status, "MCP query context rejected"); }
if (!binding.ok) { await binding.body?.cancel(); if (binding.status === 401) this.tokens.delete(owner.runtimeSessionId); throw error(binding.status, "MCP query context rejected", transientMcpHttpStatus(binding.status)); }
const body = await binding.json() as { context_handle?: unknown };
if (typeof body.context_handle !== "string" || !/^[A-Za-z0-9_-]{32,128}$/.test(body.context_handle)) throw error(502, "MCP query context response invalid");
const handle = body.context_handle;
+43
View File
@@ -0,0 +1,43 @@
import { setTimeout as delay } from "node:timers/promises";
import type { Tool } from "@modelcontextprotocol/client";
import { PRODUCT_MCP_READ_TOOLS } from "../runtime/product-mcp-tools.js";
const record = (value: unknown): value is Record<string, unknown> => !!value && typeof value === "object" && !Array.isArray(value);
const transientCodes = new Set(["SOURCE_UNAVAILABLE", "QUERY_TIMEOUT", "RATE_LIMIT", "RATE_LIMITED", "REQUEST_TIMEOUT"]);
const networkCodes = new Set(["ECONNRESET", "ECONNREFUSED", "ETIMEDOUT", "EAI_AGAIN", "ENOTFOUND", "EPIPE", "UND_ERR_SOCKET", "UND_ERR_CONNECT_TIMEOUT"]);
export const transientMcpHttpStatus = (status: unknown) => status === 429 || status === 502 || status === 503 || status === 504;
export function mayRetryMcpRead(tool: Tool): boolean {
// The calculation waiter already owns four bounded status attempts. Do not
// multiply its polling budget, or trust readOnlyHint as an independent grant.
return tool.name !== "list_calculation_jobs" && (PRODUCT_MCP_READ_TOOLS as readonly string[]).includes(tool.name) &&
tool.annotations?.readOnlyHint === true && tool.annotations.destructiveHint !== true;
}
export function mcpToolFailure(result: unknown): Record<string, unknown> | undefined {
if (!record(result) || result.isError !== true || !record(result.structuredContent) || !record(result.structuredContent.error)) return;
return result.structuredContent.error;
}
export function isMcpAuthenticationFailure(value: unknown): boolean {
if (!record(value)) return false;
const status = value.status ?? value.statusCode;
return typeof status === "number" ? status === 401 : value.code === "UNAUTHORIZED" || value.code === "AUTH_REQUIRED";
}
export function isTransientMcpFailure(value: unknown): boolean {
if (!record(value) || value.retryable === false) return false;
const status = value.status ?? value.statusCode;
if (typeof status === "number") return transientMcpHttpStatus(status);
if (typeof value.code === "string" && (transientCodes.has(value.code) || networkCodes.has(value.code))) return true;
if (value.name === "TimeoutError" || value.code === -32001) return true;
if (value.name === "TypeError" && typeof value.message === "string" && /fetch|network|connection|connect|load failed|socket/i.test(value.message)) return true;
return record(value.cause) && typeof value.cause.code === "string" && networkCodes.has(value.cause.code);
}
export async function waitForMcpRetry(attempt: number, signal: AbortSignal): Promise<void> {
signal.throwIfAborted();
try { await delay(attempt === 0 ? 100 : 300, undefined, { signal }); }
catch (cause) { signal.throwIfAborted(); throw cause; }
}
+2 -2
View File
@@ -332,7 +332,7 @@ export const streamPromptResponse = async ({
emittedText = true;
done = true;
}
} else await runtime.prompt(sessionId, message, toRuntimeModel(model));
} else await runtime.prompt(sessionId, message, toRuntimeModel(model), undefined, signal);
onPromptDispatched?.();
})
.then(() => {
@@ -1048,7 +1048,7 @@ export const streamPromptResponse = async ({
// the same managed session/lease, never create another task run.
finalAnswer = undefined;
sawResponseActivity = false;
await runtime.prompt(sessionId, typeof idleDecision === "string" ? idleDecision : "请处理收信箱中新到达的调查协作消息,按需回复,然后完成本轮工作。", toRuntimeModel(model));
await runtime.prompt(sessionId, typeof idleDecision === "string" ? idleDecision : "请处理收信箱中新到达的调查协作消息,按需回复,然后完成本轮工作。", toRuntimeModel(model), undefined, signal);
continue;
}
if (waitForUserInput && await waitForUserInput()) {
+11 -3
View File
@@ -29,7 +29,7 @@ import { type FrontendActionCoordinator } from "../frontendAction/coordinator.js
import { type LearningOrchestrator } from "../learning/orchestrator.js";
import { logger } from "../logger.js";
import { type MemoryStore } from "../memory/store.js";
import { type OpencodeRuntimeAdapter } from "../runtime/opencode.js";
import { McpPreparationError, type OpencodeRuntimeAdapter } from "../runtime/opencode.js";
import {
getRuntimeSessionContext,
setRuntimeSessionContext,
@@ -498,6 +498,11 @@ export const registerChatStreamRoute = (
const taskContext = sessionPurpose === "task_collaboration"
? resolvedTaskContext
: null;
const taskSource = z.object({ replay_context: z.record(z.unknown()).nullish() }).passthrough()
.safeParse(resolvedTaskContext?.task.source);
setRuntimeSessionContext({ ...getRuntimeSessionContext(binding.sessionId)!,
taskWorkflow: resolvedTaskContext?.task.workflow,
taskReplay: taskSource.success && !!taskSource.data.replay_context });
if (taskContext) {
const report = taskContext.selected_report ?? taskContext.task.latest_report;
const reference = z.object({ revision: z.number().int().positive() }).passthrough().safeParse(report);
@@ -1178,7 +1183,10 @@ export const registerChatStreamRoute = (
}
}
} catch (error) {
publish("error", { session_id: clientSessionId, message: abortController.signal.aborted ? "请求已中断" : "Agent response failed" });
const preparationFailure = error instanceof McpPreparationError ? error : null;
publish("error", { session_id: clientSessionId,
message: abortController.signal.aborted ? "请求已中断" : preparationFailure?.message ?? "Agent response failed",
...(preparationFailure && !abortController.signal.aborted ? { code: preparationFailure.code } : {}) });
await stopTaskHeartbeat();
if (
sessionPurpose === "task_execution" &&
@@ -1192,7 +1200,7 @@ export const registerChatStreamRoute = (
activeSessionRecord.taskId,
activeSessionRecord.runId,
clientSessionId,
abortController.signal.aborted ? "AGENT_ABORTED" : "AGENT_EXECUTION_ERROR",
abortController.signal.aborted ? "AGENT_ABORTED" : preparationFailure?.code ?? "AGENT_EXECUTION_ERROR",
abortController.signal.aborted
? "Agent execution was aborted"
: error instanceof Error ? error.message : "Agent execution failed",
+125 -7
View File
@@ -1,5 +1,7 @@
import { LOCAL_QUERY_TOOL_IDS, PRODUCT_MCP_TOOL_IDS } from "./product-mcp-tools.js";
import { LOCAL_QUERY_TOOL_IDS, PRODUCT_MCP_TOOL_IDS, PRODUCT_MCP_WRITE_TOOL_IDS, QUERY_MCP_SERVER } from "./product-mcp-tools.js";
import { assertWebSearchReady } from "./web-search-check.js";
import { allowedQueryTools } from "./query-mcp-policy.js";
import { setTimeout as delayAbortable } from "node:timers/promises";
import { isMessageNotFoundError, isSessionNotFoundError, type OpenCodeClient as OpencodeClient } from "@opencode/client";
import { createRuntimeClient, startOpencode2 } from "./opencode2-server.js";
import { formAnswers, normalizeRuntimeMessage, Opencode2EventNormalizer } from "./opencode2-events.js";
@@ -11,7 +13,7 @@ import { bindScopedConversationWorkspace, createConversationWorkspace, ensureSes
import { getRuntimeSessionContext } from "./sessionContext.js";
import { TASK_AGENT } from "./task-agent-policy.js";
import { serviceOpencodeConfig } from "./opencode2-config.js";
import { mcpReadonlyTools, mcpRuntimeReportSchema } from "./mcp-config.js";
import { mcpReadonlyTools, mcpRuntimeReportSchema, mcpTimeoutDefaults } from "./mcp-config.js";
import { parseModelReference } from "../chat/modelConfig.js";
import type { ResponseInput, InputDelivery } from "../chat/response-inputs.js";
import { assertModelCheckPassed, modelCheckReportSchema, modelCheckTargets } from "./model-check.js";
@@ -82,10 +84,24 @@ export const resolveEmbeddedOpencodePort = async (
: 0;
};
export class McpPreparationError extends Error {
readonly code = "MCP_NOT_READY";
readonly status = 503;
constructor(readonly reason: string) { super(`MCP 准备失败:${reason};未启动模型,请重试或检查服务配置。`); }
}
function recoverableMcpConnectionError(error: unknown): boolean {
const detail = typeof error === "string" ? error : error instanceof Error ? error.message
: error && typeof error === "object" && "message" in error ? String(error.message) : "";
if (/401|403|auth|forbidden|configuration|schema|protocol|not.?found/i.test(detail)) return false;
return /ECONNREFUSED|ECONNRESET|EPIPE|ETIMEDOUT|EHOSTUNREACH|ENETUNREACH|fetch failed|connection (?:closed|reset)|socket hang up|network (?:error|unavailable)|transport.*closed/i.test(detail);
}
export class OpencodeRuntimeAdapter {
private clientPromise: Promise<OpencodeClient> | null = null;
private closeServer: (() => void) | null = null;
private workspaceBindings = new Map<string, Promise<string>>();
private promptPreparations?: Map<string, Set<AbortController>>;
async ensureClient(): Promise<OpencodeClient> {
if (!this.clientPromise) {
@@ -121,13 +137,107 @@ export class OpencodeRuntimeAdapter {
return this.readMcpReport(client, directory);
}
private async readMcpReport(client: OpencodeClient, directory: string) {
private async readMcpReport(client: OpencodeClient, directory: string, signal?: AbortSignal) {
const response = await client.rpc.call({
rpcID: "tjwater.tools", method: "mcp", input: {}, location: { directory },
}, { signal: AbortSignal.timeout(15_000) });
}, { signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(15_000)]) : AbortSignal.timeout(15_000) });
return mcpRuntimeReportSchema.parse(response.output);
}
private async ensureSessionToolsReady(sessionId: string, signal?: AbortSignal) {
signal?.throwIfAborted();
const controller = new AbortController();
const preparations = this.promptPreparations ??= new Map();
const pending = preparations.get(sessionId) ?? new Set<AbortController>();
pending.add(controller);
preparations.set(sessionId, pending);
const deadline = AbortSignal.timeout(mcpTimeoutDefaults.startup + mcpTimeoutDefaults.catalog);
const readySignal = AbortSignal.any([controller.signal, deadline, ...(signal ? [signal] : [])]);
try {
const client = await this.ensureClient();
const directory = await this.ensureSessionWorkspace(sessionId);
readySignal.throwIfAborted();
const context = getRuntimeSessionContext(sessionId);
const grants = new Set(this.mcpReadonlyTools());
// Verify only queries this host-owned scope can expose. Readiness never
// grants a tool, changes its executor, or enables an optional MCP server.
const queries = context ? allowedQueryTools(context).filter((name) =>
(config.TJWATER_MCP_ENABLED || !PRODUCT_MCP_TOOL_IDS.includes(name)) &&
(grants.has(name) || PRODUCT_MCP_WRITE_TOOL_IDS.includes(name))) : [];
if (context?.purpose === "task_execution") {
if (!context.projectId || !context.taskId) throw new McpPreparationError("后台调查缺少可信任务范围");
if (!config.TJWATER_MCP_ENABLED) throw new McpPreparationError("后台调查所需业务 MCP 已禁用");
if (!queries.includes(`${QUERY_MCP_SERVER}_get_task_context`))
throw new McpPreparationError("后台调查所需上下文工具未获授权");
}
const required = context?.purpose === "task_execution"
? ["ask_task_user", "publish_task_report", "publish_task_progress", "publish_task_presentation", "propose_task_action", ...queries]
: ["ask_user", "final_answer", ...queries];
let reconnected = false, transientReadRetries = 0;
for (;;) {
readySignal.throwIfAborted();
let tools: Array<{ id: string; direct: boolean }>;
let report;
try {
const [catalog, current] = await Promise.all([
client.rpc.call({ rpcID: "tjwater.tools", method: "list", input: {}, location: { directory } }, { signal: readySignal }),
this.readMcpReport(client, directory, readySignal),
]);
if (!Array.isArray(catalog.output) || catalog.output.some((tool) =>
!tool || typeof tool.id !== "string" || typeof tool.direct !== "boolean"))
throw new McpPreparationError("工具目录格式无效");
tools = catalog.output;
report = current;
} catch (error) {
readySignal.throwIfAborted();
if (error instanceof McpPreparationError) throw error;
if (!recoverableMcpConnectionError(error) || transientReadRetries++ >= 1)
throw new McpPreparationError("无法读取工具目录或连接状态");
await delayAbortable(100, undefined, { signal: readySignal });
continue;
}
readySignal.throwIfAborted();
if (report.configuration_errors.length) throw new McpPreparationError("MCP 配置无效");
const connection = report.servers.find((server) => server.name === QUERY_MCP_SERVER);
if (queries.length && connection?.status === "needs_auth") throw new McpPreparationError("业务工具连接需要鉴权");
if (queries.length && connection?.status === "disabled") throw new McpPreparationError("业务工具连接已禁用");
if (queries.length && connection?.status === "failed") {
const native = await client.mcp.list({ location: { directory } }, { signal: readySignal });
readySignal.throwIfAborted();
const state = native.data.find((server) => server.name === QUERY_MCP_SERVER)?.status;
if (state?.status === "connected" || state?.status === "pending") {
await delayAbortable(100, undefined, { signal: readySignal });
continue;
}
if (reconnected || state?.status !== "failed" || !recoverableMcpConnectionError(state.error))
throw new McpPreparationError("业务工具连接失败");
reconnected = true;
try { await client.mcp.connect({ server: QUERY_MCP_SERVER, location: { directory } }, { signal: readySignal }); }
catch { readySignal.throwIfAborted(); throw new McpPreparationError("业务工具重连失败"); }
continue;
}
const missing = required.filter((name) => !tools.some((tool) => tool.id === name && tool.direct));
if (missing.some((name) => !name.startsWith("mcp_"))) throw new McpPreparationError("必要的宿主工具未注册");
if (!missing.length && (!queries.length || connection?.status === "connected" && queries.every((name) => connection.tools.includes(name)))) return;
await delayAbortable(100, undefined, { signal: readySignal });
}
} catch (error) {
signal?.throwIfAborted();
controller.signal.throwIfAborted();
if (deadline.aborted) throw new McpPreparationError("等待业务工具目录就绪超时");
if (error instanceof McpPreparationError) throw error;
throw new McpPreparationError("业务工具准备不可用");
} finally {
pending.delete(controller);
if (!pending.size) preparations.delete(sessionId);
}
}
private cancelSessionPreparations(sessionId: string) {
for (const controller of this.promptPreparations?.get(sessionId) ?? [])
controller.abort(new DOMException("Session preparation cancelled", "AbortError"));
}
async resolveAvailableModel(preferredModelIds: string[]): Promise<string> {
const client = await this.ensureClient();
// V2 2.0.24 model.list alone does not await a cold Location's plugin setup.
@@ -262,6 +372,7 @@ export class OpencodeRuntimeAdapter {
if (!scope && !isLegacyConversationWorkspace(directory, root) && !isManagedConversationWorkspace(directory, root, sessionId)) {
throw new Error("cannot remove another session's workspace");
}
this.cancelSessionPreparations(sessionId);
await client.session.interrupt({ sessionID: sessionId });
await this.waitForSessionIdle(sessionId);
if ((await client.session.active())[sessionId]) throw new Error("session is still active; cleanup can be retried");
@@ -365,6 +476,7 @@ export class OpencodeRuntimeAdapter {
async enqueueUserInput(sessionId: string, input: ResponseInput, options: { resume?: boolean } = {}) {
const client = await this.ensureClient();
if (options.resume !== false) await this.ensureSessionToolsReady(sessionId);
await client.session.prompt({ sessionID: sessionId, id: input.id, text: input.text,
delivery: input.delivery, resume: options.resume ?? true, metadata: { source: "tjwater_user_input" } });
}
@@ -417,7 +529,7 @@ export class OpencodeRuntimeAdapter {
return { ...item, status: "cancelled" };
}
async prompt(sessionId: string, text: string, model?: RuntimeModelOverride, messageId?: string) {
async prompt(sessionId: string, text: string, model?: RuntimeModelOverride, messageId?: string, signal?: AbortSignal) {
const client = await this.ensureClient();
const startedAt = Date.now();
logDevelopmentDebug(
@@ -434,7 +546,9 @@ export class OpencodeRuntimeAdapter {
const agent = context?.purpose === "task_execution" && context.taskId && context.projectId ? TASK_AGENT : "instruction";
await client.session.switchAgent({ sessionID: sessionId, agent });
if (model) await client.session.switchModel({ sessionID: sessionId, model: { providerID: model.providerID, id: model.modelID } });
await client.session.prompt({ sessionID: sessionId, text, ...(messageId ? { id: messageId, delivery: "steer" as const, metadata: { source: "tjwater_host_answer" } } : {}) });
await this.ensureSessionToolsReady(sessionId, signal);
signal?.throwIfAborted();
await client.session.prompt({ sessionID: sessionId, text, ...(messageId ? { id: messageId, delivery: "steer" as const, metadata: { source: "tjwater_host_answer" } } : {}) }, { signal });
logDevelopmentDebug(
"opencode session.prompt returned",
{
@@ -472,12 +586,14 @@ export class OpencodeRuntimeAdapter {
signal?.throwIfAborted();
if (state === "missing") {
if (active) throw new Error("Previous native turn is still active");
await this.prompt(sessionId, text, model, messageId);
await this.prompt(sessionId, text, model, messageId, signal);
return "started";
}
if (active) return "observing";
if (state === "enqueued") {
// Resume an admitted steering input without constructing another message.
await this.ensureSessionToolsReady(sessionId, signal);
signal?.throwIfAborted();
await client.session.interrupt({ sessionID: sessionId, resume: true });
return "observing";
}
@@ -552,6 +668,7 @@ export class OpencodeRuntimeAdapter {
}
async abortSession(sessionId: string) {
this.cancelSessionPreparations(sessionId);
const client = await this.ensureClient();
return client.session.interrupt({ sessionID: sessionId });
}
@@ -640,6 +757,7 @@ export class OpencodeRuntimeAdapter {
}
async dispose(): Promise<void> {
for (const sessionId of this.promptPreparations?.keys() ?? []) this.cancelSessionPreparations(sessionId);
this.closeServer?.();
this.closeServer = null;
this.clientPromise = null;
+3 -1
View File
@@ -6,6 +6,8 @@ export function allowedQueryTools(context: RuntimeSessionContext, reader = false
if (context.learningMode === "review") return [];
const project = !!context.projectId && !context.projectKey.startsWith("personal-") && ["ordinary_chat", "task_execution", "task_collaboration"].includes(context.purpose ?? "");
if (reader) return project ? PRODUCT_MCP_READ_TOOLS.map(queryMcpToolId) : [];
return [...(project ? PRODUCT_MCP_TOOLS.filter((name) => !(PRODUCT_MCP_WRITE_TOOLS as readonly string[]).includes(name) || context.purpose === "ordinary_chat") : []), ...LOCAL_QUERY_TOOLS.filter((name) =>
const burstEvent = !!context.taskId && context.taskWorkflow === "burst_report" && !context.taskReplay;
return [...(project ? PRODUCT_MCP_TOOLS.filter((name) => (!burstEvent || name !== "run_burst_location")
&& (!(PRODUCT_MCP_WRITE_TOOLS as readonly string[]).includes(name) || context.purpose === "ordinary_chat")) : []), ...LOCAL_QUERY_TOOLS.filter((name) =>
(!projectLocal.has(name) || project) && !(context.purpose === "task_execution" && ["list_investigation_tasks", "get_investigation_task"].includes(name)))].map(queryMcpToolId);
}
+2
View File
@@ -12,6 +12,8 @@ export type RuntimeSessionContext = {
capabilities?: string[];
purpose?: "ordinary_chat" | "task_execution" | "task_collaboration";
taskId?: string;
taskWorkflow?: string;
taskReplay?: boolean;
reportRevision?: number;
workspaceDirectory?: string;
hostTurnId?: string;