@shanepadgett/tau-agent 0.12.0 → 0.14.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 +11 -0
- package/extensions/context/README.md +3 -2
- package/extensions/context/definitions.ts +19 -3
- package/extensions/context/index.ts +24 -8
- package/extensions/context/panel.ts +31 -14
- package/extensions/context/sync.ts +30 -13
- package/extensions/context/validation.ts +2 -2
- package/extensions/subagent/README.md +2 -0
- package/extensions/subagent/index.ts +228 -106
- package/extensions/subagent/render.ts +4 -3
- package/extensions/subagent/run.ts +96 -45
- package/extensions/tau-help/help.md +2 -2
- package/package.json +2 -2
package/docs/subagents.md
CHANGED
|
@@ -2,6 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
Tau's `subagent` tool delegates one focused task to an isolated child Pi session. You can add your own agent definitions without writing TypeScript.
|
|
4
4
|
|
|
5
|
+
Each fresh call returns a thread ID. Continue that thread when feedback or follow-up work depends on the child's prior reads and reasoning:
|
|
6
|
+
|
|
7
|
+
```text
|
|
8
|
+
subagent({ agent: "scout", task: "Trace configuration loading" })
|
|
9
|
+
subagent({ thread: "thread-1", task: "Now check whether this proposed fix covers every caller" })
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Retained threads keep their child conversation and tool results for the current parent session. Start fresh for unrelated work or when earlier context is stale or oversized. Tau keeps up to 16 threads and evicts the least recently used idle thread when needed.
|
|
13
|
+
|
|
5
14
|
## Where definitions live
|
|
6
15
|
|
|
7
16
|
| Scope | Path | Use when |
|
|
@@ -46,8 +55,10 @@ Named tools and configured models must exist in the normally loaded child Pi env
|
|
|
46
55
|
|
|
47
56
|
- Children use the parent's cwd and inherit model/thinking unless the definition overrides them.
|
|
48
57
|
- Children do not receive the parent conversation.
|
|
58
|
+
- Follow-up calls reuse the retained child's conversation, model, thinking level, tools, and cwd.
|
|
49
59
|
- Children load only the extensions that own their declared tools. Unrelated extension hooks do not run in child sessions.
|
|
50
60
|
- At most four children run at once; extra calls wait in order.
|
|
61
|
+
- Calls to the same retained thread run one at a time.
|
|
51
62
|
- Returned text is capped (50 KB / 2,000 lines); full truncated output is saved to a private temp file.
|
|
52
63
|
|
|
53
64
|
## Built-ins
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Context stores reusable repository work scopes in `.pi/contexts`. Folder names become selector tabs, TOML files become concepts, and TOML sections become selectable entries.
|
|
4
4
|
|
|
5
|
-
Use `/context` to select entries
|
|
5
|
+
Use `/context` to select entries. Entry `files` are injected through Tau autoread. Entry `anchors` supply lazy navigation paths that the agent can grep or read in ranges when needed. Use `/context-sync` to reconcile affected scopes from the current Git changes. Tau validates both file classes as context membership after agent turns and asks the agent to sync uncovered changed files or stale references automatically.
|
|
6
6
|
|
|
7
7
|
Validation is disabled by default. Enable it globally or per project in Tau settings:
|
|
8
8
|
|
|
@@ -30,6 +30,7 @@ files = ["src/player/input.ts"]
|
|
|
30
30
|
[movement]
|
|
31
31
|
description = "Player locomotion and collision"
|
|
32
32
|
files = ["src/player/movement.ts"]
|
|
33
|
+
anchors = ["src/runtime/fetch-handler.ts"]
|
|
33
34
|
```
|
|
34
35
|
|
|
35
|
-
For example, `.pi/contexts/gameplay/player.toml` appears in the `gameplay` tab as the `Player` concept with `input` and `movement` entries.
|
|
36
|
+
For example, `.pi/contexts/gameplay/player.toml` appears in the `gameplay` tab as the `Player` concept with `input` and `movement` entries. When `movement` is selected, Tau autoreads `src/player/movement.ts` and lists `src/runtime/fetch-handler.ts` as an unloaded anchor. If one selected entry marks a path as a file and another marks it as an anchor, autoread wins.
|
|
@@ -13,6 +13,7 @@ export interface ContextEntry {
|
|
|
13
13
|
name: string;
|
|
14
14
|
description: string;
|
|
15
15
|
files: string[];
|
|
16
|
+
anchors: string[];
|
|
16
17
|
path: string;
|
|
17
18
|
}
|
|
18
19
|
|
|
@@ -36,6 +37,7 @@ const CONTEXT_IGNORED_FILENAMES = new Set([
|
|
|
36
37
|
"uv.lock",
|
|
37
38
|
"yarn.lock",
|
|
38
39
|
]);
|
|
40
|
+
const CONTEXT_ENTRY_FIELDS = new Set(["description", "files", "anchors"]);
|
|
39
41
|
|
|
40
42
|
export function isContextEligiblePath(path: string, ignoreGlobs: readonly string[] = []): boolean {
|
|
41
43
|
return (
|
|
@@ -97,6 +99,10 @@ function sortedUnique(values: readonly string[]): string[] {
|
|
|
97
99
|
return [...new Set(values)].sort((a, b) => a.localeCompare(b));
|
|
98
100
|
}
|
|
99
101
|
|
|
102
|
+
export function contextEntryPaths(entry: Pick<ContextEntry, "files" | "anchors">): string[] {
|
|
103
|
+
return sortedUnique([...entry.files, ...entry.anchors]);
|
|
104
|
+
}
|
|
105
|
+
|
|
100
106
|
export async function requireFiles(root: string, inputs: readonly string[]): Promise<string[]> {
|
|
101
107
|
const files = sortedUnique(inputs.map((input) => normalizeProjectPath(root, input)));
|
|
102
108
|
for (const file of files) {
|
|
@@ -132,15 +138,24 @@ export async function loadContextEntries(root: string): Promise<ContextEntry[]>
|
|
|
132
138
|
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
133
139
|
throw new Error(`Invalid context entry: ${path} [${name}]`);
|
|
134
140
|
const record = value as Record<string, unknown>;
|
|
141
|
+
const unknownField = Object.keys(record).find((field) => !CONTEXT_ENTRY_FIELDS.has(field));
|
|
142
|
+
if (unknownField) throw new Error(`Invalid context entry field: ${path} [${name}] ${unknownField}`);
|
|
143
|
+
const anchors = record.anchors ?? [];
|
|
135
144
|
if (
|
|
136
145
|
typeof record.description !== "string" ||
|
|
137
146
|
!record.description.trim() ||
|
|
138
147
|
!Array.isArray(record.files) ||
|
|
139
|
-
record.files.
|
|
140
|
-
|
|
148
|
+
record.files.some((item) => typeof item !== "string") ||
|
|
149
|
+
!Array.isArray(anchors) ||
|
|
150
|
+
anchors.some((item) => typeof item !== "string") ||
|
|
151
|
+
(record.files.length === 0 && anchors.length === 0)
|
|
141
152
|
)
|
|
142
153
|
throw new Error(`Invalid context entry: ${path} [${name}]`);
|
|
143
154
|
const entry = validSlug(name, "Context entry");
|
|
155
|
+
const entryFiles = sortedUnique((record.files as string[]).map((item) => normalizeProjectPath(root, item)));
|
|
156
|
+
const entryAnchors = sortedUnique((anchors as string[]).map((item) => normalizeProjectPath(root, item)));
|
|
157
|
+
const overlap = entryFiles.find((item) => entryAnchors.includes(item));
|
|
158
|
+
if (overlap) throw new Error(`Context path cannot be both file and anchor: ${path} [${name}] ${overlap}`);
|
|
144
159
|
result.push({
|
|
145
160
|
id: `${tab}/${concept}/${entry}`,
|
|
146
161
|
tab,
|
|
@@ -149,7 +164,8 @@ export async function loadContextEntries(root: string): Promise<ContextEntry[]>
|
|
|
149
164
|
conceptDescription,
|
|
150
165
|
name: entry,
|
|
151
166
|
description: record.description.trim(),
|
|
152
|
-
files:
|
|
167
|
+
files: entryFiles,
|
|
168
|
+
anchors: entryAnchors,
|
|
153
169
|
path,
|
|
154
170
|
});
|
|
155
171
|
}
|
|
@@ -53,19 +53,35 @@ export default function contextExtension(pi: ExtensionAPI): void {
|
|
|
53
53
|
);
|
|
54
54
|
if (!selected?.length) return;
|
|
55
55
|
const files = [...new Set(selected.flatMap((entry) => entry.files))].sort();
|
|
56
|
+
const fileSet = new Set(files);
|
|
57
|
+
const anchors = [...new Set(selected.flatMap((entry) => entry.anchors))]
|
|
58
|
+
.filter((path) => !fileSet.has(path))
|
|
59
|
+
.sort();
|
|
56
60
|
pi.sendMessage(
|
|
57
61
|
createInjectedContext(
|
|
58
|
-
|
|
62
|
+
[
|
|
63
|
+
"Selected repository context:",
|
|
64
|
+
...selected.map((entry) => `- ${entry.id}: ${entry.description}`),
|
|
65
|
+
"",
|
|
66
|
+
"Eager snapshots supplied through autoread:",
|
|
67
|
+
...(files.length ? files.map((path) => `- ${path}`) : ["(none)"]),
|
|
68
|
+
"",
|
|
69
|
+
"Lazy navigation anchors whose contents have not been loaded:",
|
|
70
|
+
...(anchors.length ? anchors.map((path) => `- ${path}`) : ["(none)"]),
|
|
71
|
+
"",
|
|
72
|
+
"Treat eager snapshots as authoritative current project context. Do not reread them or search for coverage around them. Inspect only the anchors needed for the request, using grep or bounded reads. Explore elsewhere only when the request or concrete evidence requires missing information.",
|
|
73
|
+
].join("\n"),
|
|
59
74
|
{ source: "context", title: "Project context" },
|
|
60
75
|
),
|
|
61
76
|
);
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
77
|
+
if (files.length)
|
|
78
|
+
emitTauEvent(pi, "tau:autoread.requested", {
|
|
79
|
+
source: "context",
|
|
80
|
+
title: "Project context",
|
|
81
|
+
cwd: root,
|
|
82
|
+
batchId: randomUUID(),
|
|
83
|
+
files: files.map((path) => ({ path })),
|
|
84
|
+
});
|
|
69
85
|
},
|
|
70
86
|
});
|
|
71
87
|
|
|
@@ -73,14 +73,15 @@ export class ContextPanel implements Component {
|
|
|
73
73
|
const list = this.activeList();
|
|
74
74
|
if (!list?.isFilterFocused()) {
|
|
75
75
|
const keys = getKeybindings();
|
|
76
|
-
const
|
|
77
|
-
|
|
76
|
+
const paths = this.currentPaths();
|
|
77
|
+
const pageSize = this.pathPageSize();
|
|
78
|
+
if (paths.length > pageSize && matchesKey(data, Key.alt("up"))) {
|
|
78
79
|
this.fileOffset = Math.max(0, this.fileOffset - pageSize);
|
|
79
80
|
this.sync();
|
|
80
81
|
return;
|
|
81
82
|
}
|
|
82
|
-
if (
|
|
83
|
-
this.fileOffset = Math.min(
|
|
83
|
+
if (paths.length > pageSize && matchesKey(data, Key.alt("down"))) {
|
|
84
|
+
this.fileOffset = Math.min(paths.length - pageSize, this.fileOffset + pageSize);
|
|
84
85
|
this.sync();
|
|
85
86
|
return;
|
|
86
87
|
}
|
|
@@ -122,35 +123,51 @@ export class ContextPanel implements Component {
|
|
|
122
123
|
return {
|
|
123
124
|
render: (width) => {
|
|
124
125
|
const tabs = this.tabs.render(width);
|
|
125
|
-
const
|
|
126
|
-
const
|
|
126
|
+
const paths = this.currentPaths();
|
|
127
|
+
const pageSize = this.pathPageSize(tabs.length);
|
|
128
|
+
const maxOffset = Math.max(0, paths.length - pageSize);
|
|
127
129
|
this.fileOffset = Math.min(this.fileOffset, maxOffset);
|
|
128
|
-
const
|
|
130
|
+
const visible = paths.slice(this.fileOffset, this.fileOffset + pageSize);
|
|
129
131
|
const range =
|
|
130
|
-
|
|
132
|
+
paths.length > pageSize
|
|
131
133
|
? this.theme.fg(
|
|
132
134
|
"dim",
|
|
133
|
-
`${this.fileOffset + 1}-${this.fileOffset +
|
|
135
|
+
`${this.fileOffset + 1}-${this.fileOffset + visible.length} of ${paths.length} paths`,
|
|
134
136
|
)
|
|
135
137
|
: undefined;
|
|
136
138
|
return [
|
|
137
139
|
...tabs,
|
|
138
140
|
"",
|
|
139
141
|
...(range ? [truncateToWidth(range, width, "…")] : []),
|
|
140
|
-
...
|
|
142
|
+
...visible.map(({ kind, path }) =>
|
|
143
|
+
truncateToWidth(
|
|
144
|
+
this.theme.fg(
|
|
145
|
+
kind === "read" ? "muted" : "dim",
|
|
146
|
+
`${kind === "read" ? "read" : "anchor"} • ${path}`,
|
|
147
|
+
),
|
|
148
|
+
width,
|
|
149
|
+
"…",
|
|
150
|
+
),
|
|
151
|
+
),
|
|
141
152
|
];
|
|
142
153
|
},
|
|
143
154
|
invalidate: () => this.tabs.invalidate(),
|
|
144
155
|
};
|
|
145
156
|
}
|
|
146
|
-
private
|
|
157
|
+
private currentPaths(): Array<{ kind: "read" | "anchor"; path: string }> {
|
|
158
|
+
return [
|
|
159
|
+
...this.current.files.map((path) => ({ kind: "read" as const, path })),
|
|
160
|
+
...this.current.anchors.map((path) => ({ kind: "anchor" as const, path })),
|
|
161
|
+
];
|
|
162
|
+
}
|
|
163
|
+
private pathPageSize(tabLines = this.tabs.render(this.tui.terminal.columns).length): number {
|
|
147
164
|
const overlayHeight = Math.floor(this.tui.terminal.rows * 0.8);
|
|
148
165
|
const available = Math.max(1, overlayHeight - tabLines - 7);
|
|
149
166
|
const pageSize = Math.min(8, available);
|
|
150
|
-
return this.
|
|
167
|
+
return this.currentPaths().length > pageSize ? Math.max(1, pageSize - 1) : pageSize;
|
|
151
168
|
}
|
|
152
169
|
private secondary(): string {
|
|
153
|
-
return `${[...this.selected.values()].reduce((sum, items) => sum + items.length, 0)} selected`;
|
|
170
|
+
return `${[...this.selected.values()].reduce((sum, items) => sum + items.length, 0)} selected · ${this.current.files.length} read · ${this.current.anchors.length} anchors`;
|
|
154
171
|
}
|
|
155
172
|
private hints() {
|
|
156
173
|
const list = this.activeList();
|
|
@@ -158,7 +175,7 @@ export class ContextPanel implements Component {
|
|
|
158
175
|
? list.getKeyHints()
|
|
159
176
|
: [
|
|
160
177
|
...this.tabs.getKeyHints(),
|
|
161
|
-
...(this.
|
|
178
|
+
...(this.currentPaths().length > this.pathPageSize() ? [rawHint("option+↑/↓", "scroll paths")] : []),
|
|
162
179
|
rawHint("ctrl+c", "clear all"),
|
|
163
180
|
bindingHint("tui.select.confirm", "inject"),
|
|
164
181
|
bindingHint("tui.select.cancel", "cancel"),
|
|
@@ -10,6 +10,7 @@ import { loadTauExtensionSettings } from "../../shared/settings/load.ts";
|
|
|
10
10
|
import { truncAt } from "../../shared/text.ts";
|
|
11
11
|
import { XAI_CHAT_MODEL, XAI_PROVIDER } from "../xai/constants.ts";
|
|
12
12
|
import {
|
|
13
|
+
contextEntryPaths,
|
|
13
14
|
isContextEligiblePath,
|
|
14
15
|
isSensitiveContextPath,
|
|
15
16
|
loadContextEntries,
|
|
@@ -216,13 +217,14 @@ async function collectSyncEvidence(
|
|
|
216
217
|
for (const path of dependencies) if (!isContextEligiblePath(path, ignoreGlobs)) dependencies.delete(path);
|
|
217
218
|
const missingPaths = new Set<string>();
|
|
218
219
|
for (const entry of entries)
|
|
219
|
-
for (const path of entry
|
|
220
|
+
for (const path of contextEntryPaths(entry))
|
|
220
221
|
if (isContextEligiblePath(path, ignoreGlobs) && !(await isFile(join(root, path)))) missingPaths.add(path);
|
|
221
222
|
const affectedIds = new Set<string>();
|
|
222
223
|
for (const entry of entries) {
|
|
224
|
+
const paths = contextEntryPaths(entry);
|
|
223
225
|
if (
|
|
224
|
-
|
|
225
|
-
|
|
226
|
+
paths.some((path) => missingPaths.has(path)) ||
|
|
227
|
+
paths.some(
|
|
226
228
|
(path) => files.some((file) => file.path === path || file.oldPath === path) || dependencies.has(path),
|
|
227
229
|
)
|
|
228
230
|
)
|
|
@@ -231,7 +233,7 @@ async function collectSyncEvidence(
|
|
|
231
233
|
const affectedConcepts = new Set([...affectedIds].map((id) => id.split("/").slice(0, 2).join("/")));
|
|
232
234
|
const siblingEntries = entries.filter((entry) => affectedConcepts.has(`${entry.tab}/${entry.concept}`));
|
|
233
235
|
const siblingFiles = siblingEntries
|
|
234
|
-
.flatMap((entry) => entry
|
|
236
|
+
.flatMap((entry) => contextEntryPaths(entry))
|
|
235
237
|
.filter((path) => !missingPaths.has(path) && isContextEligiblePath(path, ignoreGlobs));
|
|
236
238
|
const eligibleFiles = new Set([...dirtyExisting, ...dependencies, ...siblingFiles]);
|
|
237
239
|
const structuralPreviews = new Map<string, string>();
|
|
@@ -327,9 +329,13 @@ async function collectDirtyFiles(
|
|
|
327
329
|
sorted.slice(offset, offset + EVIDENCE_CONCURRENCY).map(async (file, inner) => ({
|
|
328
330
|
...file,
|
|
329
331
|
id: offset + inner + 1,
|
|
330
|
-
memberships: entries
|
|
332
|
+
memberships: entries
|
|
333
|
+
.filter((entry) => contextEntryPaths(entry).includes(file.path))
|
|
334
|
+
.map((entry) => entry.id),
|
|
331
335
|
oldMemberships: file.oldPath
|
|
332
|
-
? entries
|
|
336
|
+
? entries
|
|
337
|
+
.filter((entry) => contextEntryPaths(entry).includes(file.oldPath ?? ""))
|
|
338
|
+
.map((entry) => entry.id)
|
|
333
339
|
: [],
|
|
334
340
|
evidence: await dirtyEvidence(git, root, file),
|
|
335
341
|
})),
|
|
@@ -431,7 +437,9 @@ async function isFile(path: string): Promise<boolean> {
|
|
|
431
437
|
|
|
432
438
|
function buildContextSyncPrompt(evidence: SyncEvidence): string {
|
|
433
439
|
const stale = evidence.entries.flatMap((entry) =>
|
|
434
|
-
|
|
440
|
+
contextEntryPaths(entry)
|
|
441
|
+
.filter((path) => evidence.missingPaths.has(path))
|
|
442
|
+
.map((path) => `${entry.id}: ${path}`),
|
|
435
443
|
);
|
|
436
444
|
const affected = evidence.entries.filter((entry) => evidence.affectedIds.has(entry.id));
|
|
437
445
|
const previews = [...evidence.structuralPreviews].map(([path, preview]) => `${path}\n${preview}`);
|
|
@@ -441,6 +449,7 @@ function buildContextSyncPrompt(evidence: SyncEvidence): string {
|
|
|
441
449
|
"Call submit_context_sync exactly once and produce no prose response.",
|
|
442
450
|
"Return no-change only when every eligible changed file has context membership and no stale catalog paths remain.",
|
|
443
451
|
"Context entries are reusable work scopes, not inventories of every touched file. Prefer updating an existing entry over creating a near-duplicate. Do not create one entry per file.",
|
|
452
|
+
"The files field in your changes is desired membership, including eager files and lazy anchors. Paths already classified anywhere in the catalog preserve their loading class; new paths become eager files. Do not change loading policy.",
|
|
444
453
|
"Follow direct local dependency candidates only when needed. Do not add package dependencies, generated files, incidental imports, or recursive dependencies.",
|
|
445
454
|
"Reconsider granularity only inside affected concepts. Preserve broad entries when splitting would duplicate files without improving future work.",
|
|
446
455
|
"Every eligible changed file must belong to at least one entry. Remove every stale catalog path. Use only supplied candidate paths.",
|
|
@@ -457,8 +466,9 @@ function buildContextSyncPrompt(evidence: SyncEvidence): string {
|
|
|
457
466
|
conceptName: entry.conceptName,
|
|
458
467
|
conceptDescription: entry.conceptDescription,
|
|
459
468
|
description: entry.description,
|
|
460
|
-
|
|
461
|
-
|
|
469
|
+
eagerFiles: entry.files,
|
|
470
|
+
anchors: entry.anchors,
|
|
471
|
+
missingFiles: contextEntryPaths(entry).filter((path) => evidence.missingPaths.has(path)),
|
|
462
472
|
})),
|
|
463
473
|
),
|
|
464
474
|
"Changed files with no membership:",
|
|
@@ -540,7 +550,7 @@ export function normalizeContextSyncPlan(input: unknown, evidence: SyncEvidence)
|
|
|
540
550
|
throw new Error(`Entry is unrelated: ${id}`);
|
|
541
551
|
if (conceptName !== existing.conceptName || conceptDescription !== existing.conceptDescription)
|
|
542
552
|
throw new Error(`Existing concept metadata cannot change: ${conceptId}`);
|
|
543
|
-
if (description === existing.description && files.join("\0") === existing.
|
|
553
|
+
if (description === existing.description && files.join("\0") === contextEntryPaths(existing).join("\0"))
|
|
544
554
|
throw new Error(`Set-entry is identical: ${id}`);
|
|
545
555
|
} else {
|
|
546
556
|
const existingConcept = evidence.entries.find((item) => item.tab === tab && item.concept === concept);
|
|
@@ -560,7 +570,7 @@ export function normalizeContextSyncPlan(input: unknown, evidence: SyncEvidence)
|
|
|
560
570
|
}
|
|
561
571
|
changes.push({ action: "set-entry", tab, concept, conceptName, conceptDescription, entry, description, files });
|
|
562
572
|
}
|
|
563
|
-
const final = new Map(evidence.entries.map((entry) => [entry.id,
|
|
573
|
+
const final = new Map(evidence.entries.map((entry) => [entry.id, contextEntryPaths(entry)]));
|
|
564
574
|
for (const change of changes) {
|
|
565
575
|
const id = `${change.tab}/${change.concept}/${change.entry}`;
|
|
566
576
|
if (change.action === "delete-entry") final.delete(id);
|
|
@@ -571,7 +581,7 @@ export function normalizeContextSyncPlan(input: unknown, evidence: SyncEvidence)
|
|
|
571
581
|
)) {
|
|
572
582
|
const old = current.get(`${change.tab}/${change.concept}/${change.entry}`);
|
|
573
583
|
if (!old) continue;
|
|
574
|
-
for (const path of old
|
|
584
|
+
for (const path of contextEntryPaths(old))
|
|
575
585
|
if (evidence.eligibleFiles.has(path) && ![...final.values()].some((files) => files.includes(path)))
|
|
576
586
|
throw new Error(`Deleting entry would orphan surviving file: ${path}`);
|
|
577
587
|
}
|
|
@@ -640,6 +650,8 @@ export async function applyContextSyncPlan(
|
|
|
640
650
|
): Promise<ContextSyncDetails> {
|
|
641
651
|
for (const change of plan.changes) if (change.action === "set-entry") await requireFiles(root, change.files);
|
|
642
652
|
const concepts = [...new Set(plan.changes.map((change) => `${change.tab}/${change.concept}`))].sort();
|
|
653
|
+
const eagerPaths = new Set(entries.flatMap((entry) => entry.files));
|
|
654
|
+
const anchorPaths = new Set(entries.flatMap((entry) => entry.anchors).filter((path) => !eagerPaths.has(path)));
|
|
643
655
|
const outputs = new Map<string, string | undefined>();
|
|
644
656
|
for (const key of concepts) {
|
|
645
657
|
const [tab, concept] = key.split("/");
|
|
@@ -653,7 +665,12 @@ export async function applyContextSyncPlan(
|
|
|
653
665
|
else {
|
|
654
666
|
if (raw.name === undefined) raw.name = change.conceptName;
|
|
655
667
|
if (raw.description === undefined) raw.description = change.conceptDescription;
|
|
656
|
-
|
|
668
|
+
const anchors = change.files.filter((path) => anchorPaths.has(path));
|
|
669
|
+
raw[change.entry] = {
|
|
670
|
+
description: change.description,
|
|
671
|
+
files: change.files.filter((path) => !anchors.includes(path)),
|
|
672
|
+
...(anchors.length ? { anchors } : {}),
|
|
673
|
+
};
|
|
657
674
|
}
|
|
658
675
|
}
|
|
659
676
|
outputs.set(
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { stat } from "node:fs/promises";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import type { GitRunner } from "../../shared/git.ts";
|
|
4
|
-
import { isContextEligiblePath, isSensitiveContextPath, loadContextEntries } from "./definitions.ts";
|
|
4
|
+
import { contextEntryPaths, isContextEligiblePath, isSensitiveContextPath, loadContextEntries } from "./definitions.ts";
|
|
5
5
|
|
|
6
6
|
export interface ContextValidationResult {
|
|
7
7
|
stale: Array<{ path: string; ids: string[] }>;
|
|
@@ -16,7 +16,7 @@ export async function validateContextCatalog(
|
|
|
16
16
|
const entries = await loadContextEntries(root);
|
|
17
17
|
const memberships = new Map<string, string[]>();
|
|
18
18
|
for (const entry of entries)
|
|
19
|
-
for (const file of entry
|
|
19
|
+
for (const file of contextEntryPaths(entry)) memberships.set(file, [...(memberships.get(file) ?? []), entry.id]);
|
|
20
20
|
|
|
21
21
|
const stale: Array<{ path: string; ids: string[] }> = [];
|
|
22
22
|
for (const [path, ids] of memberships) {
|
|
@@ -12,6 +12,8 @@ Tau includes three built-in agents:
|
|
|
12
12
|
|
|
13
13
|
Ask Tau to delegate a task, or let it call `subagent` with an agent name and task. Children use the parent's current working directory and inherit its model and thinking level unless their definition overrides either value. They do not receive the parent conversation. Tau loads only the extensions that own a child's declared tools, so unrelated extension hooks do not run in child sessions. When a child must inspect another repository, put its exact absolute path in the delegated task.
|
|
14
14
|
|
|
15
|
+
Fresh calls return a thread ID. Tau can send feedback or follow-up work to that thread, preserving the child's conversation, prior reads, and tool results. It starts a fresh thread for unrelated work or when retained context is stale or oversized. Threads live for the current parent session. Tau retains up to 16 and evicts the least recently used idle thread when needed. Calls to one thread run sequentially.
|
|
16
|
+
|
|
15
17
|
## Agent definitions
|
|
16
18
|
|
|
17
19
|
Add Markdown definitions at `~/.pi/agent/tau/agents/*.md` or, in a trusted project, the nearest `.pi/tau/agents/*.md`. Project definitions override user definitions, which override built-ins. Duplicate names in one scope are invalid.
|
|
@@ -1,20 +1,66 @@
|
|
|
1
1
|
import { defineTool, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { Type } from "typebox";
|
|
3
3
|
import { createToolRowStateStore } from "../../shared/tool-row-state.js";
|
|
4
|
-
import { discoverAgents, type AgentDiscovery } from "./agents.ts";
|
|
4
|
+
import { discoverAgents, type AgentDefinition, type AgentDiscovery } from "./agents.ts";
|
|
5
5
|
import { renderSubagentCall, renderSubagentResult } from "./render.ts";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
createSubagentThread,
|
|
8
|
+
disposeSubagentThread,
|
|
9
|
+
extensionPathsForTools,
|
|
10
|
+
FifoGate,
|
|
11
|
+
runSubagentTurn,
|
|
12
|
+
type SubagentDetails,
|
|
13
|
+
type SubagentThread,
|
|
14
|
+
} from "./run.ts";
|
|
7
15
|
|
|
8
|
-
const params = Type.
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
16
|
+
const params = Type.Union([
|
|
17
|
+
Type.Object(
|
|
18
|
+
{ agent: Type.String({ minLength: 1 }), task: Type.String({ minLength: 1 }) },
|
|
19
|
+
{ additionalProperties: false },
|
|
20
|
+
),
|
|
21
|
+
Type.Object(
|
|
22
|
+
{ thread: Type.String({ minLength: 1 }), task: Type.String({ minLength: 1 }) },
|
|
23
|
+
{ additionalProperties: false },
|
|
24
|
+
),
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
const MAX_RETAINED_THREADS = 16;
|
|
28
|
+
|
|
29
|
+
function emptyDetails(
|
|
30
|
+
agent: string,
|
|
31
|
+
task: string,
|
|
32
|
+
status: "waiting" | "failed" | "aborted",
|
|
33
|
+
phase: "discovery" | "queue" | "startup",
|
|
34
|
+
model: string,
|
|
35
|
+
thinkingLevel: string,
|
|
36
|
+
threadId: string | undefined,
|
|
37
|
+
error: string | undefined,
|
|
38
|
+
): SubagentDetails {
|
|
39
|
+
return {
|
|
40
|
+
agent,
|
|
41
|
+
...(threadId === undefined ? {} : { threadId }),
|
|
42
|
+
status,
|
|
43
|
+
phase,
|
|
44
|
+
task,
|
|
45
|
+
model,
|
|
46
|
+
thinkingLevel,
|
|
47
|
+
toolCalls: 0,
|
|
48
|
+
actions: [],
|
|
49
|
+
omittedActions: 0,
|
|
50
|
+
omittedErrors: 0,
|
|
51
|
+
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 },
|
|
52
|
+
durationMs: 0,
|
|
53
|
+
...(error === undefined ? {} : { error }),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
12
56
|
|
|
13
57
|
export default function subagentExtension(pi: ExtensionAPI): void {
|
|
14
58
|
const gate = new FifoGate(4);
|
|
15
59
|
const controllers = new Set<AbortController>();
|
|
60
|
+
const threads = new Map<string, SubagentThread>();
|
|
16
61
|
const fingerprints = new Map<string, string>();
|
|
17
62
|
const runtimeWarnings = new Set<string>();
|
|
63
|
+
let nextThreadId = 1;
|
|
18
64
|
const rowState = createToolRowStateStore(pi, "subagent.tool-row-state");
|
|
19
65
|
const warn = (discovery: AgentDiscovery, ctx: ExtensionContext) => {
|
|
20
66
|
const current = new Set<string>();
|
|
@@ -33,6 +79,11 @@ export default function subagentExtension(pi: ExtensionAPI): void {
|
|
|
33
79
|
}
|
|
34
80
|
for (const path of fingerprints.keys()) if (!current.has(path)) fingerprints.delete(path);
|
|
35
81
|
};
|
|
82
|
+
const disposeThreads = async () => {
|
|
83
|
+
const retained = [...threads.values()];
|
|
84
|
+
threads.clear();
|
|
85
|
+
await Promise.all(retained.map((thread) => disposeSubagentThread(thread)));
|
|
86
|
+
};
|
|
36
87
|
pi.on("before_agent_start", async (event, ctx) => {
|
|
37
88
|
if (!pi.getActiveTools().includes("subagent")) return undefined;
|
|
38
89
|
const discovery = await discoverAgents(ctx.cwd, ctx.isProjectTrusted());
|
|
@@ -40,7 +91,23 @@ export default function subagentExtension(pi: ExtensionAPI): void {
|
|
|
40
91
|
const lines = [...discovery.agents.values()]
|
|
41
92
|
.sort((a, b) => a.name.localeCompare(b.name))
|
|
42
93
|
.map((agent) => `- ${agent.name}: ${agent.description}`);
|
|
43
|
-
const
|
|
94
|
+
const activeThreads = [...threads.values()]
|
|
95
|
+
.filter((thread) => thread.cwd === ctx.cwd)
|
|
96
|
+
.sort((a, b) => a.id.localeCompare(b.id))
|
|
97
|
+
.map((thread) => {
|
|
98
|
+
const task = thread.initialTask.replace(/\s+/g, " ").trim();
|
|
99
|
+
return `- ${thread.id} (${thread.definition.name}): ${task.length <= 160 ? task : `${task.slice(0, 159)}…`}`;
|
|
100
|
+
});
|
|
101
|
+
const threadSection = activeThreads.length ? `\n\nActive reusable threads:\n${activeThreads.join("\n")}` : "";
|
|
102
|
+
const prompt = `## Subagents
|
|
103
|
+
Use \`subagent\` when an available agent matches a focused part of the task.
|
|
104
|
+
|
|
105
|
+
Available agents for this turn:
|
|
106
|
+
${lines.join("\n")}
|
|
107
|
+
|
|
108
|
+
Start a fresh thread with \`agent\` and \`task\`. Continue an existing thread with \`thread\` and \`task\`. Reuse a thread when feedback or follow-up work depends on its prior reads and reasoning. Start fresh for unrelated work or when its context is stale or oversized.${threadSection}
|
|
109
|
+
|
|
110
|
+
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.`;
|
|
44
111
|
return { systemPrompt: `${event.systemPrompt}\n\n${prompt}` };
|
|
45
112
|
});
|
|
46
113
|
pi.registerTool(
|
|
@@ -48,116 +115,142 @@ export default function subagentExtension(pi: ExtensionAPI): void {
|
|
|
48
115
|
name: "subagent",
|
|
49
116
|
label: "Subagent",
|
|
50
117
|
description:
|
|
51
|
-
"
|
|
52
|
-
promptSnippet: "
|
|
118
|
+
"Start a focused isolated child agent or continue a retained child thread. Output is limited to 50KB or 2,000 lines.",
|
|
119
|
+
promptSnippet: "Start a focused child agent or continue a retained child thread",
|
|
53
120
|
parameters: params,
|
|
54
121
|
executionMode: "parallel",
|
|
55
122
|
async execute(_id, raw, signal, onUpdate, ctx) {
|
|
56
|
-
const agent = raw.agent.trim();
|
|
57
123
|
const task = raw.task.trim();
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
toolCalls: 0,
|
|
70
|
-
actions: [],
|
|
71
|
-
omittedActions: 0,
|
|
72
|
-
omittedErrors: 0,
|
|
73
|
-
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 },
|
|
74
|
-
durationMs: 0,
|
|
75
|
-
error: "Invalid input",
|
|
76
|
-
},
|
|
77
|
-
};
|
|
78
|
-
const discovery = await discoverAgents(ctx.cwd, ctx.isProjectTrusted());
|
|
79
|
-
warn(discovery, ctx);
|
|
80
|
-
const definition = discovery.agents.get(agent);
|
|
81
|
-
const invalid = discovery.invalid.get(agent);
|
|
82
|
-
if (!definition) {
|
|
83
|
-
const reason = invalid?.map((item) => item.reason).join("; ") ?? "unknown agent";
|
|
84
|
-
const names = [...discovery.agents.keys()].sort().join(", ") || "none";
|
|
85
|
-
const error = `Agent ${agent} discovery failed: ${reason}. Runnable agents: ${names}`;
|
|
124
|
+
const continuing = "thread" in raw;
|
|
125
|
+
const parentModel = ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "unavailable";
|
|
126
|
+
const parentThinking = pi.getThinkingLevel();
|
|
127
|
+
let thread: SubagentThread | undefined;
|
|
128
|
+
let definition: AgentDefinition | undefined;
|
|
129
|
+
let agent = continuing ? raw.thread.trim() : raw.agent.trim();
|
|
130
|
+
let threadId = continuing ? raw.thread.trim() : undefined;
|
|
131
|
+
if (!task || !agent) {
|
|
132
|
+
const error = continuing
|
|
133
|
+
? "Subagent continuation requires non-empty thread and task"
|
|
134
|
+
: "Subagent input requires non-empty agent and task";
|
|
86
135
|
return {
|
|
87
136
|
content: [{ type: "text", text: error }],
|
|
88
|
-
details:
|
|
89
|
-
agent,
|
|
90
|
-
task,
|
|
91
|
-
status: "failed",
|
|
92
|
-
phase: "discovery",
|
|
93
|
-
model: ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "unavailable",
|
|
94
|
-
thinkingLevel,
|
|
95
|
-
toolCalls: 0,
|
|
96
|
-
actions: [],
|
|
97
|
-
omittedActions: 0,
|
|
98
|
-
omittedErrors: 0,
|
|
99
|
-
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 },
|
|
100
|
-
durationMs: 0,
|
|
101
|
-
error,
|
|
102
|
-
},
|
|
137
|
+
details: emptyDetails(agent, task, "failed", "queue", parentModel, parentThinking, threadId, error),
|
|
103
138
|
};
|
|
104
139
|
}
|
|
140
|
+
if (continuing) {
|
|
141
|
+
thread = threads.get(agent);
|
|
142
|
+
if (!thread || thread.cwd !== ctx.cwd) {
|
|
143
|
+
const names =
|
|
144
|
+
[...threads.values()]
|
|
145
|
+
.filter((item) => item.cwd === ctx.cwd)
|
|
146
|
+
.map((item) => item.id)
|
|
147
|
+
.sort()
|
|
148
|
+
.join(", ") || "none";
|
|
149
|
+
const error = `Subagent thread ${agent} is unavailable. Active threads: ${names}`;
|
|
150
|
+
return {
|
|
151
|
+
content: [{ type: "text", text: error }],
|
|
152
|
+
details: emptyDetails(
|
|
153
|
+
agent,
|
|
154
|
+
task,
|
|
155
|
+
"failed",
|
|
156
|
+
"discovery",
|
|
157
|
+
parentModel,
|
|
158
|
+
parentThinking,
|
|
159
|
+
agent,
|
|
160
|
+
error,
|
|
161
|
+
),
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
agent = thread.definition.name;
|
|
165
|
+
threadId = thread.id;
|
|
166
|
+
definition = thread.definition;
|
|
167
|
+
} else {
|
|
168
|
+
const discovery = await discoverAgents(ctx.cwd, ctx.isProjectTrusted());
|
|
169
|
+
warn(discovery, ctx);
|
|
170
|
+
definition = discovery.agents.get(agent);
|
|
171
|
+
const invalid = discovery.invalid.get(agent);
|
|
172
|
+
if (!definition) {
|
|
173
|
+
const reason = invalid?.map((item) => item.reason).join("; ") ?? "unknown agent";
|
|
174
|
+
const names = [...discovery.agents.keys()].sort().join(", ") || "none";
|
|
175
|
+
const error = `Agent ${agent} discovery failed: ${reason}. Runnable agents: ${names}`;
|
|
176
|
+
return {
|
|
177
|
+
content: [{ type: "text", text: error }],
|
|
178
|
+
details: emptyDetails(
|
|
179
|
+
agent,
|
|
180
|
+
task,
|
|
181
|
+
"failed",
|
|
182
|
+
"discovery",
|
|
183
|
+
parentModel,
|
|
184
|
+
parentThinking,
|
|
185
|
+
undefined,
|
|
186
|
+
error,
|
|
187
|
+
),
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
threadId = `thread-${nextThreadId++}`;
|
|
191
|
+
}
|
|
105
192
|
const controller = new AbortController();
|
|
106
193
|
controllers.add(controller);
|
|
107
194
|
const combined = AbortSignal.any([controller.signal, ...(signal ? [signal] : [])]);
|
|
108
|
-
let
|
|
195
|
+
let releaseThread: (() => void) | undefined;
|
|
196
|
+
let releaseGlobal: (() => void) | undefined;
|
|
197
|
+
let reservedThread: SubagentThread | undefined;
|
|
198
|
+
let phase: "queue" | "startup" = "queue";
|
|
109
199
|
try {
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
},
|
|
126
|
-
});
|
|
127
|
-
try {
|
|
128
|
-
release = await gate.acquire(combined);
|
|
129
|
-
} catch (error) {
|
|
130
|
-
const message = error instanceof Error ? error.message : `Agent ${agent} queue failed`;
|
|
131
|
-
const details: SubagentDetails = {
|
|
132
|
-
agent,
|
|
133
|
-
task,
|
|
134
|
-
status: combined.aborted ? "aborted" : "failed",
|
|
135
|
-
phase: "queue",
|
|
136
|
-
model: ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "unavailable",
|
|
137
|
-
thinkingLevel,
|
|
138
|
-
toolCalls: 0,
|
|
139
|
-
actions: [],
|
|
140
|
-
omittedActions: 0,
|
|
141
|
-
omittedErrors: 0,
|
|
142
|
-
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 },
|
|
143
|
-
durationMs: 0,
|
|
144
|
-
error: message,
|
|
145
|
-
};
|
|
146
|
-
return { content: [{ type: "text", text: message }], details };
|
|
200
|
+
const waiting = emptyDetails(
|
|
201
|
+
agent,
|
|
202
|
+
task,
|
|
203
|
+
"waiting",
|
|
204
|
+
"queue",
|
|
205
|
+
thread?.model ?? parentModel,
|
|
206
|
+
thread?.thinkingLevel ?? parentThinking,
|
|
207
|
+
threadId,
|
|
208
|
+
undefined,
|
|
209
|
+
);
|
|
210
|
+
await onUpdate?.({ content: [{ type: "text", text: `${agent}: waiting` }], details: waiting });
|
|
211
|
+
if (thread) {
|
|
212
|
+
reservedThread = thread;
|
|
213
|
+
thread.pendingTurns += 1;
|
|
214
|
+
releaseThread = await thread.turnGate.acquire(combined);
|
|
147
215
|
}
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
216
|
+
releaseGlobal = await gate.acquire(combined);
|
|
217
|
+
if (!thread) {
|
|
218
|
+
phase = "startup";
|
|
219
|
+
if (!threadId || !definition) throw new Error("Subagent startup state is incomplete");
|
|
220
|
+
const selectedDefinition = definition;
|
|
221
|
+
if (threads.size >= MAX_RETAINED_THREADS) {
|
|
222
|
+
const evicted = [...threads.values()]
|
|
223
|
+
.filter((item) => item.pendingTurns === 0)
|
|
224
|
+
.sort((a, b) => a.lastUsedAt - b.lastUsedAt)[0];
|
|
225
|
+
if (!evicted) throw new Error("Subagent thread limit reached while all retained threads are busy");
|
|
226
|
+
threads.delete(evicted.id);
|
|
227
|
+
await disposeSubagentThread(evicted);
|
|
228
|
+
}
|
|
229
|
+
thread = await createSubagentThread({
|
|
230
|
+
id: threadId,
|
|
231
|
+
definition: selectedDefinition,
|
|
232
|
+
extensionPaths: extensionPathsForTools(pi, selectedDefinition.tools),
|
|
233
|
+
initialTask: task,
|
|
234
|
+
ctx,
|
|
235
|
+
thinkingLevel: parentThinking,
|
|
236
|
+
signal: combined,
|
|
237
|
+
onWarning: (warning) => {
|
|
238
|
+
const message = `Subagent definition ${selectedDefinition.path}: ${warning}`;
|
|
239
|
+
if (runtimeWarnings.has(message)) return;
|
|
240
|
+
runtimeWarnings.add(message);
|
|
241
|
+
ctx.ui.notify(message, "warning");
|
|
242
|
+
},
|
|
243
|
+
});
|
|
244
|
+
threads.set(thread.id, thread);
|
|
245
|
+
reservedThread = thread;
|
|
246
|
+
thread.pendingTurns += 1;
|
|
247
|
+
}
|
|
248
|
+
if (!thread) throw new Error("Subagent thread startup failed");
|
|
249
|
+
const result = await runSubagentTurn({
|
|
250
|
+
thread,
|
|
151
251
|
task,
|
|
152
|
-
|
|
153
|
-
thinkingLevel,
|
|
252
|
+
initial: thread.turns === 0,
|
|
154
253
|
signal: combined,
|
|
155
|
-
onWarning: (warning) => {
|
|
156
|
-
const message = `Subagent definition ${definition.path}: ${warning}`;
|
|
157
|
-
if (runtimeWarnings.has(message)) return;
|
|
158
|
-
runtimeWarnings.add(message);
|
|
159
|
-
ctx.ui.notify(message, "warning");
|
|
160
|
-
},
|
|
161
254
|
onUpdate: (details) =>
|
|
162
255
|
onUpdate?.({
|
|
163
256
|
content: [
|
|
@@ -169,9 +262,33 @@ export default function subagentExtension(pi: ExtensionAPI): void {
|
|
|
169
262
|
details,
|
|
170
263
|
}),
|
|
171
264
|
});
|
|
172
|
-
return {
|
|
265
|
+
return {
|
|
266
|
+
content: [
|
|
267
|
+
{
|
|
268
|
+
type: "text",
|
|
269
|
+
text: `Thread: ${thread.id}\nReuse with subagent({ thread: "${thread.id}", task: "..." })\n\n${result.content}`,
|
|
270
|
+
},
|
|
271
|
+
],
|
|
272
|
+
details: result.details,
|
|
273
|
+
};
|
|
274
|
+
} catch (error) {
|
|
275
|
+
const message = error instanceof Error ? error.message : `Agent ${agent} ${phase} failed`;
|
|
276
|
+
const status = combined.aborted ? "aborted" : "failed";
|
|
277
|
+
const details = emptyDetails(
|
|
278
|
+
agent,
|
|
279
|
+
task,
|
|
280
|
+
status,
|
|
281
|
+
phase,
|
|
282
|
+
thread?.model ?? parentModel,
|
|
283
|
+
thread?.thinkingLevel ?? parentThinking,
|
|
284
|
+
thread?.id ?? threadId,
|
|
285
|
+
message,
|
|
286
|
+
);
|
|
287
|
+
return { content: [{ type: "text", text: message }], details };
|
|
173
288
|
} finally {
|
|
174
|
-
|
|
289
|
+
if (reservedThread) reservedThread.pendingTurns -= 1;
|
|
290
|
+
releaseGlobal?.();
|
|
291
|
+
releaseThread?.();
|
|
175
292
|
controllers.delete(controller);
|
|
176
293
|
}
|
|
177
294
|
},
|
|
@@ -200,12 +317,17 @@ export default function subagentExtension(pi: ExtensionAPI): void {
|
|
|
200
317
|
const details = event.details as SubagentDetails | undefined;
|
|
201
318
|
if (details?.status === "failed" || details?.status === "aborted") return { isError: true };
|
|
202
319
|
});
|
|
203
|
-
pi.on("session_start", () => {
|
|
320
|
+
pi.on("session_start", async () => {
|
|
321
|
+
for (const controller of controllers) controller.abort();
|
|
322
|
+
controllers.clear();
|
|
323
|
+
await disposeThreads();
|
|
324
|
+
nextThreadId = 1;
|
|
204
325
|
rowState.clear();
|
|
205
326
|
runtimeWarnings.clear();
|
|
206
327
|
});
|
|
207
|
-
pi.on("session_shutdown", () => {
|
|
328
|
+
pi.on("session_shutdown", async () => {
|
|
208
329
|
for (const controller of controllers) controller.abort();
|
|
209
330
|
controllers.clear();
|
|
331
|
+
await disposeThreads();
|
|
210
332
|
});
|
|
211
333
|
}
|
|
@@ -12,7 +12,7 @@ const title = (theme: Theme, rowState: ToolRowStateStore, rowId: string, invalid
|
|
|
12
12
|
};
|
|
13
13
|
|
|
14
14
|
export function renderSubagentCall(
|
|
15
|
-
args: { agent?: string; task?: string },
|
|
15
|
+
args: { agent?: string; thread?: string; task?: string },
|
|
16
16
|
theme: Theme,
|
|
17
17
|
context: {
|
|
18
18
|
executionStarted: boolean;
|
|
@@ -29,7 +29,7 @@ export function renderSubagentCall(
|
|
|
29
29
|
return text;
|
|
30
30
|
}
|
|
31
31
|
const prefix =
|
|
32
|
-
`${title(theme, context.rowState, context.rowId, context.invalidate)} ${theme.fg("accent", args.agent ?? "")}`.trimEnd();
|
|
32
|
+
`${title(theme, context.rowState, context.rowId, context.invalidate)} ${theme.fg("accent", args.agent ?? args.thread ?? "")}`.trimEnd();
|
|
33
33
|
const task = args.task?.replace(/\s+/g, " ").trim();
|
|
34
34
|
text.setText(task ? `${prefix} ${theme.fg("muted", task)}` : prefix);
|
|
35
35
|
return text;
|
|
@@ -47,7 +47,8 @@ export function renderSubagentResult(
|
|
|
47
47
|
text.setText("");
|
|
48
48
|
return text;
|
|
49
49
|
}
|
|
50
|
-
const
|
|
50
|
+
const identity = details.threadId ? `${details.agent} · ${details.threadId}` : details.agent;
|
|
51
|
+
const header = `${title(theme, context.rowState, context.rowId, context.invalidate)} ${theme.fg("accent", identity)} ${theme.fg("muted", `$${details.usage.cost.toFixed(4)} · ${(details.durationMs / 1000).toFixed(1)}s · ${details.toolCalls} tools`)}`;
|
|
51
52
|
if (!expanded) {
|
|
52
53
|
text.setText(`${header} ${theme.fg("muted", details.task.replace(/\s+/g, " ").trim())}`);
|
|
53
54
|
return text;
|
|
@@ -55,6 +55,7 @@ export interface SubagentUsage {
|
|
|
55
55
|
}
|
|
56
56
|
export interface SubagentDetails {
|
|
57
57
|
agent: string;
|
|
58
|
+
threadId?: string;
|
|
58
59
|
status: "waiting" | "running" | "completed" | "failed" | "aborted";
|
|
59
60
|
phase: "discovery" | "queue" | "startup" | "run" | "output";
|
|
60
61
|
completionState?: string;
|
|
@@ -80,6 +81,20 @@ export interface SubagentDetails {
|
|
|
80
81
|
};
|
|
81
82
|
}
|
|
82
83
|
|
|
84
|
+
export interface SubagentThread {
|
|
85
|
+
id: string;
|
|
86
|
+
definition: AgentDefinition;
|
|
87
|
+
session: AgentSession;
|
|
88
|
+
cwd: string;
|
|
89
|
+
model: string;
|
|
90
|
+
thinkingLevel: string;
|
|
91
|
+
initialTask: string;
|
|
92
|
+
turns: number;
|
|
93
|
+
turnGate: FifoGate;
|
|
94
|
+
pendingTurns: number;
|
|
95
|
+
lastUsedAt: number;
|
|
96
|
+
}
|
|
97
|
+
|
|
83
98
|
export function extensionPathsForTools(pi: ExtensionAPI, tools: readonly string[]): string[] {
|
|
84
99
|
const selected = new Set(tools);
|
|
85
100
|
return [
|
|
@@ -154,31 +169,26 @@ function textOf(message: AssistantMessage): string {
|
|
|
154
169
|
.join("");
|
|
155
170
|
}
|
|
156
171
|
|
|
157
|
-
function
|
|
158
|
-
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
export async function runSubagent(options: {
|
|
172
|
+
export async function createSubagentThread(options: {
|
|
173
|
+
id: string;
|
|
162
174
|
definition: AgentDefinition;
|
|
163
175
|
extensionPaths: readonly string[];
|
|
164
|
-
|
|
176
|
+
initialTask: string;
|
|
165
177
|
ctx: ExtensionContext;
|
|
166
178
|
thinkingLevel: string;
|
|
167
179
|
signal: AbortSignal;
|
|
168
|
-
onUpdate?: (details: SubagentDetails) => void | Promise<void>;
|
|
169
180
|
onWarning?: (warning: string) => void;
|
|
170
|
-
}): Promise<
|
|
181
|
+
}): Promise<SubagentThread> {
|
|
171
182
|
const {
|
|
183
|
+
id,
|
|
172
184
|
definition,
|
|
173
185
|
extensionPaths,
|
|
174
|
-
|
|
186
|
+
initialTask,
|
|
175
187
|
ctx,
|
|
176
188
|
thinkingLevel: parentThinkingLevel,
|
|
177
189
|
signal,
|
|
178
|
-
onUpdate,
|
|
179
190
|
onWarning,
|
|
180
191
|
} = options;
|
|
181
|
-
const started = Date.now();
|
|
182
192
|
let model = ctx.model;
|
|
183
193
|
let thinkingLevel = parentThinkingLevel;
|
|
184
194
|
if (definition.model) {
|
|
@@ -205,34 +215,7 @@ export async function runSubagent(options: {
|
|
|
205
215
|
else thinkingLevel = definition.thinking;
|
|
206
216
|
}
|
|
207
217
|
const modelName = model ? `${model.provider}/${model.id}` : "unavailable";
|
|
208
|
-
const usage: SubagentUsage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 };
|
|
209
|
-
const details: SubagentDetails = {
|
|
210
|
-
agent: definition.name,
|
|
211
|
-
status: "running",
|
|
212
|
-
phase: "startup",
|
|
213
|
-
task,
|
|
214
|
-
model: modelName,
|
|
215
|
-
thinkingLevel,
|
|
216
|
-
toolCalls: 0,
|
|
217
|
-
actions: [],
|
|
218
|
-
omittedActions: 0,
|
|
219
|
-
omittedErrors: 0,
|
|
220
|
-
usage,
|
|
221
|
-
durationMs: 0,
|
|
222
|
-
};
|
|
223
218
|
let session: AgentSession | undefined;
|
|
224
|
-
let unsubscribe: (() => void) | undefined;
|
|
225
|
-
let updateChain = Promise.resolve();
|
|
226
|
-
let lastTextUpdate = 0;
|
|
227
|
-
const publish = (force = false) => {
|
|
228
|
-
const now = Date.now();
|
|
229
|
-
if (!force && now - lastTextUpdate < 100) return;
|
|
230
|
-
lastTextUpdate = now;
|
|
231
|
-
details.durationMs = now - started;
|
|
232
|
-
const snapshot = structuredClone(details);
|
|
233
|
-
snapshot.actions = snapshot.actions.slice(-5);
|
|
234
|
-
updateChain = updateChain.then(() => onUpdate?.(snapshot)).catch(() => undefined);
|
|
235
|
-
};
|
|
236
219
|
try {
|
|
237
220
|
if (!model) throw new Error(`Agent ${definition.name} startup failed: parent has no model`);
|
|
238
221
|
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
@@ -267,12 +250,78 @@ export async function runSubagent(options: {
|
|
|
267
250
|
`Agent ${definition.name} startup failed: unavailable tools: ${missing.join(", ") || "active tool mismatch"}`,
|
|
268
251
|
);
|
|
269
252
|
}
|
|
270
|
-
|
|
253
|
+
return {
|
|
254
|
+
id,
|
|
255
|
+
definition,
|
|
256
|
+
session,
|
|
257
|
+
cwd: ctx.cwd,
|
|
258
|
+
model: modelName,
|
|
259
|
+
thinkingLevel,
|
|
260
|
+
initialTask,
|
|
261
|
+
turns: 0,
|
|
262
|
+
turnGate: new FifoGate(1),
|
|
263
|
+
pendingTurns: 0,
|
|
264
|
+
lastUsedAt: Date.now(),
|
|
265
|
+
};
|
|
266
|
+
} catch (error) {
|
|
267
|
+
if (session?.isStreaming) await session.abort().catch(() => undefined);
|
|
268
|
+
session?.dispose();
|
|
269
|
+
throw error;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export async function disposeSubagentThread(thread: SubagentThread): Promise<void> {
|
|
274
|
+
if (thread.session.isStreaming) await thread.session.abort().catch(() => undefined);
|
|
275
|
+
thread.session.dispose();
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export async function runSubagentTurn(options: {
|
|
279
|
+
thread: SubagentThread;
|
|
280
|
+
task: string;
|
|
281
|
+
initial: boolean;
|
|
282
|
+
signal: AbortSignal;
|
|
283
|
+
onUpdate?: (details: SubagentDetails) => void | Promise<void>;
|
|
284
|
+
}): Promise<{ content: string; details: SubagentDetails }> {
|
|
285
|
+
const { thread, task, initial, signal, onUpdate } = options;
|
|
286
|
+
const { definition, session } = thread;
|
|
287
|
+
const started = Date.now();
|
|
288
|
+
const usage: SubagentUsage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 };
|
|
289
|
+
const details: SubagentDetails = {
|
|
290
|
+
agent: definition.name,
|
|
291
|
+
threadId: thread.id,
|
|
292
|
+
status: "running",
|
|
293
|
+
phase: "run",
|
|
294
|
+
task,
|
|
295
|
+
model: thread.model,
|
|
296
|
+
thinkingLevel: thread.thinkingLevel,
|
|
297
|
+
toolCalls: 0,
|
|
298
|
+
actions: [],
|
|
299
|
+
omittedActions: 0,
|
|
300
|
+
omittedErrors: 0,
|
|
301
|
+
usage,
|
|
302
|
+
durationMs: 0,
|
|
303
|
+
};
|
|
304
|
+
let unsubscribe: (() => void) | undefined;
|
|
305
|
+
let updateChain = Promise.resolve();
|
|
306
|
+
let lastTextUpdate = 0;
|
|
307
|
+
const turnMessages: AssistantMessage[] = [];
|
|
308
|
+
const publish = (force = false) => {
|
|
309
|
+
const now = Date.now();
|
|
310
|
+
if (!force && now - lastTextUpdate < 100) return;
|
|
311
|
+
lastTextUpdate = now;
|
|
312
|
+
details.durationMs = now - started;
|
|
313
|
+
const snapshot = structuredClone(details);
|
|
314
|
+
snapshot.actions = snapshot.actions.slice(-5);
|
|
315
|
+
updateChain = updateChain.then(() => onUpdate?.(snapshot)).catch(() => undefined);
|
|
316
|
+
};
|
|
317
|
+
try {
|
|
271
318
|
const actionById = new Map<string, string>();
|
|
272
319
|
unsubscribe = session.subscribe((event: AgentSessionEvent) => {
|
|
273
320
|
if (event.type === "message_update" && event.message.role === "assistant") {
|
|
274
321
|
details.response = capped(textOf(event.message), PREVIEW_LIMIT);
|
|
275
322
|
publish();
|
|
323
|
+
} else if (event.type === "message_end" && event.message.role === "assistant") {
|
|
324
|
+
turnMessages.push(event.message);
|
|
276
325
|
} else if (event.type === "tool_execution_start") {
|
|
277
326
|
details.toolCalls += 1;
|
|
278
327
|
const summary = `${event.toolName} ${capped(event.args)}`.trim();
|
|
@@ -302,14 +351,15 @@ export async function runSubagent(options: {
|
|
|
302
351
|
signal.addEventListener("abort", abort, { once: true });
|
|
303
352
|
try {
|
|
304
353
|
await session.prompt(
|
|
305
|
-
|
|
354
|
+
initial
|
|
355
|
+
? `You are an isolated delegated child agent. Stay within the delegated task and return only the requested result.\n\n## Agent instructions\n${definition.prompt}\n\n## Delegated task\n${task}`
|
|
356
|
+
: `Continue the existing delegated work using the context already in this thread. Return only the requested result.\n\n## Parent follow-up\n${task}`,
|
|
306
357
|
{ expandPromptTemplates: false },
|
|
307
358
|
);
|
|
308
359
|
} finally {
|
|
309
360
|
signal.removeEventListener("abort", abort);
|
|
310
361
|
}
|
|
311
|
-
const
|
|
312
|
-
for (const message of messages) {
|
|
362
|
+
for (const message of turnMessages) {
|
|
313
363
|
usage.input += message.usage.input;
|
|
314
364
|
usage.output += message.usage.output;
|
|
315
365
|
usage.cacheRead += message.usage.cacheRead;
|
|
@@ -317,7 +367,7 @@ export async function runSubagent(options: {
|
|
|
317
367
|
usage.cost += message.usage.cost.total;
|
|
318
368
|
usage.turns += 1;
|
|
319
369
|
}
|
|
320
|
-
const terminal =
|
|
370
|
+
const terminal = turnMessages.at(-1);
|
|
321
371
|
if (!terminal) throw new Error(`Agent ${definition.name} run failed: no terminal assistant response`);
|
|
322
372
|
const response = textOf(terminal);
|
|
323
373
|
if (terminal.stopReason === "aborted") {
|
|
@@ -374,7 +424,8 @@ export async function runSubagent(options: {
|
|
|
374
424
|
return { content: details.error, details };
|
|
375
425
|
} finally {
|
|
376
426
|
unsubscribe?.();
|
|
377
|
-
|
|
378
|
-
|
|
427
|
+
thread.turns += 1;
|
|
428
|
+
thread.lastUsedAt = Date.now();
|
|
429
|
+
if (session.isStreaming) await session.abort().catch(() => undefined);
|
|
379
430
|
}
|
|
380
431
|
}
|
|
@@ -28,7 +28,7 @@ Adds `/commit` for semantic commit grouping, review, and committing selected rep
|
|
|
28
28
|
|
|
29
29
|
## context
|
|
30
30
|
|
|
31
|
-
Adds `/context` to select reusable repository work scopes from `.pi/contexts`, and `/context-sync` to reconcile affected scopes from current Git changes. Context validation is disabled by default; when enabled, Tau checks changed-file membership and stale references after agent turns. Folder names are tabs, TOML files are concepts, and TOML sections are selectable entries.
|
|
31
|
+
Adds `/context` to select reusable repository work scopes from `.pi/contexts`, and `/context-sync` to reconcile affected scopes from current Git changes. Entry `files` are autoread; entry `anchors` are unloaded navigation paths the agent can inspect when needed. Context validation is disabled by default; when enabled, Tau checks both classes for changed-file membership and stale references after agent turns. Folder names are tabs, TOML files are concepts, and TOML sections are selectable entries.
|
|
32
32
|
|
|
33
33
|
## explore
|
|
34
34
|
|
|
@@ -92,7 +92,7 @@ Adds `Alt+S` to stash the current prompt draft and `/pop` to browse stashed draf
|
|
|
92
92
|
|
|
93
93
|
## subagent
|
|
94
94
|
|
|
95
|
-
Gives Tau a subagent delegation tool for isolated, focused work. You can also create your own subagents in the supported subagent directories. Ask Tau how to do it and have it consult the extension’s own documentation; the built-in
|
|
95
|
+
Gives Tau a subagent delegation tool for isolated, focused work. Tau can continue a retained child thread when follow-up work depends on its prior reads and reasoning. You can also create your own subagents in the supported subagent directories. Ask Tau how to do it and have it consult the extension’s own documentation; the built-in agents show the pattern. Each subagent can register its own model and the tools it is allowed to use.
|
|
96
96
|
|
|
97
97
|
## tau-help
|
|
98
98
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shanepadgett/tau-agent",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.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.14.0",
|
|
32
32
|
"@toon-format/toon": "2.3.0",
|
|
33
33
|
"smol-toml": "1.7.0"
|
|
34
34
|
},
|