@shanepadgett/tau-agent 0.20.2 → 0.22.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.
@@ -3,116 +3,58 @@ import type { ActiveContextPruningState } from "../../shared/context-pruning-sta
3
3
 
4
4
  type ContextMessage = ContextEvent["messages"][number];
5
5
 
6
- const NUDGE_MESSAGE_TYPE = "tau.context-pruning.nudge";
7
- const DEFERRED_MESSAGE_TYPE = "tau.context-pruning.deferred";
8
-
9
6
  export function projectContext(
10
7
  messages: readonly ContextMessage[],
11
8
  state: ActiveContextPruningState,
12
9
  ): ContextMessage[] {
13
- const abandonedToolCallIds = new Set<string>();
14
- const inputPairs = indexToolPairs(messages, abandonedToolCallIds);
15
- const anchorBoundary = visibleAnchorBoundary(messages, state.latestAnchorToolCallId, inputPairs);
16
- const prunedToolCallIds = state.latestAnchorToolCallId === undefined ? new Set<string>() : state.prunedToolCallIds;
17
- const prunedAutoreadRowIds =
18
- state.latestAnchorToolCallId === undefined ? new Set<string>() : state.prunedAutoreadRowIds;
19
- const projected: ContextMessage[] = [];
20
-
21
- for (let index = 0; index < messages.length; index += 1) {
10
+ if (state.latestAnchorToolCallId === undefined) return [...messages];
11
+ let anchorIndex = -1;
12
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
22
13
  const message = messages[index];
23
- if (message.role === "toolResult" && prunedToolCallIds.has(message.toolCallId)) continue;
24
14
  if (
25
- message.role === "custom" &&
26
- message.customType === "tau.autoread" &&
27
- isPrunedAutoread(message.details, prunedAutoreadRowIds)
28
- )
29
- continue;
30
- if (
31
- anchorBoundary !== undefined &&
32
- index <= anchorBoundary &&
33
- message.role === "custom" &&
34
- (message.customType === NUDGE_MESSAGE_TYPE || message.customType === DEFERRED_MESSAGE_TYPE)
35
- )
36
- continue;
37
- if (message.role !== "assistant") {
38
- projected.push(message);
39
- continue;
15
+ message?.role === "assistant" &&
16
+ message.content.some(
17
+ (block) =>
18
+ block.type === "toolCall" && block.id === state.latestAnchorToolCallId && block.name === "context_prune",
19
+ )
20
+ ) {
21
+ anchorIndex = index;
22
+ break;
40
23
  }
41
-
42
- const removeThinking = anchorBoundary !== undefined && index <= anchorBoundary;
43
- let changed = false;
44
- const content = message.content.filter((block) => {
45
- const remove =
46
- (removeThinking && block.type === "thinking") ||
47
- (block.type === "toolCall" && (prunedToolCallIds.has(block.id) || abandonedToolCallIds.has(block.id)));
48
- if (remove) changed = true;
49
- return !remove;
50
- });
51
- if (content.length === 0) continue;
52
- projected.push(changed ? { ...message, content } : message);
53
24
  }
54
-
55
- indexToolPairs(projected);
56
- return projected;
57
- }
58
-
59
- function isPrunedAutoread(value: unknown, prunedRowIds: ReadonlySet<string>): boolean {
60
- if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
61
- const rowId = (value as Record<string, unknown>).rowId;
62
- return typeof rowId === "string" && prunedRowIds.has(rowId);
63
- }
64
-
65
- function visibleAnchorBoundary(
66
- messages: readonly ContextMessage[],
67
- anchorToolCallId: string | undefined,
68
- pairs: ReadonlyMap<string, { callIndex: number; resultIndex: number }>,
69
- ): number | undefined {
70
- if (anchorToolCallId === undefined) return undefined;
71
- const pair = pairs.get(anchorToolCallId);
72
- if (!pair) return undefined;
73
- const call = messages[pair.callIndex];
74
- const result = messages[pair.resultIndex];
75
- if (call?.role !== "assistant" || result?.role !== "toolResult" || result.toolName !== "context_prune")
76
- return undefined;
77
- const block = call.content.find((item) => item.type === "toolCall" && item.id === anchorToolCallId);
78
- return block?.type === "toolCall" && block.name === "context_prune" ? pair.resultIndex : undefined;
79
- }
80
-
81
- function indexToolPairs(
82
- messages: readonly ContextMessage[],
83
- abandonedToolCallIds?: Set<string>,
84
- ): Map<string, { callIndex: number; resultIndex: number }> {
85
- const calls = new Map<string, { index: number; aborted: boolean }>();
86
- const results = new Map<string, number>();
87
- for (let index = 0; index < messages.length; index += 1) {
25
+ if (anchorIndex < 0) return [...messages];
26
+ const retainedCallNames = new Map<string, string>();
27
+ const retainedResultNames = new Map<string, string>();
28
+ for (let index = 0; index < anchorIndex; index += 1) {
88
29
  const message = messages[index];
89
- if (message.role === "assistant") {
30
+ if (message?.role === "assistant") {
90
31
  for (const block of message.content) {
91
- if (block.type !== "toolCall") continue;
92
- if (calls.has(block.id)) throw new Error(`Duplicate tool call in projected context: ${block.id}`);
93
- calls.set(block.id, { index, aborted: message.stopReason === "aborted" });
32
+ if (block.type === "toolCall" && state.retainedToolCallIds.has(block.id)) {
33
+ retainedCallNames.set(block.id, block.name);
34
+ }
94
35
  }
95
- } else if (message.role === "toolResult") {
96
- if (results.has(message.toolCallId))
97
- throw new Error(`Duplicate tool result in projected context: ${message.toolCallId}`);
98
- results.set(message.toolCallId, index);
36
+ } else if (message?.role === "toolResult" && state.retainedToolCallIds.has(message.toolCallId)) {
37
+ retainedResultNames.set(message.toolCallId, message.toolName);
99
38
  }
100
39
  }
40
+ const retainableToolCallIds = new Set(
41
+ [...retainedCallNames].flatMap(([id, name]) => (retainedResultNames.get(id) === name ? [id] : [])),
42
+ );
101
43
 
102
- const pairs = new Map<string, { callIndex: number; resultIndex: number }>();
103
- for (const [id, call] of calls) {
104
- const resultIndex = results.get(id);
105
- if (resultIndex === undefined) {
106
- if (call.aborted && abandonedToolCallIds) {
107
- abandonedToolCallIds.add(id);
108
- continue;
109
- }
110
- throw new Error(`Orphaned tool call in projected context: ${id}`);
44
+ const projected: ContextMessage[] = [];
45
+ for (let index = 0; index < anchorIndex; index += 1) {
46
+ const message = messages[index];
47
+ if (!message) continue;
48
+ if (message.role === "toolResult") {
49
+ if (retainableToolCallIds.has(message.toolCallId)) projected.push(message);
50
+ continue;
111
51
  }
112
- pairs.set(id, { callIndex: call.index, resultIndex });
113
- }
114
- for (const id of results.keys()) {
115
- if (!calls.has(id)) throw new Error(`Orphaned tool result in projected context: ${id}`);
52
+ if (message.role !== "assistant") continue;
53
+ const content = message.content.filter(
54
+ (block) => block.type === "toolCall" && retainableToolCallIds.has(block.id),
55
+ );
56
+ if (content.length > 0) projected.push({ ...message, content });
116
57
  }
117
- return pairs;
58
+ projected.push(...messages.slice(anchorIndex));
59
+ return projected;
118
60
  }
@@ -1,43 +1,21 @@
1
- import {
2
- estimateTokens,
3
- type ContextEvent,
4
- type ExtensionAPI,
5
- type ExtensionContext,
6
- type SessionEntry,
7
- } from "@earendil-works/pi-coding-agent";
1
+ import { resolve } from "node:path";
2
+ import { type ExtensionContext, type SessionEntry } from "@earendil-works/pi-coding-agent";
8
3
  import { type Static, Type } from "typebox";
9
- import type { ContextPruneDeferredFileV1, ContextPruneDetailsV1 } from "../../shared/context-pruning-state.ts";
10
- import { canonicalizeFileSelections, selectFileEvidence } from "./file-evidence.ts";
11
-
12
- type ContextMessage = ContextEvent["messages"][number];
4
+ import type { ContextPruneDeferredFileV2, ContextPruneDetailsV2 } from "../../shared/context-pruning-state.ts";
5
+ import { prepareAutoreadMessage, type PreparedAutoreadMessage } from "../explore/autoread.ts";
6
+ import { MAX_COMPLETE_FILE_SNAPSHOT_BYTES } from "../explore/full-file-knowledge.ts";
13
7
 
14
8
  export const contextPruneParameters = Type.Object(
15
9
  {
16
10
  keepFiles: Type.Array(
17
- Type.Object(
18
- {
19
- path: Type.String(),
20
- relevance: Type.String(),
21
- },
22
- { additionalProperties: false },
23
- ),
11
+ Type.Object({ path: Type.String(), relevance: Type.String() }, { additionalProperties: false }),
24
12
  ),
25
13
  keepToolCalls: Type.Array(
26
- Type.Object(
27
- {
28
- toolCallId: Type.String(),
29
- relevance: Type.String(),
30
- },
31
- { additionalProperties: false },
32
- ),
14
+ Type.Object({ toolCallId: Type.String(), relevance: Type.String() }, { additionalProperties: false }),
33
15
  ),
34
16
  deferFiles: Type.Array(
35
17
  Type.Object(
36
- {
37
- path: Type.String(),
38
- reason: Type.String(),
39
- relevantWhen: Type.String(),
40
- },
18
+ { path: Type.String(), reason: Type.String(), relevantWhen: Type.String() },
41
19
  { additionalProperties: false },
42
20
  ),
43
21
  ),
@@ -47,294 +25,164 @@ export const contextPruneParameters = Type.Object(
47
25
 
48
26
  export type ContextPruneInput = Static<typeof contextPruneParameters>;
49
27
 
50
- interface ProjectionSnapshot {
51
- generation: number;
52
- messages: ContextEvent["messages"];
53
- }
54
-
55
28
  interface ContextPruneExecutionOptions {
56
- pi: Pick<ExtensionAPI, "sendMessage">;
57
29
  toolCallId: string;
58
30
  params: ContextPruneInput;
59
31
  signal: AbortSignal | undefined;
60
32
  ctx: ExtensionContext;
61
- projection: ProjectionSnapshot | undefined;
33
+ generation: number;
62
34
  currentGeneration: () => number;
63
- currentEnabled: () => boolean;
64
- minimumReclaimTokens: number;
65
35
  }
66
36
 
67
37
  interface ContextPruneExecutionResult {
68
38
  content: Array<{ type: "text"; text: string }>;
69
- details: ContextPruneDetailsV1;
70
- }
71
-
72
- interface PreparedPrune {
73
- messages: Array<Parameters<ExtensionAPI["sendMessage"]>[0]>;
74
- result: ContextPruneExecutionResult;
39
+ details: ContextPruneDetailsV2;
75
40
  }
76
41
 
77
42
  export async function executeContextPrune(options: ContextPruneExecutionOptions): Promise<ContextPruneExecutionResult> {
78
- const skipped = (reason: string, estimates = { before: 0, after: 0 }): ContextPruneExecutionResult => ({
79
- content: [{ type: "text", text: `Prune skipped: ${reason} Continue the task without immediately retrying.` }],
80
- details: {
81
- v: 1,
82
- status: "skipped",
83
- anchorToolCallId: options.toolCallId,
84
- newlyPrunedToolCallIds: [],
85
- newlyPrunedAutoreadRowIds: [],
86
- retainedToolCallIds: [],
87
- retainedAutoreadRowIds: [],
88
- refreshedFiles: [],
89
- deferredFiles: [],
90
- tokensBefore: estimates.before,
91
- tokensAfter: estimates.after,
92
- tokensReclaimed: estimates.before - estimates.after,
93
- },
94
- });
95
-
96
- if (!options.currentEnabled()) return skipped("context pruning is disabled.");
97
- options.signal?.throwIfAborted();
98
- const projection = options.projection;
99
- if (!projection || projection.generation !== options.currentGeneration()) {
100
- return skipped("the latest provider-context projection is unavailable.");
101
- }
102
- const currentAssistant = findCurrentAssistant(options.ctx.sessionManager.buildContextEntries(), options.toolCallId);
103
- if (!currentAssistant) return skipped("the current context_prune call is absent from the active branch.");
104
- const currentToolCalls = currentAssistant.content.filter((block) => block.type === "toolCall");
105
- if (
106
- currentToolCalls.length !== 1 ||
107
- currentToolCalls[0]?.id !== options.toolCallId ||
108
- currentToolCalls[0]?.name !== "context_prune"
109
- ) {
110
- return skipped("context_prune must be the only tool call in its assistant message.");
111
- }
112
-
113
- let pairs: Set<string>;
114
- let autoreads: Map<string, ContextMessage>;
115
- let preparedPrune: PreparedPrune;
116
- try {
117
- pairs = indexCompleteToolPairs(projection.messages);
118
- autoreads = indexAutoreads(projection.messages);
119
- } catch (error) {
120
- return skipped(errorMessage(error));
121
- }
122
-
123
- const explicitlyRetained = new Set<string>();
124
- for (const selection of options.params.keepToolCalls) {
125
- if (explicitlyRetained.has(selection.toolCallId)) {
126
- return skipped(`tool-call ID ${selection.toolCallId} was selected more than once.`);
127
- }
128
- if (!pairs.has(selection.toolCallId)) {
129
- return skipped(`tool-call ID ${selection.toolCallId} is not a complete currently projected exchange.`);
130
- }
131
- explicitlyRetained.add(selection.toolCallId);
132
- }
133
-
134
- try {
135
- const canonical = await canonicalizeFileSelections({
136
- cwd: options.ctx.cwd,
137
- keepFiles: options.params.keepFiles,
138
- deferFiles: options.params.deferFiles,
139
- });
140
- const fileEvidence = await selectFileEvidence({
141
- cwd: options.ctx.cwd,
142
- messages: projection.messages,
143
- files: canonical.keepFiles,
144
- anchorToolCallId: options.toolCallId,
145
- signal: options.signal,
146
- isLifecycleCurrent: () => projection.generation === options.currentGeneration(),
147
- });
148
- options.signal?.throwIfAborted();
149
- if (projection.generation !== options.currentGeneration()) {
150
- throw new Error("Prune preparation crossed a session lifecycle boundary");
151
- }
152
-
153
- const retainedTools = new Set([...explicitlyRetained, ...fileEvidence.retainedToolCallIds]);
154
- const retainedAutoreads = new Set(fileEvidence.retainedAutoreadRowIds);
155
- const retainedToolCallIds = [...pairs.keys()].filter((id) => retainedTools.has(id));
156
- const retainedAutoreadRowIds = [
157
- ...[...autoreads.keys()].filter((id) => retainedAutoreads.has(id)),
158
- ...fileEvidence.preparedSnapshots.map((snapshot) => snapshot.details.rowId),
159
- ];
160
- const newlyPrunedToolCallIds = [...pairs.keys()].filter((id) => !retainedTools.has(id));
161
- const newlyPrunedAutoreadRowIds = [...autoreads.keys()].filter((id) => !retainedAutoreads.has(id));
162
- const deferredFiles: ContextPruneDeferredFileV1[] = canonical.deferFiles.map((file) => ({
163
- path: file.displayPath,
164
- reason: file.request.reason,
165
- relevantWhen: file.request.relevantWhen,
166
- }));
167
- const deferredMessage = deferredFiles.length === 0 ? undefined : createDeferredMessage(deferredFiles);
168
- const beforeMessages = [...projection.messages, currentAssistant];
169
- const afterMessages = projectCandidate(
170
- beforeMessages,
171
- new Set(newlyPrunedToolCallIds),
172
- new Set(newlyPrunedAutoreadRowIds),
173
- );
174
- for (const prepared of fileEvidence.preparedSnapshots) {
175
- afterMessages.push({
176
- role: "custom",
177
- customType: prepared.customType,
178
- content: prepared.content,
179
- display: prepared.display,
180
- details: prepared.details,
181
- timestamp: 0,
182
- });
183
- }
184
- if (deferredMessage) afterMessages.push({ role: "custom", ...deferredMessage, timestamp: 0 });
185
- const tokensBefore = beforeMessages.reduce((total, message) => total + estimateTokens(message), 0);
186
- const tokensAfter = afterMessages.reduce((total, message) => total + estimateTokens(message), 0);
187
- const tokensReclaimed = tokensBefore - tokensAfter;
188
- if (tokensReclaimed < options.minimumReclaimTokens) {
189
- return skipped(
190
- `estimated reclaim is ${tokensReclaimed} tokens, below the ${options.minimumReclaimTokens}-token minimum.`,
191
- { before: tokensBefore, after: tokensAfter },
43
+ assertCurrent(options);
44
+ const retainedToolCallIds = [...new Set(options.params.keepToolCalls.map((selection) => selection.toolCallId))];
45
+ const retainedTools = new Set(retainedToolCallIds);
46
+ const { prunedToolCallIds, prunedAutoreadRowIds } = collectPrunedRows(
47
+ options.ctx.sessionManager.getBranch(),
48
+ options.toolCallId,
49
+ retainedTools,
50
+ );
51
+ const warnings: string[] = [];
52
+ const preparedSnapshots: PreparedAutoreadMessage[] = [];
53
+ const keptPaths = new Set<string>();
54
+ for (const selection of options.params.keepFiles) {
55
+ assertCurrent(options);
56
+ const path = normalizePath(selection.path);
57
+ const pathKey = resolve(options.ctx.cwd, path);
58
+ if (keptPaths.has(pathKey)) continue;
59
+ keptPaths.add(pathKey);
60
+ try {
61
+ preparedSnapshots.push(
62
+ await prepareAutoreadMessage({
63
+ rowId: `${options.toolCallId}:${preparedSnapshots.length}`,
64
+ path,
65
+ cwd: options.ctx.cwd,
66
+ source: "context-pruning",
67
+ batchId: options.toolCallId,
68
+ signal: options.signal,
69
+ isLifecycleCurrent: () => options.generation === options.currentGeneration(),
70
+ maximumBytes: MAX_COMPLETE_FILE_SNAPSHOT_BYTES,
71
+ }),
192
72
  );
73
+ } catch (error) {
74
+ if (options.signal?.aborted || options.generation !== options.currentGeneration()) throw error;
75
+ warnings.push(`${path}: ${errorMessage(error)}`);
193
76
  }
194
-
195
- options.signal?.throwIfAborted();
196
- if (projection.generation !== options.currentGeneration()) {
197
- throw new Error("Prune preparation crossed a session lifecycle boundary");
198
- }
199
- const details: ContextPruneDetailsV1 = {
200
- v: 1,
201
- status: "applied",
202
- anchorToolCallId: options.toolCallId,
203
- newlyPrunedToolCallIds,
204
- newlyPrunedAutoreadRowIds,
205
- retainedToolCallIds,
206
- retainedAutoreadRowIds,
207
- refreshedFiles: [...fileEvidence.refreshedFiles],
208
- deferredFiles,
209
- tokensBefore,
210
- tokensAfter,
211
- tokensReclaimed,
212
- };
213
- preparedPrune = {
214
- messages: [...fileEvidence.preparedSnapshots, ...(deferredMessage === undefined ? [] : [deferredMessage])],
215
- result: {
216
- content: [
217
- {
218
- type: "text",
219
- text: `Prune applied: reclaimed about ${tokensReclaimed} tokens. Continue with the next action stated before this call.`,
220
- },
221
- ],
222
- details,
223
- },
224
- };
225
- } catch (error) {
226
- if (options.signal?.aborted) throw error;
227
- if (projection.generation !== options.currentGeneration()) throw error;
228
- return skipped(errorMessage(error));
229
77
  }
230
78
 
231
- // Pi synchronously enqueues steering messages. Keep this commit outside preparation's
232
- // failure-to-skipped boundary so a publication error can never masquerade as an atomic no-op.
233
- for (const message of preparedPrune.messages) options.pi.sendMessage(message, { deliverAs: "steer" });
234
- return preparedPrune.result;
235
- }
236
-
237
- function indexCompleteToolPairs(messages: readonly ContextMessage[]): Set<string> {
238
- const calls = new Map<string, { name: string; index: number }>();
239
- const results = new Map<string, { name: string; index: number }>();
240
- for (let index = 0; index < messages.length; index += 1) {
241
- const message = messages[index];
242
- if (!message) continue;
243
- if (message.role === "assistant") {
244
- for (const block of message.content) {
245
- if (block.type !== "toolCall") continue;
246
- if (calls.has(block.id)) throw new Error(`Duplicate projected tool-call ID: ${block.id}.`);
247
- calls.set(block.id, { name: block.name, index });
248
- }
249
- } else if (message.role === "toolResult") {
250
- if (results.has(message.toolCallId))
251
- throw new Error(`Duplicate projected tool result: ${message.toolCallId}.`);
252
- results.set(message.toolCallId, { name: message.toolName, index });
253
- }
254
- }
255
- const pairs = new Set<string>();
256
- for (const [id, call] of calls) {
257
- const result = results.get(id);
258
- if (!result || result.name !== call.name || result.index <= call.index) {
259
- throw new Error(`Incomplete projected tool exchange: ${id}.`);
260
- }
261
- pairs.add(id);
79
+ const deferredFiles: ContextPruneDeferredFileV2[] = [];
80
+ const deferredPaths = new Set<string>();
81
+ for (const selection of options.params.deferFiles) {
82
+ const path = normalizePath(selection.path);
83
+ const pathKey = resolve(options.ctx.cwd, path);
84
+ if (keptPaths.has(pathKey) || deferredPaths.has(pathKey)) continue;
85
+ deferredPaths.add(pathKey);
86
+ deferredFiles.push({ path, reason: selection.reason, relevantWhen: selection.relevantWhen });
262
87
  }
263
- for (const id of results.keys()) if (!calls.has(id)) throw new Error(`Orphaned projected tool result: ${id}.`);
264
- return pairs;
88
+ assertCurrent(options);
89
+
90
+ const retainedAutoreadRowIds = preparedSnapshots.map((snapshot) => snapshot.details.rowId);
91
+ const refreshedFiles = preparedSnapshots.map((snapshot) => ({
92
+ path: snapshot.details.path,
93
+ rowId: snapshot.details.rowId,
94
+ servedHash: snapshot.details.readCache.servedHash,
95
+ autoreadDetails: { ...snapshot.details },
96
+ }));
97
+ const details: ContextPruneDetailsV2 = {
98
+ v: 2,
99
+ anchorToolCallId: options.toolCallId,
100
+ prunedToolCallIds,
101
+ prunedAutoreadRowIds,
102
+ retainedToolCallIds,
103
+ retainedAutoreadRowIds,
104
+ refreshedFiles,
105
+ deferredFiles,
106
+ warnings,
107
+ };
108
+ const status =
109
+ warnings.length === 0
110
+ ? "Context checkpoint applied. Continue with the next action stated before this call."
111
+ : `Context checkpoint applied with ${warnings.length} warning${warnings.length === 1 ? "" : "s"}:\n${warnings.map((warning) => `- ${warning}`).join("\n")}\nContinue with the next action stated before this call.`;
112
+ return {
113
+ content: [
114
+ { type: "text", text: status },
115
+ ...preparedSnapshots.map((snapshot) => ({ type: "text" as const, text: snapshot.content })),
116
+ ...(deferredFiles.length === 0 ? [] : [{ type: "text" as const, text: deferredFileText(deferredFiles) }]),
117
+ ],
118
+ details,
119
+ };
265
120
  }
266
121
 
267
- function indexAutoreads(messages: readonly ContextMessage[]): Map<string, ContextMessage> {
268
- const rows = new Map<string, ContextMessage>();
269
- for (const message of messages) {
270
- if (message.role !== "custom" || message.customType !== "tau.autoread") continue;
122
+ function collectPrunedRows(
123
+ branch: readonly SessionEntry[],
124
+ anchorToolCallId: string,
125
+ retainedToolCallIds: ReadonlySet<string>,
126
+ ): { prunedToolCallIds: string[]; prunedAutoreadRowIds: string[] } {
127
+ let anchorIndex = -1;
128
+ for (let index = branch.length - 1; index >= 0; index -= 1) {
129
+ const entry = branch[index];
271
130
  if (
272
- !isRecord(message.details) ||
273
- typeof message.details.rowId !== "string" ||
274
- message.details.rowId.length === 0
131
+ entry?.type === "message" &&
132
+ entry.message.role === "assistant" &&
133
+ entry.message.content.some(
134
+ (block) => block.type === "toolCall" && block.id === anchorToolCallId && block.name === "context_prune",
135
+ )
275
136
  ) {
276
- throw new Error("Projected autoread evidence has no stable row ID.");
137
+ anchorIndex = index;
138
+ break;
277
139
  }
278
- if (rows.has(message.details.rowId))
279
- throw new Error(`Duplicate projected autoread row: ${message.details.rowId}.`);
280
- rows.set(message.details.rowId, message);
281
140
  }
282
- return rows;
283
- }
284
-
285
- function findCurrentAssistant(
286
- branch: readonly SessionEntry[],
287
- toolCallId: string,
288
- ): Extract<ContextMessage, { role: "assistant" }> | undefined {
289
- for (let index = branch.length - 1; index >= 0; index -= 1) {
141
+ if (anchorIndex < 0) return { prunedToolCallIds: [], prunedAutoreadRowIds: [] };
142
+ const prunedToolCallIds = new Set<string>();
143
+ const prunedAutoreadRowIds = new Set<string>();
144
+ for (let index = 0; index < anchorIndex; index += 1) {
290
145
  const entry = branch[index];
291
- if (entry?.type !== "message" || entry.message.role !== "assistant") continue;
292
- if (entry.message.content.some((block) => block.type === "toolCall" && block.id === toolCallId))
293
- return entry.message;
294
- }
295
- return undefined;
296
- }
297
-
298
- function projectCandidate(
299
- messages: readonly ContextMessage[],
300
- prunedToolIds: ReadonlySet<string>,
301
- prunedAutoreadIds: ReadonlySet<string>,
302
- ): ContextMessage[] {
303
- const projected: ContextMessage[] = [];
304
- for (const message of messages) {
305
- if (message.role === "toolResult" && prunedToolIds.has(message.toolCallId)) continue;
306
- if (
307
- message.role === "custom" &&
308
- (message.customType === "tau.context-pruning.nudge" || message.customType === "tau.context-pruning.deferred")
309
- )
146
+ if (entry?.type === "message" && entry.message.role === "assistant") {
147
+ for (const block of entry.message.content) {
148
+ if (block.type === "toolCall" && !retainedToolCallIds.has(block.id)) prunedToolCallIds.add(block.id);
149
+ }
310
150
  continue;
311
- if (message.role === "custom" && message.customType === "tau.autoread" && isRecord(message.details)) {
312
- const rowId = message.details.rowId;
313
- if (typeof rowId === "string" && prunedAutoreadIds.has(rowId)) continue;
314
151
  }
315
- if (message.role !== "assistant") {
316
- projected.push(message);
317
- continue;
152
+ if (entry?.type !== "custom_message" || entry.customType !== "tau.autoread") continue;
153
+ if (isRecord(entry.details) && typeof entry.details.rowId === "string") {
154
+ prunedAutoreadRowIds.add(entry.details.rowId);
318
155
  }
319
- const content = message.content.filter(
320
- (block) => block.type !== "thinking" && !(block.type === "toolCall" && prunedToolIds.has(block.id)),
321
- );
322
- if (content.length > 0)
323
- projected.push(content.length === message.content.length ? message : { ...message, content });
156
+ continue;
324
157
  }
325
- return projected;
158
+ for (let index = 0; index < anchorIndex; index += 1) {
159
+ const entry = branch[index];
160
+ if (entry?.type !== "message" || entry.message.role !== "toolResult") continue;
161
+ if (entry.message.toolName !== "context_prune" || !isRecord(entry.message.details)) continue;
162
+ const files = entry.message.details.refreshedFiles;
163
+ if (!Array.isArray(files)) continue;
164
+ for (const file of files)
165
+ if (isRecord(file) && typeof file.rowId === "string") prunedAutoreadRowIds.add(file.rowId);
166
+ }
167
+ return { prunedToolCallIds: [...prunedToolCallIds], prunedAutoreadRowIds: [...prunedAutoreadRowIds] };
326
168
  }
327
169
 
328
- function createDeferredMessage(files: readonly ContextPruneDeferredFileV1[]) {
329
- return {
330
- customType: "tau.context-pruning.deferred" as const,
331
- content: [
332
- "Deferred files are advisory. Reconsider them only when their condition applies:",
333
- ...files.map((file) => `- ${file.path}: ${file.reason} Relevant when: ${file.relevantWhen}`),
334
- ].join("\n"),
335
- display: false as const,
336
- details: { v: 1 as const, files },
337
- };
170
+ function deferredFileText(files: readonly ContextPruneDeferredFileV2[]): string {
171
+ return [
172
+ "Deferred files are advisory. Reconsider them only when their condition applies:",
173
+ ...files.map((file) => `- ${file.path}: ${file.reason} Relevant when: ${file.relevantWhen}`),
174
+ ].join("\n");
175
+ }
176
+
177
+ function normalizePath(path: string): string {
178
+ return path.replace(/^@/, "");
179
+ }
180
+
181
+ function assertCurrent(options: ContextPruneExecutionOptions): void {
182
+ options.signal?.throwIfAborted();
183
+ if (options.generation !== options.currentGeneration()) {
184
+ throw new Error("Context checkpoint crossed a session lifecycle boundary");
185
+ }
338
186
  }
339
187
 
340
188
  function errorMessage(error: unknown): string {