@getpipher/armory-fleet 0.12.1 → 0.12.2
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/spawnSubagent.ts +36 -13
- 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.2",
|
|
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
|
+
}
|
|
@@ -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 {
|
|
@@ -399,9 +419,11 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
|
|
|
399
419
|
status = "completed";
|
|
400
420
|
}
|
|
401
421
|
|
|
402
|
-
return await finishRun(opts, runId, startedAt, status, finalText, todoId, priorStatus, error, agentDef.name, model, tokenTotal, costTotal, contextTokens);
|
|
422
|
+
return await finishRun(opts, runId, startedAt, status, finalText, todoId, priorStatus, error, agentDef.name, model, tokenTotal, costTotal, contextTokens, modelError ? true : undefined);
|
|
403
423
|
} finally {
|
|
404
|
-
|
|
424
|
+
// #31: a readOnly dispatch never acquired the lock — don't release what it didn't take
|
|
425
|
+
// (releasing a lock held by another concurrent write dispatch would corrupt serialization).
|
|
426
|
+
if (!readOnly) opts.lock.release();
|
|
405
427
|
}
|
|
406
428
|
}
|
|
407
429
|
|
|
@@ -419,6 +441,7 @@ async function finishRun(
|
|
|
419
441
|
opts: SpawnOptions, runId: string, startedAt: number,
|
|
420
442
|
status: FleetRunStatus, finalText: string, todoId: string | null, priorStatus: string | undefined,
|
|
421
443
|
error: string | undefined, agentName: string, model: string, tokenTotal = 0, costTotal = 0, contextTokens = 0,
|
|
444
|
+
retryable?: boolean,
|
|
422
445
|
): Promise<SpawnResult> {
|
|
423
446
|
if (finalizedRunIds.has(runId)) {
|
|
424
447
|
// Already finalized — return the existing registry record's result without re-appending.
|
|
@@ -427,7 +450,7 @@ async function finishRun(
|
|
|
427
450
|
status: existing?.status ?? status, finalText: existing?.resultSummary ?? finalText,
|
|
428
451
|
runId, todoId, agent: agentName, model,
|
|
429
452
|
durationMs: existing?.endedAt ? existing.endedAt - startedAt : Date.now() - startedAt,
|
|
430
|
-
tokenTotal, costTotal, contextTokens, error,
|
|
453
|
+
tokenTotal, costTotal, contextTokens, error, retryable,
|
|
431
454
|
};
|
|
432
455
|
}
|
|
433
456
|
finalizedRunIds.add(runId);
|
|
@@ -461,6 +484,6 @@ async function finishRun(
|
|
|
461
484
|
}
|
|
462
485
|
return {
|
|
463
486
|
status, finalText, runId, todoId, agent: agentName, model,
|
|
464
|
-
durationMs: endedAt - startedAt, tokenTotal, costTotal, contextTokens, error,
|
|
487
|
+
durationMs: endedAt - startedAt, tokenTotal, costTotal, contextTokens, error, retryable,
|
|
465
488
|
};
|
|
466
489
|
}
|
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
|
+
}
|