@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
|
@@ -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,10 @@ 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 ? ["Prune now."] : parsed.tier > 1 ? ["Prune soon."] : []),
|
|
132
|
+
],
|
|
79
133
|
});
|
|
80
134
|
}
|
|
81
135
|
|
|
@@ -108,7 +162,7 @@ export function renderContextPruneResult(
|
|
|
108
162
|
lastComponent: unknown,
|
|
109
163
|
): Text {
|
|
110
164
|
const component = lastComponent instanceof Text ? lastComponent : new Text("", 0, 0);
|
|
111
|
-
const details =
|
|
165
|
+
const details = parseContextPruneDetailsV2(result.details);
|
|
112
166
|
if (!details) {
|
|
113
167
|
component.setText(
|
|
114
168
|
theme.fg(
|
|
@@ -118,27 +172,21 @@ export function renderContextPruneResult(
|
|
|
118
172
|
);
|
|
119
173
|
return component;
|
|
120
174
|
}
|
|
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;
|
|
175
|
+
const pruned = details.prunedToolCallIds.length + details.prunedAutoreadRowIds.length;
|
|
129
176
|
const retained = details.retainedToolCallIds.length + details.retainedAutoreadRowIds.length;
|
|
130
177
|
let text = theme.fg(
|
|
131
|
-
"success",
|
|
132
|
-
`
|
|
178
|
+
details.warnings.length === 0 ? "success" : "warning",
|
|
179
|
+
`Checkpoint · pruned ${pruned} · retained ${retained} · refreshed ${details.refreshedFiles.length} · deferred ${details.deferredFiles.length}${details.warnings.length === 0 ? "" : ` · warnings ${details.warnings.length}`}`,
|
|
133
180
|
);
|
|
134
181
|
if (expanded) {
|
|
135
182
|
const lines = [
|
|
136
|
-
...details.
|
|
137
|
-
...details.
|
|
183
|
+
...details.prunedToolCallIds.map((id) => `pruned tool: ${id}`),
|
|
184
|
+
...details.prunedAutoreadRowIds.map((id) => `pruned autoread: ${id}`),
|
|
138
185
|
...details.retainedToolCallIds.map((id) => `retained tool: ${id}`),
|
|
139
186
|
...details.retainedAutoreadRowIds.map((id) => `retained autoread: ${id}`),
|
|
140
187
|
...details.refreshedFiles.map((file) => `refreshed: ${file.path} (${file.rowId})`),
|
|
141
188
|
...details.deferredFiles.map((file) => `deferred: ${file.path} — ${file.reason}; when ${file.relevantWhen}`),
|
|
189
|
+
...details.warnings.map((warning) => `warning: ${warning}`),
|
|
142
190
|
];
|
|
143
191
|
const shown = lines.slice(0, MAX_EXPANDED_ITEMS).map((line) => boundedText(line, MAX_EXPANDED_LINE_CHARACTERS));
|
|
144
192
|
if (lines.length > shown.length) shown.push(`… ${lines.length - shown.length} more`);
|
|
@@ -174,6 +222,18 @@ function isBoundary(value: unknown): value is number {
|
|
|
174
222
|
return isPercent(value) && value > 0;
|
|
175
223
|
}
|
|
176
224
|
|
|
225
|
+
function isReminder(value: unknown): value is number {
|
|
226
|
+
return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 100;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function isTier(value: unknown): value is number {
|
|
230
|
+
return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 5;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function isTierFloor(value: unknown): value is number {
|
|
234
|
+
return typeof value === "number" && Number.isInteger(value) && value >= 0 && value <= 5;
|
|
235
|
+
}
|
|
236
|
+
|
|
177
237
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
178
238
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
179
239
|
}
|
|
@@ -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,145 @@
|
|
|
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(ctx.sessionManager.getEntries(), ctx.sessionManager.getLeafId()).messages;
|
|
39
|
+
if (messages.length === 0) {
|
|
40
|
+
ctx.ui.notify("No conversation context to hand off.", "error");
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
let generationError: string | undefined;
|
|
45
|
+
const draft = await ctx.ui.custom<HandoffDraft | null>((tui, theme, _keys, done) => {
|
|
46
|
+
const loader = new BorderedLoader(tui, theme, "Generating handoff");
|
|
47
|
+
loader.onAbort = () => done(null);
|
|
48
|
+
void (async () => {
|
|
49
|
+
try {
|
|
50
|
+
const thinking = pi.getThinkingLevel();
|
|
51
|
+
const candidates = await resolveCandidates(
|
|
52
|
+
ctx,
|
|
53
|
+
[
|
|
54
|
+
{
|
|
55
|
+
provider: model.provider,
|
|
56
|
+
model: model.id,
|
|
57
|
+
reasoning: thinking === "off" ? undefined : thinking,
|
|
58
|
+
},
|
|
59
|
+
],
|
|
60
|
+
false,
|
|
61
|
+
);
|
|
62
|
+
const conversation = serializeConversation(convertToLlm(messages));
|
|
63
|
+
const result = await generateToolValidated(
|
|
64
|
+
{ ui: ctx.ui, signal: loader.signal },
|
|
65
|
+
candidates,
|
|
66
|
+
buildHandoffRequest(conversation, goal, ctx.cwd),
|
|
67
|
+
HANDOFF_TOOL,
|
|
68
|
+
handoffDraftFromToolInput,
|
|
69
|
+
(error, output) =>
|
|
70
|
+
[
|
|
71
|
+
`The handoff failed validation: ${error.message}`,
|
|
72
|
+
`Call ${HANDOFF_TOOL.name} again with corrected arguments only.`,
|
|
73
|
+
"Previous response:",
|
|
74
|
+
output,
|
|
75
|
+
].join("\n"),
|
|
76
|
+
{ maxAttempts: 3 },
|
|
77
|
+
);
|
|
78
|
+
done(result);
|
|
79
|
+
} catch (error) {
|
|
80
|
+
if (!loader.signal.aborted) generationError = errorText(error);
|
|
81
|
+
done(null);
|
|
82
|
+
}
|
|
83
|
+
})();
|
|
84
|
+
return loader;
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
if (!draft) {
|
|
88
|
+
ctx.ui.notify(
|
|
89
|
+
generationError ? `Handoff failed: ${generationError}` : "Handoff cancelled.",
|
|
90
|
+
generationError ? "error" : "info",
|
|
91
|
+
);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const batchId = randomUUID();
|
|
96
|
+
const cwd = ctx.cwd;
|
|
97
|
+
const result = await ctx.newSession({
|
|
98
|
+
parentSession: currentSessionFile,
|
|
99
|
+
setup: async (sessionManager) => {
|
|
100
|
+
const messages = await Promise.all(
|
|
101
|
+
draft.files.map(async (path, index) => {
|
|
102
|
+
const details = {
|
|
103
|
+
rowId: `${batchId}:${index}`,
|
|
104
|
+
path,
|
|
105
|
+
cwd,
|
|
106
|
+
source: "handoff",
|
|
107
|
+
batchId,
|
|
108
|
+
};
|
|
109
|
+
try {
|
|
110
|
+
return await prepareAutoreadMessage({
|
|
111
|
+
...details,
|
|
112
|
+
signal: undefined,
|
|
113
|
+
isLifecycleCurrent: () => true,
|
|
114
|
+
});
|
|
115
|
+
} catch (error) {
|
|
116
|
+
const message = errorText(error);
|
|
117
|
+
return {
|
|
118
|
+
customType: "tau.autoread" as const,
|
|
119
|
+
content: `${path}\nAutoread failed: ${message}`,
|
|
120
|
+
display: true as const,
|
|
121
|
+
details: { ...details, status: "failed", error: message } satisfies AutoreadDetails,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
}),
|
|
125
|
+
);
|
|
126
|
+
for (const message of messages) {
|
|
127
|
+
sessionManager.appendCustomMessageEntry(
|
|
128
|
+
message.customType,
|
|
129
|
+
message.content,
|
|
130
|
+
message.display,
|
|
131
|
+
message.details,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
withSession: async (replacementCtx) => {
|
|
136
|
+
replacementCtx.ui.setEditorText(draft.prompt);
|
|
137
|
+
replacementCtx.ui.notify("Handoff ready. Review the draft and submit when ready.", "info");
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
if (result.cancelled) {
|
|
141
|
+
ctx.ui.notify("New session cancelled.", "info");
|
|
142
|
+
}
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
}
|
|
@@ -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
|
+
}
|