@sayknow-cli/agent-core 0.3.16 → 0.4.0

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.
@@ -9,8 +9,9 @@
9
9
  */
10
10
 
11
11
  import type { ToolCall, ToolResultMessage } from "@sayknow-cli/ai";
12
+ import { sanitizeText } from "@sayknow-cli/utils";
12
13
  import type { AgentMessage } from "../types";
13
- import { estimateEntryTokens } from "./compaction";
14
+ import { estimateEntryTokens, estimateTextTokensHeuristic } from "./compaction";
14
15
  import type { SessionEntry, SessionMessageEntry } from "./entries";
15
16
 
16
17
  export interface PruneConfig {
@@ -48,6 +49,7 @@ export interface PruneResult {
48
49
  }
49
50
 
50
51
  const DIGEST_NOTICE_TOKEN_CAP_MULTIPLIER = 1.25;
52
+ const ERROR_DIGEST_NOTICE_MIN_CHARS = 240;
51
53
 
52
54
  function createGenericPrunedNotice(tokens: number): string {
53
55
  return `[Output truncated - ${tokens} tokens]`;
@@ -66,6 +68,17 @@ function firstErrorLine(text: string): string | undefined {
66
68
  ?.trim();
67
69
  }
68
70
 
71
+ function firstNonEmptyLine(text: string): string | undefined {
72
+ return text
73
+ .split(/\r?\n/)
74
+ .find(line => line.trim().length > 0)
75
+ ?.trim();
76
+ }
77
+
78
+ function lastNonEmptyLine(text: string): string | undefined {
79
+ return text.trim().split(/\r?\n/).filter(Boolean).at(-1)?.trim();
80
+ }
81
+
69
82
  function truncateField(value: string, maxLength: number): string {
70
83
  if (value.length <= maxLength) return value;
71
84
  if (maxLength <= 1) return "…";
@@ -74,7 +87,7 @@ function truncateField(value: string, maxLength: number): string {
74
87
 
75
88
  function resultDigest(message: ToolResultMessage): string | undefined {
76
89
  const toolName = message.toolName.toLowerCase();
77
- const text = firstTextContent(message);
90
+ const text = sanitizeText(firstTextContent(message));
78
91
  if (toolName === "bash") {
79
92
  const details = message as { details?: { exitCode?: unknown } };
80
93
  const exitCode =
@@ -99,7 +112,12 @@ function resultDigest(message: ToolResultMessage): string | undefined {
99
112
  .join("; ") || "search digest unavailable"
100
113
  );
101
114
  }
102
- return undefined;
115
+ if (message.isError !== true) return undefined;
116
+ if (text.trim().length === 0) return "error=tool result failed without text";
117
+ const error = firstErrorLine(text);
118
+ if (error) return `error=${error}`;
119
+ const summary = firstNonEmptyLine(text) ?? lastNonEmptyLine(text);
120
+ return summary ? `summary=${summary}` : undefined;
103
121
  }
104
122
 
105
123
  function createPrunedNotice(tokens: number, message?: ToolResultMessage): string {
@@ -110,7 +128,9 @@ function createPrunedNotice(tokens: number, message?: ToolResultMessage): string
110
128
  const maxTokens = Math.max(genericTokens, Math.floor(genericTokens * DIGEST_NOTICE_TOKEN_CAP_MULTIPLIER));
111
129
  const prefix = `[Output truncated - ${tokens} tokens; `;
112
130
  const suffix = "]";
113
- const maxChars = Math.max(0, maxTokens * 4 - prefix.length - suffix.length);
131
+ const digestChars = maxTokens * 4 - prefix.length - suffix.length;
132
+ const maxChars =
133
+ message?.isError === true ? Math.max(ERROR_DIGEST_NOTICE_MIN_CHARS, digestChars) : Math.max(0, digestChars);
114
134
  return `${prefix}${truncateField(digest, maxChars)}${suffix}`;
115
135
  }
116
136
 
@@ -122,8 +142,7 @@ function getToolResultMessage(entry: SessionEntry): ToolResultMessage | undefine
122
142
  }
123
143
 
124
144
  function estimatePrunedSavings(tokens: number, notice: string): number {
125
- const noticeTokens = Math.ceil(notice.length / 4);
126
- return Math.max(0, tokens - noticeTokens);
145
+ return tokens - estimateTextTokensHeuristic(notice);
127
146
  }
128
147
 
129
148
  export interface AssistantArgumentPruneResult {
@@ -267,6 +286,56 @@ function readBasePath(path: string): string {
267
286
  return base;
268
287
  }
269
288
 
289
+ type ReadLineRange = { start: number; end: number };
290
+
291
+ const DEFAULT_READ_LINE_LIMIT = 500;
292
+
293
+ /** Parse trailing read selectors using the read tool's actual bounded default. */
294
+ function readLineRanges(path: string): ReadLineRange[] {
295
+ let target = path;
296
+ let raw = false;
297
+ while (/:(?:raw|conflicts)$/.test(target)) {
298
+ raw ||= target.endsWith(":raw");
299
+ target = target.replace(/:(?:raw|conflicts)$/, "");
300
+ }
301
+ const match = target.match(/:(\d+(?:[-+]\d+)?(?:,\d+(?:[-+]\d+)?)*)$/);
302
+ if (!match) return raw ? [{ start: 1, end: Number.POSITIVE_INFINITY }] : [];
303
+ return match[1].split(",").flatMap(part => {
304
+ const range = part.match(/^(\d+)(?:([-+])(\d+))?$/);
305
+ if (!range) return [];
306
+ const start = Number(range[1]);
307
+ const end =
308
+ range[2] === "+"
309
+ ? start + Number(range[3]) - 1
310
+ : range[2] === "-"
311
+ ? Number(range[3])
312
+ : start + DEFAULT_READ_LINE_LIMIT - 1;
313
+ return start > 0 && end >= start ? [{ start, end }] : [];
314
+ });
315
+ }
316
+
317
+ function strictlyContainsReadRange(container: ReadLineRange, contained: ReadLineRange): boolean {
318
+ return (
319
+ container.start <= contained.start &&
320
+ container.end >= contained.end &&
321
+ (container.start < contained.start || container.end > contained.end)
322
+ );
323
+ }
324
+
325
+ function readSupersedesRead(
326
+ later: ToolCall,
327
+ earlier: ToolCall,
328
+ lineRangesByCall: ReadonlyMap<ToolCall, ReadLineRange[]>,
329
+ ): boolean {
330
+ const laterRanges = lineRangesByCall.get(later);
331
+ const earlierRanges = lineRangesByCall.get(earlier);
332
+ return (
333
+ laterRanges?.length === 1 &&
334
+ earlierRanges?.length === 1 &&
335
+ strictlyContainsReadRange(laterRanges[0], earlierRanges[0])
336
+ );
337
+ }
338
+
270
339
  /**
271
340
  * Stable identity for "the same logical lookup": same tool re-targeting the
272
341
  * same subject. A later result with the same key supersedes earlier ones.
@@ -275,9 +344,23 @@ function readBasePath(path: string): string {
275
344
  * (`skip`) and result-shaping flags (`i`, `gitignore`): a later page or a
276
345
  * differently-shaped search complements earlier output, it does not replace it.
277
346
  */
347
+ const IDEMPOTENT_BASH_COMMAND =
348
+ /^(?:(?:bun|npm|pnpm|yarn)\s+(?:run\s+)?(?:test|build)\b|git\s+status\b|cargo\s+build\b|(?:make|just)\s+build\b)/;
349
+
350
+ function normalizedIdempotentBashCommand(call: ToolCall): string | undefined {
351
+ if (call.name !== "bash") return undefined;
352
+ const command = call.arguments.command;
353
+ if (typeof command !== "string") return undefined;
354
+ const normalized = command.trim().replace(/\s+/g, " ");
355
+ if (/[;&|]/.test(normalized) || !IDEMPOTENT_BASH_COMMAND.test(normalized)) return undefined;
356
+ return JSON.stringify([normalized, typeof call.arguments.cwd === "string" ? call.arguments.cwd : undefined]);
357
+ }
358
+
278
359
  function toolTargetKey(call: ToolCall): string | undefined {
279
360
  const path = toolCallPath(call);
280
361
  if (path !== undefined) return JSON.stringify([call.name, "path", path]);
362
+ const command = normalizedIdempotentBashCommand(call);
363
+ if (command !== undefined) return JSON.stringify([call.name, "command", command]);
281
364
  const pattern = call.arguments.pattern;
282
365
  if (typeof pattern === "string" && pattern.length > 0) {
283
366
  const paths = call.arguments.paths;
@@ -372,8 +455,9 @@ function buildStalenessIndex(entries: SessionEntry[]): StalenessIndex {
372
455
  }
373
456
  }
374
457
 
458
+ type ResultMeta = { key?: string; call: ToolCall; message: ToolResultMessage };
375
459
  const lastResultIndexByKey = new Map<string, number>();
376
- const resultMeta = new Map<number, { key?: string; call: ToolCall; message: ToolResultMessage }>();
460
+ const resultMeta = new Map<number, ResultMeta>();
377
461
  const lastEditIndexByPath = new Map<string, number>();
378
462
 
379
463
  for (let i = 0; i < entries.length; i++) {
@@ -439,6 +523,31 @@ function buildStalenessIndex(entries: SessionEntry[]): StalenessIndex {
439
523
  }
440
524
  }
441
525
 
526
+ const readsByBasePath = new Map<string, Array<[number, ResultMeta]>>();
527
+ const lineRangesByCall = new Map<ToolCall, ReadLineRange[]>();
528
+ for (const [index, meta] of resultMeta) {
529
+ if (meta.call.name !== "read") continue;
530
+ const path = toolCallPath(meta.call);
531
+ if (!path) continue;
532
+ lineRangesByCall.set(meta.call, readLineRanges(path));
533
+ const basePath = readBasePath(path);
534
+ const group = readsByBasePath.get(basePath);
535
+ if (group) group.push([index, meta]);
536
+ else readsByBasePath.set(basePath, [[index, meta]]);
537
+ }
538
+ for (const reads of readsByBasePath.values()) {
539
+ if (reads.length < 2) continue;
540
+ for (let earlier = 0; earlier < reads.length - 1; earlier++) {
541
+ const [index, meta] = reads[earlier];
542
+ for (let later = earlier + 1; later < reads.length; later++) {
543
+ if (readSupersedesRead(reads[later][1].call, meta.call, lineRangesByCall)) {
544
+ staleResultIndices.add(index);
545
+ break;
546
+ }
547
+ }
548
+ }
549
+ }
550
+
442
551
  return { staleResultIndices };
443
552
  }
444
553
  export function pruneAssistantToolArguments(
@@ -580,11 +689,17 @@ function collectToolOutputPruneCandidates(
580
689
  }
581
690
 
582
691
  const notice = createPrunedNotice(tokens, message);
692
+ const savings = estimatePrunedSavings(tokens, notice);
693
+ const errorNoticeGrows = message.isError === true && notice.length > firstTextContent(message).length;
694
+ if (savings <= 0 || errorNoticeGrows) {
695
+ accumulatedTokens += tokens;
696
+ continue;
697
+ }
583
698
  candidates.push({
584
699
  entry: entry as SessionMessageEntry,
585
700
  tokens,
586
701
  notice,
587
- savings: estimatePrunedSavings(tokens, notice),
702
+ savings,
588
703
  });
589
704
  accumulatedTokens += tokens;
590
705
  }
@@ -596,6 +711,13 @@ function collectToolOutputPruneCandidates(
596
711
  return { candidates, tokensSaved };
597
712
  }
598
713
 
714
+ function minimumSavings(config: PruneConfig, options: PruneToolOutputsOptions = {}): number {
715
+ const relaxedMinimum = options.relaxedMinimum;
716
+ return typeof relaxedMinimum === "number" && Number.isFinite(relaxedMinimum)
717
+ ? Math.min(config.minimumSavings, Math.max(0, relaxedMinimum))
718
+ : config.minimumSavings;
719
+ }
720
+
599
721
  /**
600
722
  * Estimate the token savings {@link pruneToolOutputs} would achieve, without
601
723
  * mutating any entry. Returns 0 savings when below the configured minimum so the
@@ -604,9 +726,10 @@ function collectToolOutputPruneCandidates(
604
726
  export function estimateToolOutputPruneSavings(
605
727
  entries: SessionEntry[],
606
728
  config: PruneConfig = DEFAULT_PRUNE_CONFIG,
729
+ options: PruneToolOutputsOptions = {},
607
730
  ): { prunableCount: number; tokensSaved: number } {
608
731
  const { candidates, tokensSaved } = collectToolOutputPruneCandidates(entries, config);
609
- if (tokensSaved < config.minimumSavings || candidates.length === 0) {
732
+ if (tokensSaved < minimumSavings(config, options) || candidates.length === 0) {
610
733
  return { prunableCount: 0, tokensSaved: 0 };
611
734
  }
612
735
  return { prunableCount: candidates.length, tokensSaved };
@@ -630,10 +753,20 @@ export function shouldRunMaintenancePrune(args: {
630
753
  return args.estimatedSavings > args.cacheEpochResetCost;
631
754
  }
632
755
 
633
- export function pruneToolOutputs(entries: SessionEntry[], config: PruneConfig = DEFAULT_PRUNE_CONFIG): PruneResult {
756
+ export interface PruneToolOutputsOptions {
757
+ /** Lower the usual minimum only when the caller is already over its compaction threshold. */
758
+ relaxedMinimum?: number;
759
+ }
760
+
761
+ export function pruneToolOutputs(
762
+ entries: SessionEntry[],
763
+ config: PruneConfig = DEFAULT_PRUNE_CONFIG,
764
+ options: PruneToolOutputsOptions = {},
765
+ ): PruneResult {
634
766
  const { candidates, tokensSaved } = collectToolOutputPruneCandidates(entries, config);
767
+ const minimum = minimumSavings(config, options);
635
768
 
636
- if (tokensSaved < config.minimumSavings || candidates.length === 0) {
769
+ if (tokensSaved < minimum || candidates.length === 0) {
637
770
  return { prunedCount: 0, tokensSaved: 0, prunedEntries: [] };
638
771
  }
639
772
 
package/src/proxy.ts CHANGED
@@ -30,6 +30,33 @@ class ProxyMessageEventStream extends EventStream<AssistantMessageEvent, Assista
30
30
  }
31
31
  }
32
32
 
33
+ interface ReasoningBuffers {
34
+ summary: string;
35
+ raw: string;
36
+ }
37
+
38
+ const reasoningBuffers = new WeakMap<object, ReasoningBuffers>();
39
+
40
+ function materializeReasoningProvenance(
41
+ content: Extract<AssistantMessage["content"][number], { type: "thinking" }>,
42
+ ): void {
43
+ const buffers = reasoningBuffers.get(content);
44
+ if (!buffers) return;
45
+ const mutable = content as { provenance?: "summary" | "raw" | "mixed"; summaryText?: string; rawText?: string };
46
+ if (mutable.provenance === undefined) {
47
+ if (mutable.summaryText === undefined && buffers.summary) mutable.summaryText = buffers.summary;
48
+ if (mutable.rawText === undefined && buffers.raw) mutable.rawText = buffers.raw;
49
+ mutable.provenance =
50
+ buffers.summary && buffers.raw ? "mixed" : buffers.summary ? "summary" : buffers.raw ? "raw" : undefined;
51
+ }
52
+ // Finalized display string must exclude raw CoT when a summary exists (parity with
53
+ // the Responses/Codex decoders): summary/mixed -> summary only; raw-only -> raw. The
54
+ // raw text stays available separately via rawText for explicit consumers.
55
+ const effSummary = mutable.summaryText ?? buffers.summary;
56
+ const effRaw = mutable.rawText ?? buffers.raw;
57
+ content.thinking = mutable.provenance === "raw" ? effRaw : effSummary || effRaw;
58
+ }
59
+
33
60
  /**
34
61
  * Proxy event types - server sends these with partial field stripped to reduce bandwidth.
35
62
  */
@@ -41,6 +68,9 @@ export type ProxyAssistantMessageEvent =
41
68
  | { type: "thinking_start"; contentIndex: number }
42
69
  | { type: "thinking_delta"; contentIndex: number; delta: string }
43
70
  | { type: "thinking_end"; contentIndex: number; contentSignature?: string }
71
+ | { type: "reasoning_summary_start"; contentIndex: number }
72
+ | { type: "reasoning_summary_delta"; contentIndex: number; delta: string }
73
+ | { type: "reasoning_summary_end"; contentIndex: number; content?: string }
44
74
  | { type: "toolcall_start"; contentIndex: number; id: string; toolName: string }
45
75
  | { type: "toolcall_delta"; contentIndex: number; delta: string }
46
76
  | { type: "toolcall_end"; contentIndex: number }
@@ -238,14 +268,22 @@ function processProxyEvent(
238
268
  throw new Error("Received text_end for non-text content");
239
269
  }
240
270
 
241
- case "thinking_start":
242
- partial.content[proxyEvent.contentIndex] = { type: "thinking", thinking: "" };
271
+ case "thinking_start": {
272
+ const content = { type: "thinking", thinking: "" } as Extract<
273
+ AssistantMessage["content"][number],
274
+ { type: "thinking" }
275
+ >;
276
+ partial.content[proxyEvent.contentIndex] = content;
277
+ reasoningBuffers.set(content, { summary: "", raw: "" });
243
278
  return { type: "thinking_start", contentIndex: proxyEvent.contentIndex, partial };
279
+ }
244
280
 
245
281
  case "thinking_delta": {
246
282
  const content = partial.content[proxyEvent.contentIndex];
247
283
  if (content?.type === "thinking") {
248
284
  content.thinking += proxyEvent.delta;
285
+ const buffers = reasoningBuffers.get(content);
286
+ if (buffers) buffers.raw += proxyEvent.delta;
249
287
  return {
250
288
  type: "thinking_delta",
251
289
  contentIndex: proxyEvent.contentIndex,
@@ -256,10 +294,52 @@ function processProxyEvent(
256
294
  throw new Error("Received thinking_delta for non-thinking content");
257
295
  }
258
296
 
297
+ case "reasoning_summary_start":
298
+ return { type: "reasoning_summary_start", contentIndex: proxyEvent.contentIndex, partial };
299
+
300
+ case "reasoning_summary_delta": {
301
+ const content = partial.content[proxyEvent.contentIndex];
302
+ if (content?.type === "thinking") {
303
+ content.thinking += proxyEvent.delta;
304
+ const buffers = reasoningBuffers.get(content);
305
+ if (buffers) buffers.summary += proxyEvent.delta;
306
+ return {
307
+ type: "reasoning_summary_delta",
308
+ contentIndex: proxyEvent.contentIndex,
309
+ delta: proxyEvent.delta,
310
+ partial,
311
+ };
312
+ }
313
+ throw new Error("Received reasoning_summary_delta for non-thinking content");
314
+ }
315
+
316
+ case "reasoning_summary_end": {
317
+ const content = partial.content[proxyEvent.contentIndex];
318
+ if (content?.type === "thinking") {
319
+ const buffers = reasoningBuffers.get(content);
320
+ // Final-only summaries arrive with the text on the end event and no summary
321
+ // deltas, so the accumulated buffer is empty. Prefer the end event's content
322
+ // so the summary survives the proxy and materializes as provenance "summary".
323
+ if (buffers && !buffers.summary.trim() && proxyEvent.content) {
324
+ buffers.summary = proxyEvent.content;
325
+ if (!content.thinking.trim()) content.thinking = proxyEvent.content;
326
+ }
327
+ materializeReasoningProvenance(content);
328
+ return {
329
+ type: "reasoning_summary_end",
330
+ contentIndex: proxyEvent.contentIndex,
331
+ content: buffers?.summary || proxyEvent.content || "",
332
+ partial,
333
+ };
334
+ }
335
+ throw new Error("Received reasoning_summary_end for non-thinking content");
336
+ }
337
+
259
338
  case "thinking_end": {
260
339
  const content = partial.content[proxyEvent.contentIndex];
261
340
  if (content?.type === "thinking") {
262
341
  content.thinkingSignature = proxyEvent.contentSignature;
342
+ materializeReasoningProvenance(content);
263
343
  return {
264
344
  type: "thinking_end",
265
345
  contentIndex: proxyEvent.contentIndex,
package/src/types.ts CHANGED
@@ -13,6 +13,7 @@ import type {
13
13
  Tool,
14
14
  ToolChoice,
15
15
  ToolResultMessage,
16
+ TransportFailureFacts,
16
17
  TSchema,
17
18
  } from "@sayknow-cli/ai";
18
19
  import type { AppendOnlyContextManager } from "./append-only-context";
@@ -25,11 +26,84 @@ export type StreamFn = (
25
26
  ...args: Parameters<typeof streamSimple>
26
27
  ) => AssistantMessageEventStream | Promise<AssistantMessageEventStream>;
27
28
 
29
+ /** Stable identifier for a managed logical run, shared by all of its retry attempts. */
30
+ export type ManagedLogicalRunId = number;
31
+
32
+ /** Terminal completion requested for a logical run. */
33
+ export interface RunTerminalRequest {
34
+ stopReason: "cancelled" | "error" | "exhausted";
35
+ messages?: AgentMessage[];
36
+ }
37
+
38
+ /**
39
+ * Ownership token supplied when Agent invokes a retry continuation.
40
+ *
41
+ * A continuation MUST verify `isCurrent()` immediately before starting a
42
+ * follow-up invocation and abandon the retry when it returns false. The token
43
+ * becomes invalid when its originating run is force-aborted or superseded.
44
+ * Coding-agent retry continuations must accept this argument and must not call
45
+ * `agent.continue()` after ownership has been lost.
46
+ */
47
+ export interface ManagedAttemptContinuationOwnership {
48
+ /** Per-attempt run-loop id; use only for attempt-local ownership checks. */
49
+ readonly runId: number;
50
+ /** Stable managed logical-run id; use for all terminal completion requests. */
51
+ readonly logicalRunId: ManagedLogicalRunId;
52
+ readonly generation: number;
53
+ isCurrent(): boolean;
54
+ }
55
+
56
+ /** Runs after a discarded attempt is idle, only while its ownership token remains current. */
57
+ export type ManagedAttemptContinuation = (ownership: ManagedAttemptContinuationOwnership) => void | Promise<void>;
58
+
59
+ /** Decision returned by managed fallback policy for one provisional attempt. */
60
+ export type ManagedAttemptDecision =
61
+ | { type: "retry"; continuation: ManagedAttemptContinuation }
62
+ | { type: "maintenance"; continuation: ManagedAttemptContinuation }
63
+ | { type: "terminal"; terminal: RunTerminalRequest };
64
+
65
+ /** Structured result for one managed upstream invocation. */
66
+ export type ManagedAttemptOutcome =
67
+ | {
68
+ type: "retryable_discarded";
69
+ failure: {
70
+ message: AssistantMessage;
71
+ /** Exact provider transport facts, including retry headers, for fallback policy. */
72
+ transportFailure?: TransportFailureFacts;
73
+ };
74
+ }
75
+ | { type: "context_overflow_discarded"; message: AssistantMessage }
76
+ | { type: "run_terminal"; reason: "cancelled" | "error" | "exhausted" };
77
+
78
+ export type ManagedAttemptOutcomeHandler = (
79
+ outcome: ManagedAttemptOutcome,
80
+ ) => ManagedAttemptDecision | Promise<ManagedAttemptDecision>;
81
+
82
+ /**
83
+ * Outcome of a cooperative mid-run context-maintenance checkpoint (see
84
+ * {@link AgentLoopConfig.maintainContext}). Any value other than "not-needed"
85
+ * means the checkpoint mutated (or attempted to mutate) durable context, so the
86
+ * loop ends the current run without the lossy `agent_end` finalization and the
87
+ * maintenance owner resumes the run on the rewritten context.
88
+ */
89
+ export type MidRunMaintenanceOutcome = "not-needed" | "pruned" | "compacted" | "promoted" | "failed" | "aborted";
90
+
28
91
  /**
29
92
  * Configuration for the agent loop.
30
93
  */
31
94
  export interface AgentLoopConfig extends SimpleStreamOptions {
32
95
  model: Model;
96
+ /**
97
+ * Supplies a fresh opaque token at each concrete managed transport invocation.
98
+ * The callback runs at the stream boundary so controller accounting matches
99
+ * upstream request count, including multi-step tool turns.
100
+ */
101
+ nextFallbackAttempt?: (model: Model) => SimpleStreamOptions["fallbackAttempt"];
102
+ /** Called after a managed upstream request is accepted and committed. */
103
+ onManagedAttemptAccepted?: () => void | Promise<void>;
104
+
105
+ /** Receives a managed invocation outcome without publishing provisional lifecycle events. */
106
+ onManagedAttemptOutcome?: ManagedAttemptOutcomeHandler;
33
107
 
34
108
  /**
35
109
  * When to interrupt tool execution for steering messages.
@@ -161,6 +235,33 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
161
235
  */
162
236
  syncContextBeforeModelCall?: (context: AgentContext) => void | Promise<void>;
163
237
 
238
+ /**
239
+ * Cooperative mid-run context-maintenance checkpoint.
240
+ *
241
+ * Invoked at the top of every loop iteration AFTER pending tool-result /
242
+ * steering messages have been materialized into durable context and BEFORE
243
+ * {@link syncContextBeforeModelCall} and the model call. This is the only
244
+ * boundary where the full unsent context (tool results + dequeued steering)
245
+ * is already durable, so a long uninterrupted tool loop can be bounded here
246
+ * before it grows past the provider window.
247
+ *
248
+ * The callback owns the maintenance decision (prune / compact / promote) and
249
+ * receives the minimal cancellation-aware lifecycle: `signal` is the
250
+ * non-optional loop signal, and `awaitEventDrain(invocationSignal)` waits for
251
+ * prior event consumer bodies with loop and invocation cancellation composed.
252
+ * Any outcome other than "not-needed" ends the current run with
253
+ * `agent_end.stopReason === "maintenance"` (NOT the lossy pause / completed
254
+ * finalization); the callback's continuation owner resumes the run on the
255
+ * rewritten context.
256
+ */
257
+ maintainContext?: (
258
+ context: AgentContext,
259
+ lifecycle: {
260
+ signal: AbortSignal;
261
+ awaitEventDrain: (invocationSignal: AbortSignal) => Promise<void>;
262
+ },
263
+ ) => Promise<MidRunMaintenanceOutcome> | MidRunMaintenanceOutcome;
264
+
164
265
  /**
165
266
  * Optional transform applied to tool call arguments before execution.
166
267
  * Use for deobfuscating secrets or rewriting arguments.
@@ -357,7 +458,7 @@ export type AgentMessage = Message | CustomAgentMessages[keyof CustomAgentMessag
357
458
  */
358
459
  export interface AgentState {
359
460
  systemPrompt: string[];
360
- model: Model;
461
+ model: Model | undefined;
361
462
  thinkingLevel?: Effort;
362
463
  tools: AgentTool<any>[];
363
464
  messages: AgentMessage[]; // Can include attachments + custom message types
@@ -470,8 +571,10 @@ export type AgentEvent =
470
571
  | {
471
572
  type: "agent_end";
472
573
  messages: AgentMessage[];
473
- /** Indicates whether the loop ended normally or suspended at a pause checkpoint. */
474
- stopReason?: "completed" | "paused";
574
+ /** Indicates whether the loop ended normally, suspended, cancelled, or entered maintenance. */
575
+ stopReason?: "completed" | "paused" | "cancelled" | "maintenance";
576
+ /** Present iff `stopReason === "maintenance"`; the maintenance outcome. */
577
+ maintenanceOutcome?: MidRunMaintenanceOutcome;
475
578
  /** Present iff `AgentTelemetryConfig` was supplied on this run. */
476
579
  telemetry?: AgentRunSummary;
477
580
  coverage?: AgentRunCoverage;
@@ -1,56 +0,0 @@
1
- /**
2
- * Agent loop that works with AgentMessage throughout.
3
- * Transforms to Message[] only at the LLM call boundary.
4
- */
5
- import { type Context, EventStream } from "@sayknow-cli/ai";
6
- import { type AgentRunCoverage, type AgentRunSummary } from "./run-collector";
7
- import type { AgentContext, AgentEvent, AgentLoopConfig, AgentMessage, StreamFn } from "./types";
8
- /**
9
- * Start an agent loop with a new prompt message.
10
- * The prompt is added to the context and events are emitted for it.
11
- */
12
- export declare function agentLoop(prompts: AgentMessage[], context: AgentContext, config: AgentLoopConfig, signal?: AbortSignal, streamFn?: StreamFn): EventStream<AgentEvent, AgentMessage[]>;
13
- /**
14
- * Continue an agent loop from the current context without adding a new message.
15
- * Used for retries - context already has user message or tool results.
16
- *
17
- * **Important:** The last message in context must convert to a `user` or `toolResult` message
18
- * via `convertToLlm`. If it doesn't, the LLM provider will reject the request.
19
- * This cannot be validated here since `convertToLlm` is only called once per turn.
20
- */
21
- export declare function agentLoopContinue(context: AgentContext, config: AgentLoopConfig, signal?: AbortSignal, streamFn?: StreamFn): EventStream<AgentEvent, AgentMessage[]>;
22
- /**
23
- * Detailed-result handle returned by {@link agentLoopDetailed}. Adds the
24
- * run-level telemetry/coverage rollup to the existing `AgentMessage[]`
25
- * payload without changing the resolved type of `stream.result()`.
26
- */
27
- export interface AgentLoopDetailedResult {
28
- readonly messages: AgentMessage[];
29
- readonly telemetry: AgentRunSummary | undefined;
30
- readonly coverage: AgentRunCoverage | undefined;
31
- }
32
- /**
33
- * Convenience wrapper over {@link agentLoop} that exposes the run-level
34
- * summary + coverage alongside the messages. The returned `stream` is the
35
- * same `EventStream` callers already consume; `detailed()` awaits the
36
- * stream's `agent_end` event and returns the additive fields.
37
- *
38
- * Existing `stream.result()` semantics are preserved — it still resolves to
39
- * `AgentMessage[]`. Use {@link agentLoopDetailed} when you need the rollup;
40
- * use {@link agentLoop} when you do not.
41
- */
42
- export declare function agentLoopDetailed(prompts: AgentMessage[], context: AgentContext, config: AgentLoopConfig, signal?: AbortSignal, streamFn?: StreamFn): {
43
- readonly stream: EventStream<AgentEvent, AgentMessage[]>;
44
- readonly detailed: () => Promise<AgentLoopDetailedResult>;
45
- };
46
- /**
47
- * Like {@link agentLoopDetailed} but built on top of
48
- * {@link agentLoopContinue}.
49
- */
50
- export declare function agentLoopContinueDetailed(context: AgentContext, config: AgentLoopConfig, signal?: AbortSignal, streamFn?: StreamFn): {
51
- readonly stream: EventStream<AgentEvent, AgentMessage[]>;
52
- readonly detailed: () => Promise<AgentLoopDetailedResult>;
53
- };
54
- export declare function normalizeMessagesForProvider(messages: Context["messages"], model: AgentLoopConfig["model"]): Context["messages"];
55
- export declare const INTENT_FIELD = "_i";
56
- export declare function normalizeTools(tools: AgentContext["tools"], injectIntent: boolean): Context["tools"];