Documentation
¶
Overview ¶
Package specialists loads specialist .tmpl files from disk and turns them into ADK v2 agents. .tmpl files are YAML frontmatter (bounded by `---`) followed by a Markdown body used as the specialist's system prompt.
Schema follows docs/specialists-design.md. This package implements the spike subset (name, description, mode, instruction, model override) plus the output-schema contract. Tool allowlists are enforced at Build time (see filterToolsets), as is the model override: a spec's `model:` is resolved through BuildOptions.Resolve, and a declared override that cannot be resolved fails the build rather than falling back to the parent's model.
A spec may instead declare `tier: small | mid | frontier`, which is the portable half of the same override: it names how much model the step is worth, not which vendor's model it must run on, so a bundle that puts its twelve diagnosers on the cheap tier still runs on whichever provider the operator points mast at. It resolves through BuildOptions.ResolveTier (internal/compose maps tier → model ID for the running provider via pkg/taskclass.ModelForTier) and fails the build the same way `model:` does when it cannot be resolved. Declaring both on one spec is a load error: they are two answers to one question, and picking a winner silently would mean the loser's declaration was decoration.
A spec's `output_schema:` names a JSON-Schema document relative to the .tmpl file; it is read, normalized and checked at load time (see schema.go) and reaches the agent as llmagent.Config.OutputSchema. From there ADK enforces it — a violation is an error on both paths, never a warning. In Task mode the schema becomes the finish_task declaration and an invalid call is rejected back to the model with the validation error, so a bad shape cannot become the task's output. In SingleTurn mode the reply is validated on the way out and a failure propagates as a run error.
Budget fields are parsed here but enforced elsewhere, per field:
- max_wallclock_seconds — enforced in graph dispatch: pkg/graph maps it to workflow.NodeConfig.Timeout on the specialist's AgentNode (the sanctioned per-node wallclock knob).
- max_turns and max_cost_usd — enforced by the session meter, not here: cost and turns are derived from UsageMetadata on the runner's event stream, which Build never sees. The roster's declarations become budget scopes (internal/compose.MeterScopes → budget.Config.Scopes) that the meter buckets by event author, so a specialist with a tighter ceiling than its workload stops the run on its own — see pkg/budget, "Scopes", for the composition rule and its two known limitations.
Attribution is by session.Event.Author, which carries the agent's name on every dispatch shape mast builds. Event.Branch is not the seam: in the coordinator/sub-agent-tool shape it is empty.
Index ¶
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
func Build ¶
func Build(spec Spec, opts BuildOptions) (adkagent.Agent, error)
Build turns a Spec into an ADK agent, dispatching to Task or SingleTurn constructors based on Spec.Mode. The agent runs on spec.Model or spec.Tier when the spec declares one, opts.Model otherwise — see modelFor.
A spec's OutputSchema reaches both modes. The two enforce it differently — Task mode through the finish_task declaration, which rejects a malformed call back to the model, SingleTurn through validation of the reply, which fails the run — but in neither case can output that violates the contract become the specialist's result.
Task-mode specialists are always built unable to transfer, and are built with the stall guard when opts.OnStall asks for it. The two are the same hazard from opposite ends — a specialist that hands the question back, and one that never hands anything back — and only the first is safe to default, because only the first is a pure subtraction. See BuildOptions.OnStall.
Types ¶
type Budget ¶
type Budget struct {
MaxTurns int `yaml:"max_turns,omitempty"`
MaxWallclockSeconds int `yaml:"max_wallclock_seconds,omitempty"`
MaxCostUSD float64 `yaml:"max_cost_usd,omitempty"`
}
Budget captures the per-specialist runtime bounds. See docs/specialists-design.md schema for field semantics, and the package doc above for where each is enforced: MaxWallclockSeconds by graph dispatch (pkg/graph → NodeConfig.Timeout), MaxTurns and MaxCostUSD by the session meter (pkg/budget scopes).
type BuildOptions ¶
type BuildOptions struct {
// Model is the parent's model — the default every specialist runs
// on when it declares no `model:` override.
Model model.LLM
// Resolve resolves per-specialist `model:` overrides. Nil is legal
// only when no Spec in the roster declares an override; Build
// refuses a declared override it cannot resolve rather than
// silently running the specialist on the parent's model (the bug
// this field fixes — see docs/v0.3-plan.md W1.1).
Resolve ModelResolver
// ResolveTier resolves per-specialist `tier:` declarations. Nil is
// legal only when no Spec in the roster declares one; a declared
// tier with no resolver is a build error for the same reason a
// declared `model:` with no Resolve is.
ResolveTier TierResolver
Tools []tool.Tool
Toolsets []tool.Toolset
// OnStall, when non-nil, installs mastagent.FinishOnStall on every
// Task-mode specialist Build creates, using the payload this function
// returns for that spec. Returning nil selects
// mastagent.DefaultStallPayload.
//
// It is opt-in and it is per-spec, in that order of importance.
//
// Opt-in because the guard fabricates a tool call. Every other default in
// this package is a property of the wiring — which model, which toolsets,
// whether transfer is offered — and can be argued for without knowing what
// the roster does. This one puts words in a specialist's mouth on its
// behalf, and a roster that would rather see the delegation die should not
// have to discover the field to get that.
//
// Per-spec because the payload has to satisfy the spec's own
// output_schema, and only the roster knows what an empty value means in
// its own contract. Requesting the guard for a spec that declares a schema
// without returning a payload for it is a build error rather than a
// fallback, for the same reason a declared-but-unresolvable model override
// is: the fallback would produce a finish_task call the runtime refuses,
// leaving exactly the unresolved delegation the guard exists to prevent,
// and it would do it at run time on the one turn nobody is watching.
OnStall func(spec Spec) mastagent.StallPayload
}
BuildOptions carries the runtime bindings a Spec needs to become a concrete ADK agent. The model is required. Toolsets are offered to every built specialist but filtered through Spec.Tools.MCP first — see filterToolsets for the spike allowlist semantics.
type Capability ¶ added in v0.3.0
type Capability string
Capability is what a specialist is allowed to do to the world. It is the read/write half of the roster split: analysts diagnose, and a separate, declared specialist carries out changes.
The field exists because an allowlist alone cannot distinguish "this specialist may write" from "somebody added a write tool to a diagnoser and nobody noticed". A prompt saying *do not mutate* is not a control; a declaration a loader can refuse is. See docs/specialists-design.md, "Capability".
const ( // CapabilityReadOnly is a specialist that may not reach a mutating // tool. Default when capability: is absent — the safe direction, and // the one most specialists want. CapabilityReadOnly Capability = "read_only" // CapabilityChangeExecutor is a specialist that may. Declaring it is // not an approval: every mutating call it makes still goes to the // write gate (pkg/approval) like any other. CapabilityChangeExecutor Capability = "change_executor" )
type Frontmatter ¶
type Frontmatter struct {
Name string `yaml:"name,omitempty"`
Description string `yaml:"description"`
Mode Mode `yaml:"mode,omitempty"`
Model string `yaml:"model,omitempty"`
Tier string `yaml:"tier,omitempty"`
Capability Capability `yaml:"capability,omitempty"`
Budget Budget `yaml:"budget,omitempty"`
Tools ToolAllowlist `yaml:"tools,omitempty"`
// OutputSchema is a path to a JSON-Schema document, relative to the
// .tmpl file's own directory. It is a reference rather than an
// inline block on purpose — see the comment at the top of schema.go.
OutputSchema string `yaml:"output_schema,omitempty"`
}
Frontmatter is the YAML block at the top of a .tmpl file.
type MCPAllowlist ¶
MCPAllowlist is the per-MCP-server tool allowlist for a specialist.
type Mode ¶
type Mode string
Mode is the ADK v2 agent mode a specialist runs in.
const ( // ModeTask is a Task-mode specialist. Runs to a finish_task // completion. Default when mode: is absent. ModeTask Mode = "Task" // ModeSingleTurn is a SingleTurn-mode specialist. Runs exactly one // model call. The shape behind LLM-as-router classifiers. ModeSingleTurn Mode = "SingleTurn" )
type ModelResolver ¶ added in v0.3.0
ModelResolver turns a specialist's `model:` frontmatter override into a concrete model.LLM. It exists so pkg/specialists can honor the override without depending on the provider packages: this package knows a specialist declared "claude-haiku-4-5", and nothing else about what that string means.
Implementations are expected to memoize — a roster of eight analysts on the same tier should share one provider client, not open eight. internal/compose.NewModelResolver is the one mast ships; it resolves through the same BuildModel path the root model came from.
type Spec ¶
type Spec struct {
// Filename is the path (or filename) the spec was loaded from,
// preserved for diagnostics.
Filename string
// Frontmatter fields, promoted for convenience.
Name string
Description string
Mode Mode
Model string
Tier string
Capability Capability
Budget Budget
Tools ToolAllowlist
// Instruction is the body of the .tmpl file — the specialist's
// system prompt, verbatim.
Instruction string
// OutputSchema is the loaded, normalized and checked contract this
// specialist's output must satisfy, or nil when the spec declares
// none. Loaded eagerly by LoadFile so a broken schema is a load
// error rather than a surprise on the first live turn.
OutputSchema *genai.Schema
// OutputSchemaPath is the resolved path OutputSchema came from,
// preserved for diagnostics. Empty when the spec declares none.
OutputSchemaPath string
}
Spec is a fully-loaded specialist: parsed frontmatter plus the raw Markdown body used as the system prompt.
type TierResolver ¶ added in v0.4.0
TierResolver is the same seam for a specialist's `tier:` frontmatter: it turns "small" into whatever the running provider's small model is. The mapping lives outside this package for the same reason ModelResolver's does, and for one more — the answer depends on which provider the operator started mast with, which is a composition fact, not a roster fact. internal/compose.BuildRoot supplies the one mast ships; it goes through pkg/taskclass.ModelForTier and then through the same memoized ModelResolver, so a roster of twelve small-tier diagnosers still opens one client.
type ToolAllowlist ¶
type ToolAllowlist struct {
Builtin []string `yaml:"builtin,omitempty"`
MCP []MCPAllowlist `yaml:"mcp,omitempty"`
Skills []string `yaml:"skills,omitempty"`
}
ToolAllowlist is the composite allowlist of built-in tools, MCP tools, and skills a specialist may invoke.
Presence is significant per axis, per the normative table in docs/specialists-design.md: an absent field inherits everything on that axis, a present-but-empty field denies everything on it, and a non-empty field is a whitelist. `mcp: []` is therefore not the same declaration as no `mcp:` key at all — see InheritsAllMCP.
func (ToolAllowlist) InheritsAllMCP ¶ added in v0.3.0
func (t ToolAllowlist) InheritsAllMCP() bool
InheritsAllMCP reports whether this allowlist leaves the MCP axis unrestricted — i.e. the spec declared no `mcp:` key, so the specialist is offered every MCP toolset the workload has.
It exists because the distinction it draws is a nil check that reads like a typo. `mcp: []` decodes to an empty non-nil slice and means *deny every MCP tool*; a missing `mcp:` decodes to nil and means *grant them all*. Those are opposite outcomes one character apart, so the question gets asked through a named method rather than re-derived at each call site — filterToolsets enforces it, and internal/compose.CheckCapabilitySplit refuses the inherit-all case for a read_only specialist when the workload has a tool catalog.