Documentation
¶
Overview ¶
Package compose wires a workload bundle plus its specialist specs into a runnable root agent. It is the shared core behind the two entry points that construct dispatch shapes: cmd/mast (flag-driven) and the top-level mast convenience package (programmatic). Both MUST go through BuildRoot so the dispatch semantics — planner override, graph vs. coordinator, per-mode toolset offering — cannot drift between the binary and the library.
This is runtime glue, not public API (docs/library-api-design.md marks internal/ packages churnable); library consumers reach it via the root mast package or compose the pkg/ subsystems directly.
Index ¶
- func ApprovedChangeSet(logger *slog.Logger) graph.ChangeSetLookup
- func BuildClassRoot(class string, llm model.LLM, pauseRecorder planner.PauseRecorder) (adkagent.Agent, error)
- func BuildModel(ctx context.Context, provider, name string) (model.LLM, error)
- func BuildRoot(ctx context.Context, cfg RootConfig) (adkagent.Agent, error)
- func CheckCapabilitySplit(b workload.Bundle, specs []specialists.Spec, pred effects.Predicate, ...) error
- func CheckRoster(b workload.Bundle, specs []specialists.Spec, dispatch Dispatch) error
- func IsOfflineFake(name string) bool
- func MeterScopes(specs []specialists.Spec, provider, rootModelName string) map[string]budget.Limits
- func MutationPredicate(b workload.Bundle, logger *slog.Logger) effects.Predicate
- func NewModelResolver(ctx context.Context, provider, rootName string, root model.LLM, ...) specialists.ModelResolver
- func NewTierResolver(ctx context.Context, provider, rootName string, root model.LLM, ...) specialists.TierResolver
- func RatePer1K(modelName string) float64
- func SpecModelName(spec specialists.Spec, provider, rootModelName string) string
- func TierModelName(provider, rootModelName, tier string) (string, error)
- func WriteGate(cfg WriteGateConfig) (*plugin.Plugin, error)
- type Dispatch
- type RootConfig
- type WriteGateConfig
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
func ApprovedChangeSet ¶ added in v0.4.0
func ApprovedChangeSet(logger *slog.Logger) graph.ChangeSetLookup
ApprovedChangeSet reads back what the write gate recorded when a specialist's finding proposed an executable change (v0.4 W7.0), for pkg/graph's diagnoser→executor routing predicate.
This function is the seam between the two packages. pkg/graph cannot read the record itself — pkg/approval's own tests import pkg/graph, so the import can only go one way — and internal/compose is the one place that already imports both.
A read failure is "nothing was proposed", not an error: the state key is absent for every finding that proposed nothing, which is most of them. What it must never do is guess. Returning true on a value it could not decode would route an unreadable proposal to a specialist that changes clusters.
func BuildClassRoot ¶
func BuildClassRoot(class string, llm model.LLM, pauseRecorder planner.PauseRecorder) (adkagent.Agent, error)
BuildClassRoot constructs the runnable root agent for one public task class — the shape behind cmd/mast's one-shot path (`mast --task=<class> "<prompt>"`). The class → mode mapping is pkg/taskclass's (docs/orchestration-design.md "Public task classes"):
- chat → a Chat-mode coordinator (pkg/agent.NewCoordinator).
- debug / implement / research / review → a Task-mode agent, wrapped in a one-node workflow root because ADK v2.1.0's runner only accepts Chat-mode LlmAgent roots directly (same idiom as pkg/planner.NewRoot).
- orchestrate → the planner-enabled root (pkg/planner.NewRoot) with an empty specialist roster — the planner scaffold runs and reports honestly that no specialists are declared; a roster needs a workload bundle, which is serve-mode territory in v0.1.
Instruction precedence (pkg/taskclass modes.go): the class profile's per-class default is passed explicitly, so it beats the generic per-mode fallback; classes without per-class text (chat) fall through to pkg/agent's mode default via the constructor's own empty-Instruction rule. pauseRecorder enables the planner classes' pause_session tool when the caller has a durable store (nil otherwise — an in-memory pause would die with the process).
func BuildModel ¶
BuildModel constructs the model.LLM for the given provider alias and model name. The provider alias is only consulted where the model id alone is ambiguous (claude-* serves against api.anthropic.com or Vertex); everything else dispatches on the name.
- "echo": fake in-process echo model (no credentials required).
- "toolactor": request-driven offline fake that drives registered tool calls deterministically (pkg/agent/toolactor.go); the v0.2 UAT harness uses it to exercise the crash/drain/abort legs against a real blocking MCP tool. No credentials required.
- "scripted": JSONL recorded-turn replay via pkg/providers/mock; the recording path comes from MAST_SCRIPT, and MAST_SCRIPT_STRICT=1 enables strict Contents matching.
- "gemini-*": ADK's Gemini model wrapped in pkg/providers/gemini's builtin-tool layer (GoogleSearch + URLContext on — core-agent's defaults; Vertex vs API key is genai's env-driven selection).
- "claude-*": pkg/providers/anthropic; see anthropicProvider for backend selection.
func BuildRoot ¶
BuildRoot builds the roster and assembles the dispatch shape. Every shape is refused up front if the roster's read/write split does not hold — see CheckCapabilitySplit.
- bundle.Planner.Enabled → the supervisor-body planner root (pkg/planner); Dispatch is ignored.
- DispatchGraph → the workflow graph (pkg/graph); errors without a SingleTurn classifier.
- DispatchFanout → the concurrent-analysts fan-out shape (pkg/graph.BuildFanout); errors without a graph.SynthesisName specialist, or if any analyst can reach a mutating tool.
- DispatchBounded → the one-call shape (bounded.go); errors unless the roster is exactly one SingleTurn specialist declaring an output_schema.
- DispatchCoordinator → the SubAgents coordinator (pkg/router).
- DispatchAuto/empty → the bundle's own `dispatch:` when it names one (see Dispatch.Resolve); otherwise fanout when the roster has a synthesis specialist, graph when it has both a SingleTurn classifier and a graph.FallbackName specialist, else coordinator.
func CheckCapabilitySplit ¶ added in v0.3.0
func CheckCapabilitySplit(b workload.Bundle, specs []specialists.Spec, pred effects.Predicate, logger *slog.Logger) error
CheckCapabilitySplit is W2.4: a specialist may only reach a mutating tool if it says so.
The rule is one line — a roster's write surface must be declared, per specialist, in a field rather than in a prompt — and the reason is that the alternative was load-bearing until now. The shipped gke-triage diagnosers held `patch_resource` and were restrained by the sentence "Do NOT mutate anything on your own initiative", which is a suggestion to a language model, not a control. Declaring `capability: change_executor` is not an approval either: every mutating call still goes to the write gate. What it buys is that adding a write tool to a diagnoser now fails the roster at startup, naming the specialist and the tool, instead of quietly widening what an incident can do to a cluster.
Three cases count as reaching a mutating tool, and only the first is the obvious one (this mirrors pkg/graph's fan-out branch check, which found the other two):
- the specialist names a tool the predicate does not classify read-only;
- it names an MCP server with no tools: list, which grants it every tool on that server, present and future;
- it declares no tools.mcp key at all while the workload declares a tool catalog, which grants it the whole catalog.
Under mast's default-deny-unknown predicate an un-enumerated grant is a grant of mutating tools whether or not any exist today, so cases 2 and 3 are refusals rather than warnings. The cost is real — a roster has to classify its read tools by name in tool_catalog.tools — and it is the intended cost: the alternative is trusting a tool's name.
Case 3 turns on presence, not length: `mcp: []` is the documented deny-all spelling and passes, because a specialist that reaches no MCP tool at all reaches no mutating one. That is the spelling for a pure-reasoning specialist — a synthesis node, a summarizer — in a workload that does have a catalog.
SingleTurn specialists are exempt. They are built without toolsets (see BuildRoot), so a classifier cannot reach a tool of any class, and requiring it to enumerate an allowlist it will never use would be ceremony.
The boundary worth knowing: this checks *declarations*. A library embed that passes Toolsets directly and composes its own Specs can hand a read-only specialist a mutating tool without saying so, and nothing here will see it — enumerating a live toolset means connecting to every MCP server at construction. The write gate is the runtime backstop for that path: an undeclared mutating call still parks.
func CheckRoster ¶ added in v0.4.0
CheckRoster answers "could this roster ever be this shape?" from the bundle and the specs alone — no model, no provider, no toolsets.
It exists because BuildRoot is not the first thing a daemon does. The binary wires the workload's MCP servers first, and wiring one is a real side effect: a process spawned, or an OAuth token fetched. A roster the shape can never build should not cost either, and in an environment without credentials it costs worse than that — the operator gets "initial Google OAuth token fetch: 403" as the reason mast would not start, when the actual reason is that they pointed `--dispatch=bounded` at a fourteen-specialist roster. The token fetch was never going to make that roster buildable.
Static checks only, and deliberately a subset of BuildRoot's: the dispatch enum and the bounded roster contract. CheckCapabilitySplit is just as static but stays where it is, because it logs the audited mutation-class overrides as it goes and those lines are worth exactly one appearance in a startup log.
BuildRoot re-runs both checks rather than trusting a caller to have called this first — every refusal here is a pure function of its arguments, so running it twice costs nothing and skipping it would make the library path weaker than the binary's.
func IsOfflineFake ¶ added in v0.3.0
IsOfflineFake reports whether name is one of mast's offline test doubles.
func MeterScopes ¶ added in v0.3.0
MeterScopes derives the per-specialist budget scopes for a roster: the ceilings a spec declares (`max_turns`, `max_cost_usd`) plus, when it declares a `model:` override or a `tier:`, that model's price. Specialists that declare neither get no scope — they are metered into the session totals and nothing else, which is what an un-tiered, un-capped roster wants.
The price follows SpecModelName, so a `tier:` is priced at the model the tier resolved to and not at the root's rate; that is why the provider the run was started with is a parameter here.
max_wallclock_seconds is deliberately absent: it is a node-level knob (pkg/graph maps it onto workflow.NodeConfig.Timeout), not something a usage meter can see.
Pricing collapses under an offline fake, on the same condition NewModelResolver collapses the models themselves: if the root model is echo/scripted/toolactor then every override resolved back to it, so every token was produced by the fake and pricing a specialist at its declared tier would report a cost that provably did not happen. The consequence is worth stating plainly — per-model cost attribution cannot be demonstrated end-to-end in a credential-free test tier, because the condition that makes the tier credential-free is exactly the condition that collapses the tiers. The derivation is unit- testable here; the end-to-end claim needs real models.
func MutationPredicate ¶ added in v0.3.0
MutationPredicate builds the tool mutation classifier for a bundle: mast's default-deny-unknown stance, narrowed by the workload's audited tool_catalog.tools overrides.
The conversion exists because pkg/effects deliberately does not import pkg/workload (that would drag the YAML loader into every library embed that only wants the guard), so somebody who imports both has to bridge the two ToolPolicy types. compose imports both.
func NewModelResolver ¶ added in v0.3.0
func NewModelResolver(ctx context.Context, provider, rootName string, root model.LLM, logger *slog.Logger) specialists.ModelResolver
NewModelResolver returns the specialists.ModelResolver that binds a specialist's `model:` override to a concrete model.LLM. Resolution goes through BuildModel, so an override is dispatched exactly like a --model value: by model id, with provider only disambiguating the Anthropic backend.
Two rules shape it, and both are load-bearing:
Cross-provider overrides are allowed (specialists-design open Q#4, resolved 2026-08-12). BuildModel already dispatches on the model id, so a gemini-* specialist under a claude-* parent needs no new machinery; refusing it would mean inventing a provider-family classifier as a second source of truth beside BuildModel's own dispatch. The price is that credentials for every distinct provider in the roster must resolve, and they must resolve at construction — which is where the error lands, not mid-incident on first call.
When the root model is an offline fake, every override collapses back to it. A bundle that declares real model tiers must still run under `--model=echo` / `scripted` / `toolactor`, or tiering a bundle would silently break the offline S/U/E test tiers (docs/v0.3-plan.md §2) and scripts/demo-spike2.sh — the whole process is a test double, and there is nothing to tier.
Resolution is memoized per model id: eight analysts on one tier share one provider client.
func NewTierResolver ¶ added in v0.4.0
func NewTierResolver(ctx context.Context, provider, rootName string, root model.LLM, resolve specialists.ModelResolver, logger *slog.Logger) specialists.TierResolver
NewTierResolver builds the specialists.TierResolver mast ships. It resolves through resolve — the memoized ModelResolver — rather than calling BuildModel itself, so a roster of twelve small-tier diagnosers shares the one client the tier maps to.
Offline fakes collapse before the mapping is attempted, not after: under `--model=echo` there is no provider family to derive and nothing to tier, and a tiered bundle must still run in the credential-free S/U/E test tiers. Same rule NewModelResolver applies to `model:` overrides, for the same reason.
func RatePer1K ¶
RatePer1K derives pkg/budget's flat USD-per-1K-total-tokens rate for a model name (budget.Limits.RatePer1K — API unchanged).
Gemini and Claude rates come from pkg/pricing's builtin catalog (longest-prefix lookup, so dated/suffixed IDs land). The catalog prices input and output tokens separately, but the budget meter only sees UsageMetadata.TotalTokenCount, so the flat rate is the plain average of the two per-MTok rates scaled to per-1K — a deliberate v0.1 approximation that overcharges input-heavy sessions and undercharges output-heavy ones rather than complicating the budget API. Gemini IDs the catalog doesn't know keep the old flat spike rate so cost metering never silently drops to zero.
The echo fake keeps its inflated rate: offline smoke tests (scripts/demo-spike2.sh scenario 3) trip small caps with it. The scripted replay and toolactor share it — all offline test doubles.
func SpecModelName ¶ added in v0.4.0
func SpecModelName(spec specialists.Spec, provider, rootModelName string) string
SpecModelName is the model id a spec will actually run on, or "" when it inherits the parent's. It exists so the two places that need that answer — building the agent and pricing its tokens — derive it the same way. They did not, at first: MeterScopes priced off `s.Model` alone, which would have billed every tiered specialist at the root model's rate while the audit log showed the tiered one.
Errors are swallowed to "" on purpose: the callers that price cannot act on them, and BuildRoot has already refused the roster by the time anything is metered.
func TierModelName ¶ added in v0.4.0
TierModelName maps a `tier:` declaration to a concrete model id for the running provider. It is the whole of tier resolution; everything else in this file is plumbing around it.
An unmappable tier is an error, not a fall-through to the root model. A roster that declares tiers and silently runs every specialist on the parent's model is the exact fiction W1.1 removed for `model:`.
func WriteGate ¶ added in v0.3.0
func WriteGate(cfg WriteGateConfig) (*plugin.Plugin, error)
WriteGate builds the pre-call write gate for a workload (docs/v0.3-plan.md W2.1/W2.2; docs/orchestration-design.md hitl_policy.on_mutation).
It returns (nil, nil) when there is no bundle. The gate's whole mechanism is parking a call until an operator answers, and the default place an operator's answer arrives is the daemon's resume path against a durable session. A library embed that constructs its own runner with no workload has neither, so defaulting it on there would park mutating calls in a process with no way to un-park them — a hang, not a safety property. Such an embed opts in by passing a bundle (or by registering pkg/approval's plugin itself).
With a bundle, the policy is hitl.on_mutation, whose default is require_approval: a workload that says nothing about mutation gets gated. Registration order matters and is settled — the effects outbox runs first, so a call whose result is being replayed from the log is never re-approved (resolved-decision row 144).
Types ¶
type Dispatch ¶
type Dispatch string
Dispatch selects the root shape BuildRoot assembles.
const ( // DispatchCoordinator is the spike-1 SubAgents pattern: a // Chat-mode coordinator with the roster as SubAgents (pkg/router). DispatchCoordinator Dispatch = "coordinator" // DispatchGraph is the spike-2 workflow-graph LLM-as-router shape // (pkg/graph). Requires a SingleTurn classifier in the roster. DispatchGraph Dispatch = "graph" // DispatchFanout is the W3 fan-out shape (pkg/graph.BuildFanout): // the roster's Task specialists run concurrently as read-only // analysts and a graph.SynthesisName specialist merges what they // return. Requires that specialist, and refuses to build an analyst // that can mutate. DispatchFanout Dispatch = "fanout" // DispatchBounded is the W4.3 one-call shape (see bounded.go): a // roster of exactly one SingleTurn specialist that declares an // output_schema, run as a single workflow node. No router, no tool // loop, no second specialist to choose between — so a cycle costs // one model call, and that is a number rather than a hope. DispatchBounded Dispatch = "bounded" // DispatchAuto picks the shape from the roster: fanout when a // graph.SynthesisName specialist is present, graph when a // SingleTurn classifier and a graph.FallbackName Task specialist // are both present (the pair graph dispatch needs), coordinator // otherwise. This is the library default — programmatic callers // declare a roster, not a flag. // // It never picks bounded; see RosterShape. DispatchAuto Dispatch = "auto" )
func RosterShape ¶ added in v0.3.0
func RosterShape(specs []specialists.Spec) Dispatch
RosterShape reads the dispatch shape out of a roster: fan-out when it has a synthesis merger, graph when it has both a SingleTurn classifier and a graph.FallbackName Task specialist, coordinator otherwise.
Exported because BuildRoot is not the only caller that has to know the shape — cmd/mast's boot-time auto-resume pass runs only under coordinator dispatch, and a second copy of this rule living there is a copy that drifts. It reads specs rather than built agents so a caller can ask before paying for construction.
Why bounded is never inferred ¶
A roster of one SingleTurn specialist that declares an output_schema is exactly the bounded shape's roster, and it falls through to coordinator here on purpose. Every other shape this function infers is one a roster cannot have arrived at by accident — you do not add a `_synthesis` specialist without meaning fan-out. A one-specialist roster is what every workload looks like on its first day, and inferring bounded from it would silently take the coordinator away from bundles that have been running on it since v0.1: the coordinator answers in its own voice, the bounded shape returns the specialist's JSON report, and no error is raised on the way. `dispatch: bounded` is therefore a declaration, never a deduction — the same reasoning that keeps cmd/mast's terminal default at coordinator rather than auto (see resolveDispatch).
func (Dispatch) Resolve ¶ added in v0.3.0
Resolve returns the dispatch shape to build, given the caller's choice and the bundle's own declaration.
A shape is a property of the roster — fan-out needs read-only analysts and a synthesis specialist, graph needs a SingleTurn classifier and a `_fallback` — so a bundle that declares one is stating a fact about itself, not a preference. It therefore wins over an unspecified caller, and loses to a caller that named a shape explicitly (an operator overriding one run).
type RootConfig ¶
type RootConfig struct {
// Bundle is the workload definition (naming, roster order,
// planner/HITL policy).
Bundle workload.Bundle
// Specs is the loaded specialist roster. Specs with an empty Mode
// build as Task-mode (the same default pkg/specialists applies).
Specs []specialists.Spec
// Model is the root model: the one the coordinator/planner runs on
// and the default for every specialist that declares no `model:`
// override.
Model model.LLM
// ModelName and Provider are the strings Model was built from (the
// --model / --provider values). They are what per-specialist
// `model:` overrides resolve against — see NewModelResolver.
//
// Leaving ModelName empty is legal (a library caller may hand over
// a model.LLM it constructed itself); overrides then resolve on
// their own model id, with provider selection falling back to the
// env-driven detection in BuildModel.
ModelName string
Provider string
// Toolsets are offered to Task-mode specialists (and filtered
// through each spec's allowlist by specialists.Build). SingleTurn
// classifiers never receive toolsets — they run one shot with no
// tool loop.
Toolsets []tool.Toolset
// Dispatch selects the root shape. Empty means DispatchAuto.
Dispatch Dispatch
// Logger, when non-nil, receives the same construction-time notes
// cmd/mast has always logged (e.g. planner overriding dispatch).
Logger *slog.Logger
// PauseRecorder enables the planner's pause_session tool (v0.2
// plane-A self-pause) by giving it a durable record sink —
// *transcript.Store, or the daemon's scheduler-aware wrapper. Nil
// (no durable store) leaves the tool unregistered.
PauseRecorder planner.PauseRecorder
}
RootConfig carries everything BuildRoot needs to turn a loaded bundle + specs into a root agent. Bundle and Specs use the existing pkg/workload and pkg/specialists vocabulary — file-loaded and programmatic values are indistinguishable here by design (docs/library-api-design.md, "Embeddable config vs. file-loaded config").
type WriteGateConfig ¶ added in v0.3.0
type WriteGateConfig struct {
// Bundle is the loaded workload, or nil when there is none.
Bundle *workload.Bundle
// Predicate classifies tools. Optional: WriteGate derives one from
// the bundle when it is nil. Pass the one the effects outbox is
// using so the two plugins agree about what a mutation is —
// disagreement means either a call that is recorded but ungated or
// one that is gated but unrecorded.
Predicate effects.Predicate
// Gate decides policy for a parked call. Optional: WriteGate builds
// a default gate when the policy needs one and this is nil.
Gate *permissions.Gate
// Specs is the loaded roster, used to work out which tools a change
// executor in it can actually run. Optional: without it the
// producer contract still checks that a proposed change names a
// real tool with valid arguments, it just cannot narrow that to one
// specialist's allowlist.
Specs []specialists.Spec
// ToolSchemas resolves a wired tool's declared input schema by
// name, for the producer contract (v0.4 W7.0). It answers two
// questions at once: whether this daemon holds a tool by that name
// at all, and what arguments it takes.
//
// Optional, and its absence is fail-closed rather than fail-open:
// with a bundle and no resolver, a specialist that proposes a
// change gets it refused, because nothing here can tell an
// executable call from an invented one. A roster whose report
// schema has no proposed_change field is unaffected either way.
ToolSchemas func(toolName string) (*jsonschema.Schema, error)
// ToolRead runs a read-only tool on mast's own behalf and returns
// its result, for change-set freshness preconditions (v0.4 W7).
// It is mast reading the cluster, not the agent: the call is not in
// the model's transcript and its result is not shown to the model,
// because its only consumer is the comparison between what the
// world looked like when an operator approved a change set and what
// it looks like now.
//
// Optional, and fail-closed in the same direction as ToolSchemas: a
// tool that declares a precondition and a deployment that cannot
// run reads simply mints no grant, so its calls park one at a time.
ToolRead func(ctx adkagent.Context, toolName string, args map[string]any) (map[string]any, error)
Logger *slog.Logger
}
WriteGateConfig configures WriteGate.