subagents

package
v0.4.0 Latest Latest
Warning

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

Go to latest
Published: Aug 13, 2026 License: MIT Imports: 19 Imported by: 0

Documentation

Overview

Package subagents owns typed-subagent configuration, the per-session task registry for background runs, and the discovery + parsing of agent definition files (`.yottacode/agents/*.md` and `~/.yottacode/agents/*.md`). The agent loop itself imports this package to look up `Agent` tool invocations; nothing here imports the agent package, which keeps the dependency direction clean.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func NewTaskID

func NewTaskID() string

NewTaskID returns a short hex-encoded random ID suitable for a transcript filename and for prefix-matching by /subagents stop. 8 bytes → 16 hex chars; collisions within a single session are vanishingly unlikely.

func ProjectAgentsDir

func ProjectAgentsDir(cwd string) string

ProjectAgentsDir returns the per-project agents dir: <cwd>/.yottacode/agents. Project-local definitions are checked in here so a team can ship a repo-specific `Explore`/`Plan` flavor alongside the codebase. Project wins on name collision with the user-scope dir.

func TranscriptDirFor

func TranscriptDirFor(cwd string) (string, error)

TranscriptDirFor resolves where subagent run transcripts get persisted: <project memory dir>/subagents/ — i.e. ~/.yottacode/memory/projects/<slug>/subagents/. Transcripts nest inside the project's memory dir so every per-project artifact is discoverable from one `ls ~/.yottacode/memory` tree; the memory loader skips subdirectories, so transcript .md files never load as memories. Root resolution (incl. the $YOTTACODE_HOME override) is owned by memory.ProjectMemoryDir.

func UserAgentsDir

func UserAgentsDir() (string, error)

UserAgentsDir returns the global agents dir: $YOTTACODE_HOME/agents (when the env var is set) or ~/.yottacode/agents otherwise — the shared ychome.Dir resolution, so all global state lives under the same root regardless of override.

Types

type AgentConfig

type AgentConfig struct {
	Name        string   // subagent_type the parent uses in the Agent tool call
	Description string   // shown to the parent model as part of the Agent tool schema
	Tools       []string // optional tool allowlist; ["*"] or nil means "inherit all parent tools (minus Agent)"
	Model       string   // optional adapter model override; empty means inherit parent's
	Prompt      string   // markdown body — used as the child's system prompt
	Background  bool     // when true, dispatches default to background unless the caller opts in to foreground
	Source      string   // "builtin" | "global" | "project" — diagnostics only
	SourcePath  string   // absolute path of the source file (empty for builtins)
}

AgentConfig is one typed-subagent definition. The body of the source file becomes the child's system prompt; the frontmatter declares metadata + an optional tool allowlist + an optional model override.

func Find

func Find(configs []AgentConfig, name string) *AgentConfig

Find returns the named config or nil. Case-sensitive: agent names are validated against agentNamePattern so casing is part of identity.

func LoadBuiltins

func LoadBuiltins() []AgentConfig

LoadBuiltins parses every embedded *.md file under builtins/ and returns the resulting AgentConfig set. The Source is always "builtin" so warnings/diagnostics can distinguish embedded vs disk-loaded definitions. Parse errors here would be ship-blocking (the embedded files are part of the binary), so we panic — the test suite catches any bad frontmatter before release.

func ParseAgentFile

func ParseAgentFile(data []byte) (AgentConfig, error)

