@sayknow-cli/agent-core 0.2.2
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 +588 -0
- package/README.md +473 -0
- package/dist/types/agent-loop.d.ts +56 -0
- package/dist/types/agent.d.ts +381 -0
- package/dist/types/append-only-context.d.ts +124 -0
- package/dist/types/compaction/branch-summarization.d.ts +103 -0
- package/dist/types/compaction/compaction.d.ts +253 -0
- package/dist/types/compaction/entries.d.ts +109 -0
- package/dist/types/compaction/errors.d.ts +26 -0
- package/dist/types/compaction/index.d.ts +11 -0
- package/dist/types/compaction/messages.d.ts +61 -0
- package/dist/types/compaction/openai.d.ts +62 -0
- package/dist/types/compaction/pruning.d.ts +37 -0
- package/dist/types/compaction/utils.d.ts +32 -0
- package/dist/types/compaction.d.ts +1 -0
- package/dist/types/harmony-leak.d.ts +99 -0
- package/dist/types/index.d.ts +10 -0
- package/dist/types/proxy.d.ts +84 -0
- package/dist/types/run-collector.d.ts +196 -0
- package/dist/types/telemetry.d.ts +596 -0
- package/dist/types/thinking.d.ts +18 -0
- package/dist/types/types.d.ts +430 -0
- package/package.json +75 -0
- package/src/agent-loop.ts +1302 -0
- package/src/agent.ts +1531 -0
- package/src/append-only-context.ts +460 -0
- package/src/compaction/branch-summarization.ts +358 -0
- package/src/compaction/compaction.ts +1342 -0
- package/src/compaction/entries.ts +139 -0
- package/src/compaction/errors.ts +31 -0
- package/src/compaction/index.ts +12 -0
- package/src/compaction/messages.ts +212 -0
- package/src/compaction/openai.ts +570 -0
- package/src/compaction/prompts/auto-handoff-threshold-focus.md +1 -0
- package/src/compaction/prompts/branch-summary-context.md +5 -0
- package/src/compaction/prompts/branch-summary-preamble.md +2 -0
- package/src/compaction/prompts/branch-summary.md +30 -0
- package/src/compaction/prompts/compaction-short-summary.md +9 -0
- package/src/compaction/prompts/compaction-summary-context.md +5 -0
- package/src/compaction/prompts/compaction-summary.md +38 -0
- package/src/compaction/prompts/compaction-turn-prefix.md +17 -0
- package/src/compaction/prompts/compaction-update-summary.md +45 -0
- package/src/compaction/prompts/file-operations.md +10 -0
- package/src/compaction/prompts/handoff-document.md +49 -0
- package/src/compaction/prompts/summarization-system.md +3 -0
- package/src/compaction/pruning.ts +431 -0
- package/src/compaction/utils.ts +185 -0
- package/src/compaction.ts +1 -0
- package/src/harmony-leak.ts +428 -0
- package/src/index.ts +19 -0
- package/src/proxy.ts +326 -0
- package/src/run-collector.ts +631 -0
- package/src/telemetry.ts +2049 -0
- package/src/thinking.ts +20 -0
- package/src/types.ts +490 -0
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
You MUST incorporate new messages above into the existing handoff summary in <previous-summary> tags, used by another LLM to resume task.
|
|
2
|
+
RULES:
|
|
3
|
+
- MUST preserve all information from previous summary
|
|
4
|
+
- MUST add new progress, decisions, and context from new messages
|
|
5
|
+
- MUST update Progress: move items from "In Progress" to "Done" when completed
|
|
6
|
+
- MUST update "Next Steps" based on what was accomplished
|
|
7
|
+
- MUST preserve exact file paths, function names, and error messages
|
|
8
|
+
- You MAY remove anything no longer relevant
|
|
9
|
+
|
|
10
|
+
IMPORTANT: If new messages end with unanswered question or request to user, you MUST add it to Critical Context (replacing any previous pending question if answered).
|
|
11
|
+
|
|
12
|
+
You MUST use this format (omit sections if not applicable):
|
|
13
|
+
|
|
14
|
+
## Goal
|
|
15
|
+
[Preserve existing goals; add new ones if task expanded]
|
|
16
|
+
|
|
17
|
+
## Constraints & Preferences
|
|
18
|
+
- [Preserve existing; add new ones discovered]
|
|
19
|
+
|
|
20
|
+
## Progress
|
|
21
|
+
|
|
22
|
+
### Done
|
|
23
|
+
- [x] [Include previously done and newly completed items]
|
|
24
|
+
|
|
25
|
+
### In Progress
|
|
26
|
+
- [ ] [Current work—update based on progress]
|
|
27
|
+
|
|
28
|
+
### Blocked
|
|
29
|
+
- [Current blockers—remove if resolved]
|
|
30
|
+
|
|
31
|
+
## Key Decisions
|
|
32
|
+
- **[Decision]**: [Brief rationale] (preserve all previous, add new)
|
|
33
|
+
|
|
34
|
+
## Next Steps
|
|
35
|
+
1. [Update based on current state]
|
|
36
|
+
|
|
37
|
+
## Critical Context
|
|
38
|
+
- [Preserve important context; add new if needed]
|
|
39
|
+
|
|
40
|
+
## Additional Notes
|
|
41
|
+
[Other important info not fitting above]
|
|
42
|
+
|
|
43
|
+
You MUST output only the structured summary; you NEVER include extra text.
|
|
44
|
+
|
|
45
|
+
Sections MUST be kept concise. You MUST preserve relevant tool outputs/command results. You MUST include repository state changes (branch, uncommitted changes) if mentioned.
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
<critical>
|
|
2
|
+
Write a handoff document for another instance of yourself.
|
|
3
|
+
The handoff MUST be sufficient for seamless continuation without access to this conversation.
|
|
4
|
+
Output ONLY the handoff document. No preamble, no commentary, no wrapper text.
|
|
5
|
+
</critical>
|
|
6
|
+
|
|
7
|
+
<instruction>
|
|
8
|
+
Capture exact technical state, not abstractions.
|
|
9
|
+
- File paths, symbol names, commands run
|
|
10
|
+
- Test results, observed failures
|
|
11
|
+
- Decisions made
|
|
12
|
+
- Partial work affecting the next step
|
|
13
|
+
</instruction>
|
|
14
|
+
|
|
15
|
+
<output>
|
|
16
|
+
Use exactly this structure:
|
|
17
|
+
|
|
18
|
+
## Goal
|
|
19
|
+
[What the user is trying to accomplish]
|
|
20
|
+
|
|
21
|
+
## Constraints & Preferences
|
|
22
|
+
- [Any constraints, preferences, or requirements mentioned]
|
|
23
|
+
|
|
24
|
+
## Progress
|
|
25
|
+
### Done
|
|
26
|
+
- [x] [Completed tasks with specifics]
|
|
27
|
+
|
|
28
|
+
### In Progress
|
|
29
|
+
- [ ] [Current work if any]
|
|
30
|
+
|
|
31
|
+
### Pending
|
|
32
|
+
- [ ] [Tasks mentioned but not started]
|
|
33
|
+
|
|
34
|
+
## Key Decisions
|
|
35
|
+
- **[Decision]**: [Rationale]
|
|
36
|
+
|
|
37
|
+
## Critical Context
|
|
38
|
+
- Code snippets, file paths, function/type names, error messages, data essential to continue
|
|
39
|
+
- Repository state if relevant
|
|
40
|
+
|
|
41
|
+
## Next Steps
|
|
42
|
+
1. [What should happen next]
|
|
43
|
+
</output>
|
|
44
|
+
|
|
45
|
+
{{#if additionalFocus}}
|
|
46
|
+
<instruction>
|
|
47
|
+
Additional focus: {{additionalFocus}}
|
|
48
|
+
</instruction>
|
|
49
|
+
{{/if}}
|
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool output pruning utilities for compaction.
|
|
3
|
+
*
|
|
4
|
+
* Candidate selection is staleness-aware: tool results that have been
|
|
5
|
+
* superseded by a later result for the same target (same file read again,
|
|
6
|
+
* same search re-run) or invalidated by a later successful edit/write to a
|
|
7
|
+
* covered file are pruned in preference to merely-old results. Protect-window
|
|
8
|
+
* and minimum-savings hysteresis semantics are unchanged.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { ToolCall, ToolResultMessage } from "@sayknow-cli/ai";
|
|
12
|
+
import type { AgentMessage } from "../types";
|
|
13
|
+
import { estimateEntryTokens } from "./compaction";
|
|
14
|
+
import type { SessionEntry, SessionMessageEntry } from "./entries";
|
|
15
|
+
|
|
16
|
+
export interface PruneConfig {
|
|
17
|
+
/** Keep the most recent tool output tokens intact. */
|
|
18
|
+
protectTokens: number;
|
|
19
|
+
/** Only prune if total savings meets this threshold. */
|
|
20
|
+
minimumSavings: number;
|
|
21
|
+
/** Tool names that should never be pruned. */
|
|
22
|
+
protectedTools: string[];
|
|
23
|
+
/**
|
|
24
|
+
* Tools in `protectedTools` whose protection is waived once the result is
|
|
25
|
+
* superseded (a later result for the same target, or a later successful
|
|
26
|
+
* edit/write to the covered file). The most recent result per target is
|
|
27
|
+
* never considered superseded. Optional; defaults to none.
|
|
28
|
+
*/
|
|
29
|
+
staleOverridableTools?: string[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const DEFAULT_PRUNE_CONFIG: PruneConfig = {
|
|
33
|
+
protectTokens: 40_000,
|
|
34
|
+
minimumSavings: 20_000,
|
|
35
|
+
protectedTools: ["skill", "read"],
|
|
36
|
+
staleOverridableTools: ["read"],
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export interface PruneResult {
|
|
40
|
+
prunedCount: number;
|
|
41
|
+
tokensSaved: number;
|
|
42
|
+
/**
|
|
43
|
+
* The mutated message entries. Callers whose entry source returns
|
|
44
|
+
* materialized copies (not live references) must write these back into
|
|
45
|
+
* their canonical store by id.
|
|
46
|
+
*/
|
|
47
|
+
prunedEntries: SessionMessageEntry[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const DIGEST_NOTICE_TOKEN_CAP_MULTIPLIER = 1.25;
|
|
51
|
+
|
|
52
|
+
function createGenericPrunedNotice(tokens: number): string {
|
|
53
|
+
return `[Output truncated - ${tokens} tokens]`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function firstTextContent(message: ToolResultMessage): string {
|
|
57
|
+
if (typeof message.content === "string") return message.content;
|
|
58
|
+
const block = message.content.find(part => part.type === "text");
|
|
59
|
+
return block?.type === "text" ? block.text : "";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function firstErrorLine(text: string): string | undefined {
|
|
63
|
+
return text
|
|
64
|
+
.split(/\r?\n/)
|
|
65
|
+
.find(line => /error|failed|exception|panic/i.test(line))
|
|
66
|
+
?.trim();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function truncateField(value: string, maxLength: number): string {
|
|
70
|
+
if (value.length <= maxLength) return value;
|
|
71
|
+
if (maxLength <= 1) return "…";
|
|
72
|
+
return `${value.slice(0, maxLength - 1)}…`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function resultDigest(message: ToolResultMessage): string | undefined {
|
|
76
|
+
const toolName = message.toolName.toLowerCase();
|
|
77
|
+
const text = firstTextContent(message);
|
|
78
|
+
if (toolName === "bash") {
|
|
79
|
+
const details = message as { details?: { exitCode?: unknown } };
|
|
80
|
+
const exitCode =
|
|
81
|
+
typeof details.details?.exitCode === "number" ? details.details.exitCode : message.isError ? 1 : 0;
|
|
82
|
+
const tail = text.trim().split(/\r?\n/).filter(Boolean).at(-1) ?? "";
|
|
83
|
+
const error = firstErrorLine(text);
|
|
84
|
+
return [`exit=${exitCode}`, tail ? `tail=${tail}` : undefined, error ? `error=${error}` : undefined]
|
|
85
|
+
.filter((part): part is string => part !== undefined)
|
|
86
|
+
.join("; ");
|
|
87
|
+
}
|
|
88
|
+
if (toolName === "search" || toolName === "grep") {
|
|
89
|
+
const match = text.match(/(\d+)\s+matches?/i) ?? text.match(/totalMatches["']?:\s*(\d+)/i);
|
|
90
|
+
const files = text.match(/(\d+)\s+files?/i) ?? text.match(/filesWithMatches["']?:\s*(\d+)/i);
|
|
91
|
+
const error = firstErrorLine(text);
|
|
92
|
+
return (
|
|
93
|
+
[
|
|
94
|
+
match ? `matches=${match[1]}` : undefined,
|
|
95
|
+
files ? `files=${files[1]}` : undefined,
|
|
96
|
+
error ? `error=${error}` : undefined,
|
|
97
|
+
]
|
|
98
|
+
.filter((part): part is string => part !== undefined)
|
|
99
|
+
.join("; ") || "search digest unavailable"
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function createPrunedNotice(tokens: number, message?: ToolResultMessage): string {
|
|
106
|
+
const generic = createGenericPrunedNotice(tokens);
|
|
107
|
+
const digest = message ? resultDigest(message) : undefined;
|
|
108
|
+
if (!digest) return generic;
|
|
109
|
+
const genericTokens = Math.ceil(generic.length / 4);
|
|
110
|
+
const maxTokens = Math.max(genericTokens, Math.floor(genericTokens * DIGEST_NOTICE_TOKEN_CAP_MULTIPLIER));
|
|
111
|
+
const prefix = `[Output truncated - ${tokens} tokens; `;
|
|
112
|
+
const suffix = "]";
|
|
113
|
+
const maxChars = Math.max(0, maxTokens * 4 - prefix.length - suffix.length);
|
|
114
|
+
return `${prefix}${truncateField(digest, maxChars)}${suffix}`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function getToolResultMessage(entry: SessionEntry): ToolResultMessage | undefined {
|
|
118
|
+
if (entry.type !== "message") return undefined;
|
|
119
|
+
const message = entry.message as AgentMessage;
|
|
120
|
+
if (message.role !== "toolResult") return undefined;
|
|
121
|
+
return message as ToolResultMessage;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function estimatePrunedSavings(tokens: number, notice: string): number {
|
|
125
|
+
const noticeTokens = Math.ceil(notice.length / 4);
|
|
126
|
+
return Math.max(0, tokens - noticeTokens);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const EDIT_TOOL_NAMES = new Set(["edit", "write", "apply_patch", "ast_edit"]);
|
|
130
|
+
|
|
131
|
+
/** Extract the file-path argument from a tool call, when the tool has one. */
|
|
132
|
+
function toolCallPath(call: ToolCall): string | undefined {
|
|
133
|
+
const args = call.arguments;
|
|
134
|
+
const path = args.path ?? args.file_path ?? args.filePath;
|
|
135
|
+
return typeof path === "string" && path.length > 0 ? path : undefined;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* `*** Add|Update|Delete File: <path>` headers open a hunk; `*** Move to:
|
|
140
|
+
* <path>` attaches a rename destination to the current hunk. Move
|
|
141
|
+
* destinations count as touched paths: a rename onto a file invalidates
|
|
142
|
+
* earlier reads of that destination.
|
|
143
|
+
*/
|
|
144
|
+
const APPLY_PATCH_HEADER = /^\*\*\* (?:((?:Add|Update|Delete) File)|(Move to)): (.+)$/gm;
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Paths touched by an edit-class tool call, grouped per hunk so a failed
|
|
148
|
+
* hunk can be excluded wholesale (its rename destination included). Most
|
|
149
|
+
* edit tools carry a single path argument; apply_patch envelopes carry an
|
|
150
|
+
* `input` string with per-file headers instead. The envelope shape can
|
|
151
|
+
* arrive under the custom `apply_patch` tool OR the regular `edit` tool
|
|
152
|
+
* (providers without custom-tool support fall back to the JSON function), so
|
|
153
|
+
* any edit-class call with a string `input` is parsed for headers.
|
|
154
|
+
*/
|
|
155
|
+
function editToolPathGroups(call: ToolCall): string[][] {
|
|
156
|
+
const path = toolCallPath(call);
|
|
157
|
+
if (path !== undefined) return [[path]];
|
|
158
|
+
const input = call.arguments.input;
|
|
159
|
+
if (typeof input !== "string") return [];
|
|
160
|
+
const groups: string[][] = [];
|
|
161
|
+
for (const match of input.matchAll(APPLY_PATCH_HEADER)) {
|
|
162
|
+
const headerPath = match[3]?.trim();
|
|
163
|
+
if (!headerPath) continue;
|
|
164
|
+
const isMoveTo = match[2] !== undefined;
|
|
165
|
+
if (isMoveTo && groups.length > 0) {
|
|
166
|
+
groups[groups.length - 1].push(headerPath);
|
|
167
|
+
} else {
|
|
168
|
+
groups.push([headerPath]);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return groups;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Trailing read selectors (`:50`, `:50-200`, `:50+150`, `:5-16,960-973`,
|
|
176
|
+
* `:raw`, `:conflicts`), possibly stacked (`:2-4:raw`). Stripped to resolve
|
|
177
|
+
* the underlying file for edit invalidation.
|
|
178
|
+
*/
|
|
179
|
+
const READ_SELECTOR_SUFFIX = /:(?:raw|conflicts|\d+(?:[-+]\d+)?(?:,\d+(?:[-+]\d+)?)*)$/;
|
|
180
|
+
|
|
181
|
+
/** Base file path of a read target with any line/mode selectors stripped. */
|
|
182
|
+
function readBasePath(path: string): string {
|
|
183
|
+
let base = path;
|
|
184
|
+
while (READ_SELECTOR_SUFFIX.test(base)) {
|
|
185
|
+
base = base.replace(READ_SELECTOR_SUFFIX, "");
|
|
186
|
+
}
|
|
187
|
+
return base;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Stable identity for "the same logical lookup": same tool re-targeting the
|
|
192
|
+
* same subject. A later result with the same key supersedes earlier ones.
|
|
193
|
+
* Keys are canonical JSON tuples so user-controlled text (patterns, paths)
|
|
194
|
+
* can never collide via delimiter ambiguity. Search keys include pagination
|
|
195
|
+
* (`skip`) and result-shaping flags (`i`, `gitignore`): a later page or a
|
|
196
|
+
* differently-shaped search complements earlier output, it does not replace it.
|
|
197
|
+
*/
|
|
198
|
+
function toolTargetKey(call: ToolCall): string | undefined {
|
|
199
|
+
const path = toolCallPath(call);
|
|
200
|
+
if (path !== undefined) return JSON.stringify([call.name, "path", path]);
|
|
201
|
+
const pattern = call.arguments.pattern;
|
|
202
|
+
if (typeof pattern === "string" && pattern.length > 0) {
|
|
203
|
+
const paths = call.arguments.paths;
|
|
204
|
+
const pathList = Array.isArray(paths) ? paths.filter((p): p is string => typeof p === "string") : [];
|
|
205
|
+
const skip = typeof call.arguments.skip === "number" ? call.arguments.skip : 0;
|
|
206
|
+
const caseInsensitive = call.arguments.i === true;
|
|
207
|
+
const gitignore = call.arguments.gitignore !== false;
|
|
208
|
+
return JSON.stringify([call.name, "pattern", pattern, pathList, skip, caseInsensitive, gitignore]);
|
|
209
|
+
}
|
|
210
|
+
return undefined;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Files actually mutated according to a tool result's details. Used for
|
|
215
|
+
* AST-edit-shaped results (`ast_edit` direct-apply and the hidden `resolve`
|
|
216
|
+
* apply step), which report `{ applied: true, files: [...] }` — the resolve
|
|
217
|
+
* tool nests that payload under `details.sourceResultDetails`. Conservative:
|
|
218
|
+
* returns nothing unless the details explicitly mark the change as applied.
|
|
219
|
+
* Checked even on `isError` results: a stale-preview apply reports an error
|
|
220
|
+
* while still having mutated the listed files.
|
|
221
|
+
*/
|
|
222
|
+
function resultDetailFiles(message: ToolResultMessage): string[] {
|
|
223
|
+
const raw = message.details as { applied?: unknown; files?: unknown; sourceResultDetails?: unknown } | undefined;
|
|
224
|
+
const candidates = [raw, raw?.sourceResultDetails as { applied?: unknown; files?: unknown } | undefined];
|
|
225
|
+
for (const details of candidates) {
|
|
226
|
+
if (details?.applied === true && Array.isArray(details.files)) {
|
|
227
|
+
return details.files.filter((file): file is string => typeof file === "string" && file.length > 0);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return [];
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Paths that FAILED in a per-file edit result (`details.perFileResults`) and
|
|
235
|
+
* were NOT mutated by any same-path entry. Multi-file apply_patch catches
|
|
236
|
+
* per-file failures and still returns a non-error result; a purely-failed
|
|
237
|
+
* path was not mutated and must not stale reads. But apply_patch can emit
|
|
238
|
+
* multiple entries for the same path (e.g. several hunks): if any same-path
|
|
239
|
+
* entry succeeded the file still mutated, so it must NOT be suppressed.
|
|
240
|
+
* Conservative: only an entry explicitly marked `isError === true` counts as
|
|
241
|
+
* a failure; anything else (including ambiguous/malformed entries) counts as
|
|
242
|
+
* a success and keeps the path out of the suppression set.
|
|
243
|
+
*/
|
|
244
|
+
function failedEditPaths(message: ToolResultMessage): Set<string> {
|
|
245
|
+
const details = message.details as { perFileResults?: unknown } | undefined;
|
|
246
|
+
const perFile = details?.perFileResults;
|
|
247
|
+
if (!Array.isArray(perFile)) return new Set();
|
|
248
|
+
const failed = new Set<string>();
|
|
249
|
+
const succeeded = new Set<string>();
|
|
250
|
+
for (const item of perFile) {
|
|
251
|
+
const entry = item as { path?: unknown; isError?: unknown };
|
|
252
|
+
if (typeof entry?.path !== "string") continue;
|
|
253
|
+
if (entry.isError === true) failed.add(entry.path);
|
|
254
|
+
else succeeded.add(entry.path);
|
|
255
|
+
}
|
|
256
|
+
// A path mutated if any same-path entry succeeded, even when another
|
|
257
|
+
// same-path entry failed; drop those from the suppression set.
|
|
258
|
+
for (const path of succeeded) failed.delete(path);
|
|
259
|
+
return failed;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Concrete file path a `read` result actually came from, when the tool
|
|
264
|
+
* reported one (`details.resolvedPath`). Suffix resolution can map a bare
|
|
265
|
+
* filename argument onto a different concrete path.
|
|
266
|
+
*/
|
|
267
|
+
function readResolvedPath(message: ToolResultMessage): string | undefined {
|
|
268
|
+
const details = message.details as { resolvedPath?: unknown } | undefined;
|
|
269
|
+
const resolved = details?.resolvedPath;
|
|
270
|
+
return typeof resolved === "string" && resolved.length > 0 ? resolved : undefined;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
interface StalenessIndex {
|
|
274
|
+
/** Entry indices of toolResults superseded by a later same-target result or a later edit. */
|
|
275
|
+
staleResultIndices: Set<number>;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Build a staleness index over session entries (oldest -> newest):
|
|
280
|
+
* - a toolResult is stale when a later non-error toolResult shares its target key;
|
|
281
|
+
* - a `read` result is stale when a later non-error edit/write touches its file.
|
|
282
|
+
* The most recent result per target is never stale.
|
|
283
|
+
*/
|
|
284
|
+
function buildStalenessIndex(entries: SessionEntry[]): StalenessIndex {
|
|
285
|
+
const callsById = new Map<string, ToolCall>();
|
|
286
|
+
for (const entry of entries) {
|
|
287
|
+
if (entry.type !== "message") continue;
|
|
288
|
+
const message = entry.message as AgentMessage;
|
|
289
|
+
if (message.role !== "assistant") continue;
|
|
290
|
+
for (const content of message.content) {
|
|
291
|
+
if (content.type === "toolCall") callsById.set(content.id, content);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const lastResultIndexByKey = new Map<string, number>();
|
|
296
|
+
const resultMeta = new Map<number, { key?: string; call: ToolCall; message: ToolResultMessage }>();
|
|
297
|
+
const lastEditIndexByPath = new Map<string, number>();
|
|
298
|
+
|
|
299
|
+
for (let i = 0; i < entries.length; i++) {
|
|
300
|
+
const message = getToolResultMessage(entries[i]);
|
|
301
|
+
if (!message) continue;
|
|
302
|
+
const call = callsById.get(message.toolCallId);
|
|
303
|
+
if (!call) continue;
|
|
304
|
+
|
|
305
|
+
// AST edits mutate files when previews are applied via the hidden
|
|
306
|
+
// `resolve` tool; the call args carry globs, not concrete paths. Both
|
|
307
|
+
// tools report actually-touched files in result details. Collected
|
|
308
|
+
// BEFORE the error gate: a stale-preview apply reports an error while
|
|
309
|
+
// still having mutated the listed files.
|
|
310
|
+
if (call.name === "resolve" || call.name === "ast_edit") {
|
|
311
|
+
for (const editPath of resultDetailFiles(message)) {
|
|
312
|
+
lastEditIndexByPath.set(editPath, i);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
if (message.isError) continue;
|
|
316
|
+
|
|
317
|
+
const key = toolTargetKey(call);
|
|
318
|
+
resultMeta.set(i, { key, call, message });
|
|
319
|
+
if (key !== undefined) lastResultIndexByKey.set(key, i);
|
|
320
|
+
if (EDIT_TOOL_NAMES.has(call.name)) {
|
|
321
|
+
// Per-file edit results record failures in details.perFileResults;
|
|
322
|
+
// a failed hunk mutated nothing, so exclude its whole path group
|
|
323
|
+
// (rename destination included) from touched paths.
|
|
324
|
+
const failed = failedEditPaths(message);
|
|
325
|
+
for (const group of editToolPathGroups(call)) {
|
|
326
|
+
if (group.some(groupPath => failed.has(groupPath))) continue;
|
|
327
|
+
for (const editPath of group) {
|
|
328
|
+
lastEditIndexByPath.set(editPath, i);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const staleResultIndices = new Set<number>();
|
|
335
|
+
for (const [index, meta] of resultMeta) {
|
|
336
|
+
if (meta.key !== undefined) {
|
|
337
|
+
const lastIndex = lastResultIndexByKey.get(meta.key);
|
|
338
|
+
if (lastIndex !== undefined && lastIndex > index) {
|
|
339
|
+
staleResultIndices.add(index);
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
if (meta.call.name === "read") {
|
|
344
|
+
// Check both the call argument (selectors stripped) and the resolved
|
|
345
|
+
// path from result details: suffix resolution can map a bare filename
|
|
346
|
+
// onto a different concrete path, and edits may use either form.
|
|
347
|
+
const lookupPaths = new Set<string>();
|
|
348
|
+
const argPath = toolCallPath(meta.call);
|
|
349
|
+
if (argPath !== undefined) lookupPaths.add(readBasePath(argPath));
|
|
350
|
+
const resolved = readResolvedPath(meta.message);
|
|
351
|
+
if (resolved !== undefined) lookupPaths.add(resolved);
|
|
352
|
+
for (const lookupPath of lookupPaths) {
|
|
353
|
+
const editIndex = lastEditIndexByPath.get(lookupPath);
|
|
354
|
+
if (editIndex !== undefined && editIndex > index) {
|
|
355
|
+
staleResultIndices.add(index);
|
|
356
|
+
break;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
return { staleResultIndices };
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
export function pruneToolOutputs(entries: SessionEntry[], config: PruneConfig = DEFAULT_PRUNE_CONFIG): PruneResult {
|
|
366
|
+
let accumulatedTokens = 0;
|
|
367
|
+
let tokensSaved = 0;
|
|
368
|
+
let prunedCount = 0;
|
|
369
|
+
|
|
370
|
+
const { staleResultIndices } = buildStalenessIndex(entries);
|
|
371
|
+
const staleOverridable = new Set(config.staleOverridableTools ?? []);
|
|
372
|
+
const candidates: Array<{ entry: SessionMessageEntry; tokens: number; notice: string; savings: number }> = [];
|
|
373
|
+
|
|
374
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
375
|
+
const entry = entries[i];
|
|
376
|
+
const message = getToolResultMessage(entry);
|
|
377
|
+
if (!message) continue;
|
|
378
|
+
|
|
379
|
+
const tokens = estimateEntryTokens(entry);
|
|
380
|
+
const isStale = staleResultIndices.has(i);
|
|
381
|
+
// Staleness waives protected-tool immunity for overridable tools
|
|
382
|
+
// (e.g. a superseded `read`); the most recent result per target is
|
|
383
|
+
// never stale, so the latest read of each file stays protected.
|
|
384
|
+
const isProtected =
|
|
385
|
+
config.protectedTools.includes(message.toolName) && !(isStale && staleOverridable.has(message.toolName));
|
|
386
|
+
|
|
387
|
+
if (message.prunedAt !== undefined) {
|
|
388
|
+
accumulatedTokens += tokens;
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Stale results are prunable even inside the recency protect window —
|
|
393
|
+
// they are superseded, so recency no longer implies relevance. They
|
|
394
|
+
// still count toward window accounting so non-stale protection is
|
|
395
|
+
// unchanged.
|
|
396
|
+
const insideProtectWindow = accumulatedTokens < config.protectTokens;
|
|
397
|
+
if ((insideProtectWindow && !isStale) || isProtected) {
|
|
398
|
+
accumulatedTokens += tokens;
|
|
399
|
+
continue;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const notice = createPrunedNotice(tokens, message);
|
|
403
|
+
candidates.push({
|
|
404
|
+
entry: entry as SessionMessageEntry,
|
|
405
|
+
tokens,
|
|
406
|
+
notice,
|
|
407
|
+
savings: estimatePrunedSavings(tokens, notice),
|
|
408
|
+
});
|
|
409
|
+
accumulatedTokens += tokens;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
for (const candidate of candidates) {
|
|
413
|
+
tokensSaved += candidate.savings;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
if (tokensSaved < config.minimumSavings || candidates.length === 0) {
|
|
417
|
+
return { prunedCount: 0, tokensSaved: 0, prunedEntries: [] };
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const prunedAt = Date.now();
|
|
421
|
+
const prunedEntries: SessionMessageEntry[] = [];
|
|
422
|
+
for (const candidate of candidates) {
|
|
423
|
+
const message = candidate.entry.message as ToolResultMessage;
|
|
424
|
+
message.content = [{ type: "text", text: candidate.notice }];
|
|
425
|
+
message.prunedAt = prunedAt;
|
|
426
|
+
prunedEntries.push(candidate.entry);
|
|
427
|
+
prunedCount++;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
return { prunedCount, tokensSaved, prunedEntries };
|
|
431
|
+
}
|