@shanepadgett/tau-agent 0.20.2 → 0.21.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/extensions/context-pruning/README.md +18 -114
- package/extensions/context-pruning/index.ts +74 -74
- package/extensions/context-pruning/projection.ts +40 -96
- package/extensions/context-pruning/prune.ts +138 -283
- package/extensions/context-pruning/render.ts +99 -35
- package/extensions/context-pruning/settings.ts +14 -15
- package/extensions/explore/read-cache.ts +31 -7
- package/extensions/handoff/README.md +7 -0
- package/extensions/handoff/index.ts +139 -0
- package/extensions/handoff/model.ts +71 -0
- package/extensions/subagent/index.ts +3 -1
- package/extensions/tau-help/help.md +5 -1
- package/package.json +2 -2
- package/schemas/tau.schema.json +15 -12
- package/shared/context-pruning-state.ts +69 -273
- package/extensions/context-pruning/file-evidence.ts +0 -265
|
@@ -1,40 +1,38 @@
|
|
|
1
1
|
import type { SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
|
|
3
3
|
const CONTEXT_PRUNE_TOOL = "context_prune";
|
|
4
|
-
const AUTOREAD_MESSAGE_TYPE = "tau.autoread";
|
|
5
4
|
|
|
6
|
-
export interface
|
|
5
|
+
export interface ContextPruneRefreshedFileV2 {
|
|
7
6
|
path: string;
|
|
8
7
|
rowId: string;
|
|
9
8
|
servedHash: string;
|
|
9
|
+
autoreadDetails: Record<string, unknown>;
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
export interface
|
|
12
|
+
export interface ContextPruneDeferredFileV2 {
|
|
13
13
|
path: string;
|
|
14
14
|
reason: string;
|
|
15
15
|
relevantWhen: string;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
export interface
|
|
19
|
-
v:
|
|
20
|
-
status: "applied" | "skipped";
|
|
18
|
+
export interface ContextPruneDetailsV2 {
|
|
19
|
+
v: 2;
|
|
21
20
|
anchorToolCallId: string;
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
prunedToolCallIds: string[];
|
|
22
|
+
prunedAutoreadRowIds: string[];
|
|
24
23
|
retainedToolCallIds: string[];
|
|
25
24
|
retainedAutoreadRowIds: string[];
|
|
26
|
-
refreshedFiles:
|
|
27
|
-
deferredFiles:
|
|
28
|
-
|
|
29
|
-
tokensAfter: number;
|
|
30
|
-
tokensReclaimed: number;
|
|
25
|
+
refreshedFiles: ContextPruneRefreshedFileV2[];
|
|
26
|
+
deferredFiles: ContextPruneDeferredFileV2[];
|
|
27
|
+
warnings: string[];
|
|
31
28
|
}
|
|
32
29
|
|
|
33
30
|
export interface ActiveContextPruningState {
|
|
34
31
|
latestAnchorToolCallId: string | undefined;
|
|
32
|
+
retainedToolCallIds: ReadonlySet<string>;
|
|
35
33
|
prunedToolCallIds: ReadonlySet<string>;
|
|
36
34
|
prunedAutoreadRowIds: ReadonlySet<string>;
|
|
37
|
-
deferredFiles: readonly
|
|
35
|
+
deferredFiles: readonly ContextPruneDeferredFileV2[];
|
|
38
36
|
}
|
|
39
37
|
|
|
40
38
|
const RUNTIME_ENABLED_KEY = Symbol.for("@earendil-works/tau-agent/context-pruning-enabled");
|
|
@@ -48,67 +46,35 @@ function isContextPruningEnabled(): boolean {
|
|
|
48
46
|
return runtimeState[RUNTIME_ENABLED_KEY] === true;
|
|
49
47
|
}
|
|
50
48
|
|
|
51
|
-
export function
|
|
52
|
-
if (
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
"anchorToolCallId",
|
|
57
|
-
"newlyPrunedToolCallIds",
|
|
58
|
-
"newlyPrunedAutoreadRowIds",
|
|
59
|
-
"retainedToolCallIds",
|
|
60
|
-
"retainedAutoreadRowIds",
|
|
61
|
-
"refreshedFiles",
|
|
62
|
-
"deferredFiles",
|
|
63
|
-
"tokensBefore",
|
|
64
|
-
"tokensAfter",
|
|
65
|
-
"tokensReclaimed",
|
|
66
|
-
])
|
|
67
|
-
)
|
|
68
|
-
return undefined;
|
|
69
|
-
if (value.v !== 1 || (value.status !== "applied" && value.status !== "skipped")) return undefined;
|
|
70
|
-
if (!isNonEmptyString(value.anchorToolCallId)) return undefined;
|
|
71
|
-
|
|
72
|
-
const newlyPrunedToolCallIds = parseUniqueStrings(value.newlyPrunedToolCallIds);
|
|
73
|
-
const newlyPrunedAutoreadRowIds = parseUniqueStrings(value.newlyPrunedAutoreadRowIds);
|
|
74
|
-
const retainedToolCallIds = parseUniqueStrings(value.retainedToolCallIds);
|
|
49
|
+
export function parseContextPruneDetailsV2(value: unknown): ContextPruneDetailsV2 | undefined {
|
|
50
|
+
if (!isRecord(value) || value.v !== 2 || !isNonEmptyString(value.anchorToolCallId)) return undefined;
|
|
51
|
+
const prunedToolCallIds = parseUniqueStrings(value.prunedToolCallIds);
|
|
52
|
+
const prunedAutoreadRowIds = parseUniqueStrings(value.prunedAutoreadRowIds);
|
|
53
|
+
const retainedToolCallIds = parseUniqueStrings(value.retainedToolCallIds, true);
|
|
75
54
|
const retainedAutoreadRowIds = parseUniqueStrings(value.retainedAutoreadRowIds);
|
|
76
55
|
const refreshedFiles = parseRefreshedFiles(value.refreshedFiles);
|
|
77
56
|
const deferredFiles = parseDeferredFiles(value.deferredFiles);
|
|
57
|
+
const warnings = parseStrings(value.warnings, true);
|
|
78
58
|
if (
|
|
79
|
-
!
|
|
80
|
-
!
|
|
59
|
+
!prunedToolCallIds ||
|
|
60
|
+
!prunedAutoreadRowIds ||
|
|
81
61
|
!retainedToolCallIds ||
|
|
82
62
|
!retainedAutoreadRowIds ||
|
|
83
63
|
!refreshedFiles ||
|
|
84
|
-
!deferredFiles
|
|
85
|
-
|
|
86
|
-
return undefined;
|
|
87
|
-
if (
|
|
88
|
-
!isNonNegativeFinite(value.tokensBefore) ||
|
|
89
|
-
!isNonNegativeFinite(value.tokensAfter) ||
|
|
90
|
-
!isFiniteNumber(value.tokensReclaimed) ||
|
|
91
|
-
value.tokensBefore - value.tokensAfter !== value.tokensReclaimed
|
|
64
|
+
!deferredFiles ||
|
|
65
|
+
!warnings
|
|
92
66
|
)
|
|
93
67
|
return undefined;
|
|
94
|
-
if (hasOverlap(newlyPrunedToolCallIds, retainedToolCallIds)) return undefined;
|
|
95
|
-
if (hasOverlap(newlyPrunedAutoreadRowIds, retainedAutoreadRowIds)) return undefined;
|
|
96
|
-
if (value.status === "skipped" && (newlyPrunedToolCallIds.length > 0 || newlyPrunedAutoreadRowIds.length > 0))
|
|
97
|
-
return undefined;
|
|
98
|
-
|
|
99
68
|
return {
|
|
100
|
-
v:
|
|
101
|
-
status: value.status,
|
|
69
|
+
v: 2,
|
|
102
70
|
anchorToolCallId: value.anchorToolCallId,
|
|
103
|
-
|
|
104
|
-
|
|
71
|
+
prunedToolCallIds,
|
|
72
|
+
prunedAutoreadRowIds,
|
|
105
73
|
retainedToolCallIds,
|
|
106
74
|
retainedAutoreadRowIds,
|
|
107
75
|
refreshedFiles,
|
|
108
76
|
deferredFiles,
|
|
109
|
-
|
|
110
|
-
tokensAfter: value.tokensAfter,
|
|
111
|
-
tokensReclaimed: value.tokensReclaimed,
|
|
77
|
+
warnings,
|
|
112
78
|
};
|
|
113
79
|
}
|
|
114
80
|
|
|
@@ -118,233 +84,71 @@ export function replayContextPruningState(
|
|
|
118
84
|
): ActiveContextPruningState {
|
|
119
85
|
const prunedToolCallIds = new Set<string>();
|
|
120
86
|
const prunedAutoreadRowIds = new Set<string>();
|
|
121
|
-
|
|
122
|
-
|
|
87
|
+
let latestAnchorToolCallId: string | undefined;
|
|
88
|
+
let retainedToolCallIds = new Set<string>();
|
|
89
|
+
let deferredFiles: readonly ContextPruneDeferredFileV2[] = [];
|
|
90
|
+
if (enabled) {
|
|
91
|
+
for (const entry of branch) {
|
|
92
|
+
if (entry.type !== "message" || entry.message.role !== "toolResult") continue;
|
|
93
|
+
const result = entry.message;
|
|
94
|
+
if (result.toolName !== CONTEXT_PRUNE_TOOL) continue;
|
|
95
|
+
const details = parseContextPruneDetailsV2(result.details);
|
|
96
|
+
if (!details || details.anchorToolCallId !== result.toolCallId) continue;
|
|
97
|
+
for (const id of details.prunedToolCallIds) prunedToolCallIds.add(id);
|
|
98
|
+
for (const id of details.prunedAutoreadRowIds) prunedAutoreadRowIds.add(id);
|
|
99
|
+
latestAnchorToolCallId = details.anchorToolCallId;
|
|
100
|
+
retainedToolCallIds = new Set(details.retainedToolCallIds);
|
|
101
|
+
deferredFiles = details.deferredFiles;
|
|
102
|
+
}
|
|
103
|
+
for (const id of retainedToolCallIds) prunedToolCallIds.delete(id);
|
|
104
|
+
}
|
|
105
|
+
return {
|
|
106
|
+
latestAnchorToolCallId,
|
|
107
|
+
retainedToolCallIds,
|
|
123
108
|
prunedToolCallIds,
|
|
124
109
|
prunedAutoreadRowIds,
|
|
125
|
-
deferredFiles
|
|
110
|
+
deferredFiles,
|
|
126
111
|
};
|
|
127
|
-
if (!enabled) return state;
|
|
128
|
-
|
|
129
|
-
const toolCalls = new Map<string, { count: number; name: string; index: number }>();
|
|
130
|
-
const toolResults = new Map<string, { count: number; name: string; index: number }>();
|
|
131
|
-
const autoreads = new Map<
|
|
132
|
-
string,
|
|
133
|
-
{ count: number; path: string; servedHash: string; source: string; batchId: string; index: number }
|
|
134
|
-
>();
|
|
135
|
-
for (let index = 0; index < branch.length; index += 1) {
|
|
136
|
-
const entry = branch[index];
|
|
137
|
-
if (entry.type === "message") {
|
|
138
|
-
const message = entry.message;
|
|
139
|
-
if (message.role === "assistant") {
|
|
140
|
-
for (const block of message.content) {
|
|
141
|
-
if (block.type !== "toolCall") continue;
|
|
142
|
-
const current = toolCalls.get(block.id);
|
|
143
|
-
toolCalls.set(block.id, { count: (current?.count ?? 0) + 1, name: block.name, index });
|
|
144
|
-
}
|
|
145
|
-
} else if (message.role === "toolResult") {
|
|
146
|
-
const current = toolResults.get(message.toolCallId);
|
|
147
|
-
toolResults.set(message.toolCallId, {
|
|
148
|
-
count: (current?.count ?? 0) + 1,
|
|
149
|
-
name: message.toolName,
|
|
150
|
-
index,
|
|
151
|
-
});
|
|
152
|
-
}
|
|
153
|
-
continue;
|
|
154
|
-
}
|
|
155
|
-
if (entry.type !== "custom_message" || entry.customType !== AUTOREAD_MESSAGE_TYPE) continue;
|
|
156
|
-
const autoread = parseAutoreadReference(entry.details);
|
|
157
|
-
if (!autoread) continue;
|
|
158
|
-
const current = autoreads.get(autoread.rowId);
|
|
159
|
-
autoreads.set(autoread.rowId, {
|
|
160
|
-
count: (current?.count ?? 0) + 1,
|
|
161
|
-
path: autoread.path,
|
|
162
|
-
servedHash: autoread.servedHash,
|
|
163
|
-
source: autoread.source,
|
|
164
|
-
batchId: autoread.batchId,
|
|
165
|
-
index,
|
|
166
|
-
});
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
for (let resultIndex = 0; resultIndex < branch.length; resultIndex += 1) {
|
|
170
|
-
const entry = branch[resultIndex];
|
|
171
|
-
if (entry.type !== "message" || entry.message.role !== "toolResult") continue;
|
|
172
|
-
const result = entry.message;
|
|
173
|
-
if (result.toolName !== CONTEXT_PRUNE_TOOL) continue;
|
|
174
|
-
const details = parseContextPruneDetailsV1(result.details);
|
|
175
|
-
if (!details || details.status !== "applied" || details.anchorToolCallId !== result.toolCallId) continue;
|
|
176
|
-
if (!isCompleteExchange(details.anchorToolCallId, CONTEXT_PRUNE_TOOL, toolCalls, toolResults)) continue;
|
|
177
|
-
const anchorCallIndex = toolCalls.get(details.anchorToolCallId)?.index;
|
|
178
|
-
if (anchorCallIndex === undefined || anchorCallIndex >= resultIndex) continue;
|
|
179
|
-
if (details.newlyPrunedToolCallIds.includes(details.anchorToolCallId)) continue;
|
|
180
|
-
if (details.retainedToolCallIds.includes(details.anchorToolCallId)) continue;
|
|
181
|
-
if (
|
|
182
|
-
!allCompleteExchanges(details.newlyPrunedToolCallIds, toolCalls, toolResults) ||
|
|
183
|
-
!allCompleteExchanges(details.retainedToolCallIds, toolCalls, toolResults) ||
|
|
184
|
-
!allAutoreads(details.newlyPrunedAutoreadRowIds, autoreads) ||
|
|
185
|
-
!allAutoreads(details.retainedAutoreadRowIds, autoreads)
|
|
186
|
-
)
|
|
187
|
-
continue;
|
|
188
|
-
if (
|
|
189
|
-
details.newlyPrunedToolCallIds.some((id) => !exchangePrecedes(id, anchorCallIndex, toolCalls, toolResults)) ||
|
|
190
|
-
details.retainedToolCallIds.some((id) => !exchangePrecedes(id, anchorCallIndex, toolCalls, toolResults)) ||
|
|
191
|
-
details.newlyPrunedAutoreadRowIds.some((id) => {
|
|
192
|
-
const row = autoreads.get(id);
|
|
193
|
-
return row === undefined || row.index >= anchorCallIndex;
|
|
194
|
-
})
|
|
195
|
-
)
|
|
196
|
-
continue;
|
|
197
|
-
if (
|
|
198
|
-
details.newlyPrunedToolCallIds.some((id) => prunedToolCallIds.has(id)) ||
|
|
199
|
-
details.retainedToolCallIds.some((id) => prunedToolCallIds.has(id)) ||
|
|
200
|
-
details.newlyPrunedAutoreadRowIds.some((id) => prunedAutoreadRowIds.has(id)) ||
|
|
201
|
-
details.retainedAutoreadRowIds.some((id) => prunedAutoreadRowIds.has(id))
|
|
202
|
-
)
|
|
203
|
-
continue;
|
|
204
|
-
if (
|
|
205
|
-
details.refreshedFiles.some((file) => {
|
|
206
|
-
const row = autoreads.get(file.rowId);
|
|
207
|
-
return (
|
|
208
|
-
row?.count !== 1 ||
|
|
209
|
-
row.path !== file.path ||
|
|
210
|
-
row.servedHash !== file.servedHash ||
|
|
211
|
-
row.source !== "context-pruning" ||
|
|
212
|
-
row.batchId !== details.anchorToolCallId ||
|
|
213
|
-
!details.retainedAutoreadRowIds.includes(file.rowId)
|
|
214
|
-
);
|
|
215
|
-
})
|
|
216
|
-
)
|
|
217
|
-
continue;
|
|
218
|
-
const refreshedRowIds = new Set(details.refreshedFiles.map((file) => file.rowId));
|
|
219
|
-
if (
|
|
220
|
-
details.retainedAutoreadRowIds.some((id) => {
|
|
221
|
-
const row = autoreads.get(id);
|
|
222
|
-
return row === undefined || (row.index >= anchorCallIndex && !refreshedRowIds.has(id));
|
|
223
|
-
})
|
|
224
|
-
)
|
|
225
|
-
continue;
|
|
226
|
-
|
|
227
|
-
for (const id of details.newlyPrunedToolCallIds) prunedToolCallIds.add(id);
|
|
228
|
-
for (const id of details.newlyPrunedAutoreadRowIds) prunedAutoreadRowIds.add(id);
|
|
229
|
-
state.latestAnchorToolCallId = details.anchorToolCallId;
|
|
230
|
-
state.deferredFiles = details.deferredFiles;
|
|
231
|
-
}
|
|
232
|
-
return state;
|
|
233
112
|
}
|
|
234
113
|
|
|
235
|
-
function parseRefreshedFiles(value: unknown):
|
|
114
|
+
function parseRefreshedFiles(value: unknown): ContextPruneRefreshedFileV2[] | undefined {
|
|
236
115
|
if (!Array.isArray(value)) return undefined;
|
|
237
|
-
const files:
|
|
238
|
-
const rowIds = new Set<string>();
|
|
116
|
+
const files: ContextPruneRefreshedFileV2[] = [];
|
|
239
117
|
for (const item of value) {
|
|
240
|
-
if (!
|
|
241
|
-
if (!isNonEmptyString(item.
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
118
|
+
if (!isRecord(item) || !isNonEmptyString(item.path) || !isNonEmptyString(item.rowId)) return undefined;
|
|
119
|
+
if (!isNonEmptyString(item.servedHash) || !isRecord(item.autoreadDetails)) return undefined;
|
|
120
|
+
files.push({
|
|
121
|
+
path: item.path,
|
|
122
|
+
rowId: item.rowId,
|
|
123
|
+
servedHash: item.servedHash,
|
|
124
|
+
autoreadDetails: { ...item.autoreadDetails },
|
|
125
|
+
});
|
|
246
126
|
}
|
|
247
127
|
return files;
|
|
248
128
|
}
|
|
249
129
|
|
|
250
|
-
function parseDeferredFiles(value: unknown):
|
|
130
|
+
function parseDeferredFiles(value: unknown): ContextPruneDeferredFileV2[] | undefined {
|
|
251
131
|
if (!Array.isArray(value)) return undefined;
|
|
252
|
-
const files:
|
|
132
|
+
const files: ContextPruneDeferredFileV2[] = [];
|
|
253
133
|
for (const item of value) {
|
|
254
|
-
if (!
|
|
255
|
-
if (
|
|
256
|
-
return undefined;
|
|
134
|
+
if (!isRecord(item) || typeof item.path !== "string" || typeof item.reason !== "string") return undefined;
|
|
135
|
+
if (typeof item.relevantWhen !== "string") return undefined;
|
|
257
136
|
files.push({ path: item.path, reason: item.reason, relevantWhen: item.relevantWhen });
|
|
258
137
|
}
|
|
259
138
|
return files;
|
|
260
139
|
}
|
|
261
140
|
|
|
262
|
-
function parseUniqueStrings(value: unknown): string[] | undefined {
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
const seen = new Set<string>();
|
|
266
|
-
for (const item of value) {
|
|
267
|
-
if (!isNonEmptyString(item) || seen.has(item)) return undefined;
|
|
268
|
-
seen.add(item);
|
|
269
|
-
strings.push(item);
|
|
270
|
-
}
|
|
141
|
+
function parseUniqueStrings(value: unknown, allowEmpty = false): string[] | undefined {
|
|
142
|
+
const strings = parseStrings(value, allowEmpty);
|
|
143
|
+
if (!strings || new Set(strings).size !== strings.length) return undefined;
|
|
271
144
|
return strings;
|
|
272
145
|
}
|
|
273
146
|
|
|
274
|
-
function
|
|
275
|
-
value
|
|
276
|
-
): { rowId: string; path: string; servedHash: string; source: string; batchId: string } | undefined {
|
|
277
|
-
if (!isRecord(value) || value.status !== "read") return undefined;
|
|
278
|
-
if (
|
|
279
|
-
!isNonEmptyString(value.rowId) ||
|
|
280
|
-
!isNonEmptyString(value.path) ||
|
|
281
|
-
!isNonEmptyString(value.cwd) ||
|
|
282
|
-
!isNonEmptyString(value.source) ||
|
|
283
|
-
!isNonEmptyString(value.batchId)
|
|
284
|
-
)
|
|
147
|
+
function parseStrings(value: unknown, allowEmpty = false): string[] | undefined {
|
|
148
|
+
if (!Array.isArray(value) || !value.every((item) => typeof item === "string" && (allowEmpty || item.length > 0))) {
|
|
285
149
|
return undefined;
|
|
286
|
-
|
|
287
|
-
return
|
|
288
|
-
rowId: value.rowId,
|
|
289
|
-
path: value.path,
|
|
290
|
-
servedHash: value.readCache.servedHash,
|
|
291
|
-
source: value.source,
|
|
292
|
-
batchId: value.batchId,
|
|
293
|
-
};
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
function allCompleteExchanges(
|
|
297
|
-
ids: readonly string[],
|
|
298
|
-
calls: ReadonlyMap<string, { count: number; name: string; index: number }>,
|
|
299
|
-
results: ReadonlyMap<string, { count: number; name: string; index: number }>,
|
|
300
|
-
): boolean {
|
|
301
|
-
return ids.every((id) => {
|
|
302
|
-
const call = calls.get(id);
|
|
303
|
-
return call !== undefined && isCompleteExchange(id, call.name, calls, results);
|
|
304
|
-
});
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
function isCompleteExchange(
|
|
308
|
-
id: string,
|
|
309
|
-
name: string,
|
|
310
|
-
calls: ReadonlyMap<string, { count: number; name: string; index: number }>,
|
|
311
|
-
results: ReadonlyMap<string, { count: number; name: string; index: number }>,
|
|
312
|
-
): boolean {
|
|
313
|
-
const call = calls.get(id);
|
|
314
|
-
const result = results.get(id);
|
|
315
|
-
return (
|
|
316
|
-
call?.count === 1 &&
|
|
317
|
-
result?.count === 1 &&
|
|
318
|
-
call.name === name &&
|
|
319
|
-
result.name === name &&
|
|
320
|
-
call.index < result.index
|
|
321
|
-
);
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
function exchangePrecedes(
|
|
325
|
-
id: string,
|
|
326
|
-
anchorCallIndex: number,
|
|
327
|
-
calls: ReadonlyMap<string, { index: number }>,
|
|
328
|
-
results: ReadonlyMap<string, { index: number }>,
|
|
329
|
-
): boolean {
|
|
330
|
-
const call = calls.get(id);
|
|
331
|
-
const result = results.get(id);
|
|
332
|
-
return call !== undefined && result !== undefined && call.index < result.index && result.index < anchorCallIndex;
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
function allAutoreads(ids: readonly string[], autoreads: ReadonlyMap<string, { count: number }>): boolean {
|
|
336
|
-
return ids.every((id) => autoreads.get(id)?.count === 1);
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
function hasOverlap(left: readonly string[], right: readonly string[]): boolean {
|
|
340
|
-
const rightSet = new Set(right);
|
|
341
|
-
return left.some((item) => rightSet.has(item));
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
function hasExactKeys(value: unknown, keys: readonly string[]): value is Record<string, unknown> {
|
|
345
|
-
if (!isRecord(value)) return false;
|
|
346
|
-
const actual = Object.keys(value);
|
|
347
|
-
return actual.length === keys.length && keys.every((key) => Object.hasOwn(value, key));
|
|
150
|
+
}
|
|
151
|
+
return [...value];
|
|
348
152
|
}
|
|
349
153
|
|
|
350
154
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
@@ -354,11 +158,3 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|
|
354
158
|
function isNonEmptyString(value: unknown): value is string {
|
|
355
159
|
return typeof value === "string" && value.length > 0;
|
|
356
160
|
}
|
|
357
|
-
|
|
358
|
-
function isNonNegativeFinite(value: unknown): value is number {
|
|
359
|
-
return typeof value === "number" && Number.isFinite(value) && value >= 0;
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
function isFiniteNumber(value: unknown): value is number {
|
|
363
|
-
return typeof value === "number" && Number.isFinite(value);
|
|
364
|
-
}
|
|
@@ -1,265 +0,0 @@
|
|
|
1
|
-
import { realpath } from "node:fs/promises";
|
|
2
|
-
import { basename, dirname, relative, resolve, sep } from "node:path";
|
|
3
|
-
import { estimateTokens, type ContextEvent } from "@earendil-works/pi-coding-agent";
|
|
4
|
-
import { prepareAutoreadMessage, type PreparedAutoreadMessage } from "../explore/autoread.ts";
|
|
5
|
-
import { COMPLETE_FILE_SCOPE, MAX_COMPLETE_FILE_SNAPSHOT_BYTES } from "../explore/full-file-knowledge.ts";
|
|
6
|
-
import { readMetaFromMessage, replayReadCache } from "../explore/read-cache.ts";
|
|
7
|
-
|
|
8
|
-
type ContextMessage = ContextEvent["messages"][number];
|
|
9
|
-
type ReadEvidenceMessage =
|
|
10
|
-
| Extract<ContextMessage, { role: "toolResult" }>
|
|
11
|
-
| Extract<ContextMessage, { role: "custom" }>;
|
|
12
|
-
|
|
13
|
-
interface RequestedFile {
|
|
14
|
-
path: string;
|
|
15
|
-
relevance: string;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
interface RequestedDeferredFile {
|
|
19
|
-
path: string;
|
|
20
|
-
reason: string;
|
|
21
|
-
relevantWhen: string;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
interface CanonicalFile<T> {
|
|
25
|
-
canonicalPath: string;
|
|
26
|
-
displayPath: string;
|
|
27
|
-
request: T;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
interface FileEvidenceSelection {
|
|
31
|
-
retainedToolCallIds: ReadonlySet<string>;
|
|
32
|
-
retainedAutoreadRowIds: ReadonlySet<string>;
|
|
33
|
-
preparedSnapshots: readonly PreparedAutoreadMessage[];
|
|
34
|
-
refreshedFiles: readonly { path: string; rowId: string; servedHash: string }[];
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
export async function canonicalizeFileSelections(options: {
|
|
38
|
-
cwd: string;
|
|
39
|
-
keepFiles: readonly RequestedFile[];
|
|
40
|
-
deferFiles: readonly RequestedDeferredFile[];
|
|
41
|
-
}): Promise<{
|
|
42
|
-
keepFiles: CanonicalFile<RequestedFile>[];
|
|
43
|
-
deferFiles: CanonicalFile<RequestedDeferredFile>[];
|
|
44
|
-
}> {
|
|
45
|
-
const seen = new Map<string, string>();
|
|
46
|
-
const canonicalCwd = await canonicalizePath(resolve(options.cwd));
|
|
47
|
-
const canonicalize = async <T extends { path: string }>(request: T): Promise<CanonicalFile<T>> => {
|
|
48
|
-
const requested = resolve(options.cwd, request.path.replace(/^@/, ""));
|
|
49
|
-
const canonicalPath = await canonicalizePath(requested);
|
|
50
|
-
const previous = seen.get(canonicalPath);
|
|
51
|
-
if (previous !== undefined) {
|
|
52
|
-
throw new Error(`Duplicate file selection: ${request.path} resolves to the same path as ${previous}`);
|
|
53
|
-
}
|
|
54
|
-
seen.set(canonicalPath, request.path);
|
|
55
|
-
const relativePath = relative(canonicalCwd, canonicalPath);
|
|
56
|
-
const displayPath =
|
|
57
|
-
relativePath !== "" && relativePath !== ".." && !relativePath.startsWith(`..${sep}`)
|
|
58
|
-
? relativePath
|
|
59
|
-
: canonicalPath;
|
|
60
|
-
return { canonicalPath, displayPath, request };
|
|
61
|
-
};
|
|
62
|
-
|
|
63
|
-
const keepFiles: CanonicalFile<RequestedFile>[] = [];
|
|
64
|
-
for (const request of options.keepFiles) keepFiles.push(await canonicalize(request));
|
|
65
|
-
const deferFiles: CanonicalFile<RequestedDeferredFile>[] = [];
|
|
66
|
-
for (const request of options.deferFiles) deferFiles.push(await canonicalize(request));
|
|
67
|
-
return { keepFiles, deferFiles };
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
export async function selectFileEvidence(options: {
|
|
71
|
-
cwd: string;
|
|
72
|
-
messages: readonly ContextMessage[];
|
|
73
|
-
files: readonly CanonicalFile<RequestedFile>[];
|
|
74
|
-
anchorToolCallId: string;
|
|
75
|
-
signal: AbortSignal | undefined;
|
|
76
|
-
isLifecycleCurrent: () => boolean;
|
|
77
|
-
}): Promise<FileEvidenceSelection> {
|
|
78
|
-
const entries = options.messages.map((message) =>
|
|
79
|
-
message.role === "custom" ? { type: "custom_message", ...message } : { type: "message", message },
|
|
80
|
-
);
|
|
81
|
-
const replay = replayReadCache(entries, options.cwd);
|
|
82
|
-
const retainedToolCallIds = new Set<string>();
|
|
83
|
-
const retainedAutoreadRowIds = new Set<string>();
|
|
84
|
-
const preparedSnapshots: PreparedAutoreadMessage[] = [];
|
|
85
|
-
const refreshedFiles: Array<{ path: string; rowId: string; servedHash: string }> = [];
|
|
86
|
-
const rowMessages = indexEvidenceRows(options.messages);
|
|
87
|
-
|
|
88
|
-
for (let fileIndex = 0; fileIndex < options.files.length; fileIndex += 1) {
|
|
89
|
-
const file = options.files[fileIndex];
|
|
90
|
-
if (!file) continue;
|
|
91
|
-
options.signal?.throwIfAborted();
|
|
92
|
-
if (!options.isLifecycleCurrent()) throw new Error("Prune preparation crossed a session lifecycle boundary");
|
|
93
|
-
|
|
94
|
-
const rawRows: ContextMessage[] = [];
|
|
95
|
-
for (const message of options.messages) {
|
|
96
|
-
const pathKey = rawReadPath(message, options.cwd);
|
|
97
|
-
if (pathKey && (await canonicalizePath(pathKey)) === file.canonicalPath) rawRows.push(message);
|
|
98
|
-
}
|
|
99
|
-
if (rawRows.length === 0) throw new Error(`${file.displayPath} has no prior read evidence; read the file first`);
|
|
100
|
-
const rawCompleteRows = rawRows.filter((message) => rawScopeKey(message) === COMPLETE_FILE_SCOPE);
|
|
101
|
-
if (rawCompleteRows.length === 0) {
|
|
102
|
-
throw new Error(`${file.displayPath} has only partial read evidence; read the complete file first`);
|
|
103
|
-
}
|
|
104
|
-
const acceptedRows: Array<(typeof replay.acceptedRows)[number]> = [];
|
|
105
|
-
for (const row of replay.acceptedRows) {
|
|
106
|
-
if (row.scopeKey === COMPLETE_FILE_SCOPE && (await canonicalizePath(row.pathKey)) === file.canonicalPath)
|
|
107
|
-
acceptedRows.push(row);
|
|
108
|
-
}
|
|
109
|
-
if (acceptedRows.length === 0) {
|
|
110
|
-
throw new Error(`${file.displayPath} has malformed complete-file evidence; read the file again`);
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
const rowId = `${options.anchorToolCallId}:${fileIndex}`;
|
|
114
|
-
const prepared = await prepareAutoreadMessage({
|
|
115
|
-
rowId,
|
|
116
|
-
path: file.displayPath,
|
|
117
|
-
cwd: options.cwd,
|
|
118
|
-
source: "context-pruning",
|
|
119
|
-
batchId: options.anchorToolCallId,
|
|
120
|
-
signal: options.signal,
|
|
121
|
-
isLifecycleCurrent: options.isLifecycleCurrent,
|
|
122
|
-
maximumBytes: MAX_COMPLETE_FILE_SNAPSHOT_BYTES,
|
|
123
|
-
});
|
|
124
|
-
const currentHash = prepared.details.readCache.servedHash;
|
|
125
|
-
const unchangedBaseline = acceptedRows.find(
|
|
126
|
-
(row) => (row.meta.mode === "baseline" || row.meta.mode === "recovery") && row.meta.servedHash === currentHash,
|
|
127
|
-
);
|
|
128
|
-
if (unchangedBaseline) {
|
|
129
|
-
retainRows(unchangedBaseline.dependencyRowIds, rowMessages, retainedToolCallIds, retainedAutoreadRowIds);
|
|
130
|
-
continue;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
let chain = replay.completeFileChains.get(file.canonicalPath);
|
|
134
|
-
if (!chain) {
|
|
135
|
-
for (const candidate of replay.completeFileChains.values()) {
|
|
136
|
-
if ((await canonicalizePath(candidate.pathKey)) === file.canonicalPath) {
|
|
137
|
-
chain = candidate;
|
|
138
|
-
break;
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
if (chain?.servedHash === currentHash) {
|
|
143
|
-
const chainCost = chain.rowIds.reduce((total, id) => total + (rowMessages.get(id)?.tokens ?? 0), 0);
|
|
144
|
-
const snapshotCost = estimatePreparedSnapshot(prepared);
|
|
145
|
-
if (chainCost <= snapshotCost) {
|
|
146
|
-
retainRows(chain.rowIds, rowMessages, retainedToolCallIds, retainedAutoreadRowIds);
|
|
147
|
-
continue;
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
preparedSnapshots.push(prepared);
|
|
152
|
-
retainedAutoreadRowIds.add(rowId);
|
|
153
|
-
refreshedFiles.push({ path: file.displayPath, rowId, servedHash: currentHash });
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
return { retainedToolCallIds, retainedAutoreadRowIds, preparedSnapshots, refreshedFiles };
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
function indexEvidenceRows(
|
|
160
|
-
messages: readonly ContextMessage[],
|
|
161
|
-
): Map<string, { tokens: number; kind: "tool" | "autoread" }> {
|
|
162
|
-
const rows = new Map<string, { tokens: number; kind: "tool" | "autoread" }>();
|
|
163
|
-
const calls = new Map<string, { message: Extract<ContextMessage, { role: "assistant" }>; blockIndex: number }>();
|
|
164
|
-
for (const message of messages) {
|
|
165
|
-
if (message.role === "assistant") {
|
|
166
|
-
for (let blockIndex = 0; blockIndex < message.content.length; blockIndex += 1) {
|
|
167
|
-
const block = message.content[blockIndex];
|
|
168
|
-
if (block?.type === "toolCall") calls.set(block.id, { message, blockIndex });
|
|
169
|
-
}
|
|
170
|
-
continue;
|
|
171
|
-
}
|
|
172
|
-
if (message.role === "toolResult" && message.toolName === "read") {
|
|
173
|
-
const call = calls.get(message.toolCallId);
|
|
174
|
-
const block = call?.message.content[call.blockIndex];
|
|
175
|
-
if (call && block?.type === "toolCall") {
|
|
176
|
-
rows.set(message.toolCallId, {
|
|
177
|
-
tokens: estimateTokens({ ...call.message, content: [block] }) + estimateTokens(message),
|
|
178
|
-
kind: "tool",
|
|
179
|
-
});
|
|
180
|
-
}
|
|
181
|
-
continue;
|
|
182
|
-
}
|
|
183
|
-
if (message.role === "custom" && message.customType === "tau.autoread") {
|
|
184
|
-
const rowId = autoreadRowId(message);
|
|
185
|
-
if (rowId) rows.set(rowId, { tokens: estimateTokens(message), kind: "autoread" });
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
return rows;
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
function retainRows(
|
|
192
|
-
rowIds: readonly string[],
|
|
193
|
-
rows: ReadonlyMap<string, { kind: "tool" | "autoread" }>,
|
|
194
|
-
toolIds: Set<string>,
|
|
195
|
-
autoreadIds: Set<string>,
|
|
196
|
-
): void {
|
|
197
|
-
for (const rowId of rowIds) {
|
|
198
|
-
const row = rows.get(rowId);
|
|
199
|
-
if (!row) throw new Error(`Complete-file dependency row is absent from projected context: ${rowId}`);
|
|
200
|
-
if (row.kind === "tool") toolIds.add(rowId);
|
|
201
|
-
else autoreadIds.add(rowId);
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
function estimatePreparedSnapshot(prepared: PreparedAutoreadMessage): number {
|
|
206
|
-
const message: ContextMessage = {
|
|
207
|
-
role: "custom",
|
|
208
|
-
customType: prepared.customType,
|
|
209
|
-
content: prepared.content,
|
|
210
|
-
display: prepared.display,
|
|
211
|
-
details: prepared.details,
|
|
212
|
-
timestamp: 0,
|
|
213
|
-
};
|
|
214
|
-
return estimateTokens(message);
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
function rawReadPath(message: ContextMessage, cwd: string): string | undefined {
|
|
218
|
-
const meta = readMetaFromMessage(message);
|
|
219
|
-
if (meta) return resolve(cwd, meta.pathKey);
|
|
220
|
-
if (!isReadEvidence(message) || !isRecord(message.details) || !isRecord(message.details.readCache)) return undefined;
|
|
221
|
-
const pathKey = message.details.readCache.pathKey;
|
|
222
|
-
return typeof pathKey === "string" ? resolve(cwd, pathKey) : undefined;
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
function rawScopeKey(message: ContextMessage): string | undefined {
|
|
226
|
-
if (!isReadEvidence(message) || !isRecord(message.details) || !isRecord(message.details.readCache)) return undefined;
|
|
227
|
-
return typeof message.details.readCache.scopeKey === "string" ? message.details.readCache.scopeKey : undefined;
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
function isReadEvidence(message: ContextMessage): message is ReadEvidenceMessage {
|
|
231
|
-
return (
|
|
232
|
-
(message.role === "toolResult" && message.toolName === "read") ||
|
|
233
|
-
(message.role === "custom" && message.customType === "tau.autoread")
|
|
234
|
-
);
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
function autoreadRowId(message: ContextMessage): string | undefined {
|
|
238
|
-
if (message.role !== "custom" || !isRecord(message.details)) return undefined;
|
|
239
|
-
return typeof message.details.rowId === "string" ? message.details.rowId : undefined;
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
function isMissingPathError(error: unknown): boolean {
|
|
243
|
-
return isRecord(error) && error.code === "ENOENT";
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
async function canonicalizePath(path: string): Promise<string> {
|
|
247
|
-
const missingSegments: string[] = [];
|
|
248
|
-
let cursor = path;
|
|
249
|
-
while (true) {
|
|
250
|
-
try {
|
|
251
|
-
const canonicalAncestor = await realpath(cursor);
|
|
252
|
-
return resolve(canonicalAncestor, ...missingSegments.reverse());
|
|
253
|
-
} catch (error) {
|
|
254
|
-
if (!isMissingPathError(error)) throw error;
|
|
255
|
-
const parent = dirname(cursor);
|
|
256
|
-
if (parent === cursor) return path;
|
|
257
|
-
missingSegments.push(basename(cursor));
|
|
258
|
-
cursor = parent;
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
264
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
265
|
-
}
|