ParseAgentFile parses one agents/*.md file into an AgentConfig. Returns an error when the frontmatter is missing or required fields are blank — the caller is expected to surface the error with the file path so the user can fix it.

Frontmatter format (YAML-ish, deliberately tolerant of hand-edits; matches the approach internal/memory uses):

---
name: Explore
description: Fast read-only search agent for locating code.
tools: [read_file, grep, glob]      # optional; "*" or absent = inherit
model: claude-haiku-4-5             # optional
---
<markdown body — used as the child's system prompt>

func (AgentConfig) ToolAllowed

func (c AgentConfig) ToolAllowed(name string) bool

ToolAllowed reports whether the named tool should be exposed to the child registry. Returns true when Tools is nil/empty (inherit all) or when the name is in the allowlist. Caller is still responsible for the unconditional Agent/exit_plan_mode exclusion — this method only encodes the per-config allowlist semantics.

type LoadResult

type LoadResult struct {
	Configs  []AgentConfig
	Warnings []string
}

LoadResult is what LoadAll returns: a deduplicated, ordered slice of configs (project > global > builtin precedence) plus a slice of human-readable warnings the caller can surface to the user. Warnings are non-fatal — a single malformed file shouldn't block startup.

func LoadAll

func LoadAll(cwd string, validToolNames map[string]bool) (LoadResult, error)

LoadAll resolves agent definitions from all three sources and merges them with project > global > builtin precedence. validToolNames is the set of names the live tool registry exposes — any agent that references a tool outside this set emits a warning and that name is silently dropped from the allowlist. Pass nil to skip allowlist validation entirely (used by tests that don't care).

type Registry

type Registry struct {
	// contains filtered or unexported fields
}

Registry tracks active and historical subagent tasks for the current session. Mutex-guarded so the agent goroutine, the TUI render path, and slash commands can all touch it safely. Snapshots returned by List() are deep-enough copies that callers can hold them indefinitely.

waiters maps task id → list of channels signaled (closed) when the task transitions out of Running via MarkDone. The get_subagent_result tool registers a channel here when called with wait_seconds > 0, then blocks on it instead of polling. Channels are closed (not sent on) so multiple waiters per task work naturally — closing broadcasts to every receiver.

func NewRegistry

func NewRegistry() *Registry

NewRegistry returns an empty Registry ready for concurrent use.

func (*Registry) ActiveCount

func (r *Registry) ActiveCount() int

ActiveCount returns the number of tasks currently in TaskRunning. Used by the TUI to render a status-bar indicator and to enforce the background concurrency cap.

func (*Registry) ActiveForegroundCount added in v0.3.0

func (r *Registry) ActiveForegroundCount() int

ActiveForegroundCount returns the number of foreground tasks currently in TaskRunning. Used by AgentTool.Execute to enforce the foreground concurrency cap when the parent fans out multiple Agent calls in one assistant message (now parallel-safe).

func (*Registry) Add

func (r *Registry) Add(t *Task)

Add inserts a task. The ID is expected to be unique within the session; Add overwrites silently rather than failing so callers don't need to bother with conflict handling for short hex suffixes.

func (*Registry) AddUsage added in v0.4.0

func (r *Registry) AddUsage(id string, u *adapter.Usage)

AddUsage accumulates one assistant turn's exact provider-reported token usage onto the task's running Usage total. Called each turn from the runner as it forwards the child loop's AssistantMessage event — mirroring what the main TUI loop does with session.AddUsage for the parent thread, so subagent spend is captured with the same fidelity. Nil-safe (a turn whose adapter reported no usage is a no-op) and a no-op for unknown ids.

func (*Registry) AppendActivity

func (r *Registry) AppendActivity(id, line string)

AppendActivity adds a short status string to the task's ring buffer. Oldest entries drop when the ring fills.

func (*Registry) AttachCancel

func (r *Registry) AttachCancel(id string, cancel context.CancelFunc)

AttachCancel records the cancel func so /subagents stop has a way to interrupt the child loop. Must be called before the goroutine starts; later AttachCancel calls overwrite.

func (*Registry) BatchActiveCount added in v0.4.0

func (r *Registry) BatchActiveCount(batchID string) int

BatchActiveCount returns how many tasks in the named dispatch batch are still TaskRunning. The TUI reads this to decide whether a batch's background completions are ready to wake the model: workers finish at staggered times, and waking once per worker would burn a turn each. Zero means the batch has fully drained (or the id is unknown). An empty batchID counts nothing — non-dispatch tasks carry no BatchID and must never be batch-gated.

func (*Registry) Cancel

func (r *Registry) Cancel(id string) bool

Cancel invokes the task's cancel func (if any) so the underlying agent.Turn goroutine returns at the next context check. Marks CanceledByUser=true so the runner's outcome message can attribute the cancellation to /subagents stop rather than to a parent-turn cancellation or a context deadline. The MarkDone(TaskCanceled) follow-up is the responsibility of the goroutine itself when it observes the canceled context.

func (*Registry) CancelAll added in v0.3.0

func (r *Registry) CancelAll() int

CancelAll invokes every running task's cancel func, signaling all in-flight subagents — foreground AND detached background workers — to stop at their next context check. Used on session shutdown so background workers (which run on context.Background() to survive the parent turn) don't leak their goroutines and provider SSE streams past TUI exit. Returns the number of tasks signaled. Like Cancel, it does not mark the tasks done — each goroutine does that when it observes the canceled context; callers that need to wait for the drain can poll ActiveCount.

func (*Registry) CancelBatch added in v0.4.0

func (r *Registry) CancelBatch(batchID string) int

CancelBatch cancels every running member of one dispatch batch, leaving tasks outside it untouched. A batch is up to 8 workers, and stopping one by one via Cancel means finding and typing each id while the rest keep burning tokens. Returns the number signaled. Same contract as Cancel: the goroutines mark themselves done when they observe the canceled context. An empty batchID cancels nothing — batch-less tasks must never be swept up.

func (*Registry) CommittingCount added in v0.4.0

func (r *Registry) CommittingCount() int

CommittingCount returns how many running tasks are mid-commit. Session teardown polls this to extend its drain only while real work is at risk, rather than making every quit pay a longer worst-case wait.

func (*Registry) Export added in v0.4.0

func (r *Registry) Export() []TaskRecord

Export returns a serializable snapshot of every task for persistence in the session file, oldest-first. Taken under the read lock so it's safe to call alongside live registry mutation.

func (*Registry) FindByPrefix

func (r *Registry) FindByPrefix(prefix string) (*Task, bool)

FindByPrefix is a typing-friendly accessor for /subagents stop: it returns the task whose ID starts with the given prefix. Returns (nil, false) when there's no match OR multiple matches (caller renders the disambiguation message). Matches Get's snapshot semantics.

func (*Registry) Get

func (r *Registry) Get(id string) (*Task, bool)

Get returns a snapshot of the named task or (nil, false). The returned pointer references a copy, so callers can read its fields without holding the registry lock — but mutating it has no effect on the stored task.

func (*Registry) Import added in v0.4.0

func (r *Registry) Import(records []TaskRecord)

Import rehydrates persisted task records as HISTORICAL entries so a prior session's task-ids resolve (get_subagent_result, /subagents) after a restart. A record still Running when its session ended is unattachable — it's marked errored-orphaned so it reads as "didn't finish" rather than a phantom live task. Existing tasks with the same id are not overwritten, and the records don't count toward this session's concurrency cap (terminal) or token budget (historical). Typically called once at startup on an empty registry.

func (*Registry) IncrementCompactionCount added in v0.4.0

func (r *Registry) IncrementCompactionCount(id string)

IncrementCompactionCount records one successful firing of the child's own in-loop compaction (agent.ContextCompacted with Err == nil) — the runner calls this from its child-event loop alongside the existing activity-line rendering, so /usage can show a subagent's own compaction history instead of it being visible only as a transient activity string. A no-op for unknown ids.

func (*Registry) List

func (r *Registry) List() []Task

List returns a snapshot of every task, newest-first. Callers can retain the slice without locking.

func (*Registry) MarkDone

func (r *Registry) MarkDone(id string, status TaskStatus, result string, errored bool, tokensUsed int)

MarkDone updates the task's terminal state and signals every waiter blocked on WaitFor. Idempotent: calling MarkDone twice keeps the first finish time + status and overwrites only the Result/Errored fields if the caller passes different values. The cancel func is cleared so a later Cancel() call is a no-op rather than crashing.

Waiters are signaled by *closing* their channels (not sending on them), which fans out to every receiver and makes the channels reusable on multiple `<-ch` receives. Both the running→terminal transition AND a no-op MarkDone on an already-finished task close any registered waiters — defensive against late waiters that registered just before MarkDone ran.

func (*Registry) SetCommitting added in v0.4.0

func (r *Registry) SetCommitting(id string, committing bool)

SetCommitting marks (or clears) a task's commit-in-flight window. See Task.Committing — session teardown consults it before giving up on a drain.

func (*Registry) SetContextUsage added in v0.3.0

func (r *Registry) SetContextUsage(id string, tokens, window int)

SetContextUsage records the subagent's current context size + window so the live dock can render a fill bar. Called each iteration from the runner as it forwards the child loop's ContextUsage event.

func (*Registry) SetToolCalls

func (r *Registry) SetToolCalls(id string, n int)

SetToolCalls records the final tool-call count for a task. Called by the runner just before MarkDone so the card / list can render an accurate ACTS value without inspecting the activities ring (which is bounded and may have dropped entries).

func (*Registry) TotalTokensUsed added in v0.4.0

func (r *Registry) TotalTokensUsed() int

TotalTokensUsed sums the estimated TokensUsed across every task in the session (running tasks contribute 0 until MarkDone records their estimate). The Agent tool reads this to enforce a session-wide subagent token budget — a backstop against unbounded fan-out spend that the per-wave concurrency cap can't bound. O(n) over the task set, called once per spawn; n is small.

func (*Registry) TryReserve added in v0.4.0

func (r *Registry) TryReserve(t *Task, max int, countForegroundOnly bool) bool

TryReserve atomically inserts t only if the relevant Running count is still below max, doing the count AND the insert under a single lock — so concurrent reservations (e.g. N parallel Agent calls in one assistant message) cannot each pass a separate check-then-Add and overshoot the cap. Returns false WITHOUT inserting when the class is already at/over max.

countForegroundOnly selects the class: true counts only foreground Running tasks (the foreground cap); false counts ALL Running tasks (the background cap, which bounds total concurrency — matching the historical ActiveCount()-based check). t.Status should be TaskRunning.

func (*Registry) TryReserveBatch added in v0.4.0

func (r *Registry) TryReserveBatch(tasks []*Task, max int, countForegroundOnly bool) bool

TryReserveBatch is the all-or-nothing variant of TryReserve for a group of tasks that must be admitted together: it counts the Running class ONCE and inserts every task, or inserts none. Dispatch needs this because it spawns N workers from a single call — a per-task TryReserve loop could admit some and reject the rest, leaving a half-built batch whose worktrees are already on disk, and a check-then-Add (count here, insert in the spawned goroutines) reopens the very race TryReserve exists to close.

countForegroundOnly selects the class, same as TryReserve. Every task's Status should be TaskRunning. Returns false WITHOUT inserting anything when the batch would push the class past max.

func (*Registry) WaitFor

func (r *Registry) WaitFor(id string) <-chan struct{}

WaitFor returns a channel that is closed when the task transitions out of Running (via MarkDone). The channel is closed (never sent on) so multiple goroutines can wait simultaneously and unblock together. If the task is already terminal at call time, the returned channel is closed immediately so the caller's select case fires on the first iteration — no special-case needed at the call site.

Returns a closed channel for unknown task ids too (rather than nil), so a typo doesn't deadlock the caller. The caller should re-read the task state after the wait returns to decide what "no longer running" actually means.

type Task

type Task struct {
	ID         string
	AgentType  string
	Prompt     string
	Started    time.Time
	Finished   time.Time
	Status     TaskStatus
	Result     string
	Errored    bool
	Background bool
	// NotifyOnDone records that the spawner asked to be re-prompted with
	// this task's result when it finishes (the Agent tool's
	// notify_on_done). The TUI reads it to decide whether a background
	// completion should wake the model with the result, vs. stay silent
	// until fetched. Always false for foreground tasks.
	NotifyOnDone bool
	TokensUsed   int
	// Usage is the exact, provider-reported token tally accumulated across
	// the child's assistant turns (input/output/cache/reasoning). Unlike
	// TokensUsed — a single ~4-chars/token estimate stamped at MarkDone and
	// used only for the session budget backstop — Usage is updated live each
	// turn from the child loop's AssistantMessage event, and is the number
	// /usage folds into the session total. Zero (IsZero) for providers that
	// don't report usage; readers fall back to the TokensUsed estimate there.
	Usage           adapter.Usage
	ToolCalls       int    // count of ToolStart events from the child
	CompactionCount int    // number of times the child's own in-loop compaction fired (agent.ContextCompacted, Err == nil)
	Model           string // model the child ran on when task-routed; "" = inherited the parent's model
	TranscriptPath  string
	// Branch / Worktree are set for dispatch write-subtasks that run in
	// their own git worktree+branch. Empty for ordinary (shared-cwd)
	// subagents. The integrate tool merges Branch into the integration
	// branch; the TUI shows it per task.
	Branch   string
	Worktree string
	// Base is the commit SHA the dispatch worktree branched from. The
	// session-exit sweep uses it to decide whether Branch ever gained
	// commits (base..HEAD) before reclaiming an empty worktree. Empty for
	// non-worktree subagents.
	Base string
	// BatchID groups the children of one dispatch call so the TUI can
	// render them together and the parent can refer to the batch. Empty
	// for standalone Agent dispatches.
	BatchID string
	// Committing marks the window where a dispatch write-worker is staging and
	// committing its worktree to Branch. That work deliberately runs on a
	// cancellation-detached context so a just-finished worker still saves its
	// output — which means canceling the session does NOT stop it, and killing
	// the process mid-commit can abandon a half-written index (a stale
	// index.lock the user has to clear by hand in a worktree they never knew
	// existed). Session teardown reads this to keep waiting while a commit is
	// genuinely in flight, instead of abandoning it on a flat deadline.
	Committing bool
	// CtxTokens / CtxWindow track the subagent's live context usage,
	// updated each iteration from the child loop's ContextUsage event.
	// CtxWindow is 0 until the first update (and for child models whose
	// window couldn't be resolved); the dock shows a fill bar only when
	// CtxWindow > 0.
	CtxTokens int
	CtxWindow int
	// CanceledByUser is set when /subagents stop fires for this task.
	// Lets the runner distinguish "user explicitly stopped me" from
	// "parent turn was canceled" / "context deadline" in the outcome
	// message. Reading is safe under the registry lock; runChild
	// snapshots via Get().
	CanceledByUser bool
	Activities     []string
	// LastActivityAt is when the most recent activity was appended. Lets
	// the dock and get_subagent_result distinguish a subagent making steady
	// progress from one wedged on a single tool call: a long gap since the
	// last activity on a still-Running task is the stall signal. Zero until
	// the first activity.
	LastActivityAt time.Time
	// Historical marks a task rehydrated from a prior session (via Import):
	// a resolvable record of past work, not live this-session activity. The
	// session-token budget (TotalTokensUsed) skips these, and the TUI's
	// dropped-completion reconciliation skips them too, so resuming a session
	// neither starts with a drained budget nor re-banners/re-wakes on old
	// background results.
	Historical bool
	// contains filtered or unexported fields
}

Task is one subagent run, foreground or background. The fields are snapshot-friendly (no live channels) so List() can return a copy without holding the registry lock while the caller renders.

func (Task) Duration

func (t Task) Duration() time.Duration

Duration returns how long the task ran. For still-running tasks, it returns the time since Started.

func (Task) UsageTokens added in v0.4.0

func (t Task) UsageTokens() int

UsageTokens returns the exact provider-reported total this subagent consumed (input + output + cache read + cache write), matching the "total tokens" basis /usage uses so every surface tells the same story. Returns 0 when the provider never reported usage (Usage.IsZero) — callers fall back to the ~4-char/token estimate then.

type TaskRecord added in v0.4.0

type TaskRecord struct {
	ID              string        `json:"id"`
	AgentType       string        `json:"agent_type"`
	Prompt          string        `json:"prompt,omitempty"`
	Status          TaskStatus    `json:"status"`
	Result          string        `json:"result,omitempty"`
	Errored         bool          `json:"errored,omitempty"`
	Background      bool          `json:"background,omitempty"`
	NotifyOnDone    bool          `json:"notify_on_done,omitempty"`
	TranscriptPath  string        `json:"transcript_path,omitempty"`
	Started         time.Time     `json:"started,omitzero"`
	Finished        time.Time     `json:"finished,omitzero"`
	TokensUsed      int           `json:"tokens_used,omitempty"`
	Usage           adapter.Usage `json:"usage,omitzero"`
	ToolCalls       int           `json:"tool_calls,omitempty"`
	CompactionCount int           `json:"compaction_count,omitempty"`
	Model           string        `json:"model,omitempty"`
	Branch          string        `json:"branch,omitempty"`
	Worktree        string        `json:"worktree,omitempty"`
	Base            string        `json:"base,omitempty"`
	BatchID         string        `json:"batch_id,omitempty"`
}

TaskRecord is the JSON-serializable summary of a Task persisted in the session file so subagent task-ids survive a restart. The live cancel func, the activity ring, and live-context gauges are intentionally dropped — a rehydrated task is a historical record, not a re-attachable run. The Worktree/Base/Branch fields are kept so a startup sweep can reclaim a crashed session's empty dispatch worktrees.

type TaskStatus

type TaskStatus int

TaskStatus is the lifecycle phase of a subagent run. The four terminal states are completed (clean TurnDone), errored (loop error or model returned an error message), canceled (parent invoked task.Cancel via /subagents stop), and iterCapped (hit MaxIterations without a final reply — surfaced as a soft error).

const (
	TaskRunning TaskStatus = iota
	TaskCompleted
	TaskErrored
	TaskCanceled
	TaskIterCapped
)

func (TaskStatus) String

func (s TaskStatus) String() string

String renders the status as a short label suitable for `/subagents list`.

Jump to

Keyboard shortcuts

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