@getpipher/armory-fleet 0.12.1 → 0.12.3
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/package.json +1 -1
- package/src/engine/child-loader.ts +26 -8
- package/src/engine/run-registry.ts +9 -1
- package/src/engine/spawnSubagent.ts +65 -15
- package/src/panel/widget-rows.ts +41 -6
- package/src/tools/subagent.ts +57 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@getpipher/armory-fleet",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.3",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "The armory suite's subagent orchestrator for the pi coding agent — a cross-harness, superpowers-native fleet where every agent is armory-native from birth.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -7,6 +7,30 @@ import { dirname } from "node:path";
|
|
|
7
7
|
import type { AgentDef } from "../registry/frontmatter.ts";
|
|
8
8
|
import type { MemoryHydratePort } from "../memory-hydrate/port.ts";
|
|
9
9
|
|
|
10
|
+
/** Shape of the pi resource-loader's current-skills context (the `cur` arg of skillsOverride).
|
|
11
|
+
* Generic over the skill + diagnostics types so the pi SDK's full `Skill`/`ResourceDiagnostic`
|
|
12
|
+
* types flow through unchanged. */
|
|
13
|
+
export interface InstalledSkills<S = { name: string }, D = unknown> {
|
|
14
|
+
skills: S[];
|
|
15
|
+
diagnostics: D;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** #32: resolve the child's skill bundle from the agent's declared skills + the installed arsenal.
|
|
19
|
+
*
|
|
20
|
+
* - Agent declares specific skills → load only those (filtered from the installed arsenal).
|
|
21
|
+
* - Agent declares NO skills → load NO skills (lean substrate). This is the #32 fix: previously
|
|
22
|
+
* an agent with no `skills` field loaded ALL installed skills (~42 → ~570K substrate on every
|
|
23
|
+
* `general-purpose` dispatch, ~59% of a 976K context window from turn 1). Callers opt in to
|
|
24
|
+
* skills via the `subagent` tool's `skills` param (threaded as `skillsOverride`, which clones
|
|
25
|
+
* `agent.skills` before this runs).
|
|
26
|
+
* - Returning `[]` (not `cur.skills`) is the deliberate behavior change. */
|
|
27
|
+
export function resolveChildSkills<S extends { name: string }, D>(agent: Pick<AgentDef, "skills">, cur: InstalledSkills<S, D>): InstalledSkills<S, D> {
|
|
28
|
+
const selected = agent.skills && agent.skills.length
|
|
29
|
+
? cur.skills.filter((s) => agent.skills!.includes(s.name))
|
|
30
|
+
: [];
|
|
31
|
+
return { skills: selected, diagnostics: cur.diagnostics };
|
|
32
|
+
}
|
|
33
|
+
|
|
10
34
|
/** Fixed pseudo-cwd for the global cross-project user memory scope. */
|
|
11
35
|
export const USER_PSEUDO_CWD = "/__armory-fleet-user__";
|
|
12
36
|
|
|
@@ -36,12 +60,6 @@ export function buildChildLoader(opts: ChildLoaderOpts): DefaultResourceLoader {
|
|
|
36
60
|
noExtensions: true,
|
|
37
61
|
systemPromptOverride: (base) =>
|
|
38
62
|
composeChildPrompt({ rolePrompt: opts.agent.rolePrompt, memoryBlock, base: base ?? "" }),
|
|
39
|
-
skillsOverride: (cur) => (
|
|
40
|
-
skills:
|
|
41
|
-
opts.agent.skills && opts.agent.skills.length
|
|
42
|
-
? cur.skills.filter((s) => opts.agent.skills!.includes(s.name))
|
|
43
|
-
: cur.skills,
|
|
44
|
-
diagnostics: cur.diagnostics,
|
|
45
|
-
}),
|
|
63
|
+
skillsOverride: (cur) => resolveChildSkills(opts.agent, cur),
|
|
46
64
|
});
|
|
47
|
-
}
|
|
65
|
+
}
|
|
@@ -40,6 +40,14 @@ export interface RunRecord {
|
|
|
40
40
|
* Transient, in-memory only — never written to RunLog (the journal append constructs
|
|
41
41
|
* a plain object, not RunRecord). */
|
|
42
42
|
session?: LiveSessionHandle;
|
|
43
|
+
/** #23: liveness — current turn count (1-indexed; live, updated on turn_start). */
|
|
44
|
+
turnCount?: number;
|
|
45
|
+
/** #23: the run's max turn budget (set at spawn; for the widget's `turn N/max`). */
|
|
46
|
+
turnMax?: number;
|
|
47
|
+
/** #23: the last event class seen (e.g. "tool:edit", "assistant", "turn") — liveness only, no content. */
|
|
48
|
+
lastEventClass?: string;
|
|
49
|
+
/** #23: timestamp (ms) of the last event — liveness heartbeat ("are events still arriving?"). */
|
|
50
|
+
lastEventAt?: number;
|
|
43
51
|
}
|
|
44
52
|
|
|
45
53
|
/** runId format: fl-<base36 ms>-<6 random> (SPEC-1 §5.1). */
|
|
@@ -76,4 +84,4 @@ export class RunRegistry {
|
|
|
76
84
|
private emit(): void {
|
|
77
85
|
for (const fn of this.listeners) fn();
|
|
78
86
|
}
|
|
79
|
-
}
|
|
87
|
+
}
|
|
@@ -142,6 +142,11 @@ export interface SpawnOptions {
|
|
|
142
142
|
modelRegistry?: ModelRegistryLike;
|
|
143
143
|
/** SPEC-6-3: workflow adapter tier override — replaces agent.tier before model resolution. */
|
|
144
144
|
tierOverride?: string;
|
|
145
|
+
/** #31: when true, the caller asserts this dispatch will NOT mutate the working directory
|
|
146
|
+
* (review/audit/research). Read-only dispatches bypass the foreground single-slot lock so
|
|
147
|
+
* multiple readOnly dispatches — and/or a readOnly dispatch alongside a write dispatch — can
|
|
148
|
+
* run in parallel. Mislabeling a write dispatch as readOnly risks in-place edit conflicts. */
|
|
149
|
+
readOnly?: boolean;
|
|
145
150
|
}
|
|
146
151
|
|
|
147
152
|
export interface SpawnResult {
|
|
@@ -158,6 +163,11 @@ export interface SpawnResult {
|
|
|
158
163
|
/** SPEC-6-1: final context tokens (calcContextTokens of the last usage). */
|
|
159
164
|
contextTokens?: number;
|
|
160
165
|
error?: string;
|
|
166
|
+
/** #39: true when the failure is a retryable provider rate-limit / auth failure
|
|
167
|
+
* (stopReason "error"). The subagent tool retries once with the configured `modelFallback`
|
|
168
|
+
* when this is set. Non-retryable failures (turn budget, agent-not-found, EMPTY_RESULT,
|
|
169
|
+
* abort, lock busy) leave this unset. */
|
|
170
|
+
retryable?: boolean;
|
|
161
171
|
}
|
|
162
172
|
|
|
163
173
|
export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
|
|
@@ -165,15 +175,25 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
|
|
|
165
175
|
const maxTurns = opts.maxTurns ?? DEFAULT_MAX_TURNS;
|
|
166
176
|
const startedAt = Date.now();
|
|
167
177
|
|
|
168
|
-
//
|
|
169
|
-
//
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
178
|
+
// #31: read-only dispatches (review/audit/research) bypass the foreground single-slot lock —
|
|
179
|
+
// the caller asserts no cwd mutation, so the in-place edit-conflict guard doesn't apply and
|
|
180
|
+
// multiple readOnly dispatches (and/or a readOnly alongside a write dispatch) may run in
|
|
181
|
+
// parallel. The lock is still acquired for write dispatches (default), preserving concurrency=1.
|
|
182
|
+
const readOnly = opts.readOnly ?? false;
|
|
183
|
+
let runId: string;
|
|
184
|
+
if (readOnly) {
|
|
185
|
+
runId = genRunId();
|
|
186
|
+
} else {
|
|
187
|
+
// concurrency=1 (SPEC-1 §9.2) — peek before generating a runId so a rejected
|
|
188
|
+
// call doesn't mint a discarded id; the held id is named in the message.
|
|
189
|
+
const busyId = opts.lock.current();
|
|
190
|
+
if (busyId !== null) {
|
|
191
|
+
return fail("", startedAt, `a subagent is already running (concurrency=1 in v0.1); wait for ${busyId} to finish or abort it first`, opts.agent);
|
|
192
|
+
}
|
|
193
|
+
runId = genRunId();
|
|
194
|
+
if (!opts.lock.tryAcquire(runId)) {
|
|
195
|
+
return fail("", startedAt, "concurrency lock unexpectedly unavailable", opts.agent);
|
|
196
|
+
}
|
|
177
197
|
}
|
|
178
198
|
|
|
179
199
|
try {
|
|
@@ -274,7 +294,7 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
|
|
|
274
294
|
let aborted = false;
|
|
275
295
|
const handle = toLiveHandle(session);
|
|
276
296
|
handle.abort = async () => { aborted = true; await session.abort(); };
|
|
277
|
-
opts.runRegistry.update(runId, { session: handle });
|
|
297
|
+
opts.runRegistry.update(runId, { session: handle, turnMax: maxTurns });
|
|
278
298
|
|
|
279
299
|
const budget = createTurnBudget(maxTurns);
|
|
280
300
|
let finalText = "";
|
|
@@ -287,6 +307,18 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
|
|
|
287
307
|
let modelError: string | undefined; // model-call failure surfaced via stopReason "error"
|
|
288
308
|
let sawAssistantMessage = false; // #22: did the child emit any assistant message_end at all?
|
|
289
309
|
|
|
310
|
+
// #23: liveness — classify events into a short, content-free class string for the widget.
|
|
311
|
+
// Names the tool (safe — tool name is not args/result) so the operator sees "what's happening"
|
|
312
|
+
// without leaking prompt content, secrets, or full tool arguments/results (per #23 acceptance).
|
|
313
|
+
const classifyEvent = (e: ChildSessionEvent): string => {
|
|
314
|
+
if (e.type === "turn_start") return "turn";
|
|
315
|
+
if (e.type === "turn_end") return "turn_end";
|
|
316
|
+
if (e.type === "message_end") return e.message?.role === "assistant" ? "assistant" : (e.message?.role ?? "message");
|
|
317
|
+
if (e.type === "tool_execution_end") return `tool:${(e as { toolName?: string }).toolName ?? "?"}`;
|
|
318
|
+
if (e.type === "session_init") return "init";
|
|
319
|
+
return e.type;
|
|
320
|
+
};
|
|
321
|
+
|
|
290
322
|
const onSignalAbort = (): void => { aborted = true; void session.abort(); };
|
|
291
323
|
opts.signal?.addEventListener("abort", onSignalAbort);
|
|
292
324
|
|
|
@@ -337,6 +369,15 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
|
|
|
337
369
|
opts.runLog?.append(runId, buildToolEvent((e as any).toolName, (e as any).args, (e as any).result, (e as any).isError ?? false, turnIdx));
|
|
338
370
|
} catch { /* best-effort */ }
|
|
339
371
|
}
|
|
372
|
+
// #23: liveness heartbeat — update the run record on meaningful events so the fleet widget
|
|
373
|
+
// can show turn count + last-event class + "events still arriving" without leaking content.
|
|
374
|
+
if (e.type === "turn_start" || e.type === "message_end" || e.type === "tool_execution_end") {
|
|
375
|
+
opts.runRegistry.update(runId, {
|
|
376
|
+
turnCount: Math.max(0, turnIdx + 1),
|
|
377
|
+
lastEventClass: classifyEvent(e),
|
|
378
|
+
lastEventAt: Date.now(),
|
|
379
|
+
});
|
|
380
|
+
}
|
|
340
381
|
opts.onEvent?.(e);
|
|
341
382
|
});
|
|
342
383
|
|
|
@@ -355,7 +396,13 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
|
|
|
355
396
|
let error: string | undefined;
|
|
356
397
|
if (aborted) {
|
|
357
398
|
status = "aborted";
|
|
358
|
-
|
|
399
|
+
// #23: distinguish TODO-status reversion from filesystem rollback. A foreground run is in-place
|
|
400
|
+
// (no worktree isolation — that's a background-run concern), so an abort reverts the linked TODO
|
|
401
|
+
// to open (retryable) but leaves any partial file edits in the working dir for inspection.
|
|
402
|
+
const rollbackNote = " — TODO reverted to open (retryable); in-place file changes NOT rolled back (inspect the working dir for partial work)";
|
|
403
|
+
error = tier?.costCap && costTotal > tier.costCap
|
|
404
|
+
? `budget_exceeded (cost $${costTotal.toFixed(4)} > cap $${tier.costCap})${rollbackNote}`
|
|
405
|
+
: `aborted by user${rollbackNote}`;
|
|
359
406
|
} else if (budget.count() >= maxTurns) {
|
|
360
407
|
// #25: surface a coherent partial, not a mid-sentence 200-char cut. The controller reads
|
|
361
408
|
// `res.error` (the tool surfaces error, not finalText, for failed runs), so the partial must
|
|
@@ -399,9 +446,11 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
|
|
|
399
446
|
status = "completed";
|
|
400
447
|
}
|
|
401
448
|
|
|
402
|
-
return await finishRun(opts, runId, startedAt, status, finalText, todoId, priorStatus, error, agentDef.name, model, tokenTotal, costTotal, contextTokens);
|
|
449
|
+
return await finishRun(opts, runId, startedAt, status, finalText, todoId, priorStatus, error, agentDef.name, model, tokenTotal, costTotal, contextTokens, modelError ? true : undefined);
|
|
403
450
|
} finally {
|
|
404
|
-
|
|
451
|
+
// #31: a readOnly dispatch never acquired the lock — don't release what it didn't take
|
|
452
|
+
// (releasing a lock held by another concurrent write dispatch would corrupt serialization).
|
|
453
|
+
if (!readOnly) opts.lock.release();
|
|
405
454
|
}
|
|
406
455
|
}
|
|
407
456
|
|
|
@@ -419,6 +468,7 @@ async function finishRun(
|
|
|
419
468
|
opts: SpawnOptions, runId: string, startedAt: number,
|
|
420
469
|
status: FleetRunStatus, finalText: string, todoId: string | null, priorStatus: string | undefined,
|
|
421
470
|
error: string | undefined, agentName: string, model: string, tokenTotal = 0, costTotal = 0, contextTokens = 0,
|
|
471
|
+
retryable?: boolean,
|
|
422
472
|
): Promise<SpawnResult> {
|
|
423
473
|
if (finalizedRunIds.has(runId)) {
|
|
424
474
|
// Already finalized — return the existing registry record's result without re-appending.
|
|
@@ -427,7 +477,7 @@ async function finishRun(
|
|
|
427
477
|
status: existing?.status ?? status, finalText: existing?.resultSummary ?? finalText,
|
|
428
478
|
runId, todoId, agent: agentName, model,
|
|
429
479
|
durationMs: existing?.endedAt ? existing.endedAt - startedAt : Date.now() - startedAt,
|
|
430
|
-
tokenTotal, costTotal, contextTokens, error,
|
|
480
|
+
tokenTotal, costTotal, contextTokens, error, retryable,
|
|
431
481
|
};
|
|
432
482
|
}
|
|
433
483
|
finalizedRunIds.add(runId);
|
|
@@ -461,6 +511,6 @@ async function finishRun(
|
|
|
461
511
|
}
|
|
462
512
|
return {
|
|
463
513
|
status, finalText, runId, todoId, agent: agentName, model,
|
|
464
|
-
durationMs: endedAt - startedAt, tokenTotal, costTotal, contextTokens, error,
|
|
514
|
+
durationMs: endedAt - startedAt, tokenTotal, costTotal, contextTokens, error, retryable,
|
|
465
515
|
};
|
|
466
516
|
}
|
package/src/panel/widget-rows.ts
CHANGED
|
@@ -11,6 +11,12 @@ import { fmtDuration, fmtTokens } from "./rows.ts";
|
|
|
11
11
|
import type { RunRecord } from "../engine/run-registry.ts";
|
|
12
12
|
import type { BgRunStatus } from "./rows.ts";
|
|
13
13
|
|
|
14
|
+
/** #23: liveness segments (turn count, last-event class, abort warning) appear only after a run
|
|
15
|
+
* has been active this long — keeps short foreground runs concise (per #23 acceptance criteria). */
|
|
16
|
+
export const LIVENESS_THRESHOLD_MS = 30_000;
|
|
17
|
+
/** #23: a run whose last event is older than this is flagged stale ("are events still arriving?"). */
|
|
18
|
+
export const STALE_THRESHOLD_MS = 60_000;
|
|
19
|
+
|
|
14
20
|
export interface WidgetRun {
|
|
15
21
|
runId: string;
|
|
16
22
|
agent: string;
|
|
@@ -32,6 +38,14 @@ export interface WidgetRun {
|
|
|
32
38
|
maxContext?: number;
|
|
33
39
|
/** SPEC-6-1: cumulative $ (for the $ segment). */
|
|
34
40
|
costTotal?: number;
|
|
41
|
+
/** #23: liveness — current turn count (1-indexed). */
|
|
42
|
+
turnCount?: number;
|
|
43
|
+
/** #23: liveness — max turn budget (for `turn N/max`). */
|
|
44
|
+
turnMax?: number;
|
|
45
|
+
/** #23: liveness — last event class (e.g. "tool:edit", "assistant", "turn"). */
|
|
46
|
+
lastEventClass?: string;
|
|
47
|
+
/** #23: liveness — timestamp (ms) of the last event ("events still arriving?"). */
|
|
48
|
+
lastEventAt?: number;
|
|
35
49
|
}
|
|
36
50
|
|
|
37
51
|
export function toWidgetRun(r: RunRecord): WidgetRun {
|
|
@@ -40,6 +54,7 @@ export function toWidgetRun(r: RunRecord): WidgetRun {
|
|
|
40
54
|
startedAt: r.startedAt, endedAt: r.endedAt, tokenTotal: r.tokenTotal,
|
|
41
55
|
kind: "fg",
|
|
42
56
|
task: r.task, costTotal: r.costTotal, contextTokens: r.contextTokens,
|
|
57
|
+
turnCount: r.turnCount, turnMax: r.turnMax, lastEventClass: r.lastEventClass, lastEventAt: r.lastEventAt,
|
|
43
58
|
};
|
|
44
59
|
}
|
|
45
60
|
|
|
@@ -88,16 +103,36 @@ function widgetLine(r: WidgetRun, now: number): string {
|
|
|
88
103
|
// fg: task excerpt as primary label (fallback to runId if no task)
|
|
89
104
|
const label = r.task ? `"${r.task.slice(0, 40)}"` : r.runId;
|
|
90
105
|
const agentSeg = r.agent && r.agent !== "general-purpose" ? ` · ${r.agent}` : "";
|
|
91
|
-
|
|
106
|
+
// #23: liveness — only after LIVENESS_THRESHOLD_MS, to keep short runs concise (per acceptance).
|
|
107
|
+
// turn N/max + last-event class (no prompt content, no args/results — only the tool name)
|
|
108
|
+
// + a stale indicator if no event has arrived for STALE_THRESHOLD_MS ("events still arriving?").
|
|
109
|
+
const elapsed = typeof r.startedAt === "number" ? now - r.startedAt : 0;
|
|
110
|
+
let liveness = "";
|
|
111
|
+
if (elapsed > LIVENESS_THRESHOLD_MS) {
|
|
112
|
+
const turn = (r.turnCount != null && r.turnMax != null) ? ` turn ${r.turnCount}/${r.turnMax}` : (r.turnCount != null ? ` turn ${r.turnCount}` : "");
|
|
113
|
+
const ev = r.lastEventClass ? ` ●${r.lastEventClass}` : "";
|
|
114
|
+
const stale = (r.lastEventAt != null && now - r.lastEventAt > STALE_THRESHOLD_MS) ? " ⏰stale" : "";
|
|
115
|
+
liveness = `${turn}${ev}${stale}`;
|
|
116
|
+
}
|
|
117
|
+
return `${glyph} ${label}${agentSeg}${dur}${liveness}${tok}${ctx}${cost}`;
|
|
92
118
|
}
|
|
93
119
|
|
|
94
|
-
/** Above-editor widget: one line per active run, cap 5, overflow → "+N more in /fleet".
|
|
120
|
+
/** Above-editor widget: one line per active run, cap 5, overflow → "+N more in /fleet".
|
|
121
|
+
* #23: when an active foreground run has been running longer than LIVENESS_THRESHOLD_MS, append an
|
|
122
|
+
* explicit abort-warning footer naming its runId (so the controller can distinguish active work
|
|
123
|
+
* from a hang without cancelling, and knows submitting a message will abort it). */
|
|
95
124
|
export function renderWidgetLines(runs: WidgetRun[], now: number = Date.now()): string[] {
|
|
96
125
|
const active = filterActive(runs);
|
|
97
126
|
const cap = 5;
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
127
|
+
const lines = active.length <= cap
|
|
128
|
+
? active.map((r) => widgetLine(r, now))
|
|
129
|
+
: [...active.slice(0, cap).map((r) => widgetLine(r, now)), `+${active.length - cap} more in /fleet`];
|
|
130
|
+
// #23: abort-warning footer — only when a RUNNING foreground run is active long enough that
|
|
131
|
+
// a controller might worry it's hung. Paused/queued fg runs aren't aborted by a new message.
|
|
132
|
+
const longFg = active.find((r) => r.kind === "fg" && r.status === "running" && typeof r.startedAt === "number" && now - r.startedAt > LIVENESS_THRESHOLD_MS);
|
|
133
|
+
if (longFg) {
|
|
134
|
+
lines.push(`⚠ submitting a message aborts the foreground run · ${longFg.runId} · /fleet to inspect`);
|
|
135
|
+
}
|
|
136
|
+
return lines;
|
|
102
137
|
}
|
|
103
138
|
|
package/src/tools/subagent.ts
CHANGED
|
@@ -29,10 +29,21 @@ export const subagentParams = Type.Object({
|
|
|
29
29
|
], { description: "Edit isolation for background runs. 'worktree' = git worktree (requires a git repo; fails sync if not). 'none' = in-place in cwd (no isolation; parallel edits may conflict). 'auto' (default) = worktree when cwd is a git repo, in-place otherwise." })),
|
|
30
30
|
schedule: Type.Optional(Type.String({ description: 'Schedule the run instead of firing now: a cron string ("0 9 * * 1-5"), an interval ("30m"/"2h"), or a one-shot ISO datetime ("2026-07-25T14:00"). Returns { scheduleId, nextFire }. Session-scoped (fires only while pi is open); no catch-up.' })),
|
|
31
31
|
maxTurns: Type.Optional(Type.Number({ description: 'Per-run turn budget (default 20). Raise for complex multi-step tasks (e.g. 40) so the subagent doesn\'t hit the budget mid-task; lower for trivial lookups.' })),
|
|
32
|
+
readOnly: Type.Optional(Type.Boolean({ description: 'Default false. Pass true ONLY for dispatches that will NOT mutate the working directory (review/audit, or research that writes no scratch files). A readOnly dispatch bypasses the foreground single-slot lock so multiple readOnly dispatches — and/or a readOnly alongside a write dispatch — can run in parallel. The caller is responsible for the assertion: mislabeling a dispatch that edits as readOnly risks in-place edit conflicts. Has no effect on background/scheduled runs (they use their own locks).' })),
|
|
33
|
+
skills: Type.Optional(Type.Array(Type.String(), { description: 'Skills to load for this dispatch (opt-in). By default a dispatch loads NO skills (#32 — lean substrate; previously an agent with no skills field loaded ALL ~42 installed skills, ~570K tokens / ~59% of context). Pass skill names from the installed arsenal (e.g. ["executing-plans", "test-driven-development"]) to opt in. For a direct dispatch, this replaces the agent\'s frontmatter skills (pass [] to load zero). For a lifecycle dispatch, this is ADDITIVE — the phase\'s designed skill bundle always loads and these are merged on top (a caller cannot strip a phase\'s required skills).' })),
|
|
34
|
+
modelFallback: Type.Optional(Type.String({ description: 'Model to retry with if the primary dispatch fails with a retryable provider rate-limit / auth failure (stopReason "error"). The fleet retries ONCE on this model and relinks the same tracked todo. Surface the model that served the retry in the result details (retriedWithModel). Per the AGENTS.md "Ollama primary + OpenRouter fallback" pattern. No effect on non-retryable failures (turn budget, agent-not-found, abort). Direct foreground dispatches only — background/scheduled/lifecycle retries are a follow-up.' })),
|
|
32
35
|
});
|
|
33
36
|
|
|
34
37
|
export type SubagentInput = Static<typeof subagentParams>;
|
|
35
38
|
|
|
39
|
+
/** #32: merge a lifecycle phase's skill bundle with the caller's `skills` param.
|
|
40
|
+
* Additive + deduped — the phase's designed skills always load; the caller can add extras but
|
|
41
|
+
* cannot strip phase skills (avoids the footgun where a caller passing `skills: ["tdd"]` with
|
|
42
|
+
* `lifecycle: "default"` would silently drop `brainstorming` from the brainstorm phase). */
|
|
43
|
+
export function mergeLifecycleSkills(phaseSkills: string[] | undefined, callerSkills: string[] | undefined): string[] {
|
|
44
|
+
return [...new Set([...(phaseSkills ?? []), ...(callerSkills ?? [])])];
|
|
45
|
+
}
|
|
46
|
+
|
|
36
47
|
export interface SubagentToolDeps {
|
|
37
48
|
registry: Map<string, AgentDef>;
|
|
38
49
|
runRegistry: RunRegistry;
|
|
@@ -76,6 +87,9 @@ export function createSubagentTool(deps: SubagentToolDeps) {
|
|
|
76
87
|
"Use subagent to delegate an isolated, well-scoped task to a named agent; it runs in the foreground and returns the result + a runId.",
|
|
77
88
|
"Pass todoId to link the run to an existing open todo you see in the Open TODOs block; otherwise fleet creates a tracked fleet task.",
|
|
78
89
|
"Pass track:false only for trivial throwaway lookups that don't represent real work.",
|
|
90
|
+
"Pass readOnly:true for dispatches that will NOT edit the working directory (review/audit, or research that writes no scratch files). It bypasses the foreground single-slot lock so multiple readOnly dispatches can run in parallel. Only use it when you are certain the child won't mutate cwd — mislabeling risks edit conflicts.",
|
|
91
|
+
"By default a dispatch loads NO skills (lean substrate). If the task needs a skill (e.g. test-driven-development for a TDD task, executing-plans for a plan-execution task), pass its name in the `skills` array to opt in — loading all skills by default wastes ~59% of the context window.",
|
|
92
|
+
"Pass `modelFallback` so a transient provider rate-limit / auth failure (stopReason 'error') auto-retries once on the fallback model instead of failing the dispatch. Per the AGENTS.md 'Ollama primary + OpenRouter fallback' pattern — don't let infra limits break a dispatch chain.",
|
|
79
93
|
],
|
|
80
94
|
parameters: subagentParams,
|
|
81
95
|
async execute(_toolCallId: string, params: SubagentInput, signal: AbortSignal, _onUpdate: unknown, _ctx: any) {
|
|
@@ -101,11 +115,12 @@ export function createSubagentTool(deps: SubagentToolDeps) {
|
|
|
101
115
|
...deps.lifecycleDeps,
|
|
102
116
|
spawn: async (o) => spawnSubagent({
|
|
103
117
|
agent: o.agent, task: o.task, lifecycleTodoId: o.lifecycleTodoId, model: o.model,
|
|
104
|
-
skillsOverride: o.skills, backendOverride: o.backend,
|
|
118
|
+
skillsOverride: mergeLifecycleSkills(o.skills, params.skills), backendOverride: o.backend,
|
|
105
119
|
registry: deps.registry, todoSync: deps.todoSync, runRegistry: deps.runRegistry, lock: deps.lock,
|
|
106
120
|
backendRegistry: deps.backendRegistry, parentModel: deps.parentModel, parentCwd: deps.parentCwd, runLog: deps.runLog, signal,
|
|
107
121
|
maxTurns: params.maxTurns,
|
|
108
122
|
tierRegistry: deps.tierRegistry, modelRegistry: deps.modelRegistry,
|
|
123
|
+
readOnly: params.readOnly,
|
|
109
124
|
}),
|
|
110
125
|
};
|
|
111
126
|
const res = await runLifecycle(params.task, params.lifecycle, {
|
|
@@ -127,6 +142,8 @@ export function createSubagentTool(deps: SubagentToolDeps) {
|
|
|
127
142
|
todoId: params.todoId,
|
|
128
143
|
track: params.track,
|
|
129
144
|
model: params.model,
|
|
145
|
+
readOnly: params.readOnly,
|
|
146
|
+
skillsOverride: params.skills,
|
|
130
147
|
registry: deps.registry,
|
|
131
148
|
todoSync: deps.todoSync,
|
|
132
149
|
runRegistry: deps.runRegistry,
|
|
@@ -139,15 +156,49 @@ export function createSubagentTool(deps: SubagentToolDeps) {
|
|
|
139
156
|
maxTurns: params.maxTurns,
|
|
140
157
|
tierRegistry: deps.tierRegistry, modelRegistry: deps.modelRegistry,
|
|
141
158
|
});
|
|
142
|
-
|
|
159
|
+
// #39: auto-retry on a retryable provider rate-limit / auth failure (stopReason "error").
|
|
160
|
+
// The primary run reverted its linked todo to open (finishRun -> markRunTodoReverted), so the
|
|
161
|
+
// retry relinks the SAME todoId to continue the tracked task. Retry ONCE, only on the direct
|
|
162
|
+
// foreground path, only if a distinct fallback model was provided. The retry's runId differs
|
|
163
|
+
// from the primary's (each spawnSubagent call mints its own); details.retriedWithModel marks it.
|
|
164
|
+
let retriedWithModel: string | undefined;
|
|
165
|
+
let finalRes = res;
|
|
166
|
+
if (
|
|
167
|
+
res.status === "failed" && res.retryable && params.modelFallback &&
|
|
168
|
+
params.modelFallback !== res.model && !signal.aborted
|
|
169
|
+
) {
|
|
170
|
+
finalRes = await spawnSubagent({
|
|
171
|
+
agent: params.agent,
|
|
172
|
+
task: params.task,
|
|
173
|
+
todoId: res.todoId ?? undefined,
|
|
174
|
+
track: params.track,
|
|
175
|
+
model: params.modelFallback,
|
|
176
|
+
readOnly: params.readOnly,
|
|
177
|
+
skillsOverride: params.skills,
|
|
178
|
+
registry: deps.registry,
|
|
179
|
+
todoSync: deps.todoSync,
|
|
180
|
+
runRegistry: deps.runRegistry,
|
|
181
|
+
lock: deps.lock,
|
|
182
|
+
backendRegistry: deps.backendRegistry,
|
|
183
|
+
parentModel: deps.parentModel,
|
|
184
|
+
parentCwd: deps.parentCwd,
|
|
185
|
+
runLog: deps.runLog,
|
|
186
|
+
signal,
|
|
187
|
+
maxTurns: params.maxTurns,
|
|
188
|
+
tierRegistry: deps.tierRegistry, modelRegistry: deps.modelRegistry,
|
|
189
|
+
});
|
|
190
|
+
retriedWithModel = params.modelFallback;
|
|
191
|
+
}
|
|
192
|
+
const isError = finalRes.status === "failed" || finalRes.status === "aborted";
|
|
143
193
|
return {
|
|
144
|
-
content: [{ type: "text" as const, text: isError ? (
|
|
194
|
+
content: [{ type: "text" as const, text: isError ? (finalRes.error ?? finalRes.status) : finalRes.finalText }],
|
|
145
195
|
details: {
|
|
146
|
-
runId:
|
|
147
|
-
status:
|
|
196
|
+
runId: finalRes.runId, todoId: finalRes.todoId, agent: finalRes.agent, model: finalRes.model,
|
|
197
|
+
status: finalRes.status, durationMs: finalRes.durationMs, tokenTotal: finalRes.tokenTotal,
|
|
198
|
+
retriedWithModel,
|
|
148
199
|
},
|
|
149
200
|
isError,
|
|
150
201
|
};
|
|
151
202
|
},
|
|
152
203
|
};
|
|
153
|
-
}
|
|
204
|
+
}
|