Documentation
¶
Overview ¶
Package agent is the use-case heart of mecatl: the streaming agent loop that ties the ports together. It records the user prompt, calls the LLMProvider, streams assistant deltas, dispatches tool calls (read-parallel / mutate-serial) through the permission policy and hook lifecycle, pauses on permission asks, compacts the history at the context-window threshold, and emits a single ordered stream of session.Events terminating in a result.
Import rule: this package imports ONLY engine/session, engine/port, engine/tool, engine/governance, engine/prompt, engine/team (the domain coordination substrate for agent teams), and the standard library. Adapters are injected as ports; the loop never names a concrete adapter or the api layer. (Tests may import adapters.)
Index ¶
- Constants
- Variables
- func DefaultChildLimits() session.Limits
- func MemberSessionID(teamID, member string) session.SessionID
- func MemberToolNames() map[string]struct{}
- func MemberTools(t *team.Team, self string, hooks port.HookRunner) []tool.Tool
- func NewBashStatusTool() tool.Tool
- func NewBashTool() tool.Tool
- func NewInspectMemberTool(store port.SessionStore) tool.Tool
- func NewInspectMemberToolWithOwnership(store port.SessionStore, ownershipEnforced bool) tool.Tool
- func NewInspectSubagentTool(store port.SessionStore) tool.Tool
- func NewInspectSubagentToolWithOwnership(store port.SessionStore, ownershipEnforced bool) tool.Tool
- func NewParallelTool(childEngine *Engine, forker tool.EnvironmentForker, opts ...ParallelOption) tool.Tool
- func NewPlanAwareScheduleTool(base tool.Tool, mgr port.ScheduleManager) tool.Tool
- func NewPresentPlanTool() tool.Tool
- func NewScheduleQueryTool(mgr port.ScheduleManager) tool.Tool
- func NewScheduleTool(mgr port.ScheduleManager) tool.Tool
- func NewSubagentStatusTool() tool.Tool
- func NewSubagentTool(childEngine *Engine, opts ...SubagentOption) tool.Tool
- func NewTeamTool(factory TeamMemberEngineFactory, opts ...TeamOption) tool.Tool
- func ParseReflectionOutcome(in learning.Input, raw []byte, limits ReflectionLimits) (learning.Outcome, error)
- func RunGuardrailCheck(ctx context.Context, engine *Engine, prompt string) (string, error)
- func RunModelRouter(ctx context.Context, engine *Engine, req ModelRouteRequest) (category string, usage session.Usage, missReason string, ok bool)
- func StripLoneCodeFence(s string) string
- func TeamStop(o TeamOutcome) session.StopReason
- func TightenTeamTokenBudget(serverBudget, request int) int
- type AgentMeta
- type ArtifactHandle
- type BashStatusTool
- func (*BashStatusTool) Execute(_ context.Context, call session.ToolCall, _ tool.Environment) (session.ToolResult, error)
- func (t *BashStatusTool) ExecuteWithParent(ctx context.Context, call session.ToolCall, env tool.Environment, ...) (session.ToolResult, error)
- func (*BashStatusTool) ReadOnly() bool
- func (*BashStatusTool) Spec() tool.ToolSpec
- type BashTool
- func (t BashTool) Execute(ctx context.Context, in session.ToolCall, env tool.Environment) (session.ToolResult, error)
- func (t BashTool) ExecuteWithParent(ctx context.Context, in session.ToolCall, env tool.Environment, ...) (session.ToolResult, error)
- func (BashTool) ReadOnly() bool
- func (BashTool) Spec() tool.ToolSpec
- type BranchJudge
- type BranchSummary
- type CascadeCompactor
- type ChildAskReview
- type ChildAskReviewRequest
- type ChildAskReviewer
- type Compactor
- type DelegationTightening
- type Deps
- type Engine
- func (e *Engine) Capabilities() port.ProviderCapabilities
- func (e *Engine) CompactSession(ctx context.Context, sess *session.Session) (ManualCompactionResult, error)
- func (e *Engine) ContextWindow() int
- func (e *Engine) HasTool(name string) bool
- func (e *Engine) Model() string
- func (e *Engine) ResumeApproval(ctx context.Context, sess *session.Session, env tool.Environment, askID string, ...) *Run
- func (e *Engine) RetryFailedStep(ctx context.Context, sess *session.Session, env tool.Environment) *Run
- func (e *Engine) Run(ctx context.Context, sess *session.Session, env tool.Environment, ...) *Run
- func (e *Engine) SteerEnabled() bool
- type EngineAskReviewerOption
- type EngineJudgeOption
- type EvidenceReflector
- func (r *EvidenceReflector) Reflect(ctx context.Context, in learning.Input) (learning.Outcome, error)
- func (r *EvidenceReflector) ReflectProjection(ctx context.Context, projection learning.Projection) (learning.Outcome, error)
- func (r *EvidenceReflector) RequestTokenEstimate(in learning.Input) (int, error)
- type HeuristicCompactor
- type HeuristicTokenCounter
- type InspectMemberTool
- type InspectSubagentTool
- type LRUForkReaper
- type ManualCompactionResult
- type MemberBuild
- type MemberDisposition
- type MemberEngine
- type MemberOutcome
- type MemberSpec
- type MemberStopReason
- type ModelRouteCategory
- type ModelRouteRequest
- type ParallelOption
- func WithAutoMerge(m tool.EnvironmentMerger) ParallelOption
- func WithMaxBranches(n int) ParallelOption
- func WithParallelChildLimits(l session.Limits) ParallelOption
- func WithParallelChildMode(m session.PermissionMode) ParallelOption
- func WithParallelChildSessionPrefix(p string) ParallelOption
- func WithParallelConcurrency(n int) ParallelOption
- func WithParallelEngineFactory(f func(model string) (*Engine, bool)) ParallelOption
- func WithParallelJudge(j BranchJudge) ParallelOption
- func WithParallelStore(store port.SessionStore) ParallelOption
- func WithParallelSubagentStopHook(h port.HookRunner) ParallelOption
- func WithWinnerReaper(s PreservedForkStore) ParallelOption
- type ParallelTool
- func (t *ParallelTool) Execute(ctx context.Context, call session.ToolCall, env tool.Environment) (session.ToolResult, error)
- func (t *ParallelTool) ExecuteWithParent(ctx context.Context, call session.ToolCall, env tool.Environment, ...) (session.ToolResult, error)
- func (t *ParallelTool) MutatesParent(call session.ToolCall) bool
- func (*ParallelTool) ReadOnly() bool
- func (*ParallelTool) Spec() tool.ToolSpec
- type PreservedForkStore
- type ReflectionLimits
- type Run
- func (r *Run) Approve(askID string, v session.ApprovalVerdict)
- func (r *Run) Cancel()
- func (r *Run) CancelChild(childID string) bool
- func (r *Run) CancelSteer() (SteerOutcome, error)
- func (r *Run) EnqueueSteer(text string, parts []session.Content) (SteerOutcome, error)
- func (r *Run) Events() <-chan session.Event
- func (r *Run) RetractPermissionAsk(askID string) bool
- func (r *Run) RunID() string
- type RunRequest
- type ScheduleQueryTool
- type ScheduleTool
- type SteerOutcome
- type SubagentOption
- func WithAgentEngines(engines map[string]*Engine, meta []AgentMeta) SubagentOption
- func WithAgentModelEngineFactory(f func(agentName, model string) (*Engine, bool)) SubagentOption
- func WithAgentWritableEngineFactory(f func(agentName string) (*Engine, bool)) SubagentOption
- func WithAgentWritableModelEngineFactory(f func(agentName, model string) (*Engine, bool)) SubagentOption
- func WithChildForker(f tool.EnvironmentForker) SubagentOption
- func WithChildLimits(l session.Limits) SubagentOption
- func WithChildMode(m session.PermissionMode) SubagentOption
- func WithChildSessionPrefix(p string) SubagentOption
- func WithMaxConcurrentChildren(n int) SubagentOption
- func WithPinnedAgents(names []string) SubagentOption
- func WithRoutableAgents(names []string) SubagentOption
- func WithSharedChildWorkspace(view func(tool.Workspace) tool.Workspace) SubagentOption
- func WithSubagentEngineFactory(f func(model string) (*Engine, bool)) SubagentOption
- func WithSubagentNoFSNote() SubagentOption
- func WithSubagentOwnershipEnforced(ownershipEnforced bool) SubagentOption
- func WithSubagentReadLedgerFactory(factory func() tool.ReadLedger) SubagentOption
- func WithSubagentShellDisabledNote(reason string) SubagentOption
- func WithSubagentStopHook(h port.HookRunner) SubagentOption
- func WithSubagentStore(store port.SessionStore) SubagentOption
- func WithWritableChildEngine(e *Engine) SubagentOption
- func WithWritableEngineFactory(f func(model string) (*Engine, bool)) SubagentOption
- type SubagentStatusTool
- func (*SubagentStatusTool) Execute(_ context.Context, call session.ToolCall, _ tool.Environment) (session.ToolResult, error)
- func (t *SubagentStatusTool) ExecuteWithParent(ctx context.Context, call session.ToolCall, env tool.Environment, ...) (session.ToolResult, error)
- func (*SubagentStatusTool) ReadOnly() bool
- func (*SubagentStatusTool) Spec() tool.ToolSpec
- type SubagentTool
- func (t *SubagentTool) Execute(ctx context.Context, call session.ToolCall, env tool.Environment) (session.ToolResult, error)
- func (t *SubagentTool) ExecuteObserved(ctx context.Context, call session.ToolCall, env tool.Environment, ...) (session.ToolResult, error)
- func (t *SubagentTool) ExecuteWithParent(ctx context.Context, call session.ToolCall, env tool.Environment, ...) (session.ToolResult, error)
- func (t *SubagentTool) MutatesParent(call session.ToolCall) bool
- func (*SubagentTool) ReadOnly() bool
- func (t *SubagentTool) Spec() tool.ToolSpec
- type Supervisor
- func (s *Supervisor) AddMember(ctx context.Context, spec MemberSpec) error
- func (s *Supervisor) CancelMember(name string) bool
- func (s *Supervisor) MemberModel(name string) string
- func (s *Supervisor) MemberRouting(name string) (category, model, reason string)
- func (s *Supervisor) Run(ctx context.Context, sink func(TeamEvent)) TeamOutcome
- type SupervisorOption
- func WithForker(f tool.EnvironmentForker) SupervisorOption
- func WithMaxRounds(n int) SupervisorOption
- func WithMemberErrorRetries(n int) SupervisorOption
- func WithMemberLiveness(liveness port.SessionLiveness) SupervisorOption
- func WithMemberSessionPrefix(p string) SupervisorOption
- func WithMemberStore(store port.SessionStore) SupervisorOption
- func WithMemberTurnBudget(n int) SupervisorOption
- func WithReadOnlyForker(f tool.EnvironmentForker) SupervisorOption
- func WithRootAuthority(authority session.Authority) SupervisorOption
- func WithTeamConcurrency(n int) SupervisorOption
- func WithTeamGoal(goal string) SupervisorOption
- func WithTeamHooks(h port.HookRunner) SupervisorOption
- func WithTeamLimits(l session.Limits) SupervisorOption
- func WithTeamMode(m session.PermissionMode) SupervisorOption
- func WithTeamOwner(owner *session.Principal) SupervisorOption
- func WithTeamReadLedgerFactory(factory func() tool.ReadLedger) SupervisorOption
- func WithTeamSharedBaseWorkspace(view func(tool.Workspace) tool.Workspace) SupervisorOption
- func WithTeamTokenBudget(n int) SupervisorOption
- func WithUntrustedGoal(untrusted bool) SupervisorOption
- type TeamEvent
- type TeamMemberArg
- type TeamMemberEngineFactory
- type TeamOption
- func WithTeamToolForker(f tool.EnvironmentForker) TeamOption
- func WithTeamToolHooks(h port.HookRunner) TeamOption
- func WithTeamToolReadLedgerFactory(factory func() tool.ReadLedger) TeamOption
- func WithTeamToolReadOnlyForker(f tool.EnvironmentForker) TeamOption
- func WithTeamToolSharedBaseWorkspace(view func(tool.Workspace) tool.Workspace) TeamOption
- func WithTeamToolStore(s port.SessionStore) TeamOption
- func WithTeamToolTokenBudget(n int) TeamOption
- type TeamOutcome
- type TeamTool
- func (t *TeamTool) Execute(ctx context.Context, call session.ToolCall, env tool.Environment) (session.ToolResult, error)
- func (t *TeamTool) ExecuteObserved(ctx context.Context, call session.ToolCall, env tool.Environment, ...) (session.ToolResult, error)
- func (t *TeamTool) ExecuteWithParent(ctx context.Context, call session.ToolCall, env tool.Environment, ...) (session.ToolResult, error)
- func (*TeamTool) ReadOnly() bool
- func (*TeamTool) Spec() tool.ToolSpec
- type TokenCounter
- type UserModelReviewer
Constants ¶
const ( // SubagentSessionPrefix prefixes flat-fleet Subagent children: // "subagent-<callID>" (SubagentTool's default; WithChildSessionPrefix // overrides the stem). SubagentSessionPrefix = "subagent-" // ParallelSessionPrefix prefixes Parallel fork-join branches: // "parallel-<callID>-<i>" (ParallelTool's default; // WithParallelChildSessionPrefix overrides the stem). Issue #30: the // InspectSubagent prefix gate ALSO consumes this prefix, so a persisted branch // (WithParallelStore) is loadable by its surfaced "branch id:". ParallelSessionPrefix = "parallel-" // TeamSessionPrefix prefixes team members: "team-<teamID>-<member>" — // MemberSessionID's scheme (memberSessionIDPrefix aliases this constant). TeamSessionPrefix = "team-" // BashCmdJobPrefix prefixes background-Bash job ids: "bashcmd-<callID>". // Unlike the three delegation prefixes above it names NO session (the job // is a bare process, not a child loop), so the InspectSubagent prefix gate // and the child-session retention GC must NOT learn it — it exists only so // the job-id spelling has one source. BashCmdJobPrefix = "bashcmd-" )
The delegation families' child-session id PREFIXES — the single exported source for the id-minting convention. Each constant is the literal prefix of a child SESSION id; the three are disjoint, and every consumer derives from them rather than re-spelling the literals: the minting sites (SubagentTool's default child prefix, ParallelTool's default branch prefix, MemberSessionID's team scheme), the InspectSubagent prefix gate (subagent- ∪ parallel-, issue #30), and the composition layer's child-session retention GC (internal/app/childgc.go's childSessionPrefixes). A deployment overriding a prefix (WithChildSessionPrefix / WithParallelChildSessionPrefix / WithMemberSessionPrefix) departs from this convention and from everything keyed on it — see those options' docs.
const ( // RouterMissDegenerateInput: a fast fail-soft miss on a leaf-guard input — a nil // classifier engine, an empty category list, or a blank task prompt. No classifier // call was made. RouterMissDegenerateInput = "degenerate-input" // RouterMissClassifierError: the classifier run ended StopError (the provider/run // failed) — fail-soft inherit. RouterMissClassifierError = "classifier-error" // RouterMissCancelled: the classifier run ended StopCancelled — the caller's ctx was // cancelled (including the 30s modelRouterTimeout firing) — fail-soft inherit. RouterMissCancelled = "cancelled" // RouterMissBadVerdict: the classifier's output was not a single JSON object, was // unparseable JSON, or named an empty category — the whole-output-single-object parse // rejected it (fail-soft, defeats a forged verdict echoed inside the fenced prompt). RouterMissBadVerdict = "bad-verdict" // RouterMissUnknownCategory: the verdict named a category NOT in the offered list (a // hallucination) — the membership check rejected it (fail-soft). RouterMissUnknownCategory = "unknown-category" )
Router miss-reason constants (issue #287): the SPECIFIC reason a classification did NOT yield a routed model. They enumerate the CLOSED set RunModelRouter itself returns (its `missReason` output); the dispatch-path chokepoint logs WHY a delegation fell through to the inherited default model. Empty ("") is the success sentinel.
NOTE: the Deps.SubagentModelRouter closure (the COMPOSITION half) may return ADDITIONAL, OPEN-SET free-form reasons for its own category-mapping misses (e.g. "category-selector-empty (category=…)", "category-target-unresolvable (category=… selector=…)", and the dispatch closure's "empty-model" fallback). Those are NOT in this enum — the full string is INFORMATIONAL for the operator log. routingReasonPayload reduces the two known composition shapes to static codes before an event is emitted and substitutes a generic code for every other open-set value; callers never branch on the free-form text.
All reasons are metadata ONLY — never the task prompt or the classifier's output (gauntlet #7).
const DefaultAskReviewMaxDenies = 3
DefaultAskReviewMaxDenies is the breaker threshold applied when Deps.ChildAskReviewMaxDenies is unset (NewEngine maps <=0 to this): after this many CONSECUTIVE non-allow outcomes (denies, failures, timeouts) within one run, further asks skip the reviewer and fall through to auto-deny — bounding reviewer spend on a run whose children keep proposing disallowed commands. An allow resets the count; an abstention (ErrNotReviewable) does not affect it.
Exported so the cmd/ flag declarations (mecated/mecatui/mecatequi) reference a single named const for their --subagent-ask-reviewer-max-denies default instead of an inline literal that could drift from this value. See the run-bounds index in docs/design/IMPLEMENTATION-NOTES.md and the drift guard in engine/agent/runbounds_drift_test.go.
const DefaultPreservedForkCap = 8
DefaultPreservedForkCap is the number of preserved winner forks an LRUForkReaper keeps when constructed with a non-positive cap. Small by design: preserved forks are full workspace copies, so the default trades a little inspectability headroom for a tight disk bound.
const MinSubagentRunTokens = 25_000
MinSubagentRunTokens is the floor applied to per-call MaxRunTokensOverride. The system prompt + AGENTS.md + project instructions are replayed on every turn, costing ~20k+ tokens on the very first turn of a typical workspace run. A model-supplied budget below this floor would stop the child before it can complete even one useful turn, which is never the model's intent. The floor is safe because the operator ceiling still wins via the tighten-only fold in effectiveMaxRunTokens: min(override, Deps.MaxRunTokens) — clamping the override UP to 25k never raises the effective budget above the operator bound.
Exported so tests and external callers can reference the floor value without hard-coding the magic number.
const PlanApprovedProceedText = "Plan approved by operator. Proceed with execution."
PlanApprovedProceedText is the harness-framed proceed message injected (by Wave 4's ApprovePlan service seam) as ordinary recorded history when an operator approves a presented plan, signalling the model to begin execution. It is event-silent (it is a recorded user message, NOT a diagnostics line — the loop's "exactly THREE lines" invariant holds) and mirrors the harness-note framing of the background-completion notices. Exported so the composition/service layer (which owns the proceed injection) can reference the exact text without re-stringing it; the text itself is a stable contract the model reads as the proceed signal.
const ScheduleQueryToolName = "ScheduleQuery"
ScheduleQueryToolName is the catalog name of the READ-ONLY scheduled-task query tool (list/inspect). Exported: the composition root references it for the permission floor Allow (defaultRules) and the catalog registration.
const ScheduleToolName = "Schedule"
ScheduleToolName is the catalog name of the MUTATING scheduled-task management tool (create/pause/resume/delete/fire). Exported: the composition root references it for the permission floor Allow (defaultRules) and the catalog registration.
Variables ¶
var ( // ErrReflectionLimits reports an invalid or explicitly unbounded limit set. ErrReflectionLimits = errors.New("agent: invalid reflection limits") // ErrReflectionOutput reports strict output decoding, bounds, or evidence failure. ErrReflectionOutput = errors.New("agent: invalid reflection output") // ErrReflectionProvider reports a provider start or stream failure. ErrReflectionProvider = errors.New("agent: reflection provider failure") )
var ( // ErrMemberNameRequired is returned by AddMember when the spec name is empty. // It is a bad-request (caller) error. ErrMemberNameRequired = errors.New("agent: team member name is required") // ErrMemberAlreadyAdded is returned by AddMember when the supervisor already // holds a member of that name (a duplicate at the supervisor layer, distinct // from team.ErrMemberExists at the aggregate layer). It is a bad-request error. ErrMemberAlreadyAdded = errors.New("agent: member already added") // ErrNoForker is returned by AddMember when a Mutating member is requested but // no EnvironmentForker is configured. This is a server MISCONFIGURATION (the // composition root did not wire a forker), not a bad client request. ErrNoForker = errors.New("agent: Mutating member requires a configured EnvironmentForker") // ErrForkWorkspace wraps a failure to fork a Mutating member's workspace. It is // an I/O / internal fault. ErrForkWorkspace = errors.New("agent: fork member workspace") // ErrNilEngine is returned by AddMember when the member-engine factory returns // a nil Engine. It is a server-internal fault (a broken factory). ErrNilEngine = errors.New("agent: member engine factory returned nil") // ErrReadOnlyMemberMutating is returned by AddMember when a BASE-SHARING member's // catalog contains a workspace-mutating tool. It is a server MISCONFIGURATION of // the member's catalog, not a bad client request. It is gated on base-sharing // (neither Mutating nor read-only-isolated): a worktree-isolated read-only member // is exempt exactly like a mutating one, because its mutating-classified Bash // lands in its own throwaway worktree, never the shared base. ErrReadOnlyMemberMutating = errors.New("agent: read-only member given workspace-mutating tool") // ErrReadOnlyShellNoForker is returned by AddMember when a member's factory marked // it read-only-isolated (MemberBuild.IsolateReadOnly — it put Bash into a // non-mutating member's catalog) but no read-only forker is configured. It is a // should-never-happen server MIS-WIRE assertion: the composition layer only sets // IsolateReadOnly when the read-only forker is wired, so this guards the two from // drifting apart. ErrReadOnlyShellNoForker = errors.New("agent: read-only-isolated member requires a configured read-only EnvironmentForker") )
AddMember failure-class sentinels. They let a caller (e.g. the gRPC adapter) classify an enrolment failure into the right wire status instead of collapsing every failure to "invalid argument". The team-aggregate failures (duplicate / reserved / too-many members) are NOT re-wrapped here — callers test those with errors.Is against the team package's own sentinels (team.ErrMemberExists, team.ErrReservedName, team.ErrTooManyMembers), which AddMember already wraps via %w through s.team.AddMember.
var ErrCompactionWouldOrphan = errors.New("agent: compaction would orphan a tool result or dangle a tool call")
ErrCompactionWouldOrphan is the sentinel a Compactor returns when the only history it could produce would be tool-pairing-invalid (an orphaned tool result or a dangling tool call). The loop treats it like any other compaction failure — keep the original history and continue without compaction — rather than emitting a history that draws a provider HTTP 400 and bricks the session. Compactors return the ORIGINAL messages alongside this error so a caller that ignores the sentinel still gets a safe slice.
var ErrNotAwaiting = errors.New("agent: session is not awaiting the given approval")
ErrNotAwaiting is the terminal cause driveFromAwaiting fails with when it is asked to resume a session that is NOT parked on a pending ask (no PendingAsk), or whose pending ask does not match the askID the resume targets. It never silently completes: a mismatch ends the run as StopError carrying this cause so a stale/duplicate Approve cannot drive an unexpected session to a clean terminal.
var ErrNotReviewable = errors.New("agent: child ask not reviewable by this reviewer")
ErrNotReviewable is the sentinel a ChildAskReviewer returns to ABSTAIN from a particular ask (for example a reviewer that only judges shell commands, asked to review a different tool). It is distinct from a genuine review failure: an abstention falls through to the default headless auto-deny WITHOUT counting against the consecutive-failure breaker, so a reviewer that legitimately cannot judge some asks never silences review for the asks it can judge. Every OTHER error counts toward Deps.ChildAskReviewMaxDenies.
Functions ¶
func DefaultChildLimits ¶
DefaultChildLimits returns the default per-child/per-member stop conditions a Subagent tool (and a team member, via WithTeamLimits) runs under when the caller does not override them. The composition layer uses it as the per-field FALLBACK when deriving a def's session.Limits from its maxTurns/maxToolCalls: a zero def field inherits the matching default here, so a def that sets neither is bounded exactly as before.
func MemberSessionID ¶
MemberSessionID derives the COLLISION-FREE session id for one team member, namespaced by the team id: "team-<teamID>-<member>". It is the SINGLE source of truth for the member-session id scheme — the Team tool seeds the supervisor's member-session prefix from it, and the InspectMemberTool derives an id with it — so the producer (the supervisor, which saves the session) and the consumer (the inspect tool, which loads it) cannot drift. Because teamID is the parent call id (Team tool) or the server-assigned "team-<NewID()>" (gRPC path), two concurrent teams sharing a member name still get distinct ids.
CONTRACT — teamID MUST be the EXACT team id published on the wire: the value on EvTeamStart.TeamID, the Team tool's call id, and the CreateTeam/CreateTeamResponse team_id. Pass it VERBATIM — never normalised, trimmed, or re-prefixed. The string is intentionally NOT canonicalised here: the gRPC path's published team id is itself "team-<NewID()>", so the saved id is "team-team-<NewID()>-<member>" — that double "team-" is CORRECT and load-bearing, because the only caller that derives an inspect id (InspectMemberTool) passes the SAME published "team-<NewID()>" string, so producer and consumer agree byte-for-byte. The Team-tool path publishes the parent call id as the team id (no "team-" of its own), so its saved id is "team-<callID>-<member>". Both paths are pinned by round-trip tests (TestMemberSessionIDRoundTripsTeamToolPath / ...GRPCPath). Changing the published team-id string would change these saved ids — do not normalise it to "fix" the double prefix.
func MemberToolNames ¶
func MemberToolNames() map[string]struct{}
MemberToolNames returns the set of coordination-tool names MemberTools installs into every member's catalog. These tools report ReadOnly() == false because they mutate TEAM state, but they NEVER touch the workspace, so they are safe for a read-only (base-sharing) member. The supervisor uses this set to distinguish a member's coordination tools from genuine WORKSPACE-mutating tools (Edit / Write / non-read-only Bash) when it enforces the read-only-member invariant in AddMember. It is kept in lock-step with MemberTools by construction: it derives the names from MemberTools over a throwaway team.
func MemberTools ¶
MemberTools returns the coordination tools bound to the shared team t and the member name self. The composition root registers these into every member's catalog (including the lead's) so team coordination is always available to a member even when an agent definition otherwise restricts its tools. hooks, when non-nil, fires the TaskCreated / TaskCompleted lifecycle gates from AddTask / CompleteTask (a Block outcome vetoes the action); pass nil to disable them.
func NewBashStatusTool ¶
NewBashStatusTool constructs the BashStatus tool. It is stateless: all state lives in the per-run registry handed down via parentCaps.
func NewBashTool ¶
NewBashTool constructs the Bash tool. The runner is NOT captured here — it is read off the tool.Environment at Execute time (issue #462). The composition root registers the returned tool ONLY when a runner is available for the namespace; without one, the catalog has no Bash and the agent runs shell-less. Background calls additionally require the bound runner to implement tool.CommandStreamer (they decline honestly when it does not).
func NewInspectMemberTool ¶
func NewInspectMemberTool(store port.SessionStore) tool.Tool
NewInspectMemberTool constructs the legacy-compatible InspectMember tool over a session store. Without a verified-caller request edge, ownership enforcement stays disabled.
func NewInspectMemberToolWithOwnership ¶
func NewInspectMemberToolWithOwnership(store port.SessionStore, ownershipEnforced bool) tool.Tool
NewInspectMemberToolWithOwnership constructs InspectMember with the request edge's ownership policy. When enforcement is enabled, only the owner with the same (Issuer, Subject) pair may read a persisted member transcript.
func NewInspectSubagentTool ¶
func NewInspectSubagentTool(store port.SessionStore) tool.Tool
NewInspectSubagentTool constructs the legacy-compatible InspectSubagent tool over a session store. Without a verified-caller request edge, ownership enforcement stays disabled.
func NewInspectSubagentToolWithOwnership ¶
func NewInspectSubagentToolWithOwnership(store port.SessionStore, ownershipEnforced bool) tool.Tool
NewInspectSubagentToolWithOwnership constructs InspectSubagent with the request edge's ownership policy. When enforcement is enabled, only the owner with the same (Issuer, Subject) pair may read a persisted child transcript.
func NewParallelTool ¶
func NewParallelTool(childEngine *Engine, forker tool.EnvironmentForker, opts ...ParallelOption) tool.Tool
NewParallelTool constructs the Parallel fan-out tool over a pre-built child *Engine and an EnvironmentForker. The composition root builds childEngine with the SCOPED child catalog and a non-interactive policy (see NewSubagentTool's guidance); the child catalog MUST NOT contain Parallel or Subagent (so a branch cannot fan out further). childEngine and forker must be non-nil; NewParallelTool panics otherwise, because a Parallel tool with no child loop or no isolation seam is a composition-root programming error.
func NewPlanAwareScheduleTool ¶
NewPlanAwareScheduleTool wraps the MUTATING Schedule tool for a PLAN-MODE session's catalog (ADR 0073 decision 4, the AC4.3 gate). The default mutating tool reports ReadOnly()==false, so the plan-mode catalog projection (engine/tool/catalog.go Available(ModePlan)) would hide the WHOLE tool — including the read-leaning create plan mode must keep (a schedule CREATE does not itself mutate the workspace; the FIRE's posture is pinned at create-time by the Mutating/Mode invariant). The plan-aware variant reports ReadOnly()==true (so the plan-mode projection advertises it) and hard-denies the mutating shapes per call with the plan-mode deny reason BEFORE the base tool runs — the read-leaning create (mutating:false) drives through unchanged. The read-only ScheduleQueryTool needs no wrapper: it is ReadOnly()==true, so the plan-mode projection advertises it as-is.
The wrapper's ReadOnly()==true is sound because the ONLY call shapes it lets through are the read-leaning ones: none mutate the workspace (a CREATE writes the schedule REGISTRY, not the tree; a read-leaning schedule's FIRE runs in plan mode), so no admitted plan-mode call can mutate. mgr is the SAME ScheduleManager the base drives — the fire gate reads the schedule's pinned Mutating posture from it (the fire verb carries no mutating flag of its own). The non-plan engine keeps the DEFAULT tool (the read/mutate serialization contract is unchanged).
func NewPresentPlanTool ¶
NewPresentPlanTool constructs the PresentPlan tool — the signalling affordance the model calls once it has presented a complete plan for operator approval. The tool is read-only and signaling-only; in plan mode the dispatcher intercepts it by name (presentPlanToolName) and surfaces the ask, so Execute is only reached on a misroute.
func NewScheduleQueryTool ¶
func NewScheduleQueryTool(mgr port.ScheduleManager) tool.Tool
NewScheduleQueryTool constructs the read-only Schedule query tool over the injected port.ScheduleManager. mgr must be non-nil; NewScheduleQueryTool panics otherwise (the same composition-root programming-error contract as NewScheduleTool). Composition registers it alongside the mutating Schedule tool when the session's store backs a ScheduleStore.
func NewScheduleTool ¶
func NewScheduleTool(mgr port.ScheduleManager) tool.Tool
NewScheduleTool constructs the mutating Schedule tool over the injected port.ScheduleManager. mgr must be non-nil; NewScheduleTool panics otherwise (a composition-root programming error — the tool has nothing to drive without the seam). Composition registers the tool ONLY when the session's store backs a ScheduleStore (the scheduleStore() != nil gate), so a nil manager never reaches the catalog.
func NewSubagentStatusTool ¶
NewSubagentStatusTool constructs the SubagentStatus tool. It is stateless: all state lives in the per-run registry handed down via parentCaps.
func NewSubagentTool ¶
func NewSubagentTool(childEngine *Engine, opts ...SubagentOption) tool.Tool
NewSubagentTool constructs the Subagent tool tool over a pre-built child *Engine.
The composition root (cmd/mecated, WP11) is responsible for building childEngine with the SCOPED child catalog and policy. The recommended, deterministic wiring is:
- Catalog: a read-only explorer set — Read, Grep, Glob ONLY. It MUST NOT contain the Subagent tool (otherwise a subagent could spawn subagents — infinite recursion) and SHOULD NOT contain mutating tools (Edit/Write/non-RO Bash): the default explorer subagent cannot mutate the workspace.
- Policy: allow-all over those read-only tools (e.g. permpolicy.NewPolicy([]governance.Rule{{Effect: governance.Allow}})), so the child never produces a permission "ask". Subagents are one-shot and non-interactive — there is no human on the other end of a child run.
Even with that wiring, Execute defends the non-interactive invariant: if the child loop ever pauses on a permission ask, Execute auto-resolves it as DENY so the child can never block waiting for a human. This keeps the subagent deterministic regardless of the policy it is given.
childEngine must be non-nil; NewSubagentTool panics otherwise, because a Subagent tool with no child loop to delegate to is a programming error at the composition root.
func NewTeamTool ¶
func NewTeamTool(factory TeamMemberEngineFactory, opts ...TeamOption) tool.Tool
NewTeamTool constructs the Team tool over a per-member engine factory. factory must be non-nil; NewTeamTool panics otherwise (a composition-root programming error — a Team tool with no way to build member engines cannot run a team).
func ParseReflectionOutcome ¶
func ParseReflectionOutcome(in learning.Input, raw []byte, limits ReflectionLimits) (learning.Outcome, error)
ParseReflectionOutcome strictly parses and validates one reflector response.
func RunGuardrailCheck ¶
RunGuardrailCheck drives a dedicated, tool-less one-turn Engine over a fully assembled checker prompt and returns its raw final text. A run failure or cancellation is an error (never a fabricated reply), so a caller can treat a failed check distinctly from a verdict. The drive is bounded by a hard timeout derived from ctx.
engine must be a tool-less checker Engine that fires no hooks and carries no nested reviewer, so a check can never recurse or call a tool. It returns an error on a nil engine rather than panicking, since it is a leaf helper.
func RunModelRouter ¶
func RunModelRouter(ctx context.Context, engine *Engine, req ModelRouteRequest) (category string, usage session.Usage, missReason string, ok bool)
RunModelRouter drives a dedicated, tool-less one-turn classifier Engine over a fenced classification prompt and returns the chosen CATEGORY NAME plus the classifier's accumulated session.Usage (so the caller can fold it into a parent session's budget brake — the #92 CWE-770 fix). It mirrors RunGuardrailCheck: bounded by modelRouterTimeout, drained under a zero-capability child posture (role "model-router"), and FAIL-SOFT — any failure, cancellation, unparseable verdict, or hallucinated category returns ("", zero, false) so the caller inherits the default model. It never returns an error: the router is never load-bearing, so a miss is just a soft fall-through, not a condition the caller branches on.
Usage is returned on EVERY path including early-return degenerate inputs (zero usage) and fail-soft miss paths (whatever was spent before the failure) so the caller can always fold it unconditionally.
engine must be a tool-less classifier Engine that fires no hooks and carries no nested reviewer/router (built via the composition's childEngineDepsForProvider recipe), so a classification can never recurse or call a tool. A nil engine, an empty category list, or a blank task prompt is a fail-soft miss (ok=false), never a panic — it is a leaf helper on the fast path.
func StripLoneCodeFence ¶
StripLoneCodeFence removes a single surrounding ```…``` fence (optionally language-tagged) from s, returning the inner text trimmed; if s is not a lone fenced block it is returned unchanged.
func TeamStop ¶
func TeamStop(o TeamOutcome) session.StopReason
TeamStop maps a TeamOutcome to its terminal StopReason — the SINGLE quiescent/budget/round-cap rule teamStop applies for EvTeamEnd, exported so the wire layers can stamp the same string-passthrough stop onto the terminal RunTeam outcome frame without duplicating the rule (issue #36).
func TightenTeamTokenBudget ¶
TightenTeamTokenBudget folds a per-request team token budget into the server-configured one, TIGHTEN-ONLY (issue #36): a non-positive request inherits the server budget verbatim; a positive request applies only when it is LOWER than the server's bound (with a 0 server budget meaning "unlimited", so any positive request tightens it). It delegates to the single tightenLimit algorithm the per-call Subagent/Team overrides use — the caller (the wire CreateTeam handlers) can therefore never loosen the operator's ceiling.
Types ¶
type AgentMeta ¶
type AgentMeta struct {
// Name is the agent def's routing key (the value the model passes as `agent`).
Name string
// Description is the one-line summary the model uses to choose a specialist.
Description string
// Limits are the per-def session stop conditions the child session runs under
// when this agent is selected. The composition root derives them from the def's
// maxTurns/maxToolCalls (per-field falling back to the Subagent tool's default
// limits), so a def with no limits carries the same bound as the default
// explorer. A zero Limits value is treated as "no per-def override" — Execute
// then uses the Subagent tool's default limits, exactly as the no-`agent` path does.
Limits session.Limits
// AuthorityCeiling is the resolved read-only definition tool ceiling. It is
// honoured only when Managed is true; lower tiers can never establish a ceiling.
AuthorityCeiling governance.CapabilitySet
// WritableAuthorityCeiling is the corresponding direct-write ceiling used only
// for a fresh mode:"read-write" delegation to this definition.
WritableAuthorityCeiling governance.CapabilitySet
Managed bool
}
AgentMeta is the plain (name, description) summary of one registered agent definition, surfaced in the Subagent tool's Spec().Description for progressive disclosure. It is a layering-clean value type: the composition root translates the agents adapter's Registry into a []AgentMeta + a map[string]*Engine and injects both via WithAgentEngines, so engine/agent never imports the agents adapter.
type ArtifactHandle ¶ added in v0.13.0
type ArtifactHandle string
ArtifactHandle is an opaque handle for a preserved delegation artifact. It is deliberately distinct from the server's placement-selector protocol: artifact discovery never grants placement authority, and no placement API accepts this value.
type BashStatusTool ¶
type BashStatusTool struct{}
BashStatusTool reports the LIVE state of this run's background Bash jobs, collects a finished job's result, and cancels a live job. It reaches the parent run's child registry through parentCaps (the childCapableTool seam) — the same seam SubagentStatus uses, filtered to the bash-cmd family. It is registered wherever the agent Bash tool is (the composition root's main catalogs), never in child catalogs.
func (*BashStatusTool) Execute ¶
func (*BashStatusTool) Execute(_ context.Context, call session.ToolCall, _ tool.Environment) (session.ToolResult, error)
Execute is the caps-less path (plain Execute, no parent run threaded): there is no registry to read, which is an honest model-addressable error — this tool is only meaningful inside a run that registers its children.
func (*BashStatusTool) ExecuteWithParent ¶
func (t *BashStatusTool) ExecuteWithParent(ctx context.Context, call session.ToolCall, env tool.Environment, _ func(session.Event), caps parentCaps) (session.ToolResult, error)
ExecuteWithParent is the childCapableTool seam: it receives the parent run's capabilities and reads/collects/cancels through caps.children, filtered to the bash-cmd family (bashCmdFamiliesOnly).
func (*BashStatusTool) ReadOnly ¶
func (*BashStatusTool) ReadOnly() bool
ReadOnly reports that BashStatus never mutates the workspace: its reads hit the in-memory registry, and cancel only signals the job's context (the kill is the job drive's own ctx reaction). The dispatcher may therefore run it read-parallel — which is also what lets a wait_ms park overlap other tools in the same turn.
func (*BashStatusTool) Spec ¶
func (*BashStatusTool) Spec() tool.ToolSpec
Spec returns the model-facing specification for the BashStatus tool.
type BashTool ¶
type BashTool struct{}
BashTool is the agent-loop Bash tool: the foreground path is byte-identical to the fstools Bash body's orchestration (same arg validation, timeout ctx, runner.Run, combined-output shaping, exit-code error), and background:true detaches the command as a run-scoped background job on the parent run's child registry (the childCapableTool seam). It lives in engine/agent — not the fstools adapter — because the background half needs the child registry, which is an agent-package type.
The tool registers under tool.BashToolName ("Bash"): the permission evaluator special-cases that literal name (resolveBash / planModeDecision / LearnableRule), so a second tool name would silently bypass the bash gate. Statically non-read-only: whether a specific command is read-only is governance's job, not this tool's.
The runner is read off the tool.Environment at Execute time (issue #462), NOT captured at construction: a bound runner is part of the per-namespace Environment (main session, or a forked child whose runner is bound to the child namespace), so the command's cwd always matches the workspace the tool executes against, never a stale shared parent base. A namespace with no shell (env.CommandRunner == nil) surfaces ErrNoShell honestly. The composition root decides whether to REGISTER a Bash tool at all based on runner availability; a shell-less catalog simply omits Bash.
func (BashTool) Execute ¶
func (t BashTool) Execute(ctx context.Context, in session.ToolCall, env tool.Environment) (session.ToolResult, error)
Execute runs a FOREGROUND Bash call. A background:true call must arrive via the childCapableTool seam (ExecuteWithParent), which owns the child registry; on the plain Execute path it gets an honest error, never a silent foreground fallback (the model was promised detached delivery).
func (BashTool) ExecuteWithParent ¶
func (t BashTool) ExecuteWithParent(ctx context.Context, in session.ToolCall, env tool.Environment, _ func(session.Event), caps parentCaps) (session.ToolResult, error)
ExecuteWithParent is the childCapableTool seam. The foreground half is the same as Execute; the background half registers a run-scoped job on the parent run's child registry (cancelled at run end by its drain), detaches the drive, and returns the started-result immediately. It emits NO events — a background Bash job is not a delegation family; the started-result, the registry (notice/status/collect), and the stored result are the only channels.
type BranchJudge ¶
type BranchJudge interface {
// Judge picks the winner among candidates, guided by criteria. It returns the
// 0-based position WITHIN candidates (not a branch index) and a short rationale.
// Implementations MUST be non-interactive and bounded. ParallelTool treats a nil
// error with an out-of-range index — or any error — as "fall back to the first
// successful branch", so a judge must never be load-bearing for correctness.
Judge(ctx context.Context, candidates []BranchSummary, criteria string) (winner int, rationale string, err error)
}
BranchJudge selects a winning branch from candidate summaries. It is the reusable selection seam for the Parallel "judge"/"best" strategy (and, later, a team tournament finish — AGENT-TEAMS-SPIKE §8.7). It is an interface so engine/agent never imports an adapter (the default impl runs an *Engine injected by the composition root) and so a non-LLM scorer can be substituted in tests/future.
func NewEngineJudge ¶
func NewEngineJudge(engine *Engine, opts ...EngineJudgeOption) BranchJudge
NewEngineJudge constructs the default LLM-over-child-Engine BranchJudge. engine must be non-nil; it is the dedicated judge Engine the composition root builds (its own catalog/provider, distinct from the branch child Engine so their LLM calls never interleave). It panics on a nil engine — a judge with no loop to run is a composition-root programming error.
type BranchSummary ¶
type BranchSummary struct {
// Label is the branch's stable, human-meaningful tag (e.g. "branch-2").
Label string
// Summary is the branch's terminal result text.
Summary string
// Failed reports whether the branch failed. ParallelTool only ever passes
// SUCCESSFUL candidates to a judge (you cannot pick a crashed branch), so this
// is false in the default flow; the field exists so a future caller (e.g. a
// team tournament) can pass the full set if it wants.
Failed bool
}
BranchSummary is the compact, transcript-free view of one candidate branch the judge scores. It is exactly the information a human would get from the joined summary — Label + Summary (and whether the branch Failed) — never the branch's intermediate transcript, so judging preserves Parallel's context-isolation guarantee.
type CascadeCompactor ¶
type CascadeCompactor struct {
// Counter sizes the history between tiers; nil → HeuristicTokenCounter.
Counter TokenCounter
// BudgetTokens is the target the cascade reduces toward. Tiers stop running
// once the history is at or below it. Zero means "run every deterministic
// tier once" (snip→strip→collapse), which is the offline default.
BudgetTokens int
// KeepLastTurns overrides cascadeKeepLastTurns when > 0.
KeepLastTurns int
// StripToolBodyChars overrides cascadeStripToolBodyChars when > 0.
StripToolBodyChars int
// MaxCollapseChars overrides cascadeMaxCollapseChars when > 0.
MaxCollapseChars int
// LLM, when non-nil, enables tier 4 (LLM summary of the oldest segment). When
// nil the cascade stops at tier 3 and never makes a model call.
LLM port.LLMProvider
// Model is the model identifier passed to the LLM on the tier-4 summary call.
Model string
// SummaryMaxTokens is the SOFT size budget for the tier-4 summary, expressed
// in the summariser PROMPT (the trailing instruction), not enforced — the
// model may overshoot and the cascade accepts it (fail-open). It is NOT a
// port.LLMRequest field: the request stays provider-neutral, so the budget
// rides as prompt text only. Zero means defaultSummaryMaxTokens.
SummaryMaxTokens int
}
CascadeCompactor is a tiered, cheapest-first Compactor (harness pattern 5, doc 07 §4 / doc 08 #12). It applies up to four tiers in order, stopping as soon as the history fits the budget:
- snip — drop the oldest low-value turns (assistant/tool pairs and old user chatter) outside the preserved head (system + goal) and tail (recent K).
- strip — truncate large tool-result bodies to a small budget, eliding the rest with a marker (the lightest-touch "tool-result clearing").
- collapse — replace large file-read tool results with a path+size pointer, dropping the body entirely (the model can re-read on demand).
- summarize — if still over budget AND an LLMProvider is injected, ask the model for a compact STRUCTURED summary of the oldest segment (the section-locked summarizerSystemPrompt template) and replace it. With no LLM injected the cascade STOPS at tier 3, remaining fully deterministic and offline-testable.
Across every tier it preserves the system prompt, the user goal, all touched file paths (as a synthesised summary message), and the recent tail — back-snapped to recent USER turns (snapCutToRecentUserTurn) so the most-recent user instruction survives verbatim instead of being summarised into the middle (prior art: Codex, gemini-cli; docs/harnesses/07-context-and-mcp.md §4, 03-claude-code-architecture.md, 08-design-considerations.md §12). It drops file bodies, verbose tool output, and old stack traces — the doc-08 "what to preserve / what to drop" contract.
func (CascadeCompactor) Compact ¶
func (c CascadeCompactor) Compact(ctx context.Context, conv *session.Conversation) ([]session.Message, string, error)
Compact implements Compactor by running the tiered cascade. It always returns a reduced (or equal) history and a human-readable summary of what each tier did; it returns an error only when tier-4 summarisation fails — an LLM call error or an EMPTY summary (tiers 1–3 cannot fail) — or when the assembled history would orphan a tool pairing (ErrCompactionWouldOrphan). In both error cases the ORIGINAL history is returned alongside the error (abort-to-original).
type ChildAskReview ¶
type ChildAskReview struct {
// Allowed reports the verdict. An allow approves THIS ONE call only — it is
// never learned as a rule, so an identical later command is reviewed again.
Allowed bool
// Reason is the reviewer's short rationale. On a deny it is folded (clamped)
// into the message the child model sees and into the operator audit line; on
// an allow it rides the audit line.
Reason string
}
ChildAskReview is one reviewer verdict over a child's permission ask.
type ChildAskReviewRequest ¶
type ChildAskReviewRequest struct {
// Ask is the child's pending permission ask under review.
Ask session.PendingAsk
// Isolated reports whether the command would run inside an isolated,
// throwaway worktree/fork (its filesystem effects confined to a disposable
// copy of the workspace; the process, network, and absolute paths are NOT
// isolated). A reviewer may weigh this when judging filesystem-mutating
// commands.
Isolated bool
}
ChildAskReviewRequest is the input to one review. It is a struct (not a positional argument list) so future inputs — a child role, a workspace hint — can be added without breaking external reviewer implementations.
type ChildAskReviewer ¶
type ChildAskReviewer interface {
Review(ctx context.Context, req ChildAskReviewRequest) (ChildAskReview, error)
}
ChildAskReviewer adjudicates a child agent's (subagent / team member / parallel branch) permission ask that the harness could not resolve from its static rules and that no attached human can answer — the alternative to a blanket auto-deny on a non-interactive run.
CONSUMER CONTRACT:
- It is consulted ONLY for a headless, NON-configured ask: a deliberately configured "ask" rule always demands a human and is never delegated here, and an interactive run surfaces the ask to its client instead.
- Each call is bounded by a 30-second deadline (the ctx carries it); a reviewer that has not answered by then is treated as a failure.
- An allow grants the ONE call under review — nothing is learned.
- Return ErrNotReviewable to ABSTAIN (falls through to auto-deny, does NOT count against the breaker). EVERY OTHER error — and any unparseable or ambiguous verdict — falls through to auto-deny AND counts toward the consecutive-failure breaker (Deps.ChildAskReviewMaxDenies). A reviewer is therefore FAIL-SAFE and never load-bearing for safety.
- Implementations MUST be non-interactive and safe for concurrent calls.
It is an interface so the engine never depends on a concrete reviewer: the default implementation runs an injected one-turn Engine, and a consumer may substitute its own policy engine.
func NewEngineAskReviewer ¶
func NewEngineAskReviewer(engine *Engine, opts ...EngineAskReviewerOption) ChildAskReviewer
NewEngineAskReviewer constructs the default LLM-over-Engine ChildAskReviewer. engine must be non-nil; it is the dedicated, tool-less reviewer Engine the caller builds. It panics on a nil engine — a reviewer with no loop to run is a programming error.
type Compactor ¶
type Compactor interface {
Compact(ctx context.Context, conv *session.Conversation) (compacted []session.Message, summary string, err error)
}
Compactor compresses a Conversation that has grown past the context-window threshold into a shorter, semantically-equivalent history. It is a seam (ARCHITECTURE.md §8, gauntlet #12): the default implementation is a pure, offline heuristic, but an LLM-backed summariser can be slotted in behind the same interface.
Compact returns the replacement message slice, a human-readable summary of what was dropped (surfaced on the compaction Event), and an error only on a genuine failure. Implementations MUST preserve file paths, decisions, and unresolved questions while dropping large tool-output bodies and stale file contents.
type DelegationTightening ¶
type DelegationTightening struct {
Tools []string `json:"tools,omitempty"`
RemainingDelegationDepth *int `json:"remaining_delegation_depth,omitempty"`
FileSystem *bool `json:"filesystem,omitempty"`
DirectWrite *bool `json:"direct_write,omitempty"`
}
DelegationTightening is the optional per-call capability reduction requested for a Subagent. Nil fields inherit the already-derived value.
type Deps ¶
type Deps struct {
// LLM is the model-call seam.
LLM port.LLMProvider
// Catalog is the tool registry; the loop reads Specs(mode) and Lookup(name).
Catalog *tool.Catalog
// Policy evaluates each tool call (deny → ask → allow).
Policy port.PermissionPolicy
// AuthorityEvaluator authorizes executions for sessions carrying a derived
// authority set. Bound sessions require an evaluator; composition selects either
// enforcement or the explicit noop evaluator when it mints bound sessions.
AuthorityEvaluator port.AuthorityEvaluator
// Hooks runs the PreToolUse / PostToolUse lifecycle hooks.
Hooks port.HookRunner
// Store persists session state (optional; nil disables persistence).
Store port.SessionStore
// SessionLiveness is the optional lifecycle-exclusion seam for engine-owned
// child sessions. The parent run registers each delegation child before it can
// queue or drive and releases it on every terminal or pre-start-abort path.
// Composition may back it with both process-local tracking and SessionLease;
// registration failure prevents the child from becoming runnable.
SessionLiveness port.SessionLiveness
// Clock supplies wall time for tool-call timing (optional; nil → no timing).
Clock port.Clock
// ToolCallRecorder records tool-execution observability — the tool-call audit
// seam (optional; nil → no recording).
ToolCallRecorder port.ToolCallRecorder
// Sink, when non-nil, also receives every Event the loop emits, in addition
// to the Run.Events() channel which is always the primary surface.
Sink port.EventSink
// EnableDurableEvidence emits debugger-only request manifests and accepts
// sanitized provider-attempt observations. It is opt-in because constructing
// that evidence requires hashing/counting, contexts, maps, and slices; hosts
// should enable it only when their relay persists events to a durable EventLog
// for later inspection. Sink presence is not a durability signal.
EnableDurableEvidence bool
// Diagnostics is the general-purpose operational logging seam (optional; nil →
// port.NopDiagnostics, applied in NewEngine, so the engine never nil-panics and
// stays silent when no sink is injected). It is DISTINCT from ToolCallRecorder
// (the per-tool audit seam) and Sink (the model's conversation stream).
Diagnostics port.Diagnostics
// LearningMode and LearningObserver install the optional completed-trajectory
// observation seam. Observation is synchronous and runs once after each eligible
// clean completion. The zero mode, Off, or a nil observer is inert.
LearningMode learning.Mode
LearningObserver learning.Observer
// Compactor compresses history at the threshold; nil → HeuristicCompactor.
Compactor Compactor
// TokenCounter estimates history size for the compaction trigger (and is
// shared with the Compactor); nil → HeuristicTokenCounter.
TokenCounter TokenCounter
// Instructions assembles the project-instruction messages recorded once at
// the start of a run; nil → prompt.RootAssembler (root-only AGENTS.md /
// CLAUDE.md, the v1 default).
Instructions prompt.InstructionAssembler
// CommandExpander rewrites a raw user prompt into the text the model sees,
// expanding slash-command invocations (e.g. "/review foo.go") against the
// workspace before the prompt is recorded; nil → prompt.NoopExpander (no
// expansion, the v1 default).
CommandExpander prompt.CommandExpander
// PromptConfig seeds the cache-stable system prompt (role/tone/safety). The
// loop fills in Tools and the volatile Env per turn.
PromptConfig prompt.Config
// PromptBuilder assembles the layered system prompt each turn. nil →
// prompt.Build (the v1 default: coding-agent role/tone/safety + tool
// inventory), byte-identical to v0.0.1. A host that needs a fully host-owned
// system prompt (e.g. a non-coding agent) supplies a prompt.Builder here; it
// receives the same Config the loop builds (Tools + volatile Env and live
// operator profile filled per turn) and returns the Layered prompt. Only the
// MAIN loop's buildRequest routes through this field — the compaction summarizer
// (cascade.go) builds its own prompt.Layered directly and is unaffected.
PromptBuilder prompt.Builder
// OperatorProfileSource supplies full durable user facts. When non-nil it is
// read before every model request; failures are fail-soft and retain the last
// good snapshot within the run.
OperatorProfileSource prompt.OperatorProfileSource
// Model is the provider model identifier sent on every request.
Model string
// ContextWindow returns the model's context window in tokens, resolved LIVE at
// the point of use (the compaction check / Engine.ContextWindow) rather than
// frozen at construction — so a post-construction live-catalog refresh self-
// corrects on the next turn without rebuilding the engine. A nil closure OR a
// <=0 return DISABLES compaction (preserving the old "zero disables"). The
// closure is a stdlib func value; it is built in composition (internal/app)
// over the FIXED (provider, model) so the engine itself imports no adapter.
ContextWindow func() int
// CompactionRatio overrides defaultCompactionRatio when in (0,1].
CompactionRatio float64
// Role is the operator-facing label this engine logs under in diagnostics: the
// empty string for the MAIN engine (correlated by session only), or a non-empty
// role for a child/subagent engine (e.g. "task" for the Subagent tool, a team
// member's name, or a fork-branch label) so interleaved child diagnostics are
// readable. It is read once per run when binding the run-scoped Diagnostics (see
// drive): empty → only the "session" key; set → "session"+"agent" keys. It is
// NOT plumbed into Sink/ToolCallRecorder (those stay off for children) and never
// reaches the model.
Role string
// MaxNoProgressNudges bounds how many times the loop injects a continuation
// ("please continue") user message after a completed turn that produced NEITHER a
// tool call NOR meaningful assistant text (a reasoning-only / empty turn). It is
// the blast-radius knob for the no-progress handler. Semantics (applied in
// NewEngine): a ZERO value (the default; existing Deps built without it) uses the
// safety-net default defaultNoProgressNudges (2); a NEGATIVE value DISABLES nudging
// entirely (a no-progress turn terminates immediately with StopNoProgress, the old
// behaviour minus the silent StopEndTurn mislabel); a positive value overrides the
// default. It is a loop concern, exactly like CompactionRatio — NOT a
// port.LLMRequest field (the request stays provider-neutral). Composition plumbs it
// from Config so it is operator-tunable; children inherit the default.
MaxNoProgressNudges int
// Interactive reports whether a HUMAN approver is attached to this engine's runs:
// true for the bidi Converse / HTTP-SSE surfaces where a client can answer a
// permission ask, false for a headless/in-process run (the demo, a RunTeam with no
// attached client). It is composition's knowledge of whether an approval UI exists,
// set on the MAIN engine only and read once per run (drive) to (a) install the
// child-ask router so a subagent's ask can be SURFACED to the human, and (b) tell a
// subagent posture whether to surface (interactive) or auto-deny (headless). It is a
// plain bool — NOT a port.LLMRequest field and never reaches the model. Child engines
// leave it false (a child never surfaces further). DEFAULT false (fail-safe: a run
// with no declared approver auto-denies a subagent ask rather than hanging).
Interactive bool
// PlanModeAutoApprove is an OPT-IN, OPERATOR-TIER-ONLY, DEFAULT-OFF flag that
// tells the engine to SURFACE a plan-approval ask (PresentPlan) even when headless
// (no human approver attached), so the composition layer's Service can auto-resolve
// it via ApprovePlan without operator interaction. It is DELIBERATELY ONLY the
// PresentPlan gate — a non-plan ask (policy/hook) is still headless-auto-denied.
// DEFAULT false (fail-safe: a headless plan ask is auto-denied like every other ask).
// It is a plain bool — NOT a port.LLMRequest field and never reaches the model.
// Child engines inherit it from the parent (so a Subagent/team child's plan ask also
// parks rather than auto-denies), allowing composition to auto-approve at the Service
// layer.
PlanModeAutoApprove bool
// ChildAskReviewer, when non-nil, reviews a child agent's (subagent / team
// member / parallel branch) permission ask that the harness could not resolve
// statically and that no attached human can answer — instead of blanket-denying
// it. It is consulted ONLY for a headless run with no interactive approver, and
// never for a deliberately-configured "ask" rule (that always demands a human).
// An allow grants the one call only (nothing is learned); a deny — or any review
// failure, timeout, or ambiguous verdict — keeps the call denied, so a reviewer
// is fail-safe and never load-bearing for safety. Reviews are bounded by a
// 30-second deadline and serialised through a per-run consecutive-failure
// breaker (ChildAskReviewMaxDenies). DEFAULT nil: a headless ask is
// blanket-denied, the long-standing behaviour. Set this on the engine whose runs
// have no human approver; an engine whose runs surface asks to a client leaves
// it unused (the surface path wins).
ChildAskReviewer ChildAskReviewer
// ChildAskReviewMaxDenies is the per-run circuit-breaker threshold for
// ChildAskReviewer: after this many CONSECUTIVE non-allow review outcomes (deny
// verdicts, failures, timeouts, ambiguous verdicts — an abstention via
// ErrNotReviewable does not count) within one run, further asks skip the reviewer
// and fall through to auto-deny, bounding reviewer spend. An allow resets the
// count. <=0 (the default) uses a built-in threshold of 3. Only consulted when
// ChildAskReviewer is set.
ChildAskReviewMaxDenies int
// MaxRunTokens is the loop-level cumulative TOKEN ceiling for a single run: when
// the run's accumulated session.Usage (input+output, via Usage.TotalTokens) crosses
// this value, the loop terminates CLEANLY at the next turn boundary with
// session.StopBudget. It is the shared runaway brake the AGENT-TEAMS-SPIKE named the
// missing token budget — checked in drive Step 2, so it serves EVERY engine: main +
// Subagent + Team member + lead synthesis + Fork branch. Semantics: 0 (the default;
// existing Deps built without it) DISABLES the budget (behaviour byte-identical to
// before); a positive value is the ceiling. It is a turn-BOUNDARY check (never a
// mid-stream abort), so an in-flight turn always completes and
// no-replay-after-first-chunk holds; the terminal is non-error, so the session ends
// COMPLETED and stays Reopen-recoverable (mirrors StopNoProgress exactly). It is a
// loop concern, like CompactionRatio / MaxNoProgressNudges — NOT a port.LLMRequest
// field (the request stays provider-neutral). Composition plumbs it from Config so it
// is operator-tunable; children INHERIT it (childEngineDepsForProvider keeps it) and a
// per-call override may only TIGHTEN it.
MaxRunTokens int
// SubagentModelRouter, when non-nil, is the OPT-IN semantic model router (ADR
// 0031, the Phase 5 headline feature): given a Subagent call's (model-authored,
// untrusted) task prompt it returns the ALREADY-RESOLVED concrete model id to mint
// the child on, plus the category label it classified into, plus the classifier's
// session.Usage (which the dispatch-path routeTask closure folds into the parent
// sess.Usage so classifier spend counts against --max-run-tokens — the #92 fix).
// It is a composition closure — the engine layer is model-string-only (the layering
// rule): composition owns the classifier engine, the category taxonomy, and the
// category→model mapping (aliases/slots/the allowlist cap), and hands the engine
// only func(ctx, string)(string, string, session.Usage, string, bool) (the trailing
// string is the miss REASON — issue #287, logged VERBATIM at the dispatch chokepoint
// on a miss; empty on a hit). The reason is OPERATOR-DIAGNOSTIC detail: it also rides
// the delegation-start event's RoutingReason field, but ONLY after the engine's
// event-safe allowlist (routingReasonPayload) confines it to the harness/composition
// metadata constants — an external composition returning a provider error body,
// classifier output, or a task excerpt sees it substituted with a generic label on the
// wire (gauntlet #7), while the verbatim text stays in the diagnostics channel. It is consulted by
// the Subagent run() hook ONLY for a plain default delegation (no per-call model,
// no agent, no fork, no resume) and is FAIL-SOFT throughout: ok=false (any
// classifier failure, an unknown category, the breaker open) → the call falls
// through to the inherited default explorer model, byte-identically to a deployment
// with no router. DEFAULT nil: no router, the long-standing behaviour. Set on the
// MAIN engine only (a child has no Subagent tool, so structurally no router);
// childEngineDepsForProvider forces it nil (the no-nesting recursion guard). Like
// ChildAskReviewer, the router is built into the per-call parentCaps.routeTask
// closure in Engine.parentCaps, never called directly by the loop, so it is NOT a
// port.LLMRequest field and never reaches a request.
//
// The ctx is the RUN's ctx (threaded down via parentCaps.routeTask), NOT
// context.Background(): a Run.Cancel between the breaker's hardAbort check and the
// classifier call must propagate into RunModelRouter so the classifier turn dies
// with the run instead of running out its 30s clock (issue #94 — the
// cancellation-propagation gap the hardAbort TOCTOU otherwise leaves). Fail-soft
// holds regardless: a cancelled ctx yields StopCancelled → ok=false → inherit the
// default model, exactly the existing miss path.
SubagentModelRouter func(ctx context.Context, taskPrompt string) (category, model string, usage session.Usage, missReason string, ok bool)
// ProgressiveTools, when true, enables progressive tool disclosure
// (pattern 9): the per-turn request advertises lightweight specs for tools
// implementing tool.Disclosable plus a built-in ToolSearch tool the model
// uses to hydrate a full spec on demand. The DEFAULT (false) sends every
// tool's full spec every turn, exactly as v1 does. The ToolSearch tool is
// registered into the catalog by NewEngine only when this is enabled.
ProgressiveTools bool
// DeliveryQueue, when non-nil, is the DURABLE per-session pending-delivery queue
// the loop's turn-boundary drain reads (ADR 0075 decision #3, fire-result-delivery
// Scenario 4). The fire path (composition) enqueues a rendered fire-result note
// for an origin session that is BUSY or AWAITING (it cannot drive a delivery run
// without colliding); the loop drains the pending notes at Step 2a, BEFORE
// BeginTurn — the SAME turn-boundary seam injectBackgroundNotice uses — recording
// each as an ordinary harness-framed user continuation (recordContinuation) and
// marking it delivered via MarkDelivered (the session-scoped exactly-once ledger).
//
// The drain is registered on the MAIN + per-session engines ONLY — never on child
// engines (a child origin degrades to pull-only with a WARN at the fire path). nil
// (the default) is the byte-identical no-delivery path: nothing drains, the
// fire path's enqueue is a no-op against a nil queue. It is a port (port.DeliveryQueue),
// so the agent package imports no concrete adapter.
DeliveryQueue port.DeliveryQueue
// EnableSteer, when true, arms each Run with an in-memory, best-effort
// steer inbox (steer-while-running, issue #512): an operator-supplied
// instruction enqueued mid-run that the loop drains at the next turn
// boundary (Step 2a, the same seam injectBackgroundNotice /
// drainPendingDelivery use) and records as an ordinary user continuation
// via recordContinuation, so it replays to the model and flows through
// compaction / session.ValidateToolPairing / ADR-0038 rehydration
// unchanged. It is a plain loop-concern bool — NOT a port.LLMRequest field
// and never reaches the model as anything but an ordinary recorded user
// message. DEFAULT false (the zero value): no inbox is armed and the drain
// is a strict no-op, byte-identical to the pre-steer posture. Composition
// plumbs it from Config; child engines inherit the shared deps.
// A pending (un-drained) steer is in-memory only and lost with the run —
// never persisted (see steer.go).
EnableSteer bool
}
Deps are the injected ports and configuration a single Engine is built from. Every field is a port (an interface) or plain config, so the agent package never depends on a concrete adapter. The composition root wires real or fake adapters in.
type Engine ¶
type Engine struct {
// contains filtered or unexported fields
}
Engine builds Runs from a fixed set of ports. It is safe for concurrent use: each Run owns its own goroutine and state, and the injected ports are expected to be concurrency-safe (the provided adapters are). One Engine typically backs the whole process; the API layer (WP10) calls Run per prompt.
func NewEngine ¶
NewEngine constructs an Engine from deps, applying defaults for the optional Compactor and CompactionRatio.
func (*Engine) Capabilities ¶
func (e *Engine) Capabilities() port.ProviderCapabilities
Capabilities reports the multimodal input capabilities of the Engine's LLM provider, so a surface adapter can advertise them and gate unsupported prompt content. It is a pure pass-through to the injected provider.
func (*Engine) CompactSession ¶
func (e *Engine) CompactSession(ctx context.Context, sess *session.Session) (ManualCompactionResult, error)
CompactSession runs the configured Compactor once at a session turn boundary, regardless of the automatic compaction threshold. It creates no conversation turn. Empty, identical, and non-reducing candidates are successful no-ops. Invalid tool pairing and compactor failures are returned without mutation.
func (*Engine) ContextWindow ¶
ContextWindow reports the model's context window in tokens, resolved LIVE via Deps.ContextWindow at the point of call, or 0 when unknown/unset/disabled. The team supervisor reads it from each member's engine so a forwarded turn.end can carry the denominator for the per-member context meter in the ctrl+a agents overlay (the resolver lives in private deps).
func (*Engine) HasTool ¶
HasTool reports whether a tool with the given registered name is present in the Engine's catalog. It is the read-only seam a surface adapter uses to report capabilities (e.g. memory/skills/bash availability) from the BUILT catalog rather than a static config flag, so the report can never claim a feature the engine did not register. It is nil-safe: a nil catalog yields false. It exposes only presence, never the concrete tool, keeping the agent package free of any adapter dependency.
func (*Engine) Model ¶
Model returns the provider model identifier this engine sends on every request (deps.Model). It is the read-only seam a delegation emitter uses to surface the child's resolved model on its start event, independent of how the model was chosen (inherited default, agent-def pin, per-call override, or the opt-in router). The string is bare metadata (a model id); the engine stays model-string-only — no adapter/proto type crosses here. Used by the Subagent / Parallel / Team delegation emit sites to populate the generic Model field (issue #112, ADR 0035).
func (*Engine) ResumeApproval ¶
func (e *Engine) ResumeApproval(ctx context.Context, sess *session.Session, env tool.Environment, askID string, verdict session.ApprovalVerdict) *Run
ResumeApproval is the FOURTH, awaiting-ONLY run-entry seam (cloud-native Phase 2): it re-enters the loop AT a parked permission ask on a session that is in StateAwaiting (typically loaded fresh from a snapshot after the process that parked the ask died), applies verdict to the pending tool call, closes out any unanswered sibling calls on the same trailing assistant message, then continues the loop to completion. It mirrors Engine.Run's Run-construction preamble exactly (same events/asks/cancel/ctx/hardAbort/serial/diag/children/router discipline) but launches driveFromAwaiting instead of drive.
It is DISTINCT from the three existing terminal-recovery seams (Reopen / Interrupt / Recover, all via resetToIdle at a turn boundary): those zero the Counters and clear the pending ask; this preserves both via the awaiting-only session.ResumeWith seam (the live loop's own resume path), so the re-entered run continues the SAME logical turn with its spend and limits intact. It adds NO new aggregate transition verb (ResumeWith already exists); the "fourth seam" is the run-entry orchestration here + Service.resumeFromAwaiting, not a domain change.
PRECONDITION FAILURES surface ON THE RUN, not as a return value (ResumeApproval only ever returns a live *Run): if sess is not in StateAwaiting, or its pending ask does not match askID, or the trailing assistant message carries no matching tool call, driveFromAwaiting terminates the returned run with session.StopError carrying a cause matching errors.Is(_, ErrNotAwaiting). A consumer reads this off the run's terminal EvResult (Stop == StopError, the Error field), exactly like any other terminal — it never silently completes. The pending tool executes EXACTLY ONCE on the allow path and NOT AT ALL on a precondition failure or a deny.
func (*Engine) RetryFailedStep ¶
func (e *Engine) RetryFailedStep(ctx context.Context, sess *session.Session, env tool.Environment) *Run
RetryFailedStep resumes the failed model step without submitting another user prompt. Persisted conversation and tool state are reused, while live turn-0 instructions and system prompt inputs are re-resolved by the normal request builder. sess must carry durable failed-step retry intent prepared by the host.
func (*Engine) Run ¶
func (e *Engine) Run(ctx context.Context, sess *session.Session, env tool.Environment, req RunRequest) *Run
Run starts processing req against sess with the exact supplied environment and returns immediately with a handle to the background run.
func (*Engine) SteerEnabled ¶
SteerEnabled reports whether this engine arms its Runs with the mid-run steer inbox (Deps.EnableSteer, steer-while-running issue #512). It is the read-only seam composition reads to advertise the feature — the ServerCapabilities.steer bit reads the SAME wired knob the runs consult, so the advertisement can never claim a steer path the engine did not arm (single-source, never recomputed per sink).
type EngineAskReviewerOption ¶
type EngineAskReviewerOption func(*engineAskReviewer)
EngineAskReviewerOption configures an engineAskReviewer.
func WithAskReviewPolicy ¶
func WithAskReviewPolicy(p string) EngineAskReviewerOption
WithAskReviewPolicy overrides the trusted policy rubric the default reviewer applies. Empty/whitespace is ignored (the built-in rubric stands).
type EngineJudgeOption ¶
type EngineJudgeOption func(*engineJudge)
EngineJudgeOption configures an engineJudge.
func WithJudgeLimits ¶
func WithJudgeLimits(l session.Limits) EngineJudgeOption
WithJudgeLimits overrides the judge run's stop conditions (default defaultChildLimits).
func WithJudgeSessionPrefix ¶
func WithJudgeSessionPrefix(p string) EngineJudgeOption
WithJudgeSessionPrefix sets the prefix used to derive the judge session id (default "fork-judge").
type EvidenceReflector ¶
type EvidenceReflector struct {
// contains filtered or unexported fields
}
EvidenceReflector performs exactly one provider-neutral, zero-tool model turn for an admitted reflection input. It never enters the Engine loop.
func NewEvidenceReflector ¶
func NewEvidenceReflector(provider port.LLMProvider, model string, counter TokenCounter, limits ReflectionLimits) (*EvidenceReflector, error)
NewEvidenceReflector constructs a bounded direct model-backed reflector. A nil counter selects the dependency-free heuristic counter.
func (*EvidenceReflector) Reflect ¶
func (r *EvidenceReflector) Reflect(ctx context.Context, in learning.Input) (learning.Outcome, error)
Reflect implements learning.Reflector. Inputs without any host-supplied or structurally detected signal abstain without spending a provider call.
func (*EvidenceReflector) ReflectProjection ¶ added in v0.14.0
func (r *EvidenceReflector) ReflectProjection(ctx context.Context, projection learning.Projection) (learning.Outcome, error)
ReflectProjection performs reflection across a restart-safe boundary that accepts only the bounded canonical learning projection. It rejects projections that are not reproducible from their own content-addressed evidence metadata.
func (*EvidenceReflector) RequestTokenEstimate ¶
func (r *EvidenceReflector) RequestTokenEstimate(in learning.Input) (int, error)
RequestTokenEstimate returns the selected-model estimate for the exact bounded request Reflect would send, plus the configured maximum output tokens.
type HeuristicCompactor ¶
type HeuristicCompactor struct {
// MaxToolBodyChars overrides maxToolBodyChars when > 0.
MaxToolBodyChars int
// KeepLastTurns overrides keepLastTurns when > 0.
KeepLastTurns int
}
HeuristicCompactor is the default, network-free Compactor. It keeps the system prompt and the user goal, synthesises a single summary message that lists every file path touched so far, truncates large tool-result bodies, and preserves the last keepLastTurns messages verbatim — back-snapped to recent USER turns (snapCutToRecentUserTurn) so the most-recent user instruction survives verbatim rather than being summarised away (prior art: Codex, gemini-cli keep the recent user turns verbatim; docs/harnesses/07-context-and-mcp.md §4 "what to preserve", 03-claude-code-architecture.md "first user message is summarized away", 08-design-considerations.md §12). It performs no LLM call, so it is fully deterministic and testable offline (gauntlet #12-lite).
type HeuristicTokenCounter ¶
type HeuristicTokenCounter struct {
// CharsPerToken overrides charsPerToken when > 0.
CharsPerToken int
}
HeuristicTokenCounter is the default, dependency-free TokenCounter. It divides byte length by charsPerToken and adds a small fixed overhead per message and per tool call so a history of many short messages is not undercounted. It performs no network or tokenizer-table lookup, so it is fully deterministic and testable offline. It replaces the inline 4-chars/token estimate the loop used before the TokenCounter seam existed.
func (HeuristicTokenCounter) Count ¶
func (h HeuristicTokenCounter) Count(text string) int
Count implements TokenCounter for a single string.
func (HeuristicTokenCounter) CountMessages ¶
func (h HeuristicTokenCounter) CountMessages(msgs []session.Message) int
CountMessages implements TokenCounter for a conversation slice, summing the text/reasoning/tool bodies (divided by the chars-per-token ratio) plus the fixed per-message and per-tool-call framing overhead.
type InspectMemberTool ¶
type InspectMemberTool struct {
// contains filtered or unexported fields
}
InspectMemberTool reads one persisted team member session via a port.SessionStore and returns a BOUNDED rendering of its conversation as its ToolResult. It is PULL and read-only.
func (*InspectMemberTool) Execute ¶
func (t *InspectMemberTool) Execute(ctx context.Context, call session.ToolCall, _ tool.Environment) (session.ToolResult, error)
Execute loads the member's persisted session (id derived via the SHARED MemberSessionID helper, so it cannot drift from the supervisor's save id) and renders a bounded transcript. An unknown id is a model-addressable error (the team may not have run, or the member never started).
func (*InspectMemberTool) ReadOnly ¶
func (*InspectMemberTool) ReadOnly() bool
ReadOnly reports that InspectMember only READS the store (no workspace mutation), so the dispatcher may run it read-parallel.
func (*InspectMemberTool) Spec ¶
func (*InspectMemberTool) Spec() tool.ToolSpec
Spec returns the model-facing specification for the InspectMember tool.
type InspectSubagentTool ¶
type InspectSubagentTool struct {
// contains filtered or unexported fields
}
InspectSubagentTool reads one persisted subagent OR Parallel-branch session via a port.SessionStore and returns a BOUNDED rendering of its conversation as its ToolResult. It is PULL and read-only. The agent_id it takes IS the session id verbatim — no derivation — so the id from a Subagent result's 'agentId:' line OR a Parallel result's 'branch id:' line loads directly (issue #30).
func (*InspectSubagentTool) Execute ¶
func (t *InspectSubagentTool) Execute(ctx context.Context, call session.ToolCall, _ tool.Environment) (session.ToolResult, error)
Execute loads the subagent's persisted session by its agent id (the id IS the session id, verbatim — no derivation) and renders a bounded transcript. An unknown id is a model-addressable error (the subagent may not have run yet).
func (*InspectSubagentTool) ReadOnly ¶
func (*InspectSubagentTool) ReadOnly() bool
ReadOnly reports that InspectSubagent only READS the store (no workspace mutation), so the dispatcher may run it read-parallel.
func (*InspectSubagentTool) Spec ¶
func (*InspectSubagentTool) Spec() tool.ToolSpec
Spec returns the model-facing specification for the InspectSubagent tool.
type LRUForkReaper ¶
type LRUForkReaper struct {
// contains filtered or unexported fields
}
LRUForkReaper is a process-scoped, bounded PreservedForkStore: it keeps the most-recent cap preserved winner forks and reaps the OLDEST when a new winner exceeds the cap (invoking that fork's captured cleanup). This bounds the disk a run can accumulate from preserved winners while keeping the most recent winners inspectable. It is safe for concurrent use.
A non-positive cap is normalised to DefaultPreservedForkCap. A reaper is NOT required for Parallel to work — without one, ParallelTool falls back to the original behaviour (winner forks are preserved forever); the reaper is the bound.
func NewLRUForkReaper ¶
func NewLRUForkReaper(capacity int) *LRUForkReaper
NewLRUForkReaper constructs a bounded reaper that keeps at most cap preserved winner forks (the most recent). A non-positive cap uses DefaultPreservedForkCap.
func (*LRUForkReaper) Close ¶ added in v0.13.0
func (r *LRUForkReaper) Close()
Close reaps every retained fork and waits for eviction cleanups detached before closure. It is safe to call concurrently with Preserve and is idempotent. Once closed, a reaper never retains another fork: Preserve instead invokes its supplied cleanup immediately, and Close does not wait for that later work. Cleanup runs outside the mutex.
func (*LRUForkReaper) Len ¶
func (r *LRUForkReaper) Len() int
Len reports how many preserved forks the reaper currently retains. Test/diagnostic only.
func (*LRUForkReaper) Preserve ¶
func (r *LRUForkReaper) Preserve(handle ArtifactHandle, cleanup func() error)
Preserve records a winner fork and reaps the oldest beyond the cap. Re-preserving the same handle refreshes its recency (and adopts the new cleanup) rather than double-counting. A nil cleanup is ignored. After Close, cleanup runs immediately. Cleanup runs OUTSIDE the lock so a slow filesystem teardown does not serialize concurrent Parallel calls.
type ManualCompactionResult ¶
ManualCompactionResult describes one out-of-band compaction attempt. Archive and Summary are populated only when Changed is true and are suitable for the existing compaction notice and durable archive events.
type MemberBuild ¶
type MemberBuild struct {
// Engine is the member's loop engine. It must be non-nil; AddMember rejects a
// nil Engine with ErrNilEngine.
Engine *Engine
// Mode is the optional per-member permission mode. Empty => the team default.
Mode session.PermissionMode
// Limits are the OPTIONAL per-member, per-round stop conditions resolved from the
// member's agent definition (its maxTurns/maxToolCalls). A zero Limits field means
// "use the supervisor's team default" (s.limits / WithTeamLimits) for THAT field,
// exactly as an empty Mode falls back to s.mode — the factory is the only place
// that resolves a def to limits, so the mapping stays in the composition layer and
// the supervisor stays agnostic of agent definitions. A wholly zero Limits leaves
// the member on the team default, unchanged.
Limits session.Limits
// Close, if non-nil, tears down resources the factory opened for THIS member —
// specifically the inline per-agent MCP manager(s) connected for the member's
// agent definition (a reference entry opens nothing, so it contributes no Close).
// The supervisor composes it with the member's fork cleanup so it runs on every
// teardown path (cleanupAll, a failed/stopped member, a rejected enrolment).
Close func() error
// MCPToolNames are the names of MCP tools the factory added to this member's
// catalog (from its def's mcpServers). MCP tools report ReadOnly()==false but
// touch only the remote server, never the workspace, so the supervisor EXEMPTS
// them from the read-only-member workspace-mutating-tool backstop — exactly like
// the team coordination tools. Empty when the def scopes no MCP servers.
MCPToolNames []string
// IsolateReadOnly tells the supervisor this is a NON-mutating member that the
// factory nonetheless gave Bash (i.e. it put a workspace-mutating shell into a
// read-only member's catalog because a read-only forker is available). When true
// the supervisor runs the member in an isolated git WORKTREE (via s.roForker) so
// its mutating-classified Bash lands in a throwaway checkout, never the shared
// base — and the read-only-member workspace-mutating-tool backstop EXEMPTS it
// (the guard gates on base-sharing, and an isolated member is not base-sharing).
// It is false for a base-sharing read-only member (no shell) and for a Mutating
// member (the Mutating flag already drives its force-copy fork). The factory must
// set it true ONLY when it actually added Bash to a non-mutating catalog AND a
// read-only forker is available; setting it without a wired forker trips
// ErrReadOnlyShellNoForker.
IsolateReadOnly bool
}
MemberBuild is what the per-member engine factory returns: the constructed *Engine plus the OPTIONAL per-member permission mode resolved from the member's agent definition. An empty Mode means "use the team-wide default" (WithTeamMode / s.mode); a non-empty Mode (e.g. session.ModePlan from a def's permissionMode) overrides it for THIS member's session only. The mode lives here, not on MemberSpec, because the factory is the only place that resolves a def to a mode — keeping the spec a pure request value and the supervisor agnostic of agent definitions (the registry→mode mapping stays in the composition layer).
type MemberDisposition ¶
type MemberDisposition string
MemberDisposition is the terminal disposition of one team member at the end of a Run: a closed enum (done / stopped), never free-form text. It is a SUPERVISOR verdict, not member-authored content, so it sidesteps the redaction question.
const ( // DispositionDone is a member that finished cleanly (idle, no-progress, or any // non-error terminal whose session re-opened successfully). DispositionDone MemberDisposition = "done" // DispositionStopped is a member that ended non-resumably or exhausted its budget. DispositionStopped MemberDisposition = "stopped" )
type MemberEngine ¶
type MemberEngine func(spec MemberSpec, routedModel string) MemberBuild
MemberEngine builds the per-member engine (and its optional permission mode) from its spec. The composition root supplies it; it is expected to capture the shared *team.Team so the member's catalog includes MemberTools(team, spec.Name) (always available to a member, even under a restrictive agent definition) plus the member's scoped base tools, model, and policy.
Catalog shaping follows the three-tier workspace policy (see the package doc):
- A read-only member that the factory CANNOT isolate (no read-only forker / runner wired) shares the base workspace, so it must get NO mutating tool (no Edit/Write/Bash) and MemberBuild.IsolateReadOnly stays false.
- A read-only member the factory CAN isolate gets Read/Grep/Glob PLUS Bash (but NOT Edit/Write) and sets MemberBuild.IsolateReadOnly=true, so the supervisor runs it in a throwaway git worktree (s.roForker) where its Bash is confined.
- A Mutating member (which runs in an isolated force-copy fork via s.forker) may get Edit/Write/Bash.
Bash is workspace-aware: BashTool.Execute runs the command with the member's (forked) Workspace.Root() as the working directory, so an isolated member's Bash runs in its OWN worktree/fork, never the shared parent base — which is why an isolated member MAY be given Bash while a base-sharing read-only member must not.
routedModel is the OPT-IN semantic model router's classification for an UNDEFINED member (ADR 0034), the ALREADY-RESOLVED concrete model id the member's engine should be minted on; it is "" when the router was off, missed, or the member is DEFINED (a def pins its own model — the factory IGNORES routedModel then). The supervisor owns the route decision (it holds the parent caps) and passes the result here; composition substitutes routedModel for the default child model only on the undefined branch.
type MemberOutcome ¶
type MemberOutcome struct {
// Name is the member name.
Name string
// LastText is the member's most recent terminal assistant text.
LastText string
// Stopped reports whether the supervisor descheduled the member before the team
// finished (its last run failed or was cancelled, or it exhausted its lifetime turn
// budget), so it ran no further rounds.
Stopped bool
// Disposition is the member's TERMINAL disposition (done / stopped). It is the
// closed-enum form of Stopped: Disposition == DispositionStopped iff Stopped.
Disposition MemberDisposition
// Reason is WHY a stopped member stopped (error / cancelled / budget); empty for a
// done member.
Reason MemberStopReason
// ErrorRounds is how many of this member's rounds ended in session.StopError,
// whether it was RETRIED through them or finally benched by them (issue #318 /
// ADR 0200). It is the disposition-HONESTY signal: a bounded retry means
// a member can fail a round and still finish, and such a member reports
// DispositionDone with no Reason — so without this count a transient failure would
// be invisible to the caller and the run would read as silently clean. It is a
// COUNT, deliberately not a new MemberDisposition value: the disposition is a closed
// enum mirrored on the proto wire, and "done" is still the honest terminal.
//
// It is INDEPENDENT of the terminal: it counts errored rounds over the member's whole
// LIFETIME (the counter is monotonic — that monotonicity is the retry cap's
// termination proof), so it does NOT follow from Reason and Reason does not follow
// from it. 0 exactly when the member never had an errored round. A member benched by
// its errors has >=1 alongside Stopped/StopReasonError, but so can one benched for
// cancellation or budget: a member that failed round 1, was recovered and retried,
// then was cancelled in round 2 reports Reason "cancelled" with ErrorRounds 1. Read
// the two together, never one from the other.
ErrorRounds int
// Completed holds this member's completed-task descriptions (clamped), captured in
// outcome() from the shared task list. It feeds the ledger-rich deliverable
// fallback so a degraded report can state what each member actually finished — the
// "honest gaps" data the incident wanted. Empty for a member that completed no task.
Completed []string
// Lead reports whether this member is the coordinating lead. The structured
// deliverable fallback SKIPS the lead's LastText: after the synthesis turn the
// lead's last words ARE the synthesis (empty, truncated, or the rejected refusal
// the fallback exists to replace), so echoing them would re-surface the very text
// the fallback discarded. The lead's disposition still appears.
Lead bool
}
MemberOutcome summarises one member at the end of a Run.
type MemberSpec ¶
type MemberSpec struct {
// Name is the unique member handle peers address messages to.
Name string
// AgentType is the optional agent-definition name this member adopts; passed
// through to the engine factory and recorded on the team roster.
AgentType string
// Lead marks the coordinating member. The lead is NOT auto-assigned tasks (it
// coordinates); it runs on its initial prompt and whenever it has messages.
Lead bool
// Mutating requests a self-contained force-copied fork (own `.git`) for this
// member, with Edit/Write/Bash. A read-only member (the default) either runs in
// an isolated git WORKTREE with a shell for inspection (when a read-only forker is
// wired — the factory sets MemberBuild.IsolateReadOnly) or, failing that, shares
// the base workspace with no shell. See the package "Workspace policy" doc.
Mutating bool
// InitialPrompt is the member's first-turn input, run in round 0 (typically the
// lead's top-level task, or a teammate's role briefing).
InitialPrompt string
}
MemberSpec describes a member to enrol before running the team.
type MemberStopReason ¶
type MemberStopReason string
MemberStopReason is WHY a stopped member stopped: a closed enum. Empty/unspecified for a member that finished cleanly (DispositionDone).
const ( // StopReasonError is a run that failed (StopError) or a session that could not be // returned to idle — both the internal-fault class. A failed run is RECOVERED // (issue #318), so this reason no longer implies the session is undrivable; only // memberRT.nonResumable says that. StopReasonError MemberStopReason = "error" // StopReasonCancelled is a member ended by ctx cancellation. StopReasonCancelled MemberStopReason = "cancelled" // StopReasonBudget is a member that exhausted its lifetime turn budget (its session // stays resumable; it is merely non-schedulable). StopReasonBudget MemberStopReason = "budget" )
type ModelRouteCategory ¶
type ModelRouteCategory struct {
// Name is the routing key the classifier must echo back as its verdict (and the
// key composition maps to a concrete model). A verdict naming a category NOT in
// the request's list is a hallucination → ok=false (fail-soft).
Name string
// Description is the one-line summary the classifier reads to choose. Operators
// are told to make these clear and distinct (the classifier's only signal).
Description string
}
ModelRouteCategory is one routing category the classifier chooses among: a NAME (the verdict value) and a one-line DESCRIPTION the classifier reads to decide. It is a layering-clean value type — the composition root translates the operator's `models.router.categories` taxonomy into these and supplies the category→model mapping itself; engine/agent never sees the per-category model selector.
type ModelRouteRequest ¶
type ModelRouteRequest struct {
// TaskPrompt is the Subagent call's `prompt` — model-authored and possibly
// peer-injected → UNTRUSTED. It is wrapped in a governance.UntrustedFence (framing
// neutralised) by buildModelRoutePrompt so it can neither forge the verdict nor
// fabricate a fresh classifier instruction.
TaskPrompt string
// Categories are the routing choices (name + description), rendered in the CLEAR
// (operator-authored, trusted). A verdict must name one of these.
Categories []ModelRouteCategory
// Default is the category name the classifier is told to choose when no category
// clearly fits. It is advisory to the classifier; the caller's own fallback (the
// inherited model on ok=false) is the real safety net.
Default string
}
ModelRouteRequest is the input to one classification: the (model-authored, UNTRUSTED) task prompt being routed, the available categories, and the default category the classifier should fall back to when none clearly fits.
type ParallelOption ¶
type ParallelOption func(*ParallelTool)
ParallelOption configures a ParallelTool.
func WithAutoMerge ¶
func WithAutoMerge(m tool.EnvironmentMerger) ParallelOption
WithAutoMerge injects the OPTIONAL tool.EnvironmentMerger that auto-merges a SINGLE-BRANCH join=first winner's diff back into the parent workspace after the run. It is the composition-owned, opt-in (--parallel-auto-merge, default OFF) capability that lets a delegated implementer's edits actually land without a manual copy/merge step. nil (the default) keeps the historical no-auto-merge boundary unchanged.
The merger fires ONLY for a single-branch run (len(tasks)==1) with a successful winner under join=first/judge. Multi-branch runs and join=all NEVER auto-merge (the no-auto-merge boundary stays for fan-out). The merge is a POST-RUN step before the winner cleanup is handed to the reaper and before Execute returns, so ReadOnly() stays true for read-only fan-out; a merge-completing CALL is excluded from the concurrent read batch via MutatesParent (dispatch-serial — see parentMutatingCaller), so it never overlaps a sibling parent read, and cross-run merge-vs-merge is serialized by the shared SerializingMerger. On a conflict Execute returns a tool error with the ephemeral fork path, which may already be gone if graceful shutdown began. See tool.EnvironmentMerger and the forker.Merger adapter.
func WithMaxBranches ¶
func WithMaxBranches(n int) ParallelOption
WithMaxBranches caps the number of branches a single Parallel call may fan out to (default defaultMaxBranches). A non-positive value is ignored.
func WithParallelChildLimits ¶
func WithParallelChildLimits(l session.Limits) ParallelOption
WithParallelChildLimits overrides the per-branch stop conditions (default defaultChildLimits).
func WithParallelChildMode ¶
func WithParallelChildMode(m session.PermissionMode) ParallelOption
WithParallelChildMode sets the permission mode each child branch session runs under (default session.ModeDefault).
func WithParallelChildSessionPrefix ¶
func WithParallelChildSessionPrefix(p string) ParallelOption
WithParallelChildSessionPrefix sets the prefix used to derive child branch SessionIDs (default "parallel"). Child ids are of the form "<prefix>-<callID>-<i>".
func WithParallelConcurrency ¶
func WithParallelConcurrency(n int) ParallelOption
WithParallelConcurrency bounds how many branches run simultaneously (default defaultParallelConcurrency). A non-positive value is ignored.
func WithParallelEngineFactory ¶
func WithParallelEngineFactory(f func(model string) (*Engine, bool)) ParallelOption
WithParallelEngineFactory injects the composition-supplied factory that mints a per-branch child engine on an OPT-IN model-router-classified model (ADR 0034). It is the EXACT shape WithSubagentEngineFactory takes (func(model string)(*Engine,bool)); the factory re-derives the override branch engine's Compactor/TokenCounter/Env.Model/ContextWindow for the routed model through the contamination-safe per-provider path (never a clone-and-swap). nil (the default) disables per-branch routing — every branch runs on the shared childEngine, byte-identical to today. engine/agent stays model-string-only: the factory takes an opaque model id and composition owns the category→model→engine mapping.
func WithParallelJudge ¶
func WithParallelJudge(j BranchJudge) ParallelOption
WithParallelJudge injects the BranchJudge used by the "judge"/"best" join strategy (nil disables judging — the strategy then returns a model-addressable error). The default build wires an engineJudge over a dedicated, tool-less read-only child Engine; see internal/app.
func WithParallelStore ¶
func WithParallelStore(store port.SessionStore) ParallelOption
WithParallelStore injects the optional session store each branch's child session is best-effort persisted to after its run (issue #30), so the PULL InspectSubagent tool can later load a branch's transcript by the "branch id:" line the Parallel result surfaces. Nil disables persistence. Mirrors SubagentTool's WithSubagentStore / persistChild discipline exactly (the closest structural sibling — both take a *session.Session): the save is advisory and a failure is swallowed (no diagnostic). Branch ids share the SHARED store with subagent and team-member ids on a DISJOINT prefix ("parallel-"), so no collision engineering is needed. The tool consumes the port.SessionStore interface, never a concrete adapter, so no layering rule is crossed.
func WithParallelSubagentStopHook ¶
func WithParallelSubagentStopHook(h port.HookRunner) ParallelOption
WithParallelSubagentStopHook injects the HookRunner that fires SubagentStop when a branch run finishes (best-effort; nil disables it).
func WithWinnerReaper ¶
func WithWinnerReaper(s PreservedForkStore) ParallelOption
WithWinnerReaper injects a bounded PreservedForkStore that caps how many PRESERVED winner forks (join=first / join=judge) survive at once: a new winner beyond the cap reaps the oldest. nil (the default) preserves winner forks indefinitely. See NewLRUForkReaper for the default bounded implementation.
type ParallelTool ¶
type ParallelTool struct {
// contains filtered or unexported fields
}
ParallelTool is the fork-join fan-out tool (harness pattern 8). When executed it forks N ISOLATED child workspaces from the parent's workspace (via the injected tool.EnvironmentForker), runs one CHILD agent loop per branch in PARALLEL (bounded by a worker limit) over the injected child *Engine — each with its own fresh Session, tighter Limits, and the child Engine's scoped catalog — drains every child's Event stream internally, and JOINS the results into a SINGLE session.ToolResult that summarizes all branches.
Like SubagentTool (gauntlet #7), the parent NEVER observes any child's intermediate tool.call / tool.result / message.delta / permission.ask events: each child's stream is drained entirely inside Execute and only the terminal summary folds back. A child permission ask is auto-denied so children stay non-interactive.
Isolation: each branch runs in its OWN forked workspace, so even a child wired with mutating tools writes only to its fork — parallel writes are SAFE because they are isolated (this is strictly safer than concurrent SubagentTool calls, which share the base). v1 does NOT auto-merge: Execute returns the per-branch summaries and the child workspace ROOT paths so a human or the parent can inspect/merge the forked trees. cleanup tears each fork down after its summary has been captured.
The child Engine is built by the composition root with a scoped catalog (no Parallel, no Subagent — children cannot fan out further) exactly as for SubagentTool.
func (*ParallelTool) Execute ¶
func (t *ParallelTool) Execute(ctx context.Context, call session.ToolCall, env tool.Environment) (session.ToolResult, error)
Execute forks N isolated child workspaces (one per task), runs a child loop in each IN PARALLEL bounded by the worker limit, drains every child stream internally, cleans up each fork, and returns ONE ToolResult that joins all branch summaries. The whole operation is bounded by the parent ctx: cancelling it cancels every in-flight branch. A branch that fails is reported in the joined summary without aborting the others; the call returns a harness-level error only for a setup failure (invalid args / cap exceeded).
func (*ParallelTool) ExecuteWithParent ¶
func (t *ParallelTool) ExecuteWithParent(ctx context.Context, call session.ToolCall, env tool.Environment, emit func(session.Event), caps parentCaps) (session.ToolResult, error)
ExecuteWithParent is the childCapableTool seam: it runs Parallel like Execute but threads the PARENT's caps (interactivity + surface back-channel) into each branch's posture AND the parent emit closure — so a Parallel run projects its REDACTED parallel.* group observability stream (start / per-branch / end) onto the parent event channel, exactly as Subagent projects subagent.*.
func (*ParallelTool) MutatesParent ¶
func (t *ParallelTool) MutatesParent(call session.ToolCall) bool
MutatesParent implements the optional parentMutatingCaller seam (FIX C): it reports whether THIS specific call will auto-merge a single branch's fork diff back into the PARENT workspace. ReadOnly() stays true so multi-branch / join=all fan-out keeps batching in parallel; a call for which this returns true is excluded from the concurrent read batch (dispatch-serial via runOne) so its post-run merge never overlaps a sibling parent Read/Grep/Glob. It returns true ONLY for a call that will ACTUALLY merge: the auto-merger wired, exactly ONE task, and join in {first,judge} (autoMergeWinner only merges a single-branch winner). A malformed/unparseable args payload returns false (the call errors later anyway, and never merges).
It keys on the TASK count (len(nonEmptyTasks)==1) whereas autoMergeWinner keys on the RESULT count (len(results)==1); these differ only when the lone branch fails to START (1 task, 0 results → no merge). That makes MutatesParent a deliberate OVER-approximation: at worst it flushes a non-merging single-branch call serially instead of in the read batch — a throughput cost on a rare path, never a correctness gap (it never UNDER-declares a call that will merge).
func (*ParallelTool) ReadOnly ¶
func (*ParallelTool) ReadOnly() bool
ReadOnly reports that the Parallel tool is read-only with respect to the PARENT's shared workspace, which lets the parent dispatcher run it concurrently with other read-only tools (read-parallel / mutate-serial; see dispatch.go).
INVARIANT — this is the same invariant SubagentTool documents, but Parallel makes it strictly safer: every child branch runs in its OWN forked workspace, never the shared base. So the child's filesystem-mutating tools (Edit / Write) land in the isolated fork and CANNOT race on, or mutate, the parent's shared base. Bash is now workspace-aware (see app.buildParallelChildEngine): BashTool.Execute passes the per-branch forked Workspace.Root() to its CommandRunner as the working directory, so a branch's Bash runs in its OWN fork — its DEFAULT cwd is the fork, not the shared parent base. (Residual: unlike path-scoped Edit/Write, Bash can still escape its cwd via absolute paths or `cd`; that is the inherent Bash trust model, the same as the main session. What the fix guarantees is that no ACCIDENTAL shared-base mutation happens — a branch's relative-path Bash lands in the fork.) That is why ReadOnly() can safely return true even for mutating (Edit/Write/Bash) children — for the SAME reason SubagentTool.ReadOnly() stays true: each tool isolates its mutating child so the child's writes never touch the shared parent base. Isolation, not catalog read-only-ness, is the boundary (after Phase 2 a Subagent child with Bash runs in its OWN git worktree exactly as a Parallel branch runs in its own force-copy). The remaining distinction is only WHICH tools the child gets: a Parallel branch keeps Edit/Write (it is meant to IMPLEMENT in its fork), while a Subagent child drops them and is shell-only (a read-only explorer that may run git/build/test but cannot edit the project).
ReadOnly() stays true for read-only fan-out; a merge-completing CALL (single-branch join=first/judge with the merger wired) is excluded from the concurrent read batch via MutatesParent (dispatch-serial — see parentMutatingCaller), and cross-run merge-vs-merge is serialized by the shared SerializingMerger.
func (*ParallelTool) Spec ¶
func (*ParallelTool) Spec() tool.ToolSpec
Spec returns the model-facing specification for the Parallel tool.
type PreservedForkStore ¶
type PreservedForkStore interface {
// Preserve records a winning fork under an opaque artifact handle and retains
// its private cleanup capability until eviction. The handle is safe to surface;
// the physical root remains private to the Environment and cleanup closure.
Preserve(handle ArtifactHandle, cleanup func() error)
}
PreservedForkStore is the seam the Parallel tool uses to retain a winning branch's PRESERVED fork (join=first / join=judge) under a BOUNDED policy. A winner's fork is intentionally not torn down at the end of the call — its contents are the deliverable, inspectable/mergeable by the operator — but without a bound every winner over the process lifetime would leak a fork dir, growing disk without limit. A PreservedForkStore caps how many preserved forks survive at once: when a new winner pushes the count past the cap, the OLDEST preserved fork is reaped (its captured cleanup invoked).
LAYERING: this lives in engine/agent (application layer) so ParallelTool can be injected with it without importing an adapter. The cleanup func is the SAME teardown the forker handed runBranch — the reaper simply defers calling it (for the winner) until eviction, instead of the caller dropping it on the floor.
Implementations must be safe for concurrent use: several Parallel calls can finish concurrently and each preserves at most one winner.
type ReflectionLimits ¶
type ReflectionLimits struct {
InputBytes int
Events int
Existing int
Candidates int
EvidencePerCandidate int
OutputBytes int
Tokens int
Timeout time.Duration
}
ReflectionLimits are explicit resource bounds for one direct reflection call. Zero selects a conservative default; negative values and values above the hard ceilings are rejected.
type Run ¶
type Run struct {
// contains filtered or unexported fields
}
Run is the handle to one in-flight prompt. It exposes the Event stream plus the out-of-band controls the bidi API needs (Approve resolves a permission.ask; Cancel aborts the run). The Events channel is closed exactly once, when the run terminates.
func (*Run) Approve ¶
func (r *Run) Approve(askID string, v session.ApprovalVerdict)
Approve resolves the permission.ask identified by askID with the client's verdict: VerdictDeny refuses the call, VerdictAllowOnce permits this call only, and VerdictAllowAlways permits it AND asks the policy to learn a per-session allow rule for the matching tool+pattern. It is non-blocking and safe to call from another goroutine; an unknown or already-resolved askID is ignored.
func (*Run) Cancel ¶
func (r *Run) Cancel()
Cancel aborts the in-flight run: it first arms the hardAbort grace timer (the explicit unwedge signal — any send still parked on a full events channel hardAbortGrace later gives up instead of blocking forever), THEN cancels the run context. The arm-before-cancel order matters: a ctx-woken goroutine that loops back into an emit must already be covered by the pending abort, or it could park indefinitely. The AfterFunc timer is deliberately never Stop()ed: at worst it fires once, shortly after a run that already ended, and closes a channel nobody reads any more — a harmless once-per-run close (contrast joinChildren's defer timer.Stop, which reclaims a shared 10s timer early on the common all-joined-fast path — a different shape: that timer does real work only on expiry; this one's entire job IS to fire). The loop observes the cancellation (mid-stream, mid-tool, or while awaiting an approval) and terminates with a result carrying StopCancelled.
func (*Run) CancelChild ¶
CancelChild requests cancellation of ONE child of this run, addressed by its child session id (the `agentId:` trailer on the Subagent result / the overlay ChildID — the single handle convention; no per-family interpretation). It is the per-child mirror of Approve's routing role: a client frame addressed at a child, routed by the parent Run.
It is idempotent and safe from any goroutine; an unknown or already-done id returns false (the finished-as-you-pressed race is benign). On a live child it: marks the registry entry clientCancelled (so the Subagent terminal renders "[subagent cancelled by user]" rather than a generic cancel), SNAPSHOTS the child's surfaced askIDs (without clearing — see below), cancels the child's per-call context OUTSIDE the registry lock (unwinding a mid-drive turn, a gate wait, or a parked askRegistry.await alike), then EAGERLY retracts the snapshot via the shared retractAsksVia: each askID is unregistered from the parent's childAskRouter atomically with its permission.retract emit (one emitMu section) — so a racing late approval falls through to the parent's own registry and dies as an unknown-ask no-op (fail-safe ordering), while the client dismisses its modal promptly, ahead of the child's unwind. The unregister's answered-vs-pending gate also means an ask whose verdict was JUST routed (route deleted the router entry first) no longer draws a spurious retract.
The eager retract is BEST-EFFORT only — it runs on the caller's goroutine, unsynchronized with the run's terminate path, so it can lose a scheduling race to the seal (then its emitMu section sees sealed and skips, gate untouched). The GUARANTEED leg is the child's own registry terminal (markDoneResult — the retraction chokepoint, pre-doneCh-close ⇒ pre-seal), which takes the un-cleared set and retracts whatever this path didn't deliver; the atomic unregister gate keeps the two legs exactly-once.
func (*Run) CancelSteer ¶
func (r *Run) CancelSteer() (SteerOutcome, error)
CancelSteer is the wire-facing steer-cancel entry point: a live run's Service routes an operator steer_cancel here. It retracts the PENDING (un-drained) steer and reports the authoritative SteerOutcome (retracted/none_pending) — a steer that already drained at a turn boundary is ordinary recorded history and cannot be retracted (the cancel reports none_pending then: the drain won).
func (*Run) EnqueueSteer ¶
EnqueueSteer enqueues a text, media, or mixed steer atomically. It is the wire-facing steer entry point: a live run's Service routes an operator steer here. It reports the authoritative SteerOutcome (accepted/appended/too_late) — never an error for the ordinary too-late race (that outcome is what the Service promotes on).
func (*Run) Events ¶
Events returns the channel of domain Events for this run. It is closed when the run ends (after the terminal result Event has been delivered).
func (*Run) RetractPermissionAsk ¶ added in v0.13.0
RetractPermissionAsk withdraws this run's own pending permission ask without resolving it and emits one permission.retract event. It returns false when the ask is unknown or already resolved. The run remains parked until its host cancels it; this narrow seam lets a lease-owning host retract local delivery while preserving an already-durable awaiting snapshot for a successor.
func (*Run) RunID ¶
RunID reports the opaque, host-minted identity of this run (ADR 0249), or "" when the host supplied none.
It exists so a caller holding a *Run can ASK which run it holds, rather than inferring it from the session aggregate. That distinction matters for stale controls: a control addressed at a specific run must be compared against the run it would actually affect, and a session's aggregate is a step removed from that (it names the session's CURRENT run, which after a terminal race may not be the one the caller is holding).
type RunRequest ¶
type RunRequest struct {
// Text is the (possibly empty) text of the user prompt. Command expansion and the
// UserPromptSubmit hook operate on Text only; the media Parts pass through untouched
// and are recorded verbatim on the user message. Text may be empty when Parts
// carries the content.
Text string
// Parts carries non-text media (image/audio) alongside Text. nil for a text-only
// prompt. The media passes through to the engine untouched.
Parts []session.Content
// MaxRunTokensOverride, when > 0, is a per-run TIGHTEN-ONLY override of the engine's
// Deps.MaxRunTokens budget: the effective ceiling for THIS run is the lower of the
// two non-zero values (a per-call ceiling may make the run stricter than the operator
// default, never looser — mirroring the per-call limit tighten-only discipline). 0
// (the default) inherits the engine's Deps.MaxRunTokens unchanged.
MaxRunTokensOverride int
// ExtraTools are run-scoped tools layered OVER the engine's catalog for THIS run
// only: their specs are advertised to the model this run and they are dispatchable,
// but they are never registered into the shared catalog (so concurrent runs of the
// same engine never see them, and the engine is not mutated). A name collision with a
// catalog tool resolves to the EXTRA tool (the run-scoped overlay wins) for THIS run.
// The Subagent tool uses this to inject the synthetic SubmitResult deliverable tool for a
// structured-output child. Every ExtraTool MUST be ReadOnly (it is dispatched on the
// read-parallel path); a structured-output SubmitResult records into a per-run sink
// and performs no workspace mutation, so it is read-only.
ExtraTools []tool.Tool
// RunID is the opaque, host-minted identity of THIS run (ADR 0249).
//
// It does two things and nothing else. Every event this run emits is stamped
// with it at Run.emit/emitOrAbort, beside the existing Seq stamp, so no relay,
// transport, or persistence path downstream can omit it. And when
// AskIDDiscriminator is empty it also SUPPLIES the ask discriminator, which
// is what ADR 0044 always meant by "a durable host passes its own RunID" —
// so a durable host sets ONE field, not two carrying the same value.
//
// HOST CONTRACT (inherited from AskIDDiscriminator, because it feeds it): the
// value must be UNIQUE per run-ATTEMPT and STABLE across processes for the
// SAME attempt, or the CWE-863 askID replay guard weakens. It should be
// colon-free; a colon-bearing value still stamps events fine but cannot serve
// as an ask discriminator (the askID grammar would be ambiguous), so the run
// falls back to the process-global serial for asks and logs a WARN.
//
// Empty (the zero value) is the legacy behaviour exactly: events carry an
// empty RunID and asks use the process-global serial. mecatui, mecademo, and
// tests pass nothing and are unaffected.
//
// The loop's licence over this value is deliberately narrow: STAMP it, and
// DERIVE the ask discriminator from it. It must never be branched on, logged,
// sent to a provider, or used to reach storage — see ADR 0249's consequences.
RunID string
// AskIDDiscriminator, when non-empty, REPLACES the trailing process-global
// "r<serial>" component of every askID minted this run (see agent.newAskID),
// making the askID reconstructable across processes from persisted state. The
// askID format is "<sessionID>:<n>:<callID>:<discriminator>". HOST CONTRACT:
// the host MUST supply a value that is (a) UNIQUE per run-ATTEMPT and (b)
// STABLE across processes for the SAME attempt — this preserves the CWE-863
// replay guard the process-global serial provides (the Interrupt/re-mint
// scenario documented on newAskID): a stale verdict for a retracted ask must
// never resolve a re-minted ask of a different attempt. (c) It MUST be
// colon-free to keep the askID grammar unambiguous; a value containing a colon
// is IGNORED and the run falls back to the process-global "r<serial>" (a WARN
// is logged) rather than minting an ambiguous id. Empty (the zero value) keeps
// the legacy "r<serial>" behavior with no change — mecatui, tests, and
// in-memory hosts pass nothing and are unaffected. A durable host (e.g. a downstream consumer)
// passes its own RunID. Same opt-in RunRequest seam pattern as
// MaxRunTokensOverride/ExtraTools. See ADR-0044.
//
// FOOTGUN GUARD: after startRun the RESOLVED value (this when valid, else the
// "r<serial>" fallback) lives on Run.askDiscriminator. askID minting (newAskID,
// in authorize) MUST read r.askDiscriminator — NEVER this raw, un-validated
// r.req.AskIDDiscriminator, which may be empty or colon-bearing and would
// bypass the colon/empty fallback.
AskIDDiscriminator string
}
RunRequest is the single request shape a caller threads into Engine.Run. It carries the user prompt (text and/or non-text media parts) PLUS the run-scoped overrides that live on the Run, never on the shared Engine.Deps, so a per-call knob (a tighter token ceiling, a synthetic deliverable tool) works on a SHARED child engine WITHOUT minting a fresh engine or mutating the engine other concurrent runs share. The zero value of the override fields is the legacy run (no override, no extras).
type ScheduleQueryTool ¶
type ScheduleQueryTool struct {
// contains filtered or unexported fields
}
ScheduleQueryTool is the model-facing READ-ONLY scheduled-task query tool (list/inspect). It shares the injected port.ScheduleManager with the mutating ScheduleTool but reports ReadOnly()==true, so its calls join the read-parallel batch — the AC1.4 partition (a mutating verb can never fan out from here because this tool carries none).
func (*ScheduleQueryTool) Execute ¶
func (t *ScheduleQueryTool) Execute(ctx context.Context, call session.ToolCall, _ tool.Environment) (session.ToolResult, error)
Execute dispatches on the read-only verb (list/inspect). A mutating verb is a model-addressable unknown-verb error (this tool carries none).
func (*ScheduleQueryTool) ReadOnly ¶
func (*ScheduleQueryTool) ReadOnly() bool
ReadOnly reports true — list/inspect never mutate the workspace or the schedule registry, so they are parallel-safe (the read-half of the AC1.4 partition).
func (*ScheduleQueryTool) Spec ¶
func (*ScheduleQueryTool) Spec() tool.ToolSpec
Spec returns the model-facing specification for the read-only Schedule query tool.
type ScheduleTool ¶
type ScheduleTool struct {
// contains filtered or unexported fields
}
ScheduleTool is the model-facing MUTATING scheduled-task management tool (create/pause/resume/delete/fire). It consumes the consumer-local port.ScheduleManager seam composition injects (the server Service's schedule methods) — the SAME validated create-seam the REST/gRPC handlers ride, never a second path, so a schedule created in-chat is indistinguishable from an API-created one (one store, one truth).
READONLY PARTITION (the dispatch invariant — AGENTS.md): the read-only verbs (list/inspect) live on the SEPARATE ScheduleQueryTool (ReadOnly()==true, read-parallel); this tool carries ONLY the mutating verbs and reports ReadOnly()==false, so every mutating Schedule call serialises on the mutate path and NEVER runs concurrently with a sibling read. The per-verb split is realised as two catalog entries over the one ScheduleManager because a single tool's ReadOnly() takes no args — pinned by TestScheduleTool_ReadOnlyPartition.
func (*ScheduleTool) Execute ¶
func (t *ScheduleTool) Execute(ctx context.Context, call session.ToolCall, env tool.Environment) (session.ToolResult, error)
Execute dispatches on the verb, mapping the call args onto the injected ScheduleManager and rendering the result as model-readable text. A verb-level error (an unknown schedule, a rejected create, a fire overlap) is a MODEL-ADDRESSABLE ToolResult (IsError), not a harness-level error — the loop records it and lets the model react.
func (*ScheduleTool) ReadOnly ¶
func (*ScheduleTool) ReadOnly() bool
ReadOnly reports false — this tool carries ONLY the mutating verbs (create/pause/resume/delete/fire), so every call serialises on the mutate path and never overlaps a sibling read (the dispatch invariant). The read-only list/inspect verbs live on the ScheduleQueryTool (ReadOnly()==true). See the type doc.
func (*ScheduleTool) Spec ¶
func (*ScheduleTool) Spec() tool.ToolSpec
Spec returns the model-facing specification for the mutating Schedule tool.
type SteerOutcome ¶
type SteerOutcome string
SteerOutcome is the closed-enum result of a steer inbox transition (enqueue or cancel). The engine is AUTHORITATIVE: the client cannot observe the exact drain moment across stream latency, so the outcome reports what actually happened to the steer rather than letting the client guess. It is an ENUM, not stacked booleans — AGENTS.md's "a third provenance ⇒ extract an enum" discipline (a bool pair cannot express accepted-vs-appended-vs-too_late).
const ( // SteerAccepted means the steer was parked in the single pending slot (the // slot was empty). It will drain at the next turn boundary unless retracted // first. SteerAccepted SteerOutcome = "accepted" // SteerAppended means the steer found the pending slot OCCUPIED and was // MERGED into it: the pending bundle's text grew by "\n\n"+text (it still // drains as ONE bundle). Append is the DEFAULT for a second steer on the // occupied slot (round-3 rework; its outcome distinguished from accepted — // the new pending bundle — so the client can render "merged onto pending" // honestly). Replacing the pending bundle is the explicit cancel path // (steer_cancel, then a fresh steer); append and replace are distinct. SteerAppended SteerOutcome = "appended" // SteerRetracted means a cancel found a pending steer and retracted it; the // run drains nothing for it and the next turn sees no injected message. SteerRetracted SteerOutcome = "retracted" // SteerNonePending means a cancel found the slot EMPTY — there was nothing // to retract (no steer was pending, or the run is terminal). SteerNonePending SteerOutcome = "none_pending" // SteerTooLate means the steer arrived after the run went terminal (the // inbox is closed) — it is never parked and never silently drained into a // finished turn. The service layer promotes a too_late steer to a fresh // follow-up run; the engine only reports the outcome. SteerTooLate SteerOutcome = "too_late" )
type SubagentOption ¶
type SubagentOption func(*SubagentTool)
SubagentOption configures a SubagentTool.
func WithAgentEngines ¶
func WithAgentEngines(engines map[string]*Engine, meta []AgentMeta) SubagentOption
WithAgentEngines injects the per-definition child engines (keyed by agent name) and their (name, description) metadata for progressive disclosure. The composition root builds each engine with a SCOPED, read-only catalog (the Subagent read-only invariant is preserved — see ReadOnly) and the def's resolved model/prompt, then passes the map and a name-sorted meta slice here.
engines and meta should describe the same set of names; meta drives the Spec enumeration while engines drives routing. A nil/empty map leaves Subagent with only the default explorer (no behaviour change). It is the agent-package boundary the agents adapter never crosses: only plain map + structs flow in.
func WithAgentModelEngineFactory ¶
func WithAgentModelEngineFactory(f func(agentName, model string) (*Engine, bool)) SubagentOption
WithAgentModelEngineFactory injects the composition-supplied factory that mints a child engine for a per-call `agent`+`model` combination. The override model runs on the named specialist's resolved provider, and the def's SCOPED engine (catalog/prompt/hooks/ memory) is REBUILT on the override model through the contamination-safe per-provider path (engineDepsForProvider re-derives Compactor/TokenCounter/Env.Model/ContextWindow) — NEVER a clone-and-swap of an existing engine, and the pre-built agentEngines map is never mutated. It returns (engine, true) for a routable (agent, model) and (nil, false) otherwise (an unknown/unroutable model, which Subagent surfaces as a model-addressable error naming both the agent and the model). nil (the default, and the no-FS path) leaves Subagent without agent+model support (a call setting both then errors).
The override model is passed VERBATIM (no alias resolution — an opaque string the provider validates at request time), matching the model-only path's parity. The def's resolved PROVIDER (def.Provider pinned-and-known → that provider; else the parent's) is the only provider dimension; cross-provider override OF the provider by a bare model id is out of scope (matches buildSubagentEngineFactory's existing out-of-scope comment). A def with INLINE MCP servers is a v1 scope limit (the inline managers' live sessions must outlive a per-call engine); the factory returns (nil, false) and selectChildEngine surfaces the accurate error. read-write+agent+model is rejected by validateMode before this factory is consulted (a v1 scope limit — a writable specialist runs on its own resolved model); read-write+agent ALONE routes through the separate agentWritableFactory (WithAgentWritableEngineFactory), not this one.
func WithAgentWritableEngineFactory ¶
func WithAgentWritableEngineFactory(f func(agentName string) (*Engine, bool)) SubagentOption
WithAgentWritableEngineFactory injects the composition-supplied factory that mints a WRITABLE child engine for a mode:"read-write"+`agent` call (ADR 0058 — a writable named specialist). Given an agent name it REBUILDS the named specialist's scoped engine (catalog/prompt/hooks/memory) with allowMutating=true on the def's resolved provider/model through the SAME contamination-safe per-provider path the startup engines use (buildAgentDefEngine → newChildEngineForProvider re-derives Compactor/TokenCounter/Env.Model/ContextWindow), using the MAIN session's command runner (direct-write parity, ADR 0077 — no fork, no copy, no merge-back); its Edit/Write/Bash mutate the REAL parent workspace in place, exactly as the main agent does, and git is the rollback layer. The pre-built agentEngines map is NEVER mutated (a fresh engine is minted per call). It returns (engine, true) for a known reference-only-MCP def and (nil, false) for an unknown agent or a def with INLINE MCP servers (a v1 scope limit — the inline manager's live session has no process-lifetime owner on a per-call engine; selectChildEngine surfaces the model-addressable error). Reference-only MCP servers ARE supported (they borrow the process-lifetime mainMgr, no new connection).
nil (the default, and ALWAYS on the no-FS path) leaves Subagent without writable- specialist support: a mode:"read-write"+`agent` call then errors with a clear "not supported in this deployment" message from validateMode. Explicit read-write+`agent`+ `model` arguments together are OUT OF SCOPE for v1 (validateMode rejects them before this factory is ever consulted); a router-selected model is handled by the separate WithAgentWritableModelEngineFactory. It is layering- clean: the closure takes a string and returns *Engine — both agent-layer types — and no adapter/proto/server type crosses (same shape as WithAgentModelEngineFactory).
func WithAgentWritableModelEngineFactory ¶
func WithAgentWritableModelEngineFactory(f func(agentName, model string) (*Engine, bool)) SubagentOption
WithAgentWritableModelEngineFactory injects the composition-supplied factory that rebuilds a WRITABLE named specialist on a router-selected model. It is distinct from WithAgentModelEngineFactory because the resulting engine retains mutating tools and runs directly in the parent environment. A declined routed target falls back to the ordinary writable specialist minted by WithAgentWritableEngineFactory.
func WithChildForker ¶
func WithChildForker(f tool.EnvironmentForker) SubagentOption
WithChildForker injects the workspace-isolation seam each child run forks before executing. The composition root wires it ONLY when the child catalog includes Bash (the read-only explorer's shell), so the child's mutating-classified Bash lands in a throwaway git worktree, never the shared parent base — preserving Subagent's read-parallel safety (see ReadOnly). It should be the forker's DEFAULT mode (git worktree: shares the base repo's `.git` ⇒ full history for git log/show). When the forker is nil (the default), the child shares the parent content backend through any composition-supplied authority-narrowing Workspace view. A fork failure on this path is a tool error, not a silent fallback.
func WithChildLimits ¶
func WithChildLimits(l session.Limits) SubagentOption
WithChildLimits overrides the subagent's stop conditions. Use it to make a child even tighter (or, rarely, looser) than the defaults.
func WithChildMode ¶
func WithChildMode(m session.PermissionMode) SubagentOption
WithChildMode sets the permission mode the child session runs under (default session.ModeDefault). session.ModePlan additionally hides any non-read-only tools from the child at the catalog level.
func WithChildSessionPrefix ¶
func WithChildSessionPrefix(p string) SubagentOption
WithChildSessionPrefix sets the prefix used to derive child SessionIDs (default "subagent"). Child ids are of the form "<prefix>-<callID>".
func WithMaxConcurrentChildren ¶
func WithMaxConcurrentChildren(n int) SubagentOption
WithMaxConcurrentChildren bounds how many Subagent children may run CONCURRENTLY — forking AND forker-less (default defaultMaxConcurrentChildren). It is the single fan-out brake on the dispatcher's read-parallel batch: N Subagent calls in one turn each block on the gate, so at most cap children run at once. A value < 1 is clamped to 1 (a zero-capacity gate would deadlock).
func WithPinnedAgents ¶
func WithPinnedAgents(names []string) SubagentOption
WithPinnedAgents injects the composition-computed SET of agent-def names that expressed model intent (ANY non-empty `model:`, including explicit `inherit`). The set is used only to attribute an unrouted named delegation as RoutingReasonAgentDefPinned. It is separate from WithRoutableAgents because a def may be unroutable for reasons other than a model pin (for example a provider switch or inline MCP server); those cases report router-disabled. nil/empty is safe and preserves the pre-option default.
func WithRoutableAgents ¶
func WithRoutableAgents(names []string) SubagentOption
WithRoutableAgents injects the composition-computed SET of agent-def names eligible for the OPT-IN model router (issue #286): a def that expressed NO model intent (absent `model:`), does not switch provider away from the parent, and has no inline MCP servers. An `agent`-named delegation to one of these — read-only, with the agent+model factory wired — is CLASSIFIED and its SCOPED engine rebuilt on the routed model (fail-soft to the pre-built def engine). Absence from this set does NOT itself mean pinned: provider-switched and inline-MCP defs are also ineligible. WithPinnedAgents carries the narrower attribution set. nil/empty (the default) means NO def routes — byte-identical to pre-#286. It is layering-clean: only def NAME strings flow in. It is inert when the router is off (routeTask nil) — wiring it unconditionally is safe.
func WithSharedChildWorkspace ¶
func WithSharedChildWorkspace(view func(tool.Workspace) tool.Workspace) SubagentOption
WithSharedChildWorkspace injects a capability-narrowing view for base-sharing children. The function receives the actual parent Workspace and must preserve its content backend; it exists so child authority can be stricter than a posture-relaxed main-session wrapper without reconstructing storage from Root.
func WithSubagentEngineFactory ¶
func WithSubagentEngineFactory(f func(model string) (*Engine, bool)) SubagentOption
WithSubagentEngineFactory injects the composition-supplied factory that mints a child engine for a per-call `model` override. The closure closes over the provider registry and builds the override child through the contamination-safe per-provider path (engineDepsForProvider) — Compactor/TokenCounter/Env.Model/ContextWindow are re-derived for the override model, NEVER a clone-and-swap of the LLM on an existing engine. It returns (engine, true) for a routable model and (nil, false) otherwise (an unknown/unroutable model, which Subagent surfaces as a model-addressable error). nil (the default) leaves Subagent without a per-call model override (a `model` arg then errors). It is the layering-clean seam: only func(string)(*Engine,bool) crosses into engine/agent (same shape as WithAgentEngines).
func WithSubagentNoFSNote ¶
func WithSubagentNoFSNote() SubagentOption
WithSubagentNoFSNote tells the Subagent tool's Spec() that this session has NO FILESYSTEM (the "no-fs" session profile), replacing the WHOLE tool-surface description: under no-FS the spec must not claim Read/Grep/Glob, a worktree shell, kept file changes (Parallel — absent under no-FS), or "a quick read you can do with Read/Grep" — it describes the real child surface instead (MCP tools, memory, web fetch; no file access, no shell). DISTINCT from WithSubagentShellDisabledNote (issue #40), which swaps only the shell clause and keeps the read-only file-tool claims that are still true on that path. Without this option the description stays byte-identical to the historical one.
func WithSubagentOwnershipEnforced ¶
func WithSubagentOwnershipEnforced(ownershipEnforced bool) SubagentOption
WithSubagentOwnershipEnforced records whether the request edge verifies caller identity. Enabled deployments require a resume caller to match the persisted child owner; disabled deployments retain legacy ownerless compatibility.
func WithSubagentReadLedgerFactory ¶ added in v0.14.0
func WithSubagentReadLedgerFactory(factory func() tool.ReadLedger) SubagentOption
WithSubagentReadLedgerFactory injects the mandatory fresh child-ledger factory.
func WithSubagentShellDisabledNote ¶
func WithSubagentShellDisabledNote(reason string) SubagentOption
WithSubagentShellDisabledNote tells the Subagent tool's Spec() that NO child gets a shell on this workspace and why. With it set, the spec's "plus a full shell in an isolated, throwaway git worktree …" clause is REPLACED by an honest read-only-only description carrying the reason, so the model never plans build/test/git delegation the child cannot perform. The composition root sets it ONLY when the workspace-trust gate withheld the shell (issue #40) — a shell-less deployment (--no-bash / empty shell) keeps the historical description unchanged, exactly like before this option existed. An empty reason is a no-op (the default, byte-identical description).
func WithSubagentStopHook ¶
func WithSubagentStopHook(h port.HookRunner) SubagentOption
WithSubagentStopHook injects the HookRunner that fires the SubagentStop hook when a child run finishes. It is best-effort: a hook error or block never fails the Subagent call. Passing nil disables the hook.
func WithSubagentStore ¶
func WithSubagentStore(store port.SessionStore) SubagentOption
WithSubagentStore injects the optional session store each child session is best-effort persisted to after its run (ALL terminals: clean, limit-stopped, structured-output-exhausted, errored, cancelled/timed out — the final state after any structured-output re-drives). Nil disables persistence. Mirrors the team supervisor's WithMemberStore/persistMember discipline: the save is advisory and a failure is swallowed (no diagnostic). The tool consumes the port.SessionStore interface, never a concrete adapter, so no layering rule is crossed.
func WithWritableChildEngine ¶
func WithWritableChildEngine(e *Engine) SubagentOption
WithWritableChildEngine injects the child *Engine a mode:"read-write" Subagent call runs on: a WRITABLE explorer whose catalog includes Edit/Write (the composition root builds it over the read-only explorer catalog + Edit + Write, using the MAIN session's command runner). It is SEPARATE from the read-only childEngine; a read-write call selects this engine instead, so the read-only fan-out path is byte-identical when read-write is never used. A read-write child runs DIRECTLY against the parent workspace — no fork, no copy, no merge-back (ADR 0041); git is the rollback layer. nil (the default, and the no-FS path) leaves writable subagents unwired (a read-write arg then errors).
func WithWritableEngineFactory ¶
func WithWritableEngineFactory(f func(model string) (*Engine, bool)) SubagentOption
WithWritableEngineFactory injects the composition-supplied factory that mints a WRITABLE EXPLORER child engine on a per-call OVERRIDE model for a mode:"read-write" call with no `agent` (issue #285 — a writable explorer honours the per-call `model` and the router pick, closing the gap where read-write silently ran on its default model). Given a model id it REBUILDS the generic writable explorer engine (read-only explorer catalog + Edit + Write) on that model through the SAME contamination-safe per-provider path writableChildEngine uses, using the MAIN session's command runner (direct-write parity, ADR 0077 — no fork, no copy, no merge-back); its Edit/Write/Bash mutate the REAL parent workspace in place, and git is the rollback layer. It re-derives the provider-closing Deps (Compactor/TokenCounter/Env.Model/ContextWindow) for the override model — NEVER a clone-and-swap. It returns (engine, true) for a routable model and (nil, false) for an unknown/unroutable model (or a blank model), which selectChildEngine surfaces as a model-addressable error. It is ALSO the fail-soft mint target for the OPT-IN router on a plain writable delegation (a routed pick mints here; a miss falls back to writableChildEngine).
nil (the default, and ALWAYS on the no-FS path) leaves Subagent without writable-explorer per-model support: a mode:"read-write"+`model` (no `agent`) call then errors with a clear "not supported in this deployment" message from validateMode (never a silent inherit). It is layering-clean: the closure takes a string and returns *Engine — both agent-layer types — and no adapter/proto/server type crosses (same shape as WithSubagentEngineFactory).
type SubagentStatusTool ¶
type SubagentStatusTool struct{}
SubagentStatusTool reports the LIVE state of this run's delegated children and collects background subagent results. It reaches the parent run's child registry through parentCaps (the childCapableTool seam) — no store, no adapter, no layering cost. It is registered wherever Subagent is (the composition root's main catalogs), never in child catalogs.
func (*SubagentStatusTool) Execute ¶
func (*SubagentStatusTool) Execute(_ context.Context, call session.ToolCall, _ tool.Environment) (session.ToolResult, error)
Execute is the caps-less path (plain Execute, no parent run threaded): there is no registry to read, which is an honest model-addressable error — this tool is only meaningful inside a run that registers its children.
func (*SubagentStatusTool) ExecuteWithParent ¶
func (t *SubagentStatusTool) ExecuteWithParent(ctx context.Context, call session.ToolCall, env tool.Environment, _ func(session.Event), caps parentCaps) (session.ToolResult, error)
ExecuteWithParent is the childCapableTool seam: it receives the parent run's capabilities and reads/collects from caps.children.
func (*SubagentStatusTool) ReadOnly ¶
func (*SubagentStatusTool) ReadOnly() bool
ReadOnly reports that SubagentStatus only reads the in-memory registry (no workspace mutation), so the dispatcher may run it read-parallel — which is also what lets a wait_ms park overlap other tools in the same turn.
func (*SubagentStatusTool) Spec ¶
func (*SubagentStatusTool) Spec() tool.ToolSpec
Spec returns the model-facing specification for the SubagentStatus tool.
type SubagentTool ¶
type SubagentTool struct {
// contains filtered or unexported fields
}
SubagentTool is the subagent delegation tool (gauntlet #7). It is a tool.Tool that, when executed, spins up a CHILD agent loop with its own fresh Session, its own (tighter) Limits, and a SCOPED tool catalog — supplied by the injected child *Engine — runs it to completion, and returns ONLY the child's final summary string as a single ToolResult.
Workspace: when a child forker is wired (WithChildForker — the composition root wires it iff the child catalog includes Bash), each child runs in its OWN isolated git WORKTREE (shares the base repo's `.git` ⇒ full history) so the explorer's shell can inspect (git log/show, cat, build, test) without its writes touching the shared parent workspace; the worktree is torn down after the child drains. Without a forker the child has no Bash and runs against the parent workspace, exactly as it originally did. Either way Subagent stays read-parallel-safe (see ReadOnly).
Context isolation is the whole point: the parent never observes the child's intermediate tool.call / tool.result / message.delta events. The child's Event stream is drained entirely inside Execute; only the terminal result text folds back into the parent conversation. This keeps a noisy "search → read N files → summarize" investigation from bloating the main context window.
The child Engine is built by the composition root (cmd/mecated, WP11) with a read-only explorer catalog (Read, Grep, Glob) that NEVER includes the Subagent tool itself — so a subagent cannot recurse — and an allow-all policy over those read-only tools so the child never needs to prompt a human. See NewSubagentTool.
Resume: when a store is wired (WithSubagentStore), a Subagent call carrying `resume` CONTINUES a previously-run child by its persisted id (the result trailer's `agentId:` line) — the prior conversation is reloaded and its terminal state recovered (completed →Reopen, cancelled→Interrupt, failed→Recover), then it runs on the default explorer engine in a FRESH workspace fork (the original worktree is gone; a staleness note is prepended). An in-flight guard rejects a concurrent run on the same id.
func (*SubagentTool) Execute ¶
func (t *SubagentTool) Execute(ctx context.Context, call session.ToolCall, env tool.Environment) (session.ToolResult, error)
Execute runs one subagent: it builds a FRESH child Session (own conversation, own Limits, its configured mode) — or, on `resume`, reloads the persisted child session and recovers its terminal state (completed→Reopen, cancelled→Interrupt, failed→Recover) before driving it in a NEW workspace fork — runs the child loop via the injected child Engine, drains the child's entire Event stream internally, and returns only the child's final summary text as a single ToolResult. The parent therefore never observes the child's intermediate events (gauntlet #7).
The child run is bounded by the parent ctx: cancelling the parent cancels the child. Any permission ask the child raises is auto-denied so the child is non-interactive. When the child finishes, the SubagentStop hook fires best-effort.
func (*SubagentTool) ExecuteObserved ¶
func (t *SubagentTool) ExecuteObserved(ctx context.Context, call session.ToolCall, env tool.Environment, emit func(session.Event)) (session.ToolResult, error)
ExecuteObserved runs the subagent like Execute but, when emit is non-nil, forwards a REDACTED, metadata-only projection of the child's activity to the parent run's event stream via the three subagent.* events. emit only sequences and channels events; it never touches the parent's Conversation, so this is orthogonal to context isolation (gauntlet #7): the child's CONTENT still never enters the parent context. It is the observableTool seam the dispatcher calls.
func (*SubagentTool) ExecuteWithParent ¶
func (t *SubagentTool) ExecuteWithParent(ctx context.Context, call session.ToolCall, env tool.Environment, emit func(session.Event), caps parentCaps) (session.ToolResult, error)
ExecuteWithParent is the childCapableTool seam: it runs the subagent like ExecuteObserved but threads the PARENT's capabilities (interactivity + the surface back-channel) into the child posture, so a child Bash ask that A1/A2 did not auto-resolve is SURFACED to the human (interactive) or auto-denied with the accurate message + operator diagnostic (headless).
func (*SubagentTool) MutatesParent ¶
func (t *SubagentTool) MutatesParent(call session.ToolCall) bool
MutatesParent implements the optional parentMutatingCaller seam: it reports whether THIS specific call will mutate the PARENT workspace. ReadOnly() stays true so read-only Subagent fan-out keeps batching in parallel; a call for which this returns true is excluded from the concurrent read batch (dispatch-serial, flushed alone via runOne) so the writable child's IN-PLACE Edit/Write/Bash against the real tree never overlaps a sibling parent Read/Grep/Glob — a torn read. This is the LOAD-BEARING correctness fix for direct-write (ADR 0077): a mode:"read-write" child mutates the real workspace DURING its run (no fork, no merge), so the dispatcher MUST keep it mutate-serial — independent of any merger (there no longer is one). It returns true ONLY for a call that will ACTUALLY run writable: mode:"read-write" with the writable child engine wired (the writable explorer) OR the agent writable factory wired (a writable named specialist mutates the real tree too — ADR 0058); the two are OR'd so a deployment wiring either form keeps its writable calls dispatch-serial. A malformed/unparseable args payload returns false (the call errors later anyway, and never writes).
func (*SubagentTool) ReadOnly ¶
func (*SubagentTool) ReadOnly() bool
ReadOnly reports that the Subagent tool is read-only, which lets the parent's dispatcher run Subagent CONCURRENTLY with other read-only tools that share the same Workspace (read-parallel / mutate-serial; see dispatch.go).
INVARIANT — what keeps this safe is WORKSPACE ISOLATION, not catalog read-only-ness. A Subagent child may now WRITE via Bash (the read-only explorer's shell — git, build, test, cat), but when a child forker is wired (childForker != nil — the composition root wires it iff the child catalog has Bash) the child runs in an ISOLATED git WORKTREE, so its writes land in a throwaway checkout and NEVER touch the shared parent workspace the parent's other read-only calls race over. The read-parallel guarantee therefore holds exactly as before: no two concurrent dispatched tools ever mutate the same tree.
The only surface a worktree child shares with the parent is the `.git` object DB/refs (git-locked for concurrent access; config-driven code-execution vectors — hooks/pager/fsmonitor/external-diff — neutralized by the sandboxed runner's gitenv env in the composition layer), and a detached-HEAD worktree's stray commit is dangling and gc-able. A fork FAILURE is surfaced as a tool error, never a silent fallback to the shared ws (which WOULD break this), so the invariant cannot be violated by a degraded fork.
When NO forker is wired the child has no Bash (the catalog stays a pure read-only explorer) and runs against the shared ws — also safe, by catalog read-only-ness, exactly as it always was. Either way Subagent is read-parallel-safe and ReadOnly() honestly returns true.
ReadOnly() stays true for read-only fan-out; a mode:"read-write" CALL mutates the parent workspace IN PLACE during the run (direct-write, ADR 0077 — no fork, no merge), so it is excluded from the concurrent read batch via MutatesParent (dispatch-serial, run alone — see parentMutatingCaller) so its in-place edits never overlap a sibling parent read.
func (*SubagentTool) Spec ¶
func (t *SubagentTool) Spec() tool.ToolSpec
Spec returns the model-facing specification for the Subagent tool. When named agent definitions are configured, their names+descriptions are appended to the description (progressive disclosure, like the Skill tool enumerates skills) so the model can choose a specialist via the optional `agent` arg.
type Supervisor ¶
type Supervisor struct {
// contains filtered or unexported fields
}
Supervisor orchestrates one agent team. Build it with NewSupervisor, enrol members with AddMember (before Run), then call Run.
func NewSupervisor ¶
func NewSupervisor(t *team.Team, base tool.Environment, factory MemberEngine, opts ...SupervisorOption) *Supervisor
NewSupervisor constructs a team supervisor over a shared team, a base workspace, and a per-member engine factory. team, base, and factory must be non-nil; NewSupervisor panics otherwise (a composition-root programming error).
func (*Supervisor) AddMember ¶
func (s *Supervisor) AddMember(ctx context.Context, spec MemberSpec) error
AddMember enrols a member: it registers it on the team roster, builds its engine, selects its workspace per the three-tier policy (the shared base for a base-sharing read-only member; a worktree fork for a read-only-isolated member; a force-copy fork for a Mutating one), constructs its session, and records it. It must be called before Run. A Mutating member without s.forker, or a read-only-isolated member without s.roForker, is an error.
func (*Supervisor) CancelMember ¶
func (s *Supervisor) CancelMember(name string) bool
CancelMember requests cancellation of ONE member by name: it fires the member's per-member cancel, which unwinds a mid-drive turn (the drive ctx derives from the member ctx → the existing StopCancelled classification in runTurn de-schedules it, releasing its tasks) or, for a member idle between rounds, is caught by planRound's up-front ctx check before the next round plans it (D5: de-schedule, never skip-turn). It returns false for an unknown name; cancelling an already-stopped member is a harmless no-op (its ctx just goes unobserved). Safe from any goroutine: members/ cancel are immutable after AddMember (which must precede Run).
It is the seam BOTH cancel paths share: the Converse-path Team tool reaches members through the parent registry's CancelChild (whose registered cancel IS this member cancel), and the RunTeam-path `CancelTeammate` unary (D4, issue #29 — server.Service.CancelTeammate) calls it directly.
func (*Supervisor) MemberModel ¶
func (s *Supervisor) MemberModel(name string) string
MemberModel returns the concrete MODEL id the named member's engine actually runs on ("" for an unknown member). It is the read-only seam the Team tool uses to surface the member's resolved model on the EvTeamStart roster — independent of how it was chosen (inherited default member model, agent-def pin, or the opt-in router). Captured at AddMember (the engine is built once and reused). Bare metadata, never member content. When the router classified the member, MemberModel == the routed model. See issue #112 / ADR 0035.
func (*Supervisor) MemberRouting ¶
func (s *Supervisor) MemberRouting(name string) (category, model, reason string)
MemberRouting returns the OPT-IN model router's bare-metadata classification (category, model id) for a member by name (both empty when the member was not routed or is unknown), plus the bare-metadata REASON it was not routed (empty on a routed hit — issue #397). It is the read-back seam the Team tool uses to project routed metadata onto the EvTeamStart roster — captured once at AddMember, never member content. Safe to call after AddMember (the fields are immutable once set).
func (*Supervisor) Run ¶
func (s *Supervisor) Run(ctx context.Context, sink func(TeamEvent)) TeamOutcome
Run drives the team to quiescence (or the round cap), invoking sink for every member event as it is produced, and returns the outcome. sink may be nil. The run is bounded by ctx: cancelling it stops scheduling further rounds and lets the in-flight round finish. Forked member workspaces are cleaned up on return.
type SupervisorOption ¶
type SupervisorOption func(*Supervisor)
SupervisorOption configures a Supervisor.
func WithForker ¶
func WithForker(f tool.EnvironmentForker) SupervisorOption
WithForker injects the workspace-isolation seam used to fork a Mutating member's workspace (force-copy: own `.git`). It is required only if any member is Mutating.
func WithMaxRounds ¶
func WithMaxRounds(n int) SupervisorOption
WithMaxRounds caps the number of scheduling rounds (default defaultMaxRounds). A non-positive value is ignored.
func WithMemberErrorRetries ¶
func WithMemberErrorRetries(n int) SupervisorOption
WithMemberErrorRetries sets how many times a member whose round ended in session.StopError — and whose session the supervisor then RECOVERED successfully — is left SCHEDULABLE for a later round instead of being benched (default defaultMemberErrorRetries = 1; ADR 0200, issue #318). A retried member releases its in-progress task claim (so it, or a peer, can re-claim the work) and is force-scheduled for exactly one turn even when it holds no message and no claimable task. Once its errored-round count EXCEEDS this cap it is benched exactly as before this: stopped, MemberStopReason StopReasonError, tasks released, registry entry closed. 0 disables retry entirely (the previous release's behaviour); a negative value is ignored.
A member whose RECOVERY itself failed (memberRT.nonResumable) is NEVER retried regardless of this cap — its session cannot be driven at all — and neither is a cancelled or turn-budget-exhausted member (those are not transient failures).
func WithMemberLiveness ¶
func WithMemberLiveness(liveness port.SessionLiveness) SupervisorOption
WithMemberLiveness injects the maintenance exclusion used for team-member sessions. Run acquires it for every member before scheduling begins and cleanupAll releases each hold after the between-round and synthesis lifecycle has ended. Acquisition failure safely skips that member. This capability is independent of parent permission/ask capabilities, so a direct Service RunTeam can remain zero-capability.
func WithMemberSessionPrefix ¶
func WithMemberSessionPrefix(p string) SupervisorOption
WithMemberSessionPrefix sets the prefix used to derive member session ids (default "team"). Ids are of the form "<prefix>-<member>".
func WithMemberStore ¶
func WithMemberStore(store port.SessionStore) SupervisorOption
WithMemberStore injects the optional session store the supervisor uses to persist each member session for out-of-band inspection. Nil disables persistence. The supervisor consumes the port.SessionStore interface, never a concrete adapter, so no layering rule is crossed.
func WithMemberTurnBudget ¶
func WithMemberTurnBudget(n int) SupervisorOption
WithMemberTurnBudget sets the cumulative LIFETIME turn cap each member may spend across all rounds (default defaultMemberTurnBudget). Unlike WithTeamLimits — whose counters session.Reopen resets every round — this budget accumulates across rounds and is the only ceiling on a member's total turn spend. A member that exhausts it is stopped (its in-progress tasks released) exactly like a failed member, so a member that never finishes its work cannot loop to the round cap unbounded. A non-positive value disables the budget (n == 0 means "no lifetime cap").
func WithReadOnlyForker ¶
func WithReadOnlyForker(f tool.EnvironmentForker) SupervisorOption
WithReadOnlyForker injects the workspace-isolation seam used to fork a read-only member that the factory granted a shell (MemberBuild.IsolateReadOnly). It should be the forker's DEFAULT mode (git worktree: shares the base repo's `.git`), so an inspect-only member gets full history cheaply. It is required only if the factory marks any read-only member IsolateReadOnly; without it such a member trips ErrReadOnlyShellNoForker.
func WithRootAuthority ¶
func WithRootAuthority(authority session.Authority) SupervisorOption
WithRootAuthority supplies the composed root set for members of a directly server-created team. It is ignored for a team created from a parent run; child derivation remains outside this option.
func WithTeamConcurrency ¶
func WithTeamConcurrency(n int) SupervisorOption
WithTeamConcurrency bounds how many member turns run simultaneously within a scheduling round (default defaultTeamConcurrency). A non-positive value is ignored.
func WithTeamGoal ¶
func WithTeamGoal(goal string) SupervisorOption
WithTeamGoal sets the team's top-level objective. It is rendered as the team's TRUSTED top-level instruction into every member's round-0 turn and into the lead's synthesis prompt (the goal IS the member's genuine job; its provenance is the principal, never a peer). It is still governance.NeutraliseFraming'd on render so it cannot forge a fence/header. Use WithUntrustedGoal(true) to re-fence it as UNTRUSTED data when a deployment may interpolate untrusted end-user text into the goal. Empty is legal (the gRPC default before the goal field is supplied).
func WithTeamHooks ¶
func WithTeamHooks(h port.HookRunner) SupervisorOption
WithTeamHooks injects the HookRunner that fires the TeammateIdle lifecycle hook when a member goes idle after a turn (best-effort; nil disables it). It is the same runner the composition root should pass to MemberTools for the TaskCreated / TaskCompleted gates, so a team's lifecycle hooks all flow through one runner.
func WithTeamLimits ¶
func WithTeamLimits(l session.Limits) SupervisorOption
WithTeamLimits overrides the per-member, per-round stop conditions (default defaultChildLimits). Reopen resets these counters each round, so they bound one turn-loop, not the member's whole life.
func WithTeamMode ¶
func WithTeamMode(m session.PermissionMode) SupervisorOption
WithTeamMode sets the permission mode each member session runs under (default session.ModeDefault).
func WithTeamOwner ¶
func WithTeamOwner(owner *session.Principal) SupervisorOption
WithTeamOwner attributes members of a directly server-created team to the verified caller that created it. It is ignored for a team created from a parent run, where caps.owner carries the authoritative inheritance.
It supplies the OWNER ONLY. The gRPC path deliberately runs with zero parent caps — no ask surfacing and no child-ask adjudicator — and that posture is preserved: this option must never become a general caps channel.
func WithTeamReadLedgerFactory ¶ added in v0.14.0
func WithTeamReadLedgerFactory(factory func() tool.ReadLedger) SupervisorOption
WithTeamReadLedgerFactory injects the mandatory fresh member-ledger factory.
func WithTeamSharedBaseWorkspace ¶
func WithTeamSharedBaseWorkspace(view func(tool.Workspace) tool.Workspace) SupervisorOption
WithTeamSharedBaseWorkspace injects a capability-narrowing view for base-sharing members. It receives the actual base Workspace and must preserve its content backend rather than reconstructing storage from Root.
func WithTeamTokenBudget ¶
func WithTeamTokenBudget(n int) SupervisorOption
WithTeamTokenBudget sets the TEAM-WIDE cumulative token budget (input+output, session.Usage.TotalTokens) summed across ALL members and ALL rounds, including the lead's synthesis turn in the final accounting. It is checked at the ROUND boundary only (before planRound): the in-flight round always completes, so the overshoot is bounded by concurrency × one round's per-member spend (each drive itself bounded by per-round Limits and any Deps.MaxRunTokens). When it trips the TEAM stops scheduling — members are NOT individually stopped (no new MemberStopReason) and the lead's synthesis turn still runs (the report is the deliverable). It is ORTHOGONAL to the per-engine Deps.MaxRunTokens ceiling, which bounds one member drive and resets on Reopen each round. 0 (the default) disables it; a negative value is ignored.
func WithUntrustedGoal ¶
func WithUntrustedGoal(untrusted bool) SupervisorOption
WithUntrustedGoal marks the team goal as UNTRUSTED, so it is fenced as data rather than rendered as the team's trusted instruction. Use it ONLY when the goal may contain untrusted end-user text (a relay / multi-tenant front door). Default (no option) = trusted. The trust DECISION is a composition concern (where provenance is known); the supervisor is pure mechanism and only takes the bool.
type TeamEvent ¶
type TeamEvent struct {
// Member is the name of the member whose session produced Event.
Member string
// MemberSessionID is the producing member's child SESSION id (MemberSessionID:
// "team-<teamID>-<member>") — the uniform cancel/inspect handle, forwarded onto
// the team.member projection so a client can address the member (CancelChild)
// without deriving the id grammar. The same for every event of a given member.
MemberSessionID string
// MemberIncarnation binds internal durable event correlation to this member lifetime.
MemberIncarnation session.IncarnationID
// Event is the underlying session Event (turn.start, tool.call, result, ...).
Event session.Event
// ContextWindow is the producing member engine's context window in tokens
// (Engine.ContextWindow), the denominator for the per-member context meter. It
// is the same for every event of a given member; projectTeamEvent forwards it
// onto the turn.end projection (0 when the member's engine has no window set).
ContextWindow int
}
TeamEvent tags a member session Event with the member that produced it, for the multiplexed team event stream the caller observes.
type TeamMemberArg ¶
type TeamMemberArg struct {
// Name is the member's unique handle peers address messages to.
Name string `json:"name"`
// Role is the member's role briefing — its first-turn instruction. It becomes
// the member's InitialPrompt.
Role string `json:"role"`
// Mutating requests a self-contained copied workspace (own `.git`) with
// edit/write/shell tools (Edit/Write/Bash). A read-only member (the default,
// false) runs in an isolated throwaway git worktree with full shell for
// INSPECTION (git log/show, cat, build, test) but no Edit/Write. Neither tier is
// merged back into the base.
Mutating bool `json:"mutating,omitempty"`
}
TeamMemberArg is one roster entry the model supplies in a Team call. It maps 1:1 onto MemberSpec: Name → Name, Role → InitialPrompt, Mutating → Mutating.
type TeamMemberEngineFactory ¶
type TeamMemberEngineFactory func(t *team.Team, spec MemberSpec, routedModel string) MemberBuild
TeamMemberEngineFactory builds a team member's engine (as a MemberBuild carrying the constructed *Engine plus the member's optional per-member permission mode) from the shared team and the member spec. It is the SINGLE canonical factory shape consumed by every team entry point — the gRPC CreateTeam path (server.Config.MemberEngine) AND the Team tool — so the per-member engine wiring lives in one place (internal/app) and cannot drift between the two paths. The composition root supplies it; it is expected to capture nothing (the team is passed per-call) and to shape the member's catalog per the three-tier workspace policy (a base-sharing read-only member must NOT be handed workspace-mutating tools; a read-only-isolated member may have Bash and sets IsolateReadOnly; a Mutating member may have Edit/Write/Bash) plus the team coordination tools (MemberTools). Returning a MemberBuild (rather than a bare *Engine) is how a member's agent-definition permissionMode reaches the supervisor's per-member session — it is the exact shape server.MemberEngineFactory has, so one factory serves both paths.
routedModel is the OPT-IN model router's classification (ADR 0034) — the ALREADY-RESOLVED concrete model id for an UNDEFINED member, "" otherwise. The factory substitutes it for the default child model on the undefined branch only; a DEFINED member's factory ignores it (its def pins the model). The supervisor owns the route decision (it holds the parent caps) and threads the result through.
type TeamOption ¶
type TeamOption func(*TeamTool)
TeamOption configures a TeamTool.
func WithTeamToolForker ¶
func WithTeamToolForker(f tool.EnvironmentForker) TeamOption
WithTeamToolForker injects the workspace forker used to isolate a Mutating member's workspace (force-copy). It is required only if the model forms a Mutating roster.
func WithTeamToolHooks ¶
func WithTeamToolHooks(h port.HookRunner) TeamOption
WithTeamToolHooks injects the HookRunner threaded into the Supervisor (and, by the composition root, the member coordination tools) so a team's lifecycle hooks flow through one runner. nil disables them.
func WithTeamToolReadLedgerFactory ¶ added in v0.14.0
func WithTeamToolReadLedgerFactory(factory func() tool.ReadLedger) TeamOption
WithTeamToolReadLedgerFactory injects the fresh ledger factory for members.
func WithTeamToolReadOnlyForker ¶
func WithTeamToolReadOnlyForker(f tool.EnvironmentForker) TeamOption
WithTeamToolReadOnlyForker injects the workspace forker (the worktree-default mode) used to isolate a read-only member that the factory granted a shell. It is required only if the factory marks a read-only member IsolateReadOnly.
func WithTeamToolSharedBaseWorkspace ¶
func WithTeamToolSharedBaseWorkspace(view func(tool.Workspace) tool.Workspace) TeamOption
WithTeamToolSharedBaseWorkspace injects the capability-narrowing Workspace view the Team tool forwards to its Supervisor for base-sharing members.
func WithTeamToolStore ¶
func WithTeamToolStore(s port.SessionStore) TeamOption
WithTeamToolStore injects the session store the Team tool threads into the supervisor (WithMemberStore) to persist member sessions for out-of-band inspection. nil disables persistence. The Team tool consumes the port.SessionStore interface, never a concrete adapter (layering holds).
func WithTeamToolTokenBudget ¶
func WithTeamToolTokenBudget(n int) TeamOption
WithTeamToolTokenBudget sets the operator-configured team-wide token budget the tool threads into the supervisor (WithTeamTokenBudget). A per-call max_team_tokens arg may only TIGHTEN it (tightenLimit semantics). 0 disables.
type TeamOutcome ¶
type TeamOutcome struct {
// Rounds is the number of scheduling rounds that ran work.
Rounds int
// Quiescent reports whether the team reached genuine completion (all tasks
// done, mailboxes empty, no member still working) versus stopping because a
// round planned no work while tasks remained (a stuck dependency / deadlock).
Quiescent bool
// Members holds each member's terminal summary in enrolment order.
Members []MemberOutcome
// Report is the LEAD's consolidated synthesis — the team's deliverable, produced
// by a final synthesis turn in Run after the scheduling loop. It is the value the
// Team tool returns as its ToolResult. It is empty when synthesis could not run
// (no lead, lead stopped, or the lead produced no text); the caller then renders
// the degraded joinTeamFallback concatenation instead.
Report string
// Findings is the team's findings ledger at the end of the Run, snapshotted in
// append order — the PRIMARY deterministic, member-authored data the degraded
// deliverable leads with. It is the same data buildSynthesisSources reads
// (s.team.Findings()), captured onto the outcome so BOTH the Team-tool deliverable
// path and the gRPC RunTeam consumer get the rich fallback without reaching into
// the live *team.Team. Bodies are clamped identically to the wire/observability
// projection (projectTeamFindingsSnapshot).
Findings []session.TeamFindingSnapshot
// BudgetExhausted reports that the team-wide token budget (WithTeamTokenBudget)
// crossed at a round boundary and no further round was scheduled — the in-flight
// round and the lead's synthesis still completed. false when no budget was set or it
// never crossed.
BudgetExhausted bool
// Usage is the supervisor-accumulated team total — Σ per-drive EvResult.Usage across
// all members and rounds, synthesis included; equal by construction to the TeamTool
// sink's turn.end sum, which remains authoritative for the EvTeamEnd payload.
Usage session.Usage
}
TeamOutcome is the result of a team Run.
type TeamTool ¶
type TeamTool struct {
// contains filtered or unexported fields
}
TeamTool forms a team of coordinating subagents in-process (see teamsupervisor.go). When executed it builds a fresh team.Team, enrols the model-supplied roster (synthesizing the first member as the lead), drives the existing Supervisor to quiescence over the SAME base workspace as the parent, and returns the team's deliverable as a single ToolResult. The deliverable resolves through the three-tier deliverable() chain: (1) the lead's consolidated synthesis when it is a usable report (non-empty AND not a non-deliverable — isNonDeliverable rejects a bare refusal over a populated ledger), (2) a ledger-rich structured fallback (findings grouped by member, per-member disposition/reason/completed-tasks/last-text), (3) an honest floor when even the ledger is empty. A non-convergence header is prepended when the team did NOT converge. The ToolResult is therefore NEVER a bare refusal or empty.
It is the team analogue of SubagentTool/ParallelTool, with two deliberate differences:
- ReadOnly() == false. A team spawns Mutating members and is long-lived and stateful, so the dispatcher must SERIALISE it (mutate-serial) rather than run it read-parallel like Subagent. (Mutating members run in isolated forks, but the supervisor's member maps are not safe to drive alongside other tools.)
- Member activity is OBSERVABLE and fuller. Via the observableTool seam, each member event is projected to the parent run's stream as a team.member event carrying the member's message text and BOUNDED tool previews — a team is meant to be watched. permission.ask is dropped; every preview is capped.
Context isolation holds exactly as for Subagent/Fork: the per-member transcripts are never written to the parent Session's Conversation. Only the lead's synthesis (the ToolResult) folds back, so the LLM's context stays summary-only. (The PULL InspectMember tool may later pull ONE member's transcript on the parent's deliberate request — still not auto-injection.)
The composition root injects the member-engine factory, the workspace Forker, and the team hooks runner (mirroring Service.CreateTeam's wiring) so this tool drives the SAME Supervisor the gRPC team path drives.
func (*TeamTool) Execute ¶
func (t *TeamTool) Execute(ctx context.Context, call session.ToolCall, env tool.Environment) (session.ToolResult, error)
Execute runs a team with no observability (the emit == nil path): the team's member activity is not forwarded, only the lead's consolidated report is returned. Existing non-observing callers are unaffected by the observability seam.
func (*TeamTool) ExecuteObserved ¶
func (t *TeamTool) ExecuteObserved(ctx context.Context, call session.ToolCall, env tool.Environment, emit func(session.Event)) (session.ToolResult, error)
ExecuteObserved runs a team like Execute but, when emit is non-nil, forwards a BOUNDED projection of member activity to the parent run's stream via the three team.* events. emit only sequences and channels events; it never touches the parent's Conversation, so member content still never enters the parent context (only the lead's synthesis ToolResult does). It is the observableTool seam the dispatcher calls.
func (*TeamTool) ExecuteWithParent ¶
func (t *TeamTool) ExecuteWithParent(ctx context.Context, call session.ToolCall, env tool.Environment, emit func(session.Event), caps parentCaps) (session.ToolResult, error)
ExecuteWithParent is the childCapableTool seam: it runs the team like ExecuteObserved but threads the PARENT's capabilities (interactivity + surface back-channel) into the supervisor, so a member's permission ask that A2 (isolation auto-approve) did not resolve is SURFACED to the human (interactive parent) or auto-denied with the accurate message + operator diagnostic (headless). The in-loop Team tool's goal stays trusted; only the ask resolution posture changes.
func (*TeamTool) ReadOnly ¶
ReadOnly reports that the Team tool is NOT read-only, so the parent dispatcher SERIALISES it (mutate-serial) — it never runs concurrently with another tool. A team is long-lived, stateful, and may spawn Mutating members; its Supervisor drives unsynchronised member state, so it must not race the parent's other tool calls. This is the deliberate opposite of SubagentTool/ParallelTool, which are read-parallel.
type TokenCounter ¶
type TokenCounter interface {
// Count returns the estimated token count of a single string.
Count(text string) int
// CountMessages returns the estimated token count of a conversation slice,
// summing each message's text/reasoning/tool bodies plus the per-message and
// per-role framing overhead the provider adds on the wire.
CountMessages(msgs []session.Message) int
}
TokenCounter estimates how many model tokens a piece of text or a slice of conversation messages occupies. It is a seam (ARCHITECTURE.md §8, gauntlet #12): the loop and the compaction cascade consume it to decide when to compact and which segments to drop, while the concrete tokenizer (a dependency-free heuristic by default, an optional tiktoken-backed adapter in production) is injected from the composition root.
Implementations MUST be deterministic and SHOULD be cheap: Count/CountMessages run on every turn. A counter is never required to be exact — the loop only needs the estimate to be in the right ballpark — but it must be stable, since an unstable estimate would make compaction non-reproducible.
type UserModelReviewer ¶
type UserModelReviewer struct {
// contains filtered or unexported fields
}
UserModelReviewer extracts durable operator facts from completed trajectories. Observe consumes the owned trajectory directly and requires only the injected child engine. The legacy Review method additionally requires a SessionStore so it can load history by session ID.
func NewUserModelObserver ¶
func NewUserModelObserver(engine *Engine) *UserModelReviewer
NewUserModelObserver constructs the standard trajectory observer. It needs no SessionStore because learning.Trajectory already owns the completed history.
func NewUserModelReviewer ¶
func NewUserModelReviewer(store port.SessionStore, engine *Engine) *UserModelReviewer
NewUserModelReviewer constructs the legacy ID-based reviewer. Its SessionStore is used only by Review; automatic trajectory observation should use NewUserModelObserver instead.
func (*UserModelReviewer) Observe ¶
func (r *UserModelReviewer) Observe(ctx context.Context, tr learning.Trajectory) error
Observe implements learning.Observer from an owned completed-trajectory snapshot. Unlike the legacy Review method it needs no SessionStore reload and therefore observes exactly the history that completed.
func (*UserModelReviewer) Review ¶
func (r *UserModelReviewer) Review(ctx context.Context, sessionID string) error
Review loads the transcript of the just-finished session sessionID, runs the extraction child loop, and discards everything but completion (the child's RememberUser calls land in the user-model store as a side effect). Review returns an error when this reviewer was constructed by NewUserModelObserver, because the legacy ID-based path has no SessionStore in that configuration. It is otherwise best-effort: an empty/unreadable transcript, or a child that writes nothing, is a clean no-op. It returns an error only for genuinely surprising faults (e.g. a store load error) so a caller logging it has something to log; a fail-soft caller may ignore it.
It runs against a workspace-less child (the extraction needs no filesystem — the only tool is RememberUser, which ignores its Workspace). ctx bounds the run.
Source Files
¶
- askadjudicator.go
- authority_delegation.go
- authority_disclosure.go
- bashstatus.go
- bashtool.go
- cascade.go
- child_environment.go
- childregistry.go
- compaction.go
- dispatch.go
- evidencereflector.go
- fence.go
- forkjudge.go
- forkreaper.go
- guardrailcheck.go
- hooks.go
- loop.go
- manual_compaction.go
- modelrouter.go
- parallel.go
- permission.go
- presentplan.go
- request_manifest.go
- scheduletool.go
- sessionorigin.go
- steer.go
- structuredoutput.go
- subagent.go
- subagentinspect.go
- subagentstatus.go
- tailbuffer.go
- teaminspect.go
- teamsupervisor.go
- teamtool.go
- teamtools.go
- tokencount.go
- usermodelreview.go