@shanepadgett/tau-agent 0.12.0 → 0.13.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 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
@@ -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 { extensionPathsForTools, FifoGate, runSubagent, type SubagentDetails } from "./run.ts";
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.Object(
9
- { agent: Type.String({ minLength: 1 }), task: Type.String({ minLength: 1 }) },
10
- { additionalProperties: false },
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 prompt = `## Subagents\nUse \`subagent\` when an available agent matches a focused part of the task.\n\nAvailable agents for this turn:\n${lines.join("\n")}\n\nDelegate 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.`;
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
- "Delegate one focused task to a named isolated child agent. Output is limited to 50KB or 2,000 lines.",
52
- promptSnippet: "Delegate one focused task to a named isolated child agent",
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 thinkingLevel = pi.getThinkingLevel();
59
- if (!agent || !task)
60
- return {
61
- content: [{ type: "text", text: "Subagent input requires non-empty agent and task" }],
62
- details: {
63
- agent,
64
- task,
65
- status: "failed",
66
- phase: "queue",
67
- model: ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "unavailable",
68
- thinkingLevel,
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 release: (() => void) | undefined;
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
- await onUpdate?.({
111
- content: [{ type: "text", text: `${agent}: waiting` }],
112
- details: {
113
- agent,
114
- task,
115
- status: "waiting",
116
- phase: "queue",
117
- model: ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "unavailable",
118
- thinkingLevel,
119
- toolCalls: 0,
120
- actions: [],
121
- omittedActions: 0,
122
- omittedErrors: 0,
123
- usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 },
124
- durationMs: 0,
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
- const result = await runSubagent({
149
- definition,
150
- extensionPaths: extensionPathsForTools(pi, definition.tools),
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
- ctx,
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 { content: [{ type: "text", text: result.content }], details: result.details };
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
- release?.();
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 header = `${title(theme, context.rowState, context.rowId, context.invalidate)} ${theme.fg("accent", details.agent)} ${theme.fg("muted", `$${details.usage.cost.toFixed(4)} · ${(details.durationMs / 1000).toFixed(1)}s · ${details.toolCalls} tools`)}`;
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 assistants(session: AgentSession): AssistantMessage[] {
158
- return session.messages.filter((message): message is AssistantMessage => message.role === "assistant");
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
- task: string;
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<{ content: string; details: SubagentDetails }> {
181
+ }): Promise<SubagentThread> {
171
182
  const {
183
+ id,
172
184
  definition,
173
185
  extensionPaths,
174
- task,
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
- details.phase = "run";
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
- `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}`,
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 messages = assistants(session);
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 = messages.at(-1);
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
- if (session?.isStreaming) await session.abort().catch(() => undefined);
378
- session?.dispose();
427
+ thread.turns += 1;
428
+ thread.lastUsedAt = Date.now();
429
+ if (session.isStreaming) await session.abort().catch(() => undefined);
379
430
  }
380
431
  }
@@ -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 `scout` and `web-research` subagents show the pattern. Each subagent can register its own model and the tools it is allowed to use.
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.12.0",
3
+ "version": "0.13.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.12.0",
31
+ "@shanepadgett/tau-tui": "0.13.0",
32
32
  "@toon-format/toon": "2.3.0",
33
33
  "smol-toml": "1.7.0"
34
34
  },