Skip to content

Commit 2c1d288

Browse files
committed
feat: add multi-turn conversation state management with type safety improvements
- Add StateAccessor interface for pluggable state persistence - Add ConversationState type with status tracking (complete, interrupted, awaiting_approval, in_progress) - Add approval workflow support with requireApproval tool option and partitionToolCalls helper - Add type guards (isValidUnsentToolResult, isValidParsedToolCall) to replace unsafe type assertions - Make resolveAsyncFunctions generic over TTools to eliminate double-casting - Fix isEventStream to check constructor name on prototype chain - Handle both streaming and non-streaming API responses gracefully - Add conversation-state.ts with helper functions for state management - Add unit tests for conversation state utilities (21 tests) - Add e2e tests for state management integration (5 tests)
1 parent a96ad34 commit 2c1d288

File tree

11 files changed

+1359
-46
lines changed

11 files changed

+1359
-46
lines changed

src/funcs/call-model.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,16 @@ export function callModel<TTools extends readonly Tool[]>(
124124
request: CallModelInput<TTools>,
125125
options?: RequestOptions,
126126
): ModelResult<TTools> {
127-
const { tools, stopWhen, ...apiRequest } = request;
127+
// Destructure state management options along with tools and stopWhen
128+
const {
129+
tools,
130+
stopWhen,
131+
state,
132+
requireApproval,
133+
approveToolCalls,
134+
rejectToolCalls,
135+
...apiRequest
136+
} = request;
128137

129138
// Convert tools to API format - no cast needed now that convertToolsToAPIFormat accepts readonly
130139
const apiTools = tools ? convertToolsToAPIFormat(tools) : undefined;
@@ -146,8 +155,11 @@ export function callModel<TTools extends readonly Tool[]>(
146155
options: options ?? {},
147156
// Preserve the exact TTools type instead of widening to Tool[]
148157
tools: tools as TTools | undefined,
149-
...(stopWhen !== undefined && {
150-
stopWhen,
151-
}),
158+
...(stopWhen !== undefined && { stopWhen }),
159+
// Pass state management options
160+
...(state !== undefined && { state }),
161+
...(requireApproval !== undefined && { requireApproval }),
162+
...(approveToolCalls !== undefined && { approveToolCalls }),
163+
...(rejectToolCalls !== undefined && { rejectToolCalls }),
152164
} as GetResponseOptions<TTools>);
153165
}

