Skip to content

Commit 8ba1df8

Browse files
committed
feat: make requireApproval support async functions with turn context
Update the call-level requireApproval function signature to: - Accept TurnContext as a second parameter - Support async functions (returning Promise<boolean>) This enables approval decisions based on conversation state: - Number of turns completed - Dynamic approval logic based on context Updated files: - src/lib/async-params.ts - Update type definition - src/lib/model-result.ts - Update type and field - src/lib/conversation-state.ts - Make toolRequiresApproval and partitionToolCalls async, add context parameter - tests/unit/conversation-state.test.ts - Update tests, add async test - tests/e2e/call-model-state.test.ts - Update test signature
1 parent 804ea30 commit 8ba1df8

File tree

5 files changed

+83
-39
lines changed

5 files changed

+83
-39
lines changed

src/lib/async-params.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,14 @@ export type CallModelInput<TTools extends readonly Tool[] = readonly Tool[]> = {
4545
stopWhen?: StopWhen<TTools>;
4646
/** State accessor for multi-turn persistence and approval gates */
4747
state?: StateAccessor<TTools>;
48-
/** Call-level approval check - overrides tool-level requireApproval setting */
49-
requireApproval?: (toolCall: ParsedToolCall<TTools[number]>) => boolean;
48+
/**
49+
* Call-level approval check - overrides tool-level requireApproval setting
50+
* Receives the tool call and turn context, can be sync or async
51+
*/
52+
requireApproval?: (
53+
toolCall: ParsedToolCall<TTools[number]>,
54+
context: TurnContext
55+
) => boolean | Promise<boolean>;
5056
/** Tool call IDs to approve (for resuming from awaiting_approval status) */
5157
approveToolCalls?: string[];
5258
/** Tool call IDs to reject (for resuming from awaiting_approval status) */

src/lib/conversation-state.ts

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type {
33
ConversationState,
44
ParsedToolCall,
55
Tool,
6+
TurnContext,
67
UnsentToolResult,
78
} from './tool-types.js';
89
import { normalizeInputToArray } from './turn-context.js';
@@ -96,16 +97,18 @@ export function appendToMessages(
9697
* Check if a tool call requires approval
9798
* @param toolCall - The tool call to check
9899
* @param tools - Available tools
99-
* @param callLevelCheck - Optional call-level approval function (overrides tool-level)
100+
* @param context - Turn context for the approval check
101+
* @param callLevelCheck - Optional call-level approval function (overrides tool-level), can be async
100102
*/
101-
export function toolRequiresApproval<TTools extends readonly Tool[]>(
103+
export async function toolRequiresApproval<TTools extends readonly Tool[]>(
102104
toolCall: ParsedToolCall<TTools[number]>,
103105
tools: TTools,
104-
callLevelCheck?: (toolCall: ParsedToolCall<TTools[number]>) => boolean
105-
): boolean {
106+
context: TurnContext,
107+
callLevelCheck?: (toolCall: ParsedToolCall<TTools[number]>, context: TurnContext) => boolean | Promise<boolean>
108+
): Promise<boolean> {
106109
// Call-level check takes precedence
107110
if (callLevelCheck) {
108-
return callLevelCheck(toolCall);
111+
return callLevelCheck(toolCall, context);
109112
}
110113

111114
// Fall back to tool-level setting
@@ -115,20 +118,25 @@ export function toolRequiresApproval<TTools extends readonly Tool[]>(
115118

116119
/**
117120
* Partition tool calls into those requiring approval and those that can auto-execute
121+
* @param toolCalls - Tool calls to partition
122+
* @param tools - Available tools
123+
* @param context - Turn context for the approval check
124+
* @param callLevelCheck - Optional call-level approval function (overrides tool-level), can be async
118125
*/
119-
export function partitionToolCalls<TTools extends readonly Tool[]>(
126+
export async function partitionToolCalls<TTools extends readonly Tool[]>(
120127
toolCalls: ParsedToolCall<TTools[number]>[],
121128
tools: TTools,
122-
callLevelCheck?: (toolCall: ParsedToolCall<TTools[number]>) => boolean
123-
): {
129+
context: TurnContext,
130+
callLevelCheck?: (toolCall: ParsedToolCall<TTools[number]>, context: TurnContext) => boolean | Promise<boolean>
131+
): Promise<{
124132
requiresApproval: ParsedToolCall<TTools[number]>[];
125133
autoExecute: ParsedToolCall<TTools[number]>[];
126-
} {
134+
}> {
127135
const requiresApproval: ParsedToolCall<TTools[number]>[] = [];
128136
const autoExecute: ParsedToolCall<TTools[number]>[] = [];
129137

130138
for (const tc of toolCalls) {
131-
if (toolRequiresApproval(tc, tools, callLevelCheck)) {
139+
if (await toolRequiresApproval(tc, tools, context, callLevelCheck)) {
132140
requiresApproval.push(tc);
133141
} else {
134142
autoExecute.push(tc);

src/lib/model-result.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,14 @@ export interface GetResponseOptions<TTools extends readonly Tool[]> {
9696
stopWhen?: StopWhen<TTools>;
9797
// State management for multi-turn conversations
9898
state?: StateAccessor<TTools>;
99-
requireApproval?: (toolCall: ParsedToolCall<TTools[number]>) => boolean;
99+
/**
100+
* Call-level approval check - overrides tool-level requireApproval setting
101+
* Receives the tool call and turn context, can be sync or async
102+
*/
103+
requireApproval?: (
104+
toolCall: ParsedToolCall<TTools[number]>,
105+
context: TurnContext
106+
) => boolean | Promise<boolean>;
100107
approveToolCalls?: string[];
101108
rejectToolCalls?: string[];
102109
}
@@ -140,7 +147,7 @@ export class ModelResult<TTools extends readonly Tool[]> {
140147
// State management for multi-turn conversations
141148
private stateAccessor: StateAccessor<TTools> | null = null;
142149
private currentState: ConversationState<TTools> | null = null;
143-
private requireApprovalFn: ((toolCall: ParsedToolCall<TTools[number]>) => boolean) | null = null;
150+
private requireApprovalFn: ((toolCall: ParsedToolCall<TTools[number]>, context: TurnContext) => boolean | Promise<boolean>) | null = null;
144151
private approvedToolCalls: string[] = [];
145152
private rejectedToolCalls: string[] = [];
146153
private isResumingFromApproval = false;
@@ -337,16 +344,18 @@ export class ModelResult<TTools extends readonly Tool[]> {
337344
): Promise<boolean> {
338345
if (!this.options.tools) return false;
339346

340-
const { requiresApproval: needsApproval, autoExecute } = partitionToolCalls(
347+
const turnContext: TurnContext = { numberOfTurns: currentRound };
348+
349+
const { requiresApproval: needsApproval, autoExecute } = await partitionToolCalls(
341350
toolCalls as ParsedToolCall<TTools[number]>[],
342351
this.options.tools,
352+
turnContext,
343353
this.requireApprovalFn ?? undefined
344354
);
345355

346356
if (needsApproval.length === 0) return false;
347357

348358
// Execute auto-approve tools
349-
const turnContext: TurnContext = { numberOfTurns: currentRound };
350359
const unsentResults = await this.executeAutoApproveTools(autoExecute, turnContext);
351360

352361
// Save state with pending approvals

tests/e2e/call-model-state.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,8 @@ describe('State Management Integration', () => {
154154
});
155155

156156
// Custom function that requires approval for all tools
157-
const customRequireApproval = (toolCall: { name: string }) => {
157+
// Accepts toolCall and context (TurnContext) as per the updated signature
158+
const customRequireApproval = (toolCall: { name: string }, _context: { numberOfTurns: number }) => {
158159
checkedTools.push(toolCall.name);
159160
return true; // Always require approval
160161
};

tests/unit/conversation-state.test.ts

Lines changed: 42 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -110,34 +110,48 @@ describe('Conversation State Utilities', () => {
110110
execute: async () => ({}),
111111
});
112112

113-
it('should return true for tools with requireApproval', () => {
113+
const context = { numberOfTurns: 1 };
114+
115+
it('should return true for tools with requireApproval', async () => {
114116
const toolCall = { id: '1', name: 'dangerous_action', arguments: {} };
115-
expect(toolRequiresApproval(toolCall, [toolWithApproval, toolWithoutApproval])).toBe(true);
117+
expect(await toolRequiresApproval(toolCall, [toolWithApproval, toolWithoutApproval], context)).toBe(true);
116118
});
117119

118-
it('should return false for tools without requireApproval', () => {
120+
it('should return false for tools without requireApproval', async () => {
119121
const toolCall = { id: '1', name: 'safe_action', arguments: {} };
120-
expect(toolRequiresApproval(toolCall, [toolWithApproval, toolWithoutApproval])).toBe(false);
122+
expect(await toolRequiresApproval(toolCall, [toolWithApproval, toolWithoutApproval], context)).toBe(false);
121123
});
122124

123-
it('should return false for unknown tools', () => {
125+
it('should return false for unknown tools', async () => {
124126
const toolCall = { id: '1', name: 'unknown_tool', arguments: {} };
125-
expect(toolRequiresApproval(toolCall, [toolWithApproval, toolWithoutApproval])).toBe(false);
127+
expect(await toolRequiresApproval(toolCall, [toolWithApproval, toolWithoutApproval], context)).toBe(false);
126128
});
127129

128-
it('should use call-level check when provided', () => {
130+
it('should use call-level check when provided', async () => {
129131
const toolCall = { id: '1', name: 'safe_action', arguments: {} };
130132
const alwaysRequire = () => true;
131133

132-
expect(toolRequiresApproval(toolCall, [toolWithoutApproval], alwaysRequire)).toBe(true);
134+
expect(await toolRequiresApproval(toolCall, [toolWithoutApproval], context, alwaysRequire)).toBe(true);
133135
});
134136

135-
it('should call-level check can override tool-level approval', () => {
137+
it('should call-level check can override tool-level approval', async () => {
136138
const toolCall = { id: '1', name: 'dangerous_action', arguments: {} };
137139
const neverRequire = () => false;
138140

139141
// Call-level check takes precedence
140-
expect(toolRequiresApproval(toolCall, [toolWithApproval], neverRequire)).toBe(false);
142+
expect(await toolRequiresApproval(toolCall, [toolWithApproval], context, neverRequire)).toBe(false);
143+
});
144+
145+
it('should support async call-level check', async () => {
146+
const toolCall = { id: '1', name: 'safe_action', arguments: {} };
147+
const asyncCheck = async (_tc: unknown, ctx: { numberOfTurns: number }) => {
148+
// Simulate async operation
149+
await Promise.resolve();
150+
return ctx.numberOfTurns > 0;
151+
};
152+
153+
expect(await toolRequiresApproval(toolCall, [toolWithoutApproval], context, asyncCheck)).toBe(true);
154+
expect(await toolRequiresApproval(toolCall, [toolWithoutApproval], { numberOfTurns: 0 }, asyncCheck)).toBe(false);
141155
});
142156
});
143157

@@ -155,15 +169,18 @@ describe('Conversation State Utilities', () => {
155169
execute: async () => ({}),
156170
});
157171

158-
it('should partition tool calls correctly', () => {
172+
const context = { numberOfTurns: 1 };
173+
174+
it('should partition tool calls correctly', async () => {
159175
const toolCalls = [
160176
{ id: '1', name: 'needs_approval', arguments: {} },
161177
{ id: '2', name: 'auto_execute', arguments: {} },
162178
];
163179

164-
const { requiresApproval, autoExecute } = partitionToolCalls(
180+
const { requiresApproval, autoExecute } = await partitionToolCalls(
165181
toolCalls,
166-
[approvalTool, autoTool]
182+
[approvalTool, autoTool],
183+
context
167184
);
168185

169186
expect(requiresApproval).toHaveLength(1);
@@ -172,38 +189,41 @@ describe('Conversation State Utilities', () => {
172189
expect(autoExecute[0]?.name).toBe('auto_execute');
173190
});
174191

175-
it('should handle all tools requiring approval', () => {
192+
it('should handle all tools requiring approval', async () => {
176193
const toolCalls = [
177194
{ id: '1', name: 'needs_approval', arguments: {} },
178195
];
179196

180-
const { requiresApproval, autoExecute } = partitionToolCalls(
197+
const { requiresApproval, autoExecute } = await partitionToolCalls(
181198
toolCalls,
182-
[approvalTool, autoTool]
199+
[approvalTool, autoTool],
200+
context
183201
);
184202

185203
expect(requiresApproval).toHaveLength(1);
186204
expect(autoExecute).toHaveLength(0);
187205
});
188206

189-
it('should handle all tools auto-executing', () => {
207+
it('should handle all tools auto-executing', async () => {
190208
const toolCalls = [
191209
{ id: '1', name: 'auto_execute', arguments: {} },
192210
];
193211

194-
const { requiresApproval, autoExecute } = partitionToolCalls(
212+
const { requiresApproval, autoExecute } = await partitionToolCalls(
195213
toolCalls,
196-
[approvalTool, autoTool]
214+
[approvalTool, autoTool],
215+
context
197216
);
198217

199218
expect(requiresApproval).toHaveLength(0);
200219
expect(autoExecute).toHaveLength(1);
201220
});
202221

203-
it('should handle empty tool calls', () => {
204-
const { requiresApproval, autoExecute } = partitionToolCalls(
222+
it('should handle empty tool calls', async () => {
223+
const { requiresApproval, autoExecute } = await partitionToolCalls(
205224
[],
206-
[approvalTool, autoTool]
225+
[approvalTool, autoTool],
226+
context
207227
);
208228

209229
expect(requiresApproval).toHaveLength(0);

0 commit comments

Comments
 (0)