@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.
- package/CHANGELOG.md +23 -0
- package/package.json +7 -8
- package/src/agent-loop.ts +826 -40
- package/src/agent.ts +260 -22
- package/src/compaction/compaction.ts +89 -95
- package/src/compaction/entries.ts +19 -0
- package/src/compaction/openai.ts +24 -30
- package/src/compaction/prompts/handoff-document.md +7 -0
- package/src/compaction/pruning.ts +144 -11
- package/src/proxy.ts +82 -2
- package/src/types.ts +106 -3
- package/dist/types/agent-loop.d.ts +0 -56
- package/dist/types/agent.d.ts +0 -403
- package/dist/types/append-only-context.d.ts +0 -137
- package/dist/types/compaction/branch-summarization.d.ts +0 -103
- package/dist/types/compaction/compaction.d.ts +0 -298
- package/dist/types/compaction/entries.d.ts +0 -109
- package/dist/types/compaction/errors.d.ts +0 -26
- package/dist/types/compaction/index.d.ts +0 -11
- package/dist/types/compaction/messages.d.ts +0 -61
- package/dist/types/compaction/openai.d.ts +0 -63
- package/dist/types/compaction/pruning.d.ts +0 -69
- package/dist/types/compaction/utils.d.ts +0 -32
- package/dist/types/compaction.d.ts +0 -1
- package/dist/types/harmony-leak.d.ts +0 -100
- package/dist/types/image-placeholder-guard.d.ts +0 -4
- package/dist/types/index.d.ts +0 -11
- package/dist/types/proxy.d.ts +0 -84
- package/dist/types/run-collector.d.ts +0 -196
- package/dist/types/telemetry.d.ts +0 -596
- package/dist/types/thinking.d.ts +0 -18
- package/dist/types/types.d.ts +0 -430
|
@@ -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
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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 <
|
|
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
|
-
|
|
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
|
|
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"];
|