src/index.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ export type { Fetcher, HTTPClientOptions } from './lib/http.js';
1212
// Tool types
1313
export type {
1414
ChatStreamEvent,
15+
ConversationState,
16+
ConversationStatus,
1517
ResponseStreamEvent as EnhancedResponseStreamEvent,
1618
InferToolEvent,
1719
InferToolEventsUnion,
@@ -21,6 +23,8 @@ export type {
2123
NextTurnParamsContext,
2224
NextTurnParamsFunctions,
2325
ParsedToolCall,
26+
PartialResponse,
27+
StateAccessor,
2428
StepResult,
2529
StopCondition,
2630
StopWhen,
@@ -34,6 +38,7 @@ export type {
3438
TurnContext,
3539
TypedToolCall,
3640
TypedToolCallUnion,
41+
UnsentToolResult,
3742
Warning,
3843
} from './lib/tool-types.js';
3944
export type { BuildTurnContextOptions } from './lib/turn-context.js';
@@ -109,4 +114,15 @@ export {
109114
} from './lib/tool-types.js';
110115
// Turn context helpers
111116
export { buildTurnContext, normalizeInputToArray } from './lib/turn-context.js';
117+
// Conversation state helpers
118+
export {
119+
appendToMessages,
120+
createInitialState,
121+
createRejectedResult,
122+
createUnsentResult,
123+
generateConversationId,
124+
partitionToolCalls,
125+
toolRequiresApproval,
126+
updateState,
127+
} from './lib/conversation-state.js';
112128
export * from './sdk/sdk.js';

src/lib/async-params.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import type * as models from '../models/index.js';
2-
import type { StopWhen, Tool, TurnContext } from './tool-types.js';
2+
import type { ParsedToolCall, StateAccessor, StopWhen, Tool, TurnContext } from './tool-types.js';
3+
4+
// Re-export Tool type for convenience
5+
export type { Tool } from './tool-types.js';
36

47
/**
58
* Type guard to check if a value is a parameter function
@@ -40,6 +43,14 @@ export type CallModelInput<TTools extends readonly Tool[] = readonly Tool[]> = {
4043
} & {
4144
tools?: TTools;
4245
stopWhen?: StopWhen<TTools>;
46+
/** State accessor for multi-turn persistence and approval gates */
47+
state?: StateAccessor<TTools>;
48+
/** Call-level approval check - overrides tool-level requireApproval setting */
49+
requireApproval?: (toolCall: ParsedToolCall<TTools[number]>) => boolean;
50+
/** Tool call IDs to approve (for resuming from awaiting_approval status) */
51+
approveToolCalls?: string[];
52+
/** Tool call IDs to reject (for resuming from awaiting_approval status) */
53+
rejectToolCalls?: string[];
4354
};
4455

4556
/**
@@ -70,8 +81,8 @@ export type ResolvedCallModelInput = Omit<models.OpenResponsesRequest, 'stream'
7081
* // resolved.temperature === 0.2
7182
* ```
7283
*/
73-
export async function resolveAsyncFunctions(
74-
input: CallModelInput,
84+
export async function resolveAsyncFunctions<TTools extends readonly Tool[] = readonly Tool[]>(
85+
input: CallModelInput<TTools>,
7586
context: TurnContext,
7687
): Promise<ResolvedCallModelInput> {
7788
// Build array of resolved entries

src/lib/conversation-state.ts

Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
import type * as models from '../models/index.js';
2+
import type {
3+
ConversationState,
4+
ParsedToolCall,
5+
Tool,
6+
UnsentToolResult,
7+
} from './tool-types.js';
8+
import { normalizeInputToArray } from './turn-context.js';
9+
10+
/**
11+
* Type guard to verify an object is a valid UnsentToolResult
12+
*/
13+
function isValidUnsentToolResult<TTools extends readonly Tool[]>(
14+
obj: unknown
15+
): obj is UnsentToolResult<TTools> {
16+
if (typeof obj !== 'object' || obj === null) return false;
17+
const candidate = obj as Record<string, unknown>;
18+
return (
19+
typeof candidate['callId'] === 'string' &&
20+
typeof candidate['name'] === 'string' &&
21+
'output' in candidate
22+
);
23+
}
24+
25+
/**
26+
* Type guard to verify an object is a valid ParsedToolCall
27+
*/
28+
function isValidParsedToolCall<TTools extends readonly Tool[]>(
29+
obj: unknown
30+
): obj is ParsedToolCall<TTools[number]> {
31+
if (typeof obj !== 'object' || obj === null) return false;
32+
const candidate = obj as Record<string, unknown>;
33+
return (
34+
typeof candidate['id'] === 'string' &&
35+
typeof candidate['name'] === 'string' &&
36+
'arguments' in candidate
37+
);
38+
}
39+
40+
/**
41+
* Generate a unique ID for a conversation
42+
* Uses crypto.randomUUID if available, falls back to timestamp + random
43+
*/
44+
export function generateConversationId(): string {
45+
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
46+
return `conv_${crypto.randomUUID()}`;
47+
}
48+
// Fallback for environments without crypto.randomUUID
49+
return `conv_${Date.now()}_${Math.random().toString(36).substring(2, 15)}`;
50+
}
51+
52+
/**
53+
* Create an initial conversation state
54+
* @param id - Optional custom ID, generates one if not provided
55+
*/
56+
export function createInitialState<TTools extends readonly Tool[] = readonly Tool[]>(
57+
id?: string
58+
): ConversationState<TTools> {
59+
const now = Date.now();
60+
return {
61+
id: id ?? generateConversationId(),
62+
messages: [],
63+
status: 'in_progress',
64+
createdAt: now,
65+
updatedAt: now,
66+
};
67+
}
68+
69+
/**
70+
* Update a conversation state with new values
71+
* Automatically updates the updatedAt timestamp
72+
*/
73+
export function updateState<TTools extends readonly Tool[] = readonly Tool[]>(
74+
state: ConversationState<TTools>,
75+
updates: Partial<Omit<ConversationState<TTools>, 'id' | 'createdAt' | 'updatedAt'>>
76+
): ConversationState<TTools> {
77+
return {
78+
...state,
79+
...updates,
80+
updatedAt: Date.now(),
81+
};
82+
}
83+
84+
/**
85+
* Append new items to the message history
86+
*/
87+
export function appendToMessages(
88+
current: models.OpenResponsesInput,
89+
newItems: models.OpenResponsesInput1[]
90+
): models.OpenResponsesInput {
91+
const currentArray = normalizeInputToArray(current);
92+
return [...currentArray, ...newItems];
93+
}
94+
95+
/**
96+
* Check if a tool call requires approval
97+
* @param toolCall - The tool call to check
98+
* @param tools - Available tools
99+
* @param callLevelCheck - Optional call-level approval function (overrides tool-level)
100+
*/
101+
export function toolRequiresApproval<TTools extends readonly Tool[]>(
102+
toolCall: ParsedToolCall<TTools[number]>,
103+
tools: TTools,
104+
callLevelCheck?: (toolCall: ParsedToolCall<TTools[number]>) => boolean
105+
): boolean {
106+
// Call-level check takes precedence
107+
if (callLevelCheck) {
108+
return callLevelCheck(toolCall);
109+
}
110+
111+
// Fall back to tool-level setting
112+
const tool = tools.find(t => t.function.name === toolCall.name);
113+
return tool?.function.requireApproval ?? false;
114+
}
115+
116+
/**
117+
* Partition tool calls into those requiring approval and those that can auto-execute
118+
*/
119+
export function partitionToolCalls<TTools extends readonly Tool[]>(
120+
toolCalls: ParsedToolCall<TTools[number]>[],
121+
tools: TTools,
122+
callLevelCheck?: (toolCall: ParsedToolCall<TTools[number]>) => boolean
123+
): {
124+
requiresApproval: ParsedToolCall<TTools[number]>[];
125+
autoExecute: ParsedToolCall<TTools[number]>[];
126+
} {
127+
const requiresApproval: ParsedToolCall<TTools[number]>[] = [];
128+
const autoExecute: ParsedToolCall<TTools[number]>[] = [];
129+
130+
for (const tc of toolCalls) {
131+
if (toolRequiresApproval(tc, tools, callLevelCheck)) {
132+
requiresApproval.push(tc);
133+
} else {
134+
autoExecute.push(tc);
135+
}
136+
}
137+
138+
return { requiresApproval, autoExecute };
139+
}
140+
141+
/**
142+
* Create an unsent tool result from a successful execution
143+
*/
144+
export function createUnsentResult<TTools extends readonly Tool[] = readonly Tool[]>(
145+
callId: string,
146+
name: string,
147+
output: unknown
148+
): UnsentToolResult<TTools> {
149+
const result = { callId, name, output };
150+
if (!isValidUnsentToolResult<TTools>(result)) {
151+
throw new Error('Invalid UnsentToolResult structure');
152+
}
153+
return result;
154+
}
155+
156+
/**
157+
* Create an unsent tool result from a rejection
158+
*/
159+
export function createRejectedResult<TTools extends readonly Tool[] = readonly Tool[]>(
160+
callId: string,
161+
name: string,
162+
reason?: string
163+
): UnsentToolResult<TTools> {
164+
const result = {
165+
callId,
166+
name,
167+
output: null,
168+
error: reason ?? 'Tool call rejected by user',
169+
};
170+
if (!isValidUnsentToolResult<TTools>(result)) {
171+
throw new Error('Invalid UnsentToolResult structure');
172+
}
173+
return result;
174+
}
175+
176+
/**
177+
* Convert unsent tool results to API format for sending to the model
178+
*/
179+
export function unsentResultsToAPIFormat(
180+
results: UnsentToolResult[]
181+
): models.OpenResponsesFunctionCallOutput[] {
182+
return results.map(r => ({
183+
type: 'function_call_output' as const,
184+
id: `output_${r.callId}`,
185+
callId: r.callId,
186+
output: r.error
187+
? JSON.stringify({ error: r.error })
188+
: JSON.stringify(r.output),
189+
}));
190+
}
191+
192+
/**
193+
* Extract text content from a response
194+
*/
195+
export function extractTextFromResponse(
196+
response: models.OpenResponsesNonStreamingResponse
197+
): string {
198+
if (!response.output) {
199+
return '';
200+
}
201+
202+
const outputs = Array.isArray(response.output) ? response.output : [response.output];
203+
const textParts: string[] = [];
204+
205+
for (const item of outputs) {
206+
if (item.type === 'message' && item.content) {
207+
for (const content of item.content) {
208+
if (content.type === 'output_text' && content.text) {
209+
textParts.push(content.text);
210+
}
211+
}
212+
}
213+
}
214+
215+
return textParts.join('');
216+
}
217+
218+
/**
219+
* Extract tool calls from a response
220+
*/
221+
export function extractToolCallsFromResponse<TTools extends readonly Tool[]>(
222+
response: models.OpenResponsesNonStreamingResponse
223+
): ParsedToolCall<TTools[number]>[] {
224+
if (!response.output) {
225+
return [];
226+
}
227+
228+
const outputs = Array.isArray(response.output) ? response.output : [response.output];
229+
const toolCalls: ParsedToolCall<TTools[number]>[] = [];
230+
231+
for (const item of outputs) {
232+
if (item.type === 'function_call') {
233+
const toolCall = {
234+
id: item.callId ?? item.id ?? '',
235+
name: item.name ?? '',
236+
arguments: typeof item.arguments === 'string'
237+
? JSON.parse(item.arguments)
238+
: item.arguments,
239+
};
240+
if (!isValidParsedToolCall<TTools>(toolCall)) {
241+
throw new Error(`Invalid tool call structure for tool: ${item.name}`);
242+
}
243+
toolCalls.push(toolCall);
244+
}
245+
}
246+
247+
return toolCalls;
248+
}

0 commit comments

Comments
 (0)