Skip to content

Commit 3d8fc7f

Browse files
committed
feat: support async function-based requireApproval on tool definitions
The tool-level requireApproval now supports both boolean values and async functions that receive the tool's input params and turn context, matching the call-level requireApproval signature. This enables dynamic approval decisions based on: - Tool arguments (e.g., require approval only for dangerous parameter values) - Turn context (e.g., require approval after a certain number of turns) Changes: - Add ToolApprovalCheck type for function-based approval - Update BaseToolFunction and all tool config types - Update toolRequiresApproval to handle function-based approval - Add unit tests for function-based tool-level approval - Export ToolApprovalCheck type from index
1 parent 8ba1df8 commit 3d8fc7f

File tree

5 files changed

+116
-11
lines changed

5 files changed

+116
-11
lines changed

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export type {
2929
StopCondition,
3030
StopWhen,
3131
Tool,
32+
ToolApprovalCheck,
3233
ToolExecutionResult,
3334
ToolExecutionResultUnion,
3435
ToolPreliminaryResultEvent,

src/lib/conversation-state.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,17 @@ export async function toolRequiresApproval<TTools extends readonly Tool[]>(
113113

114114
// Fall back to tool-level setting
115115
const tool = tools.find(t => t.function.name === toolCall.name);
116-
return tool?.function.requireApproval ?? false;
116+
if (!tool) return false;
117+
118+
const requireApproval = tool.function.requireApproval;
119+
120+
// If it's a function, call it with the tool's arguments and context
121+
if (typeof requireApproval === 'function') {
122+
return requireApproval(toolCall.arguments, context);
123+
}
124+
125+
// Otherwise treat as boolean
126+
return requireApproval ?? false;
117127
}
118128

119129
/**

src/lib/tool-types.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,16 @@ export type NextTurnParamsFunctions<TInput> = {
5858
) => NextTurnParamsContext[K] | Promise<NextTurnParamsContext[K]>;
5959
};
6060

61+
/**
62+
* Tool-level approval check function type
63+
* Receives the tool's input params and turn context
64+
* Returns true if approval is required, false otherwise
65+
*/
66+
export type ToolApprovalCheck<TInput> = (
67+
params: TInput,
68+
context: TurnContext
69+
) => boolean | Promise<boolean>;
70+
6171
/**
6272
* Base tool function interface with inputSchema
6373
*/
@@ -66,8 +76,11 @@ export interface BaseToolFunction<TInput extends ZodObject<ZodRawShape>> {
6676
description?: string;
6777
inputSchema: TInput;
6878
nextTurnParams?: NextTurnParamsFunctions<z.infer<TInput>>;
69-
/** Whether this tool requires human approval before execution */
70-
requireApproval?: boolean;
79+
/**
80+
* Whether this tool requires human approval before execution
81+
* Can be a boolean or an async function that receives the tool's input params and context
82+
*/
83+
requireApproval?: boolean | ToolApprovalCheck<z.infer<TInput>>;
7184
}
7285

7386
/**

src/lib/tool.ts

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
type ToolWithGenerator,
77
type ManualTool,
88
type NextTurnParamsFunctions,
9+
type ToolApprovalCheck,
910
} from "./tool-types.js";
1011

1112
/**
@@ -21,8 +22,11 @@ type RegularToolConfigWithOutput<
2122
outputSchema: TOutput;
2223
eventSchema?: undefined;
2324
nextTurnParams?: NextTurnParamsFunctions<z.infer<TInput>>;
24-
/** Whether this tool requires human approval before execution */
25-
requireApproval?: boolean;
25+
/**
26+
* Whether this tool requires human approval before execution
27+
* Can be a boolean or an async function that receives the tool's input params and context
28+
*/
29+
requireApproval?: boolean | ToolApprovalCheck<z.infer<TInput>>;
2630
execute: (
2731
params: z.infer<TInput>,
2832
context?: TurnContext
@@ -42,8 +46,11 @@ type RegularToolConfigWithoutOutput<
4246
outputSchema?: undefined;
4347
eventSchema?: undefined;
4448
nextTurnParams?: NextTurnParamsFunctions<z.infer<TInput>>;
45-
/** Whether this tool requires human approval before execution */
46-
requireApproval?: boolean;
49+
/**
50+
* Whether this tool requires human approval before execution
51+
* Can be a boolean or an async function that receives the tool's input params and context
52+
*/
53+
requireApproval?: boolean | ToolApprovalCheck<z.infer<TInput>>;
4754
execute: (
4855
params: z.infer<TInput>,
4956
context?: TurnContext
@@ -64,8 +71,11 @@ type GeneratorToolConfig<
6471
eventSchema: TEvent;
6572
outputSchema: TOutput;
6673
nextTurnParams?: NextTurnParamsFunctions<z.infer<TInput>>;
67-
/** Whether this tool requires human approval before execution */
68-
requireApproval?: boolean;
74+
/**
75+
* Whether this tool requires human approval before execution
76+
* Can be a boolean or an async function that receives the tool's input params and context
77+
*/
78+
requireApproval?: boolean | ToolApprovalCheck<z.infer<TInput>>;
6979
execute: (
7080
params: z.infer<TInput>,
7181
context?: TurnContext
@@ -80,8 +90,11 @@ type ManualToolConfig<TInput extends ZodObject<ZodRawShape>> = {
8090
description?: string;
8191
inputSchema: TInput;
8292
nextTurnParams?: NextTurnParamsFunctions<z.infer<TInput>>;
83-
/** Whether this tool requires human approval before execution */
84-
requireApproval?: boolean;
93+
/**
94+
* Whether this tool requires human approval before execution
95+
* Can be a boolean or an async function that receives the tool's input params and context
96+
*/
97+
requireApproval?: boolean | ToolApprovalCheck<z.infer<TInput>>;
8598
execute: false;
8699
};
87100

tests/unit/conversation-state.test.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,74 @@ describe('Conversation State Utilities', () => {
153153
expect(await toolRequiresApproval(toolCall, [toolWithoutApproval], context, asyncCheck)).toBe(true);
154154
expect(await toolRequiresApproval(toolCall, [toolWithoutApproval], { numberOfTurns: 0 }, asyncCheck)).toBe(false);
155155
});
156+
157+
it('should support function-based tool-level requireApproval', async () => {
158+
// Tool with function-based approval that checks params
159+
const toolWithFunctionApproval = tool({
160+
name: 'conditional_action',
161+
inputSchema: z.object({ dangerous: z.boolean() }),
162+
requireApproval: (params) => params.dangerous === true,
163+
execute: async () => ({}),
164+
});
165+
166+
// Safe action - should not require approval
167+
const safeCall = { id: '1', name: 'conditional_action', arguments: { dangerous: false } };
168+
expect(await toolRequiresApproval(safeCall, [toolWithFunctionApproval], context)).toBe(false);
169+
170+
// Dangerous action - should require approval
171+
const dangerousCall = { id: '2', name: 'conditional_action', arguments: { dangerous: true } };
172+
expect(await toolRequiresApproval(dangerousCall, [toolWithFunctionApproval], context)).toBe(true);
173+
});
174+
175+
it('should support async function-based tool-level requireApproval', async () => {
176+
// Tool with async function-based approval
177+
const toolWithAsyncApproval = tool({
178+
name: 'async_conditional',
179+
inputSchema: z.object({ value: z.number() }),
180+
requireApproval: async (params, ctx) => {
181+
// Simulate async operation
182+
await Promise.resolve();
183+
// Require approval if value > 100 OR after first turn
184+
return params.value > 100 || ctx.numberOfTurns > 1;
185+
},
186+
execute: async () => ({}),
187+
});
188+
189+
// Low value, first turn - no approval needed
190+
const lowValueCall = { id: '1', name: 'async_conditional', arguments: { value: 50 } };
191+
expect(await toolRequiresApproval(lowValueCall, [toolWithAsyncApproval], { numberOfTurns: 1 })).toBe(false);
192+
193+
// High value - approval needed
194+
const highValueCall = { id: '2', name: 'async_conditional', arguments: { value: 150 } };
195+
expect(await toolRequiresApproval(highValueCall, [toolWithAsyncApproval], { numberOfTurns: 1 })).toBe(true);
196+
197+
// Low value but second turn - approval needed
198+
expect(await toolRequiresApproval(lowValueCall, [toolWithAsyncApproval], { numberOfTurns: 2 })).toBe(true);
199+
});
200+
201+
it('should pass context to function-based tool-level approval', async () => {
202+
const receivedContexts: Array<{ numberOfTurns: number }> = [];
203+
204+
const toolWithContextCheck = tool({
205+
name: 'context_checker',
206+
inputSchema: z.object({}),
207+
requireApproval: (_params, ctx) => {
208+
receivedContexts.push(ctx);
209+
return ctx.numberOfTurns > 2;
210+
},
211+
execute: async () => ({}),
212+
});
213+
214+
const toolCall = { id: '1', name: 'context_checker', arguments: {} };
215+
216+
await toolRequiresApproval(toolCall, [toolWithContextCheck], { numberOfTurns: 1 });
217+
await toolRequiresApproval(toolCall, [toolWithContextCheck], { numberOfTurns: 3 });
218+
219+
expect(receivedContexts).toEqual([
220+
{ numberOfTurns: 1 },
221+
{ numberOfTurns: 3 },
222+
]);
223+
});
156224
});
157225

158226
describe('partitionToolCalls', () => {

0 commit comments

Comments
 (0)