diff --git a/src/funcs/call-model.ts b/src/funcs/call-model.ts index bc2db9b..b57515e 100644 --- a/src/funcs/call-model.ts +++ b/src/funcs/call-model.ts @@ -124,7 +124,16 @@ export function callModel( request: CallModelInput, options?: RequestOptions, ): ModelResult { - const { tools, stopWhen, ...apiRequest } = request; + // Destructure state management options along with tools and stopWhen + const { + tools, + stopWhen, + state, + requireApproval, + approveToolCalls, + rejectToolCalls, + ...apiRequest + } = request; // Convert tools to API format - no cast needed now that convertToolsToAPIFormat accepts readonly const apiTools = tools ? convertToolsToAPIFormat(tools) : undefined; @@ -144,10 +153,12 @@ export function callModel( client, request: finalRequest, options: options ?? {}, - // Preserve the exact TTools type instead of widening to Tool[] - tools: tools as TTools | undefined, - ...(stopWhen !== undefined && { - stopWhen, - }), + tools, + ...(stopWhen !== undefined && { stopWhen }), + // Pass state management options + ...(state !== undefined && { state }), + ...(requireApproval !== undefined && { requireApproval }), + ...(approveToolCalls !== undefined && { approveToolCalls }), + ...(rejectToolCalls !== undefined && { rejectToolCalls }), } as GetResponseOptions); } diff --git a/src/index.ts b/src/index.ts index 5121a18..03717f8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,7 @@ // Async params support export type { CallModelInput, + CallModelInputWithState, FieldOrAsyncFunction, ResolvedCallModelInput, } from './lib/async-params.js'; @@ -12,7 +13,10 @@ export type { Fetcher, HTTPClientOptions } from './lib/http.js'; // Tool types export type { ChatStreamEvent, + ConversationState, + ConversationStatus, ResponseStreamEvent as EnhancedResponseStreamEvent, + HasApprovalTools, InferToolEvent, InferToolEventsUnion, InferToolInput, @@ -21,12 +25,16 @@ export type { NextTurnParamsContext, NextTurnParamsFunctions, ParsedToolCall, + PartialResponse, + StateAccessor, StepResult, StopCondition, StopWhen, Tool, + ToolApprovalCheck, ToolExecutionResult, ToolExecutionResultUnion, + ToolHasApproval, ToolPreliminaryResultEvent, ToolStreamEvent, ToolWithExecute, @@ -34,6 +42,7 @@ export type { TurnContext, TypedToolCall, TypedToolCallUnion, + UnsentToolResult, Warning, } from './lib/tool-types.js'; export type { BuildTurnContextOptions } from './lib/turn-context.js'; @@ -101,14 +110,27 @@ export { // Tool creation helpers export { tool } from './lib/tool.js'; export { + hasApprovalRequiredTools, hasExecuteFunction, isGeneratorTool, isRegularExecuteTool, isToolPreliminaryResultEvent, + toolHasApprovalConfigured, ToolType, } from './lib/tool-types.js'; // Turn context helpers export { buildTurnContext, normalizeInputToArray } from './lib/turn-context.js'; +// Conversation state helpers +export { + appendToMessages, + createInitialState, + createRejectedResult, + createUnsentResult, + generateConversationId, + partitionToolCalls, + toolRequiresApproval, + updateState, +} from './lib/conversation-state.js'; // Real-time tool event broadcasting export { ToolEventBroadcaster } from './lib/tool-event-broadcaster.js'; export * from './sdk/sdk.js'; diff --git a/src/lib/async-params.ts b/src/lib/async-params.ts index 28c96c9..91a36c9 100644 --- a/src/lib/async-params.ts +++ b/src/lib/async-params.ts @@ -1,5 +1,8 @@ import type * as models from '../models/index.js'; -import type { StopWhen, Tool, TurnContext } from './tool-types.js'; +import type { ParsedToolCall, StateAccessor, StopWhen, Tool, TurnContext } from './tool-types.js'; + +// Re-export Tool type for convenience +export type { Tool } from './tool-types.js'; /** * Type guard to check if a value is a parameter function @@ -29,19 +32,67 @@ function buildResolvedRequest( export type FieldOrAsyncFunction = T | ((context: TurnContext) => T | Promise); /** - * Input type for callModel function - * Each field can independently be a static value or a function that computes the value - * Generic over TTools to enable proper type inference for stopWhen conditions + * Base input type for callModel without approval-related fields */ -export type CallModelInput = { +type BaseCallModelInput = { [K in keyof Omit]?: FieldOrAsyncFunction< models.OpenResponsesRequest[K] >; } & { tools?: TTools; stopWhen?: StopWhen; + /** + * Call-level approval check - overrides tool-level requireApproval setting + * Receives the tool call and turn context, can be sync or async + */ + requireApproval?: ( + toolCall: ParsedToolCall, + context: TurnContext + ) => boolean | Promise; +}; + +/** + * Approval params when state is provided (allows approve/reject) + */ +type ApprovalParamsWithState = { + /** State accessor for multi-turn persistence and approval gates */ + state: StateAccessor; + /** Tool call IDs to approve (for resuming from awaiting_approval status) */ + approveToolCalls?: string[]; + /** Tool call IDs to reject (for resuming from awaiting_approval status) */ + rejectToolCalls?: string[]; +}; + +/** + * Approval params when state is NOT provided (forbids approve/reject) + */ +type ApprovalParamsWithoutState = { + /** State accessor for multi-turn persistence and approval gates */ + state?: undefined; + /** Not allowed without state - will cause type error */ + approveToolCalls?: never; + /** Not allowed without state - will cause type error */ + rejectToolCalls?: never; }; +/** + * Input type for callModel function + * Each field can independently be a static value or a function that computes the value + * Generic over TTools to enable proper type inference for stopWhen conditions + * + * Type enforcement: + * - `approveToolCalls` and `rejectToolCalls` are only valid when `state` is provided + * - Using these without `state` will cause a TypeScript error + */ +export type CallModelInput = + BaseCallModelInput & (ApprovalParamsWithState | ApprovalParamsWithoutState); + +/** + * CallModelInput variant that requires state - use when approval workflows are needed + */ +export type CallModelInputWithState = + BaseCallModelInput & ApprovalParamsWithState; + /** * Resolved CallModelInput (all functions evaluated to values) * This is the type after all async functions have been resolved to their values @@ -70,8 +121,8 @@ export type ResolvedCallModelInput = Omit( + input: CallModelInput, context: TurnContext, ): Promise { // Build array of resolved entries diff --git a/src/lib/conversation-state.ts b/src/lib/conversation-state.ts new file mode 100644 index 0000000..ea3e7db --- /dev/null +++ b/src/lib/conversation-state.ts @@ -0,0 +1,266 @@ +import type * as models from '../models/index.js'; +import type { + ConversationState, + ParsedToolCall, + Tool, + TurnContext, + UnsentToolResult, +} from './tool-types.js'; +import { normalizeInputToArray } from './turn-context.js'; + +/** + * Type guard to verify an object is a valid UnsentToolResult + */ +function isValidUnsentToolResult( + obj: unknown +): obj is UnsentToolResult { + if (typeof obj !== 'object' || obj === null) return false; + const candidate = obj as Record; + return ( + typeof candidate['callId'] === 'string' && + typeof candidate['name'] === 'string' && + 'output' in candidate + ); +} + +/** + * Type guard to verify an object is a valid ParsedToolCall + */ +function isValidParsedToolCall( + obj: unknown +): obj is ParsedToolCall { + if (typeof obj !== 'object' || obj === null) return false; + const candidate = obj as Record; + return ( + typeof candidate['id'] === 'string' && + typeof candidate['name'] === 'string' && + 'arguments' in candidate + ); +} + +/** + * Generate a unique ID for a conversation + * Uses crypto.randomUUID if available, falls back to timestamp + random + */ +export function generateConversationId(): string { + if (typeof crypto !== 'undefined' && crypto.randomUUID) { + return `conv_${crypto.randomUUID()}`; + } + // Fallback for environments without crypto.randomUUID + return `conv_${Date.now()}_${Math.random().toString(36).substring(2, 15)}`; +} + +/** + * Create an initial conversation state + * @param id - Optional custom ID, generates one if not provided + */ +export function createInitialState( + id?: string +): ConversationState { + const now = Date.now(); + return { + id: id ?? generateConversationId(), + messages: [], + status: 'in_progress', + createdAt: now, + updatedAt: now, + }; +} + +/** + * Update a conversation state with new values + * Automatically updates the updatedAt timestamp + */ +export function updateState( + state: ConversationState, + updates: Partial, 'id' | 'createdAt' | 'updatedAt'>> +): ConversationState { + return { + ...state, + ...updates, + updatedAt: Date.now(), + }; +} + +/** + * Append new items to the message history + */ +export function appendToMessages( + current: models.OpenResponsesInput, + newItems: models.OpenResponsesInput1[] +): models.OpenResponsesInput { + const currentArray = normalizeInputToArray(current); + return [...currentArray, ...newItems]; +} + +/** + * Check if a tool call requires approval + * @param toolCall - The tool call to check + * @param tools - Available tools + * @param context - Turn context for the approval check + * @param callLevelCheck - Optional call-level approval function (overrides tool-level), can be async + */ +export async function toolRequiresApproval( + toolCall: ParsedToolCall, + tools: TTools, + context: TurnContext, + callLevelCheck?: (toolCall: ParsedToolCall, context: TurnContext) => boolean | Promise +): Promise { + // Call-level check takes precedence + if (callLevelCheck) { + return callLevelCheck(toolCall, context); + } + + // Fall back to tool-level setting + const tool = tools.find(t => t.function.name === toolCall.name); + if (!tool) return false; + + const requireApproval = tool.function.requireApproval; + + // If it's a function, call it with the tool's arguments and context + if (typeof requireApproval === 'function') { + return requireApproval(toolCall.arguments, context); + } + + // Otherwise treat as boolean + return requireApproval ?? false; +} + +/** + * Partition tool calls into those requiring approval and those that can auto-execute + * @param toolCalls - Tool calls to partition + * @param tools - Available tools + * @param context - Turn context for the approval check + * @param callLevelCheck - Optional call-level approval function (overrides tool-level), can be async + */ +export async function partitionToolCalls( + toolCalls: ParsedToolCall[], + tools: TTools, + context: TurnContext, + callLevelCheck?: (toolCall: ParsedToolCall, context: TurnContext) => boolean | Promise +): Promise<{ + requiresApproval: ParsedToolCall[]; + autoExecute: ParsedToolCall[]; +}> { + const requiresApproval: ParsedToolCall[] = []; + const autoExecute: ParsedToolCall[] = []; + + for (const tc of toolCalls) { + if (await toolRequiresApproval(tc, tools, context, callLevelCheck)) { + requiresApproval.push(tc); + } else { + autoExecute.push(tc); + } + } + + return { requiresApproval, autoExecute }; +} + +/** + * Create an unsent tool result from a successful execution + */ +export function createUnsentResult( + callId: string, + name: string, + output: unknown +): UnsentToolResult { + const result = { callId, name, output }; + if (!isValidUnsentToolResult(result)) { + throw new Error('Invalid UnsentToolResult structure'); + } + return result; +} + +/** + * Create an unsent tool result from a rejection + */ +export function createRejectedResult( + callId: string, + name: string, + reason?: string +): UnsentToolResult { + const result = { + callId, + name, + output: null, + error: reason ?? 'Tool call rejected by user', + }; + if (!isValidUnsentToolResult(result)) { + throw new Error('Invalid UnsentToolResult structure'); + } + return result; +} + +/** + * Convert unsent tool results to API format for sending to the model + */ +export function unsentResultsToAPIFormat( + results: UnsentToolResult[] +): models.OpenResponsesFunctionCallOutput[] { + return results.map(r => ({ + type: 'function_call_output' as const, + id: `output_${r.callId}`, + callId: r.callId, + output: r.error + ? JSON.stringify({ error: r.error }) + : JSON.stringify(r.output), + })); +} + +/** + * Extract text content from a response + */ +export function extractTextFromResponse( + response: models.OpenResponsesNonStreamingResponse +): string { + if (!response.output) { + return ''; + } + + const outputs = Array.isArray(response.output) ? response.output : [response.output]; + const textParts: string[] = []; + + for (const item of outputs) { + if (item.type === 'message' && item.content) { + for (const content of item.content) { + if (content.type === 'output_text' && content.text) { + textParts.push(content.text); + } + } + } + } + + return textParts.join(''); +} + +/** + * Extract tool calls from a response + */ +export function extractToolCallsFromResponse( + response: models.OpenResponsesNonStreamingResponse +): ParsedToolCall[] { + if (!response.output) { + return []; + } + + const outputs = Array.isArray(response.output) ? response.output : [response.output]; + const toolCalls: ParsedToolCall[] = []; + + for (const item of outputs) { + if (item.type === 'function_call') { + const toolCall = { + id: item.callId ?? item.id ?? '', + name: item.name ?? '', + arguments: typeof item.arguments === 'string' + ? JSON.parse(item.arguments) + : item.arguments, + }; + if (!isValidParsedToolCall(toolCall)) { + throw new Error(`Invalid tool call structure for tool: ${item.name}`); + } + toolCalls.push(toolCall); + } + } + + return toolCalls; +} diff --git a/src/lib/model-result.ts b/src/lib/model-result.ts index e054b5a..4530061 100644 --- a/src/lib/model-result.ts +++ b/src/lib/model-result.ts @@ -4,13 +4,16 @@ import type { CallModelInput } from './async-params.js'; import type { EventStream } from './event-streams.js'; import type { RequestOptions } from './sdks.js'; import type { + ConversationState, ResponseStreamEvent, InferToolEventsUnion, ParsedToolCall, + StateAccessor, StopWhen, Tool, ToolStreamEvent, TurnContext, + UnsentToolResult, } from './tool-types.js'; import { ToolEventBroadcaster } from './tool-event-broadcaster.js'; @@ -20,6 +23,16 @@ import { resolveAsyncFunctions, type ResolvedCallModelInput, } from './async-params.js'; +import { + appendToMessages, + createInitialState, + createRejectedResult, + createUnsentResult, + extractTextFromResponse as extractTextFromResponseState, + partitionToolCalls, + unsentResultsToAPIFormat, + updateState, +} from './conversation-state.js'; import { ReusableReadableStream } from './reusable-stream.js'; import { buildResponsesMessageStream, @@ -35,22 +48,32 @@ import { import { executeTool } from './tool-executor.js'; import { executeNextTurnParamsFunctions, applyNextTurnParamsToRequest } from './next-turn-params.js'; import { hasExecuteFunction } from './tool-types.js'; -import { isStopConditionMet } from './stop-conditions.js'; +import { isStopConditionMet, stepCountIs } from './stop-conditions.js'; + +/** + * Default maximum number of tool execution steps if no stopWhen is specified. + * This prevents infinite loops in tool execution. + */ +const DEFAULT_MAX_STEPS = 5; /** * Type guard for stream event with toReadableStream method + * Checks constructor name, prototype, and method availability */ function isEventStream(value: unknown): value is EventStream { - return ( - value !== null && - typeof value === 'object' && - 'toReadableStream' in value && - typeof ( - value as { - toReadableStream: unknown; - } - ).toReadableStream === 'function' - ); + if (value === null || typeof value !== 'object') { + return false; + } + + // Check constructor name for EventStream + const constructorName = Object.getPrototypeOf(value)?.constructor?.name; + if (constructorName === 'EventStream') { + return true; + } + + // Fallback: check for toReadableStream method (may be on prototype) + const maybeStream = value as { toReadableStream?: unknown }; + return typeof maybeStream.toReadableStream === 'function'; } /** @@ -78,6 +101,18 @@ export interface GetResponseOptions { options?: RequestOptions; tools?: TTools; stopWhen?: StopWhen; + // State management for multi-turn conversations + state?: StateAccessor; + /** + * Call-level approval check - overrides tool-level requireApproval setting + * Receives the tool call and turn context, can be sync or async + */ + requireApproval?: ( + toolCall: ParsedToolCall, + context: TurnContext + ) => boolean | Promise; + approveToolCalls?: string[]; + rejectToolCalls?: string[]; } /** @@ -101,7 +136,6 @@ export interface GetResponseOptions { */ export class ModelResult { private reusableStream: ReusableReadableStream | null = null; - private streamPromise: Promise> | null = null; private textPromise: Promise | null = null; private options: GetResponseOptions; private initPromise: Promise | null = null; @@ -121,8 +155,34 @@ export class ModelResult { // Track resolved request after async function resolution private resolvedRequest: models.OpenResponsesRequest | null = null; + // State management for multi-turn conversations + private stateAccessor: StateAccessor | null = null; + private currentState: ConversationState | null = null; + private requireApprovalFn: ((toolCall: ParsedToolCall, context: TurnContext) => boolean | Promise) | null = null; + private approvedToolCalls: string[] = []; + private rejectedToolCalls: string[] = []; + private isResumingFromApproval = false; + constructor(options: GetResponseOptions) { this.options = options; + + // Runtime validation: approval decisions require state + const hasApprovalDecisions = + (options.approveToolCalls && options.approveToolCalls.length > 0) || + (options.rejectToolCalls && options.rejectToolCalls.length > 0); + + if (hasApprovalDecisions && !options.state) { + throw new Error( + 'approveToolCalls and rejectToolCalls require a state accessor. ' + + 'Provide a StateAccessor via the "state" parameter to persist approval decisions.' + ); + } + + // Initialize state management + this.stateAccessor = options.state ?? null; + this.requireApprovalFn = options.requireApproval ?? null; + this.approvedToolCalls = options.approveToolCalls ?? []; + this.rejectedToolCalls = options.rejectToolCalls ?? []; } /** @@ -142,6 +202,7 @@ export class ModelResult { /** * Type guard to check if a value is a non-streaming response + * Only requires 'output' field and absence of 'toReadableStream' method */ private isNonStreamingResponse( value: unknown, @@ -149,13 +210,452 @@ export class ModelResult { return ( value !== null && typeof value === 'object' && - 'id' in value && - 'object' in value && 'output' in value && !('toReadableStream' in value) ); } + // ========================================================================= + // Extracted Helper Methods for executeToolsIfNeeded + // ========================================================================= + + /** + * Get initial response from stream or cached final response. + * Consumes the stream to completion if needed to extract the response. + * + * @returns The complete non-streaming response + * @throws Error if neither stream nor response has been initialized + */ + private async getInitialResponse(): Promise { + if (this.finalResponse) { + return this.finalResponse; + } + if (this.reusableStream) { + return consumeStreamForCompletion(this.reusableStream); + } + throw new Error('Neither stream nor response initialized'); + } + + /** + * Save response output to state. + * Appends the response output to the message history and records the response ID. + * + * @param response - The API response to save + */ + private async saveResponseToState( + response: models.OpenResponsesNonStreamingResponse + ): Promise { + if (!this.stateAccessor || !this.currentState) return; + + const outputItems = Array.isArray(response.output) + ? response.output + : [response.output]; + + await this.saveStateSafely({ + messages: appendToMessages( + this.currentState.messages, + outputItems as models.OpenResponsesInput1[] + ), + previousResponseId: response.id, + }); + } + + /** + * Mark state as complete. + * Sets the conversation status to 'complete' indicating no further tool execution is needed. + */ + private async markStateComplete(): Promise { + await this.saveStateSafely({ status: 'complete' }); + } + + /** + * Save tool results to state. + * Appends tool execution results to the message history for multi-turn context. + * + * @param toolResults - The tool execution results to save + */ + private async saveToolResultsToState( + toolResults: models.OpenResponsesFunctionCallOutput[] + ): Promise { + if (!this.currentState) return; + await this.saveStateSafely({ + messages: appendToMessages(this.currentState.messages, toolResults), + }); + } + + /** + * Check if execution should be interrupted by external signal. + * Polls the state accessor for interruption flags set by external processes. + * + * @param currentResponse - The current response to save as partial state + * @returns True if interrupted and caller should exit, false to continue + */ + private async checkForInterruption( + currentResponse: models.OpenResponsesNonStreamingResponse + ): Promise { + if (!this.stateAccessor) return false; + + const freshState = await this.stateAccessor.load(); + if (!freshState?.interruptedBy) return false; + + // Save partial state + if (this.currentState) { + const currentToolCalls = extractToolCallsFromResponse(currentResponse); + await this.saveStateSafely({ + status: 'interrupted', + partialResponse: { + text: extractTextFromResponseState(currentResponse), + toolCalls: currentToolCalls as ParsedToolCall[], + }, + }); + } + + this.finalResponse = currentResponse; + return true; + } + + /** + * Check if stop conditions are met. + * Returns true if execution should stop. + * + * @remarks + * Default: stepCountIs(DEFAULT_MAX_STEPS) if no stopWhen is specified. + * This evaluates stop conditions against the complete step history. + */ + private async shouldStopExecution(): Promise { + const stopWhen = this.options.stopWhen ?? stepCountIs(DEFAULT_MAX_STEPS); + + const stopConditions = Array.isArray(stopWhen) + ? stopWhen + : [stopWhen]; + + return isStopConditionMet({ + stopConditions, + steps: this.allToolExecutionRounds.map((round) => ({ + stepType: 'continue' as const, + text: extractTextFromResponse(round.response), + toolCalls: round.toolCalls, + toolResults: round.toolResults.map((tr) => ({ + toolCallId: tr.callId, + toolName: round.toolCalls.find((tc) => tc.id === tr.callId)?.name ?? '', + result: JSON.parse(tr.output), + })), + response: round.response, + usage: round.response.usage, + finishReason: undefined, + })), + }); + } + + /** + * Check if any tool calls have execute functions. + * Used to determine if automatic tool execution should be attempted. + * + * @param toolCalls - The tool calls to check + * @returns True if at least one tool call has an executable function + */ + private hasExecutableToolCalls(toolCalls: ParsedToolCall[]): boolean { + return toolCalls.some((toolCall) => { + const tool = this.options.tools?.find((t) => t.function.name === toolCall.name); + return tool && hasExecuteFunction(tool); + }); + } + + /** + * Execute tools that can auto-execute (don't require approval). + * Processes tool calls that are approved for automatic execution. + * + * @param toolCalls - The tool calls to execute + * @param turnContext - The current turn context + * @returns Array of unsent tool results for later submission + */ + private async executeAutoApproveTools( + toolCalls: ParsedToolCall[], + turnContext: TurnContext + ): Promise[]> { + const results: UnsentToolResult[] = []; + + for (const tc of toolCalls) { + const tool = this.options.tools?.find(t => t.function.name === tc.name); + if (!tool || !hasExecuteFunction(tool)) continue; + + const result = await executeTool(tool, tc as ParsedToolCall, turnContext); + + if (result.error) { + results.push(createRejectedResult(tc.id, String(tc.name), result.error.message)); + } else { + results.push(createUnsentResult(tc.id, String(tc.name), result.result)); + } + } + + return results; + } + + /** + * Check for tools requiring approval and handle accordingly. + * Partitions tool calls into those needing approval and those that can auto-execute. + * + * @param toolCalls - The tool calls to check + * @param currentRound - The current execution round (1-indexed) + * @param currentResponse - The current response to save if pausing + * @returns True if execution should pause for approval, false to continue + * @throws Error if approval is required but no state accessor is configured + */ + private async handleApprovalCheck( + toolCalls: ParsedToolCall[], + currentRound: number, + currentResponse: models.OpenResponsesNonStreamingResponse + ): Promise { + if (!this.options.tools) return false; + + const turnContext: TurnContext = { numberOfTurns: currentRound }; + + const { requiresApproval: needsApproval, autoExecute } = await partitionToolCalls( + toolCalls as ParsedToolCall[], + this.options.tools, + turnContext, + this.requireApprovalFn ?? undefined + ); + + if (needsApproval.length === 0) return false; + + // Validate: approval requires state accessor + if (!this.stateAccessor) { + const toolNames = needsApproval.map(tc => tc.name).join(', '); + throw new Error( + `Tool(s) require approval but no state accessor is configured: ${toolNames}. ` + + 'Provide a StateAccessor via the "state" parameter to enable approval workflows.' + ); + } + + // Execute auto-approve tools + const unsentResults = await this.executeAutoApproveTools(autoExecute, turnContext); + + // Save state with pending approvals + const stateUpdates: Partial, 'id' | 'createdAt' | 'updatedAt'>> = { + pendingToolCalls: needsApproval, + status: 'awaiting_approval', + }; + if (unsentResults.length > 0) { + stateUpdates.unsentToolResults = unsentResults; + } + await this.saveStateSafely(stateUpdates); + + this.finalResponse = currentResponse; + return true; // Pause for approval + } + + /** + * Execute all tools in a single round. + * Runs each tool call sequentially and collects results for API submission. + * + * @param toolCalls - The tool calls to execute + * @param turnContext - The current turn context + * @returns Array of function call outputs formatted for the API + */ + private async executeToolRound( + toolCalls: ParsedToolCall[], + turnContext: TurnContext + ): Promise { + const toolResults: models.OpenResponsesFunctionCallOutput[] = []; + + for (const toolCall of toolCalls) { + const tool = this.options.tools?.find((t) => t.function.name === toolCall.name); + if (!tool || !hasExecuteFunction(tool)) continue; + + // Create callback for real-time preliminary results + const onPreliminaryResult = this.toolEventBroadcaster + ? (callId: string, resultValue: unknown) => { + this.toolEventBroadcaster?.push({ + type: 'preliminary_result' as const, + toolCallId: callId, + result: resultValue as InferToolEventsUnion, + }); + } + : undefined; + + const result = await executeTool(tool, toolCall, turnContext, onPreliminaryResult); + + toolResults.push({ + type: 'function_call_output' as const, + id: `output_${toolCall.id}`, + callId: toolCall.id, + output: result.error + ? JSON.stringify({ error: result.error.message }) + : JSON.stringify(result.result), + }); + } + + return toolResults; + } + + /** + * Resolve async functions for the current turn. + * Updates the resolved request with turn-specific parameter values. + * + * @param turnContext - The turn context for parameter resolution + */ + private async resolveAsyncFunctionsForTurn(turnContext: TurnContext): Promise { + if (hasAsyncFunctions(this.options.request)) { + const resolved = await resolveAsyncFunctions(this.options.request, turnContext); + this.resolvedRequest = { ...resolved, stream: false }; + } + } + + /** + * Apply nextTurnParams from executed tools. + * Allows tools to modify request parameters for subsequent turns. + * + * @param toolCalls - The tool calls that were just executed + */ + private async applyNextTurnParams(toolCalls: ParsedToolCall[]): Promise { + if (!this.options.tools || toolCalls.length === 0 || !this.resolvedRequest) { + return; + } + + const computedParams = await executeNextTurnParamsFunctions( + toolCalls, + this.options.tools, + this.resolvedRequest + ); + + if (Object.keys(computedParams).length > 0) { + this.resolvedRequest = applyNextTurnParamsToRequest( + this.resolvedRequest, + computedParams + ); + } + } + + /** + * Make a follow-up API request with tool results. + * Continues the conversation after tool execution. + * + * @param currentResponse - The response that contained tool calls + * @param toolResults - The results from executing those tools + * @returns The new response from the API + */ + private async makeFollowupRequest( + currentResponse: models.OpenResponsesNonStreamingResponse, + toolResults: models.OpenResponsesFunctionCallOutput[] + ): Promise { + // Build new input with tool results + const newInput: models.OpenResponsesInput = [ + ...(Array.isArray(currentResponse.output) + ? currentResponse.output + : [currentResponse.output]), + ...toolResults, + ]; + + if (!this.resolvedRequest) { + throw new Error('Request not initialized'); + } + + const newRequest: models.OpenResponsesRequest = { + ...this.resolvedRequest, + input: newInput, + stream: false, + }; + + const newResult = await betaResponsesSend( + this.options.client, + newRequest, + this.options.options, + ); + + if (!newResult.ok) { + throw newResult.error; + } + + // Handle streaming or non-streaming response + const value = newResult.value; + if (isEventStream(value)) { + const stream = new ReusableReadableStream(value); + return consumeStreamForCompletion(stream); + } else if (this.isNonStreamingResponse(value)) { + return value; + } else { + throw new Error('Unexpected response type from API'); + } + } + + /** + * Validate the final response has required fields. + * + * @param response - The response to validate + * @throws Error if response is missing required fields or has invalid output + */ + private validateFinalResponse( + response: models.OpenResponsesNonStreamingResponse + ): void { + if (!response?.id || !response?.output) { + throw new Error('Invalid final response: missing required fields'); + } + if (!Array.isArray(response.output) || response.output.length === 0) { + throw new Error('Invalid final response: empty or invalid output'); + } + } + + /** + * Resolve async functions in the request for a given turn context. + * Extracts non-function fields and resolves any async parameter functions. + * + * @param context - The turn context for parameter resolution + * @returns The resolved request without async functions + */ + private async resolveRequestForContext(context: TurnContext): Promise { + if (hasAsyncFunctions(this.options.request)) { + return resolveAsyncFunctions(this.options.request, context); + } + // Already resolved, extract non-function fields + // Filter out stopWhen and state-related fields that aren't part of the API request + const { stopWhen: _, state: _s, requireApproval: _r, approveToolCalls: _a, rejectToolCalls: _rj, ...rest } = this.options.request; + return rest as ResolvedCallModelInput; + } + + /** + * Safely persist state with error handling. + * Wraps state save operations to ensure failures are properly reported. + * + * @param updates - Optional partial state updates to apply before saving + * @throws Error if state persistence fails + */ + private async saveStateSafely( + updates?: Partial, 'id' | 'createdAt' | 'updatedAt'>> + ): Promise { + if (!this.stateAccessor || !this.currentState) return; + + if (updates) { + this.currentState = updateState(this.currentState, updates); + } + + try { + await this.stateAccessor.save(this.currentState); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Failed to persist conversation state: ${message}`); + } + } + + /** + * Remove optional properties from state when they should be cleared. + * Uses delete to properly remove optional properties rather than setting undefined. + * + * @param props - Array of property names to remove from current state + */ + private clearOptionalStateProperties( + props: Array<'pendingToolCalls' | 'unsentToolResults' | 'interruptedBy' | 'partialResponse'> + ): void { + if (!this.currentState) return; + for (const prop of props) { + delete this.currentState[prop]; + } + } + + // ========================================================================= + // Core Methods + // ========================================================================= + /** * Initialize the stream if not already started * This is idempotent - multiple calls will return the same promise @@ -166,6 +666,35 @@ export class ModelResult { } this.initPromise = (async () => { + // Load or create state if accessor provided + if (this.stateAccessor) { + const loadedState = await this.stateAccessor.load(); + if (loadedState) { + this.currentState = loadedState; + + // Check if we're resuming from awaiting_approval with decisions + if (loadedState.status === 'awaiting_approval' && + (this.approvedToolCalls.length > 0 || this.rejectedToolCalls.length > 0)) { + this.isResumingFromApproval = true; + await this.processApprovalDecisions(); + return; // Skip normal initialization, we're resuming + } + + // Check for interruption flag and handle + if (loadedState.interruptedBy) { + // Clear interruption flag and continue from saved state + this.currentState = updateState(loadedState, { status: 'in_progress' }); + this.clearOptionalStateProperties(['interruptedBy']); + await this.saveStateSafely(); + } + } else { + this.currentState = createInitialState(); + } + + // Update status to in_progress + await this.saveStateSafely({ status: 'in_progress' }); + } + // Resolve async functions before initial request // Build initial turn context (turn 0 for initial request) const initialContext: TurnContext = { @@ -173,19 +702,25 @@ export class ModelResult { }; // Resolve any async functions first - let baseRequest: ResolvedCallModelInput; - if (hasAsyncFunctions(this.options.request)) { - baseRequest = await resolveAsyncFunctions( - this.options.request, - initialContext, - ); - } else { - // Already resolved, extract non-function fields - // Since request is CallModelInput, we need to filter out stopWhen - // Note: tools are already in API format at this point (converted in callModel()) - const { stopWhen: _, ...rest } = this.options.request; - // Cast to ResolvedCallModelInput - we know it's resolved if hasAsyncFunctions returned false - baseRequest = rest as ResolvedCallModelInput; + let baseRequest = await this.resolveRequestForContext(initialContext); + + // If we have state with existing messages, use those as input + if (this.currentState && this.currentState.messages && + Array.isArray(this.currentState.messages) && this.currentState.messages.length > 0) { + // Append new input to existing messages + const newInput = baseRequest.input; + if (newInput) { + const inputArray = Array.isArray(newInput) ? newInput : [newInput]; + baseRequest = { + ...baseRequest, + input: appendToMessages(this.currentState.messages, inputArray as models.OpenResponsesInput1[]), + }; + } else { + baseRequest = { + ...baseRequest, + input: this.currentState.messages, + }; + } } // Store resolved request with stream mode @@ -197,29 +732,172 @@ export class ModelResult { // Force stream mode for initial request const request = this.resolvedRequest; - // Create the stream promise - this.streamPromise = betaResponsesSend( + // Make the API request + const apiResult = await betaResponsesSend( this.options.client, request, this.options.options, - ).then((result) => { - if (!result.ok) { - throw result.error; - } - // When stream: true, the API returns EventStream - // TypeScript can't narrow the union type based on runtime parameter values, - // so we assert the type here based on our knowledge that stream=true - return result.value as EventStream; - }); + ); + + if (!apiResult.ok) { + throw apiResult.error; + } - // Wait for the stream and create the reusable stream - const eventStream = await this.streamPromise; - this.reusableStream = new ReusableReadableStream(eventStream); + // Handle both streaming and non-streaming responses + // The API may return a non-streaming response even when stream: true is requested + if (isEventStream(apiResult.value)) { + this.reusableStream = new ReusableReadableStream(apiResult.value); + } else if (this.isNonStreamingResponse(apiResult.value)) { + // API returned a complete response directly - use it as the final response + this.finalResponse = apiResult.value; + } else { + throw new Error('Unexpected response type from API'); + } })(); return this.initPromise; } + /** + * Process approval/rejection decisions and resume execution + */ + private async processApprovalDecisions(): Promise { + if (!this.currentState || !this.stateAccessor) { + throw new Error('Cannot process approval decisions without state'); + } + + const pendingCalls = this.currentState.pendingToolCalls ?? []; + const unsentResults = [...(this.currentState.unsentToolResults ?? [])]; + + // Build turn context - numberOfTurns represents the current turn (1-indexed after initial) + const turnContext: TurnContext = { + numberOfTurns: this.allToolExecutionRounds.length + 1, + }; + + // Process approvals - execute the approved tools + for (const callId of this.approvedToolCalls) { + const toolCall = pendingCalls.find(tc => tc.id === callId); + if (!toolCall) continue; + + const tool = this.options.tools?.find(t => t.function.name === toolCall.name); + if (!tool || !hasExecuteFunction(tool)) { + // Can't execute, create error result + unsentResults.push(createRejectedResult(callId, String(toolCall.name), 'Tool not found or not executable')); + continue; + } + + const result = await executeTool(tool, toolCall as ParsedToolCall, turnContext); + + if (result.error) { + unsentResults.push(createRejectedResult(callId, String(toolCall.name), result.error.message)); + } else { + unsentResults.push(createUnsentResult(callId, String(toolCall.name), result.result)); + } + } + + // Process rejections + for (const callId of this.rejectedToolCalls) { + const toolCall = pendingCalls.find(tc => tc.id === callId); + if (!toolCall) continue; + + unsentResults.push(createRejectedResult(callId, String(toolCall.name), 'Rejected by user')); + } + + // Remove processed calls from pending + const processedIds = new Set([...this.approvedToolCalls, ...this.rejectedToolCalls]); + const remainingPending = pendingCalls.filter(tc => !processedIds.has(tc.id)); + + // Update state - conditionally include optional properties only if they have values + const stateUpdates: Partial, 'id' | 'createdAt' | 'updatedAt'>> = { + status: remainingPending.length > 0 ? 'awaiting_approval' : 'in_progress', + }; + if (remainingPending.length > 0) { + stateUpdates.pendingToolCalls = remainingPending; + } + if (unsentResults.length > 0) { + stateUpdates.unsentToolResults = unsentResults as UnsentToolResult[]; + } + await this.saveStateSafely(stateUpdates); + + // Clear optional properties if they should be empty + const propsToClear: Array<'pendingToolCalls' | 'unsentToolResults'> = []; + if (remainingPending.length === 0) propsToClear.push('pendingToolCalls'); + if (unsentResults.length === 0) propsToClear.push('unsentToolResults'); + if (propsToClear.length > 0) { + this.clearOptionalStateProperties(propsToClear); + await this.saveStateSafely(); + } + + // If we still have pending approvals, stop here + if (remainingPending.length > 0) { + return; + } + + // Otherwise, continue with tool execution using unsent results + await this.continueWithUnsentResults(); + } + + /** + * Continue execution with unsent tool results + */ + private async continueWithUnsentResults(): Promise { + if (!this.currentState || !this.stateAccessor) return; + + const unsentResults = this.currentState.unsentToolResults ?? []; + if (unsentResults.length === 0) return; + + // Convert to API format + const toolOutputs = unsentResultsToAPIFormat(unsentResults); + + // Build new input with tool results + const currentMessages = this.currentState.messages; + const newInput = appendToMessages(currentMessages, toolOutputs); + + // Clear unsent results from state + this.currentState = updateState(this.currentState, { + messages: newInput, + }); + this.clearOptionalStateProperties(['unsentToolResults']); + await this.saveStateSafely(); + + // Build request with the updated input + // numberOfTurns represents the current turn number (1-indexed after initial) + const turnContext: TurnContext = { + numberOfTurns: this.allToolExecutionRounds.length + 1, + }; + + const baseRequest = await this.resolveRequestForContext(turnContext); + + // Create request with the accumulated messages + const request: models.OpenResponsesRequest = { + ...baseRequest, + input: newInput, + stream: true, + }; + + this.resolvedRequest = request; + + // Make the API request + const apiResult = await betaResponsesSend( + this.options.client, + request, + this.options.options, + ); + + if (!apiResult.ok) { + throw apiResult.error; + } + + // Handle both streaming and non-streaming responses + if (isEventStream(apiResult.value)) { + this.reusableStream = new ReusableReadableStream(apiResult.value); + } else if (this.isNonStreamingResponse(apiResult.value)) { + this.finalResponse = apiResult.value; + } else { + throw new Error('Unexpected response type from API'); + } + } + /** * Execute tools automatically if they are provided and have execute functions * This is idempotent - multiple calls will return the same promise @@ -232,144 +910,80 @@ export class ModelResult { this.toolExecutionPromise = (async () => { await this.initStream(); - if (!this.reusableStream) { - throw new Error('Stream not initialized'); + // If resuming from approval and still pending, don't continue + if (this.isResumingFromApproval && this.currentState?.status === 'awaiting_approval') { + return; } - // Note: Async functions already resolved in initStream() - // Get the initial response - const initialResponse = await consumeStreamForCompletion(this.reusableStream); + // Get initial response + let currentResponse = await this.getInitialResponse(); - // Check if we have tools and if auto-execution is enabled - const shouldAutoExecute = - this.options.tools && - this.options.tools.length > 0 && - initialResponse.output.some( - (item) => hasTypeProperty(item) && item.type === 'function_call', - ); + // Save initial response to state + await this.saveResponseToState(currentResponse); + + // Check if tools should be executed + const hasToolCalls = currentResponse.output.some( + (item) => hasTypeProperty(item) && item.type === 'function_call' + ); - if (!shouldAutoExecute) { - // No tools to execute, use initial response - this.finalResponse = initialResponse; + if (!this.options.tools?.length || !hasToolCalls) { + this.finalResponse = currentResponse; + await this.markStateComplete(); return; } - // Extract tool calls - const toolCalls = extractToolCallsFromResponse(initialResponse); + // Extract and check tool calls + const toolCalls = extractToolCallsFromResponse(currentResponse); - // Check if any have execute functions - const executableTools = toolCalls.filter((toolCall) => { - const tool = this.options.tools?.find((t) => t.function.name === toolCall.name); - return tool && hasExecuteFunction(tool); - }); + // Check for approval requirements + if (await this.handleApprovalCheck(toolCalls, 0, currentResponse)) { + return; // Paused for approval + } - if (executableTools.length === 0) { - // No executable tools, use initial response - this.finalResponse = initialResponse; + if (!this.hasExecutableToolCalls(toolCalls)) { + this.finalResponse = currentResponse; + await this.markStateComplete(); return; } - let currentResponse = initialResponse; + // Main execution loop let currentRound = 0; while (true) { - // Check stopWhen conditions - if (this.options.stopWhen) { - const stopConditions = Array.isArray(this.options.stopWhen) - ? this.options.stopWhen - : [this.options.stopWhen]; - - const shouldStop = await isStopConditionMet({ - stopConditions, - steps: this.allToolExecutionRounds.map((round) => ({ - stepType: 'continue' as const, - text: extractTextFromResponse(round.response), - toolCalls: round.toolCalls, - toolResults: round.toolResults.map((tr) => ({ - toolCallId: tr.callId, - toolName: round.toolCalls.find((tc) => tc.id === tr.callId)?.name ?? '', - result: JSON.parse(tr.output), - })), - response: round.response, - usage: round.response.usage, - finishReason: undefined, // OpenResponsesNonStreamingResponse doesn't have finishReason - })), - }); - - if (shouldStop) { - break; - } + // Check for external interruption + if (await this.checkForInterruption(currentResponse)) { + return; } - const currentToolCalls = extractToolCallsFromResponse(currentResponse); + // Check stop conditions + if (await this.shouldStopExecution()) { + break; + } + const currentToolCalls = extractToolCallsFromResponse(currentResponse); if (currentToolCalls.length === 0) { break; } - const hasExecutable = currentToolCalls.some((toolCall) => { - const tool = this.options.tools?.find((t) => t.function.name === toolCall.name); - return tool && hasExecuteFunction(tool); - }); + // Check for approval requirements + if (await this.handleApprovalCheck(currentToolCalls, currentRound + 1, currentResponse)) { + return; + } - if (!hasExecutable) { + if (!this.hasExecutableToolCalls(currentToolCalls)) { break; } - // Build turn context for this round (for async parameter resolution only) - const turnContext: TurnContext = { - numberOfTurns: currentRound + 1, // 1-indexed - }; + // Build turn context + const turnContext: TurnContext = { numberOfTurns: currentRound + 1 }; // Resolve async functions for this turn - if (hasAsyncFunctions(this.options.request)) { - const resolved = await resolveAsyncFunctions( - this.options.request, - turnContext, - ); - // Update resolved request with new values - this.resolvedRequest = { - ...resolved, - stream: false, // Tool execution turns don't need streaming - }; - } + await this.resolveAsyncFunctionsForTurn(turnContext); - // Execute all tool calls - const toolResults: Array = []; + // Execute tools + const toolResults = await this.executeToolRound(currentToolCalls, turnContext); - for (const toolCall of currentToolCalls) { - const tool = this.options.tools?.find((t) => t.function.name === toolCall.name); - - if (!tool || !hasExecuteFunction(tool)) { - continue; - } - - // Create callback for real-time preliminary results - const onPreliminaryResult = this.toolEventBroadcaster - ? (callId: string, resultValue: unknown) => { - this.toolEventBroadcaster?.push({ - type: 'preliminary_result' as const, - toolCallId: callId, - result: resultValue as InferToolEventsUnion, - }); - } - : undefined; - - const result = await executeTool(tool, toolCall, turnContext, onPreliminaryResult); - - toolResults.push({ - type: 'function_call_output' as const, - id: `output_${toolCall.id}`, - callId: toolCall.id, - output: result.error - ? JSON.stringify({ - error: result.error.message, - }) - : JSON.stringify(result.result), - }); - } - - // Store execution round info including tool results + // Track execution round this.allToolExecutionRounds.push({ round: currentRound, toolCalls: currentToolCalls, @@ -377,85 +991,25 @@ export class ModelResult { toolResults, }); - // Execute nextTurnParams functions for tools that were called - if (this.options.tools && currentToolCalls.length > 0) { - if (!this.resolvedRequest) { - throw new Error('Request not initialized'); - } - - const computedParams = await executeNextTurnParamsFunctions( - currentToolCalls, - this.options.tools, - this.resolvedRequest - ); - - // Apply computed parameters to the resolved request for next turn - if (Object.keys(computedParams).length > 0) { - this.resolvedRequest = applyNextTurnParamsToRequest( - this.resolvedRequest, - computedParams - ); - } - } - - // Build new input with tool results - // For the Responses API, we need to include the tool results in the input - const newInput: models.OpenResponsesInput = [ - ...(Array.isArray(currentResponse.output) - ? currentResponse.output - : [ - currentResponse.output, - ]), - ...toolResults, - ]; - - // Make new request with tool results - if (!this.resolvedRequest) { - throw new Error('Request not initialized'); - } - - const newRequest: models.OpenResponsesRequest = { - ...this.resolvedRequest, - input: newInput, - stream: false, - }; + // Save tool results to state + await this.saveToolResultsToState(toolResults); - const newResult = await betaResponsesSend( - this.options.client, - newRequest, - this.options.options, - ); + // Apply nextTurnParams + await this.applyNextTurnParams(currentToolCalls); - if (!newResult.ok) { - throw newResult.error; - } + // Make follow-up request + currentResponse = await this.makeFollowupRequest(currentResponse, toolResults); - // Handle the result - it might be a stream or a response - const value = newResult.value; - if (isEventStream(value)) { - // It's a stream, consume it - const stream = new ReusableReadableStream(value); - currentResponse = await consumeStreamForCompletion(stream); - } else if (this.isNonStreamingResponse(value)) { - currentResponse = value; - } else { - throw new Error('Unexpected response type from API'); - } + // Save new response to state + await this.saveResponseToState(currentResponse); currentRound++; } - // Validate the final response has required fields - if (!currentResponse || !currentResponse.id || !currentResponse.output) { - throw new Error('Invalid final response: missing required fields'); - } - - // Ensure the response is in a completed state (has output content) - if (!Array.isArray(currentResponse.output) || currentResponse.output.length === 0) { - throw new Error('Invalid final response: empty or invalid output'); - } - + // Validate and finalize + this.validateFinalResponse(currentResponse); this.finalResponse = currentResponse; + await this.markStateComplete(); })(); return this.toolExecutionPromise; @@ -696,4 +1250,60 @@ export class ModelResult { await this.reusableStream.cancel(); } } + + // ========================================================================= + // Multi-Turn Conversation State Methods + // ========================================================================= + + /** + * Check if the conversation requires human approval to continue. + * Returns true if there are pending tool calls awaiting approval. + */ + async requiresApproval(): Promise { + await this.initStream(); + + // If we have pending tool calls in state, approval is required + if (this.currentState?.status === 'awaiting_approval') { + return true; + } + + // Also check if pendingToolCalls is populated + return (this.currentState?.pendingToolCalls?.length ?? 0) > 0; + } + + /** + * Get the pending tool calls that require approval. + * Returns empty array if no approvals needed. + */ + async getPendingToolCalls(): Promise[]> { + await this.initStream(); + + // Try to trigger tool execution to populate pending calls + if (!this.isResumingFromApproval) { + await this.executeToolsIfNeeded(); + } + + return (this.currentState?.pendingToolCalls ?? []) as ParsedToolCall[]; + } + + /** + * Get the current conversation state. + * Useful for inspection, debugging, or custom persistence. + * Note: This returns the raw ConversationState for inspection only. + * To resume a conversation, use the StateAccessor pattern. + */ + async getState(): Promise> { + await this.initStream(); + + // Ensure tool execution has been attempted (to populate final state) + if (!this.isResumingFromApproval) { + await this.executeToolsIfNeeded(); + } + + if (!this.currentState) { + throw new Error('State not initialized. Make sure a StateAccessor was provided to callModel.'); + } + + return this.currentState; + } } diff --git a/src/lib/tool-types.ts b/src/lib/tool-types.ts index 7998a20..8a91d31 100644 --- a/src/lib/tool-types.ts +++ b/src/lib/tool-types.ts @@ -58,6 +58,16 @@ export type NextTurnParamsFunctions = { ) => NextTurnParamsContext[K] | Promise; }; +/** + * Tool-level approval check function type + * Receives the tool's input params and turn context + * Returns true if approval is required, false otherwise + */ +export type ToolApprovalCheck = ( + params: TInput, + context: TurnContext +) => boolean | Promise; + /** * Base tool function interface with inputSchema */ @@ -66,6 +76,11 @@ export interface BaseToolFunction> { description?: string; inputSchema: TInput; nextTurnParams?: NextTurnParamsFunctions>; + /** + * Whether this tool requires human approval before execution + * Can be a boolean or an async function that receives the tool's input params and context + */ + requireApproval?: boolean | ToolApprovalCheck>; } /** @@ -420,3 +435,126 @@ export type ChatStreamEvent = type: string; event: OpenResponsesStreamEvent; }; // Pass-through for other events + +// ============================================================================= +// Multi-Turn Conversation State Types +// ============================================================================= + +/** + * Result of a tool execution that hasn't been sent to the model yet + * Used for interrupted or awaiting approval states + * @template TTools - The tools array type for proper type inference + */ +export interface UnsentToolResult { + /** The ID of the tool call this result is for */ + callId: string; + /** The name of the tool that was executed */ + name: TTools[number]['function']['name']; + /** The output of the tool execution */ + output: unknown; + /** Error message if the tool call was rejected or failed */ + error?: string; +} + +/** + * Partial response captured during interruption + * @template TTools - The tools array type for proper type inference + */ +export interface PartialResponse { + /** Partial text response accumulated before interruption */ + text?: string; + /** Tool calls that were in progress when interrupted */ + toolCalls?: Array>; +} + +/** + * Status of a conversation state + */ +export type ConversationStatus = + | 'complete' + | 'interrupted' + | 'awaiting_approval' + | 'in_progress'; + +/** + * State for multi-turn conversations with persistence and approval gates + * @template TTools - The tools array type for proper type inference + */ +export interface ConversationState { + /** Unique identifier for this conversation */ + id: string; + /** Full message history */ + messages: models.OpenResponsesInput; + /** Previous response ID for chaining (OpenRouter server-side optimization) */ + previousResponseId?: string; + /** Tool calls awaiting human approval */ + pendingToolCalls?: Array>; + /** Tool results executed but not yet sent to the model */ + unsentToolResults?: Array>; + /** Partial response data captured during interruption */ + partialResponse?: PartialResponse; + /** Signal from a new request to interrupt this conversation */ + interruptedBy?: string; + /** Current status of the conversation */ + status: ConversationStatus; + /** Creation timestamp (Unix ms) */ + createdAt: number; + /** Last update timestamp (Unix ms) */ + updatedAt: number; +} + +/** + * State accessor for loading and saving conversation state + * Enables any storage backend (memory, Redis, database, etc.) + * @template TTools - The tools array type for proper type inference + */ +export interface StateAccessor { + /** Load the current conversation state, or null if none exists */ + load: () => Promise | null>; + /** Save the conversation state */ + save: (state: ConversationState) => Promise; +} + +// ============================================================================= +// Approval Detection Helper Types +// ============================================================================= + +/** + * Check if a single tool has approval configured (non-false, non-undefined) + * Returns true if the tool definitely requires approval, + * false if it definitely doesn't, or boolean if it's uncertain + */ +export type ToolHasApproval = + T extends { function: { requireApproval: true | ToolApprovalCheck } } + ? true + : T extends { function: { requireApproval: false } } + ? false + : T extends { function: { requireApproval: undefined } } + ? false + : boolean; // Could be either (optional property) + +/** + * Check if ANY tool in an array has approval configured + * Returns true if at least one tool might require approval + */ +export type HasApprovalTools = + TTools extends readonly [infer First extends Tool, ...infer Rest extends Tool[]] + ? ToolHasApproval extends true + ? true + : HasApprovalTools + : false; + +/** + * Type guard to check if a tool has approval configured at runtime + */ +export function toolHasApprovalConfigured(tool: Tool): boolean { + const requireApproval = tool.function.requireApproval; + return requireApproval === true || typeof requireApproval === 'function'; +} + +/** + * Type guard to check if any tools in array have approval configured at runtime + */ +export function hasApprovalRequiredTools(tools: readonly Tool[]): boolean { + return tools.some(toolHasApprovalConfigured); +} diff --git a/src/lib/tool.ts b/src/lib/tool.ts index f6ba6ba..eacedf8 100644 --- a/src/lib/tool.ts +++ b/src/lib/tool.ts @@ -6,6 +6,7 @@ import { type ToolWithGenerator, type ManualTool, type NextTurnParamsFunctions, + type ToolApprovalCheck, } from "./tool-types.js"; /** @@ -21,6 +22,11 @@ type RegularToolConfigWithOutput< outputSchema: TOutput; eventSchema?: undefined; nextTurnParams?: NextTurnParamsFunctions>; + /** + * Whether this tool requires human approval before execution + * Can be a boolean or an async function that receives the tool's input params and context + */ + requireApproval?: boolean | ToolApprovalCheck>; execute: ( params: z.infer, context?: TurnContext @@ -40,6 +46,11 @@ type RegularToolConfigWithoutOutput< outputSchema?: undefined; eventSchema?: undefined; nextTurnParams?: NextTurnParamsFunctions>; + /** + * Whether this tool requires human approval before execution + * Can be a boolean or an async function that receives the tool's input params and context + */ + requireApproval?: boolean | ToolApprovalCheck>; execute: ( params: z.infer, context?: TurnContext @@ -60,6 +71,11 @@ type GeneratorToolConfig< eventSchema: TEvent; outputSchema: TOutput; nextTurnParams?: NextTurnParamsFunctions>; + /** + * Whether this tool requires human approval before execution + * Can be a boolean or an async function that receives the tool's input params and context + */ + requireApproval?: boolean | ToolApprovalCheck>; execute: ( params: z.infer, context?: TurnContext @@ -74,6 +90,11 @@ type ManualToolConfig> = { description?: string; inputSchema: TInput; nextTurnParams?: NextTurnParamsFunctions>; + /** + * Whether this tool requires human approval before execution + * Can be a boolean or an async function that receives the tool's input params and context + */ + requireApproval?: boolean | ToolApprovalCheck>; execute: false; }; @@ -215,6 +236,10 @@ export function tool< fn.nextTurnParams = config.nextTurnParams; } + if (config.requireApproval !== undefined) { + fn.requireApproval = config.requireApproval; + } + return { type: ToolType.Function, function: fn, @@ -240,6 +265,10 @@ export function tool< fn.nextTurnParams = config.nextTurnParams; } + if (config.requireApproval !== undefined) { + fn.requireApproval = config.requireApproval; + } + return { type: ToolType.Function, function: fn, @@ -256,6 +285,7 @@ export function tool< ...(config.description !== undefined && { description: config.description }), ...(config.outputSchema !== undefined && { outputSchema: config.outputSchema }), ...(config.nextTurnParams !== undefined && { nextTurnParams: config.nextTurnParams }), + ...(config.requireApproval !== undefined && { requireApproval: config.requireApproval }), }; // The function signature guarantees this is type-safe via overloads diff --git a/tests/e2e/call-model-state.test.ts b/tests/e2e/call-model-state.test.ts new file mode 100644 index 0000000..33923ae --- /dev/null +++ b/tests/e2e/call-model-state.test.ts @@ -0,0 +1,221 @@ +import * as dotenv from 'dotenv'; +import { beforeAll, describe, expect, it } from 'vitest'; +import { z } from 'zod/v4'; +import { + OpenRouter, + tool, + createInitialState, + stepCountIs, + type ConversationState, + type StateAccessor, +} from '../../src/index.js'; + +dotenv.config(); + +describe('State Management Integration', () => { + let client: OpenRouter; + + beforeAll(() => { + const apiKey = process.env.OPENROUTER_API_KEY; + if (!apiKey) { + throw new Error('OPENROUTER_API_KEY environment variable is required'); + } + client = new OpenRouter({ apiKey }); + }); + + describe('In-Memory StateAccessor', () => { + it('should persist state across tool execution', async () => { + // In-memory store + let storedState: ConversationState | null = null; + + const stateAccessor: StateAccessor = { + load: async () => storedState, + save: async (state) => { storedState = state; }, + }; + + const echoTool = tool({ + name: 'echo', + description: 'Echo back the input', + inputSchema: z.object({ message: z.string() }), + execute: async (params) => ({ echoed: params.message }), + }); + + const result = await client.callModel({ + model: 'openai/gpt-4o-mini', + input: [{ role: 'user', content: 'Say hello using the echo tool with message "test"' }], + tools: [echoTool], + state: stateAccessor, + stopWhen: stepCountIs(3), + }); + + await result.getText(); + + // State should be saved + expect(storedState).not.toBeNull(); + expect(storedState?.id).toMatch(/^conv_/); + }, 30000); + + it('should track conversation status', async () => { + let storedState: ConversationState | null = null; + const stateHistory: string[] = []; + + const stateAccessor: StateAccessor = { + load: async () => storedState, + save: async (state) => { + storedState = state; + stateHistory.push(state.status); + }, + }; + + const simpleTool = tool({ + name: 'get_time', + description: 'Get current time', + inputSchema: z.object({}), + execute: async () => ({ time: new Date().toISOString() }), + }); + + const result = await client.callModel({ + model: 'openai/gpt-4o-mini', + input: [{ role: 'user', content: 'What time is it? Use the get_time tool.' }], + tools: [simpleTool], + state: stateAccessor, + stopWhen: stepCountIs(3), + }); + + await result.getText(); + + // Should have tracked status changes + expect(stateHistory.length).toBeGreaterThan(0); + // Final state should be complete or in_progress + expect(['complete', 'in_progress']).toContain(storedState?.status); + }, 30000); + }); + + describe('Approval Workflow', () => { + it('should identify tools requiring approval', async () => { + let storedState: ConversationState | null = null; + + const stateAccessor: StateAccessor = { + load: async () => storedState, + save: async (state) => { storedState = state; }, + }; + + const dangerousTool = tool({ + name: 'delete_file', + description: 'Delete a file (requires approval)', + inputSchema: z.object({ path: z.string() }), + requireApproval: true, + execute: async (params) => ({ deleted: params.path }), + }); + + const result = await client.callModel({ + model: 'openai/gpt-4o-mini', + input: [{ role: 'user', content: 'Delete the file at /tmp/test.txt' }], + tools: [dangerousTool], + state: stateAccessor, + }); + + // Get the response to trigger tool execution + await result.getResponse(); + + // Check if approval is required + const requiresApproval = await result.requiresApproval(); + const pendingCalls = await result.getPendingToolCalls(); + + // The test validates that state management works with approval tools + // If model called the tool, approval should be required + // If model didn't call the tool, that's also valid behavior + expect(storedState).not.toBeNull(); + + if (pendingCalls.length > 0) { + expect(requiresApproval).toBe(true); + expect(storedState?.status).toBe('awaiting_approval'); + expect(pendingCalls[0]?.name).toBe('delete_file'); + } else { + // Model chose not to call the tool - state should still be tracked + expect(['complete', 'in_progress']).toContain(storedState?.status); + } + }, 30000); + + it('should use custom requireApproval function', async () => { + let storedState: ConversationState | null = null; + const checkedTools: string[] = []; + + const stateAccessor: StateAccessor = { + load: async () => storedState, + save: async (state) => { storedState = state; }, + }; + + const safeTool = tool({ + name: 'safe_action', + description: 'A safe action', + inputSchema: z.object({ action: z.string() }), + execute: async (params) => ({ result: params.action }), + }); + + // Custom function that requires approval for all tools + // Accepts toolCall and context (TurnContext) as per the updated signature + const customRequireApproval = (toolCall: { name: string }, _context: { numberOfTurns: number }) => { + checkedTools.push(toolCall.name); + return true; // Always require approval + }; + + const result = await client.callModel({ + model: 'openai/gpt-4o-mini', + input: [{ role: 'user', content: 'Perform safe_action with action "test"' }], + tools: [safeTool], + state: stateAccessor, + requireApproval: customRequireApproval, + }); + + const pendingCalls = await result.getPendingToolCalls(); + + // If the model called the tool, custom function should have been invoked + if (pendingCalls.length > 0) { + expect(checkedTools).toContain('safe_action'); + expect(await result.requiresApproval()).toBe(true); + } + }, 30000); + }); + + describe('Tool Execution with State', () => { + it('should accumulate messages in state during multi-turn', async () => { + let storedState: ConversationState | null = null; + + const stateAccessor: StateAccessor = { + load: async () => storedState, + save: async (state) => { storedState = state; }, + }; + + const mathTool = tool({ + name: 'add', + description: 'Add two numbers', + inputSchema: z.object({ + a: z.number(), + b: z.number(), + }), + execute: async (params) => ({ result: params.a + params.b }), + }); + + const result = await client.callModel({ + model: 'openai/gpt-4o-mini', + input: [{ role: 'user', content: 'What is 5 + 3? Use the add tool.' }], + tools: [mathTool], + state: stateAccessor, + stopWhen: stepCountIs(3), + }); + + await result.getText(); + + // State should contain messages from the conversation + expect(storedState).not.toBeNull(); + if (storedState?.messages) { + // Should have at least the initial user message + const messages = Array.isArray(storedState.messages) + ? storedState.messages + : [storedState.messages]; + expect(messages.length).toBeGreaterThan(0); + } + }, 30000); + }); +}); diff --git a/tests/unit/conversation-state.test.ts b/tests/unit/conversation-state.test.ts new file mode 100644 index 0000000..9544ce0 --- /dev/null +++ b/tests/unit/conversation-state.test.ts @@ -0,0 +1,407 @@ +import { describe, expect, it } from 'vitest'; +import { z } from 'zod/v4'; +import { + createInitialState, + updateState, + generateConversationId, + createUnsentResult, + createRejectedResult, + partitionToolCalls, + toolRequiresApproval, + appendToMessages, +} from '../../src/lib/conversation-state.js'; +import { tool } from '../../src/lib/tool.js'; +import { + toolHasApprovalConfigured, + hasApprovalRequiredTools, +} from '../../src/lib/tool-types.js'; + +describe('Conversation State Utilities', () => { + describe('generateConversationId', () => { + it('should generate unique IDs with conv_ prefix', () => { + const id1 = generateConversationId(); + const id2 = generateConversationId(); + + expect(id1).toMatch(/^conv_/); + expect(id2).toMatch(/^conv_/); + expect(id1).not.toBe(id2); + }); + }); + + describe('createInitialState', () => { + it('should create state with default values', () => { + const state = createInitialState(); + + expect(state.id).toMatch(/^conv_/); + expect(state.messages).toEqual([]); + expect(state.status).toBe('in_progress'); + expect(state.createdAt).toBeDefined(); + expect(state.updatedAt).toBeDefined(); + }); + + it('should use custom ID when provided', () => { + const state = createInitialState('custom-id'); + expect(state.id).toBe('custom-id'); + }); + }); + + describe('updateState', () => { + it('should update status and timestamp', () => { + const initial = createInitialState(); + const originalUpdatedAt = initial.updatedAt; + + const updated = updateState(initial, { status: 'complete' }); + + expect(updated.status).toBe('complete'); + expect(updated.updatedAt).toBeGreaterThanOrEqual(originalUpdatedAt); + expect(updated.id).toBe(initial.id); // ID unchanged + }); + + it('should preserve fields not being updated', () => { + const initial = createInitialState('test-id'); + const updated = updateState(initial, { status: 'awaiting_approval' }); + + expect(updated.id).toBe('test-id'); + expect(updated.messages).toEqual([]); + expect(updated.createdAt).toBe(initial.createdAt); + }); + }); + + describe('createUnsentResult', () => { + it('should create valid unsent result', () => { + const result = createUnsentResult('call-1', 'test_tool', { data: 'test' }); + + expect(result.callId).toBe('call-1'); + expect(result.name).toBe('test_tool'); + expect(result.output).toEqual({ data: 'test' }); + expect(result.error).toBeUndefined(); + }); + + it('should handle null output', () => { + const result = createUnsentResult('call-1', 'test_tool', null); + + expect(result.callId).toBe('call-1'); + expect(result.output).toBeNull(); + }); + }); + + describe('createRejectedResult', () => { + it('should create rejected result with default message', () => { + const result = createRejectedResult('call-1', 'test_tool'); + + expect(result.callId).toBe('call-1'); + expect(result.output).toBeNull(); + expect(result.error).toBe('Tool call rejected by user'); + }); + + it('should use custom rejection reason', () => { + const result = createRejectedResult('call-1', 'test_tool', 'Not allowed'); + expect(result.error).toBe('Not allowed'); + }); + }); + + describe('toolRequiresApproval', () => { + const toolWithApproval = tool({ + name: 'dangerous_action', + inputSchema: z.object({}), + requireApproval: true, + execute: async () => ({}), + }); + + const toolWithoutApproval = tool({ + name: 'safe_action', + inputSchema: z.object({}), + execute: async () => ({}), + }); + + const context = { numberOfTurns: 1 }; + + it('should return true for tools with requireApproval', async () => { + const toolCall = { id: '1', name: 'dangerous_action', arguments: {} }; + expect(await toolRequiresApproval(toolCall, [toolWithApproval, toolWithoutApproval], context)).toBe(true); + }); + + it('should return false for tools without requireApproval', async () => { + const toolCall = { id: '1', name: 'safe_action', arguments: {} }; + expect(await toolRequiresApproval(toolCall, [toolWithApproval, toolWithoutApproval], context)).toBe(false); + }); + + it('should return false for unknown tools', async () => { + const toolCall = { id: '1', name: 'unknown_tool', arguments: {} }; + expect(await toolRequiresApproval(toolCall, [toolWithApproval, toolWithoutApproval], context)).toBe(false); + }); + + it('should use call-level check when provided', async () => { + const toolCall = { id: '1', name: 'safe_action', arguments: {} }; + const alwaysRequire = () => true; + + expect(await toolRequiresApproval(toolCall, [toolWithoutApproval], context, alwaysRequire)).toBe(true); + }); + + it('should call-level check can override tool-level approval', async () => { + const toolCall = { id: '1', name: 'dangerous_action', arguments: {} }; + const neverRequire = () => false; + + // Call-level check takes precedence + expect(await toolRequiresApproval(toolCall, [toolWithApproval], context, neverRequire)).toBe(false); + }); + + it('should support async call-level check', async () => { + const toolCall = { id: '1', name: 'safe_action', arguments: {} }; + const asyncCheck = async (_tc: unknown, ctx: { numberOfTurns: number }) => { + // Simulate async operation + await Promise.resolve(); + return ctx.numberOfTurns > 0; + }; + + expect(await toolRequiresApproval(toolCall, [toolWithoutApproval], context, asyncCheck)).toBe(true); + expect(await toolRequiresApproval(toolCall, [toolWithoutApproval], { numberOfTurns: 0 }, asyncCheck)).toBe(false); + }); + + it('should support function-based tool-level requireApproval', async () => { + // Tool with function-based approval that checks params + const toolWithFunctionApproval = tool({ + name: 'conditional_action', + inputSchema: z.object({ dangerous: z.boolean() }), + requireApproval: (params) => params.dangerous === true, + execute: async () => ({}), + }); + + // Safe action - should not require approval + const safeCall = { id: '1', name: 'conditional_action', arguments: { dangerous: false } }; + expect(await toolRequiresApproval(safeCall, [toolWithFunctionApproval], context)).toBe(false); + + // Dangerous action - should require approval + const dangerousCall = { id: '2', name: 'conditional_action', arguments: { dangerous: true } }; + expect(await toolRequiresApproval(dangerousCall, [toolWithFunctionApproval], context)).toBe(true); + }); + + it('should support async function-based tool-level requireApproval', async () => { + // Tool with async function-based approval + const toolWithAsyncApproval = tool({ + name: 'async_conditional', + inputSchema: z.object({ value: z.number() }), + requireApproval: async (params, ctx) => { + // Simulate async operation + await Promise.resolve(); + // Require approval if value > 100 OR after first turn + return params.value > 100 || ctx.numberOfTurns > 1; + }, + execute: async () => ({}), + }); + + // Low value, first turn - no approval needed + const lowValueCall = { id: '1', name: 'async_conditional', arguments: { value: 50 } }; + expect(await toolRequiresApproval(lowValueCall, [toolWithAsyncApproval], { numberOfTurns: 1 })).toBe(false); + + // High value - approval needed + const highValueCall = { id: '2', name: 'async_conditional', arguments: { value: 150 } }; + expect(await toolRequiresApproval(highValueCall, [toolWithAsyncApproval], { numberOfTurns: 1 })).toBe(true); + + // Low value but second turn - approval needed + expect(await toolRequiresApproval(lowValueCall, [toolWithAsyncApproval], { numberOfTurns: 2 })).toBe(true); + }); + + it('should pass context to function-based tool-level approval', async () => { + const receivedContexts: Array<{ numberOfTurns: number }> = []; + + const toolWithContextCheck = tool({ + name: 'context_checker', + inputSchema: z.object({}), + requireApproval: (_params, ctx) => { + receivedContexts.push(ctx); + return ctx.numberOfTurns > 2; + }, + execute: async () => ({}), + }); + + const toolCall = { id: '1', name: 'context_checker', arguments: {} }; + + await toolRequiresApproval(toolCall, [toolWithContextCheck], { numberOfTurns: 1 }); + await toolRequiresApproval(toolCall, [toolWithContextCheck], { numberOfTurns: 3 }); + + expect(receivedContexts).toEqual([ + { numberOfTurns: 1 }, + { numberOfTurns: 3 }, + ]); + }); + }); + + describe('partitionToolCalls', () => { + const approvalTool = tool({ + name: 'needs_approval', + inputSchema: z.object({}), + requireApproval: true, + execute: async () => ({}), + }); + + const autoTool = tool({ + name: 'auto_execute', + inputSchema: z.object({}), + execute: async () => ({}), + }); + + const context = { numberOfTurns: 1 }; + + it('should partition tool calls correctly', async () => { + const toolCalls = [ + { id: '1', name: 'needs_approval', arguments: {} }, + { id: '2', name: 'auto_execute', arguments: {} }, + ]; + + const { requiresApproval, autoExecute } = await partitionToolCalls( + toolCalls, + [approvalTool, autoTool], + context + ); + + expect(requiresApproval).toHaveLength(1); + expect(requiresApproval[0]?.name).toBe('needs_approval'); + expect(autoExecute).toHaveLength(1); + expect(autoExecute[0]?.name).toBe('auto_execute'); + }); + + it('should handle all tools requiring approval', async () => { + const toolCalls = [ + { id: '1', name: 'needs_approval', arguments: {} }, + ]; + + const { requiresApproval, autoExecute } = await partitionToolCalls( + toolCalls, + [approvalTool, autoTool], + context + ); + + expect(requiresApproval).toHaveLength(1); + expect(autoExecute).toHaveLength(0); + }); + + it('should handle all tools auto-executing', async () => { + const toolCalls = [ + { id: '1', name: 'auto_execute', arguments: {} }, + ]; + + const { requiresApproval, autoExecute } = await partitionToolCalls( + toolCalls, + [approvalTool, autoTool], + context + ); + + expect(requiresApproval).toHaveLength(0); + expect(autoExecute).toHaveLength(1); + }); + + it('should handle empty tool calls', async () => { + const { requiresApproval, autoExecute } = await partitionToolCalls( + [], + [approvalTool, autoTool], + context + ); + + expect(requiresApproval).toHaveLength(0); + expect(autoExecute).toHaveLength(0); + }); + }); + + describe('appendToMessages', () => { + it('should append to empty messages', () => { + const result = appendToMessages([], [ + { role: 'user' as const, content: 'Hello' }, + ]); + + expect(result).toHaveLength(1); + }); + + it('should append to existing messages', () => { + const existing = [ + { role: 'user' as const, content: 'Hello' }, + ]; + const result = appendToMessages(existing, [ + { role: 'assistant' as const, content: 'Hi there!' }, + ]); + + expect(result).toHaveLength(2); + }); + + it('should handle string input', () => { + const result = appendToMessages('What is 2+2?', [ + { role: 'assistant' as const, content: '4' }, + ]); + + expect(result).toHaveLength(2); + }); + }); + + describe('Approval Detection Type Guards', () => { + const toolWithBooleanApproval = tool({ + name: 'needs_approval', + inputSchema: z.object({}), + requireApproval: true, + execute: async () => ({}), + }); + + const toolWithFunctionApproval = tool({ + name: 'conditional_approval', + inputSchema: z.object({ dangerous: z.boolean() }), + requireApproval: (params) => params.dangerous, + execute: async () => ({}), + }); + + const toolWithoutApproval = tool({ + name: 'safe_tool', + inputSchema: z.object({}), + execute: async () => ({}), + }); + + const toolWithFalseApproval = tool({ + name: 'explicitly_safe', + inputSchema: z.object({}), + requireApproval: false, + execute: async () => ({}), + }); + + describe('toolHasApprovalConfigured', () => { + it('should return true for tools with requireApproval: true', () => { + expect(toolHasApprovalConfigured(toolWithBooleanApproval)).toBe(true); + }); + + it('should return true for tools with requireApproval function', () => { + expect(toolHasApprovalConfigured(toolWithFunctionApproval)).toBe(true); + }); + + it('should return false for tools without requireApproval', () => { + expect(toolHasApprovalConfigured(toolWithoutApproval)).toBe(false); + }); + + it('should return false for tools with requireApproval: false', () => { + expect(toolHasApprovalConfigured(toolWithFalseApproval)).toBe(false); + }); + }); + + describe('hasApprovalRequiredTools', () => { + it('should return true if any tool has approval configured', () => { + expect(hasApprovalRequiredTools([ + toolWithoutApproval, + toolWithBooleanApproval, + ])).toBe(true); + }); + + it('should return true for function-based approval', () => { + expect(hasApprovalRequiredTools([ + toolWithFunctionApproval, + ])).toBe(true); + }); + + it('should return false if no tools have approval configured', () => { + expect(hasApprovalRequiredTools([ + toolWithoutApproval, + toolWithFalseApproval, + ])).toBe(false); + }); + + it('should return false for empty array', () => { + expect(hasApprovalRequiredTools([])).toBe(false); + }); + }); + }); +});