import type { AgentTool, AgentToolResult, AgentToolUpdateCallback } from "@f5-sales-demo/pi-agent-core"; import { Snowflake } from "@f5-sales-demo/pi-utils"; import type { Static, TSchema } from "@sinclair/typebox"; import { applyToolProxy } from "../extensibility/tool-proxy"; import type { Theme } from "../modes/theme/theme"; import type { RpcHostToolCallRequest, RpcHostToolCancelRequest, RpcHostToolDefinition, RpcHostToolResult, RpcHostToolUpdate, } from "./types"; type RpcHostToolOutput = (frame: RpcHostToolCallRequest | RpcHostToolCancelRequest) => void; type PendingHostToolCall = { resolve: (result: AgentToolResult) => void; reject: (error: Error) => void; onUpdate?: AgentToolUpdateCallback; }; /** * Validate + normalize incoming host-tool definitions (trim names/labels/descriptions, * enforce non-empty name/description and a JSON-Schema `parameters` object). Shared by * every transport driver that accepts a `set_host_tools` frame. */ export function normalizeHostToolDefinitions(tools: RpcHostToolDefinition[]): RpcHostToolDefinition[] { return tools.map((tool, index) => { const name = typeof tool.name === "string" ? tool.name.trim() : ""; if (!name) { throw new Error(`Host tool at index ${index} must provide a non-empty name`); } const description = typeof tool.description === "string" ? tool.description.trim() : ""; if (!description) { throw new Error(`Host tool "${name}" must provide a non-empty description`); } if (!tool.parameters || typeof tool.parameters !== "object" || Array.isArray(tool.parameters)) { throw new Error(`Host tool "${name}" must provide a JSON Schema object`); } const label = typeof tool.label === "string" && tool.label.trim() ? tool.label.trim() : name; return { name, label, description, parameters: tool.parameters, hidden: tool.hidden === true, }; }); } class RpcHostToolAdapter implements AgentTool { declare name: string; declare label: string; declare description: string; declare parameters: TParams; readonly strict = true; concurrency: "shared" | "exclusive" = "shared"; #bridge: RpcHostToolBridge; #definition: RpcHostToolDefinition; constructor(definition: RpcHostToolDefinition, bridge: RpcHostToolBridge) { this.#definition = definition; this.#bridge = bridge; applyToolProxy(definition, this); } execute( toolCallId: string, params: Static, signal?: AbortSignal, onUpdate?: AgentToolUpdateCallback, ): Promise> { return this.#bridge.requestExecution( this.#definition, toolCallId, params as Record, signal, onUpdate, ); } } /** * How long a host tool may go with NO response — neither a `host_tool_result` nor a * `host_tool_update` — before we give up on it. An unanswered call (e.g. an Office * task-pane whose WebView the host suspended when it lost focus) would otherwise hang * the agent turn forever, and since turns are queued behind the active one, every * later turn wedges too. This is IDLE time, reset by each streamed update, so a * legitimately slow-but-progressing tool is never cut off — only true silence trips it. */ export const HOST_TOOL_IDLE_TIMEOUT_MS = 60_000; export class RpcHostToolBridge { #output: RpcHostToolOutput; #definitions = new Map(); #pendingCalls = new Map(); #idleTimeoutMs: number; constructor(output: RpcHostToolOutput, opts?: { idleTimeoutMs?: number }) { this.#output = output; this.#idleTimeoutMs = opts?.idleTimeoutMs ?? HOST_TOOL_IDLE_TIMEOUT_MS; } getToolNames(): string[] { return Array.from(this.#definitions.keys()); } setTools(tools: RpcHostToolDefinition[]): AgentTool[] { this.#definitions = new Map(tools.map(tool => [tool.name, tool])); return tools.map(tool => new RpcHostToolAdapter(tool, this)); } handleResult(frame: RpcHostToolResult): boolean { const pending = this.#pendingCalls.get(frame.id); if (!pending) return false; this.#pendingCalls.delete(frame.id); if (frame.isError) { const text = frame.result.content .filter( (item): item is { type: "text"; text: string } => item.type === "text" && typeof item.text === "string", ) .map(item => item.text) .join("\n") .trim(); pending.reject(new Error(text || "Host tool execution failed")); return true; } pending.resolve(frame.result); return true; } handleUpdate(frame: RpcHostToolUpdate): boolean { const pending = this.#pendingCalls.get(frame.id); if (!pending) return false; pending.onUpdate?.(frame.partialResult); return true; } requestExecution( definition: RpcHostToolDefinition, toolCallId: string, args: Record, signal?: AbortSignal, onUpdate?: AgentToolUpdateCallback, ): Promise> { if (signal?.aborted) { return Promise.reject(new Error(`Host tool "${definition.name}" was aborted`)); } const id = Snowflake.next() as string; const { promise, resolve, reject } = Promise.withResolvers>(); let settled = false; // ---- idle timer (reset by updates; fires → cancel + reject) ---- let idleTimer: ReturnType | undefined; const clearIdle = (): void => { if (idleTimer !== undefined) { clearTimeout(idleTimer); idleTimer = undefined; } }; const armIdle = (): void => { clearIdle(); idleTimer = setTimeout(onIdleTimeout, this.#idleTimeoutMs); // Don't keep the process alive just for this backstop. if (typeof idleTimer === "object" && "unref" in idleTimer) idleTimer.unref(); }; const cleanup = () => { clearIdle(); signal?.removeEventListener("abort", onAbort); this.#pendingCalls.delete(id); }; const onAbort = () => { if (settled) return; settled = true; cleanup(); this.#output({ type: "host_tool_cancel", id: Snowflake.next() as string, targetId: id, }); reject(new Error(`Host tool "${definition.name}" was aborted`)); }; const onIdleTimeout = () => { if (settled) return; settled = true; cleanup(); this.#output({ type: "host_tool_cancel", id: Snowflake.next() as string, targetId: id, }); reject( new Error( `Host tool "${definition.name}" did not respond within ${this.#idleTimeoutMs / 1000}s ` + "(the host may be unresponsive — a suspended Office WebView loses its connection)", ), ); }; signal?.addEventListener("abort", onAbort, { once: true }); this.#pendingCalls.set(id, { resolve: result => { if (settled) return; settled = true; cleanup(); resolve(result); }, reject: error => { if (settled) return; settled = true; cleanup(); reject(error); }, onUpdate: partial => { // A streamed update proves the host is alive — reset the idle clock. armIdle(); onUpdate?.(partial); }, }); this.#output({ type: "host_tool_call", id, toolCallId, toolName: definition.name, arguments: args, }); // Start the idle clock AFTER sending the call. armIdle(); return promise; } rejectAllPending(message: string): void { const error = new Error(message); const pendingCalls = Array.from(this.#pendingCalls.values()); this.#pendingCalls.clear(); for (const pending of pendingCalls) { pending.reject(error); } } }