pi-subagents works,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.
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.
Spawns an agent, in the foreground (streamed) or the background (returns an id immediately).
Polls or awaits a background agent's status and result.
Injects a message into a running child's turn.
A programmatic door. Other extensions call getSubagentsService().spawn(…) through a Symbol.for key on globalThis.
The operator-facing editor for concurrency, turn limits, retention and interrupt policy.
A read-only viewer over a finished child's transcript.
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.
src/tier/ sits beside session/ and is consulted by it.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.
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.
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.
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.
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?
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.
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.
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.
terra, so within it the order is astra → sol → luna. A parent on gpt-6.1-sol moves to gpt-6-luna.| Pattern | Matches | Why |
|---|---|---|
gpt-6-luna | gpt-6-luna, gpt-6-luna-pro | A pro variant is the same model with a larger reasoning budget — the same tier. |
gpt-6*-sol | gpt-6-sol, gpt-6.1-sol | One glob for a whole generation line. |
gpt-6.1-sol | exactly that id | When 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.
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.
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.
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…
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.
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.
| File | Status | What changed |
|---|---|---|
src/tier/model-id.ts | not imported | Identity rules: what makes two ids the same model. Strips :batch and -pro, compiles glob patterns, derives a model's family. |
src/tier/tier-map.ts | not imported | The vocabulary: a ladder of rungs per family, plus the shipped OpenAI ladder and a sanitizer for hand-edited settings. |
src/tier/tier-policy.ts | not imported | The 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.ts | not imported | Reads tiered-subagents.json from both layers and hands the policy its inputs. |
src/session/model-resolver.ts | upstream | The resolution result said where the model came from — only the tier policy needed that. |
src/tools/spawn-config.ts | upstream | Applied the policy to an inherited model and added a note naming both models. |
src/session/session-config.ts | upstream | The same, for the service door that has no tool parameters. |
src/lifecycle/create-subagent-session.ts | upstream | Carried the policy into the assembler. |
src/tools/agent-tool.ts | upstream | Read the policy off the settings object it already receives. |
extensions/index.ts | upstream | Built and loaded the TierSettingsManager at the composition root. |
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".
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.
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.
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.
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 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 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:
| ADR | Decision | Consequence for this fork |
|---|---|---|
0002 | Extensions on a minimal core | The package ships a service, not a walled garden — so tier routing belongs in the core's model decision, not in a wrapper around it. |
0006 | Inherited prompt is identity only | A tier change may move a child's provider, and therefore its prompt strategy — the prompt is assembled after the model is resolved. |
0009 | Portable inheritance is provider-scoped | Same: the child's provider selects the prompt strategy, so the model must be resolved first. |
0011 | Tool surface sections are session-resolved | Unrelated to tiers, but it is why the assembler's tool list depends on the parent session. |