Skip to content

Commit 6434a01

Browse files
committed
feat: enforce state requirement for approval workflows
Add type-level and runtime enforcement that approval-related parameters require a state accessor: Type-level enforcement: - CallModelInput now uses conditional types so `approveToolCalls` and `rejectToolCalls` are typed as `never` when `state` is not provided - Added helper types: ToolHasApproval, HasApprovalTools Runtime enforcement: - Throws error in ModelResult constructor if approveToolCalls/rejectToolCalls provided without state accessor - Throws error in handleApprovalCheck if tools require approval but no state accessor is configured New exports: - Type guards: toolHasApprovalConfigured, hasApprovalRequiredTools - Helper types: ToolHasApproval, HasApprovalTools, CallModelInputWithApprovalTools
1 parent 3d8fc7f commit 6434a01

File tree

5 files changed

+189
-7
lines changed

5 files changed

+189
-7
lines changed

src/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
// Async params support
66
export type {
77
CallModelInput,
8+
CallModelInputWithApprovalTools,
89
FieldOrAsyncFunction,
910
ResolvedCallModelInput,
1011
} from './lib/async-params.js';
@@ -15,6 +16,7 @@ export type {
1516
ConversationState,
1617
ConversationStatus,
1718
ResponseStreamEvent as EnhancedResponseStreamEvent,
19+
HasApprovalTools,
1820
InferToolEvent,
1921
InferToolEventsUnion,
2022
InferToolInput,
@@ -32,6 +34,7 @@ export type {
3234
ToolApprovalCheck,
3335
ToolExecutionResult,
3436
ToolExecutionResultUnion,
37+
ToolHasApproval,
3538
ToolPreliminaryResultEvent,
3639
ToolStreamEvent,
3740
ToolWithExecute,
@@ -107,10 +110,12 @@ export {
107110
// Tool creation helpers
108111
export { tool } from './lib/tool.js';
109112
export {
113+
hasApprovalRequiredTools,
110114
hasExecuteFunction,
111115
isGeneratorTool,
112116
isRegularExecuteTool,
113117
isToolPreliminaryResultEvent,
118+
toolHasApprovalConfigured,
114119
ToolType,
115120
} from './lib/tool-types.js';
116121
// Turn context helpers

src/lib/async-params.ts

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -32,19 +32,15 @@ function buildResolvedRequest(
3232
export type FieldOrAsyncFunction<T> = T | ((context: TurnContext) => T | Promise<T>);
3333

3434
/**
35-
* Input type for callModel function
36-
* Each field can independently be a static value or a function that computes the value
37-
* Generic over TTools to enable proper type inference for stopWhen conditions
35+
* Base input type for callModel without approval-related fields
3836
*/
39-
export type CallModelInput<TTools extends readonly Tool[] = readonly Tool[]> = {
37+
type BaseCallModelInput<TTools extends readonly Tool[] = readonly Tool[]> = {
4038
[K in keyof Omit<models.OpenResponsesRequest, 'stream' | 'tools'>]?: FieldOrAsyncFunction<
4139
models.OpenResponsesRequest[K]
4240
>;
4341
} & {
4442
tools?: TTools;
4543
stopWhen?: StopWhen<TTools>;
46-
/** State accessor for multi-turn persistence and approval gates */
47-
state?: StateAccessor<TTools>;
4844
/**
4945
* Call-level approval check - overrides tool-level requireApproval setting
5046
* Receives the tool call and turn context, can be sync or async
@@ -53,12 +49,50 @@ export type CallModelInput<TTools extends readonly Tool[] = readonly Tool[]> = {
5349
toolCall: ParsedToolCall<TTools[number]>,
5450
context: TurnContext
5551
) => boolean | Promise<boolean>;
52+
};
53+
54+
/**
55+
* Approval params when state is provided (allows approve/reject)
56+
*/
57+
type ApprovalParamsWithState<TTools extends readonly Tool[] = readonly Tool[]> = {
58+
/** State accessor for multi-turn persistence and approval gates */
59+
state: StateAccessor<TTools>;
5660
/** Tool call IDs to approve (for resuming from awaiting_approval status) */
5761
approveToolCalls?: string[];
5862
/** Tool call IDs to reject (for resuming from awaiting_approval status) */
5963
rejectToolCalls?: string[];
6064
};
6165

66+
/**
67+
* Approval params when state is NOT provided (forbids approve/reject)
68+
*/
69+
type ApprovalParamsWithoutState = {
70+
/** State accessor for multi-turn persistence and approval gates */
71+
state?: undefined;
72+
/** Not allowed without state - will cause type error */
73+
approveToolCalls?: never;
74+
/** Not allowed without state - will cause type error */
75+
rejectToolCalls?: never;
76+
};
77+
78+
/**
79+
* Input type for callModel function
80+
* Each field can independently be a static value or a function that computes the value
81+
* Generic over TTools to enable proper type inference for stopWhen conditions
82+
*
83+
* Type enforcement:
84+
* - `approveToolCalls` and `rejectToolCalls` are only valid when `state` is provided
85+
* - Using these without `state` will cause a TypeScript error
86+
*/
87+
export type CallModelInput<TTools extends readonly Tool[] = readonly Tool[]> =
88+
BaseCallModelInput<TTools> & (ApprovalParamsWithState<TTools> | ApprovalParamsWithoutState);
89+
90+
/**
91+
* Strict version that requires state - use when tools have approval configured
92+
*/
93+
export type CallModelInputWithApprovalTools<TTools extends readonly Tool[] = readonly Tool[]> =
94+
BaseCallModelInput<TTools> & ApprovalParamsWithState<TTools>;
95+
6296
/**
6397
* Resolved CallModelInput (all functions evaluated to values)
6498
* This is the type after all async functions have been resolved to their values

src/lib/model-result.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,19 @@ export class ModelResult<TTools extends readonly Tool[]> {
154154

155155
constructor(options: GetResponseOptions<TTools>) {
156156
this.options = options;
157+
158+
// Runtime validation: approval decisions require state
159+
const hasApprovalDecisions =
160+
(options.approveToolCalls && options.approveToolCalls.length > 0) ||
161+
(options.rejectToolCalls && options.rejectToolCalls.length > 0);
162+
163+
if (hasApprovalDecisions && !options.state) {
164+
throw new Error(
165+
'approveToolCalls and rejectToolCalls require a state accessor. ' +
166+
'Provide a StateAccessor via the "state" parameter to persist approval decisions.'
167+
);
168+
}
169+
157170
// Initialize state management
158171
this.stateAccessor = options.state ?? null;
159172
this.requireApprovalFn = options.requireApproval ?? null;
@@ -355,11 +368,20 @@ export class ModelResult<TTools extends readonly Tool[]> {
355368

356369
if (needsApproval.length === 0) return false;
357370

371+
// Validate: approval requires state accessor
372+
if (!this.stateAccessor) {
373+
const toolNames = needsApproval.map(tc => tc.name).join(', ');
374+
throw new Error(
375+
`Tool(s) require approval but no state accessor is configured: ${toolNames}. ` +
376+
'Provide a StateAccessor via the "state" parameter to enable approval workflows.'
377+
);
378+
}
379+
358380
// Execute auto-approve tools
359381
const unsentResults = await this.executeAutoApproveTools(autoExecute, turnContext);
360382

361383
// Save state with pending approvals
362-
if (this.stateAccessor && this.currentState) {
384+
if (this.currentState) {
363385
const stateUpdates: Partial<Omit<ConversationState<TTools>, 'id' | 'createdAt' | 'updatedAt'>> = {
364386
pendingToolCalls: needsApproval,
365387
status: 'awaiting_approval',

src/lib/tool-types.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -514,3 +514,47 @@ export interface StateAccessor<TTools extends readonly Tool[] = readonly Tool[]>
514514
/** Save the conversation state */
515515
save: (state: ConversationState<TTools>) => Promise<void>;
516516
}
517+
518+
// =============================================================================
519+
// Approval Detection Helper Types
520+
// =============================================================================
521+
522+
/**
523+
* Check if a single tool has approval configured (non-false, non-undefined)
524+
* Returns true if the tool definitely requires approval,
525+
* false if it definitely doesn't, or boolean if it's uncertain
526+
*/
527+
export type ToolHasApproval<T extends Tool> =
528+
T extends { function: { requireApproval: true | ToolApprovalCheck<unknown> } }
529+
? true
530+
: T extends { function: { requireApproval: false } }
531+
? false
532+
: T extends { function: { requireApproval: undefined } }
533+
? false
534+
: boolean; // Could be either (optional property)
535+
536+
/**
537+
* Check if ANY tool in an array has approval configured
538+
* Returns true if at least one tool might require approval
539+
*/
540+
export type HasApprovalTools<TTools extends readonly Tool[]> =
541+
TTools extends readonly [infer First extends Tool, ...infer Rest extends Tool[]]
542+
? ToolHasApproval<First> extends true
543+
? true
544+
: HasApprovalTools<Rest>
545+
: false;
546+
547+
/**
548+
* Type guard to check if a tool has approval configured at runtime
549+
*/
550+
export function toolHasApprovalConfigured(tool: Tool): boolean {
551+
const requireApproval = tool.function.requireApproval;
552+
return requireApproval === true || typeof requireApproval === 'function';
553+
}
554+
555+
/**
556+
* Type guard to check if any tools in array have approval configured at runtime
557+
*/
558+
export function hasApprovalRequiredTools(tools: readonly Tool[]): boolean {
559+
return tools.some(toolHasApprovalConfigured);
560+
}

tests/unit/conversation-state.test.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ import {
1111
appendToMessages,
1212
} from '../../src/lib/conversation-state.js';
1313
import { tool } from '../../src/lib/tool.js';
14+
import {
15+
toolHasApprovalConfigured,
16+
hasApprovalRequiredTools,
17+
} from '../../src/lib/tool-types.js';
1418

1519
describe('Conversation State Utilities', () => {
1620
describe('generateConversationId', () => {
@@ -327,4 +331,77 @@ describe('Conversation State Utilities', () => {
327331
expect(result).toHaveLength(2);
328332
});
329333
});
334+
335+
describe('Approval Detection Type Guards', () => {
336+
const toolWithBooleanApproval = tool({
337+
name: 'needs_approval',
338+
inputSchema: z.object({}),
339+
requireApproval: true,
340+
execute: async () => ({}),
341+
});
342+
343+
const toolWithFunctionApproval = tool({
344+
name: 'conditional_approval',
345+
inputSchema: z.object({ dangerous: z.boolean() }),
346+
requireApproval: (params) => params.dangerous,
347+
execute: async () => ({}),
348+
});
349+
350+
const toolWithoutApproval = tool({
351+
name: 'safe_tool',
352+
inputSchema: z.object({}),
353+
execute: async () => ({}),
354+
});
355+
356+
const toolWithFalseApproval = tool({
357+
name: 'explicitly_safe',
358+
inputSchema: z.object({}),
359+
requireApproval: false,
360+
execute: async () => ({}),
361+
});
362+
363+
describe('toolHasApprovalConfigured', () => {
364+
it('should return true for tools with requireApproval: true', () => {
365+
expect(toolHasApprovalConfigured(toolWithBooleanApproval)).toBe(true);
366+
});
367+
368+
it('should return true for tools with requireApproval function', () => {
369+
expect(toolHasApprovalConfigured(toolWithFunctionApproval)).toBe(true);
370+
});
371+
372+
it('should return false for tools without requireApproval', () => {
373+
expect(toolHasApprovalConfigured(toolWithoutApproval)).toBe(false);
374+
});
375+
376+
it('should return false for tools with requireApproval: false', () => {
377+
expect(toolHasApprovalConfigured(toolWithFalseApproval)).toBe(false);
378+
});
379+
});
380+
381+
describe('hasApprovalRequiredTools', () => {
382+
it('should return true if any tool has approval configured', () => {
383+
expect(hasApprovalRequiredTools([
384+
toolWithoutApproval,
385+
toolWithBooleanApproval,
386+
])).toBe(true);
387+
});
388+
389+
it('should return true for function-based approval', () => {
390+
expect(hasApprovalRequiredTools([
391+
toolWithFunctionApproval,
392+
])).toBe(true);
393+
});
394+
395+
it('should return false if no tools have approval configured', () => {
396+
expect(hasApprovalRequiredTools([
397+
toolWithoutApproval,
398+
toolWithFalseApproval,
399+
])).toBe(false);
400+
});
401+
402+
it('should return false for empty array', () => {
403+
expect(hasApprovalRequiredTools([])).toBe(false);
404+
});
405+
});
406+
});
330407
});

0 commit comments

Comments
 (0)