@capekai/core 1.0.8 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@capekai/core",
3
- "version": "1.0.8",
3
+ "version": "1.0.9",
4
4
  "description": "Bun-native composable agent runtime and framework for Capek.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -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
- if (!hasUserMessage) {
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: stepCountIs(streamConfig.maxSteps),
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 { type: 'message.updated', message: errorMessage };
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
- yield { type: 'message.updated', message: finalMessage };
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
- yield { type: 'needs_compaction', sessionId: _sessionId };
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
 
@@ -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
- emitTerminal(event.message, sessionId);
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 = new AbortController();
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
- clearInterval(checkInterval);
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);
@@ -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
- const latestStepInputTokens = stepUsage?.inputTokens ?? 0;
78
- if (latestStepInputTokens >= ctx.autoThreshold) {
79
+ if (estimateNextStepTokens(stepFinishEvent) >= ctx.autoThreshold) {
79
80
  ctx.needsCompaction = true;
80
81
  }
81
82
  }
@@ -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 = 500;
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.slice(0, MAX_TOOL_OUTPUT_CHARS);
24
- if (state.output && typeof state.output === 'object') return JSON.stringify(state.output).slice(0, MAX_TOOL_OUTPUT_CHARS);
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
- return messages.slice(-MAX_TRANSCRIPT_MESSAGES).map((entry) => {
37
- if (entry.message.role === 'user') {
38
- const text = entry.parts
39
- .filter((part): part is TextPart => part.type === 'text')
40
- .map((part) => part.text || '')
41
- .join('');
42
- return text ? `[USER]: ${text}` : '';
43
- }
44
- if (entry.message.role === 'assistant') {
45
- return entry.parts.map((part) => {
46
- if (part.type === 'text' && part.text) return `[ASSISTANT]: ${part.text}`;
47
- if (part.type === 'tool') return `[TOOL: ${part.name}]: ${summarizeToolState(part)}`;
48
- return '';
49
- }).filter(Boolean).join('\n');
50
- }
51
- return '';
52
- }).filter(Boolean).join('\n\n');
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);
@@ -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 abortController = interruptManager.registerSession(options.sessionId, session?.parentId ?? undefined);
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({ ...options, retryAbortController: abortController })) {
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);