Skip to content
Open
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: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

## Unreleased

- Added child token usage and cost tracking: each step accumulates usage from `message_end` RPC records and surfaces it as `StepSnapshot.usage` (per-step) and `RunSnapshot.teamUsage` (run total) in `run_status`/`step_result` model output. In interactive Pi, a separate footer status line (`team: $X.XXX ↑Xk ↓Xk ...`) accumulates cost across all `agent_team` runs in the session; disable it with the `agent_team:footer-cost` flag.

## 0.9.8 - 2026-07-04

- Made child Pi sessions mandatory and observable: child launches now use normal persistent Pi sessions with deterministic names/session-dir propagation, `run_status`/`step_result`/artifacts report child session metadata, progress watchdog diagnostics preserve stalled-child evidence, failed steps retain safe partial evidence, read-only idempotent transport failures retry once only when no child/output/message evidence exists, and parent-message queue observations distinguish accepted, queued, consumed, and no-next-turn states.
Expand Down
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,22 @@ Inspect the file's authority, tools, extension grants, prompts, tasks, and `cwd`

Do not point `graphFile` at installed package/example paths. Packaged examples are references to copy and adapt. Do not put `action`, `runId`, nested `graphFile`, or other control fields inside the graph file.

## Cost visibility

After each terminal `agent_team` run, a separate footer status line shows the accumulated child token cost for the session:

```
team: $1.234 ↑80k ↓16k R4.0M W91k
```

This is distinct from the parent session's own `$X.XXX` counter. Disable it:

```
/flag agent_team:footer-cost false
```

Per-step cost and the run total also appear in `run_status` and `step_result` output.

## Safe operating rules

