compose

package
v2.8.0-dev.5 Latest Latest
Warning

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

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

Documentation

Overview

Package compose lifts reusable substrate wiring out of cmd/core-agent so library consumers can build the same agent stack the bundled binary does, per docs/compose-extraction-design.md (#386): substrate builders (compactor, agentic tools, MCP digest LLM fallback, context cache), operator-visible formatters, pricing operations, grant persistence, and multi-session construction (session factory, resumer, and authn wiring). Flag parsing and run() orchestration stay in the binary.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func AppendBuiltinAllowExtra deprecated

func AppendBuiltinAllowExtra(agentsDir, name string) error

AppendBuiltinAllowExtra adds name to permissions.builtin_allow_extras.

Deprecated: use config.AppendBuiltinAllowExtra.

func AppendPathScope deprecated

func AppendPathScope(agentsDir, pattern string) error

AppendPathScope adds pattern to path_scope.allow.

Deprecated: use config.AppendPathScope.

func AppendPathScopeEntry deprecated

func AppendPathScopeEntry(agentsDir, path, access string) error

AppendPathScopeEntry adds a typed path_scope.allow_paths entry.

Deprecated: use permissions.AppendPathScopeEntry.

func AppendPermissionsAllow deprecated

func AppendPermissionsAllow(agentsDir string, patterns []string) error

AppendPermissionsAllow adds patterns to permissions.allow.

Deprecated: use config.AppendPermissionsAllow.

func AppendPermissionsDeny deprecated

func AppendPermissionsDeny(agentsDir string, patterns []string) error

AppendPermissionsDeny adds patterns to permissions.deny.

Deprecated: use config.AppendPermissionsDeny.

func AutoContinueBootScan

func AutoContinueBootScan(deps SessionFactoryDeps, maxPerBoot int)

AutoContinueBootScan is the boot-time trigger for multi-session daemons: one pass over the persisted ACL rows that continues fresh interrupted sessions nobody re-touches (channel sessions). Candidates are found with bounded tail reads only — no agent construction for sessions that don't need it; the actual continuation happens by driving the registry's normal Lookup → resume path, so the lazy-path machinery is shared verbatim.

Guards, in order:

  • crash-loop breaker: >= breakerBootThreshold recent boots with attempted continuations → stand down this boot, loudly. A boot whose continuations survive ages out of the window naturally.
  • per-session single retry + cumulative cap: a session attempted inside breakerWindow is skipped this boot, and one attempted maxAttemptsPerSession times inside attemptLookback is skipped until the log ages — see the constants block for why counting is the only terminating bound.
  • maxPerBoot (config agent.auto_continue.max_per_boot, default 10): oldest interruption first; the remainder is logged, not silently dropped, and lazy touch still covers it.

Runs synchronously; callers launch it on a goroutine after the attach listener is up. Every failure path logs and returns — a broken scan must never take the daemon down.

func AutoContinueStartupSession

func AutoContinueStartupSession(ctx context.Context, h *eventlog.Handle, ag *agent.Agent, freshness time.Duration)

AutoContinueStartupSession is the #558 trigger: the single startup-time session of a headless --no-repl daemon. No ACL rows or scan exist for it — there is exactly one session, whose triple comes from the agent itself — but the boot-log guards matter MORE here than on the lazy path: this trigger fires on every boot with no human touch gating it, so it is the closest analogue of the boot scan. Same discipline: pre-classify (unlocked, read-only), then WRITE-AHEAD intent record, then the shared lock/classify/inject core. Call before the wake loop starts; the injected note latches the wake signal.

func BuildAgenticTools

func BuildAgenticTools(
	builtinTools []adktool.Tool,
	agentGetter func() *agent.Agent,
	provider models.Provider,
	smallModelID string,
) ([]adktool.Tool, error)

BuildAgenticTools constructs the agentic_* tool wrappers from docs/context-management-design.md Mechanism B. The wrappers call Agent.RunSubtask so raw tool output stays in the subtask and only the digest reaches the parent's context.

builtinTools is the already-constructed list (from tools.Build) — we pick out the canonical inner tools (read_file, fetch_url, grep, list_dir, glob) by name so the subtask shares the parent agent's permission gate and per-tool output caps. Wrappers whose required inner tool isn't in builtinTools are silently skipped (e.g. --no-builtin-tools, --disable-tools=read_file, or fetch_url disabled because url_scope.allow is empty).

agentGetter is the late-binding closure that resolves *Agent once agent.New finishes. provider+smallModelID is the cost- efficiency lever: when smallModelID is non-empty, the wrappers route subtasks through that cheaper model. Empty smallModelID means subtasks inherit the parent's model — the wrappers still work, just without the cost win.

func BuildCompactor

func BuildCompactor(cfg config.CompactionConfig) agent.Compactor

BuildCompactor constructs the auto-compaction trigger that the post-turn hook consults. Starts from the substrate's per-tier defaults (modeltier.DefaultCompactionThresholds) and the historical 0.85 fallback, then layers operator config overrides on top.

Resolution precedence for any one threshold lookup:

  1. cfg.ThresholdByTier[currentModelTier], when present
  2. The substrate per-tier default for that tier
  3. cfg.Threshold (single fallback), when set
  4. agent.DefaultCompactionThreshold

Operators who want to leave defaults alone provide an empty CompactionConfig — same behavior as agent.NewDefaultCompactor() returns directly.

func BuildMCPDigestLLMFallback

func BuildMCPDigestLLMFallback(
	agentRef **agent.Agent,
	provider models.Provider,
	modelID string,
) func(ctx context.Context, raw []byte) (mcp.LLMFallbackResult, error)

BuildMCPDigestLLMFallback constructs the closure that pkg/mcp invokes on responses the structural pruner can't reduce below threshold. Returns nil when the operator hasn't opted in — callers nil-check before setting DigestOptions.LLMFallback so the wrap stays on the shipped structural-only default.

Late binding via **agent.Agent: mcp.Build runs before agent.New (the toolsets need to be constructed to pass into the agent's options), so agentRef is populated by the WithPostConstruct hook after mcp.Build has already recorded the closure into DigestOptions.LLMFallback. The model can't invoke an MCP tool through the wrap before its first turn anyway, so the pointer is non-nil by the time the closure fires in practice.

modelID is resolved up-front so it appears in the startup log; the closure caches the adkmodel.LLM on first invocation and reuses it. Empty modelID → subagent inherits the parent's model (functionally correct, no cost win). SubagentModel on the returned result mirrors modelID verbatim so display-side pricing lookup uses the resolved tier, not the parent.

func BuildMultiSessionAuthn

func BuildMultiSessionAuthn(cfg config.MultiSessionConfig) (auth.Authenticator, auth.Caller, error)

BuildMultiSessionAuthn translates the operator's attach.multi_session config block into the pkg/auth Authenticator that the attach listener consults per-request. Returns:

  • authn: the resolved Authenticator (or nil for single-user mode)
  • fallback: the Caller stamped on requests that don't authenticate (used by the host's caller middleware as the no-cred default)
  • err: a fatal startup error if the config is internally inconsistent OR a referenced file can't be loaded

In single-user mode (multi_session.enabled = false), returns (nil, zero-Caller, nil) — the attach server defaults its own AnonymousAuth and the wiring is a no-op.

func BuildSessionFactory

func BuildSessionFactory(deps SessionFactoryDeps) attach.SessionFactory

BuildSessionFactory returns an attach.SessionFactory closure that constructs a fresh *agent.Agent per POST /sessions request. The closure captures the deps by value (slices + pointers); per-call it generates a unique sessionID, derives a per-session sub-gate + prompter, loads the per-caller instruction overlay, and assembles a minimal-but-functional agent.

The handler is responsible for calling RegisterOwned on the returned Registrant with the originating Caller.Identity — this factory deliberately does NOT register with the session registry itself, because that would self-register via the legacy Register() (no Owner stamp), losing the ACL ownership that's the whole point.

func BuildSessionResumer

func BuildSessionResumer(deps SessionFactoryDeps) attach.SessionResumer

BuildSessionResumer wires the attach server's SessionResumer. Reads the persisted ACL row from deps.ACLStore; materializes the original Caller from row.Owner; reconstructs the agent via ReproduceAgent with the EXPLICIT sessionID so ADK's session.Service reattaches the prior conversation history from the eventlog.

Returns nil when deps.ACLStore is nil — session-resume is opt-in. The attach server's Options.Resumer being nil leaves the legacy "Lookup miss = 404" behavior in place, no behavior change for pre-v2.5 deployments.

Resumer failures propagate to the registry; the registry's resumeAndRegister handles ErrSessionACLNotFound → ErrSessionNotFound translation. Other errors surface as 500 with the underlying cause (per docs/session-resume-design.md OQ #2).

func CfgToCatalogOverride

func CfgToCatalogOverride(m config.PricingMap) map[string]pricing.ModelRates

CfgToCatalogOverride translates config.PricingMap (the JSON-tagged per-model rate map operators put under model.pricing) into the pkg/pricing wire shape. nil-safe; an empty map means "no cfg override, fall through to the file + builtin layers".

func DescribeRefresh

func DescribeRefresh(w io.Writer, out pricing.RefreshOutcome)

DescribeRefresh renders a one-line summary of a pricing-refresh outcome to w. Surfaces the four distinct shapes operators care about: fresh write (model count), 304-not-modified, skipped (cache still within MinInterval), network failure (cache age + error so the operator knows to expect stale rates).

func FormatStartupSummary

func FormatStartupSummary(in StartupSummaryInputs) []string

FormatStartupSummary produces the config-summary block emitted at daemon startup right after the config / instruction / MCP / skills resolution completes. Six lines, one per topic, in the standard core-agent: <topic>: <detail> shape. Callers wrap each returned line with the `send` helper defined in run().

Kept pure (no I/O beyond os.Getenv for the Vertex env-var summary) so it can be unit-tested by table-driven fixtures. Operators reading the daemon log see these lines FIRST (before "attach listener on" and the other established lines) — this is the "what did the daemon actually load" answer that was silent before #212.

func NewFilteredLogWriter

func NewFilteredLogWriter(w io.Writer) io.Writer

NewFilteredLogWriter wraps w in a writer that drops noisy log lines from genai/ADK that embedding surfaces don't want exposed. Today the only filtered lines are the `Error context canceled` / `Error context deadline exceeded` messages from genai's SSE scanner, which fire every time a turn is interrupted mid-stream (genai/api_client.go:484 log.Printf's it unconditionally).

Anything that isn't filtered passes through to w unchanged, so consumer-supplied log lines still appear. Typical use:

log.SetOutput(compose.NewFilteredLogWriter(os.Stderr))

func PersistModelChoice deprecated

func PersistModelChoice(agentsDir, modelID string) error

PersistModelChoice writes the new model name.

Deprecated: use config.PersistModelChoice.

func PersistThemeChoice deprecated

func PersistThemeChoice(agentsDir, themeName string) error

PersistThemeChoice writes the theme selection.

Deprecated: use config.PersistThemeChoice.

func RebuildPricingCatalog

func RebuildPricingCatalog(cfg *config.Config, agentsDir, coreHome string) error

RebuildPricingCatalog re-reads every pricing source and installs the fresh catalog into usage.SetCatalog. Called after /pricing refresh + /pricing set so subsequent cost lookups see the new rates without a process restart.

func RefreshPricing

func RefreshPricing(ctx context.Context, cfg *config.Config, agentsDir, coreHome string) (string, error)

RefreshPricing is the /pricing refresh slash callback. Forces an out-of-cycle fetch (MinInterval: -1s) regardless of how recently the daily refresh ran, rebuilds the catalog, and returns a summary line for the chat scrollback.

func RegistryAgents

func RegistryAgents(reg *attach.SessionRegistry) []*agent.Agent

RegistryAgents unwraps the live *agent.Agent behind every registry entry whose Registrant exposes one (attachadapter.Adapter does). Duplicates collapse pointer-identity (the TUI /model swap leaves two entries wrapping distinct agents, so this mostly matters for hosts registering one adapter twice). Used by the daemon to feed agent.RegisterMetrics.

func RegistryTrackerProvider

func RegistryTrackerProvider(reg *attach.SessionRegistry) usage.TrackerProvider

RegistryTrackerProvider bridges attach-mode sessions into the usage metrics observer (usage.RegisterMetrics). Entries whose Registrant does not expose the wrapped *agent.Agent (custom hosts registering their own Registrant implementations) or whose agent has a nil tracker are silently skipped — those sessions won't appear in usage metrics.

The Entry's identity triple is authoritative for the metric attributes: it is complete from registration time, whereas the agent's own fields may lag construction.

func RenderContextStats

func RenderContextStats(s agent.ContextStats, parentInputRate float64) string

RenderContextStats formats Agent.ContextStats as a multi-line SystemMessage for the /context (alias /boundaries) slash. Empty sections collapse to a one-line "no X yet" so a fresh session still gets a meaningful response. Format intentionally mirrors /stats' two-column key-value layout for visual parity.

parentInputRate is the parent model's per-million-token input rate (in USD), used to compute the "savings vs. no-digest baseline" dollar figure for the Digest savings block. Zero means "unknown" (fresh session, provider-less pricing, or an unpriced model) — the block still renders but omits the dollar figures.

func ReproduceAgent

func ReproduceAgent(deps SessionFactoryDeps, caller auth.Caller, sid string, origin string) (*attachadapter.Adapter, context.CancelFunc, error)

ReproduceAgent constructs an *agent.Agent under (caller, sid) using the shared SessionFactoryDeps shape. Used both by the on-demand session factory (sid is freshly minted) and by the resumer (sid comes from the persisted ACL row — ADK's session.Service reattaches the prior conversation history when the same triple opens the eventlog).

origin is "created" (factory path) or "resumed" (resumer path) and flows into the operator-visible stderr log line so the daemon log distinguishes the two.

Returns the constructed agent wrapped in its attach adapter (the registry entry the handler registers via RegisterOwnedWithCancel / registerResumed — recover the agent with Adapter.Agent()) + a CancelFunc that stops the per-session wake-loop goroutine. The caller hands the cancel to the registry so eviction terminates the loop cleanly instead of leaking it past the session's lifetime. The wake loop's ctx is derived from deps.DaemonCtx — either source of cancellation (daemon shutdown or per-session evict) closes ctx.Done and the loop exits.

func SetPricing

func SetPricing(cfg *config.Config, agentsDir, coreHome, model string, inputPerMTok, outputPerMTok float64) (string, error)

SetPricing is the /pricing set slash callback. Reads the user file, writes/updates the manual entry, saves atomically, then rebuilds the catalog so the rate takes effect immediately.

Types

type AttachOptions

type AttachOptions struct {
	Listen           string
	UnixSocket       string
	TLSCert          string
	TLSKey           string
	ClientCA         string
	TokenEnv         string
	ReadOnly         bool
	PeerHub          bool
	RegisterTo       string
	RegisterName     string
	RegisterEndpoint string
	// UI enables the /ui/* route on the attach listener serving the
	// mast-web operator UI. Uses the embedded bundle from
	// internal/webui (populated by dev/tools/fetch-mast-web at build
	// time) unless UIDir overrides with a local directory. CLI-only
	// in the bundled binary — the config file's attach block has no
	// counterpart, so BuildAttachOptions leaves both zero.
	UI    bool
	UIDir string
}

AttachOptions is the resolved attach-listener configuration a host feeds into its attach.Options / registration wiring. One value bundles what the bundled CLI exposes as eleven `--attach-*` flags plus the config file's `attach` block; library consumers usually fill it straight from BuildAttachOptions and overlay their own flag/env precedence on top (the bundled CLI keeps its CLI-beats- config overlay in package main, where flag.Visit lives).

func BuildAttachOptions

func BuildAttachOptions(cfg config.AttachConfig) AttachOptions

BuildAttachOptions translates the config file's `attach` block into an AttachOptions value, expanding ${ENV_VAR} references in every string field so paths and addresses can be parameterized per deployment ("$RUNTIME_DIR/agent.sock"). This is the config half of the bundled CLI's flag-vs-config merge; hosts with their own flag surface apply whatever precedence they want on top.

type ConfigGrantStore deprecated

type ConfigGrantStore = permissions.ConfigGrantStore

ConfigGrantStore moved to pkg/permissions (#492) — it is the disk half of that package's GrantStore contract. The alias keeps existing `&compose.ConfigGrantStore{…}` wiring building unchanged.

Deprecated: use permissions.ConfigGrantStore.

type ContextCacheHandle

type ContextCacheHandle interface {
	// Delete tears down the remote cache resource. Call (typically
	// deferred) at daemon shutdown; safe on a manager whose cache
	// was never created.
	Delete(ctx context.Context)
}

ContextCacheHandle is the exported face of the wired Vertex context-cache manager. The manager itself lives in internal/vertexcache (its construction couples to the genai SDK's Caches client); everything a HOST needs after wiring — tearing the remote cache resource down at shutdown — is this one method, so promoting the whole manager would widen the stability surface for no consumer benefit (#489: the previous *vertexcache.Manager return type was un-nameable outside the module, making the function's result usable only via := inference).

func MaybeWireContextCache

func MaybeWireContextCache(
	ctx context.Context,
	provider models.Provider,
	cfg *config.Config,
	noContextCache bool,
	send func(string),
) ContextCacheHandle

MaybeWireContextCache builds the Vertex context-cache manager and installs its hooks on the provider when the following are all true:

  1. The provider is *gemini.Provider (concrete type — cache hooks live on that struct).
  2. Backend is Vertex (cfg.Model.Provider == "vertex").
  3. Caching is enabled in config (default ON; explicit enabled=false in cfg.Model.Vertex.ContextCache disables).
  4. The noContextCache kill switch (the CLI's --no-context-cache) was NOT set.

Returns the handle on success (caller wires deferred Delete) or nil when caching was skipped for any reason. Never fails hard: if constructing the sibling genai.Client fails, the helper logs and returns nil — the agent still starts, just without caching.

Contract note: every skip path returns a LITERAL nil (a nil interface), never a nil *Manager boxed into the interface — the caller's `handle != nil` guard is load-bearing because the manager's Delete is not nil-receiver-safe. Keep it that way when adding skip paths.

type SessionCustomization

type SessionCustomization struct {
	// Model drives the session's turns. Defaults to deps.Model.
	// When changed, per-turn cost attribution follows the new
	// model's name and its pricing is re-resolved from the layered
	// catalog (deps.Cfg overrides, pricing files, builtin).
	Model adkmodel.LLM
	// Tools is the flat tool list (defaults to deps.BuiltinTools).
	Tools []adktool.Tool
	// Toolsets is the toolset list (defaults to deps.Toolsets) —
	// MCP servers and skills bundles live here.
	Toolsets []adktool.Toolset
}

SessionCustomization is the per-caller slice of the session recipe a SessionCustomizer may change. Every field arrives pre-filled with the daemon-wide default (the slices are copies — append extends, reassign replaces, and neither corrupts the shared deps).

type SessionCustomizer

type SessionCustomizer func(ctx context.Context, caller auth.Caller, c *SessionCustomization) error

SessionCustomizer is SessionFactoryDeps.Customize — the per-caller hook that varies session construction without forking ReproduceAgent. ctx is the daemon lifetime context (construction may outlive the triggering request; a resume's work certainly does).

type SessionFactoryDeps

type SessionFactoryDeps struct {
	// DaemonCtx is the daemon's lifetime context — every per-session
	// wake loop spawned by the factory uses it as the cancellation
	// signal so SIGTERM / Ctrl-C ends them cleanly. Required.
	DaemonCtx context.Context

	Model          adkmodel.LLM
	Template       *permissions.Gate
	BuiltinTools   []adktool.Tool
	Toolsets       []adktool.Toolset
	EventlogHandle *eventlog.Handle
	PricingRate    usage.Pricing
	ProjectRoot    string
	UserRoot       string
	HomeAgentsDir  string
	AgentsDir      string
	UsersDir       string
	// EnvInterp is the ${env:VAR} interpolator wired from the daemon's
	// env manifest (see pkg/agentenv, #322). May be nil when the
	// bundle doesn't ship an env.yaml / env.json — loaders treat nil
	// as "no interpolation."
	EnvInterp func(string) string
	Registry  *attach.SessionRegistry
	// Cfg + MCPServers feed the read-only AttachXProvider closures
	// (memory / skills / mcp / pricing) so the per-session /memory,
	// /skills, /mcp, /pricing slash commands return real data
	// instead of "no servers configured" for on-demand sessions.
	Cfg        *config.Config
	MCPServers []*mcp.Server
	// ACLStore is the persistent ACL backing for session-resume
	// (Phase 2 of docs/session-resume-design.md). The factory
	// writes through it via RegisterOwned at session-creation time
	// (handled by the registry, not directly); the resumer reads
	// from it on Lookup miss to reconstruct evicted sessions. Nil
	// disables resume — the registry behaves as pre-v2.5.
	ACLStore attach.SessionACLStore
	// AutoContinueEnabled + AutoContinueFreshness switch on opt-in
	// continuation of restart-interrupted turns on the lazy-resume
	// path (#539, docs/auto-continue-design.md). Freshness 0 means
	// "no window" (always continue); the daemon parses and validates
	// the config strings so these are ready-to-use values here.
	// Requires EventlogHandle — with no durable eventlog there is
	// nothing to detect against.
	AutoContinueEnabled   bool
	AutoContinueFreshness time.Duration
	// NoCompact / NoCheckpoint mirror the --no-compact /
	// --no-checkpoint CLI flags. When false (the default),
	// ReproduceAgent wires WithCompactor / WithCheckpointer so
	// /compact and /done work against session-created agents; when
	// true, the corresponding option is skipped so the disable flag
	// applies uniformly to the main agent AND every session-created
	// agent under it.
	NoCompact    bool
	NoCheckpoint bool

	// Customize, when non-nil, runs at the top of every session
	// construction — POST /sessions creations AND lazy resumes — with
	// the caller the session belongs to (#505). It receives a
	// SessionCustomization pre-filled from the daemon-wide deps and
	// may vary the per-tenant knobs: the model, the tools, and the
	// toolsets (skills ride as toolsets — load a per-tenant skills
	// tree into a toolset here). Per-tenant PERMISSIONS need no hook:
	// every session already runs a sub-gate derived from Template,
	// and per-caller instructions layer via UsersDir.
	//
	// An error aborts the construction (the client sees the 500 with
	// this error's text). The hook must be safe for concurrent calls.
	//
	// On RESUME, Identity is the only Caller field populated (it is
	// materialized from the persisted ACL owner — Labels and Admin
	// are not stored). Key customization decisions on Identity
	// alone, or re-derive tenant metadata from your own store;
	// a hook keyed on Labels would silently build a different
	// session shape on lazy resume than it did at creation.
	Customize SessionCustomizer
}

SessionFactoryDeps bundles the daemon-wide configuration the per-session SessionFactory closure needs to capture. Constructed once at daemon startup; the resulting factory builds fresh *agent.Agent values for each POST /sessions request.

Substrate + config-only operator features are wired per-session: tools, eventlog, per-session sub-gate, per-caller instruction overlay, per-session prompter, plus Compactor / Checkpointer / CostCeiling (these are pure config, so per-session reconstruction is trivial and the alternative was /compact and /done erroring on every session-created agent). Features that need per-session scoping decisions the daemon doesn't yet make (BackgroundManager sharing, Watchdog alert-sink routing, agentic tool wrappers, MCP custom auth) remain deferred — sessions created via POST /sessions see the substrate without them.

type StartupSummaryInputs

type StartupSummaryInputs struct {
	// CfgPath is the value of the -c / --config flag as passed on the
	// CLI. Empty means the daemon fell through to config discovery
	// (walk-up from cwd looking for .agents/config.json).
	CfgPath string
	// Cfg is the fully-resolved config (post CLI overrides, post
	// task-class tier fills).
	Cfg *config.Config
	// AgentsDir is the resolved .agents/ directory (from
	// filepath.Dir(cfgPath) when -c is set, else from config
	// discovery). Empty means "no agentsDir was found" — the daemon
	// still runs; MCP + skills + record_plan just have nowhere to
	// live.
	AgentsDir string
	// ProviderName is the concrete provider name after resolution
	// (vertex / gemini / anthropic / anthropic-vertex / echo /
	// scripted). Comes from provider.Name() at the call site.
	ProviderName string
	// MCPServers describes every MCP server the daemon successfully
	// or unsuccessfully started. mcp.Server carries the name +
	// Status + Err — this summary calls the ones with a nil Err
	// "ok" and the ones with Status != "" but Err != nil "failed".
	MCPServers []*mcp.Server
	// LoadedSkills describes the discovered skills — count + names
	// via LoadedSkills.Infos.
	LoadedSkills skills.Skills
}

StartupSummaryInputs bundles everything FormatStartupSummary needs. Keeping the input surface explicit (vs pulling from package-level state) is what makes the formatter unit-testable.

Jump to

Keyboard shortcuts

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