@capekai/core 1.0.8 → 1.0.10
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/package.json +4 -5
- package/src/compaction/task.ts +3 -1
- package/src/compaction/usage.ts +25 -0
- package/src/core/agent.ts +40 -7
- package/src/core/chat-handler.ts +23 -84
- package/src/core/message-utils.ts +8 -0
- package/src/core/model-utils.ts +8 -24
- package/src/core/step-handlers.ts +4 -3
- package/src/core/stream/stream-config.ts +5 -1
- package/src/goals/evaluator.ts +50 -21
- package/src/goals/loop.ts +4 -0
- package/src/providers/protocol-model.ts +54 -0
- package/src/retry/stream-chat.ts +97 -9
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@capekai/core",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.10",
|
|
4
4
|
"description": "Bun-native composable agent runtime and framework for Capek.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -75,8 +75,9 @@
|
|
|
75
75
|
"access": "public"
|
|
76
76
|
},
|
|
77
77
|
"dependencies": {
|
|
78
|
-
"@ai-sdk/
|
|
78
|
+
"@ai-sdk/anthropic": "^3.0.116",
|
|
79
79
|
"@ai-sdk/openai": "^3.0.84",
|
|
80
|
+
"@ai-sdk/openai-compatible": "^2.0.74",
|
|
80
81
|
"@capekai/tool": "^1.0.3",
|
|
81
82
|
"@capekai/types": "^1.0.3",
|
|
82
83
|
"@openrouter/ai-sdk-provider": "^2.3.3",
|
|
@@ -84,9 +85,7 @@
|
|
|
84
85
|
"ai": "^6.0.116",
|
|
85
86
|
"ignore": "^7.0.3",
|
|
86
87
|
"picomatch": "^4.0.2",
|
|
87
|
-
"tar": "^7.5.13"
|
|
88
|
-
"vercel-minimax-ai-provider": "^0.0.2",
|
|
89
|
-
"zhipu-ai-provider": "^0.2.2"
|
|
88
|
+
"tar": "^7.5.13"
|
|
90
89
|
},
|
|
91
90
|
"scripts": {
|
|
92
91
|
"build": "tsc --noEmit",
|
package/src/compaction/task.ts
CHANGED
|
@@ -404,7 +404,9 @@ export async function processCompactionTask(
|
|
|
404
404
|
const hasUserMessage = messagesToCompact.some(
|
|
405
405
|
(m: MessageWithParts) => m.message.role === 'user',
|
|
406
406
|
);
|
|
407
|
-
|
|
407
|
+
// Mid-turn continuation adds assistant/tool progress without a new user
|
|
408
|
+
// message. The previous summary carries the original user objective.
|
|
409
|
+
if (!hasUserMessage && !previousSummaryText) {
|
|
408
410
|
throw new Error('Compaction boundary must contain at least one user message');
|
|
409
411
|
}
|
|
410
412
|
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { ModelMessage } from 'ai';
|
|
2
|
+
|
|
3
|
+
/** Approximation for new model-facing items not covered by provider usage yet.
|
|
4
|
+
* Count serialized UTF-8 bytes, not the raw tool payload before projection. */
|
|
5
|
+
export function estimateMessageTokens(messages: readonly ModelMessage[]): number {
|
|
6
|
+
return Math.ceil(new TextEncoder().encode(JSON.stringify(messages)).byteLength / 4);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function estimateNextStepTokens(step: {
|
|
10
|
+
usage?: { inputTokens?: number; outputTokens?: number; totalTokens?: number };
|
|
11
|
+
response?: { messages: readonly ModelMessage[] };
|
|
12
|
+
}): number {
|
|
13
|
+
const messages = step.response?.messages ?? [];
|
|
14
|
+
let lastAssistant = -1;
|
|
15
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
16
|
+
if (messages[i].role === 'assistant') {
|
|
17
|
+
lastAssistant = i;
|
|
18
|
+
break;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
const appended = messages.slice(lastAssistant + 1);
|
|
22
|
+
const usage = step.usage;
|
|
23
|
+
return (usage?.totalTokens ?? ((usage?.inputTokens ?? 0) + (usage?.outputTokens ?? 0)))
|
|
24
|
+
+ (appended.length ? estimateMessageTokens(appended) : 0);
|
|
25
|
+
}
|
package/src/core/agent.ts
CHANGED
|
@@ -41,6 +41,9 @@ export interface ChatOptions {
|
|
|
41
41
|
broadcastFn?: BuildToolsOptions['broadcastFn'];
|
|
42
42
|
responseFormat?: ResponseFormat;
|
|
43
43
|
retryAbortController?: AbortController;
|
|
44
|
+
/** The caller handles needs_compaction and resumes from persisted history. */
|
|
45
|
+
compactBetweenSteps?: boolean;
|
|
46
|
+
continueFromCompaction?: boolean;
|
|
44
47
|
}
|
|
45
48
|
|
|
46
49
|
async function collectInterruptedToolPartEvents(
|
|
@@ -72,7 +75,7 @@ export interface ChatResult {
|
|
|
72
75
|
toolCalls: ToolPart[];
|
|
73
76
|
}
|
|
74
77
|
|
|
75
|
-
export async function* streamChat(options: ChatOptions): AsyncGenerator<MessageEvent | { type: 'usage'; usage: UsageEventData; model: string; variant: string | null } | { type: 'needs_compaction'; sessionId: string } | ErrorEvent> {
|
|
78
|
+
export async function* streamChat(options: ChatOptions): AsyncGenerator<(MessageEvent & { continuation?: boolean }) | { type: 'usage'; usage: UsageEventData; model: string; variant: string | null } | { type: 'needs_compaction'; sessionId: string; resume?: boolean } | ErrorEvent> {
|
|
76
79
|
const { sessionId: _sessionId, preconfig, messages, modelId, providerId, variant, workspacePath, workspaceId, maxSteps, compactionPolicy } = options;
|
|
77
80
|
|
|
78
81
|
const managesSessionLifecycle = !options.retryAbortController;
|
|
@@ -141,7 +144,7 @@ export async function* streamChat(options: ChatOptions): AsyncGenerator<MessageE
|
|
|
141
144
|
selfDelegationAvailable,
|
|
142
145
|
});
|
|
143
146
|
|
|
144
|
-
const { model, useProviderInstructions, omitMaxOutputTokens, omitTemperature, providerOptions: baseProviderOptions } =
|
|
147
|
+
const { model, replayReasoning, useProviderInstructions, omitMaxOutputTokens, omitTemperature, providerOptions: baseProviderOptions } =
|
|
145
148
|
await getModelWithMetadata({
|
|
146
149
|
modelId: resolvedModelId,
|
|
147
150
|
providerId,
|
|
@@ -154,7 +157,11 @@ export async function* streamChat(options: ChatOptions): AsyncGenerator<MessageE
|
|
|
154
157
|
|
|
155
158
|
// Convert messages for ai-sdk
|
|
156
159
|
const modelDef = resolvedModelId ? findModel(resolvedModelId) : undefined;
|
|
157
|
-
const aiMessages = await convertToAiSdkMessages(messages, modelDef?.capabilities);
|
|
160
|
+
const aiMessages = await convertToAiSdkMessages(messages, modelDef?.capabilities, { replayReasoning });
|
|
161
|
+
if (options.continueFromCompaction) {
|
|
162
|
+
// Execution instruction only: do not persist it as another user request.
|
|
163
|
+
aiMessages.push({ role: 'user', content: 'Continue the existing task from the checkpoint above. Preserve completed work and tool outcomes; do not restart or repeat completed actions. Follow the remaining steps, or report completion if nothing remains.' });
|
|
164
|
+
}
|
|
158
165
|
|
|
159
166
|
// Build stream config (variants, providerOptions, structured output)
|
|
160
167
|
const streamConfig = buildStreamConfig({
|
|
@@ -191,6 +198,7 @@ export async function* streamChat(options: ChatOptions): AsyncGenerator<MessageE
|
|
|
191
198
|
};
|
|
192
199
|
|
|
193
200
|
const { experimental_onStepStart, onStepFinish } = createStepCallbacks(stepCtx);
|
|
201
|
+
let stoppedForCompaction = false;
|
|
194
202
|
|
|
195
203
|
const result = streamText({
|
|
196
204
|
model,
|
|
@@ -200,7 +208,19 @@ export async function* streamChat(options: ChatOptions): AsyncGenerator<MessageE
|
|
|
200
208
|
maxOutputTokens: omitMaxOutputTokens ? undefined : getMaxOutputTokens(resolvedModelId),
|
|
201
209
|
providerOptions: streamConfig.providerOptions as Parameters<typeof streamText>[0]['providerOptions'],
|
|
202
210
|
...(omitTemperature ? {} : { temperature: streamConfig.temperature }),
|
|
203
|
-
stopWhen:
|
|
211
|
+
stopWhen: [
|
|
212
|
+
stepCountIs(streamConfig.maxSteps),
|
|
213
|
+
({ steps }) => {
|
|
214
|
+
const step = steps.at(-1);
|
|
215
|
+
const toolsSettled = step && step.toolCalls.length > 0
|
|
216
|
+
&& step.toolCalls.every(call => step.content.some(part =>
|
|
217
|
+
(part.type === 'tool-result' || part.type === 'tool-error') && part.toolCallId === call.toolCallId));
|
|
218
|
+
stoppedForCompaction = options.compactBetweenSteps === true
|
|
219
|
+
&& stepCtx.needsCompaction && Boolean(toolsSettled)
|
|
220
|
+
&& steps.length < streamConfig.maxSteps;
|
|
221
|
+
return stoppedForCompaction;
|
|
222
|
+
},
|
|
223
|
+
],
|
|
204
224
|
abortSignal: abortController.signal,
|
|
205
225
|
experimental_onStepStart,
|
|
206
226
|
onStepFinish,
|
|
@@ -269,6 +289,12 @@ export async function* streamChat(options: ChatOptions): AsyncGenerator<MessageE
|
|
|
269
289
|
case 'tool-result':
|
|
270
290
|
await handlers.handleToolResult(delta);
|
|
271
291
|
break;
|
|
292
|
+
case 'tool-error':
|
|
293
|
+
await handlers.handleToolResult({
|
|
294
|
+
toolCallId: delta.toolCallId,
|
|
295
|
+
output: { error: delta.error instanceof Error ? delta.error.message : String(delta.error) },
|
|
296
|
+
});
|
|
297
|
+
break;
|
|
272
298
|
case 'error': {
|
|
273
299
|
const error = (delta as { type: 'error'; error: unknown }).error;
|
|
274
300
|
throw error;
|
|
@@ -312,7 +338,10 @@ export async function* streamChat(options: ChatOptions): AsyncGenerator<MessageE
|
|
|
312
338
|
status: 'error',
|
|
313
339
|
error: classified.message,
|
|
314
340
|
};
|
|
315
|
-
yield {
|
|
341
|
+
yield {
|
|
342
|
+
type: 'message.updated', message: errorMessage,
|
|
343
|
+
continuation: options.compactBetweenSteps === true && classified.type === 'context_overflow',
|
|
344
|
+
};
|
|
316
345
|
await updateMessage(messageId, errorMessage, { syncFts: false });
|
|
317
346
|
await syncMessageFts(messageId);
|
|
318
347
|
yield createErrorEvent(classified);
|
|
@@ -356,7 +385,8 @@ export async function* streamChat(options: ChatOptions): AsyncGenerator<MessageE
|
|
|
356
385
|
...(structuredOutputData ? { structuredOutput: structuredOutputData } : {}),
|
|
357
386
|
};
|
|
358
387
|
|
|
359
|
-
|
|
388
|
+
await updateMessage(messageId, finalMessage, { syncFts: false });
|
|
389
|
+
yield { type: 'message.updated', message: finalMessage, continuation: stoppedForCompaction };
|
|
360
390
|
|
|
361
391
|
// Sync FTS once after all final parts and message state are persisted
|
|
362
392
|
await syncMessageFts(messageId);
|
|
@@ -378,7 +408,10 @@ export async function* streamChat(options: ChatOptions): AsyncGenerator<MessageE
|
|
|
378
408
|
}
|
|
379
409
|
|
|
380
410
|
if (isMainSession && stepCtx.needsCompaction) {
|
|
381
|
-
|
|
411
|
+
const resume = stoppedForCompaction
|
|
412
|
+
&& streamCtx.toolParts.length > 0
|
|
413
|
+
&& streamCtx.toolParts.every(part => part.state.status === 'completed' || part.state.status === 'error');
|
|
414
|
+
yield { type: 'needs_compaction', sessionId: _sessionId, resume };
|
|
382
415
|
}
|
|
383
416
|
}
|
|
384
417
|
|
package/src/core/chat-handler.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import type { ResponseFormat } from '@capekai/types';
|
|
1
|
+
import type { AssistantMessage, ResponseFormat } from '@capekai/types';
|
|
2
2
|
import {
|
|
3
3
|
emitRuntimeEvent,
|
|
4
|
+
emitSessionUpdated,
|
|
4
5
|
generateSessionTitle,
|
|
5
6
|
hasManualSessionTitle,
|
|
6
7
|
isDefaultSessionTitle,
|
|
@@ -30,8 +31,6 @@ import {
|
|
|
30
31
|
} from '../storage/runtime';
|
|
31
32
|
import type { AskBroadcastFn } from '../runtime/host';
|
|
32
33
|
import type { RuntimeDelivery, RuntimeEvent, RuntimeEventContext } from '../runtime/events';
|
|
33
|
-
import { getCompactionService } from '../compaction/policy';
|
|
34
|
-
import { executeCompaction } from '../compaction/executor';
|
|
35
34
|
import { getGoalDomain } from '../goals/service';
|
|
36
35
|
import { interruptManager } from './interrupt';
|
|
37
36
|
import { getApiKeyForProvider } from '../configuration/runtime';
|
|
@@ -88,7 +87,6 @@ async function drainQueue<Origin>(
|
|
|
88
87
|
interface ChatTurnResult {
|
|
89
88
|
streamCompleted: boolean;
|
|
90
89
|
interrupted: boolean;
|
|
91
|
-
needsAutoCompaction: boolean;
|
|
92
90
|
contextOverflow: boolean;
|
|
93
91
|
isFatal: boolean;
|
|
94
92
|
isQueueDrainable: boolean;
|
|
@@ -112,6 +110,7 @@ async function runSingleChatTurn<Origin>(
|
|
|
112
110
|
attachments?: Array<{ id: string; kind: string }>,
|
|
113
111
|
responseFormat?: ResponseFormat,
|
|
114
112
|
existingUserMessageId?: string,
|
|
113
|
+
retryAbortController?: AbortController,
|
|
115
114
|
): Promise<ChatTurnResult> {
|
|
116
115
|
let userMsgId: string;
|
|
117
116
|
|
|
@@ -204,8 +203,8 @@ async function runSingleChatTurn<Origin>(
|
|
|
204
203
|
}
|
|
205
204
|
};
|
|
206
205
|
|
|
207
|
-
let pendingCompaction = false;
|
|
208
206
|
let retryCancelled = false;
|
|
207
|
+
let terminalMessage: AssistantMessage | undefined;
|
|
209
208
|
const effectiveProvider = isSandboxActive() ? 'sandbox' : provider;
|
|
210
209
|
|
|
211
210
|
try {
|
|
@@ -221,6 +220,7 @@ async function runSingleChatTurn<Origin>(
|
|
|
221
220
|
additionalPaths,
|
|
222
221
|
broadcastFn: askBroadcastFn,
|
|
223
222
|
responseFormat,
|
|
223
|
+
retryAbortController,
|
|
224
224
|
})) {
|
|
225
225
|
switch (event.type) {
|
|
226
226
|
case 'message.created':
|
|
@@ -230,7 +230,8 @@ async function runSingleChatTurn<Origin>(
|
|
|
230
230
|
case 'message.updated':
|
|
231
231
|
updateMessage(event.message.id, event.message, { syncFts: false });
|
|
232
232
|
if (event.message.role === 'assistant' && event.message.mode !== 'retry_failed') {
|
|
233
|
-
|
|
233
|
+
terminalMessage = event.message;
|
|
234
|
+
if (!event.continuation) emitTerminal(event.message, sessionId);
|
|
234
235
|
}
|
|
235
236
|
deliverToSession(ctx, sessionId, { kind: 'message', action: 'updated', message: event.message });
|
|
236
237
|
break;
|
|
@@ -273,10 +274,6 @@ async function runSingleChatTurn<Origin>(
|
|
|
273
274
|
break;
|
|
274
275
|
}
|
|
275
276
|
|
|
276
|
-
case 'needs_compaction':
|
|
277
|
-
pendingCompaction = true;
|
|
278
|
-
break;
|
|
279
|
-
|
|
280
277
|
case 'chat.retry':
|
|
281
278
|
deliverToSession(ctx, sessionId, {
|
|
282
279
|
kind: 'retry',
|
|
@@ -306,7 +303,6 @@ async function runSingleChatTurn<Origin>(
|
|
|
306
303
|
return {
|
|
307
304
|
streamCompleted: false,
|
|
308
305
|
interrupted: false,
|
|
309
|
-
needsAutoCompaction: false,
|
|
310
306
|
contextOverflow: false,
|
|
311
307
|
isFatal: true,
|
|
312
308
|
isQueueDrainable: false,
|
|
@@ -327,7 +323,6 @@ async function runSingleChatTurn<Origin>(
|
|
|
327
323
|
return {
|
|
328
324
|
streamCompleted: false,
|
|
329
325
|
interrupted: false,
|
|
330
|
-
needsAutoCompaction: false,
|
|
331
326
|
contextOverflow: false,
|
|
332
327
|
isFatal: false,
|
|
333
328
|
isQueueDrainable: true,
|
|
@@ -348,7 +343,6 @@ async function runSingleChatTurn<Origin>(
|
|
|
348
343
|
return {
|
|
349
344
|
streamCompleted: false,
|
|
350
345
|
interrupted: false,
|
|
351
|
-
needsAutoCompaction: false,
|
|
352
346
|
contextOverflow: false,
|
|
353
347
|
isFatal: false,
|
|
354
348
|
isQueueDrainable: true,
|
|
@@ -368,7 +362,6 @@ async function runSingleChatTurn<Origin>(
|
|
|
368
362
|
return {
|
|
369
363
|
streamCompleted: false,
|
|
370
364
|
interrupted: false,
|
|
371
|
-
needsAutoCompaction: false,
|
|
372
365
|
contextOverflow: false,
|
|
373
366
|
isFatal: true,
|
|
374
367
|
isQueueDrainable: false,
|
|
@@ -386,7 +379,6 @@ async function runSingleChatTurn<Origin>(
|
|
|
386
379
|
return {
|
|
387
380
|
streamCompleted: false,
|
|
388
381
|
interrupted: false,
|
|
389
|
-
needsAutoCompaction: false,
|
|
390
382
|
contextOverflow: false,
|
|
391
383
|
isFatal: true,
|
|
392
384
|
isQueueDrainable: false,
|
|
@@ -395,10 +387,13 @@ async function runSingleChatTurn<Origin>(
|
|
|
395
387
|
};
|
|
396
388
|
|
|
397
389
|
case 'error.context_overflow': {
|
|
390
|
+
deliverToOrigin(ctx, origin, {
|
|
391
|
+
kind: 'failure', category: 'generic', code: 'context_overflow',
|
|
392
|
+
message: event.message, sessionId,
|
|
393
|
+
});
|
|
398
394
|
return {
|
|
399
395
|
streamCompleted: false,
|
|
400
396
|
interrupted: false,
|
|
401
|
-
needsAutoCompaction: false,
|
|
402
397
|
contextOverflow: true,
|
|
403
398
|
isFatal: false,
|
|
404
399
|
isQueueDrainable: false,
|
|
@@ -417,7 +412,6 @@ async function runSingleChatTurn<Origin>(
|
|
|
417
412
|
return {
|
|
418
413
|
streamCompleted: false,
|
|
419
414
|
interrupted: false,
|
|
420
|
-
needsAutoCompaction: false,
|
|
421
415
|
contextOverflow: false,
|
|
422
416
|
isFatal: true,
|
|
423
417
|
isQueueDrainable: false,
|
|
@@ -437,8 +431,7 @@ async function runSingleChatTurn<Origin>(
|
|
|
437
431
|
|
|
438
432
|
return {
|
|
439
433
|
streamCompleted: !retryCancelled,
|
|
440
|
-
interrupted: wasInterrupted || retryCancelled,
|
|
441
|
-
needsAutoCompaction: pendingCompaction,
|
|
434
|
+
interrupted: terminalMessage?.status === 'interrupted' || wasInterrupted || retryCancelled,
|
|
442
435
|
contextOverflow: false,
|
|
443
436
|
isFatal: false,
|
|
444
437
|
isQueueDrainable: false,
|
|
@@ -450,7 +443,6 @@ async function runSingleChatTurn<Origin>(
|
|
|
450
443
|
return {
|
|
451
444
|
streamCompleted: false,
|
|
452
445
|
interrupted: false,
|
|
453
|
-
needsAutoCompaction: false,
|
|
454
446
|
contextOverflow: false,
|
|
455
447
|
isFatal: true,
|
|
456
448
|
isQueueDrainable: false,
|
|
@@ -592,14 +584,12 @@ export async function handleChat<Origin>(
|
|
|
592
584
|
const responseFormat = responseFormatRecord ?? undefined;
|
|
593
585
|
|
|
594
586
|
if (goalCondition) {
|
|
595
|
-
const goalAbortController =
|
|
596
|
-
const checkInterval = setInterval(() => {
|
|
597
|
-
if (interruptManager.isSessionInterrupted(sessionId) && !goalAbortController.signal.aborted) {
|
|
598
|
-
goalAbortController.abort(new Error('Goal loop cancelled by user'));
|
|
599
|
-
}
|
|
600
|
-
}, 200);
|
|
587
|
+
const goalAbortController = interruptManager.registerSession(sessionId, session.parentId ?? undefined);
|
|
601
588
|
|
|
589
|
+
let firstGoalTurn = true;
|
|
602
590
|
try {
|
|
591
|
+
const running = await updateSession(sessionId, { runningAt: new Date().toISOString() });
|
|
592
|
+
if (running) emitSessionUpdated(running);
|
|
603
593
|
await getGoalDomain().runGoalLoop({
|
|
604
594
|
sessionId,
|
|
605
595
|
condition: goalCondition,
|
|
@@ -610,8 +600,10 @@ export async function handleChat<Origin>(
|
|
|
610
600
|
runTurn: async (turnContent: string) => {
|
|
611
601
|
const result = await runSingleChatTurn(
|
|
612
602
|
ctx, origin, sessionId, turnContent, preconfig, modelId, provider,
|
|
613
|
-
workspacePath, additionalPaths, session, undefined, responseFormat,
|
|
603
|
+
workspacePath, additionalPaths, session, firstGoalTurn ? attachments : undefined, responseFormat,
|
|
604
|
+
undefined, goalAbortController,
|
|
614
605
|
);
|
|
606
|
+
firstGoalTurn = false;
|
|
615
607
|
return {
|
|
616
608
|
streamCompleted: result.streamCompleted,
|
|
617
609
|
interrupted: result.interrupted,
|
|
@@ -619,14 +611,15 @@ export async function handleChat<Origin>(
|
|
|
619
611
|
},
|
|
620
612
|
});
|
|
621
613
|
} finally {
|
|
622
|
-
|
|
614
|
+
interruptManager.unregisterSession(sessionId);
|
|
615
|
+
const stopped = await updateSession(sessionId, { runningAt: null });
|
|
616
|
+
if (stopped) emitSessionUpdated(stopped);
|
|
623
617
|
}
|
|
624
618
|
return;
|
|
625
619
|
}
|
|
626
620
|
|
|
627
621
|
let currentContent: string = content;
|
|
628
622
|
let currentAttachments: Array<{ id: string; kind: string }> | undefined = attachments;
|
|
629
|
-
let overflowRetryDepth = 0;
|
|
630
623
|
|
|
631
624
|
while (true) {
|
|
632
625
|
const result = await runSingleChatTurn(
|
|
@@ -644,43 +637,8 @@ export async function handleChat<Origin>(
|
|
|
644
637
|
responseFormat,
|
|
645
638
|
);
|
|
646
639
|
|
|
647
|
-
if (result.contextOverflow)
|
|
648
|
-
if (overflowRetryDepth >= 1) {
|
|
649
|
-
deliverToOrigin(ctx, origin, {
|
|
650
|
-
kind: 'failure',
|
|
651
|
-
category: 'generic',
|
|
652
|
-
code: 'context_overflow',
|
|
653
|
-
message: result.errorMessage ?? 'Context overflow',
|
|
654
|
-
});
|
|
655
|
-
return;
|
|
656
|
-
}
|
|
640
|
+
if (result.contextOverflow) return;
|
|
657
641
|
|
|
658
|
-
const currentSession = await getSession(sessionId);
|
|
659
|
-
const isMainSession = currentSession && !currentSession.parentId;
|
|
660
|
-
|
|
661
|
-
if (isMainSession && !getCompactionService().shouldSkipCompaction(sessionId)) {
|
|
662
|
-
const replayText = await getCompactionService().buildReplayText(sessionId);
|
|
663
|
-
const execResult = await executeCompaction(sessionId, 'overflow');
|
|
664
|
-
|
|
665
|
-
if (execResult.ok) {
|
|
666
|
-
getCompactionService().clearCompactionFailure(sessionId);
|
|
667
|
-
overflowRetryDepth++;
|
|
668
|
-
currentContent = replayText ?? 'Continue from where we left off, using the compacted context.';
|
|
669
|
-
continue;
|
|
670
|
-
} else if (!execResult.skipped) {
|
|
671
|
-
getCompactionService().recordCompactionFailure(sessionId);
|
|
672
|
-
console.warn(`[handleChat] Overflow compaction failed for session ${sessionId}: ${execResult.error}`);
|
|
673
|
-
}
|
|
674
|
-
}
|
|
675
|
-
|
|
676
|
-
deliverToOrigin(ctx, origin, {
|
|
677
|
-
kind: 'failure',
|
|
678
|
-
category: 'generic',
|
|
679
|
-
code: 'context_overflow',
|
|
680
|
-
message: result.errorMessage ?? 'Context overflow',
|
|
681
|
-
});
|
|
682
|
-
return;
|
|
683
|
-
}
|
|
684
642
|
|
|
685
643
|
if (result.isFatal) {
|
|
686
644
|
return;
|
|
@@ -706,25 +664,6 @@ export async function handleChat<Origin>(
|
|
|
706
664
|
}
|
|
707
665
|
}
|
|
708
666
|
|
|
709
|
-
if (result.streamCompleted && result.needsAutoCompaction) {
|
|
710
|
-
const currentSession = await getSession(sessionId);
|
|
711
|
-
if (currentSession && !currentSession.parentId && !getCompactionService().shouldSkipCompaction(sessionId)) {
|
|
712
|
-
const execResult = await executeCompaction(sessionId, 'auto');
|
|
713
|
-
if (execResult.ok) {
|
|
714
|
-
getCompactionService().clearCompactionFailure(sessionId);
|
|
715
|
-
} else if (!execResult.skipped) {
|
|
716
|
-
getCompactionService().recordCompactionFailure(sessionId);
|
|
717
|
-
console.warn(`[handleChat] Auto-compaction failed for session ${sessionId}: ${execResult.error}`);
|
|
718
|
-
}
|
|
719
|
-
}
|
|
720
|
-
const next = await drainQueue(ctx, sessionId);
|
|
721
|
-
if (next) {
|
|
722
|
-
currentContent = next.content;
|
|
723
|
-
currentAttachments = next.attachments;
|
|
724
|
-
continue;
|
|
725
|
-
}
|
|
726
|
-
return;
|
|
727
|
-
}
|
|
728
667
|
|
|
729
668
|
if (result.streamCompleted) {
|
|
730
669
|
const next = await drainQueue(ctx, sessionId);
|
|
@@ -47,6 +47,7 @@ async function resolveAttachmentPath(part: ImagePart | FilePart): Promise<{ abso
|
|
|
47
47
|
export async function convertToAiSdkMessages(
|
|
48
48
|
messages: MessageWithParts[],
|
|
49
49
|
modelCapabilities?: ModelCapabilities,
|
|
50
|
+
options: { replayReasoning?: boolean } = {},
|
|
50
51
|
): Promise<ModelMessage[]> {
|
|
51
52
|
const result: { role: 'user' | 'assistant' | 'system' | 'tool'; content: AiSdkContent }[] = [];
|
|
52
53
|
|
|
@@ -54,6 +55,11 @@ export async function convertToAiSdkMessages(
|
|
|
54
55
|
const msg = msgWithParts.message;
|
|
55
56
|
const parts = msgWithParts.parts;
|
|
56
57
|
|
|
58
|
+
const reasoningOptions = options.replayReasoning && msg.role === 'assistant'
|
|
59
|
+
? { providerOptions: { openaiCompatible: {
|
|
60
|
+
reasoning_content: parts.filter((part) => part.type === 'reasoning').map((part) => part.text).join(''),
|
|
61
|
+
} } }
|
|
62
|
+
: {};
|
|
57
63
|
const textBlocks: string[] = [];
|
|
58
64
|
const toolCallBlocks: Array<{
|
|
59
65
|
type: 'tool-call';
|
|
@@ -243,6 +249,7 @@ export async function convertToAiSdkMessages(
|
|
|
243
249
|
result.push({
|
|
244
250
|
role: hasCompactionTrigger ? 'user' : (msg.role as 'user' | 'assistant' | 'system'),
|
|
245
251
|
content,
|
|
252
|
+
...(!hasCompactionTrigger ? reasoningOptions : {}),
|
|
246
253
|
});
|
|
247
254
|
continue;
|
|
248
255
|
}
|
|
@@ -250,6 +257,7 @@ export async function convertToAiSdkMessages(
|
|
|
250
257
|
result.push({
|
|
251
258
|
role: msg.role as 'user' | 'assistant' | 'system',
|
|
252
259
|
content: contentParts,
|
|
260
|
+
...reasoningOptions,
|
|
253
261
|
});
|
|
254
262
|
|
|
255
263
|
for (const toolResult of toolResultBlocks) {
|
package/src/core/model-utils.ts
CHANGED
|
@@ -7,6 +7,8 @@ import { isSandboxActive } from '../runtime/host-dependencies';
|
|
|
7
7
|
|
|
8
8
|
export interface ModelWithMetadata {
|
|
9
9
|
model: LanguageModel;
|
|
10
|
+
/** Replay stored reasoning only for adapters that accept unsigned reasoning text. */
|
|
11
|
+
replayReasoning?: boolean;
|
|
10
12
|
useProviderInstructions?: boolean;
|
|
11
13
|
omitMaxOutputTokens?: boolean;
|
|
12
14
|
omitTemperature?: boolean;
|
|
@@ -97,34 +99,16 @@ export async function getModelWithMetadata(
|
|
|
97
99
|
return { model: openrouter.chat(model) as unknown as LanguageModel };
|
|
98
100
|
}
|
|
99
101
|
|
|
100
|
-
case 'minimax':
|
|
101
|
-
|
|
102
|
-
const minimax = createMinimax({ apiKey });
|
|
103
|
-
return { model: minimax.chat(model) as unknown as LanguageModel };
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
case 'zhipu': {
|
|
107
|
-
const { createZhipu } = await import('zhipu-ai-provider');
|
|
108
|
-
const zhipu = createZhipu({
|
|
109
|
-
apiKey,
|
|
110
|
-
baseURL: 'https://open.bigmodel.cn/api/paas/v4',
|
|
111
|
-
});
|
|
112
|
-
return { model: zhipu.chat(model) as unknown as LanguageModel };
|
|
113
|
-
}
|
|
114
|
-
|
|
102
|
+
case 'minimax':
|
|
103
|
+
case 'zhipu':
|
|
115
104
|
case 'zhipu-coding': {
|
|
116
|
-
const {
|
|
117
|
-
|
|
118
|
-
apiKey,
|
|
119
|
-
baseURL: 'https://api.z.ai/api/coding/paas/v4',
|
|
120
|
-
});
|
|
121
|
-
return { model: zhipu.chat(model) as unknown as LanguageModel };
|
|
105
|
+
const { createProtocolModel } = await import('../providers/protocol-model');
|
|
106
|
+
return { model: createProtocolModel(provider, model, apiKey) };
|
|
122
107
|
}
|
|
123
108
|
|
|
124
109
|
case 'deepseek': {
|
|
125
|
-
const {
|
|
126
|
-
|
|
127
|
-
return { model: deepseek.chat(model) as unknown as LanguageModel };
|
|
110
|
+
const { createProtocolModel } = await import('../providers/protocol-model');
|
|
111
|
+
return { model: createProtocolModel(provider, model, apiKey), replayReasoning: true };
|
|
128
112
|
}
|
|
129
113
|
|
|
130
114
|
case 'openai':
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import type { ModelMessage } from 'ai';
|
|
1
2
|
import type { MessageEvent, StepPart } from '@capekai/types';
|
|
3
|
+
import { estimateNextStepTokens } from '../compaction/usage';
|
|
2
4
|
import { createPart, updatePart } from '../storage/runtime';
|
|
3
5
|
import { createStepPart } from './part-utils';
|
|
4
6
|
import { randomUUID } from 'crypto';
|
|
@@ -63,7 +65,7 @@ export function createStepCallbacks(ctx: StepCallbacksContext) {
|
|
|
63
65
|
}
|
|
64
66
|
await createPart(startedStepPart, ctx.sessionId);
|
|
65
67
|
},
|
|
66
|
-
onStepFinish: async (stepFinishEvent: { stepNumber: number; finishReason: string | null; usage?: StepUsage; totalUsage?: StepUsage }) => {
|
|
68
|
+
onStepFinish: async (stepFinishEvent: { stepNumber: number; finishReason: string | null; usage?: StepUsage; totalUsage?: StepUsage; response?: { messages: ModelMessage[] } }) => {
|
|
67
69
|
const stepNumber = stepFinishEvent.stepNumber + 1;
|
|
68
70
|
|
|
69
71
|
const stepUsage = stepFinishEvent.usage;
|
|
@@ -74,8 +76,7 @@ export function createStepCallbacks(ctx: StepCallbacksContext) {
|
|
|
74
76
|
const stepNoCacheTokens = stepUsage?.inputTokenDetails?.noCacheTokens ?? 0;
|
|
75
77
|
|
|
76
78
|
if (ctx.isMainSession && ctx.contextWindow) {
|
|
77
|
-
|
|
78
|
-
if (latestStepInputTokens >= ctx.autoThreshold) {
|
|
79
|
+
if (estimateNextStepTokens(stepFinishEvent) >= ctx.autoThreshold) {
|
|
79
80
|
ctx.needsCompaction = true;
|
|
80
81
|
}
|
|
81
82
|
}
|
|
@@ -43,7 +43,11 @@ export function buildStreamConfig(options: StreamConfigOptions): StreamConfigRes
|
|
|
43
43
|
// Determine the provider-specific providerOptions key
|
|
44
44
|
const resolvedProvider = providerId || getModelsConfig().defaultProvider;
|
|
45
45
|
const registered = resolvedProvider ? getProvider(resolvedProvider) : undefined;
|
|
46
|
-
|
|
46
|
+
// Built-in MiniMax uses Anthropic's option namespace. Registered overrides
|
|
47
|
+
// retain their own namespace unless their descriptor explicitly changes it.
|
|
48
|
+
const providerOptionsKey = registered
|
|
49
|
+
? registered.descriptor.providerOptionsKey ?? resolvedProvider
|
|
50
|
+
: resolvedProvider === 'minimax' ? 'anthropic' : resolvedProvider;
|
|
47
51
|
|
|
48
52
|
// Build merged providerOptions
|
|
49
53
|
let providerOptions: Record<string, Record<string, unknown>> | undefined;
|
package/src/goals/evaluator.ts
CHANGED
|
@@ -15,16 +15,25 @@ import { runOrchestratorSession } from '../workflow/orchestrator-session';
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
const MAX_TRANSCRIPT_MESSAGES = 20;
|
|
18
|
-
const MAX_TOOL_OUTPUT_CHARS =
|
|
18
|
+
const MAX_TOOL_OUTPUT_CHARS = 1000;
|
|
19
|
+
const MAX_RECENT_CHARS = 24000;
|
|
20
|
+
const MAX_CHECKPOINT_CHARS = 8000;
|
|
21
|
+
|
|
22
|
+
function excerpt(text: string, limit: number): string {
|
|
23
|
+
if (text.length <= limit) return text;
|
|
24
|
+
const marker = '\n[...truncated...]\n';
|
|
25
|
+
const half = Math.floor((limit - marker.length) / 2);
|
|
26
|
+
return text.slice(0, half) + marker + text.slice(-(limit - marker.length - half));
|
|
27
|
+
}
|
|
19
28
|
|
|
20
29
|
function summarizeToolState(toolPart: ToolPart): string {
|
|
21
30
|
const state = toolPart.state;
|
|
22
31
|
if (state.status === 'completed') {
|
|
23
|
-
if (typeof state.output === 'string') return state.output
|
|
24
|
-
if (state.output && typeof state.output === 'object') return JSON.stringify(state.output)
|
|
32
|
+
if (typeof state.output === 'string') return excerpt(state.output, MAX_TOOL_OUTPUT_CHARS);
|
|
33
|
+
if (state.output && typeof state.output === 'object') return excerpt(JSON.stringify(state.output), MAX_TOOL_OUTPUT_CHARS);
|
|
25
34
|
return '(completed)';
|
|
26
35
|
}
|
|
27
|
-
if (state.status === 'error') return `ERROR: ${state.error ?? 'unknown'}
|
|
36
|
+
if (state.status === 'error') return excerpt(`ERROR: ${state.error ?? 'unknown'}`, MAX_TOOL_OUTPUT_CHARS);
|
|
28
37
|
return `(${state.status})`;
|
|
29
38
|
}
|
|
30
39
|
|
|
@@ -33,23 +42,43 @@ async function buildTranscriptSummary(
|
|
|
33
42
|
sessionId: string,
|
|
34
43
|
): Promise<string> {
|
|
35
44
|
const messages = await listTranscript(sessionId);
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
45
|
+
const checkpoint = [...messages].reverse().find(entry => entry.message.role === 'assistant'
|
|
46
|
+
&& entry.message.summary === true && entry.message.mode === 'compaction');
|
|
47
|
+
const checkpointText = checkpoint?.parts
|
|
48
|
+
.filter((part): part is TextPart => part.type === 'text')
|
|
49
|
+
.map(part => part.text || '').join('\n');
|
|
50
|
+
// Keep raw recent tool evidence even when it predates the checkpoint.
|
|
51
|
+
// Summaries are claims, not independent proof of goal completion.
|
|
52
|
+
const recent = messages.filter(entry => entry.message.role !== 'assistant'
|
|
53
|
+
|| (!entry.message.summary && entry.message.mode !== 'retry_failed' && entry.message.mode !== 'compact_failed'))
|
|
54
|
+
.slice(-MAX_TRANSCRIPT_MESSAGES).map(entry => {
|
|
55
|
+
if (entry.message.role === 'user') {
|
|
56
|
+
const text = entry.parts.filter((part): part is TextPart => part.type === 'text')
|
|
57
|
+
.map(part => part.text || '').join('');
|
|
58
|
+
return text ? `[USER]: ${excerpt(text, 2000)}` : '';
|
|
59
|
+
}
|
|
60
|
+
if (entry.message.role === 'assistant') {
|
|
61
|
+
return entry.parts.map(part => {
|
|
62
|
+
if (part.type === 'text' && part.text) return `[ASSISTANT]: ${excerpt(part.text, 2000)}`;
|
|
63
|
+
if (part.type === 'tool') return `[TOOL: ${part.name}]: ${summarizeToolState(part)}`;
|
|
64
|
+
return '';
|
|
65
|
+
}).filter(Boolean).join('\n');
|
|
66
|
+
}
|
|
67
|
+
return '';
|
|
68
|
+
}).filter(Boolean);
|
|
69
|
+
// Prefer newest evidence within the budget, independently of checkpoint size.
|
|
70
|
+
let remaining = MAX_RECENT_CHARS;
|
|
71
|
+
const selected: string[] = [];
|
|
72
|
+
for (const entry of recent.reverse()) {
|
|
73
|
+
if (remaining <= 100) break;
|
|
74
|
+
const bounded = excerpt(entry, Math.min(remaining - 2, 4000));
|
|
75
|
+
selected.unshift(bounded);
|
|
76
|
+
remaining -= bounded.length + 2;
|
|
77
|
+
}
|
|
78
|
+
return [
|
|
79
|
+
checkpointText ? `[COMPACTION CHECKPOINT, summary claims only, not direct evidence]:\n${excerpt(checkpointText, MAX_CHECKPOINT_CHARS)}` : '',
|
|
80
|
+
...selected,
|
|
81
|
+
].filter(Boolean).join('\n\n');
|
|
53
82
|
}
|
|
54
83
|
|
|
55
84
|
/** Structural copy of the shared orchestrator-session contract result. The
|
package/src/goals/loop.ts
CHANGED
|
@@ -154,6 +154,10 @@ export async function runGoalLoopWithDeps(
|
|
|
154
154
|
console.error('[goal:loop] Evaluator failed', { turn, error: err instanceof Error ? err.message : String(err) });
|
|
155
155
|
evaluation = { goalMet: false, reason: 'Evaluator call failed — continuing work' };
|
|
156
156
|
}
|
|
157
|
+
if (abortSignal?.aborted) {
|
|
158
|
+
await updateGoalStateWithDeps(deps, sessionId, { status: 'cancelled', completedAt: Date.now() }, broadcastSessUpdated);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
157
161
|
if (evaluation.goalMet) {
|
|
158
162
|
console.log('[goal:loop] GOAL MET!', { turn, reason: evaluation.reason });
|
|
159
163
|
await updateGoalStateWithDeps(deps, sessionId, { status: 'met', completedAt: Date.now() }, broadcastSessUpdated);
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { createAnthropic } from '@ai-sdk/anthropic';
|
|
2
|
+
import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
|
|
3
|
+
import { wrapLanguageModel, type LanguageModel } from 'ai';
|
|
4
|
+
|
|
5
|
+
export type ProtocolProvider = 'minimax' | 'zhipu' | 'zhipu-coding' | 'deepseek';
|
|
6
|
+
|
|
7
|
+
export function createProtocolModel(
|
|
8
|
+
providerId: ProtocolProvider,
|
|
9
|
+
modelId: string,
|
|
10
|
+
apiKey: string,
|
|
11
|
+
fetch?: NonNullable<Parameters<typeof createAnthropic>[0]>['fetch'],
|
|
12
|
+
): LanguageModel {
|
|
13
|
+
if (providerId === 'minimax') {
|
|
14
|
+
return createAnthropic({ apiKey, baseURL: 'https://api.minimax.io/anthropic/v1', fetch })(modelId);
|
|
15
|
+
}
|
|
16
|
+
const model = createOpenAICompatible({
|
|
17
|
+
name: providerId,
|
|
18
|
+
apiKey,
|
|
19
|
+
baseURL: providerId === 'deepseek' ? 'https://api.deepseek.com/v1' : providerId === 'zhipu'
|
|
20
|
+
? 'https://open.bigmodel.cn/api/paas/v4'
|
|
21
|
+
: 'https://api.z.ai/api/coding/paas/v4',
|
|
22
|
+
fetch,
|
|
23
|
+
})(modelId);
|
|
24
|
+
if (providerId !== 'deepseek') return model;
|
|
25
|
+
return wrapLanguageModel({
|
|
26
|
+
model,
|
|
27
|
+
middleware: {
|
|
28
|
+
specificationVersion: 'v3',
|
|
29
|
+
transformParams: async ({ params }) => ({
|
|
30
|
+
...params,
|
|
31
|
+
prompt: params.prompt.map((message) => {
|
|
32
|
+
if (message.role !== 'assistant') return message;
|
|
33
|
+
const reasoning = message.content.filter((part) => part.type === 'reasoning');
|
|
34
|
+
const existing = message.providerOptions?.openaiCompatible?.reasoning_content;
|
|
35
|
+
return {
|
|
36
|
+
...message,
|
|
37
|
+
content: message.content.filter((part) => part.type !== 'reasoning'),
|
|
38
|
+
providerOptions: {
|
|
39
|
+
...message.providerOptions,
|
|
40
|
+
openaiCompatible: {
|
|
41
|
+
...message.providerOptions?.openaiCompatible,
|
|
42
|
+
// The SDK omits empty reasoning by default. DeepSeek needs the field
|
|
43
|
+
// on tool continuations too, including SDK-generated assistant steps.
|
|
44
|
+
reasoning_content: reasoning.length > 0
|
|
45
|
+
? reasoning.map((part) => part.text).join('')
|
|
46
|
+
: typeof existing === 'string' ? existing : '',
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}),
|
|
51
|
+
}),
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
}
|
package/src/retry/stream-chat.ts
CHANGED
|
@@ -12,6 +12,9 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import type { ChatOptions } from '../core/agent';
|
|
15
|
+
import { getLLMMaxSteps } from '../configuration/runtime';
|
|
16
|
+
import { executeCompaction } from '../compaction/executor';
|
|
17
|
+
import { getCompactionService } from '../compaction/policy';
|
|
15
18
|
import type { UsageEventData } from '../core/step-handlers';
|
|
16
19
|
import type {
|
|
17
20
|
AssistantMessage, AuthErrorMessage, ChatRetryMessage, ContextOverflowErrorMessage, ErrorMessage, InvalidRequestErrorMessage, MessageEvent, RateLimitErrorMessage, ServerErrorMessage, TimeoutErrorMessage, ToolPart } from '@capekai/types';
|
|
@@ -25,6 +28,7 @@ import {
|
|
|
25
28
|
} from '../utils/errors';
|
|
26
29
|
import { emitSessionUpdated } from '../runtime/host-dependencies';
|
|
27
30
|
import {
|
|
31
|
+
buildEffectiveContextHistory,
|
|
28
32
|
getPartsByMessage,
|
|
29
33
|
getSession,
|
|
30
34
|
syncMessageFts,
|
|
@@ -41,9 +45,9 @@ import {
|
|
|
41
45
|
} from './policy';
|
|
42
46
|
|
|
43
47
|
export type StreamChatEvent =
|
|
44
|
-
| MessageEvent
|
|
48
|
+
| (MessageEvent & { continuation?: boolean })
|
|
45
49
|
| { type: 'usage'; usage: UsageEventData; model: string; variant: string | null }
|
|
46
|
-
| { type: 'needs_compaction'; sessionId: string }
|
|
50
|
+
| { type: 'needs_compaction'; sessionId: string; resume?: boolean }
|
|
47
51
|
| ChatRetryMessage
|
|
48
52
|
| RateLimitErrorMessage
|
|
49
53
|
| ServerErrorMessage
|
|
@@ -81,8 +85,8 @@ async function finalizeFailedAttempt(
|
|
|
81
85
|
completedAt: Date.now(),
|
|
82
86
|
...(retryFailed ? { mode: 'retry_failed' as const } : {}),
|
|
83
87
|
};
|
|
84
|
-
updateMessage(message.id, errorMessage, { syncFts: false });
|
|
85
|
-
syncMessageFts(message.id);
|
|
88
|
+
await updateMessage(message.id, errorMessage, { syncFts: false });
|
|
89
|
+
await syncMessageFts(message.id);
|
|
86
90
|
events.push({ type: 'message.updated', message: errorMessage });
|
|
87
91
|
return events;
|
|
88
92
|
}
|
|
@@ -130,6 +134,7 @@ export async function* streamChatWithRetry(
|
|
|
130
134
|
options: ChatOptions,
|
|
131
135
|
streamChatFn?: StreamChatFn,
|
|
132
136
|
policyOptions: StreamRetryPolicy = {},
|
|
137
|
+
compact: typeof executeCompaction = executeCompaction,
|
|
133
138
|
): AsyncGenerator<StreamChatEvent> {
|
|
134
139
|
const policy = getRetryPolicy();
|
|
135
140
|
const maxRetries = policyOptions.maxRetries ?? policy.defaults.maxRetries;
|
|
@@ -138,10 +143,12 @@ export async function* streamChatWithRetry(
|
|
|
138
143
|
const jitterRatio = policyOptions.jitterRatio ?? policy.defaults.jitterRatio;
|
|
139
144
|
const circuitKey = policy.circuitKey(options.providerId, options.modelId);
|
|
140
145
|
const session = await getSession(options.sessionId);
|
|
141
|
-
const
|
|
146
|
+
const managesSessionLifecycle = !options.retryAbortController;
|
|
147
|
+
const abortController = options.retryAbortController
|
|
148
|
+
?? interruptManager.registerSession(options.sessionId, session?.parentId ?? undefined);
|
|
142
149
|
const isMainSession = session && !session.parentId;
|
|
143
150
|
|
|
144
|
-
if (isMainSession) {
|
|
151
|
+
if (isMainSession && managesSessionLifecycle) {
|
|
145
152
|
const updatedSession = await updateSession(options.sessionId, { runningAt: new Date().toISOString() });
|
|
146
153
|
if (updatedSession) {
|
|
147
154
|
emitSessionUpdated(updatedSession);
|
|
@@ -171,13 +178,35 @@ export async function* streamChatWithRetry(
|
|
|
171
178
|
}
|
|
172
179
|
|
|
173
180
|
let retries = 0;
|
|
181
|
+
let overflowRetried = false;
|
|
182
|
+
let remainingSteps = options.maxSteps ?? getLLMMaxSteps();
|
|
183
|
+
let messages = options.messages;
|
|
184
|
+
let continueFromCompaction = false;
|
|
174
185
|
while (retries <= maxRetries) {
|
|
175
186
|
let lastAssistantMessage: AssistantMessage | null = null;
|
|
176
187
|
let attemptHadToolActivity = false;
|
|
188
|
+
let maintenanceStarted = false;
|
|
189
|
+
let pendingCompaction: Extract<StreamChatEvent, { type: 'needs_compaction' }> | undefined;
|
|
190
|
+
let overflow: ContextOverflowErrorMessage | undefined;
|
|
191
|
+
const finishedSteps = new Set<string>();
|
|
177
192
|
|
|
178
193
|
try {
|
|
179
194
|
const stream = streamChatFn ?? (await import('../core/agent')).streamChat;
|
|
180
|
-
for await (const event of stream({
|
|
195
|
+
for await (const event of stream({
|
|
196
|
+
...options, messages, maxSteps: remainingSteps,
|
|
197
|
+
compactBetweenSteps: true, continueFromCompaction, retryAbortController: abortController,
|
|
198
|
+
})) {
|
|
199
|
+
if (event.type === 'needs_compaction') {
|
|
200
|
+
pendingCompaction = event;
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
if (event.type === 'error.context_overflow') {
|
|
204
|
+
overflow = event;
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
if (event.type === 'part.updated' && event.part.type === 'step' && event.part.status === 'finished') {
|
|
208
|
+
finishedSteps.add(event.part.id);
|
|
209
|
+
}
|
|
181
210
|
if (event.type === 'message.created' || event.type === 'message.updated') {
|
|
182
211
|
if (event.message.role === 'assistant') {
|
|
183
212
|
lastAssistantMessage = event.message as AssistantMessage;
|
|
@@ -191,6 +220,64 @@ export async function* streamChatWithRetry(
|
|
|
191
220
|
yield event;
|
|
192
221
|
}
|
|
193
222
|
policy.resetCircuit(circuitKey);
|
|
223
|
+
if (abortController.signal.aborted) return;
|
|
224
|
+
if (overflow || pendingCompaction) {
|
|
225
|
+
maintenanceStarted = true;
|
|
226
|
+
// An uncertain tool outcome is not a safe checkpoint to resume from.
|
|
227
|
+
const parts = lastAssistantMessage ? await getPartsByMessage(lastAssistantMessage.id) : [];
|
|
228
|
+
if (parts.some(part => part.type === 'tool' && (part.state.status === 'pending' || part.state.status === 'running'))) {
|
|
229
|
+
const message = 'Cannot compact and continue while tool outcomes are unresolved.';
|
|
230
|
+
yield* await finalizeFailedAttempt(lastAssistantMessage, { message }, false);
|
|
231
|
+
yield overflow ?? { type: 'error', code: 'compaction_unsafe', message };
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
const service = getCompactionService();
|
|
235
|
+
const mustResume = Boolean(overflow || pendingCompaction?.resume);
|
|
236
|
+
const reason = overflow ? 'overflow' : 'auto';
|
|
237
|
+
if (!isMainSession || service.shouldSkipCompaction(options.sessionId) || (overflow && overflowRetried)) {
|
|
238
|
+
if (mustResume) {
|
|
239
|
+
const message = overflow?.message ?? 'Cannot continue while context compaction is unavailable.';
|
|
240
|
+
yield* await finalizeFailedAttempt(lastAssistantMessage, { message }, false);
|
|
241
|
+
yield overflow ?? { type: 'error', code: 'compaction_unavailable', message };
|
|
242
|
+
}
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
const result = await compact(options.sessionId, reason, undefined, undefined, abortController.signal);
|
|
246
|
+
if (abortController.signal.aborted) {
|
|
247
|
+
if (lastAssistantMessage) {
|
|
248
|
+
const interrupted: AssistantMessage = { ...lastAssistantMessage, status: 'interrupted', error: 'Interrupted by user' };
|
|
249
|
+
await updateMessage(interrupted.id, interrupted, { syncFts: false });
|
|
250
|
+
await syncMessageFts(interrupted.id);
|
|
251
|
+
yield { type: 'message.updated', message: interrupted };
|
|
252
|
+
}
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
if (!result.ok) {
|
|
256
|
+
if (!result.skipped) service.recordCompactionFailure(options.sessionId);
|
|
257
|
+
if (mustResume) {
|
|
258
|
+
yield* await finalizeFailedAttempt(lastAssistantMessage, { message: result.error }, false);
|
|
259
|
+
yield overflow ?? { type: 'error', code: 'compaction_failed', message: result.error };
|
|
260
|
+
}
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
service.clearCompactionFailure(options.sessionId);
|
|
264
|
+
if (!mustResume) return;
|
|
265
|
+
// Compaction is continuation, not a retry: completed tools are in the
|
|
266
|
+
// checkpoint and the original user input is never appended again.
|
|
267
|
+
remainingSteps -= overflow ? finishedSteps.size : Math.max(1, finishedSteps.size);
|
|
268
|
+
if (remainingSteps <= 0) {
|
|
269
|
+
if (overflow) {
|
|
270
|
+
yield* await finalizeFailedAttempt(lastAssistantMessage, { message: overflow.message }, false);
|
|
271
|
+
yield overflow;
|
|
272
|
+
}
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
overflowRetried ||= Boolean(overflow);
|
|
276
|
+
messages = (await buildEffectiveContextHistory(options.sessionId)).messages;
|
|
277
|
+
continueFromCompaction = true;
|
|
278
|
+
retries = 0;
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
194
281
|
return;
|
|
195
282
|
} catch (err) {
|
|
196
283
|
const classifiedError = policy.classify(err);
|
|
@@ -208,6 +295,7 @@ export async function* streamChatWithRetry(
|
|
|
208
295
|
// or after the run was aborted.
|
|
209
296
|
const canRetry = policyCanRetry
|
|
210
297
|
&& !attemptHadToolActivity
|
|
298
|
+
&& !maintenanceStarted
|
|
211
299
|
&& !abortController.signal.aborted;
|
|
212
300
|
const circuitOpened = classifiedError.retryable
|
|
213
301
|
&& !canRetry
|
|
@@ -300,9 +388,9 @@ export async function* streamChatWithRetry(
|
|
|
300
388
|
}
|
|
301
389
|
}
|
|
302
390
|
} finally {
|
|
303
|
-
interruptManager.unregisterSession(options.sessionId);
|
|
391
|
+
if (managesSessionLifecycle) interruptManager.unregisterSession(options.sessionId);
|
|
304
392
|
await rejectPendingAsksBySession(options.sessionId);
|
|
305
|
-
if (isMainSession) {
|
|
393
|
+
if (isMainSession && managesSessionLifecycle) {
|
|
306
394
|
const updatedSession = await updateSession(options.sessionId, { runningAt: null });
|
|
307
395
|
if (updatedSession) {
|
|
308
396
|
emitSessionUpdated(updatedSession);
|