Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@getpipher/armory-fleet",
"version": "0.9.4",
"version": "0.10.0",
"private": false,
"description": "The armory suite's subagent orchestrator for the pi coding agent \u2014 a cross-harness, superpowers-native fleet where every agent is armory-native from birth.",
"license": "MIT",
Expand Down
6 changes: 6 additions & 0 deletions src/engine/run-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@ export interface RunRecord {
forkedFrom?: string;
/** SPEC-5b-2: cumulative real tokens (input+output+cacheRead+cacheWrite) — live, updated on each message_end. */
tokenTotal?: number;
/** SPEC-6-1: cumulative $ (usage.cost.total) — live, updated on each message_end. */
costTotal?: number;
/** SPEC-6-1: latest context tokens (calcContextTokens(usage)) — live snapshot. */
contextTokens?: number;
/** SPEC-6-1: the tier name this run used (for Tiers-view "used by" + per-tier spend). */
tier?: string;
/** SPEC-5b-4: live session handle while status === "running"; cleared by finishRun.
* Transient, in-memory only — never written to RunLog (the journal append constructs
* a plain object, not RunRecord). */
Expand Down
93 changes: 71 additions & 22 deletions src/engine/spawnSubagent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,16 @@ import { createTurnBudget, DEFAULT_MAX_TURNS } from "./turn-budget.ts";
import type { SingleSlotLock } from "./concurrency-lock.ts";
import type { RunLog } from "../runtime/run-log.ts";
import { buildToolEvent } from "../runtime/run-log.ts";
import { resolveAgentModel, type ModelRegistryLike } from "../tiers/resolve.ts";
import { TierRegistry } from "../tiers/tier-registry.ts";

const PI_DEFAULT_TOOLS = ["read", "bash", "edit", "write"];

/** SPEC-6-1: derive context-token count from a usage object. Prefer totalTokens; fall back to sum. */
function calcContextTokens(u: { totalTokens?: number; input?: number; output?: number; cacheRead?: number; cacheWrite?: number }): number {
return u.totalTokens || ((u.input ?? 0) + (u.output ?? 0) + (u.cacheRead ?? 0) + (u.cacheWrite ?? 0));
}

/** No-op ports used when a caller omits them (e.g. SPEC-1 unit tests). Production (index.ts) passes real ports. */
const NOOP_MEMORY_PORT: MemoryHydratePort = { renderScopes: () => "" };
const NOOP_VISION_PORT: VisionPort = {
Expand Down Expand Up @@ -123,6 +130,10 @@ export interface SpawnOptions {
resumeLink?: string;
/** SPEC-5b-1: when set, the new run is a fork of this prior runId (written to run:ended + RunRecord.forkedFrom). */
forkLink?: string;
/** SPEC-6-1: tier registry for model-tier resolution. Optional — existing callers without it use agent.model/parent fallback. */
tierRegistry?: TierRegistry;
/** SPEC-6-1: model catalog for contextFloor filtering. Optional — absent means no catalog filtering. */
modelRegistry?: ModelRegistryLike;
}

export interface SpawnResult {
Expand All @@ -134,6 +145,10 @@ export interface SpawnResult {
model: string;
durationMs: number;
tokenTotal: number;
/** SPEC-6-1: cumulative $ (usage.cost.total) for this run. */
costTotal?: number;
/** SPEC-6-1: final context tokens (calcContextTokens of the last usage). */
contextTokens?: number;
error?: string;
}

Expand Down Expand Up @@ -173,8 +188,18 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
// skillsOverride (buildChildLoader reads agent.skills) loads the phase's bundle, not the agent's.
const childAgent = opts.skillsOverride ? { ...agentDef, skills: opts.skillsOverride } : agentDef;

// resolve model
const model = opts.model ?? agentDef.model ?? `${opts.parentModel.provider}/${opts.parentModel.id}`;
// SPEC-6-1: resolve model via tier registry (Q4 precedence + Q5 contextFloor/catalog filter).
const resolved = resolveAgentModel(
agentDef, opts.model, opts.parentModel,
opts.tierRegistry ?? new TierRegistry({ tiers: [], agents: new Map() }),
opts.modelRegistry ?? { find: () => undefined },
);
if ("error" in resolved) {
return fail(runId, startedAt, resolved.error, opts.agent);
}
const model = resolved.model;
const tier = resolved.tier;
const candidates = resolved.candidates ?? [model];

// child tools pass through UNFILTERED — the single-writer `todo`-exclusion is enforced
// downstream by the child factory's `excludeTools: ["todo"]` (SPEC-2 §9.1 hardening).
Expand All @@ -186,6 +211,7 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
opts.runRegistry.add({
runId, agent: agentDef.name, model, task: opts.task, track,
todoId: null, status: "running", startedAt,
tier: tier?.name, costTotal: 0, contextTokens: 0,
});
try {
opts.runLog?.append(runId, { type: "run:meta", runId, agent: agentDef.name, model, task: opts.task, startedAt, track, todoId: null });
Expand All @@ -206,19 +232,30 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
return await finishRun(opts, runId, startedAt, "failed", "", todoId, priorStatus, (e as Error).message, agentDef.name, model);
}

// spawn child
const { session } = await backend.factory.create({
cwd: opts.parentCwd,
model,
thinkingLevel: childAgent.thinkingLevel,
tools,
rolePrompt: childAgent.rolePrompt,
skills: childAgent.skills ?? [],
task: opts.task,
agent: childAgent,
memoryPort,
visionPort,
});
// SPEC-6-1: fallback retry loop — try candidates[0], on rejection retry candidates[1], etc.
let session: ChildSession | undefined;
let lastErr: Error | undefined;
for (const cand of candidates) {
try {
const result = await backend.factory.create({
cwd: opts.parentCwd,
model: cand,
thinkingLevel: childAgent.thinkingLevel,
tools,
rolePrompt: childAgent.rolePrompt,
skills: childAgent.skills ?? [],
task: opts.task,
agent: childAgent,
memoryPort,
visionPort,
});
session = result.session;
break;
} catch (e) { lastErr = e as Error; }
}
if (!session) {
return await finishRun(opts, runId, startedAt, "failed", "", todoId, priorStatus, `backend create failed: ${lastErr?.message ?? "unknown"}`, agentDef.name, model, 0, 0, 0);
}

// SPEC-5b-4: retain a narrow live-session handle on the run record so the panel can
// steer/abort mid-flight. Wrap abort so the local `aborted` flag is set when the panel
Expand All @@ -232,6 +269,8 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
const budget = createTurnBudget(maxTurns);
let finalText = "";
let tokenTotal = 0;
let costTotal = 0;
let contextTokens = 0;
let turnIdx = -1;

const onSignalAbort = (): void => { aborted = true; void session.abort(); };
Expand All @@ -255,11 +294,20 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
const turnTokens = (u?.input ?? 0) + (u?.output ?? 0) + (u?.cacheRead ?? 0) + (u?.cacheWrite ?? 0);
if (turnTokens > 0) {
tokenTotal += turnTokens;
opts.runRegistry.update(runId, { tokenTotal });
}
// SPEC-6-1: accumulate cost + context tokens.
const cost = u?.cost?.total ?? 0;
costTotal += cost;
contextTokens = calcContextTokens(u ?? {});
opts.runRegistry.update(runId, { costTotal, contextTokens, tokenTotal });
try {
opts.runLog?.append(runId, { type: "message", role: "assistant", text, usage: { total: turnTokens, input: u?.input, output: u?.output, cacheRead: u?.cacheRead, cacheWrite: u?.cacheWrite }, turnIndex: turnIdx });
opts.runLog?.append(runId, { type: "message", role: "assistant", text, usage: { total: turnTokens, input: u?.input, output: u?.output, cacheRead: u?.cacheRead, cacheWrite: u?.cacheWrite, cost: u?.cost }, turnIndex: turnIdx });
} catch { /* best-effort */ }
// SPEC-6-1: cap abort — if costTotal exceeds tier.costCap, abort + flag budget_exceeded.
if (tier?.costCap && costTotal > tier.costCap) {
aborted = true;
void session.abort();
}
} else if (e.type === "tool_execution_end") {
try {
opts.runLog?.append(runId, buildToolEvent((e as any).toolName, (e as any).args, (e as any).result, (e as any).isError ?? false, turnIdx));
Expand All @@ -283,7 +331,7 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
let error: string | undefined;
if (aborted) {
status = "aborted";
error = "aborted by user";
error = tier?.costCap && costTotal > tier.costCap ? `budget_exceeded (cost $${costTotal.toFixed(4)} > cap $${tier.costCap})` : "aborted by user";
} else if (budget.count() >= maxTurns) {
status = "failed";
error = `hit turn budget (${maxTurns}) mid-task; partial result: ${finalText.slice(0, 200)}`;
Expand All @@ -294,7 +342,7 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
status = "completed";
}

return await finishRun(opts, runId, startedAt, status, finalText, todoId, priorStatus, error, agentDef.name, model, tokenTotal);
return await finishRun(opts, runId, startedAt, status, finalText, todoId, priorStatus, error, agentDef.name, model, tokenTotal, costTotal, contextTokens);
} finally {
opts.lock.release();
}
Expand All @@ -310,18 +358,19 @@ function fail(runId: string, startedAt: number, message: string, agent: string):
async function finishRun(
opts: SpawnOptions, runId: string, startedAt: number,
status: FleetRunStatus, finalText: string, todoId: string | null, priorStatus: string | undefined,
error: string | undefined, agentName: string, model: string, tokenTotal = 0,
error: string | undefined, agentName: string, model: string, tokenTotal = 0, costTotal = 0, contextTokens = 0,
): Promise<SpawnResult> {
const endedAt = Date.now();
opts.runRegistry.update(runId, {
status, endedAt, resultSummary: finalText.slice(0, 120),
resumedFrom: opts.resumeLink, forkedFrom: opts.forkLink,
session: undefined, // SPEC-5b-4: clear the live handle (invariant: session ⟺ running)
costTotal, contextTokens, // SPEC-6-1: final cost/context on the terminal record
});
try {
opts.runLog?.append(runId, {
type: "run:ended", runId, status, endedAt,
resultSummary: finalText.slice(0, 120), tokenTotal,
resultSummary: finalText.slice(0, 120), tokenTotal, costTotal, contextTokens,
resumedFrom: opts.resumeLink, forkedFrom: opts.forkLink,
});
} catch { /* best-effort: journal is the index, not the product */ }
Expand All @@ -340,6 +389,6 @@ async function finishRun(
}
return {
status, finalText, runId, todoId, agent: agentName, model,
durationMs: endedAt - startedAt, tokenTotal, error,
durationMs: endedAt - startedAt, tokenTotal, costTotal, contextTokens, error,
};
}
33 changes: 32 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ import { Scheduler } from "./scheduling/scheduler.ts";
import { createFleetResultsTool } from "./tools/fleet-results.ts";
import { BgRunsStore } from "./panel/bg-runs-store.ts";
import { FleetWidgetController } from "./panel/fleet-widget.ts";
import { TierRegistry, mergeTiers } from "./tiers/tier-registry.ts";
import { BUILTIN_TIERS } from "./tiers/builtin.ts";
import { TierStore } from "./tiers/tier-store.ts";
import { splitModel } from "./tiers/resolve.ts";

/** The package builtin agents/ dir, resolved relative to this module. */
function builtinAgentsDir(): string {
Expand Down Expand Up @@ -167,6 +171,12 @@ export default async function (pi: ExtensionAPI): Promise<void> {
deps.lifecycleDeps.registry = deps.lifecycleRegistry;
deps.lifecycleDeps.agentRegistry = deps.registry;

// SPEC-6-1: shared model registry for contextWindow lookups (contextFloor + ctx% widget).
const sharedModelRegistry = new ModelRegistry(modelRuntime);
deps.modelRegistry = sharedModelRegistry;
// Builtin-only placeholder tier registry so spawn works before session_start rebuilds with merged tiers.
deps.tierRegistry = new TierRegistry({ tiers: BUILTIN_TIERS, agents: deps.registry });

// ── SPEC-5a: operational runtime (async/bg + scheduling + worktree isolation) ──
const fleetDir = (cwd: string) => join(cwd, ".pi", "fleet");
const bgRuns = new BgRunsStore();
Expand Down Expand Up @@ -195,7 +205,8 @@ export default async function (pi: ExtensionAPI): Promise<void> {
agent: o.agent, task: o.task, lifecycleTodoId: o.lifecycleTodoId, model: o.model,
skillsOverride: o.skills, backendOverride: o.backend,
registry: deps.registry, todoSync: deps.todoSync, runRegistry: deps.runRegistry, lock: bgLock,
backendRegistry: deps.backendRegistry, parentModel: deps.parentModel, parentCwd: opts.worktreePath, runLog: deps.runLog, // child runs in the worktree
backendRegistry: deps.backendRegistry, parentModel: deps.parentModel, parentCwd: opts.worktreePath, runLog: deps.runLog,
tierRegistry: deps.tierRegistry, modelRegistry: deps.modelRegistry, // SPEC-6-1
}),
};
const res = await runLifecycle(task, lifecycleName, { deps: lifecycleFullDeps, mode: opts.mode, worktreePath: opts.worktreePath, baseRef: "HEAD", onCheckpoint: async (p) => p.status === "failed" ? { action: "abort" } : { action: "continue" } });
Expand Down Expand Up @@ -282,8 +293,27 @@ export default async function (pi: ExtensionAPI): Promise<void> {
bgRuns,
ui: ctx.ui as never,
getTheme: () => ctx.ui.theme,
getModelContextWindow: (m: string) => {
const { provider, id } = splitModel(m, deps.parentModel.provider);
return sharedModelRegistry.find(provider, id)?.contextWindow;
},
});
fleetWidget.start();

// SPEC-6-1: per-session TierStore (cwd-aware project path) + real TierRegistry (builtins + global + project).
const tierStore = new TierStore({
projectPath: join(dir, "tiers.json"),
globalPath: join(process.env.HOME ?? "", ".pi", "agent", "fleet", "tiers.json"),
});
deps.tierStore = tierStore;
const reloadTiers = (): void => {
deps.tierRegistry = new TierRegistry({
tiers: mergeTiers(BUILTIN_TIERS, tierStore.read("global"), tierStore.read("project")),
agents: deps.registry,
});
};
deps.reloadTiers = reloadTiers;
reloadTiers(); // build the real merged registry (replaces the builtin-only placeholder)
});

pi.on("session_shutdown", () => {
Expand Down Expand Up @@ -348,6 +378,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
skillsOverride: o.skills, backendOverride: o.backend,
registry: deps.registry, todoSync: deps.todoSync, runRegistry: deps.runRegistry, lock: deps.lock,
backendRegistry: deps.backendRegistry, parentModel: deps.parentModel, parentCwd: deps.parentCwd,
tierRegistry: deps.tierRegistry, modelRegistry: deps.modelRegistry, // SPEC-6-1
}),
};
const res = await runLifecycle(parsed.task, lcName, { deps: lifecycleFullDeps, mode: parsed.auto ? "auto" : "checkpointed", onCheckpoint });
Expand Down
Loading
Loading