compose

package
v0.3.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 14, 2026 License: Apache-2.0 Imports: 31 Imported by: 0

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

Constants

This section is empty.

Variables

This section is empty.

Functions

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

func BuildModel(ctx context.Context, provider, name string) (model.LLM, error)

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

func BuildRoot(ctx context.Context, cfg RootConfig) (adkagent.Agent, error)

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.
  • 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):

  1. the specialist names a tool the predicate does not classify read-only;
  2. it names an MCP server with no tools: list, which grants it every tool on that server, present and future;
  3. 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 IsOfflineFake added in v0.3.0

func IsOfflineFake(name string) bool

IsOfflineFake reports whether name is one of mast's offline test doubles.

func MeterScopes added in v0.3.0

func MeterScopes(specs []specialists.Spec, rootModelName string) map[string]budget.Limits

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, 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.

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

func MutationPredicate(b workload.Bundle, logger *slog.Logger) effects.Predicate

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 RatePer1K

func RatePer1K(modelName string) float64

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 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"

	// 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.
	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.

func (Dispatch) Resolve added in v0.3.0

func (d Dispatch) Resolve(b workload.Bundle) Dispatch

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

	Logger *slog.Logger
}

WriteGateConfig configures WriteGate.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL