pi-subagentura 1.0.3 → 1.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +63 -8
- package/helpers.ts +545 -0
- package/package.json +12 -6
- package/subagent.ts +1046 -295
package/README.md
CHANGED
|
@@ -4,19 +4,24 @@
|
|
|
4
4
|
|
|
5
5
|
> **Note:** The `docs/` folder is managed by the [`pi-docs`](https://github.com/lmn451/pi-docs) package.
|
|
6
6
|
|
|
7
|
-
A public [Pi](https://pi.dev) package that adds
|
|
7
|
+
A public [Pi](https://pi.dev) package that adds in-process sub-agent tools:
|
|
8
8
|
|
|
9
9
|
- `subagent_with_context` — spawn a sub-agent that inherits the full conversation history
|
|
10
10
|
- `subagent_isolated` — spawn a sub-agent with a fresh, empty context window
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
- `get_subagent_status` — poll an async subagent job for live progress
|
|
12
|
+
- `get_subagent_result` — block until an async job completes and return the final output
|
|
13
|
+
- `cancel_subagent` — abort a running async job
|
|
14
|
+
- `prune_subagent_jobs` — remove all completed and failed jobs from the registry
|
|
15
|
+
The sub-agents run inside the current Pi process, stream live progress back to the UI, and inherit the active model by default. Async sub-agents run in the background — the main agent continues immediately while you poll for progress and collect results when ready.
|
|
13
16
|
|
|
14
17
|
## Why use it?
|
|
15
18
|
|
|
16
19
|
- Delegate focused side-tasks without leaving the current session
|
|
17
20
|
- Compare context-aware vs isolated reasoning
|
|
18
21
|
- Keep tool feedback lightweight with live status updates
|
|
19
|
-
-
|
|
22
|
+
- Run sub-agents in the background while continuing the main conversation
|
|
23
|
+
- Poll, collect, or cancel background jobs on demand
|
|
24
|
+
- Get live previews of running sub-agents (current turn, active tool, usage)
|
|
20
25
|
|
|
21
26
|

|
|
22
27
|
|
|
@@ -58,12 +63,16 @@ Parameters:
|
|
|
58
63
|
- `persona` — optional system-style persona
|
|
59
64
|
- `model` — optional model override like `anthropic/claude-sonnet-4-5`
|
|
60
65
|
- `cwd` — optional working directory override
|
|
66
|
+
- `async` — run in background; returns a jobId immediately instead of blocking
|
|
67
|
+
- `notifyOnComplete` — `"notify"` or `"inject"`; auto-deliver completion notification (async only)
|
|
68
|
+
- `maxAge` — optional TTL in ms for completed job retention (async only)
|
|
61
69
|
|
|
62
70
|
Best for:
|
|
63
71
|
|
|
64
72
|
- review tasks that depend on prior discussion
|
|
65
73
|
- continuing a line of reasoning in parallel
|
|
66
74
|
- focused implementation or research using the current context
|
|
75
|
+
- background side-quests that report results later
|
|
67
76
|
|
|
68
77
|
### `subagent_isolated`
|
|
69
78
|
|
|
@@ -75,27 +84,73 @@ Parameters:
|
|
|
75
84
|
- `persona` — optional system-style persona
|
|
76
85
|
- `model` — optional model override like `anthropic/claude-sonnet-4-5`
|
|
77
86
|
- `cwd` — optional working directory override
|
|
87
|
+
- `async` — run in background; returns a jobId immediately instead of blocking
|
|
88
|
+
- `notifyOnComplete` — `"notify"` or `"inject"`; auto-deliver completion notification (async only)
|
|
89
|
+
- `maxAge` — optional TTL in ms for completed job retention (async only)
|
|
78
90
|
|
|
79
91
|
Best for:
|
|
80
92
|
|
|
81
93
|
- second opinions
|
|
82
94
|
- clean-room summaries
|
|
83
95
|
- avoiding context contamination from the parent session
|
|
96
|
+
- background analysis without polluting the main conversation
|
|
97
|
+
|
|
98
|
+
### Async Workflow Tools
|
|
99
|
+
|
|
100
|
+
When you spawn a sub-agent with `async: true`, it returns a **jobId** immediately and runs in the background. Use these tools to manage async jobs:
|
|
101
|
+
|
|
102
|
+
#### `get_subagent_status`
|
|
103
|
+
|
|
104
|
+
Poll an async subagent job by jobId. Returns a live preview of the subagent's current turn, active tool, and partial output.
|
|
105
|
+
|
|
106
|
+
Parameters:
|
|
107
|
+
|
|
108
|
+
- `jobId` — required job ID returned by the async spawn
|
|
109
|
+
|
|
110
|
+
#### `get_subagent_result`
|
|
111
|
+
|
|
112
|
+
Block until an async subagent job completes, then return the final output and usage summary. If the job is already done, it returns immediately.
|
|
113
|
+
|
|
114
|
+
Parameters:
|
|
115
|
+
|
|
116
|
+
- `jobId` — required job ID returned by the async spawn
|
|
117
|
+
|
|
118
|
+
#### `cancel_subagent`
|
|
119
|
+
|
|
120
|
+
Abort a running async subagent job by jobId.
|
|
121
|
+
|
|
122
|
+
Parameters:
|
|
123
|
+
|
|
124
|
+
- `jobId` — required job ID returned by the async spawn
|
|
125
|
+
|
|
126
|
+
#### `prune_subagent_jobs`
|
|
127
|
+
|
|
128
|
+
Remove all completed and failed subagent jobs from the registry. Running and cancelled jobs are preserved.
|
|
129
|
+
|
|
130
|
+
### `list_available_models`
|
|
131
|
+
|
|
132
|
+
List all available AI models with auth status. Use this to validate model identifiers before passing them to subagent tools — prevents silent fallback to the parent session model.
|
|
133
|
+
|
|
134
|
+
Parameters:
|
|
84
135
|
|
|
136
|
+
- `filter` — optional substring filter for provider or model name
|
|
137
|
+
- `authOnly` — if true (default), only return models with configured auth
|
|
85
138
|
## Example prompts
|
|
86
139
|
|
|
87
140
|
- “Use a sub-agent to review this change and list risks.”
|
|
88
141
|
- “Use an isolated sub-agent to propose a README outline for this repo.”
|
|
89
142
|
- “Spawn a context-aware sub-agent to continue debugging while we keep planning here.”
|
|
143
|
+
- “Run a sub-agent in the background to run the test suite, then notify me when done.”
|
|
144
|
+
- “Spawn two isolated async sub-agents to review this code from different angles, then collect both results.”
|
|
90
145
|
|
|
91
146
|
## Development
|
|
92
147
|
|
|
93
|
-
This repo uses
|
|
148
|
+
This repo uses npm for local development.
|
|
94
149
|
|
|
95
150
|
```bash
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
151
|
+
npm install
|
|
152
|
+
npm test
|
|
153
|
+
npm run pack:check
|
|
99
154
|
```
|
|
100
155
|
|
|
101
156
|
## Contributing
|
package/helpers.ts
ADDED
|
@@ -0,0 +1,545 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared helpers for pi-subagentura
|
|
3
|
+
*
|
|
4
|
+
* Exported so both subagent.ts and test files can import them.
|
|
5
|
+
* Keeps helper logic in one place — single source of truth.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { randomBytes } from "node:crypto";
|
|
9
|
+
import { getModel, getProviders } from "@mariozechner/pi-ai";
|
|
10
|
+
import type { Model } from "@mariozechner/pi-ai";
|
|
11
|
+
|
|
12
|
+
// Note: Model<TApi> and AgentToolResult<T> are SDK generics. We use `unknown` as
|
|
13
|
+
// the type argument to avoid strict generic instantiation issues with tsc.
|
|
14
|
+
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
AuthStorage,
|
|
18
|
+
createAgentSession,
|
|
19
|
+
ModelRegistry,
|
|
20
|
+
SessionManager,
|
|
21
|
+
type AgentSession,
|
|
22
|
+
} from "@mariozechner/pi-coding-agent";
|
|
23
|
+
|
|
24
|
+
// ── Constants ────────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Milliseconds to wait before showing activeTool in the live status preview.
|
|
28
|
+
* Prevents UI flicker for fast tool executions that start and end within this window.
|
|
29
|
+
*
|
|
30
|
+
* Note: If Pi adds new model providers, update KNOWN_PROVIDERS below.
|
|
31
|
+
*/
|
|
32
|
+
export const ACTIVE_TOOL_DEBOUNCE_MS = 150;
|
|
33
|
+
|
|
34
|
+
// Note: If Pi adds new providers, getProviders() from @mariozechner/pi-ai will
|
|
35
|
+
// return them automatically. We no longer maintain a hardcoded list.
|
|
36
|
+
|
|
37
|
+
// ── Types ───────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
export interface SubagentResult {
|
|
40
|
+
output: string;
|
|
41
|
+
usage: {
|
|
42
|
+
input: number;
|
|
43
|
+
output: number;
|
|
44
|
+
cacheRead: number;
|
|
45
|
+
cacheWrite: number;
|
|
46
|
+
cost: number;
|
|
47
|
+
turns: number;
|
|
48
|
+
};
|
|
49
|
+
model?: string;
|
|
50
|
+
isError: boolean;
|
|
51
|
+
errorMessage?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface SubagentLiveStatus {
|
|
55
|
+
turn: number;
|
|
56
|
+
activeTool?: { name: string; args: Record<string, unknown> };
|
|
57
|
+
output: string;
|
|
58
|
+
usage: SubagentResult["usage"];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ── Async Job Types ─────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
export type JobStatus = "running" | "done" | "error" | "cancelled";
|
|
64
|
+
|
|
65
|
+
/** Notification delivery mode for async subagent completion */
|
|
66
|
+
export type NotifyOnComplete = "notify" | "inject";
|
|
67
|
+
|
|
68
|
+
export interface JobState {
|
|
69
|
+
id: string;
|
|
70
|
+
status: JobStatus;
|
|
71
|
+
liveStatus: SubagentLiveStatus;
|
|
72
|
+
result?: SubagentResult;
|
|
73
|
+
session: AgentSession;
|
|
74
|
+
startedAt: number;
|
|
75
|
+
promise: Promise<SubagentResult>;
|
|
76
|
+
modelLabel?: string;
|
|
77
|
+
/** Notification mode requested by spawner's notifyOnComplete param */
|
|
78
|
+
notifyOnComplete?: NotifyOnComplete;
|
|
79
|
+
/** At-most-once delivery guard */
|
|
80
|
+
notificationDelivered?: boolean;
|
|
81
|
+
/** Set true by get_subagent_result to suppress redundant notification */
|
|
82
|
+
resultRetrieved?: boolean;
|
|
83
|
+
/** Optional TTL in ms for completed job retention */
|
|
84
|
+
maxAge?: number;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ── Job Registry ────────────────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Persisted job registry using global to survive module reloads (jiti).
|
|
91
|
+
*
|
|
92
|
+
* Lifecycle:
|
|
93
|
+
* - Jobs added on async subagent spawn
|
|
94
|
+
* - Completed/error jobs persist indefinitely (no TTL)
|
|
95
|
+
* - Cancelled jobs removed immediately
|
|
96
|
+
* - All jobs lost on Pi restart (in-memory only)
|
|
97
|
+
*/
|
|
98
|
+
|
|
99
|
+
// Use 'global' for Node.js global, fall back to globalThis
|
|
100
|
+
const g = typeof global !== "undefined" ? global : globalThis;
|
|
101
|
+
|
|
102
|
+
// Create or reuse the registry on the global object
|
|
103
|
+
if (!g.__piSubagenturaRegistry) {
|
|
104
|
+
g.__piSubagenturaRegistry = new Map<string, JobState>();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export const jobRegistry = g.__piSubagenturaRegistry as Map<string, JobState>;
|
|
108
|
+
|
|
109
|
+
// Declare global piref for notification delivery (set by extension factory, read by delivery code)
|
|
110
|
+
declare global {
|
|
111
|
+
var __piSubagenturaPiRef: unknown; // ExtensionAPI ref — set in subagent.ts factory
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Initialize the global pi ref
|
|
115
|
+
if (!g.__piSubagenturaPiRef) {
|
|
116
|
+
g.__piSubagenturaPiRef = undefined;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Jobs persist indefinitely — no automatic expiration */
|
|
120
|
+
export const JOB_CLEANUP_TTL_MS = 0;
|
|
121
|
+
|
|
122
|
+
/** Maximum number of jobs to retain in the registry */
|
|
123
|
+
export const MAX_REGISTRY_SIZE = 100;
|
|
124
|
+
|
|
125
|
+
/** Remove the oldest completed or error job from the registry */
|
|
126
|
+
export function pruneOldestJob(): boolean {
|
|
127
|
+
for (const [jobId, job] of jobRegistry) {
|
|
128
|
+
if (job.status === "done" || job.status === "error") {
|
|
129
|
+
jobRegistry.delete(jobId);
|
|
130
|
+
return true;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Remove all completed and error jobs from the registry. Returns count removed. */
|
|
137
|
+
export function pruneCompletedJobs(): number {
|
|
138
|
+
let removed = 0;
|
|
139
|
+
for (const [jobId, job] of jobRegistry) {
|
|
140
|
+
if (job.status === "done" || job.status === "error") {
|
|
141
|
+
jobRegistry.delete(jobId);
|
|
142
|
+
removed++;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return removed;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function scheduleJobCleanup(
|
|
149
|
+
jobId: string,
|
|
150
|
+
immediate = false,
|
|
151
|
+
maxAge?: number,
|
|
152
|
+
): void {
|
|
153
|
+
if (!immediate) {
|
|
154
|
+
if (maxAge && maxAge > 0) {
|
|
155
|
+
setTimeout(() => {
|
|
156
|
+
jobRegistry.delete(jobId);
|
|
157
|
+
}, maxAge);
|
|
158
|
+
}
|
|
159
|
+
return; // persist indefinitely unless maxAge specified
|
|
160
|
+
}
|
|
161
|
+
setTimeout(() => {
|
|
162
|
+
jobRegistry.delete(jobId);
|
|
163
|
+
}, 0);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Generate a unique job ID (16 hex chars from crypto.randomBytes) */
|
|
167
|
+
export function generateJobId(): string {
|
|
168
|
+
// Uses randomBytes for Node 18 compatibility (randomUUID needs Node 19+)
|
|
169
|
+
return randomBytes(8).toString("hex");
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ── Helpers ─────────────────────────────────────────────────────────
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Resolve a model from a string identifier and an optional default.
|
|
176
|
+
*
|
|
177
|
+
* The caller (LLM agent) is responsible for providing the correct model id.
|
|
178
|
+
* This function does NOT guess — it only does exact lookups:
|
|
179
|
+
* 1. undefined → defaultModel
|
|
180
|
+
* 2. Use parent modelRegistry (has extension-added models like minimax)
|
|
181
|
+
* 3. "provider/id" format → exact getModel lookup (global static registry)
|
|
182
|
+
* 4. Bare id → exact getModel scan across all providers (global static registry)
|
|
183
|
+
* 5. Falls back to defaultModel when nothing matches
|
|
184
|
+
*/
|
|
185
|
+
export function resolveModel(
|
|
186
|
+
modelId: string | undefined,
|
|
187
|
+
// @ts-expect-error — Model<TApi> requires type arg; unknown is a safe placeholder
|
|
188
|
+
defaultModel: Model | undefined,
|
|
189
|
+
parentModelRegistry?: ModelRegistry,
|
|
190
|
+
) {
|
|
191
|
+
if (!modelId) return defaultModel;
|
|
192
|
+
|
|
193
|
+
// Only exact matching — no fuzzy/substring guessing.
|
|
194
|
+
// The AI should call list_available_models and pick from the list.
|
|
195
|
+
if (parentModelRegistry) {
|
|
196
|
+
if (modelId.includes("/")) {
|
|
197
|
+
const [provider, id] = modelId.split("/", 2);
|
|
198
|
+
const exact = parentModelRegistry.find(provider, id);
|
|
199
|
+
if (exact) return exact as any;
|
|
200
|
+
} else {
|
|
201
|
+
// Bare id — search all models in parent registry
|
|
202
|
+
for (const m of parentModelRegistry.getAll()) {
|
|
203
|
+
if (m.id === modelId) return m as any;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Fall back to global static registry (built-in models only)
|
|
209
|
+
if (modelId.includes("/")) {
|
|
210
|
+
const [provider, id] = modelId.split("/", 2);
|
|
211
|
+
// @ts-expect-error — getModel requires KnownProvider union; we trust the caller
|
|
212
|
+
return getModel(provider, id) ?? defaultModel;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Bare id — exact match across all providers
|
|
216
|
+
for (const provider of getProviders()) {
|
|
217
|
+
// @ts-expect-error — KnownProvider cast needed; string is assignable to it at runtime
|
|
218
|
+
const found = getModel(provider, modelId);
|
|
219
|
+
if (found) return found;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return defaultModel;
|
|
223
|
+
}
|
|
224
|
+
export function formatTokens(count: number): string {
|
|
225
|
+
if (count < 1000) return count.toString();
|
|
226
|
+
if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
|
|
227
|
+
if (count < 1000000) return `${Math.round(count / 1000)}k`;
|
|
228
|
+
return `${(count / 1000000).toFixed(1)}M`;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export function formatUsage(
|
|
232
|
+
u: SubagentResult["usage"],
|
|
233
|
+
model?: string,
|
|
234
|
+
): string {
|
|
235
|
+
const parts: string[] = [];
|
|
236
|
+
if (u.turns) parts.push(`${u.turns} turn${u.turns > 1 ? "s" : ""}`);
|
|
237
|
+
if (u.input) parts.push(`↑${formatTokens(u.input)}`);
|
|
238
|
+
if (u.output) parts.push(`↓${formatTokens(u.output)}`);
|
|
239
|
+
if (u.cacheRead) parts.push(`R${formatTokens(u.cacheRead)}`);
|
|
240
|
+
if (u.cacheWrite) parts.push(`W${formatTokens(u.cacheWrite)}`);
|
|
241
|
+
if (u.cost) parts.push(`$${u.cost.toFixed(4)}`);
|
|
242
|
+
if (model) parts.push(model);
|
|
243
|
+
return parts.join(" ");
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export function buildLiveUpdate(
|
|
247
|
+
status: SubagentLiveStatus,
|
|
248
|
+
model?: string,
|
|
249
|
+
// @ts-expect-error — AgentToolResult<T> requires type arg; unknown is a safe placeholder
|
|
250
|
+
): AgentToolResult {
|
|
251
|
+
return {
|
|
252
|
+
content: [{ type: "text", text: status.output }],
|
|
253
|
+
details: {
|
|
254
|
+
status: "running",
|
|
255
|
+
subagentStatus: status,
|
|
256
|
+
model,
|
|
257
|
+
},
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// ── startSubagentJob ────────────────────────────────────────────────
|
|
262
|
+
|
|
263
|
+
export interface StartSubagentJobParams {
|
|
264
|
+
task: string;
|
|
265
|
+
persona: string | undefined;
|
|
266
|
+
modelOverride: string | undefined;
|
|
267
|
+
cwd: string;
|
|
268
|
+
contextText: string | null;
|
|
269
|
+
signal: AbortSignal | undefined;
|
|
270
|
+
// @ts-expect-error — AgentToolResult<T> requires type arg
|
|
271
|
+
onUpdate: ((partial: AgentToolResult) => void) | undefined;
|
|
272
|
+
// @ts-expect-error — Model<TApi> requires type arg
|
|
273
|
+
defaultModel: Model | undefined;
|
|
274
|
+
maxAge?: number;
|
|
275
|
+
/** Parent session's model registry for resolving extension-added models (e.g. minimax) */
|
|
276
|
+
parentModelRegistry?: ModelRegistry;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export interface StartSubagentJobResult {
|
|
280
|
+
jobId: string;
|
|
281
|
+
jobPromise: Promise<SubagentResult>;
|
|
282
|
+
session: AgentSession;
|
|
283
|
+
liveStatus: SubagentLiveStatus;
|
|
284
|
+
modelLabel?: string;
|
|
285
|
+
/** Warning when modelOverride was specified but not found — lists available models */
|
|
286
|
+
modelWarning?: string;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Create a subagent session and start its prompt execution.
|
|
291
|
+
*
|
|
292
|
+
* Returns immediately with { jobId, jobPromise, session, liveStatus }.
|
|
293
|
+
* The jobPromise resolves to a SubagentResult when the subagent completes.
|
|
294
|
+
* The liveStatus object is mutated in real-time by the event subscriber.
|
|
295
|
+
*
|
|
296
|
+
* This is the shared core used by both sync (runSubagent) and async paths.
|
|
297
|
+
*/
|
|
298
|
+
export async function startSubagentJob(
|
|
299
|
+
params: StartSubagentJobParams,
|
|
300
|
+
): Promise<StartSubagentJobResult> {
|
|
301
|
+
const {
|
|
302
|
+
task,
|
|
303
|
+
persona,
|
|
304
|
+
modelOverride,
|
|
305
|
+
cwd,
|
|
306
|
+
contextText,
|
|
307
|
+
signal,
|
|
308
|
+
onUpdate,
|
|
309
|
+
defaultModel,
|
|
310
|
+
parentModelRegistry,
|
|
311
|
+
} = params;
|
|
312
|
+
|
|
313
|
+
// Enforce registry size cap before adding a new job
|
|
314
|
+
while (jobRegistry.size >= MAX_REGISTRY_SIZE) {
|
|
315
|
+
if (!pruneOldestJob()) break; // no old jobs to evict, allow slight overcap
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const jobId = generateJobId();
|
|
319
|
+
const authStorage = AuthStorage.create();
|
|
320
|
+
const modelRegistry = ModelRegistry.create(authStorage);
|
|
321
|
+
|
|
322
|
+
// Resolve model: exact match only, fallback to default
|
|
323
|
+
// Uses parent's modelRegistry to find extension-added models (e.g. minimax)
|
|
324
|
+
const targetModel = resolveModel(modelOverride, defaultModel, parentModelRegistry);
|
|
325
|
+
const modelLabel = targetModel
|
|
326
|
+
? `${targetModel.provider}/${targetModel.id}`
|
|
327
|
+
: undefined;
|
|
328
|
+
|
|
329
|
+
// Build model warning when override was specified (helps AI discover valid models)
|
|
330
|
+
let modelWarning: string | undefined;
|
|
331
|
+
if (modelOverride && parentModelRegistry) {
|
|
332
|
+
const available = parentModelRegistry.getAvailable();
|
|
333
|
+
const modelList = available
|
|
334
|
+
.map((m) => ` ${m.provider}/${m.id}${m.name ? ` (${m.name})` : ""}`)
|
|
335
|
+
.join("\n");
|
|
336
|
+
modelWarning =
|
|
337
|
+
`Requested model "${modelOverride}" resolved to ${modelLabel ?? "none"}. ` +
|
|
338
|
+
`Available models:\n${modelList || " (none)"}\n` +
|
|
339
|
+
`Use list_available_models to discover more.`;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
let handleAbort: (() => void) | undefined;
|
|
343
|
+
let unsubscribe: (() => void) | undefined;
|
|
344
|
+
|
|
345
|
+
const liveStatus: SubagentLiveStatus = {
|
|
346
|
+
turn: 0,
|
|
347
|
+
output: "",
|
|
348
|
+
usage: {
|
|
349
|
+
input: 0,
|
|
350
|
+
output: 0,
|
|
351
|
+
cacheRead: 0,
|
|
352
|
+
cacheWrite: 0,
|
|
353
|
+
cost: 0,
|
|
354
|
+
turns: 0,
|
|
355
|
+
},
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
// Debounce activeTool updates to prevent flickering on fast tool calls.
|
|
359
|
+
// When onUpdate is undefined (async path), skip the debounce entirely —
|
|
360
|
+
// no rendering to flicker, and the timer overhead is wasted.
|
|
361
|
+
let activeToolTimer: ReturnType<typeof setTimeout> | undefined;
|
|
362
|
+
let pendingActiveTool: SubagentLiveStatus["activeTool"] = undefined;
|
|
363
|
+
|
|
364
|
+
function setActiveToolDebounced(tool: SubagentLiveStatus["activeTool"]) {
|
|
365
|
+
pendingActiveTool = tool;
|
|
366
|
+
if (activeToolTimer) {
|
|
367
|
+
clearTimeout(activeToolTimer);
|
|
368
|
+
activeToolTimer = undefined;
|
|
369
|
+
}
|
|
370
|
+
if (tool) {
|
|
371
|
+
if (!onUpdate) {
|
|
372
|
+
// Async path: no rendering, apply immediately
|
|
373
|
+
liveStatus.activeTool = tool;
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
activeToolTimer = setTimeout(() => {
|
|
377
|
+
activeToolTimer = undefined;
|
|
378
|
+
liveStatus.activeTool = pendingActiveTool;
|
|
379
|
+
onUpdate?.(buildLiveUpdate(liveStatus, modelLabel));
|
|
380
|
+
}, ACTIVE_TOOL_DEBOUNCE_MS);
|
|
381
|
+
} else {
|
|
382
|
+
if (liveStatus.activeTool) {
|
|
383
|
+
liveStatus.activeTool = undefined;
|
|
384
|
+
onUpdate?.(buildLiveUpdate(liveStatus, modelLabel));
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Create session
|
|
390
|
+
const session = (
|
|
391
|
+
await createAgentSession({
|
|
392
|
+
sessionManager: SessionManager.inMemory(),
|
|
393
|
+
authStorage,
|
|
394
|
+
modelRegistry,
|
|
395
|
+
model: targetModel,
|
|
396
|
+
cwd,
|
|
397
|
+
})
|
|
398
|
+
).session;
|
|
399
|
+
|
|
400
|
+
// Wire abort signal
|
|
401
|
+
if (signal) {
|
|
402
|
+
handleAbort = () => {
|
|
403
|
+
session.abort().catch(() => {});
|
|
404
|
+
};
|
|
405
|
+
if (signal.aborted) {
|
|
406
|
+
handleAbort();
|
|
407
|
+
} else {
|
|
408
|
+
signal.addEventListener("abort", handleAbort);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// Wire session events
|
|
413
|
+
unsubscribe = session.subscribe((event) => {
|
|
414
|
+
switch (event.type) {
|
|
415
|
+
case "turn_start": {
|
|
416
|
+
liveStatus.turn++;
|
|
417
|
+
liveStatus.usage.turns = liveStatus.turn;
|
|
418
|
+
liveStatus.output = "";
|
|
419
|
+
onUpdate?.(buildLiveUpdate(liveStatus, modelLabel));
|
|
420
|
+
break;
|
|
421
|
+
}
|
|
422
|
+
case "tool_execution_start": {
|
|
423
|
+
setActiveToolDebounced({
|
|
424
|
+
name: event.toolName,
|
|
425
|
+
args: event.args as Record<string, unknown>,
|
|
426
|
+
});
|
|
427
|
+
break;
|
|
428
|
+
}
|
|
429
|
+
case "tool_execution_end": {
|
|
430
|
+
setActiveToolDebounced(undefined);
|
|
431
|
+
break;
|
|
432
|
+
}
|
|
433
|
+
case "turn_end": {
|
|
434
|
+
if (activeToolTimer) {
|
|
435
|
+
clearTimeout(activeToolTimer);
|
|
436
|
+
activeToolTimer = undefined;
|
|
437
|
+
}
|
|
438
|
+
liveStatus.activeTool = undefined;
|
|
439
|
+
onUpdate?.(buildLiveUpdate(liveStatus, modelLabel));
|
|
440
|
+
break;
|
|
441
|
+
}
|
|
442
|
+
case "message_update": {
|
|
443
|
+
if (event.assistantMessageEvent.type === "text_delta") {
|
|
444
|
+
liveStatus.output += event.assistantMessageEvent.delta;
|
|
445
|
+
onUpdate?.(buildLiveUpdate(liveStatus, modelLabel));
|
|
446
|
+
}
|
|
447
|
+
break;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
// Build prompt text
|
|
453
|
+
const personaPrefix = persona ? `${persona}\n\n` : "";
|
|
454
|
+
const finalPrompt = contextText
|
|
455
|
+
? `${personaPrefix}You are a SEPARATE background sub-agent. Your ONLY job is the task below.\nThe conversation history above is CONTEXT ONLY — do NOT comment on it, do NOT role-play as the main assistant, do NOT describe the spawning process. Execute ONLY the task and return ONLY the result.\n\n## Conversation History (context only — do not respond to this)\n${contextText}\n\n## Your Task (respond ONLY to this)\n${task}`
|
|
456
|
+
: `${personaPrefix}Task: ${task}`;
|
|
457
|
+
|
|
458
|
+
// Launch the prompt in a promise chain (NOT awaited — returns immediately).
|
|
459
|
+
// The jobPromise represents the full lifecycle: prompt → extraction → cleanup.
|
|
460
|
+
const jobPromise = (async (): Promise<SubagentResult> => {
|
|
461
|
+
let result: SubagentResult;
|
|
462
|
+
try {
|
|
463
|
+
await session.prompt(finalPrompt);
|
|
464
|
+
|
|
465
|
+
// Extract final assistant output
|
|
466
|
+
const messages = session.agent.state.messages;
|
|
467
|
+
let finalOutput = liveStatus.output;
|
|
468
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
469
|
+
const msg = messages[i];
|
|
470
|
+
if (msg.role === "assistant") {
|
|
471
|
+
const textParts = msg.content
|
|
472
|
+
?.filter(
|
|
473
|
+
(c: {
|
|
474
|
+
type: string;
|
|
475
|
+
text?: string;
|
|
476
|
+
}): c is { type: "text"; text: string } => c.type === "text",
|
|
477
|
+
)
|
|
478
|
+
.map((c) => c.text)
|
|
479
|
+
.join("\n");
|
|
480
|
+
if (textParts) {
|
|
481
|
+
finalOutput = textParts;
|
|
482
|
+
break;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
const usage = {
|
|
488
|
+
input: 0,
|
|
489
|
+
output: 0,
|
|
490
|
+
cacheRead: 0,
|
|
491
|
+
cacheWrite: 0,
|
|
492
|
+
cost: 0,
|
|
493
|
+
turns: 0,
|
|
494
|
+
};
|
|
495
|
+
for (const msg of messages) {
|
|
496
|
+
if (msg.role === "assistant" && msg.usage) {
|
|
497
|
+
usage.turns++;
|
|
498
|
+
usage.input += msg.usage.input;
|
|
499
|
+
usage.output += msg.usage.output;
|
|
500
|
+
usage.cacheRead += msg.usage.cacheRead;
|
|
501
|
+
usage.cacheWrite += msg.usage.cacheWrite;
|
|
502
|
+
usage.cost += msg.usage.cost.total;
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
result = {
|
|
507
|
+
output: finalOutput || "(no output)",
|
|
508
|
+
usage,
|
|
509
|
+
model: session.model
|
|
510
|
+
? `${session.model.provider}/${session.model.id}`
|
|
511
|
+
: undefined,
|
|
512
|
+
isError: !!session.agent.state.errorMessage,
|
|
513
|
+
errorMessage: session.agent.state.errorMessage,
|
|
514
|
+
};
|
|
515
|
+
} catch (err) {
|
|
516
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
517
|
+
result = {
|
|
518
|
+
output: `Sub-agent crashed: ${msg}`,
|
|
519
|
+
usage: {
|
|
520
|
+
input: 0,
|
|
521
|
+
output: 0,
|
|
522
|
+
cacheRead: 0,
|
|
523
|
+
cacheWrite: 0,
|
|
524
|
+
cost: 0,
|
|
525
|
+
turns: 0,
|
|
526
|
+
},
|
|
527
|
+
model: undefined,
|
|
528
|
+
isError: true,
|
|
529
|
+
errorMessage: msg,
|
|
530
|
+
};
|
|
531
|
+
} finally {
|
|
532
|
+
if (activeToolTimer) {
|
|
533
|
+
clearTimeout(activeToolTimer);
|
|
534
|
+
activeToolTimer = undefined;
|
|
535
|
+
}
|
|
536
|
+
if (signal && handleAbort)
|
|
537
|
+
signal.removeEventListener("abort", handleAbort);
|
|
538
|
+
if (unsubscribe) unsubscribe();
|
|
539
|
+
session?.dispose();
|
|
540
|
+
}
|
|
541
|
+
return result;
|
|
542
|
+
})();
|
|
543
|
+
|
|
544
|
+
return { jobId, jobPromise, session, liveStatus, modelLabel, modelWarning };
|
|
545
|
+
}
|