pi-tiered-subagents · source guide and design history

How pi-subagents works,
and an early routing proposal

A guided tour of @gotgenes/pi-subagents v25.0.1 — the in-process sub-agent core used as source material — alongside early, incomplete proposals for this derivative. The approved specification supersedes those proposals.

This page records the inherited runtime and a superseded routing proposal. It is not the current user guide. For the current implementation, start with the configuration guide (roles, families, model overrides, settings) and the package README.

Documentation status: the inherited runtime is implemented and characterized; the routing proposal is not. The runtime typechecks against Pi 1.3.0 and a 2,181-test characterization suite passes. The approved design uses family strategies, concrete model IDs, shared extension settings, and no fuzzy explicit selection or price-based routing fallback.

Sections 4–7 record superseded experiments, not installation instructions or a verified implementation delta. The experimental src/tier/** patch they describe was not carried into the import: it never compiled, and its design is superseded by the specification. The verified state is recorded in status.md; consult SPEC.md for current requirements and the documentation index for provenance.

MIT · Chris Lasher fork of @tintinweb/pi-subagents 73 TS files · 13.7k lines tests: 88 files + 17 helpers · 2181 passing pi 1.3.0

01What the thing actually is

A Pi extension — not a subprocess supervisor, not an external service. A child agent is a real Pi session created inside the same Node process: its own model runtime, its own session manager, its own resource loader, its own agent loop. The parent's process hosts all of it.

src/tools/agent-tool.ts

subagent

Spawns an agent, in the foreground (streamed) or the background (returns an id immediately).

src/tools/get-result-tool.ts

get_subagent_result

Polls or awaits a background agent's status and result.

src/tools/steer-tool.ts

steer_subagent

Injects a message into a running child's turn.

src/service/service.ts

SubagentsService

A programmatic door. Other extensions call getSubagentsService().spawn(…) through a Symbol.for key on globalThis.

src/ui/subagents-settings.ts

/subagents:settings

The operator-facing editor for concurrency, turn limits, retention and interrupt policy.

src/ui/session-navigator.ts

/subagents:sessions

A read-only viewer over a finished child's transcript.

02The layer cake

The codebase is cut by role, not by feature. Every arrow points downward: a layer may depend on the ones beneath it, never the other way round. That is the whole architectural discipline of the package, and it is what makes the tier feature easy to bolt on — it lands in a layer that nothing else has to know about.

extensions/index.ts composition root — wires every collaborator tools/ the three LLM doors + spawn-config resolution service/ cross-extension API (Symbol.for on globalThis) ui/ live widget, settings, transcript viewer observation/ observers, notifications, outcome delivery lifecycle/ SubagentManager · Subagent · SubagentSession · createSubagentSession ConcurrencyLimiter · turn-limits · workspace bracketing · usage the record, the run, and the queue session/ model-resolver · session-config · prompts · provider-inheritance project-context · session-dir · env · package-exclusions how a child is configured config/ default-agents · custom-agents (.md loading) · agent-types invocation-config (merge + locks) · thinking-level the agent vocabulary src/tier/ ← added by this fork tier-map · tier-policy · model-id · tier-settings the tier vocabulary and the rule that uses it sits beside session/, never inside it reads the parent's model
The dependency direction is strictly downward. src/tier/ sits beside session/ and is consulted by it.

Pure core, impure edge

The two decisions that matter — resolveSpawnConfig() and assembleSessionConfig() — are pure functions with no SDK objects in their signatures. Everything impure (disk, session creation, the network) is injected from the composition root. This is intended to make behaviour testable; the copied package does not yet include an upstream test suite.

Narrow structural contracts

Collaborators are declared as small interfaces rather than the SDK's concrete classes, because the SDK types carry private fields that a test double cannot satisfy without a cast. E.g. ModelRegistry is { find, getAll, getAvailable? }, not SdkModelRegistry.

03The life of one spawn

From the moment the model calls subagent to the moment the parent gets a result. The two shaded steps are where the child's model is decided — remember them, because that is where tier routing hooks in.

1 AgentTool.execute() src/tools/agent-tool.ts the LLM-facing door; reloads user agents from disk first 2 resolveSpawnConfig() src/tools/spawn-config.ts — PURE type resolution · config merge · turn limits · tags ★ model resolution 3 runtime.buildSnapshot() src/lifecycle/parent-snapshot.ts freezes cwd, prompt, model, registry, parent conversation 4 SubagentManager.spawn() src/lifecycle/subagent-manager.ts creates the record; background runs queue FIFO on ConcurrencyLimiter 5 Subagent.run() src/lifecycle/subagent.ts optional workspace bracket; then creates the child session 6 assembleSessionConfig() src/session/session-config.ts — PURE prompt · toolNames · thinkingLevel ★ model 7 the child session is created src/lifecycle/create-subagent-session.ts own ModelRuntime (providers replayed), own SessionManager, own ResourceLoader 8 session.runTurnLoop(prompt) the child's own agent loop runs under a turn budget; resumes re-enter this same loop 9 terminal transition observer → notification → parent nudge the result reaches the parent as text tier routing decides the model
Steps 2 and 6 are the only places a child's model is chosen. The tool door and the service door each have their own.

Why the parent's model matters. A subagent that names no model runs on exactly the model the parent session is running on — same price, same reasoning budget, same provider. If a parent runs on gpt-6.1-sol at $10/Mtok output, a dozen parallel exploration subagents each cost as much as the parent. That inheritance is the behaviour this fork changes.

04Where the model is decided

Two independent doors, one shared vocabulary. The tool door runs when the model calls the subagent tool; the service door runs when another extension spawns an agent programmatically. Both end at the same question: which model does this child run on?

THE TOOL DOOR THE SERVICE DOOR resolveInvocationModel() src/session/model-resolver.ts was a model string given? tool param, or the agent file's model: no — nothing named one source = "parent" inherit the parent's model yes — did it resolve? exact "provider/modelId" first, then fuzzy scoring resolved source = "resolved" use it, verbatim did not resolve from a tool param → error from the agent file → fall back source = "fallback" the parent's model both branches land here resolveDefaultModel() src/session/session-config.ts does the agent file name a model? and is it available in this session? yes → use that model an explicit choice by the agent's author is left alone no → the parent's model the same inheritance the tool door arrives at ★ TIER ROUTING — added by this fork When the winning model came from the parent ("parent" or "fallback"), the tier policy replaces it with the model one rung down the ladder. When it was "resolved", it is left alone.
The three source values this fork added to ModelResolution — the only signal the tier policy needs.

The seam. resolveInvocationModel() and resolveDefaultModel() are the two places a child's model is chosen. Both already end at "use the parent's model". The tier policy replaces that one answer with "use the parent's model, one tier down" — and touches nothing else.

Fuzzy model matching, for reference

resolveModel(input, registry) is what turns "haiku" into a real model. It tries an exact provider/modelId match first, then scores every available model:

exact id or provider/id            → 100
id contains the query               →  60 + (query.length / id.length) * 30
name contains the query             →  40 + (query.length / name.length) * 20
every token appears somewhere       →  20   ← acceptance threshold

Only available models — those with credentials configured — are candidates. The error message when nothing matches lists the available models, because an unlisted model is the usual cause.

05The tier ladder

Pi has no notion of model tiers. Model carries a cost, a context window and a reasoning flag — nothing that says "this model is one notch above that one". So this package supplies the vocabulary itself, as data.

Why not derive tiers from price? Because price and tier disagree often enough to matter. gpt-6.1-sol costs $10/Mtok in output; gpt-5.6-terra costs $12/Mtok. A price order puts the "lighter" Terra above the "heavier" Sol. The order a user means is a product decision, not a pricing fact — so the ladder states the order, and price ranking is only the fallback for families the ladder does not name.

THE OPENAI GPT-6 LINE — as shipped in DEFAULT_TIER_MAP gpt-6-astra rung 0 — the flagship gpt-6*-sol rung 1 — the parent sits here matches gpt-6-sol and gpt-6.1-sol gpt-6-luna rung 2 — where the subagent goes gpt-5.6 family a separate ladder sol → terra → luna parent session gpt-6.1-sol subagent gpt-6-luna one tier down THE RULE 1. Find the group whose match regex accepts the parent's model id. 2. Find the rung of that group the parent's id belongs to. 3. Take the rung tiersDown below it (default 1). 4. The first pattern of that rung with an available model wins. 5. No group matched? Fall back to ranking the parent's family by price and taking the nearest cheaper sibling. THE PRICE FALLBACK For a family the map does not name — Anthropic, Google, the local providers — rank the parent's own family by output price and take the nearest cheaper one.
The GPT-6 line has no terra, so within it the order is astra → sol → luna. A parent on gpt-6.1-sol moves to gpt-6-luna.

What a rung pattern may be

PatternMatchesWhy
gpt-6-lunagpt-6-luna, gpt-6-luna-proA pro variant is the same model with a larger reasoning budget — the same tier.
gpt-6*-solgpt-6-sol, gpt-6.1-solOne glob for a whole generation line.
gpt-6.1-solexactly that idWhen a rung names one precise model.

Batch endpoints (:batch) are never tier targets — a batch request is answered on a 24-hour horizon, which no subagent run can wait for.

06Configuration

this fork · src/shared-settings.ts

the shared settings store

Concurrency, turn limits, retention windows, interrupt policy, mid-run updates, excluded packages, and per-provider prompt inheritance live in @juanibiapina/pi-extension-settings — the store every extension can share. Global at ~/.pi/agent/settings-extensions.json, project at <cwd>/.pi/settings-extensions.json, merged shallow — the project layer wins.

this fork · src/tier/tier-settings.ts

tiered-subagents.json

Tier routing lives in its own file, with the same two layers. Keeping them apart means the two packages can be installed side by side without one rewriting the other's keys.

// ~/.pi/agent/tiered-subagents.json   or   <project>/.pi/tiered-subagents.json
{
  "enabled": true,          // absent means "on"
  "tiersDown": 1,           // how many rungs to move down
  "groups": [               // absent means the shipped ladder
    {
      "name": "OpenAI GPT-6 family",
      "providers": ["openai", "openai-codex"],
      "match": "^gpt-6(\\.\\d+)?-",
      "tiers": [
        ["gpt-6-astra"],
        ["gpt-6*-sol"],
        ["gpt-6-luna"]
      ]
    }
  ]
}

groups replaces the shipped ladder wholesale rather than merging with it. A half-merged ladder is harder to reason about than either one alone. A layer that says nothing about a value leaves the default in place, so an empty project file cannot silently switch tier routing off.

Agent definitions

Three are embedded (general-purpose, Explore, Plan). User agents are Markdown with YAML frontmatter, discovered at <cwd>/.pi/agents/*.md and ~/.pi/agent/agents/*.md — project beats global, and a user file may shadow a default name.

---
display_name: Fast Reviewer
description: Reviews one file for correctness
tools: read, grep          # a YAML list works too: [read, grep]
model: openai/gpt-6-luna   # optional — the tier policy steps an inherited model down anyway
thinking: low
max_turns: 12
locked: model              # or: locked: [tools, model]
prompt_mode: replace
inherit_context: false
run_in_background: true
enabled: true
---
You are reviewing a single file…

07What this fork changes

The upstream tree is copied 1:1 — same directory layout, same #src/* import map, same file names — as initial source material. The diagram below records an early proposed delta, not six completed modifications. The experimental tier directory also has a barrel file; consult the specification rather than these historical counts for implementation scope.

upstream @gotgenes/pi-subagents @code-fixer-23/pi-tiered-subagents src/ (73 files) copied verbatim src/ (73 files) copied verbatim src/session/model-resolver.ts returns { model } | { error } src/session/model-resolver.ts + source: parent | resolved | fallback src/tools/spawn-config.ts uses resolution.model src/tools/spawn-config.ts + tier policy + a note in the result src/session/session-config.ts falls back to parentModel src/session/session-config.ts + tier policy on the service door src/lifecycle/create-subagent-session.ts no tier knowledge src/lifecycle/create-subagent-session.ts + passes the policy into the assembler src/tools/agent-tool.ts settings = { defaultMaxTurns } src/tools/agent-tool.ts + tierPolicy in the settings shape extensions/index.ts upstream's composition root extensions/index.ts + builds the TierSettingsManager src/tier/ (4 new files) model-id · tier-map · tier-policy · tier-settings no upstream equivalent added
Historical proposed change sites, not a verified implementation delta. The approved specification supersedes this plan, and the experimental patch it describes was not carried into the import.

The diagram and the table below record an early proposal, not what the tree contains today. The experimental src/tier/** patch never compiled, and its design — glob ladders with a price-ranked fallback — is superseded by the specification, which replaces price ranking and user-supplied matchers with family strategies over declared profile data. It was therefore not carried into the import; the runtime is back to upstream's model resolution until the specified design is implemented. See ADR 0002.

FileStatusWhat changed
src/tier/model-id.tsnot importedIdentity rules: what makes two ids the same model. Strips :batch and -pro, compiles glob patterns, derives a model's family.
src/tier/tier-map.tsnot importedThe vocabulary: a ladder of rungs per family, plus the shipped OpenAI ladder and a sanitizer for hand-edited settings.
src/tier/tier-policy.tsnot importedThe rule: find the parent's rung, take the one below, fall back to the price ranking, and explain the decision either way.
src/tier/tier-settings.tsnot importedReads tiered-subagents.json from both layers and hands the policy its inputs.
src/session/model-resolver.tsupstreamThe resolution result said where the model came from — only the tier policy needed that.
src/tools/spawn-config.tsupstreamApplied the policy to an inherited model and added a note naming both models.
src/session/session-config.tsupstreamThe same, for the service door that has no tool parameters.
src/lifecycle/create-subagent-session.tsupstreamCarried the policy into the assembler.
src/tools/agent-tool.tsupstreamRead the policy off the settings object it already receives.
extensions/index.tsupstreamBuilt and loaded the TierSettingsManager at the composition root.

08Things worth knowing before you change it

Provider inheritance (#812)

A child gets a fresh ModelRuntime, and runtime registrations live on the instance — so a child cannot resolve a provider the parent registered via pi.registerProvider. The composition root replays them onto the child's runtime. Break this and children die with "No API key found".

Prompt inheritance (ADR 0009)

A child on a "re-homing" provider adopts only the parent's operator-authored parts (portable), not its whole assembled prompt (full). The strategy is keyed on the child's provider — so a tier change that moves a child to another provider moves its prompt strategy with it.

Thinking levels are validated at the door

Pi silently clamps an unrecognized thinking level down to off, so a typo would quietly disable reasoning. Both doors reject it instead (#834). This is why THINKING_LEVELS is carried locally rather than imported.

Tool surface is session-resolved (ADR 0011)

An agent's tools: list is its complete capability allowlist. MCP patterns like mcp__server__* expand against the tools the parent session actually has, read per child because an MCP server connects in the background.

Turn budgets are not the same as limits

A run has a turn ceiling; it is also warned when it is near the ceiling (wrapUpTurns), so the child can finish and answer. Zero means unlimited.

Background runs queue

Background spawns wait for a slot against maxConcurrent and report their queue position. Foreground spawns hold the parent's run signal directly and are aborted by ESC either way.

The decisions upstream records as ADRs

The full set is copied into docs/decisions/ and the phase-by-phase history into docs/architecture/history/. These are the ones that constrain where the tier feature can live:

ADRDecisionConsequence for this fork
0002Extensions on a minimal coreThe package ships a service, not a walled garden — so tier routing belongs in the core's model decision, not in a wrapper around it.
0006Inherited prompt is identity onlyA tier change may move a child's provider, and therefore its prompt strategy — the prompt is assembled after the model is resolved.
0009Portable inheritance is provider-scopedSame: the child's provider selects the prompt strategy, so the model must be resolved first.
0011Tool surface sections are session-resolvedUnrelated to tiers, but it is why the assembler's tool list depends on the parent session.