specialists

package
v0.4.0 Latest Latest
Warning

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

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

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.

func BuildAll

func BuildAll(specs []Spec, opts BuildOptions) ([]adkagent.Agent, error)

BuildAll builds every Spec in specs using the same BuildOptions. Any error short-circuits and is returned with the offending Spec name.

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

type MCPAllowlist struct {
	Server string   `yaml:"server"`
	Tools  []string `yaml:"tools,omitempty"`
}

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

type ModelResolver func(name string) (model.LLM, error)

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.

func LoadDir

func LoadDir(dir string) ([]Spec, error)

LoadDir reads every *.tmpl file in dir non-recursively and parses each into a Spec. Results are returned sorted by Spec.Name for deterministic ordering.

func LoadFile

func LoadFile(path string) (Spec, error)

LoadFile reads and parses a single .tmpl file.

type TierResolver added in v0.4.0

type TierResolver func(tier string) (model.LLM, error)

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.

Jump to

Keyboard shortcuts

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