Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 45 additions & 6 deletions src/lib/conversation-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type {
UnsentToolResult,
} from './tool-types.js';
import { normalizeInputToArray } from './turn-context.js';
import * as z4 from 'zod/v4';

/**
* Type guard to verify an object is a valid UnsentToolResult
Expand Down Expand Up @@ -102,34 +103,72 @@ export function appendToMessages(
* @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
* @param callLevelCheck - Optional call-level approval function (overrides tool-level), can be async.
* Receives schema-normalized arguments when parsing succeeds and the raw call otherwise.
*/
export async function toolRequiresApproval<TTools extends readonly Tool[]>(
toolCall: ParsedToolCall<TTools[number]>,
tools: TTools,
context: TurnContext,
callLevelCheck?: (toolCall: ParsedToolCall<TTools[number]>, context: TurnContext) => boolean | Promise<boolean>
): Promise<boolean> {
// Call-level check takes precedence
const tool = tools.find(t => t.function.name === toolCall.name);

// Call-level checks always take precedence. Pass a normalized copy when
// parsing succeeds (so the check sees the values `execute` will receive),
// or the raw call when it does not. The caller's decision is honored either
// way; the original call is never mutated.
if (callLevelCheck) {
return callLevelCheck(toolCall, context);
if (!tool) {
return callLevelCheck(toolCall, context);
}

const parsed = z4.safeParse(tool.function.inputSchema, toolCall.arguments);
if (!parsed.success) {
return callLevelCheck(toolCall, context);
}

return callLevelCheck(
{ ...toolCall, arguments: parsed.data } as ParsedToolCall<TTools[number]>,
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
// `toolCall.arguments` is only the JSON-parsed wire payload; it has not been
// validated against the tool's inputSchema. The executor validates right
// before `execute`, so handing the raw payload to the predicate would let the
// two see different values whenever the schema applies a default, coercion,
// or transform (e.g. `{ dangerous: z.boolean().default(true) }` with `{}` on
// the wire: the predicate sees `undefined`, then `execute` runs with `true`).
// Parse with the same schema so the predicate decides on the executed values.
if (typeof requireApproval === 'function') {
return requireApproval(toolCall.arguments, context);
const parsed = z4.safeParse(tool.function.inputSchema, toolCall.arguments);
if (!parsed.success) {
// No trustworthy value to judge. Fail closed rather than waive approval
// based on arguments the tool body would never receive.
return true;
}
if (!isRecord(parsed.data)) {
// Valid per the schema but not an object: the predicate contract is
// Record<string, unknown>, so fail closed.
return true;
}
return requireApproval(parsed.data, context);
}

// Otherwise treat as boolean
return requireApproval ?? false;
}

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}

/**
* Partition tool calls into those requiring approval and those that can auto-execute
* @param toolCalls - Tool calls to partition
Expand Down
18 changes: 16 additions & 2 deletions src/lib/model-result.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,8 +227,16 @@ export class ModelResult<
// Initialize state management
this.stateAccessor = options.state ?? null;
this.requireApprovalFn = options.requireApproval ?? null;
this.approvedToolCalls = options.approveToolCalls ?? [];
this.rejectedToolCalls = options.rejectToolCalls ?? [];
this.approvedToolCalls = [...new Set(options.approveToolCalls ?? [])];
this.rejectedToolCalls = [...new Set(options.rejectToolCalls ?? [])];

const rejectedIds = new Set(this.rejectedToolCalls);
const conflictingIds = this.approvedToolCalls.filter(id => rejectedIds.has(id));
if (conflictingIds.length > 0) {
throw new Error(
`Tool calls cannot be both approved and rejected: ${conflictingIds.join(', ')}`
);
}
}

/**
Expand Down Expand Up @@ -1018,6 +1026,12 @@ export class ModelResult<
return; // Skip normal initialization, we're resuming
}

// Resuming without any decision keeps the conversation paused
if (loadedState.status === 'awaiting_approval') {
this.isResumingFromApproval = true;
return;
}

// Check for interruption flag and handle
if (loadedState.interruptedBy) {
// Clear interruption flag and continue from saved state
Expand Down
92 changes: 91 additions & 1 deletion tests/unit/conversation-state.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import { z } from 'zod/v4';
import {
createInitialState,
Expand Down Expand Up @@ -131,6 +131,96 @@ describe('Conversation State Utilities', () => {
expect(await toolRequiresApproval(toolCall, [toolWithApproval, toolWithoutApproval], context)).toBe(false);
});

it('should evaluate tool-level predicates against schema-parsed arguments', async () => {
const deleteTool = tool({
name: 'delete_files',
inputSchema: z.object({ recursive: z.boolean().default(true) }),
requireApproval: (args) => args.recursive,
execute: async () => ({}),
});
const toolCall = { id: '1', name: 'delete_files', arguments: {} };

expect(await toolRequiresApproval(toolCall, [deleteTool], context)).toBe(true);
});

it('should apply schema coercions before a tool-level predicate', async () => {
const seen: unknown[] = [];
const transfer = tool({
name: 'transfer',
inputSchema: z.object({ amount: z.coerce.number() }),
requireApproval: (args) => {
seen.push(args);
return args.amount > 100;
},
execute: async () => ({}),
});
const toolCall = { id: '1', name: 'transfer', arguments: { amount: '500' } };

expect(await toolRequiresApproval(toolCall, [transfer], context)).toBe(true);
expect(seen).toEqual([{ amount: 500 }]);
});

it('should fail closed when arguments fail schema validation for a tool-level predicate', async () => {
const predicate = vi.fn(() => false);
const strict = tool({
name: 'strict_action',
inputSchema: z.object({ target: z.string() }),
requireApproval: predicate,
execute: async () => ({}),
});
const toolCall = { id: '1', name: 'strict_action', arguments: {} };

expect(await toolRequiresApproval(toolCall, [strict], context)).toBe(true);
expect(predicate).not.toHaveBeenCalled();
});

it('should fail closed when the schema parses to a non-object value', async () => {
const predicate = vi.fn(() => false);
const weird = tool({
name: 'weird_action',
inputSchema: z.object({ target: z.string() }),
requireApproval: predicate,
execute: async () => ({}),
});
(weird.function as { inputSchema: unknown }).inputSchema = z
.object({ target: z.string() })
.transform(() => ['not', 'a', 'record']);
const toolCall = { id: '1', name: 'weird_action', arguments: { target: 'x' } };

expect(await toolRequiresApproval(toolCall, [weird], context)).toBe(true);
expect(predicate).not.toHaveBeenCalled();
});

it('should pass schema-parsed arguments to a call-level check without mutating the call', async () => {
const defaulted = tool({
name: 'defaulted_action',
inputSchema: z.object({ destructive: z.boolean().default(true) }),
execute: async () => ({}),
});
const toolCall = { id: '1', name: 'defaulted_action', arguments: {} };
const callLevelCheck = vi.fn((tc: { arguments: Record<string, unknown> }) => tc.arguments['destructive'] === true);

expect(await toolRequiresApproval(toolCall, [defaulted], context, callLevelCheck)).toBe(true);
expect(callLevelCheck).toHaveBeenCalledWith({ ...toolCall, arguments: { destructive: true } }, context);
expect(toolCall.arguments).toEqual({});
});

it('should honor a call-level check with the raw call when arguments fail schema validation', async () => {
const strict = tool({
name: 'strict_action',
inputSchema: z.object({ target: z.string() }),
execute: async () => ({}),
});
const toolCall = { id: '1', name: 'strict_action', arguments: {} };
const allow = vi.fn(() => false);
const deny = vi.fn(() => true);

expect(await toolRequiresApproval(toolCall, [strict], context, allow)).toBe(false);
expect(allow).toHaveBeenCalledWith(toolCall, context);
expect(await toolRequiresApproval(toolCall, [strict], context, deny)).toBe(true);
expect(deny).toHaveBeenCalledWith(toolCall, context);
});

it('should use call-level check when provided', async () => {
const toolCall = { id: '1', name: 'safe_action', arguments: {} };
const alwaysRequire = () => true;
Expand Down
Loading
Loading