diff --git a/.changeset/server-tool-executor.md b/.changeset/server-tool-executor.md new file mode 100644 index 0000000000..6bf07df327 --- /dev/null +++ b/.changeset/server-tool-executor.md @@ -0,0 +1,5 @@ +--- +"@openuidev/server": minor +--- + +Add provider-independent tool registration through `openUIClient.tools.create`, exposing generation definitions and an executor from one registry. Execute direct server tools or scripts from complete OpenUI responses using request-scoped context, cancellation, a total deadline, and bounded sandbox continuations without replaying tools after snapshot failures. Keep `openUIClient.tools.execute` for existing handler maps. diff --git a/docs/content/docs/api-reference/index.mdx b/docs/content/docs/api-reference/index.mdx index 2fb670c1c4..8a39d6f5d5 100644 --- a/docs/content/docs/api-reference/index.mdx +++ b/docs/content/docs/api-reference/index.mdx @@ -17,7 +17,7 @@ The OpenUI SDK is split into packages that build on each other: - **`@openuidev/langchain`** — LangChain and LangGraph integration primitives for transforming protocol-v2 agent streams into AG-UI events and relaying them to an OpenUI frontend over Web-standard Server-Sent Events. -- **`@openuidev/server`** — Server client for OpenUI Gateway. Fix invalid OpenUI Lang and persist Chat Completions turns. Import `createClient` from the package root. +- **`@openuidev/server`** — Server client for OpenUI Gateway. Fix invalid OpenUI Lang, persist Chat Completions turns, and execute customer tools or sandbox scripts. Import `createClient` from the package root. - **`@openuidev/react-email`** — Pre-built email component library and prompt options for model-generated emails that can be rendered to HTML with React Email. diff --git a/docs/content/docs/api-reference/server.mdx b/docs/content/docs/api-reference/server.mdx index 84e9bd88ac..a087d31efe 100644 --- a/docs/content/docs/api-reference/server.mdx +++ b/docs/content/docs/api-reference/server.mdx @@ -1,9 +1,9 @@ --- title: "@openuidev/server" -description: A shared server client for auto-fix and conversation history persistence on the OpenUI gateway. +description: A shared server client for auto-fix, tool and script execution, and conversation history persistence on the OpenUI gateway. --- -Use `createClient` to fix invalid OpenUI Lang and save completed Chat Completions turns to conversation history on the OpenUI Gateway. +Use `createClient` to fix invalid OpenUI Lang, execute tools and generated scripts, and save completed Chat Completions turns to conversation history on the OpenUI Gateway. ## Install @@ -116,3 +116,114 @@ await openUIClient.openai.completions.conversations.appendMessages({ ``` Accepts Chat Completions messages only and returns a `ConversationItemList`. It converts user/assistant messages and function calls/results into Conversations API items; system/developer instructions are skipped. An empty converted turn makes no request. The conversation must already exist. Append only the new turn to avoid duplicate items; writes are not retried automatically. + +## Execute tools and scripts + +Register server tools once. The same registry provides definitions for generation and handlers for execution: + +```ts +type AppContext = { userId: string }; + +const appTools = openUIClient.tools.create({ + get_orders: { + description: "Read orders for a selected year.", + parameters: { + type: "object", + properties: { year: { type: "integer" } }, + required: ["year"], + }, + output: { orders: [{ region: "West", revenue: 120 }] }, + execute: (args, { context, signal }) => readOrders(args, { userId: context.userId, signal }), + }, +}); +``` + +`readOrders` is your application's data function. Each registration requires JSON Schema `parameters` and an `execute` handler. `description` and illustrative `output` are optional. Handlers receive arguments as `Record`; they validate inputs and authorize access to actual data. The example output describes a possible result for generation, not the data returned at runtime. + +### Use the definitions for generation + +```ts +import { generateSystemPrompt } from "@openuidev/lang-core"; +import library from "./openui.spec.json"; + +const systemPrompt = generateSystemPrompt({ + cloud: true, + library, + script: { tools: appTools.definitions }, +}); +``` + +`definitions` contains only `{ name, description?, parameters, output? }`. Handlers, request context and client credentials are excluded. Metadata must be JSON serializable. You can create separate registries for different MiniApp libraries using the same client. + +### Execute a direct tool or a generated script + +```ts +// A registered name runs directly on your server; no response bundle is needed. +const orders = await appTools.execute( + { name: "get_orders", arguments: { year: 2026 } }, + { context: { userId: authenticatedUser.id }, signal: request.signal }, +); + +// A generated script can call those same registered tools. +const summary = await appTools.execute( + { name: "sales_summary", arguments: { year: 2026 }, response: generatedOpenUIBundle }, + { context: { userId: authenticatedUser.id }, signal: request.signal, timeoutMs: 60_000 }, +); +``` + +`execute` returns the final value. Registered names take precedence over scripts with the same name. Other names require the complete generated `response`, including its scripts; no parsing or splitting is needed. Scripts execute through `/v1/app/execute` in the Gateway sandbox. The helper runs any requested registered tools on your server and resumes the script until it finishes. Unknown tools or scripts produce errors. + +Context, cancellation and the deadline belong to each call. A typed context is required when your registry declares one. For tools that need no context, omit the generic and call `appTools.execute({ name, arguments, response })` without a second argument. Client creation still requires a Gateway API key. + +### Connect an execution route + +Keep your registry in a server module. Derive context from authenticated server state and select only execution input from the body: + +```ts +import { ServerClientError } from "@openuidev/server"; +import { requireAppContext } from "./auth"; +import { appTools } from "./app-tools"; + +export async function POST(request: Request) { + const context = await requireAppContext(request); + const { name, arguments: args, response } = await request.json(); + + try { + const result = await appTools.execute( + { name, arguments: args, response }, + { context, signal: request.signal }, + ); + return Response.json({ result }); + } catch (error) { + if (request.signal.aborted) throw error; + const code = error instanceof ServerClientError ? error.code : "tool_exec_error"; + const status = error instanceof ServerClientError ? (error.status ?? 500) : 500; + return Response.json({ error: { code, message: "Tool execution failed." } }, { status }); + } +} +``` + +Export `appTools` from the registration module. `requireAppContext` is your authentication helper; retain your application's normal authentication and error handling. The request body cannot supply the registry, trusted context or execution settings. If you store MiniApps by ID, authorize and load the selected response on your server, then pass that full response to `execute`. + +Your Renderer's `toolProvider.callTool` can handle browser-only actions explicitly and forward other names to this route. Browser-only handlers are not available to generated scripts; register script-accessible tools on the server. + +### Use an existing handler map + +If you already manage tool metadata elsewhere, keep the one-shot form: + +```ts +const result = await openUIClient.tools.execute({ + name: "sales_summary", + arguments: { year: 2026 }, + tools: { + get_orders: (args, { context, signal }) => readOrders(args, { userId: context.userId, signal }), + }, + response: generatedOpenUIBundle, + context: { userId: authenticatedUser.id }, + signal: request.signal, +}); +``` + +Both forms use the same execution loop. Tools requested together run concurrently; dependent operations belong in successive script calls. Runs are limited to eight tool-call rounds and sixteen customer-tool calls. The total deadline defaults to 60 seconds, including tools. Cancellation propagates to requests and handlers; work that ignores it is no longer awaited. Tool failures are returned to the script, and snapshot failures never automatically replay previously executed tools. + +The root exports `ToolDefinition`, `ToolRegistration`, `RegisteredTools`, `ToolExecutionRequest`, `ToolExecutionOptions`, `ExecuteToolInput`, and the handler type `ToolExecutor`. diff --git a/docs/content/docs/autofix.mdx b/docs/content/docs/autofix.mdx index 6ffbde4d5a..05037a04dd 100644 --- a/docs/content/docs/autofix.mdx +++ b/docs/content/docs/autofix.mdx @@ -96,7 +96,7 @@ Use either `chunks` or `toResponse()` once. `result` settles after consumption. | `toResponse()` | SSE `Response` for the matching frontend. | | `result` | Settled Autofix result. Persist `content` when present, rather than joined text. | -`result` is `null` when Autofix validation did not run. A failed streamed fix throws with `code: "fix_failed"`. See the [server client reference](/docs/api-reference/server) for configuration and conversation persistence. +`result` is `null` when Autofix validation did not run. A failed streamed fix throws with `code: "fix_failed"`. See the [server client reference](/docs/api-reference/server) for configuration, persistence, and tool execution. ## Call the API directly diff --git a/packages/server/README.md b/packages/server/README.md index 066fa5d08a..b9b171289c 100644 --- a/packages/server/README.md +++ b/packages/server/README.md @@ -1,6 +1,6 @@ # `@openuidev/server` -Use `createClient` to fix invalid OpenUI Lang and save completed Chat Completions turns to conversation history on the OpenUI Gateway. +Use `createClient` to fix invalid OpenUI Lang, execute tools and generated scripts, and save completed Chat Completions turns to conversation history on the OpenUI Gateway. ## Install @@ -113,3 +113,114 @@ await openUIClient.openai.completions.conversations.appendMessages({ ``` Accepts Chat Completions messages only and returns a `ConversationItemList`. It converts user/assistant messages and function calls/results into Conversations API items; system/developer instructions are skipped. An empty converted turn makes no request. The conversation must already exist. Append only the new turn to avoid duplicate items; writes are not retried automatically. + +## Execute tools and scripts + +Register server tools once. The same registry provides definitions for generation and handlers for execution: + +```ts +type AppContext = { userId: string }; + +const appTools = openUIClient.tools.create({ + get_orders: { + description: "Read orders for a selected year.", + parameters: { + type: "object", + properties: { year: { type: "integer" } }, + required: ["year"], + }, + output: { orders: [{ region: "West", revenue: 120 }] }, + execute: (args, { context, signal }) => readOrders(args, { userId: context.userId, signal }), + }, +}); +``` + +`readOrders` is your application's data function. Each registration requires JSON Schema `parameters` and an `execute` handler. `description` and illustrative `output` are optional. Handlers receive arguments as `Record`; they validate inputs and authorize access to actual data. The example output describes a possible result for generation, not the data returned at runtime. + +### Use the definitions for generation + +```ts +import { generateSystemPrompt } from "@openuidev/lang-core"; +import library from "./openui.spec.json"; + +const systemPrompt = generateSystemPrompt({ + cloud: true, + library, + script: { tools: appTools.definitions }, +}); +``` + +`definitions` contains only `{ name, description?, parameters, output? }`. Handlers, request context and client credentials are excluded. Metadata must be JSON serializable. You can create separate registries for different MiniApp libraries using the same client. + +### Execute a direct tool or a generated script + +```ts +// A registered name runs directly on your server; no response bundle is needed. +const orders = await appTools.execute( + { name: "get_orders", arguments: { year: 2026 } }, + { context: { userId: authenticatedUser.id }, signal: request.signal }, +); + +// A generated script can call those same registered tools. +const summary = await appTools.execute( + { name: "sales_summary", arguments: { year: 2026 }, response: generatedOpenUIBundle }, + { context: { userId: authenticatedUser.id }, signal: request.signal, timeoutMs: 60_000 }, +); +``` + +`execute` returns the final value. Registered names take precedence over scripts with the same name. Other names require the complete generated `response`, including its scripts; no parsing or splitting is needed. Scripts execute through `/v1/app/execute` in the Gateway sandbox. The helper runs any requested registered tools on your server and resumes the script until it finishes. Unknown tools or scripts produce errors. + +Context, cancellation and the deadline belong to each call. A typed context is required when your registry declares one. For tools that need no context, omit the generic and call `appTools.execute({ name, arguments, response })` without a second argument. Client creation still requires a Gateway API key. + +### Connect an execution route + +Keep your registry in a server module. Derive context from authenticated server state and select only execution input from the body: + +```ts +import { ServerClientError } from "@openuidev/server"; +import { requireAppContext } from "./auth"; +import { appTools } from "./app-tools"; + +export async function POST(request: Request) { + const context = await requireAppContext(request); + const { name, arguments: args, response } = await request.json(); + + try { + const result = await appTools.execute( + { name, arguments: args, response }, + { context, signal: request.signal }, + ); + return Response.json({ result }); + } catch (error) { + if (request.signal.aborted) throw error; + const code = error instanceof ServerClientError ? error.code : "tool_exec_error"; + const status = error instanceof ServerClientError ? (error.status ?? 500) : 500; + return Response.json({ error: { code, message: "Tool execution failed." } }, { status }); + } +} +``` + +Export `appTools` from the registration module. `requireAppContext` is your authentication helper; retain your application's normal authentication and error handling. The request body cannot supply the registry, trusted context or execution settings. If you store MiniApps by ID, authorize and load the selected response on your server, then pass that full response to `execute`. + +Your Renderer's `toolProvider.callTool` can handle browser-only actions explicitly and forward other names to this route. Browser-only handlers are not available to generated scripts; register script-accessible tools on the server. + +### Use an existing handler map + +If you already manage tool metadata elsewhere, keep the one-shot form: + +```ts +const result = await openUIClient.tools.execute({ + name: "sales_summary", + arguments: { year: 2026 }, + tools: { + get_orders: (args, { context, signal }) => readOrders(args, { userId: context.userId, signal }), + }, + response: generatedOpenUIBundle, + context: { userId: authenticatedUser.id }, + signal: request.signal, +}); +``` + +Both forms use the same execution loop. Tools requested together run concurrently; dependent operations belong in successive script calls. Runs are limited to eight tool-call rounds and sixteen customer-tool calls. The total deadline defaults to 60 seconds, including tools. Cancellation propagates to requests and handlers; work that ignores it is no longer awaited. Tool failures are returned to the script, and snapshot failures never automatically replay previously executed tools. + +The root exports `ToolDefinition`, `ToolRegistration`, `RegisteredTools`, `ToolExecutionRequest`, `ToolExecutionOptions`, `ExecuteToolInput`, and the handler type `ToolExecutor`. diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 5234761407..c2dc2752e0 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -4,6 +4,7 @@ import { createCompletionsConversations } from "./openai/client"; import { openAIResponsesAdapter } from "./openai/responses-adapter"; import { resolveClientOptions, type ClientOptions } from "./shared/client"; import { createClientAutofix, createClientFix } from "./shared/client-autofix"; +import { createTools } from "./tools/execute"; import { vercelAIAdapter } from "./vercel/adapter"; import { eveStreamAdapter } from "./vercel/eve-adapter"; @@ -18,6 +19,7 @@ export type { AutofixResult, AutofixStream } from "./shared/types"; export function createClient(options: ClientOptions = {}) { const config = resolveClientOptions(options); return { + tools: createTools(config), autofix: createClientFix(config), openai: { completions: { @@ -42,3 +44,13 @@ export type Client = ReturnType; export type { MessageStreamEvent as EveStreamEvent } from "eve/client"; export type { LangGraphStreamEvent } from "./langchain/types"; + +export type { + ExecuteToolInput, + RegisteredTools, + ToolDefinition, + ToolExecutionOptions, + ToolExecutionRequest, + ToolExecutor, + ToolRegistration, +} from "./tools/types"; diff --git a/packages/server/src/tools/execute.ts b/packages/server/src/tools/execute.ts new file mode 100644 index 0000000000..c73d6cc559 --- /dev/null +++ b/packages/server/src/tools/execute.ts @@ -0,0 +1,202 @@ +import { abortable } from "../shared/abort"; +import { postJSON, ServerClientError, type ClientConfig } from "../shared/client"; +import type { + ExecuteToolInput, + RegisteredTools, + ScriptToolCall, + ScriptToolResult, + ToolExecutionOptions, + ToolRegistration, +} from "./types"; + +const MAX_ROUNDS = 8; +const MAX_TOOL_CALLS = 16; +const DEFAULT_TIMEOUT_MS = 60_000; + +const isRecord = (value: unknown): value is Record => + value !== null && typeof value === "object" && !Array.isArray(value); + +function invalidResponse(): never { + throw new ServerClientError("Invalid script execution response", "invalid_response", 502); +} + +/** Register tool metadata and handlers, or execute an existing handler map. */ +export function createTools(config: ClientConfig) { + async function execute(input: ExecuteToolInput): Promise { + if (!input || typeof input.name !== "string" || !input.name || !isRecord(input.tools)) { + throw new ServerClientError("A tool name and tools registry are required", "invalid_input"); + } + const args = input.arguments ?? {}; + if (!isRecord(args)) + throw new ServerClientError("Tool arguments must be an object", "invalid_input"); + const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS; + if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) { + throw new ServerClientError("timeoutMs must be a positive finite number", "invalid_input"); + } + input.signal?.throwIfAborted(); + const controller = new AbortController(); + const forwardAbort = () => controller.abort(input.signal?.reason); + input.signal?.addEventListener("abort", forwardAbort, { once: true }); + const timer = setTimeout( + () => + controller.abort(new ServerClientError("Tool execution timed out", "tool_timeout", 504)), + timeoutMs, + ); + const signal = controller.signal; + + const dispatch = async (name: string, toolArgs: Record) => { + signal.throwIfAborted(); + if (!Object.hasOwn(input.tools, name) || typeof input.tools[name] !== "function") { + throw new ServerClientError(`Unknown customer tool: ${name}`, "tool_not_found", 404); + } + return abortable( + Promise.resolve().then(() => + input.tools[name]!(toolArgs, { context: input.context as Context, signal }), + ), + signal, + ); + }; + + try { + if (Object.hasOwn(input.tools, input.name)) return await dispatch(input.name, args); + if (typeof input.response !== "string" || !input.response.trim()) { + throw new ServerClientError( + `Unknown tool: ${input.name}. Supply a complete OpenUI response for script execution.`, + "tool_not_found", + 404, + ); + } + + let state: string | undefined; + let toolResults: ScriptToolResult[] = []; + const seen = new Set(); + // Allow a final settlement request after the last tool-call round. + for (let round = 0; round <= MAX_ROUNDS; round++) { + const response = await abortable( + postJSON( + config, + "/v1/app/execute", + { + response: input.response, + name: input.name, + arguments: args, + tool_results: toolResults, + ...(state !== undefined && { state }), + }, + signal, + ), + signal, + ); + if (!isRecord(response)) invalidResponse(); + if (response["status"] === "done") return response["result"]; + if (response["status"] === "error") { + const error = response["error"]; + if ( + !isRecord(error) || + typeof error["code"] !== "string" || + typeof error["message"] !== "string" + ) + invalidResponse(); + // Never replay tools after a failed snapshot: a prior call may have mutated data. + throw new ServerClientError(error["message"], error["code"]); + } + const calls = response["calls"]; + if ( + response["status"] !== "tool_calls" || + typeof response["state"] !== "string" || + !response["state"] || + !Array.isArray(calls) || + !calls.length + ) + invalidResponse(); + if (round === MAX_ROUNDS || seen.size + calls.length > MAX_TOOL_CALLS) { + throw new ServerClientError("Script execution limit exceeded", "script_limit", 502); + } + // Validate the complete batch before any customer tool starts. + for (const call of calls) { + if ( + !isRecord(call) || + (call["type"] !== undefined && call["type"] !== "function_call") || + typeof call["call_id"] !== "string" || + !call["call_id"] || + seen.has(call["call_id"]) || + typeof call["name"] !== "string" || + !call["name"] || + typeof call["arguments"] !== "string" + ) + invalidResponse(); + seen.add(call["call_id"]); + } + state = response["state"]; + toolResults = await Promise.all( + (calls as ScriptToolCall[]).map(async (call): Promise => { + try { + const toolArgs: unknown = JSON.parse(call.arguments); + if (!isRecord(toolArgs)) + throw new ServerClientError("Tool arguments must be an object", "bad_args"); + const result = await dispatch(call.name, toolArgs); + signal.throwIfAborted(); + const output = JSON.stringify(result ?? null); + if (output === undefined) + throw new ServerClientError( + "Tool result must be JSON serializable", + "bad_tool_result", + ); + return { + type: "function_call_output", + call_id: call.call_id, + output, + }; + } catch (error) { + signal.throwIfAborted(); + return { + call_id: call.call_id, + error: { + code: error instanceof ServerClientError ? error.code : "tool_exec_error", + message: error instanceof Error ? error.message : String(error), + }, + }; + } + }), + ); + } + throw new ServerClientError("Script execution limit exceeded", "script_limit", 502); + } finally { + clearTimeout(timer); + input.signal?.removeEventListener("abort", forwardAbort); + controller.abort(); + } + } + + return { + execute, + create( + registry: Record>, + ): RegisteredTools { + const entries = Object.entries(registry); + const tools = Object.fromEntries(entries.map(([name, tool]) => [name, tool.execute])); + const definitions = entries.map(([name, { description, parameters, output }]) => ({ + name, + parameters, + ...(description !== undefined && { description }), + ...(output !== undefined && { output }), + })); + + return { + definitions, + execute(input, options?: ToolExecutionOptions) { + // Only request data comes from input; capabilities and context stay server-owned. + return execute({ + name: input.name, + arguments: input.arguments, + response: input.response, + tools, + context: options?.context, + signal: options?.signal, + timeoutMs: options?.timeoutMs, + } as ExecuteToolInput); + }, + }; + }, + }; +} diff --git a/packages/server/src/tools/types.ts b/packages/server/src/tools/types.ts new file mode 100644 index 0000000000..c053f50fcf --- /dev/null +++ b/packages/server/src/tools/types.ts @@ -0,0 +1,57 @@ +/** Provider-neutral metadata passed to generation, without server handlers. */ +export interface ToolDefinition { + name: string; + description?: string; + parameters: Record; + /** Illustrative output for generation; the handler supplies actual runtime data. */ + output?: unknown; +} + +/** Registered customer tools execute on the host server with trusted context. */ +export type ToolExecutor = ( + args: Record, + options: { context: Context; signal: AbortSignal }, +) => unknown | Promise; + +export type ToolRegistration = Omit & { + execute: ToolExecutor; +}; + +export interface ToolExecutionRequest { + name: string; + arguments?: Record; + /** Complete generated response. Required for script names, optional for direct tools. */ + response?: string; +} + +export type ToolExecutionOptions = { + signal?: AbortSignal; + /** Total execution budget, including customer tools. Default: 60 seconds. */ + timeoutMs?: number; +} & (undefined extends Context ? { context?: Context } : { context: Context }); + +export type ExecuteToolInput = ToolExecutionRequest & + ToolExecutionOptions & { + tools: Record>; + }; + +export interface RegisteredTools { + definitions: ToolDefinition[]; + execute( + input: ToolExecutionRequest, + ...options: undefined extends Context + ? [options?: ToolExecutionOptions] + : [options: ToolExecutionOptions] + ): Promise; +} + +export interface ScriptToolCall { + type?: "function_call"; + call_id: string; + name: string; + arguments: string; +} + +export type ScriptToolResult = + | { type: "function_call_output"; call_id: string; output: string } + | { call_id: string; error: { code: string; message: string } };