Commit 7e72b4a
authored
feat: Multi-turn conversation state management with type safety (#124)
## Summary
- Add multi-turn conversation state management with `StateAccessor`
pattern for pluggable persistence
- Add approval workflow for tool execution (`requireApproval`,
`approveToolCalls`, `rejectToolCalls`)
- Improve type safety by replacing type casts with type guards
- Refactor `executeToolsIfNeeded` from ~400 lines to ~110 lines by
extracting 14 focused helper methods
## Key Changes
### New State Management Features
- `StateAccessor` interface for pluggable state persistence (in-memory,
database, etc.)
- `ConversationState` type for tracking conversation status, messages,
pending approvals
- `createInitialState()`, `updateState()` utilities for state management
- Support for interruption handling and resumption
### Approval Workflow
- Tools can specify `requireApproval: true` to pause execution
- Call-level `requireApproval` function for dynamic approval logic
- `approveToolCalls` and `rejectToolCalls` arrays to resume with
decisions
- `getPendingToolCalls()` and `requiresApproval()` methods on
`ModelResult`
### Type Safety Improvements
- Type guards (`isValidUnsentToolResult`, `isValidParsedToolCall`)
instead of type casts
- Made `resolveAsyncFunctions` generic to eliminate double-casting
- `isEventStream` type guard handles both streaming and non-streaming
responses
## Examples
### Basic State Management
```typescript
import { callModel, type ConversationState, type StateAccessor } from '@openrouter/sdk';
// In-memory state storage (use database in production)
const conversations = new Map<string, ConversationState>();
const stateAccessor: StateAccessor = {
load: async () => conversations.get('conv-123') ?? null,
save: async (state) => { conversations.set('conv-123', state); },
};
const result = client.callModel({
model: 'anthropic/claude-sonnet-4',
input: [{ type: 'text', text: 'Hello!' }],
state: stateAccessor,
});
const text = await result.getText();
const state = await result.getState(); // Access conversation state
```
### Tool with Approval Required
```typescript
import { tool } from '@openrouter/sdk';
const deleteFileTool = tool({
name: 'delete_file',
description: 'Delete a file from the filesystem',
inputSchema: z.object({
path: z.string().describe('File path to delete'),
}),
outputSchema: z.object({ success: z.boolean() }),
requireApproval: true, // Pause for human approval
execute: async ({ path }) => {
await fs.unlink(path);
return { success: true };
},
});
const result = client.callModel({
model: 'anthropic/claude-sonnet-4',
input: [{ type: 'text', text: 'Delete the temp.txt file' }],
tools: [deleteFileTool],
state: stateAccessor,
});
// Check if approval is needed
if (await result.requiresApproval()) {
const pending = await result.getPendingToolCalls();
console.log('Pending approval:', pending);
// Show to user, get decision...
}
```
### Resume with Approval Decision
```typescript
// After user approves/rejects...
const result = client.callModel({
model: 'anthropic/claude-sonnet-4',
input: [], // Empty - resuming from state
tools: [deleteFileTool],
state: stateAccessor,
approveToolCalls: ['call_abc123'], // Approve specific tool calls
rejectToolCalls: ['call_def456'], // Reject others
});
const text = await result.getText(); // Continues execution
```
### Dynamic Approval Logic
```typescript
const result = client.callModel({
model: 'anthropic/claude-sonnet-4',
input: [{ type: 'text', text: 'Process these files' }],
tools: [readFileTool, writeFileTool, deleteFileTool],
state: stateAccessor,
// Dynamic approval based on tool and context
requireApproval: (toolCall, context) => {
// Always approve read operations
if (toolCall.name === 'read_file') return false;
// Require approval for destructive operations
if (toolCall.name === 'delete_file') return true;
// Require approval after 3 turns
return context.numberOfTurns > 3;
},
});
```
### Stop Conditions
```typescript
import { stepCountIs, hasToolCall, maxCost } from '@openrouter/sdk';
const result = client.callModel({
model: 'anthropic/claude-sonnet-4',
input: [{ type: 'text', text: 'Research this topic thoroughly' }],
tools: [searchTool, summarizeTool],
// Stop when any condition is met
stopWhen: [
stepCountIs(10), // Max 10 tool execution rounds
hasToolCall('summarize'), // Stop when summarize is called
maxCost(0.50), // Budget limit
],
});
```
## Test plan
- [x] All 320 existing tests pass
- [x] New unit tests for conversation state utilities (21 tests)
- [x] New E2E tests for state management integration (5 tests)
- [x] Build passes with no TypeScript errors
- [x] Lint passes with no warnings1 parent 1e49648 commit 7e72b4a
File tree
9 files changed
+1989
-233
lines changed- src
- funcs
- lib
- tests
- e2e
- unit
9 files changed
+1989
-233
lines changed| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
124 | 124 | | |
125 | 125 | | |
126 | 126 | | |
127 | | - | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
128 | 137 | | |
129 | 138 | | |
130 | 139 | | |
| |||
144 | 153 | | |
145 | 154 | | |
146 | 155 | | |
147 | | - | |
148 | | - | |
149 | | - | |
150 | | - | |
151 | | - | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
152 | 163 | | |
153 | 164 | | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
5 | 5 | | |
6 | 6 | | |
7 | 7 | | |
| 8 | + | |
8 | 9 | | |
9 | 10 | | |
10 | 11 | | |
11 | 12 | | |
12 | 13 | | |
13 | 14 | | |
14 | 15 | | |
| 16 | + | |
| 17 | + | |
15 | 18 | | |
| 19 | + | |
16 | 20 | | |
17 | 21 | | |
18 | 22 | | |
| |||
21 | 25 | | |
22 | 26 | | |
23 | 27 | | |
| 28 | + | |
| 29 | + | |
24 | 30 | | |
25 | 31 | | |
26 | 32 | | |
27 | 33 | | |
| 34 | + | |
28 | 35 | | |
29 | 36 | | |
| 37 | + | |
30 | 38 | | |
31 | 39 | | |
32 | 40 | | |
33 | 41 | | |
34 | 42 | | |
35 | 43 | | |
36 | 44 | | |
| 45 | + | |
37 | 46 | | |
38 | 47 | | |
39 | 48 | | |
| |||
101 | 110 | | |
102 | 111 | | |
103 | 112 | | |
| 113 | + | |
104 | 114 | | |
105 | 115 | | |
106 | 116 | | |
107 | 117 | | |
| 118 | + | |
108 | 119 | | |
109 | 120 | | |
110 | 121 | | |
111 | 122 | | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
112 | 134 | | |
113 | 135 | | |
114 | 136 | | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1 | 1 | | |
2 | | - | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
3 | 6 | | |
4 | 7 | | |
5 | 8 | | |
| |||
29 | 32 | | |
30 | 33 | | |
31 | 34 | | |
32 | | - | |
33 | | - | |
34 | | - | |
| 35 | + | |
35 | 36 | | |
36 | | - | |
| 37 | + | |
37 | 38 | | |
38 | 39 | | |
39 | 40 | | |
40 | 41 | | |
41 | 42 | | |
42 | 43 | | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
43 | 76 | | |
44 | 77 | | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
45 | 96 | | |
46 | 97 | | |
47 | 98 | | |
| |||
70 | 121 | | |
71 | 122 | | |
72 | 123 | | |
73 | | - | |
74 | | - | |
| 124 | + | |
| 125 | + | |
75 | 126 | | |
76 | 127 | | |
77 | 128 | | |
| |||
0 commit comments