- Prefer one direct pass when delegation would add noise.
Expand Down
46 changes: 43 additions & 3 deletions extensions/multiagent/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,13 @@ import { AgentTeamLiveRunsWidget, formatAgentTeamNoticeText, renderAgentTeamCall
import { describeOutputLimit } from "./src/result-format.ts";
import { AgentTeamSchema, type AgentTeamInput } from "./src/schemas.ts";
import { readSubagentSkillConfig, SUBAGENT_SKILLS_FLAG } from "./src/subagent-skills-config.ts";
import type { AgentDiagnostic, AgentInvocationDefaults, AgentTeamDetails, LibraryOptions, ParentToolInfo, ParentToolInventory } from "./src/types.ts";
import type { AgentDiagnostic, AgentInvocationDefaults, AgentTeamDetails, LibraryOptions, ParentToolInfo, ParentToolInventory, StepUsage } from "./src/types.ts";
import { getParentSkillInventory } from "./src/caller-skills.ts";

const packageRoot = dirname(dirname(dirname(fileURLToPath(import.meta.url))));
const packageAgentsDir = join(packageRoot, "agents");
const NOTICE_MESSAGE_TYPE = "agent_team.notice";
const FOOTER_COST_FLAG = "agent_team:footer-cost";
const validateAgentTeamInput = Compile(AgentTeamSchema);

/** Host-controlled extension seams for deterministic package-load and fake-Pi lifecycle probes. */
Expand All @@ -35,16 +36,23 @@ export default function multiagentExtension(pi: ExtensionAPI) {
export function registerMultiagentExtension(pi: ExtensionAPI, extensionOptions: MultiagentExtensionOptions = {}) {
const liveRunUiBySession = new Map<string, LiveRunUiState>();
const closedUiSessions = new Set<string>();
const sessionTeamUsage = new Map<string, StepUsage>();
const countedRunsBySession = new Map<string, Set<string>>();
pi.on("session_shutdown", (event: { reason?: string }, ctx) => {
const reason = event.reason ? `Parent Pi session shutdown: ${event.reason}.` : "Parent Pi session shutdown.";
const sessionId = ctx.sessionManager.getSessionId();
closedUiSessions.add(sessionId);
for (const run of listDetachedRuns()) if (run.isOwnedBy(sessionId) && !run.snapshot().terminal) run.cancel(reason, { forceKill: true });
clearRunWidget(ctx, sessionId, liveRunUiBySession);
const hadCost = sessionTeamUsage.has(sessionId);
sessionTeamUsage.delete(sessionId);
countedRunsBySession.delete(sessionId);
if (ctx.hasUI && hadCost) ctx.ui.setStatus(FOOTER_COST_FLAG, undefined);
});
pi.registerMessageRenderer<AgentTeamDetails>(NOTICE_MESSAGE_TYPE, (message, options, theme) => renderAgentTeamNoticeMessage(message.details, message.content, options, theme));
pi.on("tool_result", (event) => agentTeamToolResultErrorOverride(event));
pi.registerFlag(SUBAGENT_SKILLS_FLAG, { description: "Subagent Pi skill propagation: enabled or disabled. Default enabled gives each child all caller-visible skills.", type: "string", default: "enabled" });
pi.registerFlag(FOOTER_COST_FLAG, { description: "Show accumulated child team cost as a separate footer status line. Set to false to disable.", type: "boolean", default: true });
pi.registerTool({
name: "agent_team",
label: "Agent Team",
Expand Down Expand Up @@ -75,7 +83,7 @@ export function registerMultiagentExtension(pi: ExtensionAPI, extensionOptions:
const sessionDir = childSessionDirFromParent(ctx.sessionManager);
closedUiSessions.delete(sessionId);
reconcileRunWidget(ctx, sessionId, liveRunUiBySession);
const runUi = createRunUiHandlers(pi, ctx, sessionId, liveRunUiBySession, closedUiSessions);
const runUi = createRunUiHandlers(pi, ctx, sessionId, liveRunUiBySession, closedUiSessions, sessionTeamUsage, countedRunsBySession);
const subagentSkills = readSubagentSkillConfig(pi.getFlag(SUBAGENT_SKILLS_FLAG));
const preflight = validatePreflightShape(params);
const schemaValid = validateAgentTeamInput.Check(params);
Expand Down Expand Up @@ -111,9 +119,10 @@ export function agentTeamToolResultErrorOverride(event: ToolResultEvent): { isEr
return details.ok === false ? { isError: true } : undefined;
}

function createRunUiHandlers(pi: ExtensionAPI, ctx: ExtensionContext, sessionId: string, liveRunUiBySession: Map<string, LiveRunUiState>, closedUiSessions: Set<string>): { update: (details: AgentTeamDetails) => string | undefined; notice: (details: AgentTeamDetails) => string | undefined } {
function createRunUiHandlers(pi: ExtensionAPI, ctx: ExtensionContext, sessionId: string, liveRunUiBySession: Map<string, LiveRunUiState>, closedUiSessions: Set<string>, sessionTeamUsage: Map<string, StepUsage>, countedRunsBySession: Map<string, Set<string>>): { update: (details: AgentTeamDetails) => string | undefined; notice: (details: AgentTeamDetails) => string | undefined } {
return {
update(details) {
accumulateTeamCost(pi, ctx, sessionId, details, sessionTeamUsage, countedRunsBySession);
return updateRunWidget(ctx, sessionId, details, liveRunUiBySession, closedUiSessions);
},
notice(details) {
Expand All @@ -122,6 +131,37 @@ function createRunUiHandlers(pi: ExtensionAPI, ctx: ExtensionContext, sessionId:
};
}

function accumulateTeamCost(pi: ExtensionAPI, ctx: ExtensionContext, sessionId: string, details: AgentTeamDetails, sessionTeamUsage: Map<string, StepUsage>, countedRunsBySession: Map<string, Set<string>>): void {
if (!ctx.hasUI || !details.run?.terminal || !details.run.teamUsage) return;
if (pi.getFlag(FOOTER_COST_FLAG) === false) return;
const counted = countedRunsBySession.get(sessionId) ?? new Set<string>();
if (counted.has(details.run.runId)) return;
counted.add(details.run.runId);
countedRunsBySession.set(sessionId, counted);
const prev = sessionTeamUsage.get(sessionId) ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 };
const u = details.run.teamUsage;
const next: StepUsage = { input: prev.input + u.input, output: prev.output + u.output, cacheRead: prev.cacheRead + u.cacheRead, cacheWrite: prev.cacheWrite + u.cacheWrite, cost: prev.cost + u.cost };
sessionTeamUsage.set(sessionId, next);
ctx.ui.setStatus(FOOTER_COST_FLAG, formatTeamCostLine(next));
}

function formatTeamCostLine(u: StepUsage): string {
const parts: string[] = [];
if (u.input > 0) parts.push(`\u2191${fmtFooterTokens(u.input)}`);
if (u.output > 0) parts.push(`\u2193${fmtFooterTokens(u.output)}`);
if (u.cacheRead > 0) parts.push(`R${fmtFooterTokens(u.cacheRead)}`);
if (u.cacheWrite > 0) parts.push(`W${fmtFooterTokens(u.cacheWrite)}`);
parts.push(`$${u.cost.toFixed(3)}`);
return `team: ${parts.join(" ")}`;
}

function fmtFooterTokens(n: number): string {
if (n < 1000) return String(n);
if (n < 10_000) return `${(n / 1000).toFixed(1)}k`;
if (n < 1_000_000) return `${Math.round(n / 1000)}k`;
return `${(n / 1_000_000).toFixed(1)}M`;
}

interface LiveRunUiState {
cards: Map<string, AgentTeamDetails>;
component: AgentTeamLiveRunsWidget | undefined;
Expand Down
5 changes: 3 additions & 2 deletions extensions/multiagent/src/detached-run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { terminalRunStatus } from "./run-terminal-status.ts";
import { RunWaiters } from "./run-waiters.ts";
import { createRunUiCallback } from "./run-ui-callback.ts";
import { makeDetails, type AgentTeamRuntimeOptions, unrefTimer } from "./runtime-options.ts";
import { buildRunSnapshot, buildStepSnapshots, countStepStatuses, findSinkStepIds } from "./run-snapshot.ts";
import { buildRunSnapshot, buildStepSnapshots, countStepStatuses, findSinkStepIds, sumStepUsages } from "./run-snapshot.ts";
import { createStepOutputArtifact } from "./step-output-artifact.ts";
import { stalledStepBlockerMessage } from "./stalled-step-diagnostics.ts";
import { effectiveAgentInvocation } from "./agent-invocation.ts";
Expand Down Expand Up @@ -132,7 +132,7 @@ export class DetachedRun {

snapshot() {
const liveStepIds = this.stepSnapshots().filter((step) => step.status === "running").map((step) => step.id);
return buildRunSnapshot({ runId: this.id, objective: this.graph.objective, status: this.status, createdAt: this.createdAt, updatedAt: this.updatedAt, retentionSeconds: this.graph.options.terminalRetentionSeconds, liveStepIds, sinkStepIds: this.sinkStepIds(), lastEvent: this.lastEventSummary(), canMessage: this.status === "running" && liveStepIds.length > 0, canCancel: this.status === "running" || this.status === "canceling", counts: this.counts() });
return buildRunSnapshot({ runId: this.id, objective: this.graph.objective, status: this.status, createdAt: this.createdAt, updatedAt: this.updatedAt, retentionSeconds: this.graph.options.terminalRetentionSeconds, liveStepIds, sinkStepIds: this.sinkStepIds(), lastEvent: this.lastEventSummary(), canMessage: this.status === "running" && liveStepIds.length > 0, canCancel: this.status === "running" || this.status === "canceling", counts: this.counts(), teamUsage: sumStepUsages(this.states.values()) });
}

private async schedule() {
Expand Down Expand Up @@ -200,6 +200,7 @@ export class DetachedRun {
state.nonFinalText = result.nonFinalText;
state.assistantFinals = result.assistantFinals;
state.childSession = result.childSession;
state.usage = result.usage;
state.output = this.createStepOutput(state, status, result.text, result.assistantFinals, result.errorMessage, result.nonFinalText);
if (result.stderr.length > 0) this.appendEvent({ stepId: state.spec.id, type: "diagnostic", label: "stderr", preview: result.stderr, status: "done" });
this.finishState(state, status, result.errorMessage);
Expand Down
5 changes: 3 additions & 2 deletions extensions/multiagent/src/detached-state.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/** Mutable per-step detached-run state. */

import type { RpcChildController } from "./rpc-child-controller.ts";
import type { ChildSessionMetadata, StepOutput, StepRetryRecord, StepStatus, TeamStepSpec } from "./types.ts";
import type { ChildSessionMetadata, StepOutput, StepRetryRecord, StepStatus, StepUsage, TeamStepSpec } from "./types.ts";

export interface StepState {
spec: TeamStepSpec;
Expand All @@ -19,8 +19,9 @@ export interface StepState {
liveTextEventChars: number;
controller: RpcChildController | undefined;
promise: Promise<void> | undefined;
usage: StepUsage | undefined;
}

export function createPendingStepState(spec: TeamStepSpec): StepState {
return { spec, status: "pending", startedAt: undefined, endedAt: undefined, errorMessage: undefined, output: undefined, finalText: undefined, nonFinalText: undefined, assistantFinals: [], childSession: undefined, retryHistory: [], liveText: "", liveTextEventChars: 0, controller: undefined, promise: undefined };
return { spec, status: "pending", startedAt: undefined, endedAt: undefined, errorMessage: undefined, output: undefined, finalText: undefined, nonFinalText: undefined, assistantFinals: [], childSession: undefined, retryHistory: [], liveText: "", liveTextEventChars: 0, controller: undefined, promise: undefined, usage: undefined };
}
5 changes: 3 additions & 2 deletions extensions/multiagent/src/result-format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { compactOutputChildSession, formatOutputChildSession, optionalChildSessi
import type { AgentTeamDetails, BackgroundEvent, RunSnapshot, StepOutput, StepSnapshot } from "./types.ts";

export { boundedModelText, escapeOutputBlockMarkers, modelText } from "./result-model-text.ts";
import { formatRunTeamCost, formatStepCost } from "./result-model-text.ts";
export { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, describeOutputLimit, truncateHead } from "./result-truncation.ts";

const OBJECTIVE_PREVIEW_CHARS = 1000;
Expand Down Expand Up @@ -127,7 +128,7 @@ function formatRunAction(title: string, details: AgentTeamDetails): string {

function formatRunSnapshot(run: RunSnapshot): string {
const controls = `Exceptional controls: message=${run.canMessage ? "live-clarification-only" : "unavailable"} cancel=${run.canCancel ? "stop-only" : "unavailable"} cleanup=${run.canCleanup ? "terminal-only" : "unavailable"}. Availability is not a recommendation; wait for notices when work is healthy.`;
return [`Run: ${modelText(run.runId)}`, `Objective: ${boundedModelText(run.objective, OBJECTIVE_PREVIEW_CHARS)}`, `Status: ${modelText(run.status)} terminal=${run.terminal}`, `Updated: ${modelText(run.updatedAt)}`, `Sinks: ${run.sinkStepIds.length > 0 ? run.sinkStepIds.map(modelText).join(", ") : "none"}`, `Live steps: ${run.liveStepIds.length > 0 ? run.liveStepIds.map(modelText).join(", ") : "none"}`, `Counts: ${formatCounts(run.counts)}`, run.lastEvent ? `Last event: ${modelText(run.lastEvent)}` : "Last event: none", controls].join("\n");
return [`Run: ${modelText(run.runId)}`, `Objective: ${boundedModelText(run.objective, OBJECTIVE_PREVIEW_CHARS)}`, `Status: ${modelText(run.status)} terminal=${run.terminal}`, `Updated: ${modelText(run.updatedAt)}`, `Sinks: ${run.sinkStepIds.length > 0 ? run.sinkStepIds.map(modelText).join(", ") : "none"}`, `Live steps: ${run.liveStepIds.length > 0 ? run.liveStepIds.map(modelText).join(", ") : "none"}`, `Counts: ${formatCounts(run.counts)}`, formatRunTeamCost(run.teamUsage), run.lastEvent ? `Last event: ${modelText(run.lastEvent)}` : "Last event: none", controls].filter(Boolean).join("\n");
}

function formatEffectiveStepTools(steps: StepSnapshot[]): string {
Expand Down Expand Up @@ -171,7 +172,7 @@ function formatStep(step: StepSnapshot): string {
const activity = step.lastActivity ? ` lastActivity=${JSON.stringify(modelText(step.lastActivity))}` : "";
const needs = step.needs.length > 0 ? step.needs.map(modelText).join(",") : "none";
const after = step.after.length > 0 ? ` after=${step.after.map(modelText).join(",")}` : "";
return `- ${modelText(step.id)}: ${modelText(step.status)} agent=${modelText(step.agentRef)}${optionalScalar(" model", step.model)}${optionalScalar(" thinking", step.thinking)}${optionalOutputLimit(step)} effectiveTools=${formatList(step.effectiveTools)}${optionalList(" extensionTools", step.extensionTools)}${optionalList(" skills", step.callerSkills)} needs=${needs}${after}${optionalChildSession(step)}${optionalRetryHistory(step)}${activity}${error}`;
return `- ${modelText(step.id)}: ${modelText(step.status)} agent=${modelText(step.agentRef)}${optionalScalar(" model", step.model)}${optionalScalar(" thinking", step.thinking)}${optionalOutputLimit(step)} effectiveTools=${formatList(step.effectiveTools)}${optionalList(" extensionTools", step.extensionTools)}${optionalList(" skills", step.callerSkills)} needs=${needs}${after}${formatStepCost(step.usage)}${optionalChildSession(step)}${optionalRetryHistory(step)}${activity}${error}`;
}

function formatEvent(event: BackgroundEvent): string {
Expand Down
18 changes: 18 additions & 0 deletions extensions/multiagent/src/result-model-text.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,21 @@
import type { RunSnapshot, StepUsage } from "./types.ts";

export function fmtTokens(n: number): string {
if (n < 1000) return String(n);
if (n < 10_000) return `${(n / 1000).toFixed(1)}k`;
if (n < 1_000_000) return `${Math.round(n / 1000)}k`;
return `${(n / 1_000_000).toFixed(1)}M`;
}

export function formatRunTeamCost(teamUsage: RunSnapshot["teamUsage"]): string | undefined {
if (!teamUsage) return undefined;
return `Team cost: $${teamUsage.cost.toFixed(4)} tokens: \u2191${fmtTokens(teamUsage.input)} \u2193${fmtTokens(teamUsage.output)} R${fmtTokens(teamUsage.cacheRead)} W${fmtTokens(teamUsage.cacheWrite)}`;
}

export function formatStepCost(usage: StepUsage | undefined): string {
return usage ? ` cost=$${usage.cost.toFixed(4)}` : "";
}

export function modelText(text: string): string {
return escapeOutputBlockMarkers(text).replace(/\s+/g, " ").trim();
}
Expand Down
Loading