@xynogen/pix-subagent 0.1.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/LICENSE +27 -0
- package/README.md +114 -0
- package/package.json +46 -0
- package/src/agent-manager.ts +526 -0
- package/src/agent-runner.ts +849 -0
- package/src/agent-types.ts +152 -0
- package/src/context.ts +59 -0
- package/src/custom-agents.ts +165 -0
- package/src/default-agents.ts +126 -0
- package/src/env.ts +43 -0
- package/src/extension.ts +9 -0
- package/src/index.ts +216 -0
- package/src/invocation-config.ts +43 -0
- package/src/model-resolver.ts +98 -0
- package/src/once.ts +26 -0
- package/src/prompts.ts +111 -0
- package/src/tools.ts +822 -0
- package/src/types.ts +126 -0
- package/src/ui/notification.ts +75 -0
- package/src/ui/widget.ts +490 -0
- package/src/usage.ts +71 -0
package/src/tools.ts
ADDED
|
@@ -0,0 +1,822 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tools.ts — The 3 LLM-callable tool definitions:
|
|
3
|
+
* agent — spawn a sub-agent (fg or bg)
|
|
4
|
+
* agent_result — fetch latest output / full result by id
|
|
5
|
+
* agent_steer — inject a message into a running bg agent
|
|
6
|
+
*
|
|
7
|
+
* Design notes:
|
|
8
|
+
* - agent description is built dynamically at registration (live model + type list).
|
|
9
|
+
* - allowed_tools[] intersects the resolved tool set (never widens).
|
|
10
|
+
* - modelName is ALWAYS populated (the pix twist — shown even when same as parent).
|
|
11
|
+
* - renderCall/renderResult ported from tintinweb/pi-subagents (MIT).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { defineTool, getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
15
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
16
|
+
import { Type } from "@sinclair/typebox";
|
|
17
|
+
import type { AgentManager } from "./agent-manager.ts";
|
|
18
|
+
import {
|
|
19
|
+
getAgentConversation,
|
|
20
|
+
normalizeMaxTurns,
|
|
21
|
+
SUBAGENT_TOOL_NAMES,
|
|
22
|
+
} from "./agent-runner.ts";
|
|
23
|
+
import {
|
|
24
|
+
BUILTIN_TOOL_NAMES,
|
|
25
|
+
getAgentConfig,
|
|
26
|
+
getAvailableTypes,
|
|
27
|
+
getConfig,
|
|
28
|
+
} from "./agent-types.ts";
|
|
29
|
+
import { resolveAgentInvocationConfig } from "./invocation-config.ts";
|
|
30
|
+
import { resolveModel } from "./model-resolver.ts";
|
|
31
|
+
import type { AgentInvocation, LifetimeUsage } from "./types.ts";
|
|
32
|
+
import { getLifetimeTotal } from "./usage.ts";
|
|
33
|
+
|
|
34
|
+
// ── Types shared with ui/widget.ts (widget imports from here to avoid circular) ─
|
|
35
|
+
|
|
36
|
+
export type Theme = {
|
|
37
|
+
fg(color: string, text: string): string;
|
|
38
|
+
bold(text: string): string;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export interface AgentActivity {
|
|
42
|
+
activeTools: Map<string, string>;
|
|
43
|
+
toolUses: number;
|
|
44
|
+
responseText: string;
|
|
45
|
+
session?: unknown;
|
|
46
|
+
turnCount: number;
|
|
47
|
+
maxTurns?: number;
|
|
48
|
+
lifetimeUsage: LifetimeUsage;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface AgentDetails {
|
|
52
|
+
displayName: string;
|
|
53
|
+
description: string;
|
|
54
|
+
subagentType: string;
|
|
55
|
+
toolUses: number;
|
|
56
|
+
tokens: string;
|
|
57
|
+
durationMs: number;
|
|
58
|
+
status:
|
|
59
|
+
| "queued"
|
|
60
|
+
| "running"
|
|
61
|
+
| "completed"
|
|
62
|
+
| "steered"
|
|
63
|
+
| "aborted"
|
|
64
|
+
| "stopped"
|
|
65
|
+
| "error"
|
|
66
|
+
| "background";
|
|
67
|
+
activity?: string;
|
|
68
|
+
spinnerFrame?: number;
|
|
69
|
+
modelName?: string;
|
|
70
|
+
tags?: string[];
|
|
71
|
+
turnCount?: number;
|
|
72
|
+
maxTurns?: number;
|
|
73
|
+
agentId?: string;
|
|
74
|
+
error?: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ── Formatting helpers (also exported for ui/widget.ts) ──────────────────────
|
|
78
|
+
|
|
79
|
+
export const SPINNER = [
|
|
80
|
+
"\u280b",
|
|
81
|
+
"\u2819",
|
|
82
|
+
"\u2839",
|
|
83
|
+
"\u2838",
|
|
84
|
+
"\u283c",
|
|
85
|
+
"\u2834",
|
|
86
|
+
"\u2826",
|
|
87
|
+
"\u2827",
|
|
88
|
+
"\u2807",
|
|
89
|
+
"\u280f",
|
|
90
|
+
];
|
|
91
|
+
|
|
92
|
+
export function formatTokens(count: number): string {
|
|
93
|
+
if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M token`;
|
|
94
|
+
if (count >= 1_000) return `${(count / 1_000).toFixed(1)}k token`;
|
|
95
|
+
return `${count} token`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function formatTurns(
|
|
99
|
+
turnCount: number,
|
|
100
|
+
maxTurns?: number | null,
|
|
101
|
+
): string {
|
|
102
|
+
return maxTurns != null ? `↻${turnCount}≤${maxTurns}` : `↻${turnCount}`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function formatMs(ms: number): string {
|
|
106
|
+
return `${(ms / 1000).toFixed(1)}s`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ── helpers ──────────────────────────────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
function textResult(msg: string, details?: AgentDetails) {
|
|
112
|
+
return {
|
|
113
|
+
content: [{ type: "text" as const, text: msg }],
|
|
114
|
+
details: details as unknown,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Strip provider prefix + date suffix for a compact model label. e.g. "anthropic/claude-haiku-4-5-20251001" → "haiku-4-5" */
|
|
119
|
+
function shortModelLabel(model: {
|
|
120
|
+
provider: string;
|
|
121
|
+
id: string;
|
|
122
|
+
name?: string;
|
|
123
|
+
}): string {
|
|
124
|
+
// prefer name, strip "Claude " prefix
|
|
125
|
+
if (model.name) return model.name.replace(/^Claude\s+/i, "").toLowerCase();
|
|
126
|
+
const id = model.id.replace(/-\d{8}$/, ""); // strip date suffix
|
|
127
|
+
return id;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function buildStats(d: AgentDetails, theme: Theme): string {
|
|
131
|
+
const parts: string[] = [];
|
|
132
|
+
if (d.modelName) parts.push(theme.fg("muted", `[${d.modelName}]`));
|
|
133
|
+
if (d.tags) parts.push(...d.tags.map((t) => theme.fg("dim", t)));
|
|
134
|
+
if (d.turnCount != null && d.turnCount > 0)
|
|
135
|
+
parts.push(theme.fg("dim", formatTurns(d.turnCount, d.maxTurns)));
|
|
136
|
+
if (d.toolUses > 0)
|
|
137
|
+
parts.push(
|
|
138
|
+
theme.fg("dim", `${d.toolUses} tool use${d.toolUses === 1 ? "" : "s"}`),
|
|
139
|
+
);
|
|
140
|
+
if (d.tokens) parts.push(theme.fg("dim", d.tokens));
|
|
141
|
+
return parts.join(` ${theme.fg("dim", "·")} `);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ── tool description builder ─────────────────────────────────────────────────
|
|
145
|
+
|
|
146
|
+
export function buildAgentToolDescription(modelList: string[]): string {
|
|
147
|
+
const available = getAvailableTypes();
|
|
148
|
+
|
|
149
|
+
const typeList = available
|
|
150
|
+
.map((name) => {
|
|
151
|
+
const cfg = getAgentConfig(name);
|
|
152
|
+
const modelSuffix = cfg?.model ? ` (${cfg.model})` : "";
|
|
153
|
+
const tools = cfg?.builtinToolNames;
|
|
154
|
+
const toolsSuffix =
|
|
155
|
+
!tools || tools.length === BUILTIN_TOOL_NAMES.length
|
|
156
|
+
? " (Tools: *)"
|
|
157
|
+
: ` (Tools: ${tools.join(", ")})`;
|
|
158
|
+
return `- ${name}: ${cfg?.description ?? name}${modelSuffix}${toolsSuffix}`;
|
|
159
|
+
})
|
|
160
|
+
.join("\n");
|
|
161
|
+
|
|
162
|
+
const modelsText =
|
|
163
|
+
modelList.length > 0
|
|
164
|
+
? `\nAvailable models:\n${modelList.map((m) => ` ${m}`).join("\n")}`
|
|
165
|
+
: "";
|
|
166
|
+
|
|
167
|
+
const toolsText = `\nAvailable tools (for allowed_tools[]): ${BUILTIN_TOOL_NAMES.join(", ")}`;
|
|
168
|
+
|
|
169
|
+
return `Launch a new agent to handle complex, multi-step tasks autonomously. Each agent type has specific capabilities and tools.
|
|
170
|
+
|
|
171
|
+
Available agent types and their tools:
|
|
172
|
+
${typeList}
|
|
173
|
+
|
|
174
|
+
Custom agents can be defined in .pi/agents/<name>.md (project) or ${getAgentDir()}/agents/<name>.md (global) — picked up automatically. Project-level overrides global.
|
|
175
|
+
${modelsText}
|
|
176
|
+
${toolsText}
|
|
177
|
+
|
|
178
|
+
## When not to use
|
|
179
|
+
If the target is already known, use a direct tool — \`read\` for a known path, \`grep\`/\`find\` for a specific symbol. Reserve this tool for open-ended questions or tasks that span the codebase.
|
|
180
|
+
|
|
181
|
+
## Usage notes
|
|
182
|
+
- Always include a short (3-5 word) description summarizing what the agent will do (shown in UI).
|
|
183
|
+
- When you launch multiple agents for independent work, send them in a single message with multiple tool uses, with run_in_background: true on each, so they run concurrently.
|
|
184
|
+
- When the agent is done, it returns a single message. The result is not visible to the user — to show the user, send a text message with a concise summary.
|
|
185
|
+
- Trust but verify: an agent's summary describes what it intended to do, not what it did. When an agent writes or edits code, check the actual changes before reporting work as done.
|
|
186
|
+
- Use run_in_background for work you don't need immediately. You will be notified when it completes — do NOT poll or sleep waiting for it.
|
|
187
|
+
- Use resume with an agent ID to continue a previous agent's work.
|
|
188
|
+
- Use agent_steer to send mid-run messages to a running background agent.
|
|
189
|
+
- Use model to specify a model from the available models list above (provider/id or fuzzy e.g. "haiku").
|
|
190
|
+
- Use allowed_tools[] to restrict which tools the sub-agent can use (useful for scoping work). Omit for the agent type's default tool set.
|
|
191
|
+
- Use thinking to control extended thinking level: off|minimal|low|medium|high|xhigh.
|
|
192
|
+
- Use inherit_context if the agent needs the parent conversation history.
|
|
193
|
+
|
|
194
|
+
## Writing the prompt
|
|
195
|
+
Provide clear, detailed prompts so the agent can work autonomously. Brief it like a smart colleague who just walked into the room — it hasn't seen this conversation, doesn't know what you've tried.
|
|
196
|
+
- Explain what you're trying to accomplish and why.
|
|
197
|
+
- Include file paths, line numbers, what specifically to change.
|
|
198
|
+
- If you need a short response, say so.
|
|
199
|
+
|
|
200
|
+
**Never delegate understanding.** Write prompts that prove you understood: include file paths, line numbers, what specifically to change.`;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ── the 3 tools ──────────────────────────────────────────────────────────────
|
|
204
|
+
|
|
205
|
+
export function createAgentTool(
|
|
206
|
+
pi: Parameters<typeof manager.spawnAndWait>[0],
|
|
207
|
+
manager: AgentManager,
|
|
208
|
+
agentActivity: Map<string, AgentActivity>,
|
|
209
|
+
reloadCustomAgents: () => void,
|
|
210
|
+
modelList: string[],
|
|
211
|
+
) {
|
|
212
|
+
return defineTool({
|
|
213
|
+
name: SUBAGENT_TOOL_NAMES.AGENT,
|
|
214
|
+
label: "Agent",
|
|
215
|
+
description: buildAgentToolDescription(modelList),
|
|
216
|
+
promptSnippet: "Launch autonomous sub-agents for complex multi-step tasks",
|
|
217
|
+
|
|
218
|
+
parameters: Type.Object({
|
|
219
|
+
prompt: Type.String({
|
|
220
|
+
description: "The task for the agent to perform.",
|
|
221
|
+
}),
|
|
222
|
+
description: Type.String({
|
|
223
|
+
description:
|
|
224
|
+
"A short (3-5 word) description of the task (shown in UI).",
|
|
225
|
+
}),
|
|
226
|
+
subagent_type: Type.String({
|
|
227
|
+
description: `The type of specialized agent to use. Available: ${getAvailableTypes().join(", ")}. Custom agents from .pi/agents/*.md are also available.`,
|
|
228
|
+
}),
|
|
229
|
+
model: Type.Optional(
|
|
230
|
+
Type.String({
|
|
231
|
+
description:
|
|
232
|
+
'Optional model override. Accepts "provider/id" or fuzzy name (e.g. "haiku", "sonnet"). Must be in the available models list.',
|
|
233
|
+
}),
|
|
234
|
+
),
|
|
235
|
+
allowed_tools: Type.Optional(
|
|
236
|
+
Type.Array(Type.String(), {
|
|
237
|
+
description: `Restrict the sub-agent to a subset of tools. Intersected with the agent type's default set (never widens). Available: ${BUILTIN_TOOL_NAMES.join(", ")}. Omit for the type's full default set.`,
|
|
238
|
+
}),
|
|
239
|
+
),
|
|
240
|
+
thinking: Type.Optional(
|
|
241
|
+
Type.String({
|
|
242
|
+
description: "Thinking level: off|minimal|low|medium|high|xhigh.",
|
|
243
|
+
}),
|
|
244
|
+
),
|
|
245
|
+
max_turns: Type.Optional(
|
|
246
|
+
Type.Number({
|
|
247
|
+
description:
|
|
248
|
+
"Maximum agentic turns before stopping. Omit for unlimited.",
|
|
249
|
+
minimum: 1,
|
|
250
|
+
}),
|
|
251
|
+
),
|
|
252
|
+
run_in_background: Type.Optional(
|
|
253
|
+
Type.Boolean({
|
|
254
|
+
description:
|
|
255
|
+
"true = background (returns ID immediately, notifies on completion). false (default) = foreground (streams inline).",
|
|
256
|
+
}),
|
|
257
|
+
),
|
|
258
|
+
resume: Type.Optional(
|
|
259
|
+
Type.String({
|
|
260
|
+
description: "Agent ID to resume from. Continues previous context.",
|
|
261
|
+
}),
|
|
262
|
+
),
|
|
263
|
+
isolated: Type.Optional(
|
|
264
|
+
Type.Boolean({
|
|
265
|
+
description: "true = no extension/MCP tools, builtins only.",
|
|
266
|
+
}),
|
|
267
|
+
),
|
|
268
|
+
inherit_context: Type.Optional(
|
|
269
|
+
Type.Boolean({
|
|
270
|
+
description: "true = fork parent conversation into the sub-agent.",
|
|
271
|
+
}),
|
|
272
|
+
),
|
|
273
|
+
}),
|
|
274
|
+
|
|
275
|
+
renderCall(args, theme) {
|
|
276
|
+
const displayName = args.subagent_type
|
|
277
|
+
? getConfig(args.subagent_type as string).displayName
|
|
278
|
+
: "Agent";
|
|
279
|
+
const desc = args.description ?? "";
|
|
280
|
+
return new Text(
|
|
281
|
+
"▸ " +
|
|
282
|
+
theme.fg("toolTitle", theme.bold(displayName)) +
|
|
283
|
+
(desc ? ` ${theme.fg("muted", desc as string)}` : ""),
|
|
284
|
+
0,
|
|
285
|
+
0,
|
|
286
|
+
);
|
|
287
|
+
},
|
|
288
|
+
|
|
289
|
+
renderResult(result, { expanded, isPartial }, theme) {
|
|
290
|
+
const details = result.details as AgentDetails | undefined;
|
|
291
|
+
if (!details) {
|
|
292
|
+
const text =
|
|
293
|
+
result.content[0]?.type === "text" ? result.content[0].text : "";
|
|
294
|
+
return new Text(text, 0, 0);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const stats = buildStats(details, theme);
|
|
298
|
+
|
|
299
|
+
// Streaming / running
|
|
300
|
+
if (isPartial || details.status === "running") {
|
|
301
|
+
const frame = SPINNER[details.spinnerFrame ?? 0];
|
|
302
|
+
let line = theme.fg("accent", frame) + (stats ? ` ${stats}` : "");
|
|
303
|
+
line += `\n${theme.fg("dim", ` ⎿ ${details.activity ?? "thinking…"}`)}`;
|
|
304
|
+
return new Text(line, 0, 0);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Background launched
|
|
308
|
+
if (details.status === "background") {
|
|
309
|
+
return new Text(
|
|
310
|
+
theme.fg(
|
|
311
|
+
"dim",
|
|
312
|
+
` ⎿ Running in background (ID: ${details.agentId})`,
|
|
313
|
+
),
|
|
314
|
+
0,
|
|
315
|
+
0,
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Completed / steered
|
|
320
|
+
if (details.status === "completed" || details.status === "steered") {
|
|
321
|
+
const duration = formatMs(details.durationMs);
|
|
322
|
+
const isSteered = details.status === "steered";
|
|
323
|
+
const icon = isSteered
|
|
324
|
+
? theme.fg("warning", "✓")
|
|
325
|
+
: theme.fg("success", "✓");
|
|
326
|
+
let line =
|
|
327
|
+
icon +
|
|
328
|
+
(stats ? ` ${stats}` : "") +
|
|
329
|
+
" " +
|
|
330
|
+
theme.fg("dim", "·") +
|
|
331
|
+
" " +
|
|
332
|
+
theme.fg("dim", duration);
|
|
333
|
+
|
|
334
|
+
if (expanded) {
|
|
335
|
+
const resultText =
|
|
336
|
+
result.content[0]?.type === "text" ? result.content[0].text : "";
|
|
337
|
+
if (resultText) {
|
|
338
|
+
const lines = resultText.split("\n").slice(0, 50);
|
|
339
|
+
for (const l of lines) line += `\n${theme.fg("dim", ` ${l}`)}`;
|
|
340
|
+
if (resultText.split("\n").length > 50)
|
|
341
|
+
line +=
|
|
342
|
+
"\n" +
|
|
343
|
+
theme.fg(
|
|
344
|
+
"muted",
|
|
345
|
+
" … (use agent_result with verbose for full output)",
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
} else {
|
|
349
|
+
line +=
|
|
350
|
+
"\n" +
|
|
351
|
+
theme.fg(
|
|
352
|
+
"dim",
|
|
353
|
+
` ⎿ ${isSteered ? "Wrapped up (turn limit)" : "Done"}`,
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
return new Text(line, 0, 0);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Stopped
|
|
360
|
+
if (details.status === "stopped") {
|
|
361
|
+
let line = theme.fg("dim", "■") + (stats ? ` ${stats}` : "");
|
|
362
|
+
line += `\n${theme.fg("dim", " ⎿ Stopped")}`;
|
|
363
|
+
return new Text(line, 0, 0);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// Error / aborted
|
|
367
|
+
let line = theme.fg("error", "✗") + (stats ? ` ${stats}` : "");
|
|
368
|
+
if (details.status === "error")
|
|
369
|
+
line += `\n${theme.fg("error", ` ⎿ Error: ${details.error ?? "unknown"}`)}`;
|
|
370
|
+
else
|
|
371
|
+
line += `\n${theme.fg("warning", " ⎿ Aborted (max turns exceeded)")}`;
|
|
372
|
+
return new Text(line, 0, 0);
|
|
373
|
+
},
|
|
374
|
+
|
|
375
|
+
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
376
|
+
reloadCustomAgents();
|
|
377
|
+
|
|
378
|
+
// Resolve agent type
|
|
379
|
+
const rawType = params.subagent_type as string;
|
|
380
|
+
const resolvedKey =
|
|
381
|
+
getAvailableTypes().find(
|
|
382
|
+
(t) => t.toLowerCase() === rawType.toLowerCase(),
|
|
383
|
+
) ?? rawType;
|
|
384
|
+
const subagentType = getAvailableTypes().includes(resolvedKey)
|
|
385
|
+
? resolvedKey
|
|
386
|
+
: "general-purpose";
|
|
387
|
+
const fellBack =
|
|
388
|
+
subagentType === "general-purpose" && resolvedKey !== "general-purpose";
|
|
389
|
+
|
|
390
|
+
const displayName = getConfig(subagentType).displayName;
|
|
391
|
+
const customConfig = getAgentConfig(subagentType);
|
|
392
|
+
const resolvedConfig = resolveAgentInvocationConfig(customConfig, {
|
|
393
|
+
model: params.model as string | undefined,
|
|
394
|
+
thinking: params.thinking as string | undefined,
|
|
395
|
+
max_turns: params.max_turns as number | undefined,
|
|
396
|
+
run_in_background: params.run_in_background as boolean | undefined,
|
|
397
|
+
inherit_context: params.inherit_context as boolean | undefined,
|
|
398
|
+
isolated: params.isolated as boolean | undefined,
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
// Resolve model — ALWAYS compute modelName (the pix twist)
|
|
402
|
+
let model = ctx.model;
|
|
403
|
+
let modelName: string | undefined;
|
|
404
|
+
if (resolvedConfig.modelInput) {
|
|
405
|
+
const resolved = resolveModel(
|
|
406
|
+
resolvedConfig.modelInput,
|
|
407
|
+
ctx.modelRegistry,
|
|
408
|
+
);
|
|
409
|
+
if (typeof resolved === "string") {
|
|
410
|
+
// Model not found — return error to planner so it can re-pick
|
|
411
|
+
if (resolvedConfig.modelFromParams) return textResult(resolved);
|
|
412
|
+
// Config-specified but unavailable: silent fallback to parent
|
|
413
|
+
} else {
|
|
414
|
+
model = resolved;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
// Always set modelName (the twist: visible even when same as parent)
|
|
418
|
+
if (model) modelName = shortModelLabel(model);
|
|
419
|
+
|
|
420
|
+
const thinking = resolvedConfig.thinking;
|
|
421
|
+
const inheritContext = resolvedConfig.inheritContext;
|
|
422
|
+
const runInBackground = resolvedConfig.runInBackground;
|
|
423
|
+
const isolated = resolvedConfig.isolated;
|
|
424
|
+
const effectiveMaxTurns = normalizeMaxTurns(resolvedConfig.maxTurns);
|
|
425
|
+
|
|
426
|
+
// Build invocation snapshot (for widget + notification)
|
|
427
|
+
const agentInvocation: AgentInvocation = {
|
|
428
|
+
modelName, // always set
|
|
429
|
+
thinking,
|
|
430
|
+
maxTurns: normalizeMaxTurns(resolvedConfig.maxTurns),
|
|
431
|
+
isolated,
|
|
432
|
+
inheritContext,
|
|
433
|
+
runInBackground,
|
|
434
|
+
};
|
|
435
|
+
|
|
436
|
+
const detailBase = {
|
|
437
|
+
displayName,
|
|
438
|
+
description: params.description as string,
|
|
439
|
+
subagentType,
|
|
440
|
+
modelName, // pix twist: always pass through
|
|
441
|
+
tags: [] as string[],
|
|
442
|
+
};
|
|
443
|
+
|
|
444
|
+
if (falling_back_note(fellBack))
|
|
445
|
+
detailBase.tags.push("(unknown type → general-purpose)");
|
|
446
|
+
if (thinking) detailBase.tags.push(`thinking: ${thinking}`);
|
|
447
|
+
if (isolated) detailBase.tags.push("isolated");
|
|
448
|
+
|
|
449
|
+
// Resume existing agent
|
|
450
|
+
if (params.resume) {
|
|
451
|
+
const existing = manager.getRecord(params.resume as string);
|
|
452
|
+
if (!existing)
|
|
453
|
+
return textResult(
|
|
454
|
+
`Agent not found: "${params.resume}". It may have been cleaned up.`,
|
|
455
|
+
);
|
|
456
|
+
if (!existing.session)
|
|
457
|
+
return textResult(
|
|
458
|
+
`Agent "${params.resume}" has no active session to resume.`,
|
|
459
|
+
);
|
|
460
|
+
const record = await manager.resume(
|
|
461
|
+
params.resume as string,
|
|
462
|
+
params.prompt as string,
|
|
463
|
+
signal,
|
|
464
|
+
);
|
|
465
|
+
if (!record)
|
|
466
|
+
return textResult(`Failed to resume agent "${params.resume}".`);
|
|
467
|
+
return textResult(
|
|
468
|
+
record.result?.trim() || record.error?.trim() || "No output.",
|
|
469
|
+
buildDetails(detailBase, record),
|
|
470
|
+
);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// Validate + build allowed_tools list
|
|
474
|
+
const rawAllowed = params.allowed_tools as string[] | undefined;
|
|
475
|
+
let allowedToolNames: string[] | undefined;
|
|
476
|
+
if (rawAllowed) {
|
|
477
|
+
const knownSet = new Set([...BUILTIN_TOOL_NAMES]);
|
|
478
|
+
const unknown = rawAllowed.filter((t) => !knownSet.has(t));
|
|
479
|
+
// Warn about unknown names but proceed with the valid subset
|
|
480
|
+
const valid = rawAllowed.filter((t) => knownSet.has(t));
|
|
481
|
+
if (unknown.length > 0) {
|
|
482
|
+
const note = `(unknown tool names ignored: ${unknown.join(", ")})`;
|
|
483
|
+
detailBase.tags.push(note);
|
|
484
|
+
}
|
|
485
|
+
allowedToolNames = valid.length > 0 ? valid : undefined;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// Background execution
|
|
489
|
+
if (runInBackground) {
|
|
490
|
+
const { state: bgState, callbacks: bgCallbacks } =
|
|
491
|
+
createActivityTracker(effectiveMaxTurns, () => {
|
|
492
|
+
agentActivity.set(id, bgState);
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
let id: string;
|
|
496
|
+
try {
|
|
497
|
+
id = manager.spawn(pi, ctx, subagentType, params.prompt as string, {
|
|
498
|
+
description: params.description as string,
|
|
499
|
+
model,
|
|
500
|
+
maxTurns: effectiveMaxTurns,
|
|
501
|
+
isolated,
|
|
502
|
+
inheritContext,
|
|
503
|
+
thinkingLevel: thinking,
|
|
504
|
+
isBackground: true,
|
|
505
|
+
invocation: agentInvocation,
|
|
506
|
+
allowedToolNames,
|
|
507
|
+
...bgCallbacks,
|
|
508
|
+
});
|
|
509
|
+
} catch (err) {
|
|
510
|
+
return textResult(err instanceof Error ? err.message : String(err));
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
agentActivity.set(id, bgState);
|
|
514
|
+
|
|
515
|
+
return textResult(
|
|
516
|
+
`Running in background (ID: ${id}). Use agent_result to check progress or agent_steer to redirect.`,
|
|
517
|
+
{
|
|
518
|
+
...detailBase,
|
|
519
|
+
toolUses: 0,
|
|
520
|
+
tokens: "",
|
|
521
|
+
durationMs: 0,
|
|
522
|
+
status: "background",
|
|
523
|
+
agentId: id,
|
|
524
|
+
},
|
|
525
|
+
);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// Foreground execution — streams via onUpdate
|
|
529
|
+
const { state: fgState, callbacks: fgCallbacks } = createActivityTracker(
|
|
530
|
+
effectiveMaxTurns,
|
|
531
|
+
() => {
|
|
532
|
+
if (!onUpdate) return;
|
|
533
|
+
onUpdate({
|
|
534
|
+
content: [{ type: "text", text: fgState.responseText }],
|
|
535
|
+
details: buildDetails(detailBase, {
|
|
536
|
+
...fgState,
|
|
537
|
+
status: "running",
|
|
538
|
+
toolUses: fgState.toolUses,
|
|
539
|
+
startedAt: Date.now() - fgState.durationMs,
|
|
540
|
+
}) as unknown,
|
|
541
|
+
});
|
|
542
|
+
},
|
|
543
|
+
);
|
|
544
|
+
|
|
545
|
+
const record = await manager.spawnAndWait(
|
|
546
|
+
pi,
|
|
547
|
+
ctx,
|
|
548
|
+
subagentType,
|
|
549
|
+
params.prompt as string,
|
|
550
|
+
{
|
|
551
|
+
description: params.description as string,
|
|
552
|
+
model,
|
|
553
|
+
maxTurns: effectiveMaxTurns,
|
|
554
|
+
isolated,
|
|
555
|
+
inheritContext,
|
|
556
|
+
thinkingLevel: thinking,
|
|
557
|
+
invocation: agentInvocation,
|
|
558
|
+
allowedToolNames,
|
|
559
|
+
...fgCallbacks,
|
|
560
|
+
},
|
|
561
|
+
);
|
|
562
|
+
|
|
563
|
+
const resultText =
|
|
564
|
+
record.result?.trim() || record.error?.trim() || "No output.";
|
|
565
|
+
return textResult(resultText, buildDetails(detailBase, record, fgState));
|
|
566
|
+
},
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// ── agent_result tool ────────────────────────────────────────────────────────
|
|
571
|
+
|
|
572
|
+
export function createAgentResultTool(
|
|
573
|
+
manager: AgentManager,
|
|
574
|
+
agentActivity: Map<string, AgentActivity>,
|
|
575
|
+
) {
|
|
576
|
+
return defineTool({
|
|
577
|
+
name: SUBAGENT_TOOL_NAMES.GET_RESULT,
|
|
578
|
+
label: "Agent Result",
|
|
579
|
+
description:
|
|
580
|
+
"Fetch the latest output or full result of a background agent by ID. Call this to retrieve what a background agent produced. Sets resultConsumed so the completion notification is suppressed.",
|
|
581
|
+
parameters: Type.Object({
|
|
582
|
+
agent_id: Type.String({
|
|
583
|
+
description: "The agent ID returned by the agent tool.",
|
|
584
|
+
}),
|
|
585
|
+
verbose: Type.Optional(
|
|
586
|
+
Type.Boolean({
|
|
587
|
+
description:
|
|
588
|
+
"true = full conversation history; false (default) = latest assistant text only.",
|
|
589
|
+
}),
|
|
590
|
+
),
|
|
591
|
+
}),
|
|
592
|
+
|
|
593
|
+
renderCall(args, theme) {
|
|
594
|
+
return new Text(
|
|
595
|
+
theme.fg("toolTitle", theme.bold("agent_result ")) +
|
|
596
|
+
theme.fg("accent", args.agent_id as string),
|
|
597
|
+
0,
|
|
598
|
+
0,
|
|
599
|
+
);
|
|
600
|
+
},
|
|
601
|
+
|
|
602
|
+
async execute(_toolCallId, params) {
|
|
603
|
+
const id = params.agent_id as string;
|
|
604
|
+
const record = manager.getRecord(id);
|
|
605
|
+
if (!record) {
|
|
606
|
+
return {
|
|
607
|
+
content: [
|
|
608
|
+
{
|
|
609
|
+
type: "text" as const,
|
|
610
|
+
text: `Agent not found: "${id}". It may have been cleaned up or the ID is wrong.`,
|
|
611
|
+
},
|
|
612
|
+
],
|
|
613
|
+
details: undefined as unknown,
|
|
614
|
+
};
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// Suppress the pending completion nudge (agent_result consumed it)
|
|
618
|
+
record.resultConsumed = true;
|
|
619
|
+
|
|
620
|
+
if (params.verbose && record.session) {
|
|
621
|
+
const convo = getAgentConversation(record.session);
|
|
622
|
+
return {
|
|
623
|
+
content: [
|
|
624
|
+
{
|
|
625
|
+
type: "text" as const,
|
|
626
|
+
text: convo || "No conversation history yet.",
|
|
627
|
+
},
|
|
628
|
+
],
|
|
629
|
+
details: undefined as unknown,
|
|
630
|
+
};
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
const activity = agentActivity.get(id);
|
|
634
|
+
const text =
|
|
635
|
+
record.status === "running"
|
|
636
|
+
? activity?.responseText?.trim() ||
|
|
637
|
+
"Agent is still running. No output yet."
|
|
638
|
+
: record.result?.trim() || record.error?.trim() || "No output.";
|
|
639
|
+
return {
|
|
640
|
+
content: [{ type: "text" as const, text }],
|
|
641
|
+
details: undefined as unknown,
|
|
642
|
+
};
|
|
643
|
+
},
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// ── agent_steer tool ─────────────────────────────────────────────────────────
|
|
648
|
+
|
|
649
|
+
export function createAgentSteerTool(manager: AgentManager) {
|
|
650
|
+
return defineTool({
|
|
651
|
+
name: SUBAGENT_TOOL_NAMES.STEER,
|
|
652
|
+
label: "Steer Agent",
|
|
653
|
+
description:
|
|
654
|
+
"Inject a steering message into a running background agent to redirect its work without restarting. The message is delivered after the agent's current tool execution completes.",
|
|
655
|
+
parameters: Type.Object({
|
|
656
|
+
agent_id: Type.String({ description: "The agent ID to steer." }),
|
|
657
|
+
message: Type.String({ description: "The steering message to inject." }),
|
|
658
|
+
}),
|
|
659
|
+
|
|
660
|
+
renderCall(args, theme) {
|
|
661
|
+
return new Text(
|
|
662
|
+
theme.fg("toolTitle", theme.bold("agent_steer ")) +
|
|
663
|
+
theme.fg("accent", args.agent_id as string),
|
|
664
|
+
0,
|
|
665
|
+
0,
|
|
666
|
+
);
|
|
667
|
+
},
|
|
668
|
+
|
|
669
|
+
async execute(_toolCallId, params) {
|
|
670
|
+
const id = params.agent_id as string;
|
|
671
|
+
const record = manager.getRecord(id);
|
|
672
|
+
if (!record) {
|
|
673
|
+
return {
|
|
674
|
+
content: [
|
|
675
|
+
{ type: "text" as const, text: `Agent not found: "${id}".` },
|
|
676
|
+
],
|
|
677
|
+
details: undefined as unknown,
|
|
678
|
+
};
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
const message = params.message as string;
|
|
682
|
+
|
|
683
|
+
if (record.session) {
|
|
684
|
+
try {
|
|
685
|
+
await record.session.steer(message);
|
|
686
|
+
return {
|
|
687
|
+
content: [
|
|
688
|
+
{
|
|
689
|
+
type: "text" as const,
|
|
690
|
+
text: `Steering message delivered to agent "${id}".`,
|
|
691
|
+
},
|
|
692
|
+
],
|
|
693
|
+
details: undefined as unknown,
|
|
694
|
+
};
|
|
695
|
+
} catch (err) {
|
|
696
|
+
return {
|
|
697
|
+
content: [
|
|
698
|
+
{
|
|
699
|
+
type: "text" as const,
|
|
700
|
+
text: `Failed to steer agent: ${err instanceof Error ? err.message : String(err)}`,
|
|
701
|
+
},
|
|
702
|
+
],
|
|
703
|
+
details: undefined as unknown,
|
|
704
|
+
};
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
// Session not ready yet — queue the steer
|
|
709
|
+
if (!record.pendingSteers) record.pendingSteers = [];
|
|
710
|
+
record.pendingSteers.push(message);
|
|
711
|
+
return {
|
|
712
|
+
content: [
|
|
713
|
+
{
|
|
714
|
+
type: "text" as const,
|
|
715
|
+
text: `Agent "${id}" session not yet ready. Steer queued and will be delivered on session start.`,
|
|
716
|
+
},
|
|
717
|
+
],
|
|
718
|
+
details: undefined as unknown,
|
|
719
|
+
};
|
|
720
|
+
},
|
|
721
|
+
});
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
// ── shared helpers ───────────────────────────────────────────────────────────
|
|
725
|
+
|
|
726
|
+
/** No-op helper to clearly name the fallback for TypeScript narrowing. */
|
|
727
|
+
function falling_back_note(b: boolean): b is true {
|
|
728
|
+
return b;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/**
|
|
732
|
+
* Create an AgentActivity state and spawn callbacks for tracking tool usage.
|
|
733
|
+
*/
|
|
734
|
+
function createActivityTracker(maxTurns?: number, onStreamUpdate?: () => void) {
|
|
735
|
+
const state: AgentActivity & { durationMs: number } = {
|
|
736
|
+
activeTools: new Map(),
|
|
737
|
+
toolUses: 0,
|
|
738
|
+
turnCount: 1,
|
|
739
|
+
maxTurns,
|
|
740
|
+
responseText: "",
|
|
741
|
+
session: undefined,
|
|
742
|
+
lifetimeUsage: { input: 0, output: 0, cacheWrite: 0 },
|
|
743
|
+
durationMs: 0,
|
|
744
|
+
};
|
|
745
|
+
const startedAt = Date.now();
|
|
746
|
+
|
|
747
|
+
const callbacks = {
|
|
748
|
+
onToolActivity: (activity: { type: "start" | "end"; toolName: string }) => {
|
|
749
|
+
if (activity.type === "start") {
|
|
750
|
+
state.activeTools.set(
|
|
751
|
+
`${activity.toolName}_${Date.now()}`,
|
|
752
|
+
activity.toolName,
|
|
753
|
+
);
|
|
754
|
+
} else {
|
|
755
|
+
for (const [key, name] of state.activeTools) {
|
|
756
|
+
if (name === activity.toolName) {
|
|
757
|
+
state.activeTools.delete(key);
|
|
758
|
+
break;
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
state.toolUses++;
|
|
762
|
+
}
|
|
763
|
+
onStreamUpdate?.();
|
|
764
|
+
},
|
|
765
|
+
onTextDelta: (_delta: string, fullText: string) => {
|
|
766
|
+
state.responseText = fullText;
|
|
767
|
+
state.durationMs = Date.now() - startedAt;
|
|
768
|
+
onStreamUpdate?.();
|
|
769
|
+
},
|
|
770
|
+
onTurnEnd: (turnCount: number) => {
|
|
771
|
+
state.turnCount = turnCount;
|
|
772
|
+
onStreamUpdate?.();
|
|
773
|
+
},
|
|
774
|
+
onSessionCreated: (session: unknown) => {
|
|
775
|
+
state.session = session as AgentActivity["session"];
|
|
776
|
+
},
|
|
777
|
+
onAssistantUsage: (usage: {
|
|
778
|
+
input: number;
|
|
779
|
+
output: number;
|
|
780
|
+
cacheWrite: number;
|
|
781
|
+
}) => {
|
|
782
|
+
state.lifetimeUsage.input += usage.input;
|
|
783
|
+
state.lifetimeUsage.output += usage.output;
|
|
784
|
+
state.lifetimeUsage.cacheWrite += usage.cacheWrite;
|
|
785
|
+
onStreamUpdate?.();
|
|
786
|
+
},
|
|
787
|
+
};
|
|
788
|
+
|
|
789
|
+
return { state, callbacks };
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
function buildDetails(
|
|
793
|
+
base: Pick<
|
|
794
|
+
AgentDetails,
|
|
795
|
+
"displayName" | "description" | "subagentType" | "modelName" | "tags"
|
|
796
|
+
>,
|
|
797
|
+
record: {
|
|
798
|
+
toolUses: number;
|
|
799
|
+
startedAt: number;
|
|
800
|
+
completedAt?: number;
|
|
801
|
+
status: string;
|
|
802
|
+
error?: string;
|
|
803
|
+
id?: string;
|
|
804
|
+
lifetimeUsage: { input: number; output: number; cacheWrite: number };
|
|
805
|
+
},
|
|
806
|
+
activity?: AgentActivity & { durationMs?: number },
|
|
807
|
+
): AgentDetails {
|
|
808
|
+
const totalTokens = getLifetimeTotal(record.lifetimeUsage);
|
|
809
|
+
return {
|
|
810
|
+
...base,
|
|
811
|
+
toolUses: record.toolUses,
|
|
812
|
+
tokens: totalTokens > 0 ? formatTokens(totalTokens) : "",
|
|
813
|
+
turnCount: activity?.turnCount,
|
|
814
|
+
maxTurns: activity?.maxTurns,
|
|
815
|
+
durationMs:
|
|
816
|
+
activity?.durationMs ??
|
|
817
|
+
(record.completedAt ?? Date.now()) - record.startedAt,
|
|
818
|
+
status: record.status as AgentDetails["status"],
|
|
819
|
+
agentId: record.id,
|
|
820
|
+
error: record.error,
|
|
821
|
+
};
|
|
822
|
+
}
|