feat(agent): improve investigation contracts and runtime readiness
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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[][] };
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Vendored
+195
-3
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user