Documentation
¶
Overview ¶
Package team owns the wire protocol codebot uses on top of agentcore's team primitives. agentcore/team supplies the mailbox/registry/runner mechanism; this package supplies the format and policy choices:
- <teammate-message teammate_id=... color=... summary=...> XML envelopes for everything the model sees;
- idle_notification JSON envelopes carrying the teammate's last assistant text across agent boundaries;
- shutdown_request JSON envelopes for graceful teammate exit;
- the priority order (shutdown > leader > peer, FIFO within tier).
Keeping these out of agentcore means a future project can plug a different envelope format (e.g. plain JSON, OpenAI tool-call shape) into the same runner without forking agentcore.
Index ¶
- Constants
- func EncodeIdleNotification(from, text string) string
- func EncodeShutdownRequest(reason string) string
- func FallbackIdleStatus(from string) string
- func FormatTaskClaimPrompt(taskID, subject, description string) string
- func FormatTeammateAttachment(from, text, color, summary string) string
- func Hooks(opts HookOptions) coreteam.ProtocolHooks
- func IdleNotificationText(text string) string
- func IsIdleNotification(text string) bool
- func IsShutdownRequest(text string) bool
- func ParseTeammateAttachment(s string) (from, body string, ok bool)
- func PickPriority(queue []coreteam.Message) int
- func PromptCacheKey(sessionID, agentName string) string
- func Spawner(reg *coreteam.Registry, rt *task.Runtime, extraTools []agentcore.Tool, ...) subagent.TeamSpawner
- func SubagentHubObserver(hub *EventHub) func(meta subagent.RunMeta, ev agentcore.Event)
- type AgentInfo
- type EventHub
- func (h *EventHub) ActiveAgents() []string
- func (h *EventHub) IsActive(agentName string) bool
- func (h *EventHub) KnownAgents() []AgentInfo
- func (h *EventHub) MarkStopped(agentName string)
- func (h *EventHub) Publish(agentName string, ev agentcore.Event)
- func (h *EventHub) Subscribe(agentName string) ([]agentcore.Event, <-chan agentcore.Event, func())
- func (h *EventHub) SubscribePresence() (<-chan PresenceEvent, func())
- type HookOptions
- type Isolation
- type LeaderInboxPump
- type MessageInjector
- type Persist
- type PresenceEvent
- type Waker
Constants ¶
const DefaultTeamName = "default"
DefaultTeamName is the placeholder name bootstrap assigns to the team pre-created at session startup. Tools that surface team state to the model (team_create's result message in particular) use it to recognise the "still on the default name" case and word their response accordingly.
const WorktreeIsolation = "worktree"
WorktreeIsolation is the AgentDefinition.Isolation value that opts a teammate into a private git worktree.
Variables ¶
This section is empty.
Functions ¶
func EncodeIdleNotification ¶
EncodeIdleNotification produces the JSON envelope the leader-side pump inspects to surface a teammate's turn output. `text` is the teammate's last assistant message — empty for tool-only turns, in which case the pump injects a short status line instead.
func EncodeShutdownRequest ¶
EncodeShutdownRequest produces the JSON envelope that triggers a teammate's graceful exit. The teammate runner picks this up at the next turn boundary (top priority, see PickPriority) and returns from Run without consulting the model. `reason` is for transcript/UI only.
func FallbackIdleStatus ¶
FallbackIdleStatus is the human-readable line the leader sees when a teammate ends a turn with no assistant text (tool-only turn). The pump uses it as a fall-through after IdleNotificationText returns "".
func FormatTaskClaimPrompt ¶
FormatTaskClaimPrompt is the text fed straight to a teammate's next turn when IdleClaim pulls a task. The "Complete all open tasks" preamble keeps the model focused on the wider list rather than treating the claimed item as its only assignment.
func FormatTeammateAttachment ¶
FormatTeammateAttachment wraps text into the XML envelope the model expects. `from` is required; `color` and `summary` are optional and omitted when blank.
func Hooks ¶
func Hooks(opts HookOptions) coreteam.ProtocolHooks
Hooks returns the agentcore protocol-hook bundle wired with codebot's envelope format and priority policy. The synthetic initial prompt and every later inbound message both flow through FormatPrompt.
func IdleNotificationText ¶
IdleNotificationText extracts the assistant text from an idle envelope. Returns "" when text is not an idle envelope or carries no text.
func IsIdleNotification ¶
IsIdleNotification reports whether text is the idle-wake envelope. Exported so the leader-side pump can filter these before injecting into the prompt stream (when no text is present it surfaces a status line instead of the envelope itself).
func IsShutdownRequest ¶
IsShutdownRequest reports whether text is a shutdown_request envelope.
func ParseTeammateAttachment ¶
ParseTeammateAttachment is the inverse of FormatTeammateAttachment. Returns (from, body, true) on a valid envelope, ("", "", false) on anything that doesn't match — broken envelopes fall through to plain rendering at callers.
Tolerant of attribute ordering, missing optional attributes, and surrounding whitespace; strict about needing a non-empty teammate_id and a matching closing tag.
func PickPriority ¶
PickPriority chooses the highest-priority message index from queue. Tier order: shutdown_request (0) > team-lead (1) > peer (2). Within a tier, lowest index wins, preserving FIFO arrival.
Scanning shutdown across the whole queue first prevents a peer-DM flood from starving shutdown handling.
func PromptCacheKey ¶ added in v0.3.0
PromptCacheKey derives a spawned agent's cache routing base from the session identity. One conversation, one key: agentcore appends "#<seq>" per spawn, so runs of the same definition don't pile into a single routing bucket. Shared by teammate spawns and subagent definitions (agent_build).
func Spawner ¶ added in v0.3.0
func Spawner(reg *coreteam.Registry, rt *task.Runtime, extraTools []agentcore.Tool, hub *EventHub, baseProvider func() []agentcore.SystemBlock, dynamicProvider func() *agentcore.SystemBlock, protocol coreteam.ProtocolHooks, hookRunner *hooks.Runner, persist *Persist, isolation *Isolation) subagent.TeamSpawner
Spawner returns the subagent.TeamSpawner closure that turns a `subagent { team_name: ... }` tool call into a long-lived teammate. Bound to the runtime's team registry + task runtime so every spawn shares the same coordination surface (send_message / leader inbox pump).
Parameters:
- extraTools: force-injected on top of req.Config.Tools (send_message and the shared task tools) — listed explicitly to avoid leaking leader-only tools.
- hub: per-session teammate event fan-out; nil disables observation.
- baseBlocks: universal base prefix shared with the leader for prompt cache reuse in Default/Append modes; nil ⇒ role block only.
- dynamicProvider: invoked once per spawn to snapshot the leader's current dynamic block (MCP + overlays). Snapshot is frozen at spawn; nil skips dynamic propagation.
- protocol: fully wired ProtocolHooks (envelope, idle notification, priority, optional IdleClaim). Bootstrap owns the wiring.
- hookRunner: fires the SubagentStop lifecycle hook when a teammate exits; nil disables it.
- persist: durable roster + transcript stores. On a successful spawn the teammate is recorded in the roster and every turn is appended to its transcript, so a restart can re-spawn it with its prior context. nil disables persistence.
- isolation: optional per-teammate git-worktree sandboxing. nil keeps every teammate in the leader's shared cwd; when set, a teammate whose agent type opts into "worktree" runs in its own checkout (bound via a cwd override on the spawn context).
func SubagentHubObserver ¶ added in v0.3.0
SubagentHubObserver builds the subagent.Tool event observer that fans every sub-agent run's raw AgentLoop events into the teammate event hub. This is what makes one-shot / background sub-agents observable in the live-preview modal the same way long-lived teammates already are — one hub, two producers.
Naming: the hub keys by a human-readable display name, but RunMeta.Agent is the agent TYPE (e.g. "explore"), which collides when the same type runs concurrently (parallel mode) or alongside a same-named teammate. We assign each unique RunMeta.InstanceID a display name on its first event, picking the bare type when free and appending " #2", " #3", … otherwise — dedup mirrors uniqueAgentName but resolves against names currently live in the hub plus the ones this observer has already handed out. The mapping is released on the run's EventAgentEnd (guaranteed on every termination path) so names recycle.
Returns nil when hub is nil so callers can wire unconditionally.
Types ¶
type AgentInfo ¶ added in v0.3.0
AgentInfo describes a known teammate: its name and whether it is still publishing events. Returned by KnownAgents so the UI can render an "ended" indicator without a second round-trip.
type EventHub ¶ added in v0.3.0
type EventHub struct {
// contains filtered or unexported fields
}
EventHub is a fan-out point for events produced by teammate agent loops. agentcore.AgentLoop returns a single-consumer channel — the executor drains it for produced-message collection — so anything else that wants to observe a teammate's activity (UI transcript view, log sinks, future analytics) must subscribe here.
Design constraints:
- Publish is hot-path (called once per event by every teammate goroutine). It MUST NOT block on a slow subscriber, or it stalls the AgentLoop that produced the event. Each subscriber gets a buffered chan with a drop-oldest policy.
- Subscribers come and go (modal opens/closes). Subscribe returns an unsubscribe function instead of exposing the underlying map.
- Presence (a teammate started / stopped publishing) is its own broadcast so a UI can auto-focus on the first teammate to come online without polling task.Runtime.
- Late subscribers must see what they missed. Every published event is also appended to a per-agent ring buffer; Subscribe hands back a snapshot before wiring the live channel. The ring outlives MarkStopped so an Observer can open a teammate's transcript after it has finished.
Zero-value safety: a nil *EventHub is a valid no-op publisher — callers (Spawner) can be wired before the hub exists in tests.
func NewEventHub ¶ added in v0.3.0
func NewEventHub() *EventHub
NewEventHub returns an empty hub ready to use.
func (*EventHub) ActiveAgents ¶ added in v0.3.0
ActiveAgents returns the names that are currently publishing — i.e. have published at least once and have not been MarkStopped'd. For the broader roster (including teammates that already finished) use KnownAgents.
func (*EventHub) IsActive ¶ added in v0.3.0
IsActive reports whether agentName is currently publishing events. Returns false for unknown names and for known-but-stopped teammates.
func (*EventHub) KnownAgents ¶ added in v0.3.0
KnownAgents returns every teammate that has ever published an event in this session, alongside its current active flag. Use this for "which teammates can I open in the transcript modal?" — already-finished agents still have a readable history.
func (*EventHub) MarkStopped ¶ added in v0.3.0
MarkStopped emits a Stopped presence event and flips the active flag. The history ring is preserved so an observer can still open this teammate's transcript later. Safe to call multiple times — only the first call after a Started transition broadcasts.
func (*EventHub) Publish ¶ added in v0.3.0
Publish delivers ev to every current subscriber of agentName and appends it to the per-agent history ring. Non-blocking: if a subscriber's buffer is full, the oldest queued event is dropped to make room — slow consumers lose history, never block the publisher.
The first Publish for a (currently-stopped) agentName also broadcasts a PresenceEvent {Started: true}. "Stopped → publishes again" repeats the broadcast; this is intentional so a UI auto-attaching to active teammates catches a teammate that briefly went idle and resumed.
nil receiver is a no-op so spawner wiring stays simple in tests.
Lock discipline: Publish does ring write + chan sends inside the mutex, in strict serialisation with Subscribe's unsubscribe (which closes the chan). Without that ordering, a subscriber that cancels mid-publish would let us send on a closed chan and panic. The sends themselves are non-blocking (drop-oldest), so holding the lock briefly is fine — the hot path is O(subscribers) channel operations, not I/O.
func (*EventHub) Subscribe ¶ added in v0.3.0
Subscribe registers a listener for agentName's events. Returns the recorded history as a snapshot slice (oldest first) plus a live channel for events arriving after the snapshot was taken. The caller MUST consume the history slice before reading the channel so its transcript renders in order.
The channel is buffered (subBufferSize); the publisher drops the oldest queued event when full. cancel MUST be called when the listener is done — it removes the channel from the routing table and closes it.
func (*EventHub) SubscribePresence ¶ added in v0.3.0
func (h *EventHub) SubscribePresence() (<-chan PresenceEvent, func())
SubscribePresence returns a channel that receives a PresenceEvent every time a teammate first publishes (Started) or is marked stopped. Channel is buffered; unsubscribe closes it.
The current active roster is replayed as Started events on the returned channel before any future presence changes — late subscribers don't miss teammates that joined earlier.
type HookOptions ¶
type HookOptions struct {
// IdleClaim, when set, lets the runner pull a synthetic prompt from
// the application instead of (or alongside) waiting on the mailbox.
// ctx carries the teammate identity via coreteam.WithIdentity so
// implementations can decide who is allowed to claim what.
IdleClaim func(ctx context.Context) (synthPrompt string, ok bool)
// IdleClaimInterval is the period at which the runner re-tries
// IdleClaim while parked on the mailbox. Zero means "only try once
// per turn boundary, then block until a real message arrives" —
// non-zero is needed when tasks can appear without any mailbox
// traffic (e.g. leader creates todos without send_message).
IdleClaimInterval time.Duration
}
HookOptions wires application-supplied callbacks into the protocol hook bundle. Today the only opt-in is IdleClaim (work-stealing); other fields are universal policy. Wrapping in a struct lets future opt-ins join without another signature break.
type Isolation ¶ added in v0.3.0
type Isolation struct {
// RepoRoot is the git repository the sandboxes branch from (the leader cwd).
RepoRoot string
// Of maps agent type (subagent.Config.Name) to its isolation mode; an
// absent key means shared. Only "worktree" triggers a sandbox.
Of map[string]string
}
Isolation configures optional per-teammate git-worktree sandboxing. A nil *Isolation disables it entirely — every teammate shares the leader's cwd, the pre-Phase-2 behaviour. When set, a teammate whose agent type maps to "worktree" in Of runs in its own checkout so its writes cannot clobber a peer editing the same files. The checkout is bound at spawn by a cwd override on the teammate's context (see teammateCwd), not by rebuilding its tools — the same tool instances resolve paths against whatever cwd the running context carries.
type LeaderInboxPump ¶ added in v0.3.0
type LeaderInboxPump struct {
// contains filtered or unexported fields
}
LeaderInboxPump bridges the leader's mailbox to the leader agent. Without it, messages routed by send_message to "team-lead" land in the mailbox but never reach the model — there is no equivalent of the teammate runner loop for the main agent (it is driven by the user / TUI session instead).
The pump:
- sleeps in short backoff while no team is active (team_create wires up the leader mailbox only when invoked);
- subscribes to the leader mailbox via Wait and drains arriving messages;
- filters out idle_notification envelopes (they exist to wake the leader for fan-out coordination, but the model itself should not see the JSON);
- calls Agent.Inject for every remaining message so the agent steers / resumes / queues based on its current state.
Lifecycle: spawned at boot, exits when ctx is cancelled (Runtime.Close).
func NewLeaderInboxPump ¶ added in v0.3.0
func NewLeaderInboxPump(reg *coreteam.Registry, ag MessageInjector, waitInterval time.Duration) *LeaderInboxPump
NewLeaderInboxPump constructs a pump for the given registry + leader agent. A zero waitInterval picks the default. Both reg and ag must be non-nil.
func (*LeaderInboxPump) Run ¶ added in v0.3.0
func (p *LeaderInboxPump) Run(ctx context.Context)
Run blocks until ctx is cancelled. Safe to call as `go pump.Run(ctx)` from bootstrap. The loop is two-phase:
- No team yet: short timer-driven backoff until Registry.Mailbox returns non-nil for TeamLeadName.
- Team active: Wait on the mailbox, Drain on wake, Inject each non-control message. On ErrClosed (team torn down) fall back to phase 1.
type MessageInjector ¶ added in v0.3.0
type MessageInjector interface {
Inject(context.Context, agentcore.AgentMessage) (agentcore.InjectResult, error)
}
MessageInjector is the slice of *agentcore.Agent the pump actually uses. Defined as an interface so tests can stand in a fake without spinning the full agent machinery.
type Persist ¶ added in v0.3.0
type Persist struct {
Roster *storage.RosterStore
Transcripts *storage.TranscriptStore
}
Persist bundles the durable stores a spawned teammate writes to so the session can recover its team after a restart: the roster (who is on the team + how to re-spawn them) and the per-teammate conversation transcript. A nil *Persist — or nil fields within — disables that slice of persistence, so tests and ephemeral sessions can omit it entirely.
type PresenceEvent ¶ added in v0.3.0
PresenceEvent describes a teammate joining or leaving the hub. Started is emitted on the first Publish for an agent; Stopped is emitted by an explicit MarkStopped call (the spawner invokes this when the teammate's goroutine exits, so subscribers can release resources without polling).
type Waker ¶ added in v0.3.0
type Waker struct {
// contains filtered or unexported fields
}
Waker re-spawns a dormant teammate on demand, seeded with its persisted transcript, the first time the leader messages it. Teammate recovery is LAZY and message-driven: a stopped teammate is revived only when a message targets it, never eagerly mass-restored at session startup.
A teammate that exited — graceful completion, crash, or a prior session that ended — leaves two durable traces: its roster entry (who it was + the agent type to rebuild its Config from) and its transcript JSONL (what it had done). When a message targets that name and no live teammate answers to it, Wake rebuilds the teammate from those traces and delivers the message as its opening turn — the message itself IS the resume prompt, so no opening message is fabricated.
Live control-flow state (in-flight tool calls, pending approvals) is not restored; the teammate resumes from its last completed turn.
func NewWaker ¶ added in v0.3.0
func NewWaker(spawner subagent.TeamSpawner, configOf func(agentType string) (subagent.Config, bool), registry *coreteam.Registry, roster *storage.RosterStore, transcripts *storage.TranscriptStore) *Waker
NewWaker assembles a waker from the same spawn closure teammates are created through (so a woken teammate flows through identical tool injection, transcript recording and roster upsert) plus the durable stores. configOf rebuilds a teammate's subagent.Config from its agent type; registry is used to make wake idempotent under concurrency. A nil spawner, configOf or roster makes Wake a permanent no-op so the caller falls back to its normal not-found handling.
func (*Waker) Wake ¶ added in v0.3.0
Wake re-spawns the dormant teammate named `name`, seeding it with its persisted transcript and delivering `prompt` as its opening message:
- (true, nil) — name matched a persisted roster member and was re-spawned; the message was delivered as its first turn. Caller reports success.
- (false, nil) — name is not a known persisted teammate, OR a concurrent wake already revived it. Either way the caller re-checks liveness: live ⇒ deliver via the mailbox; still absent ⇒ fall through to not-found.
- (false, err) — name matched a roster member but re-spawn failed (unknown agent type, spawn error); caller surfaces the error.
Wake is idempotent under concurrency: a session-wide lock plus a liveness re-check ensures two parallel messages to the same dormant name revive it once, not as clones.