diff --git a/src/modules/agents.ts b/src/modules/agents.ts index 9b28764..a4242a4 100644 --- a/src/modules/agents.ts +++ b/src/modules/agents.ts @@ -5,6 +5,8 @@ import { AgentMessage, AgentsModule, AgentsModuleConfig, + ClientToolHandler, + ClientToolResult, CreateConversationParams, } from "./agents.types.js"; @@ -20,6 +22,9 @@ export function createAgentsModule({ // Track active conversations const currentConversations: Record = {}; + // Stores client tool handlers keyed by conversation ID + const clientToolHandlers: Record> = {}; + const getConversations = () => { return axios.get(`${baseURL}/conversations`); }; @@ -53,6 +58,75 @@ export function createAgentsModule({ ); }; + const registerClientToolHandlers = ( + conversationId: string, + handlers: Record + ) => { + clientToolHandlers[conversationId] = { + ...(clientToolHandlers[conversationId] || {}), + ...handlers, + }; + }; + + const submitToolResults = ( + conversationId: string, + results: ClientToolResult[] + ) => { + return axios.post(`${baseURL}/conversations/${conversationId}/client-tool-results`, { results }); + }; + + const handlePendingClientTools = async ( + conversationId: string, + message: AgentMessage + ): Promise => { + if (!message?.tool_calls) return false; + + const pendingCalls = message.tool_calls.filter( + (tc) => tc.status === "pending_client_execution" + ); + + if (pendingCalls.length === 0) return false; + + const handlers = clientToolHandlers[conversationId]; + if (!handlers) return false; + + const results: ClientToolResult[] = []; + for (const tc of pendingCalls) { + const handler = handlers[tc.name]; + if (!handler) { + results.push({ + tool_call_id: tc.id, + result: `Error: No handler registered for client tool '${tc.name}'`, + }); + continue; + } + + try { + const args = JSON.parse(tc.arguments_string); + const context = { + appId, + conversationId, + toolCallId: tc.id, + toolName: tc.name, + messages: currentConversations[conversationId]?.messages || [], + }; + const result = await handler(args, context); + results.push({ + tool_call_id: tc.id, + result: typeof result === "string" ? result : JSON.stringify(result), + }); + } catch (error: any) { + results.push({ + tool_call_id: tc.id, + result: `Error executing client tool '${tc.name}': ${error.message}`, + }); + } + } + + await submitToolResults(conversationId, results); + return true; + }; + const subscribeToConversation = ( conversationId: string, onUpdate?: (conversation: AgentConversation) => void @@ -92,6 +166,9 @@ export function createAgentsModule({ messages: updatedMessages, }; onUpdate?.(currentConversations[conversationId]!); + + // Automatically handle pending client tool calls + await handlePendingClientTools(conversationId, message); } } }, @@ -118,6 +195,9 @@ export function createAgentsModule({ listConversations, createConversation, addMessage, + registerClientToolHandlers, + submitToolResults, + handlePendingClientTools, subscribeToConversation, getWhatsAppConnectURL, }; diff --git a/src/modules/agents.types.ts b/src/modules/agents.types.ts index 5c14b55..d1181fb 100644 --- a/src/modules/agents.types.ts +++ b/src/modules/agents.types.ts @@ -42,7 +42,7 @@ export interface AgentMessageToolCall { /** Arguments passed to the tool as JSON string. */ arguments_string: string; /** Status of the tool call. */ - status: "running" | "success" | "error" | "stopped"; + status: "running" | "success" | "error" | "stopped" | "pending_client_execution"; /** Results from the tool call. */ results?: string; } @@ -153,6 +153,43 @@ export interface CreateConversationParams { metadata?: Record; } +/** + * Context passed to client tool handlers during execution. + */ +export interface ClientToolContext { + /** The app ID. */ + appId: string; + /** The conversation ID. */ + conversationId: string; + /** The unique tool call ID. */ + toolCallId: string; + /** The tool name. */ + toolName: string; + /** The conversation messages so far. */ + messages: AgentMessage[]; +} + +/** + * A client tool handler function. + * + * Receives parsed arguments and execution context, returns a string result + * (or an object that will be JSON.stringified). + */ +export type ClientToolHandler = ( + args: Record, + context: ClientToolContext +) => Promise> | string | Record; + +/** + * Result of a client tool execution, to be submitted back to the server. + */ +export interface ClientToolResult { + /** The tool call ID this result corresponds to. */ + tool_call_id: string; + /** The result string. */ + result: string; +} + /** * Configuration for creating the agents module. * @internal @@ -331,13 +368,93 @@ export interface AgentsModule { message: Partial ): Promise; + /** + * Registers handler functions for client-side tools defined in the agent configuration. + * + * Client tools are tools that execute in the browser rather than on the server. + * They are defined by the app builder in the agent configuration (name, description, + * parameters). The SDK caller provides the handler functions that run locally when + * the agent invokes these tools. + * + * When subscribed to the conversation via {@linkcode subscribeToConversation | subscribeToConversation()}, + * client tool calls are handled automatically — the SDK detects pending client tool calls, + * executes the registered handlers, and submits results back to the server. + * + * For user info inside a handler, call `base44.auth.me()` from the handler body. + * + * @param conversationId - The conversation ID to register handlers for. + * @param handlers - Map of tool name to handler function. Each handler receives + * `(args, context)` where `args` are the parsed tool arguments and `context` + * includes `appId`, `conversationId`, `toolCallId`, `toolName`, and `messages`. + * + * @example + * ```typescript + * base44.agents.registerClientToolHandlers(conversation.id, { + * get_current_location: async ({ accuracy }, { conversationId, messages }) => { + * const pos = await new Promise((resolve, reject) => + * navigator.geolocation.getCurrentPosition(resolve, reject, { + * enableHighAccuracy: accuracy === 'high' + * }) + * ); + * return JSON.stringify({ + * lat: pos.coords.latitude, + * lng: pos.coords.longitude + * }); + * }, + * get_clipboard_text: async () => { + * return await navigator.clipboard.readText(); + * } + * }); + * ``` + */ + registerClientToolHandlers( + conversationId: string, + handlers: Record + ): void; + + /** + * Submits results for client-side tool calls. + * + * This is called automatically when using {@linkcode subscribeToConversation | subscribeToConversation()} + * with registered handlers. You only need to call this directly if you are + * implementing custom tool call handling logic outside of the subscription flow. + * + * @param conversationId - The conversation ID. + * @param results - Array of tool call results. + */ + submitToolResults( + conversationId: string, + results: ClientToolResult[] + ): Promise; + + /** + * Processes pending client-side tool calls from a message. + * + * Finds tool calls with `pending_client_execution` status, executes their + * registered handlers, and submits the results back to the server. + * + * This is called automatically by {@linkcode subscribeToConversation | subscribeToConversation()}. + * You only need to call this directly for custom handling flows. + * + * @param conversationId - The conversation ID. + * @param message - The message containing tool calls. + * @returns `true` if client tool calls were processed, `false` otherwise. + */ + handlePendingClientTools( + conversationId: string, + message: AgentMessage + ): Promise; + /** * Subscribes to realtime updates for a conversation. * * Establishes a WebSocket connection to receive instant updates when new * messages are added to the conversation. Returns an unsubscribe function * to clean up the connection. - * + * + * Client tool handlers registered via {@linkcode registerClientToolHandlers | registerClientToolHandlers()} + * are automatically executed when tool calls with `pending_client_execution` status arrive. + * * When receiving messages through this function, tool call data is truncated for efficiency. The `arguments_string` is limited to 500 characters and `results` to 50 characters. The complete tool call data is always saved in storage and can be retrieved by calling {@linkcode getConversation | getConversation()} after the message completes.