@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.
- package/README.md +18 -0
- package/docs/extending-tau-agent.md +34 -2
- package/extensions/context-pruning/README.md +18 -114
- package/extensions/context-pruning/index.ts +72 -74
- package/extensions/context-pruning/projection.ts +38 -96
- package/extensions/context-pruning/prune.ts +135 -287
- package/extensions/context-pruning/render.ts +95 -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 +145 -0
- package/extensions/handoff/model.ts +71 -0
- package/extensions/image-gen/index.ts +24 -133
- package/extensions/subagent/index.ts +3 -1
- package/extensions/tau-help/help.md +5 -1
- package/package.json +9 -2
- package/schemas/tau.schema.json +15 -12
- package/shared/context-pruning-state.ts +69 -273
- package/shared/model-fallback/index.ts +1 -1
- package/{extensions/image-gen → src/image-generation}/client.ts +30 -20
- package/src/image-generation/index.ts +224 -0
- package/src/index.ts +7 -0
- package/extensions/context-pruning/file-evidence.ts +0 -265
- /package/{extensions/image-gen → src/image-generation}/constants.ts +0 -0
|
@@ -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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
|
26
|
-
message.
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
56
|
-
|
|
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
|
|
30
|
+
if (message?.role === "assistant") {
|
|
90
31
|
for (const block of message.content) {
|
|
91
|
-
if (block.type
|
|
92
|
-
|
|
93
|
-
|
|
32
|
+
if (block.type === "toolCall" && state.retainedToolCallIds.has(block.id)) {
|
|
33
|
+
retainedCallNames.set(block.id, block.name);
|
|
34
|
+
}
|
|
94
35
|
}
|
|
95
|
-
} else if (message
|
|
96
|
-
|
|
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
|
|
103
|
-
for (
|
|
104
|
-
const
|
|
105
|
-
if (
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
58
|
+
projected.push(...messages.slice(anchorIndex));
|
|
59
|
+
return projected;
|
|
118
60
|
}
|
|
@@ -1,43 +1,21 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
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 {
|
|
10
|
-
import {
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
232
|
-
|
|
233
|
-
for (const
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
-
|
|
264
|
-
|
|
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
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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
|
-
|
|
273
|
-
|
|
274
|
-
message.
|
|
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
|
-
|
|
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
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
|
292
|
-
|
|
293
|
-
|
|
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 (
|
|
316
|
-
|
|
317
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
329
|
-
return
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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 {
|