@capekai/core 1.0.7 → 1.0.9
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 +1 -1
- package/src/compaction/task.ts +3 -1
- package/src/compaction/usage.ts +25 -0
- package/src/core/agent.ts +38 -5
- package/src/core/chat-handler.ts +33 -83
- package/src/core/step-handlers.ts +4 -3
- package/src/goals/evaluator.ts +50 -21
- package/src/goals/loop.ts +4 -0
- package/src/retry/stream-chat.ts +97 -9
package/package.json
CHANGED
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;
|
|
@@ -155,6 +158,10 @@ export async function* streamChat(options: ChatOptions): AsyncGenerator<MessageE
|
|
|
155
158
|
// Convert messages for ai-sdk
|
|
156
159
|
const modelDef = resolvedModelId ? findModel(resolvedModelId) : undefined;
|
|
157
160
|
const aiMessages = await convertToAiSdkMessages(messages, modelDef?.capabilities);
|
|
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,77 +637,34 @@ 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
642
|
|
|
676
|
-
|
|
677
|
-
kind: 'failure',
|
|
678
|
-
category: 'generic',
|
|
679
|
-
code: 'context_overflow',
|
|
680
|
-
message: result.errorMessage ?? 'Context overflow',
|
|
681
|
-
});
|
|
643
|
+
if (result.isFatal) {
|
|
682
644
|
return;
|
|
683
645
|
}
|
|
684
646
|
|
|
685
|
-
if (result.
|
|
686
|
-
|
|
687
|
-
}
|
|
688
|
-
|
|
689
|
-
if (result.isQueueDrainable) {
|
|
647
|
+
if (result.interrupted) {
|
|
648
|
+
// The old turn has finished cleanup; queued input starts a fresh turn.
|
|
690
649
|
const next = await drainQueue(ctx, sessionId);
|
|
691
|
-
if (next) {
|
|
692
|
-
|
|
693
|
-
currentAttachments = next.attachments;
|
|
694
|
-
continue;
|
|
650
|
+
if (!next) {
|
|
651
|
+
return;
|
|
695
652
|
}
|
|
653
|
+
currentContent = next.content;
|
|
654
|
+
currentAttachments = next.attachments;
|
|
655
|
+
continue;
|
|
696
656
|
}
|
|
697
657
|
|
|
698
|
-
if (result.
|
|
699
|
-
const currentSession = await getSession(sessionId);
|
|
700
|
-
if (currentSession && !currentSession.parentId && !getCompactionService().shouldSkipCompaction(sessionId)) {
|
|
701
|
-
const execResult = await executeCompaction(sessionId, 'auto');
|
|
702
|
-
if (execResult.ok) {
|
|
703
|
-
getCompactionService().clearCompactionFailure(sessionId);
|
|
704
|
-
} else if (!execResult.skipped) {
|
|
705
|
-
getCompactionService().recordCompactionFailure(sessionId);
|
|
706
|
-
console.warn(`[handleChat] Auto-compaction failed for session ${sessionId}: ${execResult.error}`);
|
|
707
|
-
}
|
|
708
|
-
}
|
|
658
|
+
if (result.isQueueDrainable) {
|
|
709
659
|
const next = await drainQueue(ctx, sessionId);
|
|
710
660
|
if (next) {
|
|
711
661
|
currentContent = next.content;
|
|
712
662
|
currentAttachments = next.attachments;
|
|
713
663
|
continue;
|
|
714
664
|
}
|
|
715
|
-
return;
|
|
716
665
|
}
|
|
717
666
|
|
|
667
|
+
|
|
718
668
|
if (result.streamCompleted) {
|
|
719
669
|
const next = await drainQueue(ctx, sessionId);
|
|
720
670
|
if (next) {
|
|
@@ -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
|
}
|
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);
|
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);
|