@shanepadgett/tau-agent 0.20.1 → 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,7 +1,7 @@
|
|
|
1
1
|
import type { AgentToolResult, Theme } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { Text, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
3
3
|
import { Marker } from "@shanepadgett/tau-tui";
|
|
4
|
-
import {
|
|
4
|
+
import { parseContextPruneDetailsV2 } from "../../shared/context-pruning-state.ts";
|
|
5
5
|
import { formatToolRowTitle, type ToolRowStateStore } from "../../shared/tool-row-state.ts";
|
|
6
6
|
import type { ContextPruneInput } from "./prune.ts";
|
|
7
7
|
|
|
@@ -10,22 +10,48 @@ const MAX_EXPANDED_LINE_CHARACTERS = 240;
|
|
|
10
10
|
const MAX_EXPANDED_TEXT_CHARACTERS = 3_000;
|
|
11
11
|
const MAX_WARNING_CHARACTERS = 1_000;
|
|
12
12
|
|
|
13
|
-
export
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
13
|
+
export type ContextPruningNudgeDetailsV2 =
|
|
14
|
+
| {
|
|
15
|
+
v: 2;
|
|
16
|
+
kind: "automatic";
|
|
17
|
+
percent: number;
|
|
18
|
+
boundary: number;
|
|
19
|
+
reminder: number;
|
|
20
|
+
tier: number;
|
|
21
|
+
tierCount: number;
|
|
22
|
+
tierFloor: number;
|
|
23
|
+
anchorToolCallId: string | null;
|
|
24
|
+
growthBaselinePercent: number;
|
|
25
|
+
}
|
|
26
|
+
| {
|
|
27
|
+
v: 2;
|
|
28
|
+
kind: "manual";
|
|
29
|
+
percent: null;
|
|
30
|
+
boundary: null;
|
|
31
|
+
reminder: null;
|
|
32
|
+
tier: null;
|
|
33
|
+
tierCount: null;
|
|
34
|
+
tierFloor: null;
|
|
35
|
+
anchorToolCallId: string | null;
|
|
36
|
+
growthBaselinePercent: null;
|
|
37
|
+
};
|
|
22
38
|
|
|
23
|
-
export function
|
|
39
|
+
export function parseContextPruningNudgeDetailsV2(value: unknown): ContextPruningNudgeDetailsV2 | undefined {
|
|
24
40
|
if (!isRecord(value)) return undefined;
|
|
25
|
-
const keys = [
|
|
41
|
+
const keys = [
|
|
42
|
+
"v",
|
|
43
|
+
"kind",
|
|
44
|
+
"percent",
|
|
45
|
+
"boundary",
|
|
46
|
+
"reminder",
|
|
47
|
+
"tier",
|
|
48
|
+
"tierCount",
|
|
49
|
+
"tierFloor",
|
|
50
|
+
"anchorToolCallId",
|
|
51
|
+
"growthBaselinePercent",
|
|
52
|
+
];
|
|
26
53
|
if (Object.keys(value).length !== keys.length || !keys.every((key) => Object.hasOwn(value, key))) return undefined;
|
|
27
|
-
if (value.v !==
|
|
28
|
-
if (typeof value.pressure !== "boolean") return undefined;
|
|
54
|
+
if (value.v !== 2 || (value.kind !== "automatic" && value.kind !== "manual")) return undefined;
|
|
29
55
|
if (
|
|
30
56
|
value.anchorToolCallId !== null &&
|
|
31
57
|
(typeof value.anchorToolCallId !== "string" || value.anchorToolCallId.length === 0)
|
|
@@ -33,14 +59,25 @@ export function parseContextPruningNudgeDetailsV1(value: unknown): ContextPrunin
|
|
|
33
59
|
return undefined;
|
|
34
60
|
}
|
|
35
61
|
if (value.kind === "manual") {
|
|
36
|
-
if (
|
|
62
|
+
if (
|
|
63
|
+
value.percent !== null ||
|
|
64
|
+
value.boundary !== null ||
|
|
65
|
+
value.reminder !== null ||
|
|
66
|
+
value.tier !== null ||
|
|
67
|
+
value.tierCount !== null ||
|
|
68
|
+
value.tierFloor !== null ||
|
|
69
|
+
value.growthBaselinePercent !== null
|
|
70
|
+
)
|
|
37
71
|
return undefined;
|
|
38
72
|
return {
|
|
39
|
-
v:
|
|
73
|
+
v: 2,
|
|
40
74
|
kind: "manual",
|
|
41
75
|
percent: null,
|
|
42
76
|
boundary: null,
|
|
43
|
-
|
|
77
|
+
reminder: null,
|
|
78
|
+
tier: null,
|
|
79
|
+
tierCount: null,
|
|
80
|
+
tierFloor: null,
|
|
44
81
|
anchorToolCallId: value.anchorToolCallId,
|
|
45
82
|
growthBaselinePercent: null,
|
|
46
83
|
};
|
|
@@ -48,25 +85,39 @@ export function parseContextPruningNudgeDetailsV1(value: unknown): ContextPrunin
|
|
|
48
85
|
if (
|
|
49
86
|
!isPercent(value.percent) ||
|
|
50
87
|
!isBoundary(value.boundary) ||
|
|
88
|
+
!isReminder(value.reminder) ||
|
|
89
|
+
!isTier(value.tier) ||
|
|
90
|
+
!isTier(value.tierCount) ||
|
|
91
|
+
!isTierFloor(value.tierFloor) ||
|
|
51
92
|
!isPercent(value.growthBaselinePercent) ||
|
|
52
93
|
value.boundary > value.percent ||
|
|
53
|
-
value.
|
|
94
|
+
value.boundary <= value.growthBaselinePercent ||
|
|
95
|
+
value.tier > value.tierCount ||
|
|
96
|
+
value.tierFloor > value.tierCount ||
|
|
97
|
+
value.tier !== Math.max(Math.min(value.reminder, value.tierCount), value.tierFloor) ||
|
|
98
|
+
(value.anchorToolCallId === null && value.growthBaselinePercent !== 0)
|
|
54
99
|
) {
|
|
55
100
|
return undefined;
|
|
56
101
|
}
|
|
102
|
+
const interval = (value.boundary - value.growthBaselinePercent) / value.reminder;
|
|
103
|
+
if (!Number.isInteger(interval) || interval < 1 || interval > 100 || value.percent - value.boundary >= interval)
|
|
104
|
+
return undefined;
|
|
57
105
|
return {
|
|
58
|
-
v:
|
|
106
|
+
v: 2,
|
|
59
107
|
kind: "automatic",
|
|
60
108
|
percent: value.percent,
|
|
61
109
|
boundary: value.boundary,
|
|
62
|
-
|
|
110
|
+
reminder: value.reminder,
|
|
111
|
+
tier: value.tier,
|
|
112
|
+
tierCount: value.tierCount,
|
|
113
|
+
tierFloor: value.tierFloor,
|
|
63
114
|
anchorToolCallId: value.anchorToolCallId,
|
|
64
115
|
growthBaselinePercent: value.growthBaselinePercent,
|
|
65
116
|
};
|
|
66
117
|
}
|
|
67
118
|
|
|
68
119
|
export function renderContextPruningNudge(details: unknown, theme: Theme): Marker | undefined {
|
|
69
|
-
const parsed =
|
|
120
|
+
const parsed = parseContextPruningNudgeDetailsV2(details);
|
|
70
121
|
if (!parsed) return undefined;
|
|
71
122
|
return new Marker({
|
|
72
123
|
theme,
|
|
@@ -75,7 +126,14 @@ export function renderContextPruningNudge(details: unknown, theme: Theme): Marke
|
|
|
75
126
|
parts:
|
|
76
127
|
parsed.kind === "manual"
|
|
77
128
|
? ["Prune requested."]
|
|
78
|
-
: [
|
|
129
|
+
: [
|
|
130
|
+
`${parsed.percent}%`,
|
|
131
|
+
...(parsed.tier === parsed.tierCount
|
|
132
|
+
? ["Prune now."]
|
|
133
|
+
: parsed.tier > 1
|
|
134
|
+
? ["Prune soon."]
|
|
135
|
+
: []),
|
|
136
|
+
],
|
|
79
137
|
});
|
|
80
138
|
}
|
|
81
139
|
|
|
@@ -108,7 +166,7 @@ export function renderContextPruneResult(
|
|
|
108
166
|
lastComponent: unknown,
|
|
109
167
|
): Text {
|
|
110
168
|
const component = lastComponent instanceof Text ? lastComponent : new Text("", 0, 0);
|
|
111
|
-
const details =
|
|
169
|
+
const details = parseContextPruneDetailsV2(result.details);
|
|
112
170
|
if (!details) {
|
|
113
171
|
component.setText(
|
|
114
172
|
theme.fg(
|
|
@@ -118,27 +176,21 @@ export function renderContextPruneResult(
|
|
|
118
176
|
);
|
|
119
177
|
return component;
|
|
120
178
|
}
|
|
121
|
-
|
|
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;
|
|
179
|
+
const pruned = details.prunedToolCallIds.length + details.prunedAutoreadRowIds.length;
|
|
129
180
|
const retained = details.retainedToolCallIds.length + details.retainedAutoreadRowIds.length;
|
|
130
181
|
let text = theme.fg(
|
|
131
|
-
"success",
|
|
132
|
-
`
|
|
182
|
+
details.warnings.length === 0 ? "success" : "warning",
|
|
183
|
+
`Checkpoint · pruned ${pruned} · retained ${retained} · refreshed ${details.refreshedFiles.length} · deferred ${details.deferredFiles.length}${details.warnings.length === 0 ? "" : ` · warnings ${details.warnings.length}`}`,
|
|
133
184
|
);
|
|
134
185
|
if (expanded) {
|
|
135
186
|
const lines = [
|
|
136
|
-
...details.
|
|
137
|
-
...details.
|
|
187
|
+
...details.prunedToolCallIds.map((id) => `pruned tool: ${id}`),
|
|
188
|
+
...details.prunedAutoreadRowIds.map((id) => `pruned autoread: ${id}`),
|
|
138
189
|
...details.retainedToolCallIds.map((id) => `retained tool: ${id}`),
|
|
139
190
|
...details.retainedAutoreadRowIds.map((id) => `retained autoread: ${id}`),
|
|
140
191
|
...details.refreshedFiles.map((file) => `refreshed: ${file.path} (${file.rowId})`),
|
|
141
192
|
...details.deferredFiles.map((file) => `deferred: ${file.path} — ${file.reason}; when ${file.relevantWhen}`),
|
|
193
|
+
...details.warnings.map((warning) => `warning: ${warning}`),
|
|
142
194
|
];
|
|
143
195
|
const shown = lines.slice(0, MAX_EXPANDED_ITEMS).map((line) => boundedText(line, MAX_EXPANDED_LINE_CHARACTERS));
|
|
144
196
|
if (lines.length > shown.length) shown.push(`… ${lines.length - shown.length} more`);
|
|
@@ -174,6 +226,18 @@ function isBoundary(value: unknown): value is number {
|
|
|
174
226
|
return isPercent(value) && value > 0;
|
|
175
227
|
}
|
|
176
228
|
|
|
229
|
+
function isReminder(value: unknown): value is number {
|
|
230
|
+
return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 100;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function isTier(value: unknown): value is number {
|
|
234
|
+
return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 5;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function isTierFloor(value: unknown): value is number {
|
|
238
|
+
return typeof value === "number" && Number.isInteger(value) && value >= 0 && value <= 5;
|
|
239
|
+
}
|
|
240
|
+
|
|
177
241
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
178
242
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
179
243
|
}
|
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
import { Type } from "typebox";
|
|
2
2
|
import { defineTauExtensionSettings } from "../../shared/settings/define.ts";
|
|
3
3
|
|
|
4
|
+
const DEFAULT_NUDGE_INSTRUCTIONS: [string, ...string[]] = [
|
|
5
|
+
"No prune is required yet unless broad exploration has converged or substantial evidence is already irrelevant. Continue coherent work.",
|
|
6
|
+
"Move toward a pruning point now. Finish the current coherent step, then prune before starting another broad exploration. Managed context is materially increasing model cost.",
|
|
7
|
+
"Prune now before further tool work. Continuing with stale managed context is wasting money.",
|
|
8
|
+
];
|
|
9
|
+
|
|
4
10
|
export default defineTauExtensionSettings({
|
|
5
11
|
key: "contextPruning",
|
|
6
12
|
defaults: {
|
|
7
13
|
enabled: true as boolean,
|
|
8
14
|
nudgeEveryPercent: 20 as number,
|
|
9
|
-
|
|
10
|
-
minimumReclaimTokens: 8000 as number,
|
|
15
|
+
nudgeInstructions: DEFAULT_NUDGE_INSTRUCTIONS,
|
|
11
16
|
},
|
|
12
17
|
schema: Type.Object(
|
|
13
18
|
{
|
|
@@ -20,19 +25,13 @@ export default defineTauExtensionSettings({
|
|
|
20
25
|
description: "Context growth interval between automatic pruning hints.",
|
|
21
26
|
}),
|
|
22
27
|
),
|
|
23
|
-
|
|
24
|
-
Type.
|
|
25
|
-
default:
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
description:
|
|
29
|
-
|
|
30
|
-
),
|
|
31
|
-
minimumReclaimTokens: Type.Optional(
|
|
32
|
-
Type.Integer({
|
|
33
|
-
default: 8000,
|
|
34
|
-
minimum: 1,
|
|
35
|
-
description: "Minimum estimated tokens a prune must reclaim.",
|
|
28
|
+
nudgeInstructions: Type.Optional(
|
|
29
|
+
Type.Array(Type.String({ minLength: 1, maxLength: 2_000 }), {
|
|
30
|
+
default: DEFAULT_NUDGE_INSTRUCTIONS,
|
|
31
|
+
minItems: 1,
|
|
32
|
+
maxItems: 5,
|
|
33
|
+
description:
|
|
34
|
+
"Ordered automatic pruning instructions. Later reminders repeat the final instruction, and the final tier requires an anchor before further tool work.",
|
|
36
35
|
}),
|
|
37
36
|
),
|
|
38
37
|
},
|
|
@@ -67,6 +67,7 @@ export function createReadCacheStore(): ReadCacheStore {
|
|
|
67
67
|
}
|
|
68
68
|
const pruning = replayContextPruningState(manager.getBranch());
|
|
69
69
|
const ignoredRowIds = new Set([...pruning.prunedToolCallIds, ...pruning.prunedAutoreadRowIds]);
|
|
70
|
+
for (const id of pruning.retainedToolCallIds) ignoredRowIds.delete(id);
|
|
70
71
|
const state = replayReadCache(manager.buildContextEntries(), ctx.cwd, ignoredRowIds);
|
|
71
72
|
const trust = state.scopeTrust.get(pathKey)?.get(scopeKey);
|
|
72
73
|
return {
|
|
@@ -127,17 +128,21 @@ export function replayReadCache(
|
|
|
127
128
|
if (entry.type === "message" && "message" in entry) message = entry.message;
|
|
128
129
|
else if (entry.type === "custom_message") message = entry;
|
|
129
130
|
else continue;
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
131
|
+
const candidates = carriedFileMessages(message);
|
|
132
|
+
let acceptedCarryForward = false;
|
|
133
|
+
for (const candidate of candidates ?? [message]) {
|
|
134
|
+
const parsedMeta = readMetaFromMessage(candidate);
|
|
135
|
+
if (!parsedMeta) continue;
|
|
136
|
+
acceptedCarryForward = true;
|
|
137
|
+
const candidateRowId = readRowId(candidate);
|
|
138
|
+
if (candidateRowId && ignoredRowIds.has(candidateRowId)) continue;
|
|
134
139
|
const pathKey = resolve(cwd, parsedMeta.pathKey);
|
|
135
140
|
const meta = pathKey === parsedMeta.pathKey ? parsedMeta : { ...parsedMeta, pathKey };
|
|
136
|
-
if (
|
|
141
|
+
if (candidateRowId && applyReadMeta(trust, failedPatchRecoveryPaths, candidate, meta, candidateRowId)) {
|
|
137
142
|
const acceptedTrust = trust.get(meta.pathKey)?.get(meta.scopeKey);
|
|
138
143
|
if (acceptedTrust) {
|
|
139
144
|
acceptedRows.push({
|
|
140
|
-
rowId,
|
|
145
|
+
rowId: candidateRowId,
|
|
141
146
|
pathKey: meta.pathKey,
|
|
142
147
|
scopeKey: meta.scopeKey,
|
|
143
148
|
meta,
|
|
@@ -145,8 +150,8 @@ export function replayReadCache(
|
|
|
145
150
|
});
|
|
146
151
|
}
|
|
147
152
|
}
|
|
148
|
-
continue;
|
|
149
153
|
}
|
|
154
|
+
if (acceptedCarryForward) continue;
|
|
150
155
|
for (const path of failedPatchPaths(message, cwd)) failedPatchRecoveryPaths.add(path);
|
|
151
156
|
}
|
|
152
157
|
const completeFileChains = new Map<string, CompleteFileDependencyChain>();
|
|
@@ -163,6 +168,25 @@ export function replayReadCache(
|
|
|
163
168
|
return { scopeTrust: trust, acceptedRows, completeFileChains, failedPatchRecoveryPaths };
|
|
164
169
|
}
|
|
165
170
|
|
|
171
|
+
function carriedFileMessages(message: unknown): unknown[] | undefined {
|
|
172
|
+
if (!isRecord(message) || message.role !== "toolResult" || message.toolName !== "context_prune") return undefined;
|
|
173
|
+
if (!isRecord(message.details) || message.details.v !== 2 || !Array.isArray(message.details.refreshedFiles)) {
|
|
174
|
+
return undefined;
|
|
175
|
+
}
|
|
176
|
+
if (!Array.isArray(message.content)) {
|
|
177
|
+
return undefined;
|
|
178
|
+
}
|
|
179
|
+
const messages: unknown[] = [];
|
|
180
|
+
for (let index = 0; index < message.details.refreshedFiles.length; index += 1) {
|
|
181
|
+
const file = message.details.refreshedFiles[index];
|
|
182
|
+
const part = message.content[index + 1];
|
|
183
|
+
if (!isRecord(file) || !isRecord(file.autoreadDetails)) continue;
|
|
184
|
+
if (!isRecord(part) || part.type !== "text" || typeof part.text !== "string") continue;
|
|
185
|
+
messages.push({ customType: "tau.autoread", content: part.text, details: file.autoreadDetails });
|
|
186
|
+
}
|
|
187
|
+
return messages;
|
|
188
|
+
}
|
|
189
|
+
|
|
166
190
|
function applyReadMeta(
|
|
167
191
|
trust: Map<string, Map<string, ReadCacheScopeTrust>>,
|
|
168
192
|
failedPatchRecoveryPaths: Set<string>,
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# Handoff
|
|
2
|
+
|
|
3
|
+
Start a fresh chat from an idea already understood in the current conversation.
|
|
4
|
+
|
|
5
|
+
Run `/handoff <goal for the new chat>`. Tau generates a focused opening prompt, starts a new linked session, and asks Tau autoread to preload the relevant project files selected from the current context. The prompt stays in the editor so you can review or change it before submitting.
|
|
6
|
+
|
|
7
|
+
The original session remains available through Pi's session history. Press Escape while Tau generates the handoff to cancel.
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
BorderedLoader,
|
|
4
|
+
buildSessionContext,
|
|
5
|
+
convertToLlm,
|
|
6
|
+
type ExtensionAPI,
|
|
7
|
+
serializeConversation,
|
|
8
|
+
} from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import { prepareAutoreadMessage, type AutoreadDetails } from "../explore/autoread.ts";
|
|
10
|
+
import { generateToolValidated, resolveCandidates } from "../../shared/model-fallback/index.ts";
|
|
11
|
+
import { errorText } from "../../shared/text.ts";
|
|
12
|
+
import { buildHandoffRequest, type HandoffDraft, handoffDraftFromToolInput, HANDOFF_TOOL } from "./model.ts";
|
|
13
|
+
|
|
14
|
+
export default function handoffExtension(pi: ExtensionAPI): void {
|
|
15
|
+
pi.registerCommand("handoff", {
|
|
16
|
+
description: "Start a fresh chat with a generated prompt and autoread files",
|
|
17
|
+
handler: async (args, ctx) => {
|
|
18
|
+
if (ctx.mode !== "tui") {
|
|
19
|
+
ctx.ui.notify("/handoff requires TUI mode.", "error");
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
const goal = args.trim();
|
|
23
|
+
if (!goal) {
|
|
24
|
+
ctx.ui.notify("Usage: /handoff <goal for the new chat>", "error");
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
await ctx.waitForIdle();
|
|
28
|
+
const model = ctx.model;
|
|
29
|
+
if (!model) {
|
|
30
|
+
ctx.ui.notify("No model selected.", "error");
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
const currentSessionFile = ctx.sessionManager.getSessionFile();
|
|
34
|
+
if (!currentSessionFile) {
|
|
35
|
+
ctx.ui.notify("The current session must be persisted before handoff.", "error");
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
const messages = buildSessionContext(
|
|
39
|
+
ctx.sessionManager.getEntries(),
|
|
40
|
+
ctx.sessionManager.getLeafId(),
|
|
41
|
+
).messages;
|
|
42
|
+
if (messages.length === 0) {
|
|
43
|
+
ctx.ui.notify("No conversation context to hand off.", "error");
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let generationError: string | undefined;
|
|
48
|
+
const draft = await ctx.ui.custom<HandoffDraft | null>((tui, theme, _keys, done) => {
|
|
49
|
+
const loader = new BorderedLoader(tui, theme, "Generating handoff");
|
|
50
|
+
loader.onAbort = () => done(null);
|
|
51
|
+
void (async () => {
|
|
52
|
+
try {
|
|
53
|
+
const thinking = pi.getThinkingLevel();
|
|
54
|
+
const candidates = await resolveCandidates(
|
|
55
|
+
ctx,
|
|
56
|
+
[{ provider: model.provider, model: model.id, reasoning: thinking === "off" ? undefined : thinking }],
|
|
57
|
+
false,
|
|
58
|
+
);
|
|
59
|
+
const conversation = serializeConversation(convertToLlm(messages));
|
|
60
|
+
const result = await generateToolValidated(
|
|
61
|
+
{ ui: ctx.ui, signal: loader.signal },
|
|
62
|
+
candidates,
|
|
63
|
+
buildHandoffRequest(conversation, goal, ctx.cwd),
|
|
64
|
+
HANDOFF_TOOL,
|
|
65
|
+
handoffDraftFromToolInput,
|
|
66
|
+
(error, output) =>
|
|
67
|
+
[
|
|
68
|
+
`The handoff failed validation: ${error.message}`,
|
|
69
|
+
`Call ${HANDOFF_TOOL.name} again with corrected arguments only.`,
|
|
70
|
+
"Previous response:",
|
|
71
|
+
output,
|
|
72
|
+
].join("\n"),
|
|
73
|
+
{ maxAttempts: 3 },
|
|
74
|
+
);
|
|
75
|
+
done(result);
|
|
76
|
+
} catch (error) {
|
|
77
|
+
if (!loader.signal.aborted) generationError = errorText(error);
|
|
78
|
+
done(null);
|
|
79
|
+
}
|
|
80
|
+
})();
|
|
81
|
+
return loader;
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
if (!draft) {
|
|
85
|
+
ctx.ui.notify(generationError ? `Handoff failed: ${generationError}` : "Handoff cancelled.", generationError ? "error" : "info");
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const batchId = randomUUID();
|
|
90
|
+
const cwd = ctx.cwd;
|
|
91
|
+
const result = await ctx.newSession({
|
|
92
|
+
parentSession: currentSessionFile,
|
|
93
|
+
setup: async (sessionManager) => {
|
|
94
|
+
const messages = await Promise.all(
|
|
95
|
+
draft.files.map(async (path, index) => {
|
|
96
|
+
const details = {
|
|
97
|
+
rowId: `${batchId}:${index}`,
|
|
98
|
+
path,
|
|
99
|
+
cwd,
|
|
100
|
+
source: "handoff",
|
|
101
|
+
batchId,
|
|
102
|
+
};
|
|
103
|
+
try {
|
|
104
|
+
return await prepareAutoreadMessage({
|
|
105
|
+
...details,
|
|
106
|
+
signal: undefined,
|
|
107
|
+
isLifecycleCurrent: () => true,
|
|
108
|
+
});
|
|
109
|
+
} catch (error) {
|
|
110
|
+
const message = errorText(error);
|
|
111
|
+
return {
|
|
112
|
+
customType: "tau.autoread" as const,
|
|
113
|
+
content: `${path}\nAutoread failed: ${message}`,
|
|
114
|
+
display: true as const,
|
|
115
|
+
details: { ...details, status: "failed", error: message } satisfies AutoreadDetails,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
}),
|
|
119
|
+
);
|
|
120
|
+
for (const message of messages) {
|
|
121
|
+
sessionManager.appendCustomMessageEntry(
|
|
122
|
+
message.customType,
|
|
123
|
+
message.content,
|
|
124
|
+
message.display,
|
|
125
|
+
message.details,
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
withSession: async (replacementCtx) => {
|
|
130
|
+
replacementCtx.ui.setEditorText(draft.prompt);
|
|
131
|
+
replacementCtx.ui.notify("Handoff ready. Review the draft and submit when ready.", "info");
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
if (result.cancelled) {
|
|
135
|
+
ctx.ui.notify("New session cancelled.", "info");
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { Tool } from "@earendil-works/pi-ai";
|
|
2
|
+
import { posix } from "node:path";
|
|
3
|
+
import { Type } from "typebox";
|
|
4
|
+
|
|
5
|
+
export interface HandoffDraft {
|
|
6
|
+
prompt: string;
|
|
7
|
+
files: string[];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const HANDOFF_TOOL = {
|
|
11
|
+
name: "submit_handoff",
|
|
12
|
+
description: "Submit the prompt and project files for the new chat. This is the only allowed response.",
|
|
13
|
+
parameters: Type.Object(
|
|
14
|
+
{
|
|
15
|
+
prompt: Type.String({ description: "Self-contained opening prompt for the new chat" }),
|
|
16
|
+
files: Type.Array(
|
|
17
|
+
Type.String({ description: "Project-relative file path already known from the current conversation" }),
|
|
18
|
+
{ description: "Focused files Tau should autoread in the new chat" },
|
|
19
|
+
),
|
|
20
|
+
},
|
|
21
|
+
{ additionalProperties: false },
|
|
22
|
+
),
|
|
23
|
+
} satisfies Tool;
|
|
24
|
+
|
|
25
|
+
export function buildHandoffRequest(conversation: string, goal: string, cwd: string): string {
|
|
26
|
+
return [
|
|
27
|
+
"You prepare a handoff from the current coding chat into a fresh chat.",
|
|
28
|
+
`Call ${HANDOFF_TOOL.name} exactly once. Write no prose outside the tool call.`,
|
|
29
|
+
"",
|
|
30
|
+
"Build the handoff only from the supplied conversation and goal. Do not research, invent findings, or assume file contents that are absent from the conversation.",
|
|
31
|
+
"The prompt must give the new agent the relevant decisions, progress, constraints, unresolved questions, and exact next task.",
|
|
32
|
+
"Keep it focused. The selected files will be autoread separately, so summarize why they matter without copying their contents into the prompt.",
|
|
33
|
+
`Every file must be a path under ${cwd}, relative to that directory, and must already be known from the conversation. Return only files relevant to the goal.`,
|
|
34
|
+
"Return an empty files array when no known file is clearly relevant.",
|
|
35
|
+
"",
|
|
36
|
+
"## Handoff goal",
|
|
37
|
+
goal,
|
|
38
|
+
"",
|
|
39
|
+
"## Current effective conversation",
|
|
40
|
+
conversation,
|
|
41
|
+
].join("\n");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function handoffDraftFromToolInput(input: unknown): HandoffDraft {
|
|
45
|
+
if (!input || typeof input !== "object") throw new Error("Handoff output must be an object.");
|
|
46
|
+
const record = input as Record<string, unknown>;
|
|
47
|
+
if (typeof record.prompt !== "string" || !record.prompt.trim()) {
|
|
48
|
+
throw new Error("Handoff prompt must be a non-empty string.");
|
|
49
|
+
}
|
|
50
|
+
if (!Array.isArray(record.files) || !record.files.every((path) => typeof path === "string")) {
|
|
51
|
+
throw new Error("Handoff files must be an array of strings.");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const files: string[] = [];
|
|
55
|
+
for (const rawPath of record.files) {
|
|
56
|
+
const path = posix.normalize(rawPath.trim().replace(/^@/, "").replace(/^\.\//, ""));
|
|
57
|
+
if (
|
|
58
|
+
path === "." ||
|
|
59
|
+
path.startsWith("/") ||
|
|
60
|
+
/^[a-z]:\//i.test(path) ||
|
|
61
|
+
path === ".." ||
|
|
62
|
+
path.startsWith("../") ||
|
|
63
|
+
path.includes("\\")
|
|
64
|
+
) {
|
|
65
|
+
throw new Error(`Handoff file must be a project-relative path: ${rawPath}`);
|
|
66
|
+
}
|
|
67
|
+
if (!files.includes(path)) files.push(path);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return { prompt: record.prompt.trim(), files };
|
|
71
|
+
}
|
|
@@ -145,7 +145,9 @@ Start a fresh thread with \`agent\` and \`task\`. Continue an existing thread wi
|
|
|
145
145
|
|
|
146
146
|
Pass \`files\` when exact relevant files are already known. Tau autoreads current line-numbered snapshots into that child turn before it starts.
|
|
147
147
|
|
|
148
|
-
Delegate one focused task per call. Children do not inherit parent messages. Include exact absolute reference paths when a child must inspect a repository outside the current working directory
|
|
148
|
+
Delegate one focused task per call. Children do not inherit parent messages. Include exact absolute reference paths when a child must inspect a repository outside the current working directory.
|
|
149
|
+
|
|
150
|
+
Review limit: for one user task or coherent implementation batch, call the \`review\` agent at most twice. The first call is the initial review. The only permitted second call is a follow-up in the same retained thread to check fixes from that initial review. Never call \`review\` a third time for the same task. Do not call it for documentation-only work, trivial edits, small localized changes, or micro-adjustments after review; inspect and test those directly.`;
|
|
149
151
|
return { systemPrompt: `${event.systemPrompt}\n\n${prompt}` };
|
|
150
152
|
});
|
|
151
153
|
pi.registerTool(
|
|
@@ -36,7 +36,7 @@ Adds `/context` to select reusable repository work scopes from `.pi/contexts`, a
|
|
|
36
36
|
|
|
37
37
|
## context-pruning
|
|
38
38
|
|
|
39
|
-
Gives the agent `context_prune` for
|
|
39
|
+
Gives the agent `context_prune` for creating a hard context checkpoint after broad exploration converges. Everything before the checkpoint leaves future model input unless the agent retains an exact tool exchange or carries a file forward as a fresh snapshot. File snapshot failures are reported without blocking the checkpoint. Deferred files remain as a small conditional note. Context markers use an ordered instruction ladder that escalates from informational guidance to pruning before further tool work. Run `/prune` with no arguments to request a checkpoint manually. Session history remains unchanged, and checkpoints, markers, and pruned-row state follow the active branch.
|
|
40
40
|
|
|
41
41
|
## explore
|
|
42
42
|
|
|
@@ -46,6 +46,10 @@ Replaces Pi’s filesystem inspection tools with compact Tau versions: `ls`, `fi
|
|
|
46
46
|
|
|
47
47
|
Adds `/footer` to toggle and refresh Tau’s status footer.
|
|
48
48
|
|
|
49
|
+
## handoff
|
|
50
|
+
|
|
51
|
+
Adds `/handoff <goal>` to start a fresh linked session from the current conversation. Tau generates an opening prompt, autoreads the relevant project files it already knows about, and leaves the prompt in the editor for review instead of submitting it.
|
|
52
|
+
|
|
49
53
|
## ideas
|
|
50
54
|
|
|
51
55
|
Adds `/ideas` to log rough ideas or open the ideas browser.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shanepadgett/tau-agent",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.21.0",
|
|
4
4
|
"description": "Tau is a custom agentic harness built with pi extensions",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"README.md"
|
|
29
29
|
],
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@shanepadgett/tau-tui": "0.
|
|
31
|
+
"@shanepadgett/tau-tui": "0.21.0",
|
|
32
32
|
"@toon-format/toon": "2.3.0",
|
|
33
33
|
"smol-toml": "1.7.0"
|
|
34
34
|
},
|
package/schemas/tau.schema.json
CHANGED
|
@@ -68,18 +68,21 @@
|
|
|
68
68
|
"maximum": 100,
|
|
69
69
|
"description": "Context growth interval between automatic pruning hints."
|
|
70
70
|
},
|
|
71
|
-
"
|
|
72
|
-
"type": "
|
|
73
|
-
"
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
71
|
+
"nudgeInstructions": {
|
|
72
|
+
"type": "array",
|
|
73
|
+
"items": {
|
|
74
|
+
"type": "string",
|
|
75
|
+
"minLength": 1,
|
|
76
|
+
"maxLength": 2000
|
|
77
|
+
},
|
|
78
|
+
"default": [
|
|
79
|
+
"No prune is required yet unless broad exploration has converged or substantial evidence is already irrelevant. Continue coherent work.",
|
|
80
|
+
"Move toward a pruning point now. Finish the current coherent step, then prune before starting another broad exploration. Managed context is materially increasing model cost.",
|
|
81
|
+
"Prune now before further tool work. Continuing with stale managed context is wasting money."
|
|
82
|
+
],
|
|
83
|
+
"minItems": 1,
|
|
84
|
+
"maxItems": 5,
|
|
85
|
+
"description": "Ordered automatic pruning instructions. Later reminders repeat the final instruction, and the final tier requires an anchor before further tool work."
|
|
83
86
|
}
|
|
84
87
|
},
|
|
85
88
|
"additionalProperties": false
|