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, Anthropic prompt 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 ¶
- func AppendBuiltinAllowExtra(agentsDir, name string) errordeprecated
- func AppendPathScope(agentsDir, pattern string) errordeprecated
- func AppendPathScopeEntry(agentsDir, path, access string) errordeprecated
- func AppendPermissionsAllow(agentsDir string, patterns []string) errordeprecated
- func AppendPermissionsDeny(agentsDir string, patterns []string) errordeprecated
- func AutoContinueBootScan(deps SessionFactoryDeps, maxPerBoot int)
- func AutoContinueRetryLoop(ctx context.Context, interval time.Duration, pass func())
- func AutoContinueStartupSession(ctx context.Context, h *eventlog.Handle, ag *agent.Agent, ...)
- func BuildAgenticTools(builtinTools []adktool.Tool, agentGetter func() *agent.Agent, ...) ([]adktool.Tool, error)
- func BuildCallPeerTool(gate *permissions.Gate, cfg *config.Config, getReg func() *attach.PeerRegistry) (adktool.Tool, error)
- func BuildCompactor(cfg config.CompactionConfig) agent.Compactor
- func BuildMCPDigestLLMFallback(agentRef **agent.Agent, provider models.Provider, modelID string) func(ctx context.Context, raw []byte) (mcp.LLMFallbackResult, error)
- func BuildMultiSessionAuthn(cfg config.MultiSessionConfig) (auth.Authenticator, auth.Caller, error)
- func BuildSessionFactory(deps SessionFactoryDeps) attach.SessionFactory
- func BuildSessionResumer(deps SessionFactoryDeps) attach.SessionResumer
- func BuiltinToolsSummary(provider models.Provider) string
- func CfgToCatalogOverride(m config.PricingMap) map[string]pricing.ModelRates
- func DescribeRefresh(w io.Writer, out pricing.RefreshOutcome)
- func FormatStartupSummary(in StartupSummaryInputs) []string
- func MaybeWirePromptCache(provider models.Provider, noPromptCache bool) (status string, enabled bool)
- func MaybeWirePromptCacheTTL(provider models.Provider, noPromptCache bool, ttl string) (status string, enabled bool)
- func NewFilteredLogWriter(w io.Writer) io.Writer
- func PeerDirectory(getReg func() *attach.PeerRegistry, now func() time.Time) peer.Directory
- func PersistModelChoice(agentsDir, modelID string) errordeprecated
- func PersistThemeChoice(agentsDir, themeName string) errordeprecated
- func RebuildPricingCatalog(cfg *config.Config, agentsDir, coreHome string) error
- func RefreshPricing(ctx context.Context, cfg *config.Config, agentsDir, coreHome string) (string, error)
- func RegistryAgents(reg *attach.SessionRegistry) []*agent.Agent
- func RegistryTrackerProvider(reg *attach.SessionRegistry) usage.TrackerProvider
- func RenderContextStats(s agent.ContextStats, parentInputRate float64) string
- func ReproduceAgent(deps SessionFactoryDeps, caller auth.Caller, sid string, origin string) (*attachadapter.Adapter, context.CancelFunc, error)
- func SetPricing(cfg *config.Config, agentsDir, coreHome, model string, ...) (string, error)
- type AttachOptions
- type ConfigGrantStoredeprecated
- type ContextCacheHandle
- type SessionBackgroundFactory
- type SessionCustomization
- type SessionCustomizer
- type SessionFactoryDeps
- type SessionScope
- type SessionSubagents
- type StartupSummaryInputs
- type WakeLoopGroup
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
func AppendBuiltinAllowExtra
deprecated
func AppendPathScope
deprecated
func AppendPathScopeEntry
deprecated
func AppendPermissionsAllow
deprecated
func AppendPermissionsDeny
deprecated
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 AutoContinueRetryLoop ¶
AutoContinueRetryLoop re-runs a guarded auto-continue pass on a fixed interval until ctx cancels. It runs NO pass immediately — callers keep their existing boot-time invocation, so the note-latches-before-wake- loop ordering is unchanged; this loop only adds the in-lifetime *re-tries* that let a stable daemon self-heal a stranded continuation without waiting for a reboot or a human message (#575 defect B).
Safety: a continuation turn that kills the daemon kills this loop too, so the loop can only ever re-fire failures that did NOT take the daemon down (transient run-lock contention, a provider blip, a poisoned-but-survivable turn). True crash loops remain bounded by the cross-boot breaker, and every pass is still bounded by the per-session single-retry guard (breakerWindow) + cumulative cap (maxAttemptsPerSession) — so no separate backoff schedule is needed: a session attempted within breakerWindow simply no-ops on the next tick, and the cap terminates a self-renewing session.
interval <= 0 disables the loop (returns immediately). Callers launch it on a goroutine tracked by a WaitGroup joined before the eventlog closes, so a mid-tick pass never races DB teardown.
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 BuildCallPeerTool ¶ added in v2.9.0
func BuildCallPeerTool(gate *permissions.Gate, cfg *config.Config, getReg func() *attach.PeerRegistry) (adktool.Tool, error)
BuildCallPeerTool constructs the call_peer built-in over a hub's peer registry. Callers gate the call on cfg.Tools.CallPeer.Enabled AND the daemon actually being a hub; see cmd/core-agent, which refuses to start when the first is set without the second.
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:
- cfg.ThresholdByTier[currentModelTier], when present
- The substrate per-tier default for that tier
- cfg.Threshold (single fallback), when set
- 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 carries the model that actually ran — modelID when set, else the parent's — so display-side pricing lookup uses the resolved tier and the session-side billing in observeToolSavings has something to charge.
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 BuiltinToolsSummary ¶ added in v2.9.0
BuiltinToolsSummary renders the provider's effective server-side built-in tool set for the startup summary: a comma-joined list of the provider-neutral names, or "none" when the provider supports them and has them all off.
Returns "" — not "none" — for a provider with no server-side built-in concept at all (echo, scripted), so the model line can omit the segment rather than assert an absence that means nothing there.
Read off the constructed Provider rather than off config, for the same reason MaybeWirePromptCacheTTL reports what the provider carries: a line derived independently of the requests can drift from them. It also makes the line answer the question an operator actually has — `builtin_tools` keys are silently discarded when misspelled (config decoding does not reject unknown fields), so "did my key take" is only answerable from the far side of the constructor.
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".
Must stay field-for-field in step with pkg/usage's cfgToOverride, which performs the same translation on the path taken when no catalog has been installed (tests + library consumers). A field carried by one and dropped by the other means the same config prices a turn differently under test than it does in the daemon. TestCfgOverride_MatchesTheNoCatalogPath enforces the agreement — but only over fields its fixture sets to a non-zero value, which is how CacheCreation1hInputPerMTok was dropped here and stayed green: both translations returned the zero rate for a field neither carried, and two zeroes compare equal. New rate fields must be added to that fixture in the same change that adds them to config.PricingConfig.
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. Seven 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 MaybeWirePromptCache ¶ added in v2.9.0
func MaybeWirePromptCache(provider models.Provider, noPromptCache bool) (status string, enabled bool)
MaybeWirePromptCache applies the operator's prompt-cache kill switch (the CLI's --no-prompt-cache) to an Anthropic provider and returns a one-line status plus whether caching survived.
Unlike MaybeWireContextCache there is nothing to construct: Anthropic prompt caching is cache_control markers on the ordinary request, not a server-side resource. The config gate is already applied by the registry constructor, so this helper exists for the one thing the registry can't see — a CLI flag that arrives after models.Resolve. Call it after Resolve and BEFORE the first provider.Model() call; Model() copies the policy into each LLM it builds.
Providers other than Anthropic are left alone and report "" — Gemini has no equivalent, and an operator running Gemini shouldn't see a line about a knob that doesn't apply to them. The caller decides what to print: the daemon announces its own provider either way, while a per-subagent provider only announces a deviation from the default.
func MaybeWirePromptCacheTTL ¶ added in v2.9.0
func MaybeWirePromptCacheTTL(provider models.Provider, noPromptCache bool, ttl string) (status string, enabled bool)
MaybeWirePromptCacheTTL is MaybeWirePromptCache plus the breakpoint TTL override behind --prompt-cache-ttl: config.PromptCacheTTL5m, config.PromptCacheTTL1h, or "" to keep whatever the config gate already put on the provider.
The TTL is a launch-time property more often than a repo-level one — the same checkout run interactively wants 5m and run from cron wants 1h — which is why it gets a flag and not just a config key. The 1-hour breakpoint bills writes at 2x base input against 5m's 1.25x, so it pays only when consecutive turns are more than five minutes apart (#770).
func NewFilteredLogWriter ¶
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 PeerDirectory ¶ added in v2.9.0
PeerDirectory adapts a hub's peer registry to the roster the call_peer tool reads (#595). getReg is a closure, not a registry, because the tool is built with the rest of the tool catalog while the registry is constructed later in boot, inside the attach block. It may return nil — a nil registry reads as an empty roster, and call_peer says "no peers are registered" rather than panicking.
now is injectable for tests; nil means time.Now.
Expired leases are filtered here rather than trusted to the registry's 5-second prune tick. A peer that stopped heartbeating is presumed gone, and dialing it burns the caller's whole timeout on a pod that isn't there.
func PersistModelChoice
deprecated
func PersistThemeChoice
deprecated
func RebuildPricingCatalog ¶
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
PeerStateFile string
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:
- The provider is *gemini.Provider (concrete type — cache hooks live on that struct).
- Backend is Vertex (cfg.Model.Provider == "vertex").
- Caching is enabled in config (default ON; explicit enabled=false in cfg.Model.Vertex.ContextCache disables).
- 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 SessionBackgroundFactory ¶ added in v2.9.0
type SessionBackgroundFactory func(scope SessionScope) (SessionSubagents, error)
SessionBackgroundFactory is SessionFactoryDeps.SessionBackground — it builds the background-subagent surface for ONE session.
Per-session, not one shared daemon-wide manager, because sharing breaks four ways at once:
- AttachParent is last-writer-wins, so every session's subagents would branch off whichever session was constructed last — wrong (app, user, sid) triple, wrong eventlog branch.
- One alert channel is multiplexed across tenants, so tenant A's "[Background reports]" block can land in tenant B's turn.
- The shared manager carries the DAEMON-WIDE gate, so a session's subagents would run outside that session's own mode, approvals and plan-first state.
- ListSubagents / ListSubagentCatalog would return the union across every tenant.
The returned Tools, when non-nil, REPLACE the session's tool list: deps.BuiltinTools carries the daemon-bound spawn tools, and leaving them in place would route this session's spawns back into the shared daemon manager. Close is invoked on eviction — spawned subagents run under context.WithoutCancel, so nothing else terminates them.
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 ¶
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
// WakeLoops, when non-nil, is the join point for the wake loops
// this factory starts: cancelling DaemonCtx only *signals* them,
// and a signalled loop can still be mid-query against the
// eventlog. A caller that tears down what those loops read —
// closing the eventlog handle at daemon shutdown, or in a test —
// should cancel, then WaitFor, then tear down (#751).
//
// Optional: nil means "no join point", the pre-#751 behavior.
WakeLoops *WakeLoopGroup
Model adkmodel.LLM
// TitleModel is the cheap-tier model each session names itself
// with (#808). Optional: nil means sessions fall back to a title
// derived from the head of their first prompt, with no LLM call.
// Multi-session daemons are where titles earn their keep — this is
// the deployment whose picker has more than one row in it.
TitleModel adkmodel.LLM
Template *permissions.Gate
BuiltinTools []adktool.Tool
Toolsets []adktool.Toolset
// Subagents are the declarative subagents (config `subagents[]`,
// built once at startup) exposed to every session as SYNCHRONOUS
// tools the model can call by name — the door
// agent.WithSubagents opens. Optional; nil leaves sessions with
// the asynchronous door alone.
//
// They are shared, not rebuilt per session, and both halves of
// that matter (#741):
//
// - Shared, because a rooted subagent stands up its OWN MCP
// servers. Rebuilding one per session would multiply server
// processes by session count, which is why this looked like an
// architectural change rather than a wiring gap.
// - Safe to share, because nothing session-shaped is baked into
// the inner agent. agent.New resolves each subagent into a tool
// AFTER every option below has settled, so the wrapper captures
// THIS session's gate, session triple, eventlog service and
// usage tracker. Two sessions get two wrappers over one inner
// agent.
// - The inner agent's own tools were resolved at startup against
// the daemon template gate, and stay correct for the same
// reason the asynchronous door does: NewSubagentTool drives the
// inner ADK runner directly rather than through *Agent.Run, so
// the session-gate stamp agent.Run put on the parent's turn
// context is still the one resolveSessionGate reads (#825).
Subagents []*agent.Agent
EventlogHandle *eventlog.Handle
PricingRate usage.Pricing
ProjectRoot string
UserRoot string
HomeAgentsDir string
AgentsDir string
UsersDir string
// ContentRoots are operator-declared external directories trusted as
// additional instruction/skill scopes (config content_roots +
// --agents-content-dir), already resolved to absolute paths. Threaded
// into every per-session and attach-provider loader call so multi-
// session sessions see the same external content the daemon-wide
// startup load did. Empty = no external scopes (default). See
// docs/external-content-root-design.md.
ContentRoots []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
// AutoContinueDeferInject suppresses ReproduceAgent's inline
// continuation inject on the resumed path. Set per-call by the
// resumer from a context marker (withDeferAutoContinueInject) when
// the boot scan drives the resume: the scan then owns the
// classify+inject step itself so it can observe the outcome
// (injected vs. run-lock-held-elsewhere) and keep the boot-log
// attempt accounting honest (#575). The lazy-touch resume path
// leaves this false and injects inline as before.
AutoContinueDeferInject bool
// NoCompact mirrors the --no-compact CLI flag. When false (the
// default), ReproduceAgent wires WithCompactor so /compact works
// against session-created agents; when true the option is skipped,
// so the disable flag applies uniformly to the main agent AND every
// session-created agent under it.
NoCompact bool
// CheckpointMode is the resolved task-boundary-checkpoint posture
// for every session this factory creates — one of
// config.CheckpointModeModel / CheckpointModeOperator /
// CheckpointModeOff. Empty is Model, which is both the substrate
// default and the behavior a caller that predates this field
// already had.
//
// It travels here for the same reason WatchdogMode does: a
// multi-session daemon is the paradigm long-lived deployment, and
// #905 is a long-lived-deployment failure. Resolving the posture
// for the primary session and leaving every POST /sessions agent on
// the default would put mark_task_done back in front of exactly the
// tenants the setting exists to protect.
CheckpointMode string
// WatchdogMode is the resolved behavioral-watchdog posture for
// every session this factory creates — one of config.WatchdogOff /
// WatchdogWarn / WatchdogFeedback / WatchdogEnforce. Empty is Off so a
// caller that predates this field keeps its old behavior (no
// watchdog on session-created agents).
//
// A multi-session daemon is the paradigm unattended deployment, so
// leaving its POST /sessions agents un-watchdogged made the #642
// safe-default hollow: the primary session halted on a runaway and
// every tenant session kept looping. Alerts go to daemon stderr —
// per-tenant alert-sink routing is still deferred (see the type
// doc), but "the operator can see it in the pod log" beats "no
// signal at all", and enforce mode does not depend on the sink.
WatchdogMode string
// 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
// SessionBackground, when non-nil, gives every session its OWN
// background-subagent manager (#637). Called once per construction,
// after Customize has settled the session's model and tools.
//
// Leave nil to keep the pre-#637 behavior (no manager on
// session-created agents); the daemon leaves it nil when
// --no-background-agents is set.
SessionBackground SessionBackgroundFactory
}
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). Background subagents are wired per-session too since #637 (see SessionBackground), and the behavioral watchdog since #665 (see WatchdogMode — every mode on the ladder, alerts routed to the daemon log). Features that need per-session scoping decisions the daemon doesn't yet make (agentic tool wrappers, MCP custom auth) remain deferred — sessions created via POST /sessions see the substrate without them.
type SessionScope ¶ added in v2.9.0
type SessionScope struct {
SessionID string
Caller auth.Caller
Gate *permissions.Gate
ModelName string
Tools []adktool.Tool
}
SessionScope is the settled per-session context a SessionBackgroundFactory builds against: the identity the session belongs to, the sub-gate its tools run behind, and the model + tool surface Customize left in place.
type SessionSubagents ¶ added in v2.9.0
type SessionSubagents struct {
Manager agent.SubagentManager
Tools []adktool.Tool
Close func()
}
SessionSubagents is a SessionBackgroundFactory's output: the manager to wire via agent.WithBackgroundManager, an optional replacement tool list, and the teardown hook eviction runs.
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
// AgentsDirOrigin is the human-readable reason AgentsDir has the
// value it does — "derived from filepath.Dir(-c)", "via
// --agents-dir", "via .agents/ discovery". The value alone is not
// enough to debug a wrong one: the three routes to it are fixed in
// three different places, and knowing which applied is the
// difference between "fix your flag" and "you are running from the
// wrong directory" (#945).
//
// Empty falls back to inferring the origin from CfgPath, which is
// correct for every caller that predates --agents-dir.
AgentsDirOrigin string
// DiscoveredConfigDir is the directory config discovery actually
// landed on, which is where config.json was read from when CfgPath
// is empty. It is NOT the same thing as AgentsDir once --agents-dir
// is in play: the flag moves AgentsDir without moving the config,
// and inferring the config's location from AgentsDir would then name
// a file that does not exist (#945). Empty falls back to AgentsDir,
// which is correct for every caller that predates --agents-dir.
DiscoveredConfigDir 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
// BuiltinTools is the rendered server-side built-in tool set, from
// BuiltinToolsSummary(provider) at the call site. Empty means the
// resolved provider has no such concept and the model line omits
// the segment entirely; "none" means it has one and everything in
// it is off. The distinction is the point — see BuiltinToolsSummary.
BuiltinTools 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.
type WakeLoopGroup ¶ added in v2.9.0
type WakeLoopGroup struct {
// contains filtered or unexported fields
}
WakeLoopGroup is a join point for the per-session wake loops a SessionFactoryDeps starts. Wire one into SessionFactoryDeps.WakeLoops and the factory registers every loop it spawns; WaitFor then blocks until they have all returned.
It exists because cancelling the daemon context only *signals* those goroutines. buildSession used to start them with a bare `go runner.WakeLoop(...)` and keep no handle, so nothing downstream could tell a cancelled loop from a returned one — and a loop that has been signalled may still be mid-query against the eventlog. Tearing that handle down underneath it is a use-after-close: the race detector caught it as a flaky test teardown (#751), but the same ordering is in the daemon, where `defer handle.Close()` is registered long before the loops start and therefore runs while they are still live.
The zero value is ready to use, and every method is nil-safe so a caller that doesn't want a join point can leave the field unset.
Deliberately NOT wired into the eviction path: the registry's eviction sweep runs across every tenant, and blocking it on one session's in-flight turn would be the trade the existing `go bgClose()` comment already rejects. Eviction signals; shutdown joins. A counter plus a broadcast channel rather than a sync.WaitGroup: a session can be created while a drain is in flight (the daemon closes its listener first, but "first" is an ordering, not a barrier — an in-flight POST /sessions can still be inside the factory), and a WaitGroup whose counter goes 0 → 1 with a waiter parked panics with "Add called concurrently with Wait". Crashing the process at SIGTERM to report that a goroutine was tidy would invert the point of the exercise. Here the same interleaving simply keeps the drain open, which is also the correct answer: that session has a live loop too.
func (*WakeLoopGroup) WaitFor ¶ added in v2.9.0
func (g *WakeLoopGroup) WaitFor(timeout time.Duration) bool
WaitFor blocks until every wake loop registered with this group has returned, or until timeout elapses, and reports whether they all returned. A nil group and an empty group both report true immediately.
The caller is responsible for cancelling the loops first — this waits, it does not signal. A WaitFor with nothing cancelled will simply burn the timeout, which is why the daemon derives its own cancel for the factory rather than relying on the outermost `defer stop()`.
A false return is worth logging rather than swallowing: it means a turn is still running against resources the caller is about to tear down, and the operator's next symptom would otherwise be a SQLite error on a closing connection with no explanation attached.