@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getpipher/armory-fleet",
3
- "version": "0.12.1",
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
- // concurrency=1 (SPEC-1 §9.2) peek before generating a runId so a rejected
169
- // call doesn't mint a discarded id; the held id is named in the message.
170
- const busyId = opts.lock.current();
171
- if (busyId !== null) {
172
- return fail("", startedAt, `a subagent is already running (concurrency=1 in v0.1); wait for ${busyId} to finish or abort it first`, opts.agent);
173
- }
174
- const runId = genRunId();
175
- if (!opts.lock.tryAcquire(runId)) {
176
- return fail("", startedAt, "concurrency lock unexpectedly unavailable", opts.agent);
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
- opts.lock.release();
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
  }
@@ -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
- const isError = res.status === "failed" || res.status === "aborted";
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 ? (res.error ?? res.status) : res.finalText }],
194
+ content: [{ type: "text" as const, text: isError ? (finalRes.error ?? finalRes.status) : finalRes.finalText }],
145
195
  details: {
146
- runId: res.runId, todoId: res.todoId, agent: res.agent, model: res.model,
147
- status: res.status, durationMs: res.durationMs, tokenTotal: res.tokenTotal,
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
+ }