feat(agent): add frontend action bridge
This commit is contained in:
@@ -0,0 +1,12 @@
|
|||||||
|
import type { Plugin } from "@opencode-ai/plugin";
|
||||||
|
|
||||||
|
export const FRONTEND_ACTION_TOOLS = new Set(["zoom_to_map", "locate_features", "view_history", "view_scada"]);
|
||||||
|
|
||||||
|
export const frontendActionCallIdPlugin: Plugin = async () => ({
|
||||||
|
"tool.execute.before": async (input, output) => {
|
||||||
|
if (!FRONTEND_ACTION_TOOLS.has(input.tool)) return;
|
||||||
|
output.args = { ...(output.args ?? {}), __frontend_action_call_id: input.callID };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default frontendActionCallIdPlugin;
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
type FrontendActionArgs = Record<string, unknown> & { __frontend_action_call_id?: string; reason?: string };
|
||||||
|
|
||||||
|
const internalBaseUrl = process.env.TJWATER_AGENT_INTERNAL_BASE_URL ?? "http://127.0.0.1:8787";
|
||||||
|
const internalToken = process.env.TJWATER_AGENT_INTERNAL_TOKEN ?? "";
|
||||||
|
|
||||||
|
export const executeFrontendAction = async (
|
||||||
|
name: string,
|
||||||
|
args: FrontendActionArgs,
|
||||||
|
context: { sessionID: string },
|
||||||
|
) => {
|
||||||
|
const callId = args.__frontend_action_call_id;
|
||||||
|
if (!callId) throw new Error("frontend action bridge did not inject the OpenCode call ID");
|
||||||
|
const { __frontend_action_call_id: _callId, reason, ...params } = args;
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetch(`${internalBaseUrl}/internal/frontend-actions/request`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", "x-agent-internal-token": internalToken },
|
||||||
|
body: JSON.stringify({ session_id: context.sessionID, call_id: callId, name, params, fallback_text: reason }),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`frontend action bridge unavailable: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
}
|
||||||
|
const text = await response.text();
|
||||||
|
if (!response.ok) throw new Error(`frontend action bridge rejected request (${response.status}): ${text}`);
|
||||||
|
return text;
|
||||||
|
};
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { tool } from "@opencode-ai/plugin";
|
import { tool } from "@opencode-ai/plugin";
|
||||||
|
import { executeFrontendAction } from "./frontend_action.js";
|
||||||
|
|
||||||
export default tool({
|
export default tool({
|
||||||
description: "在前端地图上定位并高亮指定的管网要素。",
|
description: "在前端地图上定位并高亮指定的管网要素。",
|
||||||
@@ -9,14 +10,13 @@ export default tool({
|
|||||||
"Why this map positioning action is needed for the user request.",
|
"Why this map positioning action is needed for the user request.",
|
||||||
),
|
),
|
||||||
ids: tool.schema
|
ids: tool.schema
|
||||||
.array(tool.schema.string())
|
.array(tool.schema.string().min(1)).min(1).max(100)
|
||||||
.describe("Feature ids to locate."),
|
.describe("Feature ids to locate."),
|
||||||
feature_type: tool.schema
|
feature_type: tool.schema
|
||||||
.enum(["junction", "pipe", "valve", "reservoir", "pump", "tank"])
|
.enum(["junction", "pipe", "valve", "reservoir", "pump", "tank", "conduit", "orifice", "outfall"])
|
||||||
.describe("Type of feature to locate."),
|
.describe("Type of feature to locate."),
|
||||||
},
|
},
|
||||||
async execute() {
|
async execute(args, context) {
|
||||||
// 前端工具只负责生成 tool part,真正的地图动作由 Agent SSE 适配层转发给浏览器执行。
|
return executeFrontendAction("locate_features", args, context);
|
||||||
return "已在地图上定位到指定要素。";
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { tool } from "@opencode-ai/plugin";
|
import { tool } from "@opencode-ai/plugin";
|
||||||
|
import { executeFrontendAction } from "./frontend_action.js";
|
||||||
|
|
||||||
export default tool({
|
export default tool({
|
||||||
description: "为选定的管网要素打开前端的历史记录或计算结果面板。",
|
description: "为选定的管网要素打开前端的历史记录或计算结果面板。",
|
||||||
@@ -23,8 +24,7 @@ export default tool({
|
|||||||
.optional()
|
.optional()
|
||||||
.describe("Optional ISO8601 end time."),
|
.describe("Optional ISO8601 end time."),
|
||||||
},
|
},
|
||||||
async execute() {
|
async execute(args, context) {
|
||||||
// 返回短确认即可;面板打开动作由前端根据 tool_call 参数完成。
|
return executeFrontendAction("view_history", args, context);
|
||||||
return "已打开计算结果面板。";
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { tool } from "@opencode-ai/plugin";
|
import { tool } from "@opencode-ai/plugin";
|
||||||
|
import { executeFrontendAction } from "./frontend_action.js";
|
||||||
|
|
||||||
export default tool({
|
export default tool({
|
||||||
description: "打开前端的 SCADA 监测数据历史面板。",
|
description: "打开前端的 SCADA 监测数据历史面板。",
|
||||||
@@ -23,8 +24,7 @@ export default tool({
|
|||||||
.optional()
|
.optional()
|
||||||
.describe("Optional ISO8601 end time."),
|
.describe("Optional ISO8601 end time."),
|
||||||
},
|
},
|
||||||
async execute() {
|
async execute(args, context) {
|
||||||
// SCADA 面板仍在浏览器侧执行,工具结果不承载实际监测数据。
|
return executeFrontendAction("view_scada", args, context);
|
||||||
return "已打开 SCADA 监测面板。";
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { tool } from "@opencode-ai/plugin";
|
import { tool } from "@opencode-ai/plugin";
|
||||||
|
import { executeFrontendAction } from "./frontend_action.js";
|
||||||
|
|
||||||
export default tool({
|
export default tool({
|
||||||
description:
|
description:
|
||||||
@@ -26,7 +27,7 @@ export default tool({
|
|||||||
.optional()
|
.optional()
|
||||||
.describe("Optional animation duration in milliseconds. Defaults to 1000."),
|
.describe("Optional animation duration in milliseconds. Defaults to 1000."),
|
||||||
},
|
},
|
||||||
async execute() {
|
async execute(args, context) {
|
||||||
return "已缩放到指定地图坐标。";
|
return executeFrontendAction("zoom_to_map", args, context);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { logger } from "../logger.js";
|
||||||
|
import { getFrontendActionDefinition } from "./registry.js";
|
||||||
|
import type { FrontendActionRequest, FrontendActionResult } from "./types.js";
|
||||||
|
|
||||||
|
export const frontendActionResultSchema = z.object({
|
||||||
|
version: z.literal("frontend-action-result@1"),
|
||||||
|
actionId: z.string().trim().min(1).max(128),
|
||||||
|
status: z.enum(["succeeded", "failed", "rejected", "cancelled", "expired"]),
|
||||||
|
output: z.unknown().optional(),
|
||||||
|
error: z.object({
|
||||||
|
code: z.string().trim().min(1).max(128),
|
||||||
|
message: z.string().trim().min(1).max(2_000),
|
||||||
|
}).strict().optional(),
|
||||||
|
completedAt: z.number().finite().positive(),
|
||||||
|
}).strict().superRefine((value, context) => {
|
||||||
|
if (value.status !== "succeeded" && !value.error) {
|
||||||
|
context.addIssue({ code: z.ZodIssueCode.custom, message: "non-success results require an error" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
type PendingAction = {
|
||||||
|
request: FrontendActionRequest;
|
||||||
|
promise: Promise<FrontendActionResult>;
|
||||||
|
resolve: (result: FrontendActionResult) => void;
|
||||||
|
timer: ReturnType<typeof setTimeout>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SubmitResultOutcome = { duplicate: boolean; result: FrontendActionResult };
|
||||||
|
export type FrontendActionSink = (request: FrontendActionRequest) => void;
|
||||||
|
|
||||||
|
export class FrontendActionError extends Error {
|
||||||
|
constructor(readonly status: number, message: string) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FrontendActionCoordinator {
|
||||||
|
private readonly pending = new Map<string, PendingAction>();
|
||||||
|
private readonly completed = new Map<string, { sessionId: string; result: FrontendActionResult }>();
|
||||||
|
private readonly sinks = new Map<string, FrontendActionSink>();
|
||||||
|
|
||||||
|
constructor(private readonly completedLimit = 512) {}
|
||||||
|
|
||||||
|
registerSink(sessionId: string, sink: FrontendActionSink) {
|
||||||
|
this.sinks.set(sessionId, sink);
|
||||||
|
const now = Date.now();
|
||||||
|
for (const pending of this.pending.values()) {
|
||||||
|
if (pending.request.sessionId === sessionId && pending.request.expiresAt > now) sink(pending.request);
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
if (this.sinks.get(sessionId) === sink) this.sinks.delete(sessionId);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
request(options: {
|
||||||
|
sessionId: string;
|
||||||
|
toolCallId: string;
|
||||||
|
name: string;
|
||||||
|
params: unknown;
|
||||||
|
fallbackText?: string;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
}): Promise<FrontendActionResult> {
|
||||||
|
if (!options.toolCallId.trim()) throw new FrontendActionError(400, "frontend action call ID is required");
|
||||||
|
const definition = getFrontendActionDefinition(options.name);
|
||||||
|
if (!definition) throw new FrontendActionError(400, "unknown frontend action");
|
||||||
|
const parsedParams = definition.inputSchema.safeParse(options.params);
|
||||||
|
if (!parsedParams.success) throw new FrontendActionError(400, "invalid frontend action params");
|
||||||
|
const sink = this.sinks.get(options.sessionId);
|
||||||
|
if (!sink) throw new FrontendActionError(409, "frontend action session has no active browser sink");
|
||||||
|
|
||||||
|
const actionId = createHash("sha256")
|
||||||
|
.update(`${options.sessionId}\0${options.toolCallId}\0${options.name}`)
|
||||||
|
.digest("hex");
|
||||||
|
const completed = this.completed.get(actionId);
|
||||||
|
if (completed) return Promise.resolve(completed.result);
|
||||||
|
const existing = this.pending.get(actionId);
|
||||||
|
if (existing) return existing.promise;
|
||||||
|
|
||||||
|
const issuedAt = Date.now();
|
||||||
|
const request: FrontendActionRequest = {
|
||||||
|
version: "frontend-action@1",
|
||||||
|
actionId,
|
||||||
|
toolCallId: options.toolCallId,
|
||||||
|
sessionId: options.sessionId,
|
||||||
|
name: definition.manifest.id,
|
||||||
|
params: parsedParams.data,
|
||||||
|
risk: definition.manifest.risk,
|
||||||
|
replayPolicy: definition.manifest.risk === "view" ? "never" : "explicit",
|
||||||
|
issuedAt,
|
||||||
|
expiresAt: issuedAt + definition.manifest.timeoutMs,
|
||||||
|
fallbackText: options.fallbackText?.trim() || undefined,
|
||||||
|
};
|
||||||
|
let resolvePending!: (result: FrontendActionResult) => void;
|
||||||
|
const promise = new Promise<FrontendActionResult>((resolve) => { resolvePending = resolve; });
|
||||||
|
const timer = setTimeout(() => this.finish(request, {
|
||||||
|
version: "frontend-action-result@1",
|
||||||
|
actionId,
|
||||||
|
status: "expired",
|
||||||
|
error: { code: "ACTION_TIMEOUT", message: "browser action result was not received before expiry" },
|
||||||
|
completedAt: Date.now(),
|
||||||
|
}), definition.manifest.timeoutMs);
|
||||||
|
this.pending.set(actionId, { request, promise, resolve: resolvePending, timer });
|
||||||
|
options.signal?.addEventListener("abort", () => this.cancelAction(actionId), { once: true });
|
||||||
|
this.audit("proposed", request);
|
||||||
|
this.audit("validated", request);
|
||||||
|
sink(request);
|
||||||
|
this.audit("executing", request);
|
||||||
|
return promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
submitResult(sessionId: string, value: unknown): SubmitResultOutcome {
|
||||||
|
const parsed = frontendActionResultSchema.safeParse(value);
|
||||||
|
if (!parsed.success) throw new FrontendActionError(400, "invalid frontend action result");
|
||||||
|
const result = parsed.data as FrontendActionResult;
|
||||||
|
const completed = this.completed.get(result.actionId);
|
||||||
|
if (completed) {
|
||||||
|
if (sessionId !== completed.sessionId) throw new FrontendActionError(403, "frontend action session mismatch");
|
||||||
|
if (JSON.stringify(completed.result) !== JSON.stringify(result)) throw new FrontendActionError(409, "frontend action result conflicts with terminal result");
|
||||||
|
return { duplicate: true, result: completed.result };
|
||||||
|
}
|
||||||
|
const pending = this.pending.get(result.actionId);
|
||||||
|
if (!pending) throw new FrontendActionError(404, "frontend action not found");
|
||||||
|
if (pending.request.sessionId !== sessionId) throw new FrontendActionError(403, "frontend action session mismatch");
|
||||||
|
const definition = getFrontendActionDefinition(pending.request.name);
|
||||||
|
if (result.status === "succeeded" && definition && !definition.outputSchema.safeParse(result.output).success) {
|
||||||
|
throw new FrontendActionError(400, "invalid frontend action output");
|
||||||
|
}
|
||||||
|
this.finish(pending.request, result);
|
||||||
|
return { duplicate: false, result };
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelSession(sessionId: string) {
|
||||||
|
this.sinks.delete(sessionId);
|
||||||
|
for (const [actionId, pending] of this.pending) {
|
||||||
|
if (pending.request.sessionId === sessionId) this.cancelAction(actionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private cancelAction(actionId: string) {
|
||||||
|
const pending = this.pending.get(actionId);
|
||||||
|
if (!pending) return;
|
||||||
|
this.finish(pending.request, {
|
||||||
|
version: "frontend-action-result@1", actionId, status: "cancelled",
|
||||||
|
error: { code: "SESSION_CANCELLED", message: "session ended before the browser action completed" },
|
||||||
|
completedAt: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private finish(request: FrontendActionRequest, result: FrontendActionResult) {
|
||||||
|
const pending = this.pending.get(request.actionId);
|
||||||
|
if (!pending) return;
|
||||||
|
clearTimeout(pending.timer);
|
||||||
|
this.pending.delete(request.actionId);
|
||||||
|
this.completed.set(request.actionId, { sessionId: request.sessionId, result });
|
||||||
|
while (this.completed.size > this.completedLimit) {
|
||||||
|
const oldest = this.completed.keys().next().value as string | undefined;
|
||||||
|
if (!oldest) break;
|
||||||
|
this.completed.delete(oldest);
|
||||||
|
}
|
||||||
|
this.audit(result.status, request, result.error?.code);
|
||||||
|
pending.resolve(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
private audit(stage: string, request: FrontendActionRequest, errorCode?: string) {
|
||||||
|
logger.info({ actionId: request.actionId, action: request.name, stage, sessionId: request.sessionId,
|
||||||
|
toolCallId: request.toolCallId, paramKeys: typeof request.params === "object" && request.params ? Object.keys(request.params) : [], errorCode },
|
||||||
|
"frontend action lifecycle");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import type { FrontendActionManifest } from "./types.js";
|
||||||
|
|
||||||
|
type Definition = { manifest: FrontendActionManifest; inputSchema: z.ZodTypeAny; outputSchema: z.ZodTypeAny };
|
||||||
|
const zoomInput = z.object({ x: z.number().finite(), y: z.number().finite(), source_crs: z.enum(["EPSG:3857", "EPSG:4326"]).optional(), zoom: z.number().finite().optional(), duration_ms: z.number().finite().nonnegative().optional() }).strict();
|
||||||
|
const id = z.string().trim().min(1).max(256);
|
||||||
|
const isoTime = z.string().datetime({ offset: true });
|
||||||
|
const timeRange = {
|
||||||
|
start_time: isoTime.optional(),
|
||||||
|
end_time: isoTime.optional(),
|
||||||
|
};
|
||||||
|
const locateInput = z.object({
|
||||||
|
ids: z.array(id).min(1).max(100),
|
||||||
|
feature_type: z.enum(["junction", "pipe", "valve", "reservoir", "pump", "tank", "conduit", "orifice", "outfall"]),
|
||||||
|
}).strict();
|
||||||
|
const historyInput = z.object({
|
||||||
|
feature_infos: z.array(z.tuple([id, id])).min(1).max(100),
|
||||||
|
data_type: z.enum(["realtime", "scheme", "none"]),
|
||||||
|
...timeRange,
|
||||||
|
}).strict().refine((value) => !value.start_time || !value.end_time || Date.parse(value.start_time) <= Date.parse(value.end_time), "start_time must not be after end_time");
|
||||||
|
const scadaInput = z.object({
|
||||||
|
device_id: id.optional(),
|
||||||
|
device_ids: z.array(id).min(1).max(100).optional(),
|
||||||
|
...timeRange,
|
||||||
|
}).strict().refine((value) => Boolean(value.device_id) !== Boolean(value.device_ids), "provide exactly one of device_id or device_ids")
|
||||||
|
.refine((value) => !value.start_time || !value.end_time || Date.parse(value.start_time) <= Date.parse(value.end_time), "start_time must not be after end_time");
|
||||||
|
const definitions: Definition[] = [
|
||||||
|
{ manifest: { id: "zoom_to_map", version: "frontend-action@1", risk: "view", timeoutMs: 10_000, supportedSurfaces: ["map_overlay"] }, inputSchema: zoomInput, outputSchema: z.object({}).passthrough() },
|
||||||
|
{ manifest: { id: "locate_features", version: "frontend-action@1", risk: "view", timeoutMs: 15_000, supportedSurfaces: ["map_overlay"] }, inputSchema: locateInput, outputSchema: z.object({ locatedIds: z.array(id), missingIds: z.array(id), featureType: id, bounds: z.tuple([z.number(), z.number(), z.number(), z.number()]) }).strict() },
|
||||||
|
{ manifest: { id: "view_history", version: "frontend-action@1", risk: "view", timeoutMs: 10_000, supportedSurfaces: ["side_panel", "canvas"] }, inputSchema: historyInput, outputSchema: z.object({ component: z.literal("HistoryPanel"), rendered: z.literal(true), itemCount: z.number().int().nonnegative() }).strict() },
|
||||||
|
{ manifest: { id: "view_scada", version: "frontend-action@1", risk: "view", timeoutMs: 10_000, supportedSurfaces: ["side_panel", "canvas"] }, inputSchema: scadaInput, outputSchema: z.object({ component: z.literal("ScadaPanel"), rendered: z.literal(true), itemCount: z.number().int().nonnegative() }).strict() },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const frontendActionRegistry = definitions.map((item) => item.manifest);
|
||||||
|
export const getFrontendActionDefinition = (name: string) => definitions.find((item) => item.manifest.id === name);
|
||||||
|
export const frontendActionRegistryResponse = { schema_version: "frontend-action-registry@1" as const, actions: frontendActionRegistry };
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
export const FRONTEND_ACTION_VERSION = "frontend-action@1" as const;
|
||||||
|
export const FRONTEND_ACTION_RESULT_VERSION = "frontend-action-result@1" as const;
|
||||||
|
|
||||||
|
export type FrontendActionRisk = "view" | "reversible" | "side_effect";
|
||||||
|
export type FrontendActionStatus = "succeeded" | "failed" | "rejected" | "cancelled" | "expired";
|
||||||
|
|
||||||
|
export type FrontendActionRequest = {
|
||||||
|
version: typeof FRONTEND_ACTION_VERSION;
|
||||||
|
actionId: string;
|
||||||
|
toolCallId: string;
|
||||||
|
sessionId: string;
|
||||||
|
name: string;
|
||||||
|
params: unknown;
|
||||||
|
risk: FrontendActionRisk;
|
||||||
|
replayPolicy: "never" | "explicit";
|
||||||
|
issuedAt: number;
|
||||||
|
expiresAt: number;
|
||||||
|
fallbackText?: string;
|
||||||
|
replayOf?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FrontendActionResult = {
|
||||||
|
version: typeof FRONTEND_ACTION_RESULT_VERSION;
|
||||||
|
actionId: string;
|
||||||
|
status: FrontendActionStatus;
|
||||||
|
output?: unknown;
|
||||||
|
error?: { code: string; message: string };
|
||||||
|
completedAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FrontendActionManifest = {
|
||||||
|
id: string;
|
||||||
|
version: typeof FRONTEND_ACTION_VERSION;
|
||||||
|
risk: FrontendActionRisk;
|
||||||
|
timeoutMs: number;
|
||||||
|
supportedSurfaces: ["map_overlay"] | ["side_panel", "canvas"];
|
||||||
|
};
|
||||||
@@ -23,9 +23,12 @@ import { type ResultReferenceResolver } from "../results/resolver.js";
|
|||||||
import {
|
import {
|
||||||
type OpencodeRuntimeAdapter,
|
type OpencodeRuntimeAdapter,
|
||||||
} from "../runtime/opencode.js";
|
} from "../runtime/opencode.js";
|
||||||
|
import { getRuntimeSessionContext, setRuntimeSessionContext } from "../runtime/sessionContext.js";
|
||||||
import { type ChatSessionBridge } from "../chat/sessionBridge.js";
|
import { type ChatSessionBridge } from "../chat/sessionBridge.js";
|
||||||
import { type SessionRecord } from "../sessions/metadataStore.js";
|
import { type SessionRecord } from "../sessions/metadataStore.js";
|
||||||
import { uiRegistryResponse } from "../uiEnvelope/registry.js";
|
import { uiRegistryResponse } from "../uiEnvelope/registry.js";
|
||||||
|
import { FrontendActionCoordinator } from "../frontendAction/coordinator.js";
|
||||||
|
import { frontendActionRegistryResponse } from "../frontendAction/registry.js";
|
||||||
import type { UIEnvelopePayload } from "../uiEnvelope/types.js";
|
import type { UIEnvelopePayload } from "../uiEnvelope/types.js";
|
||||||
import { toActorKey, toProjectKey } from "../utils/fileStore.js";
|
import { toActorKey, toProjectKey } from "../utils/fileStore.js";
|
||||||
import {
|
import {
|
||||||
@@ -80,6 +83,7 @@ const payloadSchema = z.object({
|
|||||||
message: "unsupported model",
|
message: "unsupported model",
|
||||||
}).optional(),
|
}).optional(),
|
||||||
approval_mode: z.enum(["request", "always"]).optional().default("request"),
|
approval_mode: z.enum(["request", "always"]).optional().default("request"),
|
||||||
|
capabilities: z.array(z.string().max(64)).max(20).optional().default([]),
|
||||||
});
|
});
|
||||||
|
|
||||||
const createSessionPayloadSchema = z.object({
|
const createSessionPayloadSchema = z.object({
|
||||||
@@ -189,6 +193,7 @@ export const buildChatRouter = (
|
|||||||
sessionTranscriptStore: SessionTranscriptStore,
|
sessionTranscriptStore: SessionTranscriptStore,
|
||||||
learningOrchestrator: LearningOrchestrator,
|
learningOrchestrator: LearningOrchestrator,
|
||||||
resultReferenceResolver: ResultReferenceResolver,
|
resultReferenceResolver: ResultReferenceResolver,
|
||||||
|
frontendActionCoordinator: FrontendActionCoordinator,
|
||||||
) => {
|
) => {
|
||||||
const chatRouter = Router();
|
const chatRouter = Router();
|
||||||
|
|
||||||
@@ -203,6 +208,26 @@ export const buildChatRouter = (
|
|||||||
res.json(uiRegistryResponse);
|
res.json(uiRegistryResponse);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
chatRouter.get("/frontend-action-registry", (_req, res) => {
|
||||||
|
res.json(frontendActionRegistryResponse);
|
||||||
|
});
|
||||||
|
|
||||||
|
chatRouter.post("/frontend-actions/:action_id/result", (req, res) => {
|
||||||
|
const sessionId = req.header("x-agent-session-id")?.trim() ?? "";
|
||||||
|
if (!sessionId || req.params.action_id !== req.body?.actionId) {
|
||||||
|
res.status(400).json({ message: "invalid frontend action result" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const outcome = frontendActionCoordinator.submitResult(sessionId, req.body);
|
||||||
|
res.json({ accepted: true, duplicate: outcome.duplicate, result: outcome.result });
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "frontend action result rejected";
|
||||||
|
const status = typeof error === "object" && error && "status" in error && typeof error.status === "number" ? error.status : 400;
|
||||||
|
res.status(status).json({ message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
chatRouter.post("/session", async (req, res) => {
|
chatRouter.post("/session", async (req, res) => {
|
||||||
const parsed = createSessionPayloadSchema.safeParse(req.body ?? {});
|
const parsed = createSessionPayloadSchema.safeParse(req.body ?? {});
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
@@ -456,6 +481,7 @@ export const buildChatRouter = (
|
|||||||
clientSessionId: sessionRecord.sessionId,
|
clientSessionId: sessionRecord.sessionId,
|
||||||
sessionId: sessionRecord.sessionId,
|
sessionId: sessionRecord.sessionId,
|
||||||
});
|
});
|
||||||
|
frontendActionCoordinator.cancelSession(sessionRecord.sessionId);
|
||||||
activeRuns.delete(sessionRecord.sessionId);
|
activeRuns.delete(sessionRecord.sessionId);
|
||||||
lastRunStatuses.delete(sessionRecord.sessionId);
|
lastRunStatuses.delete(sessionRecord.sessionId);
|
||||||
await sessionMetadataStore.remove(sessionRecord);
|
await sessionMetadataStore.remove(sessionRecord);
|
||||||
@@ -469,6 +495,7 @@ export const buildChatRouter = (
|
|||||||
sessionBridge,
|
sessionBridge,
|
||||||
sessionMetadataStore,
|
sessionMetadataStore,
|
||||||
sessionUiStateStore,
|
sessionUiStateStore,
|
||||||
|
frontendActionCoordinator,
|
||||||
});
|
});
|
||||||
|
|
||||||
registerChatInteractionRoutes(chatRouter, {
|
registerChatInteractionRoutes(chatRouter, {
|
||||||
@@ -624,6 +651,10 @@ export const buildChatRouter = (
|
|||||||
traceId,
|
traceId,
|
||||||
userId,
|
userId,
|
||||||
});
|
});
|
||||||
|
setRuntimeSessionContext({
|
||||||
|
...(getRuntimeSessionContext(binding.sessionId)!),
|
||||||
|
capabilities: parsed.data.capabilities,
|
||||||
|
});
|
||||||
const { record: ensuredSessionRecord, created: sessionCreated } =
|
const { record: ensuredSessionRecord, created: sessionCreated } =
|
||||||
await sessionMetadataStore.ensure({
|
await sessionMetadataStore.ensure({
|
||||||
actorKey,
|
actorKey,
|
||||||
@@ -1023,6 +1054,10 @@ export const buildChatRouter = (
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const unregisterFrontendActionSink = parsed.data.capabilities.includes("frontend-action@1")
|
||||||
|
? frontendActionCoordinator.registerSink(clientSessionId, (request) => publish("frontend_action", request as unknown as Record<string, unknown>))
|
||||||
|
: undefined;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (isPressureTrendDemoPrompt(promptText)) {
|
if (isPressureTrendDemoPrompt(promptText)) {
|
||||||
const toolCall = createPressureTrendDemoToolCall();
|
const toolCall = createPressureTrendDemoToolCall();
|
||||||
@@ -1078,6 +1113,7 @@ export const buildChatRouter = (
|
|||||||
traceId: requestContext.traceId,
|
traceId: requestContext.traceId,
|
||||||
projectId: requestContext.projectId,
|
projectId: requestContext.projectId,
|
||||||
signal: abortController.signal,
|
signal: abortController.signal,
|
||||||
|
suppressLegacyFrontendActions: parsed.data.capabilities.includes("frontend-action@1"),
|
||||||
write: (event, data) => {
|
write: (event, data) => {
|
||||||
publish(event, data);
|
publish(event, data);
|
||||||
},
|
},
|
||||||
@@ -1148,6 +1184,7 @@ export const buildChatRouter = (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
unregisterFrontendActionSink?.();
|
||||||
if (abortController.signal.aborted) {
|
if (abortController.signal.aborted) {
|
||||||
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
|
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
|
||||||
...message,
|
...message,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { type ResultReferenceResolver } from "../results/resolver.js";
|
|||||||
import { RESULT_REFERENCE_KIND } from "../results/store.js";
|
import { RESULT_REFERENCE_KIND } from "../results/store.js";
|
||||||
import { type SessionMetadataStore } from "../sessions/metadataStore.js";
|
import { type SessionMetadataStore } from "../sessions/metadataStore.js";
|
||||||
import { type SessionUiStateStore } from "../sessions/uiStateStore.js";
|
import { type SessionUiStateStore } from "../sessions/uiStateStore.js";
|
||||||
|
import { type FrontendActionCoordinator } from "../frontendAction/coordinator.js";
|
||||||
import { toActorKey, toProjectKey } from "../utils/fileStore.js";
|
import { toActorKey, toProjectKey } from "../utils/fileStore.js";
|
||||||
import {
|
import {
|
||||||
type ActiveRun,
|
type ActiveRun,
|
||||||
@@ -28,6 +29,7 @@ type RegisterAuxiliaryRoutesOptions = {
|
|||||||
sessionBridge: ChatSessionBridge;
|
sessionBridge: ChatSessionBridge;
|
||||||
sessionMetadataStore: SessionMetadataStore;
|
sessionMetadataStore: SessionMetadataStore;
|
||||||
sessionUiStateStore: SessionUiStateStore;
|
sessionUiStateStore: SessionUiStateStore;
|
||||||
|
frontendActionCoordinator: FrontendActionCoordinator;
|
||||||
};
|
};
|
||||||
|
|
||||||
const toSessionUiStateContext = (sessionId: string) => ({
|
const toSessionUiStateContext = (sessionId: string) => ({
|
||||||
@@ -43,6 +45,7 @@ export const registerChatAuxiliaryRoutes = (
|
|||||||
sessionBridge,
|
sessionBridge,
|
||||||
sessionMetadataStore,
|
sessionMetadataStore,
|
||||||
sessionUiStateStore,
|
sessionUiStateStore,
|
||||||
|
frontendActionCoordinator,
|
||||||
}: RegisterAuxiliaryRoutesOptions,
|
}: RegisterAuxiliaryRoutesOptions,
|
||||||
) => {
|
) => {
|
||||||
chatRouter.get("/render-ref/:render_ref", async (req, res) => {
|
chatRouter.get("/render-ref/:render_ref", async (req, res) => {
|
||||||
@@ -93,6 +96,7 @@ export const registerChatAuxiliaryRoutes = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
frontendActionCoordinator.cancelSession(parsed.data.session_id);
|
||||||
const authContext = getLocalAgentContext(req);
|
const authContext = getLocalAgentContext(req);
|
||||||
const projectId = authContext.projectId;
|
const projectId = authContext.projectId;
|
||||||
const userId = authContext.userId;
|
const userId = authContext.userId;
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ type StreamPromptOptions = {
|
|||||||
traceId?: string;
|
traceId?: string;
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
|
suppressLegacyFrontendActions?: boolean;
|
||||||
write: (event: string, data: Record<string, unknown>) => void;
|
write: (event: string, data: Record<string, unknown>) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -141,6 +142,7 @@ export const streamPromptResponse = async ({
|
|||||||
traceId,
|
traceId,
|
||||||
projectId,
|
projectId,
|
||||||
signal,
|
signal,
|
||||||
|
suppressLegacyFrontendActions = false,
|
||||||
write,
|
write,
|
||||||
}: StreamPromptOptions): Promise<{
|
}: StreamPromptOptions): Promise<{
|
||||||
aborted: boolean;
|
aborted: boolean;
|
||||||
@@ -828,7 +830,7 @@ export const streamPromptResponse = async ({
|
|||||||
params: toolParams,
|
params: toolParams,
|
||||||
reason,
|
reason,
|
||||||
});
|
});
|
||||||
if (envelope) {
|
if (envelope && !(suppressLegacyFrontendActions && ["zoom_to_map", "locate_features", "view_history", "view_scada"].includes(part.tool))) {
|
||||||
write("ui_envelope", {
|
write("ui_envelope", {
|
||||||
session_id: clientSessionId,
|
session_id: clientSessionId,
|
||||||
envelope_id: createEnvelopeId(part.tool),
|
envelope_id: createEnvelopeId(part.tool),
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export type RuntimeSessionContext = {
|
|||||||
projectKey: string;
|
projectKey: string;
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
traceId: string;
|
traceId: string;
|
||||||
|
capabilities?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const contexts = new Map<string, RuntimeSessionContext>();
|
const contexts = new Map<string, RuntimeSessionContext>();
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
ResultReferenceStore,
|
ResultReferenceStore,
|
||||||
} from "./results/store.js";
|
} from "./results/store.js";
|
||||||
import { buildChatRouter } from "./routes/chat.js";
|
import { buildChatRouter } from "./routes/chat.js";
|
||||||
|
import { FrontendActionCoordinator, FrontendActionError } from "./frontendAction/coordinator.js";
|
||||||
import { opencodeRuntime } from "./runtime/opencode.js";
|
import { opencodeRuntime } from "./runtime/opencode.js";
|
||||||
import {
|
import {
|
||||||
getRuntimeSessionContext,
|
getRuntimeSessionContext,
|
||||||
@@ -38,6 +39,7 @@ const learningOrchestrator = new LearningOrchestrator(
|
|||||||
const resultReferenceStore = new ResultReferenceStore();
|
const resultReferenceStore = new ResultReferenceStore();
|
||||||
const resultReferenceResolver = new ResultReferenceResolver(resultReferenceStore);
|
const resultReferenceResolver = new ResultReferenceResolver(resultReferenceStore);
|
||||||
const internalToken = config.AGENT_INTERNAL_TOKEN ?? randomUUID();
|
const internalToken = config.AGENT_INTERNAL_TOKEN ?? randomUUID();
|
||||||
|
const frontendActionCoordinator = new FrontendActionCoordinator();
|
||||||
|
|
||||||
// 这个 token 只用于仍需服务端上下文的工具桥(store_render_ref)。
|
// 这个 token 只用于仍需服务端上下文的工具桥(store_render_ref)。
|
||||||
process.env.TJWATER_AGENT_INTERNAL_TOKEN = internalToken;
|
process.env.TJWATER_AGENT_INTERNAL_TOKEN = internalToken;
|
||||||
@@ -45,6 +47,42 @@ process.env.TJWATER_AGENT_INTERNAL_TOKEN = internalToken;
|
|||||||
app.use(cors());
|
app.use(cors());
|
||||||
app.use(express.json({ limit: "1mb" }));
|
app.use(express.json({ limit: "1mb" }));
|
||||||
|
|
||||||
|
app.post("/internal/frontend-actions/request", async (req, res) => {
|
||||||
|
if (req.header("x-agent-internal-token") !== internalToken) {
|
||||||
|
res.status(403).json({ message: "forbidden" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const runtimeSessionId = typeof req.body?.session_id === "string" ? req.body.session_id.trim() : "";
|
||||||
|
const context = runtimeSessionId ? getRuntimeSessionContext(runtimeSessionId) : null;
|
||||||
|
if (!context) {
|
||||||
|
res.status(404).json({ message: "session context not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!context.capabilities?.includes("frontend-action@1")) {
|
||||||
|
// Legacy clients still execute the existing UIEnvelope emitted from the tool call.
|
||||||
|
res.json({
|
||||||
|
version: "frontend-action-result@1",
|
||||||
|
status: "succeeded",
|
||||||
|
output: { legacy: true },
|
||||||
|
detail: "legacy client executes the action from its UIEnvelope",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await frontendActionCoordinator.request({
|
||||||
|
sessionId: context.clientSessionId,
|
||||||
|
toolCallId: typeof req.body?.call_id === "string" ? req.body.call_id : "",
|
||||||
|
name: typeof req.body?.name === "string" ? req.body.name : "",
|
||||||
|
params: req.body?.params,
|
||||||
|
fallbackText: typeof req.body?.fallback_text === "string" ? req.body.fallback_text : undefined,
|
||||||
|
});
|
||||||
|
res.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
const status = error instanceof FrontendActionError ? error.status : 500;
|
||||||
|
res.status(status).json({ message: error instanceof Error ? error.message : String(error) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.get("/health", async (_req, res) => {
|
app.get("/health", async (_req, res) => {
|
||||||
try {
|
try {
|
||||||
const runtime = await opencodeRuntime.health();
|
const runtime = await opencodeRuntime.health();
|
||||||
@@ -308,6 +346,7 @@ app.use(
|
|||||||
sessionTranscriptStore,
|
sessionTranscriptStore,
|
||||||
learningOrchestrator,
|
learningOrchestrator,
|
||||||
resultReferenceResolver,
|
resultReferenceResolver,
|
||||||
|
frontendActionCoordinator,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { describe, expect, it } from "bun:test";
|
||||||
|
|
||||||
|
import { frontendActionCallIdPlugin } from "../../.opencode/plugins/frontend-action-call-id.js";
|
||||||
|
|
||||||
|
describe("frontend action call ID plugin", () => {
|
||||||
|
it("overwrites forged IDs only for allowlisted tools", async () => {
|
||||||
|
const hooks = await frontendActionCallIdPlugin({} as never);
|
||||||
|
const before = hooks["tool.execute.before"];
|
||||||
|
if (!before) throw new Error("hook missing");
|
||||||
|
const actionOutput = { args: { x: 1, __frontend_action_call_id: "forged" } };
|
||||||
|
await before({ tool: "zoom_to_map", sessionID: "session", callID: "real" }, actionOutput);
|
||||||
|
expect(actionOutput.args.__frontend_action_call_id).toBe("real");
|
||||||
|
|
||||||
|
const ordinaryOutput = { args: { query: "water" } };
|
||||||
|
await before({ tool: "web_search", sessionID: "session", callID: "ignored" }, ordinaryOutput);
|
||||||
|
expect(ordinaryOutput.args).toEqual({ query: "water" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { describe, expect, it } from "bun:test";
|
||||||
|
|
||||||
|
import { FrontendActionCoordinator, FrontendActionError } from "../../src/frontendAction/coordinator.js";
|
||||||
|
import type { FrontendActionRequest } from "../../src/frontendAction/types.js";
|
||||||
|
|
||||||
|
const start = (coordinator: FrontendActionCoordinator, sessionId = "session-1") => {
|
||||||
|
let emitted: FrontendActionRequest | undefined;
|
||||||
|
coordinator.registerSink(sessionId, (request) => { emitted = request; });
|
||||||
|
const pending = coordinator.request({ sessionId, toolCallId: "call-1", name: "zoom_to_map", params: { x: 117.2, y: 39.1 } });
|
||||||
|
if (!emitted) throw new Error("request was not emitted");
|
||||||
|
return { emitted, pending };
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("FrontendActionCoordinator", () => {
|
||||||
|
it("emits once and resolves with a validated browser result", async () => {
|
||||||
|
const coordinator = new FrontendActionCoordinator();
|
||||||
|
const { emitted, pending } = start(coordinator);
|
||||||
|
const result = { version: "frontend-action-result@1" as const, actionId: emitted.actionId, status: "succeeded" as const, output: { zoom: 14 }, completedAt: Date.now() };
|
||||||
|
expect(coordinator.submitResult("session-1", result).duplicate).toBe(false);
|
||||||
|
expect((await pending).output).toEqual({ zoom: 14 });
|
||||||
|
expect(coordinator.submitResult("session-1", result).duplicate).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects conflicting terminal results and cross-session submissions", async () => {
|
||||||
|
const coordinator = new FrontendActionCoordinator();
|
||||||
|
const { emitted, pending } = start(coordinator);
|
||||||
|
const result = { version: "frontend-action-result@1" as const, actionId: emitted.actionId, status: "succeeded" as const, output: {}, completedAt: Date.now() };
|
||||||
|
expect(() => coordinator.submitResult("other", result)).toThrow("session mismatch");
|
||||||
|
coordinator.submitResult("session-1", result);
|
||||||
|
await pending;
|
||||||
|
expect(() => coordinator.submitResult("session-1", { ...result, completedAt: result.completedAt + 1 })).toThrow("conflicts");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cancels all pending actions for a session", async () => {
|
||||||
|
const coordinator = new FrontendActionCoordinator();
|
||||||
|
const { pending } = start(coordinator);
|
||||||
|
coordinator.cancelSession("session-1");
|
||||||
|
expect((await pending).status).toBe("cancelled");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("validates params and requires an active browser sink", () => {
|
||||||
|
const coordinator = new FrontendActionCoordinator();
|
||||||
|
expect(() => coordinator.request({ sessionId: "s", toolCallId: "c", name: "zoom_to_map", params: { x: "bad", y: 1 } })).toThrow(FrontendActionError);
|
||||||
|
coordinator.registerSink("s", () => undefined);
|
||||||
|
expect(() => coordinator.request({ sessionId: "s", toolCallId: "c", name: "unknown", params: {} })).toThrow("unknown frontend action");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user