feat(agent): add frontend action bridge
This commit is contained in:
@@ -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"];
|
||||
};
|
||||
Reference in New Issue
Block a user