@shanepadgett/tau-agent 0.16.0 → 0.17.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/docs/subagents.md +25 -6
- package/extensions/attention/README.md +1 -0
- package/extensions/attention/index.ts +38 -0
- package/extensions/context/README.md +13 -3
- package/extensions/context/definitions.ts +3 -15
- package/extensions/context/evidence.ts +516 -0
- package/extensions/context/index.ts +114 -116
- package/extensions/context/panel.ts +60 -0
- package/extensions/context/settings.ts +30 -1
- package/extensions/context/sync.ts +171 -686
- package/extensions/context/validation.ts +4 -1
- package/extensions/context/write-scope.ts +109 -0
- package/extensions/context-pruning/README.md +22 -0
- package/extensions/context-pruning/file-evidence.ts +265 -0
- package/extensions/context-pruning/index.ts +379 -0
- package/extensions/context-pruning/projection.ts +108 -0
- package/extensions/context-pruning/prune.ts +346 -0
- package/extensions/context-pruning/render.ts +179 -0
- package/extensions/context-pruning/settings.ts +41 -0
- package/extensions/explore/README.md +3 -1
- package/extensions/explore/autoread.ts +85 -22
- package/extensions/explore/full-file-knowledge.ts +234 -0
- package/extensions/explore/index.ts +2 -3
- package/extensions/explore/read-cache.ts +150 -86
- package/extensions/explore/read-snapshots.ts +17 -4
- package/extensions/explore/read.ts +57 -38
- package/extensions/footer/index.ts +62 -60
- package/extensions/run-summary/index.ts +5 -5
- package/extensions/silent-command-runner/README.md +1 -1
- package/extensions/silent-command-runner/index.ts +45 -28
- package/extensions/soul/prompt.ts +3 -1
- package/extensions/subagent/README.md +21 -5
- package/extensions/subagent/agents/context-sync.md +90 -0
- package/extensions/subagent/agents/{generalist.md → dormant/generalist.md} +6 -0
- package/extensions/subagent/agents/{scout.md → dormant/scout.md} +6 -0
- package/extensions/subagent/agents/review.md +48 -0
- package/extensions/subagent/agents/web-research.md +6 -0
- package/extensions/subagent/agents.ts +15 -2
- package/extensions/subagent/cmux-dashboard.ts +454 -0
- package/extensions/subagent/index.ts +181 -234
- package/extensions/subagent/render.ts +1 -1
- package/extensions/subagent/resume.ts +78 -0
- package/extensions/subagent/run.ts +213 -118
- package/extensions/subagent/runtime.ts +856 -0
- package/extensions/subagent/session-resource.ts +169 -0
- package/extensions/tau-help/help.md +6 -2
- package/extensions/turn-budget/index.ts +8 -36
- package/package.json +2 -2
- package/schemas/tau.schema.json +48 -1
- package/shared/context-pruning-state.ts +364 -0
- package/shared/events.ts +18 -0
- package/shared/model-fallback/index.ts +21 -10
- package/shared/model-fallback/types.ts +5 -3
- package/shared/settings/load.ts +78 -1
- package/shared/tool-row-state.ts +21 -1
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
import {
|
|
2
|
+
estimateTokens,
|
|
3
|
+
type ContextEvent,
|
|
4
|
+
type ExtensionAPI,
|
|
5
|
+
type ExtensionContext,
|
|
6
|
+
type SessionEntry,
|
|
7
|
+
} from "@earendil-works/pi-coding-agent";
|
|
8
|
+
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];
|
|
13
|
+
|
|
14
|
+
export const contextPruneParameters = Type.Object(
|
|
15
|
+
{
|
|
16
|
+
keepFiles: Type.Array(
|
|
17
|
+
Type.Object(
|
|
18
|
+
{
|
|
19
|
+
path: Type.String(),
|
|
20
|
+
relevance: Type.String(),
|
|
21
|
+
},
|
|
22
|
+
{ additionalProperties: false },
|
|
23
|
+
),
|
|
24
|
+
),
|
|
25
|
+
keepToolCalls: Type.Array(
|
|
26
|
+
Type.Object(
|
|
27
|
+
{
|
|
28
|
+
toolCallId: Type.String(),
|
|
29
|
+
relevance: Type.String(),
|
|
30
|
+
},
|
|
31
|
+
{ additionalProperties: false },
|
|
32
|
+
),
|
|
33
|
+
),
|
|
34
|
+
deferFiles: Type.Array(
|
|
35
|
+
Type.Object(
|
|
36
|
+
{
|
|
37
|
+
path: Type.String(),
|
|
38
|
+
reason: Type.String(),
|
|
39
|
+
relevantWhen: Type.String(),
|
|
40
|
+
},
|
|
41
|
+
{ additionalProperties: false },
|
|
42
|
+
),
|
|
43
|
+
),
|
|
44
|
+
},
|
|
45
|
+
{ additionalProperties: false },
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
export type ContextPruneInput = Static<typeof contextPruneParameters>;
|
|
49
|
+
|
|
50
|
+
interface ProjectionSnapshot {
|
|
51
|
+
generation: number;
|
|
52
|
+
messages: ContextEvent["messages"];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface ContextPruneExecutionOptions {
|
|
56
|
+
pi: Pick<ExtensionAPI, "sendMessage">;
|
|
57
|
+
toolCallId: string;
|
|
58
|
+
params: ContextPruneInput;
|
|
59
|
+
signal: AbortSignal | undefined;
|
|
60
|
+
ctx: ExtensionContext;
|
|
61
|
+
projection: ProjectionSnapshot | undefined;
|
|
62
|
+
currentGeneration: () => number;
|
|
63
|
+
currentEnabled: () => boolean;
|
|
64
|
+
minimumReclaimTokens: number;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface ContextPruneExecutionResult {
|
|
68
|
+
content: Array<{ type: "text"; text: string }>;
|
|
69
|
+
details: ContextPruneDetailsV1;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
interface PreparedPrune {
|
|
73
|
+
messages: Array<Parameters<ExtensionAPI["sendMessage"]>[0]>;
|
|
74
|
+
result: ContextPruneExecutionResult;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
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 },
|
|
192
|
+
);
|
|
193
|
+
}
|
|
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
|
+
}
|
|
230
|
+
|
|
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);
|
|
262
|
+
}
|
|
263
|
+
for (const id of results.keys()) if (!calls.has(id)) throw new Error(`Orphaned projected tool result: ${id}.`);
|
|
264
|
+
return pairs;
|
|
265
|
+
}
|
|
266
|
+
|
|
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;
|
|
271
|
+
if (
|
|
272
|
+
!isRecord(message.details) ||
|
|
273
|
+
typeof message.details.rowId !== "string" ||
|
|
274
|
+
message.details.rowId.length === 0
|
|
275
|
+
) {
|
|
276
|
+
throw new Error("Projected autoread evidence has no stable row ID.");
|
|
277
|
+
}
|
|
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
|
+
}
|
|
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) {
|
|
290
|
+
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
|
+
)
|
|
310
|
+
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
|
+
}
|
|
315
|
+
if (message.role !== "assistant") {
|
|
316
|
+
projected.push(message);
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
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 });
|
|
324
|
+
}
|
|
325
|
+
return projected;
|
|
326
|
+
}
|
|
327
|
+
|
|
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
|
+
};
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function errorMessage(error: unknown): string {
|
|
341
|
+
return error instanceof Error ? error.message : String(error);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
345
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
346
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import type { AgentToolResult, Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Text, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
import { Marker } from "@shanepadgett/tau-tui";
|
|
4
|
+
import { parseContextPruneDetailsV1 } from "../../shared/context-pruning-state.ts";
|
|
5
|
+
import { formatToolRowTitle, type ToolRowStateStore } from "../../shared/tool-row-state.ts";
|
|
6
|
+
import type { ContextPruneInput } from "./prune.ts";
|
|
7
|
+
|
|
8
|
+
const MAX_EXPANDED_ITEMS = 20;
|
|
9
|
+
const MAX_EXPANDED_LINE_CHARACTERS = 240;
|
|
10
|
+
const MAX_EXPANDED_TEXT_CHARACTERS = 3_000;
|
|
11
|
+
const MAX_WARNING_CHARACTERS = 1_000;
|
|
12
|
+
|
|
13
|
+
export interface ContextPruningNudgeDetailsV1 {
|
|
14
|
+
v: 1;
|
|
15
|
+
kind: "automatic" | "manual";
|
|
16
|
+
percent: number | null;
|
|
17
|
+
boundary: number | null;
|
|
18
|
+
pressure: boolean;
|
|
19
|
+
anchorToolCallId: string | null;
|
|
20
|
+
growthBaselinePercent: number | null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function parseContextPruningNudgeDetailsV1(value: unknown): ContextPruningNudgeDetailsV1 | undefined {
|
|
24
|
+
if (!isRecord(value)) return undefined;
|
|
25
|
+
const keys = ["v", "kind", "percent", "boundary", "pressure", "anchorToolCallId", "growthBaselinePercent"];
|
|
26
|
+
if (Object.keys(value).length !== keys.length || !keys.every((key) => Object.hasOwn(value, key))) return undefined;
|
|
27
|
+
if (value.v !== 1 || (value.kind !== "automatic" && value.kind !== "manual")) return undefined;
|
|
28
|
+
if (typeof value.pressure !== "boolean") return undefined;
|
|
29
|
+
if (
|
|
30
|
+
value.anchorToolCallId !== null &&
|
|
31
|
+
(typeof value.anchorToolCallId !== "string" || value.anchorToolCallId.length === 0)
|
|
32
|
+
) {
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
if (value.kind === "manual") {
|
|
36
|
+
if (value.percent !== null || value.boundary !== null || value.growthBaselinePercent !== null || value.pressure)
|
|
37
|
+
return undefined;
|
|
38
|
+
return {
|
|
39
|
+
v: 1,
|
|
40
|
+
kind: "manual",
|
|
41
|
+
percent: null,
|
|
42
|
+
boundary: null,
|
|
43
|
+
pressure: false,
|
|
44
|
+
anchorToolCallId: value.anchorToolCallId,
|
|
45
|
+
growthBaselinePercent: null,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
if (
|
|
49
|
+
!isPercent(value.percent) ||
|
|
50
|
+
!isBoundary(value.boundary) ||
|
|
51
|
+
!isPercent(value.growthBaselinePercent) ||
|
|
52
|
+
value.boundary > value.percent ||
|
|
53
|
+
value.growthBaselinePercent > value.percent
|
|
54
|
+
) {
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
v: 1,
|
|
59
|
+
kind: "automatic",
|
|
60
|
+
percent: value.percent,
|
|
61
|
+
boundary: value.boundary,
|
|
62
|
+
pressure: value.pressure,
|
|
63
|
+
anchorToolCallId: value.anchorToolCallId,
|
|
64
|
+
growthBaselinePercent: value.growthBaselinePercent,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function renderContextPruningNudge(details: unknown, theme: Theme): Marker | undefined {
|
|
69
|
+
const parsed = parseContextPruningNudgeDetailsV1(details);
|
|
70
|
+
if (!parsed) return undefined;
|
|
71
|
+
return new Marker({
|
|
72
|
+
theme,
|
|
73
|
+
state: "muted",
|
|
74
|
+
label: "Context:",
|
|
75
|
+
parts:
|
|
76
|
+
parsed.kind === "manual"
|
|
77
|
+
? ["Prune requested."]
|
|
78
|
+
: [`${parsed.percent}%`, ...(parsed.pressure ? ["Prune suggested."] : [])],
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function renderContextPruneCall(
|
|
83
|
+
args: ContextPruneInput,
|
|
84
|
+
theme: Theme,
|
|
85
|
+
context: {
|
|
86
|
+
rowState: ToolRowStateStore;
|
|
87
|
+
rowId: string;
|
|
88
|
+
invalidate: () => void;
|
|
89
|
+
lastComponent: unknown;
|
|
90
|
+
},
|
|
91
|
+
): Text {
|
|
92
|
+
context.rowState.watch(context.rowId, context.invalidate);
|
|
93
|
+
const component = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
|
|
94
|
+
const count =
|
|
95
|
+
(Array.isArray(args.keepFiles) ? args.keepFiles.length : 0) +
|
|
96
|
+
(Array.isArray(args.keepToolCalls) ? args.keepToolCalls.length : 0) +
|
|
97
|
+
(Array.isArray(args.deferFiles) ? args.deferFiles.length : 0);
|
|
98
|
+
component.setText(
|
|
99
|
+
`${formatToolRowTitle(context.rowState, context.rowId, "context_prune", theme)} ${theme.fg("muted", `${count} selection${count === 1 ? "" : "s"}`)}`,
|
|
100
|
+
);
|
|
101
|
+
return component;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function renderContextPruneResult(
|
|
105
|
+
result: AgentToolResult<unknown>,
|
|
106
|
+
expanded: boolean,
|
|
107
|
+
theme: Theme,
|
|
108
|
+
lastComponent: unknown,
|
|
109
|
+
): Text {
|
|
110
|
+
const component = lastComponent instanceof Text ? lastComponent : new Text("", 0, 0);
|
|
111
|
+
const details = parseContextPruneDetailsV1(result.details);
|
|
112
|
+
if (!details) {
|
|
113
|
+
component.setText(
|
|
114
|
+
theme.fg(
|
|
115
|
+
"warning",
|
|
116
|
+
boundedText(firstResultText(result) || "context_prune returned invalid details", MAX_WARNING_CHARACTERS),
|
|
117
|
+
),
|
|
118
|
+
);
|
|
119
|
+
return component;
|
|
120
|
+
}
|
|
121
|
+
if (details.status === "skipped") {
|
|
122
|
+
component.setText(
|
|
123
|
+
theme.fg("warning", boundedText(firstResultText(result) || "Prune skipped", MAX_WARNING_CHARACTERS)),
|
|
124
|
+
);
|
|
125
|
+
return component;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const pruned = details.newlyPrunedToolCallIds.length + details.newlyPrunedAutoreadRowIds.length;
|
|
129
|
+
const retained = details.retainedToolCallIds.length + details.retainedAutoreadRowIds.length;
|
|
130
|
+
let text = theme.fg(
|
|
131
|
+
"success",
|
|
132
|
+
`Pruned ${pruned} · retained ${retained} · refreshed ${details.refreshedFiles.length} · deferred ${details.deferredFiles.length}`,
|
|
133
|
+
);
|
|
134
|
+
if (expanded) {
|
|
135
|
+
const lines = [
|
|
136
|
+
...details.newlyPrunedToolCallIds.map((id) => `pruned tool: ${id}`),
|
|
137
|
+
...details.newlyPrunedAutoreadRowIds.map((id) => `pruned autoread: ${id}`),
|
|
138
|
+
...details.retainedToolCallIds.map((id) => `retained tool: ${id}`),
|
|
139
|
+
...details.retainedAutoreadRowIds.map((id) => `retained autoread: ${id}`),
|
|
140
|
+
...details.refreshedFiles.map((file) => `refreshed: ${file.path} (${file.rowId})`),
|
|
141
|
+
...details.deferredFiles.map((file) => `deferred: ${file.path} — ${file.reason}; when ${file.relevantWhen}`),
|
|
142
|
+
];
|
|
143
|
+
const shown = lines.slice(0, MAX_EXPANDED_ITEMS).map((line) => boundedText(line, MAX_EXPANDED_LINE_CHARACTERS));
|
|
144
|
+
if (lines.length > shown.length) shown.push(`… ${lines.length - shown.length} more`);
|
|
145
|
+
let remainingWidth = Math.max(0, MAX_EXPANDED_TEXT_CHARACTERS - visibleWidth(text));
|
|
146
|
+
for (const line of shown) {
|
|
147
|
+
if (remainingWidth <= 1) break;
|
|
148
|
+
const styled = theme.fg("dim", line);
|
|
149
|
+
const availableWidth = remainingWidth - 1;
|
|
150
|
+
text += `\n${truncateToWidth(styled, availableWidth, "…")}`;
|
|
151
|
+
remainingWidth -= 1 + Math.min(visibleWidth(styled), availableWidth);
|
|
152
|
+
if (visibleWidth(styled) > availableWidth) break;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
component.setText(text);
|
|
156
|
+
return component;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function boundedText(text: string, maximumCharacters: number): string {
|
|
160
|
+
if (text.length <= maximumCharacters) return text;
|
|
161
|
+
return `${text.slice(0, Math.max(0, maximumCharacters - 1))}…`;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function firstResultText(result: AgentToolResult<unknown>): string {
|
|
165
|
+
for (const part of result.content) if (part.type === "text") return part.text;
|
|
166
|
+
return "";
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function isPercent(value: unknown): value is number {
|
|
170
|
+
return typeof value === "number" && Number.isInteger(value) && value >= 0 && value <= 100;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function isBoundary(value: unknown): value is number {
|
|
174
|
+
return isPercent(value) && value > 0;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
178
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
179
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { Type } from "typebox";
|
|
2
|
+
import { defineTauExtensionSettings } from "../../shared/settings/define.ts";
|
|
3
|
+
|
|
4
|
+
export default defineTauExtensionSettings({
|
|
5
|
+
key: "contextPruning",
|
|
6
|
+
defaults: {
|
|
7
|
+
enabled: true as boolean,
|
|
8
|
+
nudgeEveryPercent: 20 as number,
|
|
9
|
+
pressurePercent: 50 as number,
|
|
10
|
+
minimumReclaimTokens: 8000 as number,
|
|
11
|
+
},
|
|
12
|
+
schema: Type.Object(
|
|
13
|
+
{
|
|
14
|
+
enabled: Type.Optional(Type.Boolean({ default: true, description: "Enable context pruning." })),
|
|
15
|
+
nudgeEveryPercent: Type.Optional(
|
|
16
|
+
Type.Integer({
|
|
17
|
+
default: 20,
|
|
18
|
+
minimum: 1,
|
|
19
|
+
maximum: 100,
|
|
20
|
+
description: "Context growth interval between automatic pruning hints.",
|
|
21
|
+
}),
|
|
22
|
+
),
|
|
23
|
+
pressurePercent: Type.Optional(
|
|
24
|
+
Type.Integer({
|
|
25
|
+
default: 50,
|
|
26
|
+
minimum: 1,
|
|
27
|
+
maximum: 99,
|
|
28
|
+
description: "Context usage above which pruning hints become urgent.",
|
|
29
|
+
}),
|
|
30
|
+
),
|
|
31
|
+
minimumReclaimTokens: Type.Optional(
|
|
32
|
+
Type.Integer({
|
|
33
|
+
default: 8000,
|
|
34
|
+
minimum: 1,
|
|
35
|
+
description: "Minimum estimated tokens a prune must reclaim.",
|
|
36
|
+
}),
|
|
37
|
+
),
|
|
38
|
+
},
|
|
39
|
+
{ additionalProperties: false },
|
|
40
|
+
),
|
|
41
|
+
});
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
Explore is Tau's first-party filesystem exploration extension.
|
|
4
4
|
|
|
5
|
-
It exists so agents can inspect paths, discover files, search text, and read file contents with compact model payloads and readable tool rows.
|
|
5
|
+
It exists so agents can inspect paths, discover files, search text, and read file contents with compact model payloads and readable tool rows. Autoread establishes complete-file knowledge while that source remains in active context. Later reads can return an unchanged marker or a smaller diff instead of resending the file.
|
|
6
|
+
|
|
7
|
+
When the source baseline is no longer available, such as after compaction or a cold subagent resume, `read` safely returns the current full source.
|
|
6
8
|
|
|
7
9
|
Agents invoke it with `ls`, `find`, `grep`, and `read`. Users run `/read-stats` to see estimated token and cost savings for the session.
|