@animalabs/membrane 0.5.24 → 0.5.25
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/membrane.d.ts +37 -0
- package/dist/membrane.d.ts.map +1 -1
- package/dist/membrane.js +554 -0
- package/dist/membrane.js.map +1 -1
- package/dist/providers/mock.d.ts +8 -0
- package/dist/providers/mock.d.ts.map +1 -1
- package/dist/providers/mock.js +39 -2
- package/dist/providers/mock.js.map +1 -1
- package/dist/providers/openai-compatible.d.ts.map +1 -1
- package/dist/providers/openai-compatible.js +5 -1
- package/dist/providers/openai-compatible.js.map +1 -1
- package/dist/providers/openai.d.ts.map +1 -1
- package/dist/providers/openai.js +5 -1
- package/dist/providers/openai.js.map +1 -1
- package/dist/providers/openrouter.d.ts.map +1 -1
- package/dist/providers/openrouter.js +5 -1
- package/dist/providers/openrouter.js.map +1 -1
- package/dist/types/index.d.ts +2 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/index.js +1 -0
- package/dist/types/index.js.map +1 -1
- package/dist/types/yielding-stream.d.ts +167 -0
- package/dist/types/yielding-stream.d.ts.map +1 -0
- package/dist/types/yielding-stream.js +34 -0
- package/dist/types/yielding-stream.js.map +1 -0
- package/dist/yielding-stream.d.ts +60 -0
- package/dist/yielding-stream.d.ts.map +1 -0
- package/dist/yielding-stream.js +204 -0
- package/dist/yielding-stream.js.map +1 -0
- package/package.json +1 -1
- package/src/membrane.ts +691 -1
- package/src/providers/mock.ts +47 -2
- package/src/providers/openai-compatible.ts +8 -3
- package/src/providers/openai.ts +8 -3
- package/src/providers/openrouter.ts +8 -3
- package/src/types/index.ts +23 -0
- package/src/types/yielding-stream.ts +228 -0
- package/src/yielding-stream.ts +271 -0
package/src/providers/mock.ts
CHANGED
|
@@ -12,6 +12,7 @@ import type {
|
|
|
12
12
|
ProviderResponse,
|
|
13
13
|
StreamCallbacks,
|
|
14
14
|
} from '../types/provider.js';
|
|
15
|
+
import { abortError } from '../types/errors.js';
|
|
15
16
|
|
|
16
17
|
export interface MockAdapterConfig {
|
|
17
18
|
/** Default response text when no specific response is configured */
|
|
@@ -157,12 +158,15 @@ export class MockAdapter implements ProviderAdapter {
|
|
|
157
158
|
request: ProviderRequest,
|
|
158
159
|
options?: ProviderRequestOptions
|
|
159
160
|
): Promise<ProviderResponse> {
|
|
161
|
+
// Check for abort before starting
|
|
162
|
+
this.checkAbort(options?.signal);
|
|
163
|
+
|
|
160
164
|
// Call onRequest callback if provided
|
|
161
165
|
options?.onRequest?.(request);
|
|
162
166
|
|
|
163
167
|
// Simulate processing delay
|
|
164
168
|
if (this.config.completeDelayMs > 0) {
|
|
165
|
-
await this.
|
|
169
|
+
await this.abortableSleep(this.config.completeDelayMs, options?.signal);
|
|
166
170
|
}
|
|
167
171
|
|
|
168
172
|
const responseText = this.getResponse(request);
|
|
@@ -185,20 +189,28 @@ export class MockAdapter implements ProviderAdapter {
|
|
|
185
189
|
callbacks: StreamCallbacks,
|
|
186
190
|
options?: ProviderRequestOptions
|
|
187
191
|
): Promise<ProviderResponse> {
|
|
192
|
+
// Check for abort before starting
|
|
193
|
+
this.checkAbort(options?.signal);
|
|
194
|
+
|
|
188
195
|
// Call onRequest callback if provided
|
|
189
196
|
options?.onRequest?.(request);
|
|
190
197
|
|
|
191
198
|
const responseText = this.getResponse(request);
|
|
199
|
+
let streamedText = '';
|
|
192
200
|
|
|
193
201
|
// Stream the response in chunks
|
|
194
202
|
let offset = 0;
|
|
195
203
|
while (offset < responseText.length) {
|
|
204
|
+
// Check for abort before each chunk
|
|
205
|
+
this.checkAbort(options?.signal);
|
|
206
|
+
|
|
196
207
|
const chunk = responseText.slice(offset, offset + this.config.streamChunkSize);
|
|
197
208
|
callbacks.onChunk(chunk);
|
|
209
|
+
streamedText += chunk;
|
|
198
210
|
offset += this.config.streamChunkSize;
|
|
199
211
|
|
|
200
212
|
if (offset < responseText.length && this.config.streamChunkDelayMs > 0) {
|
|
201
|
-
await this.
|
|
213
|
+
await this.abortableSleep(this.config.streamChunkDelayMs, options?.signal);
|
|
202
214
|
}
|
|
203
215
|
}
|
|
204
216
|
|
|
@@ -215,6 +227,39 @@ export class MockAdapter implements ProviderAdapter {
|
|
|
215
227
|
};
|
|
216
228
|
}
|
|
217
229
|
|
|
230
|
+
/**
|
|
231
|
+
* Check if the abort signal is set and throw if so.
|
|
232
|
+
*/
|
|
233
|
+
private checkAbort(signal?: AbortSignal): void {
|
|
234
|
+
if (signal?.aborted) {
|
|
235
|
+
throw abortError('Request aborted');
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Sleep that can be interrupted by an abort signal.
|
|
241
|
+
*/
|
|
242
|
+
private abortableSleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|
243
|
+
return new Promise((resolve, reject) => {
|
|
244
|
+
if (signal?.aborted) {
|
|
245
|
+
reject(abortError('Request aborted'));
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const onAbort = () => {
|
|
250
|
+
clearTimeout(timeout);
|
|
251
|
+
reject(abortError('Request aborted'));
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
const timeout = setTimeout(() => {
|
|
255
|
+
signal?.removeEventListener('abort', onAbort);
|
|
256
|
+
resolve();
|
|
257
|
+
}, ms);
|
|
258
|
+
|
|
259
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
|
|
218
263
|
private sleep(ms: number): Promise<void> {
|
|
219
264
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
220
265
|
}
|
|
@@ -350,16 +350,21 @@ export class OpenAICompatibleAdapter implements ProviderAdapter {
|
|
|
350
350
|
return toolResults;
|
|
351
351
|
}
|
|
352
352
|
|
|
353
|
+
// Skip messages with no usable content (image-only, embed-only messages)
|
|
354
|
+
if (textParts.length === 0 && toolCalls.length === 0) {
|
|
355
|
+
return [];
|
|
356
|
+
}
|
|
357
|
+
|
|
353
358
|
// Otherwise build normal message
|
|
354
359
|
const result: OpenAIMessage = {
|
|
355
360
|
role: msg.role,
|
|
356
|
-
content: textParts.join('\n')
|
|
361
|
+
content: textParts.length > 0 ? textParts.join('\n') : null,
|
|
357
362
|
};
|
|
358
|
-
|
|
363
|
+
|
|
359
364
|
if (toolCalls.length > 0) {
|
|
360
365
|
result.tool_calls = toolCalls;
|
|
361
366
|
}
|
|
362
|
-
|
|
367
|
+
|
|
363
368
|
return [result];
|
|
364
369
|
}
|
|
365
370
|
|
package/src/providers/openai.ts
CHANGED
|
@@ -449,16 +449,21 @@ export class OpenAIAdapter implements ProviderAdapter {
|
|
|
449
449
|
return toolResults;
|
|
450
450
|
}
|
|
451
451
|
|
|
452
|
+
// Skip messages with no usable content (image-only, embed-only messages)
|
|
453
|
+
if (textParts.length === 0 && toolCalls.length === 0) {
|
|
454
|
+
return [];
|
|
455
|
+
}
|
|
456
|
+
|
|
452
457
|
// Otherwise build normal message
|
|
453
458
|
const result: OpenAIMessage = {
|
|
454
459
|
role: msg.role,
|
|
455
|
-
content: textParts.join('\n')
|
|
460
|
+
content: textParts.length > 0 ? textParts.join('\n') : null,
|
|
456
461
|
};
|
|
457
|
-
|
|
462
|
+
|
|
458
463
|
if (toolCalls.length > 0) {
|
|
459
464
|
result.tool_calls = toolCalls;
|
|
460
465
|
}
|
|
461
|
-
|
|
466
|
+
|
|
462
467
|
return [result];
|
|
463
468
|
}
|
|
464
469
|
|
|
@@ -407,17 +407,22 @@ export class OpenRouterAdapter implements ProviderAdapter {
|
|
|
407
407
|
return toolResults;
|
|
408
408
|
}
|
|
409
409
|
|
|
410
|
+
// Skip messages with no usable content (image-only, embed-only messages)
|
|
411
|
+
if (textParts.length === 0 && toolCalls.length === 0) {
|
|
412
|
+
return [];
|
|
413
|
+
}
|
|
414
|
+
|
|
410
415
|
// Otherwise build normal message
|
|
411
416
|
const result: OpenRouterMessage = {
|
|
412
417
|
role: msg.role,
|
|
413
418
|
// Use content blocks array if caching is in use, otherwise concatenate text
|
|
414
|
-
content: hasCache ? contentBlocks : (textParts.join('\n')
|
|
419
|
+
content: hasCache ? contentBlocks : (textParts.length > 0 ? textParts.join('\n') : null),
|
|
415
420
|
};
|
|
416
|
-
|
|
421
|
+
|
|
417
422
|
if (toolCalls.length > 0) {
|
|
418
423
|
result.tool_calls = toolCalls;
|
|
419
424
|
}
|
|
420
|
-
|
|
425
|
+
|
|
421
426
|
return [result];
|
|
422
427
|
}
|
|
423
428
|
|
package/src/types/index.ts
CHANGED
|
@@ -120,6 +120,29 @@ export type {
|
|
|
120
120
|
BlockDelta,
|
|
121
121
|
} from './streaming.js';
|
|
122
122
|
|
|
123
|
+
// Yielding Stream
|
|
124
|
+
export type {
|
|
125
|
+
TokensEvent,
|
|
126
|
+
StreamBlockEvent,
|
|
127
|
+
ToolCallsEvent,
|
|
128
|
+
UsageEvent,
|
|
129
|
+
CompleteEvent,
|
|
130
|
+
ErrorEvent,
|
|
131
|
+
AbortedEvent,
|
|
132
|
+
StreamEvent,
|
|
133
|
+
YieldingStream,
|
|
134
|
+
YieldingStreamOptions,
|
|
135
|
+
} from './yielding-stream.js';
|
|
136
|
+
|
|
137
|
+
export {
|
|
138
|
+
isTokensEvent,
|
|
139
|
+
isToolCallsEvent,
|
|
140
|
+
isCompleteEvent,
|
|
141
|
+
isErrorEvent,
|
|
142
|
+
isAbortedEvent,
|
|
143
|
+
isTerminalEvent,
|
|
144
|
+
} from './yielding-stream.js';
|
|
145
|
+
|
|
123
146
|
// Errors
|
|
124
147
|
export type {
|
|
125
148
|
MembraneErrorType,
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Yielding stream types for membrane
|
|
3
|
+
*
|
|
4
|
+
* This module defines the interface for a streaming API that yields control
|
|
5
|
+
* back to the caller when tool calls are detected, rather than handling them
|
|
6
|
+
* internally via callbacks.
|
|
7
|
+
*
|
|
8
|
+
* @see agent-framework/docs/yielding-stream-architecture.md
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { ContentBlock } from './content.js';
|
|
12
|
+
import type { ToolCall, ToolResult, ToolContext } from './tools.js';
|
|
13
|
+
import type { BasicUsage, NormalizedResponse, StopReason } from './response.js';
|
|
14
|
+
import type { ChunkMeta, BlockEvent } from './streaming.js';
|
|
15
|
+
|
|
16
|
+
// ============================================================================
|
|
17
|
+
// Stream Events
|
|
18
|
+
// ============================================================================
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Token/chunk event - raw text as it arrives from the LLM.
|
|
22
|
+
*/
|
|
23
|
+
export interface TokensEvent {
|
|
24
|
+
type: 'tokens';
|
|
25
|
+
content: string;
|
|
26
|
+
meta: ChunkMeta;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Block event - structural block start/complete notifications.
|
|
31
|
+
*/
|
|
32
|
+
export interface StreamBlockEvent {
|
|
33
|
+
type: 'block';
|
|
34
|
+
event: BlockEvent;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Tool calls event - LLM has requested tool execution.
|
|
39
|
+
* The stream pauses here until results are provided via provideToolResults().
|
|
40
|
+
*/
|
|
41
|
+
export interface ToolCallsEvent {
|
|
42
|
+
type: 'tool-calls';
|
|
43
|
+
calls: ToolCall[];
|
|
44
|
+
context: ToolContext;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Usage update event - token counts updated.
|
|
49
|
+
*/
|
|
50
|
+
export interface UsageEvent {
|
|
51
|
+
type: 'usage';
|
|
52
|
+
usage: BasicUsage;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Complete event - inference cycle finished successfully.
|
|
57
|
+
*/
|
|
58
|
+
export interface CompleteEvent {
|
|
59
|
+
type: 'complete';
|
|
60
|
+
response: NormalizedResponse;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Error event - something went wrong.
|
|
65
|
+
*/
|
|
66
|
+
export interface ErrorEvent {
|
|
67
|
+
type: 'error';
|
|
68
|
+
error: Error;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Aborted event - stream was cancelled.
|
|
73
|
+
*/
|
|
74
|
+
export interface AbortedEvent {
|
|
75
|
+
type: 'aborted';
|
|
76
|
+
reason: 'user' | 'timeout' | 'error';
|
|
77
|
+
partialContent?: ContentBlock[];
|
|
78
|
+
rawAssistantText?: string;
|
|
79
|
+
toolCalls?: ToolCall[];
|
|
80
|
+
toolResults?: ToolResult[];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Union of all stream events.
|
|
85
|
+
*/
|
|
86
|
+
export type StreamEvent =
|
|
87
|
+
| TokensEvent
|
|
88
|
+
| StreamBlockEvent
|
|
89
|
+
| ToolCallsEvent
|
|
90
|
+
| UsageEvent
|
|
91
|
+
| CompleteEvent
|
|
92
|
+
| ErrorEvent
|
|
93
|
+
| AbortedEvent;
|
|
94
|
+
|
|
95
|
+
// ============================================================================
|
|
96
|
+
// Yielding Stream Interface
|
|
97
|
+
// ============================================================================
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* A streaming inference that yields control to the caller for tool execution.
|
|
101
|
+
*
|
|
102
|
+
* Usage:
|
|
103
|
+
* ```typescript
|
|
104
|
+
* const stream = membrane.streamYielding(request, options);
|
|
105
|
+
*
|
|
106
|
+
* for await (const event of stream) {
|
|
107
|
+
* switch (event.type) {
|
|
108
|
+
* case 'tokens':
|
|
109
|
+
* process.stdout.write(event.content);
|
|
110
|
+
* break;
|
|
111
|
+
* case 'tool-calls':
|
|
112
|
+
* const results = await executeTools(event.calls);
|
|
113
|
+
* stream.provideToolResults(results);
|
|
114
|
+
* break;
|
|
115
|
+
* case 'complete':
|
|
116
|
+
* console.log('Done:', event.response);
|
|
117
|
+
* break;
|
|
118
|
+
* case 'error':
|
|
119
|
+
* console.error('Error:', event.error);
|
|
120
|
+
* break;
|
|
121
|
+
* }
|
|
122
|
+
* }
|
|
123
|
+
* ```
|
|
124
|
+
*/
|
|
125
|
+
export interface YieldingStream extends AsyncIterable<StreamEvent> {
|
|
126
|
+
/**
|
|
127
|
+
* Provide tool results after receiving a 'tool-calls' event.
|
|
128
|
+
* The stream will resume and continue generating.
|
|
129
|
+
*
|
|
130
|
+
* @param results - Results for the tool calls (must match call IDs)
|
|
131
|
+
* @throws Error if called when not waiting for tool results
|
|
132
|
+
*/
|
|
133
|
+
provideToolResults(results: ToolResult[]): void;
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Cancel the stream. Any in-flight requests will be aborted.
|
|
137
|
+
* The iterator will yield an 'aborted' event and then complete.
|
|
138
|
+
*/
|
|
139
|
+
cancel(): void;
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Check if the stream is currently waiting for tool results.
|
|
143
|
+
*/
|
|
144
|
+
readonly isWaitingForTools: boolean;
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Get the IDs of tool calls we're waiting for results for.
|
|
148
|
+
* Empty if not waiting for tools.
|
|
149
|
+
*/
|
|
150
|
+
readonly pendingToolCallIds: string[];
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Current tool execution depth (0 = first inference, 1 = after first tool round, etc.)
|
|
154
|
+
*/
|
|
155
|
+
readonly toolDepth: number;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ============================================================================
|
|
159
|
+
// Yielding Stream Options
|
|
160
|
+
// ============================================================================
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Options for streamYielding().
|
|
164
|
+
* Simpler than StreamOptions since tool execution is handled externally.
|
|
165
|
+
*/
|
|
166
|
+
export interface YieldingStreamOptions {
|
|
167
|
+
/** Abort signal for cancellation */
|
|
168
|
+
signal?: AbortSignal;
|
|
169
|
+
|
|
170
|
+
/** Request timeout (per API call, not total) */
|
|
171
|
+
timeoutMs?: number;
|
|
172
|
+
|
|
173
|
+
/** Request ID for correlation/logging */
|
|
174
|
+
requestId?: string;
|
|
175
|
+
|
|
176
|
+
/** Maximum tool execution depth (default: 10) */
|
|
177
|
+
maxToolDepth?: number;
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Whether to emit 'tokens' events.
|
|
181
|
+
* Set to false if you only care about tool calls and final response.
|
|
182
|
+
* Default: true
|
|
183
|
+
*/
|
|
184
|
+
emitTokens?: boolean;
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Whether to emit 'block' events.
|
|
188
|
+
* Default: true
|
|
189
|
+
*/
|
|
190
|
+
emitBlocks?: boolean;
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Whether to emit 'usage' events.
|
|
194
|
+
* Default: true
|
|
195
|
+
*/
|
|
196
|
+
emitUsage?: boolean;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ============================================================================
|
|
200
|
+
// Type Guards
|
|
201
|
+
// ============================================================================
|
|
202
|
+
|
|
203
|
+
export function isTokensEvent(event: StreamEvent): event is TokensEvent {
|
|
204
|
+
return event.type === 'tokens';
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function isToolCallsEvent(event: StreamEvent): event is ToolCallsEvent {
|
|
208
|
+
return event.type === 'tool-calls';
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function isCompleteEvent(event: StreamEvent): event is CompleteEvent {
|
|
212
|
+
return event.type === 'complete';
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function isErrorEvent(event: StreamEvent): event is ErrorEvent {
|
|
216
|
+
return event.type === 'error';
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function isAbortedEvent(event: StreamEvent): event is AbortedEvent {
|
|
220
|
+
return event.type === 'aborted';
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Check if the stream has terminated (complete, error, or aborted).
|
|
225
|
+
*/
|
|
226
|
+
export function isTerminalEvent(event: StreamEvent): event is CompleteEvent | ErrorEvent | AbortedEvent {
|
|
227
|
+
return event.type === 'complete' || event.type === 'error' || event.type === 'aborted';
|
|
228
|
+
}
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* YieldingStream implementation
|
|
3
|
+
*
|
|
4
|
+
* Provides an async iterator interface for streaming inference that yields
|
|
5
|
+
* control back to the caller for tool execution.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type {
|
|
9
|
+
StreamEvent,
|
|
10
|
+
YieldingStream,
|
|
11
|
+
YieldingStreamOptions,
|
|
12
|
+
ToolCallsEvent,
|
|
13
|
+
} from './types/yielding-stream.js';
|
|
14
|
+
import type { ToolResult } from './types/tools.js';
|
|
15
|
+
|
|
16
|
+
// ============================================================================
|
|
17
|
+
// Internal State Types
|
|
18
|
+
// ============================================================================
|
|
19
|
+
|
|
20
|
+
type StreamState =
|
|
21
|
+
| { status: 'idle' }
|
|
22
|
+
| { status: 'streaming' }
|
|
23
|
+
| { status: 'waiting_for_tools'; pendingCallIds: string[] }
|
|
24
|
+
| { status: 'done' }
|
|
25
|
+
| { status: 'error'; error: Error };
|
|
26
|
+
|
|
27
|
+
interface PendingToolResults {
|
|
28
|
+
resolve: (results: ToolResult[]) => void;
|
|
29
|
+
reject: (error: Error) => void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ============================================================================
|
|
33
|
+
// YieldingStreamImpl
|
|
34
|
+
// ============================================================================
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Implementation of the YieldingStream interface.
|
|
38
|
+
*
|
|
39
|
+
* This class manages:
|
|
40
|
+
* - An event queue for yielding events to the consumer
|
|
41
|
+
* - A promise-based handshake for tool results
|
|
42
|
+
* - Cancellation via AbortController
|
|
43
|
+
* - State tracking for debugging and validation
|
|
44
|
+
*/
|
|
45
|
+
export class YieldingStreamImpl implements YieldingStream {
|
|
46
|
+
private state: StreamState = { status: 'idle' };
|
|
47
|
+
private eventQueue: StreamEvent[] = [];
|
|
48
|
+
private pendingToolResults: PendingToolResults | null = null;
|
|
49
|
+
private abortController: AbortController;
|
|
50
|
+
private _toolDepth = 0;
|
|
51
|
+
|
|
52
|
+
// Promise/resolver for the async iterator to wait on new events
|
|
53
|
+
private eventWaiter: {
|
|
54
|
+
resolve: () => void;
|
|
55
|
+
promise: Promise<void>;
|
|
56
|
+
} | null = null;
|
|
57
|
+
|
|
58
|
+
// Flag indicating the stream producer is done
|
|
59
|
+
private producerDone = false;
|
|
60
|
+
|
|
61
|
+
constructor(
|
|
62
|
+
private readonly options: YieldingStreamOptions,
|
|
63
|
+
private readonly runInference: (stream: YieldingStreamImpl) => Promise<void>
|
|
64
|
+
) {
|
|
65
|
+
this.abortController = new AbortController();
|
|
66
|
+
|
|
67
|
+
// Link external signal if provided
|
|
68
|
+
if (options.signal) {
|
|
69
|
+
options.signal.addEventListener('abort', () => {
|
|
70
|
+
this.cancel();
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ============================================================================
|
|
76
|
+
// Public Interface
|
|
77
|
+
// ============================================================================
|
|
78
|
+
|
|
79
|
+
get isWaitingForTools(): boolean {
|
|
80
|
+
return this.state.status === 'waiting_for_tools';
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
get pendingToolCallIds(): string[] {
|
|
84
|
+
if (this.state.status === 'waiting_for_tools') {
|
|
85
|
+
return this.state.pendingCallIds;
|
|
86
|
+
}
|
|
87
|
+
return [];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
get toolDepth(): number {
|
|
91
|
+
return this._toolDepth;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Get the abort signal for use in internal operations.
|
|
96
|
+
*/
|
|
97
|
+
get signal(): AbortSignal {
|
|
98
|
+
return this.abortController.signal;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
provideToolResults(results: ToolResult[]): void {
|
|
102
|
+
if (this.state.status !== 'waiting_for_tools') {
|
|
103
|
+
throw new Error(
|
|
104
|
+
`Cannot provide tool results: stream is not waiting for tools (status: ${this.state.status})`
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (!this.pendingToolResults) {
|
|
109
|
+
throw new Error('Internal error: no pending tool results promise');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Validate that results match pending call IDs
|
|
113
|
+
const pendingIds = new Set(this.state.pendingCallIds);
|
|
114
|
+
const providedIds = new Set(results.map((r) => r.toolUseId));
|
|
115
|
+
|
|
116
|
+
for (const id of pendingIds) {
|
|
117
|
+
if (!providedIds.has(id)) {
|
|
118
|
+
throw new Error(`Missing tool result for call ID: ${id}`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Resolve the promise and transition state
|
|
123
|
+
this.pendingToolResults.resolve(results);
|
|
124
|
+
this.pendingToolResults = null;
|
|
125
|
+
this.state = { status: 'streaming' };
|
|
126
|
+
this._toolDepth++;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
cancel(): void {
|
|
130
|
+
if (this.state.status === 'done' || this.state.status === 'error') {
|
|
131
|
+
return; // Already terminated
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
this.abortController.abort();
|
|
135
|
+
|
|
136
|
+
// If waiting for tools, reject the pending promise
|
|
137
|
+
if (this.pendingToolResults) {
|
|
138
|
+
this.pendingToolResults.reject(new Error('Stream cancelled'));
|
|
139
|
+
this.pendingToolResults = null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Emit aborted event and wake the iterator so it can deliver it
|
|
143
|
+
this.emit({ type: 'aborted', reason: 'user' });
|
|
144
|
+
this.producerDone = true;
|
|
145
|
+
this.state = { status: 'done' };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ============================================================================
|
|
149
|
+
// Async Iterator Implementation
|
|
150
|
+
// ============================================================================
|
|
151
|
+
|
|
152
|
+
[Symbol.asyncIterator](): AsyncIterator<StreamEvent> {
|
|
153
|
+
// Start the inference loop when iteration begins
|
|
154
|
+
this.startInference();
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
next: async (): Promise<IteratorResult<StreamEvent>> => {
|
|
158
|
+
while (true) {
|
|
159
|
+
// Check for queued events
|
|
160
|
+
const event = this.eventQueue.shift();
|
|
161
|
+
if (event) {
|
|
162
|
+
// Check if this is a terminal event
|
|
163
|
+
if (
|
|
164
|
+
event.type === 'complete' ||
|
|
165
|
+
event.type === 'error' ||
|
|
166
|
+
event.type === 'aborted'
|
|
167
|
+
) {
|
|
168
|
+
this.state = { status: 'done' };
|
|
169
|
+
}
|
|
170
|
+
return { value: event, done: false };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// If producer is done and queue is empty, we're done
|
|
174
|
+
if (this.producerDone) {
|
|
175
|
+
return { value: undefined as unknown as StreamEvent, done: true };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Wait for more events
|
|
179
|
+
await this.waitForEvent();
|
|
180
|
+
}
|
|
181
|
+
},
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ============================================================================
|
|
186
|
+
// Internal Methods (called by the inference loop)
|
|
187
|
+
// ============================================================================
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Push an event to be yielded to the consumer.
|
|
191
|
+
*/
|
|
192
|
+
emit(event: StreamEvent): void {
|
|
193
|
+
this.eventQueue.push(event);
|
|
194
|
+
this.notifyEventWaiter();
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Request tool execution and wait for results.
|
|
199
|
+
* Called by the inference loop when tool calls are detected.
|
|
200
|
+
*/
|
|
201
|
+
async requestToolExecution(event: ToolCallsEvent): Promise<ToolResult[]> {
|
|
202
|
+
// Emit the tool calls event
|
|
203
|
+
this.emit(event);
|
|
204
|
+
|
|
205
|
+
// Transition to waiting state
|
|
206
|
+
this.state = {
|
|
207
|
+
status: 'waiting_for_tools',
|
|
208
|
+
pendingCallIds: event.calls.map((c) => c.id),
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
// Create a promise that will be resolved by provideToolResults()
|
|
212
|
+
return new Promise<ToolResult[]>((resolve, reject) => {
|
|
213
|
+
this.pendingToolResults = { resolve, reject };
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Mark the producer as done (inference loop finished).
|
|
219
|
+
*/
|
|
220
|
+
markDone(): void {
|
|
221
|
+
this.producerDone = true;
|
|
222
|
+
this.notifyEventWaiter();
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Check if the stream has been cancelled.
|
|
227
|
+
*/
|
|
228
|
+
get isCancelled(): boolean {
|
|
229
|
+
return this.abortController.signal.aborted;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ============================================================================
|
|
233
|
+
// Private Helpers
|
|
234
|
+
// ============================================================================
|
|
235
|
+
|
|
236
|
+
private startInference(): void {
|
|
237
|
+
if (this.state.status !== 'idle') {
|
|
238
|
+
return; // Already started
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
this.state = { status: 'streaming' };
|
|
242
|
+
|
|
243
|
+
// Run the inference loop in the background
|
|
244
|
+
this.runInference(this)
|
|
245
|
+
.then(() => {
|
|
246
|
+
this.markDone();
|
|
247
|
+
})
|
|
248
|
+
.catch((error) => {
|
|
249
|
+
this.emit({ type: 'error', error });
|
|
250
|
+
this.markDone();
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
private waitForEvent(): Promise<void> {
|
|
255
|
+
if (!this.eventWaiter) {
|
|
256
|
+
let resolve: () => void;
|
|
257
|
+
const promise = new Promise<void>((r) => {
|
|
258
|
+
resolve = r;
|
|
259
|
+
});
|
|
260
|
+
this.eventWaiter = { resolve: resolve!, promise };
|
|
261
|
+
}
|
|
262
|
+
return this.eventWaiter.promise;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
private notifyEventWaiter(): void {
|
|
266
|
+
if (this.eventWaiter) {
|
|
267
|
+
this.eventWaiter.resolve();
|
|
268
|
+
this.eventWaiter = null;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|