Documentation
¶
Overview ¶
Package team implements Team — multi-agent peer-to-peer collaboration on top of the Subagent foundation. Where a Subagent is a fire-and-forget worker that returns a single result, a Teammate is a long-lived peer that stays idle between turns, can receive messages from the leader or other teammates, and only exits via an explicit shutdown handshake.
The mailbox is in-memory, single-session scoped — no disk persistence, no cross-process IPC. Goroutines wake via a channel signal on Send; there is no polling loop.
Package layout:
team.go — package overview + small constants identity.go — context plumbing for thread-local agent identity mailbox.go — per-agent inbox with priority drain + wake channel registry.go — session-wide team state (mailboxes, name registry, team ctx) runner.go — long-lived teammate goroutine (turn loop + ProtocolHooks) spawn.go — teammate registration + goroutine launch
agentcore provides the mechanism; wire format and policy live in the application layer and are injected via RunConfig.Protocol / SpawnConfig.Protocol. See ProtocolHooks for the per-field contract.
Dependency direction: team → task (not reversed). Identity lives in task because Entry needs it; everything else lives here.
Index ¶
- Constants
- Variables
- func IdentityFromContext(ctx context.Context) *task.Identity
- func IsTeammate(ctx context.Context) bool
- func Run(ctx context.Context, cfg RunConfig) error
- func WithIdentity(parent context.Context, id *task.Identity) context.Context
- type Context
- type Mailbox
- type Message
- type ProtocolHooks
- type Registry
- func (r *Registry) AgentNames() []string
- func (r *Registry) CreateTeam(name, description, leaderTaskID string) error
- func (r *Registry) DeleteTeam() error
- func (r *Registry) HasTeam() bool
- func (r *Registry) Mailbox(name string) *Mailbox
- func (r *Registry) RegisterAgent(name, taskID string) error
- func (r *Registry) RenameTeam(newName, newDescription string) error
- func (r *Registry) TaskID(name string) (string, bool)
- func (r *Registry) Team() *Context
- func (r *Registry) TeammateNames() []string
- func (r *Registry) UnregisterAgent(name string) error
- type RunConfig
- type SpawnConfig
- type SpawnResult
- type TurnExecutor
Constants ¶
const TeamLeadName = "team-lead"
TeamLeadName is the canonical name of the team coordinator. Reserved — teammates may not be created with this name.
Variables ¶
var ( ErrTeamExists = errors.New("team: a team is already active in this session") ErrNoTeam = errors.New("team: no active team") ErrAgentExists = errors.New("team: agent name already registered") ErrUnknownAgent = errors.New("team: unknown agent") ErrReservedName = errors.New("team: agent name is reserved") // ErrTeamHasMembers is returned by RenameTeam when teammates are already // registered: their agentIds were minted as `name@oldteam` and renaming // would silently invalidate them. Callers should dismiss teammates first. ErrTeamHasMembers = errors.New("team: cannot rename while teammates are registered") )
Sentinel errors returned by Registry. Callers compare via errors.Is so they can present sensible messages to the user / agent.
var ErrClosed = errors.New("team: mailbox closed")
ErrClosed is returned by Send and Wait once the mailbox is closed.
var ErrTimeout = errors.New("team: mailbox wait timed out")
ErrTimeout is returned by WaitFor when the per-call timeout elapses before a message arrives. Distinct from ctx.Err() so the caller can branch on "tick fired, do periodic work" without misclassifying parent cancellation.
Functions ¶
func IdentityFromContext ¶
IdentityFromContext returns the teammate identity attached to ctx, or nil if the caller is not running inside a teammate. Tools that vary behaviour by identity (e.g. send_message routing) consult this.
func IsTeammate ¶
IsTeammate reports whether ctx is inside a teammate goroutine.
func Run ¶
Run drives the teammate's long-lived loop in the calling goroutine. It returns nil on graceful exit (ctx cancelled, mailbox closed, or shutdown approved). Returns a non-nil error only if the underlying executor failed in a non-cancellation way.
Loop shape:
- Mark Entry running + non-idle.
- Execute one turn with: prior history + new user prompt.
- Append produced messages to history.
- Mark Entry idle + (optionally) forward Protocol.EncodeIdle output to the leader's mailbox so the leader can react to the teammate's turn.
- Wait on our mailbox for the next message (or ctx cancellation).
- Drain mailbox into a local queue, pick the highest-priority message, format it, and loop back to step 1 with it as the new prompt.
func WithIdentity ¶
WithIdentity returns a derived context carrying id. Called by the teammate runner before invoking the agent loop. nil id panics — pass nil for a non-teammate context (or just use the parent ctx unchanged).
Types ¶
type Context ¶
Context is a snapshot of the active team's metadata. Returned by Team() so callers don't hold a lock or alias internal state.
type Mailbox ¶
type Mailbox struct {
// contains filtered or unexported fields
}
Mailbox is a single-consumer, multi-producer in-memory inbox. It pairs a FIFO queue with a buffered wake channel so consumers block efficiently in Wait instead of polling. Messages are removed on Drain — read-flag semantics would only matter for UI replay or crash recovery, neither of which apply in our single-session in-memory design.
func (*Mailbox) Close ¶
func (m *Mailbox) Close()
Close marks the mailbox closed and wakes any pending Wait. Subsequent Send calls return ErrClosed. Drain still returns whatever was already queued — closing doesn't lose delivered-but-unread messages.
func (*Mailbox) Drain ¶
Drain returns all pending messages atomically and empties the queue. Returns nil if there's nothing to deliver (or the mailbox is closed). Consumers typically call this right after Wait returns, then sort by priority before handing the first message to the agent.
func (*Mailbox) Send ¶
Send appends msg and signals any waiter. Non-blocking on the wake channel — if a previous signal is still pending, this Send coalesces into it (the consumer will see both messages on its next Drain).
func (*Mailbox) Wait ¶
Wait blocks indefinitely until at least one message is available, the mailbox is closed, or ctx is cancelled. Convenience wrapper over WaitFor with no timeout. The for-loop guards against spurious wakes (the wake channel could fire just as Drain emptied the queue from another goroutine).
Returns:
- nil when messages are ready (caller should Drain)
- ErrClosed if the mailbox was closed
- ctx.Err() if the context cancelled first
func (*Mailbox) WaitFor ¶
WaitFor is Wait with a per-call deadline: returns ErrTimeout when timeout elapses before a message arrives. timeout <= 0 disables the timeout (same behavior as Wait). Used by runner-side periodic pollers (e.g. work-stealing hooks) that want to wake up every N to do bookkeeping without missing real incoming messages.
type Message ¶
Message is one envelope delivered through a mailbox. Text may be plain content (peer DM) or a JSON-encoded structured message (see protocol.go). Mailbox itself stays protocol-agnostic — callers decide how to interpret Text.
type ProtocolHooks ¶
type ProtocolHooks struct {
// FormatPrompt wraps a Message into the string the teammate's model
// sees on the next turn. Run uses this hook for every prompt — including
// the synthetic "leader's opening message" built from RunConfig at
// startup, which is packaged as Message{From: TeamLeadName, Text:
// InitialPrompt, Summary: Description}. Default: m.Text verbatim.
FormatPrompt func(Message) string
// EncodeIdle returns the envelope pushed to the leader's mailbox after
// each turn. `lastText` is the teammate's last assistant text (may be
// empty for tool-only turns). Returning "" suppresses the notification.
// Default: return "" (no notification sent).
EncodeIdle func(from, lastText string) string
// ShouldTerminate decides whether a freshly picked queue message should
// cause Run to exit gracefully before invoking the executor. Default:
// always false (no control-message handling).
ShouldTerminate func(text string) bool
// PickPriority chooses which queued message to process next. Returns the
// queue index. Default: 0 (FIFO).
PickPriority func(queue []Message) int
// IdleClaim is the work-stealing hook. Run consults it (a) at every
// turn boundary before blocking on the mailbox and (b) every
// IdleClaimInterval while parked on it. When the hook returns
// ok=true, its synthPrompt is fed directly to the next turn — no
// Message is allocated, no fake sender is recorded, and the queue
// stays untouched.
//
// ctx carries the teammate identity via WithIdentity so applications
// can branch by who is asking. Returning ok=false (or leaving this
// nil) falls back to mailbox-only behavior. synthPrompt SHOULD be the
// final text the model will see — the runner does NOT re-wrap it
// through FormatPrompt, since there is no Message envelope to format.
IdleClaim func(ctx context.Context) (synthPrompt string, ok bool)
// IdleClaimInterval is the periodic re-check cadence used while a
// teammate is parked on the mailbox. Zero (the default) means
// IdleClaim is only consulted once per turn boundary; the teammate
// then blocks until a real message arrives. Set this when the
// application expects work to appear in the claim source without any
// mailbox traffic to wake the teammate up.
IdleClaimInterval time.Duration
}
ProtocolHooks lets the application layer plug its own wire format and policy decisions into the runner without forking it. agentcore stays format-agnostic: any nil hook falls back to a permissive default (plain text passthrough, FIFO, no idle notification, never terminate). An application provides the bundle (e.g. its own envelope format + leader- first priority) by setting every field.
type Registry ¶
type Registry struct {
// contains filtered or unexported fields
}
Registry holds session-wide team state: the active team (if any), the agent-name → task-ID lookup, and one Mailbox per registered agent. Single instance lives on the codebot session; passed into the SendMessage tool and teammate runners.
Concurrency: a single sync.Mutex guards everything. Mailboxes themselves carry their own lock, so the registry lock is only held during membership changes and lookups — never across Send/Drain/Wait.
func NewRegistry ¶
func NewRegistry() *Registry
NewRegistry creates an empty registry with no active team.
func (*Registry) AgentNames ¶
AgentNames returns the registered teammate names (including the leader) in stable alphabetical order. Used for broadcasts and listings.
func (*Registry) CreateTeam ¶
CreateTeam activates a new team and auto-registers the leader with the reserved TeamLeadName. Returns ErrTeamExists if a team is already active. The leader's taskID is the caller's session ID — that's how the leader looks itself up later when draining its own inbox.
func (*Registry) DeleteTeam ¶
DeleteTeam tears down the active team: closes every mailbox and clears the registries. Returns ErrNoTeam if there is no active team. Idempotent only inside the same call — a second DeleteTeam returns ErrNoTeam.
func (*Registry) Mailbox ¶
Mailbox returns the mailbox for name, or nil if not registered. Callers that need to wait/send must hold no other lock — Mailbox manages its own.
func (*Registry) RegisterAgent ¶
RegisterAgent adds a teammate to the active team. Returns:
- ErrNoTeam if no team is active
- ErrReservedName if name == TeamLeadName (leader is auto-registered)
- ErrAgentExists if name is already taken
On success the teammate gets a fresh Mailbox and its taskID is recorded so SendMessage can route by name.
func (*Registry) RenameTeam ¶
RenameTeam updates the active team's display name and (if non-empty) description. Mailboxes and agent registrations are untouched — only Context fields change. Returns:
- ErrNoTeam if no team is active
- ErrTeamHasMembers if any teammate is registered (their agentIds would break, since agentId == name@team is captured at Spawn time)
Renaming with an empty newName is a no-op for the name but still applies the description update — callers wanting to only refresh the description can pass "" for newName.
func (*Registry) TaskID ¶
TaskID returns the registered task ID for name. The bool is false if name is not registered. Used by SendMessage to find the target entry.
func (*Registry) TeammateNames ¶
TeammateNames returns registered teammate names excluding the leader. Used by broadcast logic (leader sending to "*" should not echo to self).
func (*Registry) UnregisterAgent ¶
UnregisterAgent removes a teammate, closing its mailbox. Returns ErrUnknownAgent if name was never registered (or was already removed). Leader cannot be unregistered while the team is active — use DeleteTeam.
type RunConfig ¶
type RunConfig struct {
// Identity gives the teammate its name + team membership. Threaded into
// the agent's ctx via WithIdentity so tools can ask "who am I?".
Identity *task.Identity
// InitialPrompt is the leader's first message to the teammate. Run
// packages it as Message{From: TeamLeadName, Text: InitialPrompt,
// Summary: Description} and passes that through Protocol.FormatPrompt
// — the same path as every subsequent inbound message.
InitialPrompt string
// History seeds the running conversation before the first turn. When
// non-empty the first Execute receives History as the prefix of its
// input (History + the InitialPrompt user message). nil ⇒ fresh
// teammate. Used to resume a teammate with its prior transcript.
History []agentcore.AgentMessage
// Description is an optional short summary attached as Message.Summary
// on the synthetic initial-prompt Message. Format hooks may surface it
// (e.g. as an XML `summary=` attribute) or ignore it.
Description string
// Registry owns the mailbox and name registration for this teammate.
Registry *Registry
// TaskRT is the shared task runtime where this teammate's Entry lives.
TaskRT *task.Runtime
// TaskID identifies the Entry in TaskRT. The runner flips IsIdle on it
// across turn boundaries so the UI / leader can see state changes.
TaskID string
// Execute drives one agent turn. Required.
Execute TurnExecutor
// Protocol is the application-supplied format + policy hook bundle. Any
// nil field falls back to the agentcore default (see ProtocolHooks).
Protocol ProtocolHooks
// Now is the clock; tests inject a fake. Defaults to time.Now.
Now func() time.Time
}
RunConfig configures one teammate's long-lived loop. All fields except Now and Protocol are required.
type SpawnConfig ¶
type SpawnConfig struct {
// AgentName is the teammate's display name. Must be unique within the team
// and may not be the reserved TeamLeadName. Used in agentId (`name@team`)
// and as the routing key for SendMessage.
AgentName string
// InitialPrompt is the leader's first message to the teammate.
InitialPrompt string
// History, if non-empty, seeds the teammate's conversation with prior
// messages before the first turn — the first Execute receives History
// as the prefix of its input, with InitialPrompt appended as the new
// user turn. Used by a harness to resume a teammate with its earlier
// transcript after a restart; nil means a fresh teammate. Pure
// mechanism: Spawn/Run still do no I/O of their own.
History []agentcore.AgentMessage
// Description is an optional one-line summary shown in transcripts/UI.
Description string
// Color is an optional UI color assigned to this teammate.
Color string
// ParentSessionID identifies the leader's session — recorded on Identity
// for analytics / transcript correlation. May be empty.
ParentSessionID string
// Registry is the team registry; must have an active team.
Registry *Registry
// TaskRT is the shared task runtime where the teammate's Entry will live.
TaskRT *task.Runtime
// Execute drives one agent turn; passed straight through to Run.
Execute TurnExecutor
// Protocol is the application-supplied format + policy hook bundle.
// Forwarded to Run; see ProtocolHooks for per-field defaults.
Protocol ProtocolHooks
// Depth is the agent nesting depth at spawn time. The runner does not use
// it directly but it's recorded on the Entry so MaxAgentDepth checks at
// callsites can verify before calling Spawn.
Depth int
// OnExit, if non-nil, is invoked once the teammate goroutine is about to
// return — AFTER the Entry has been updated to its terminal status and
// the name has been unregistered. The err argument is whatever Run
// returned (nil on graceful completion, context.Canceled on shutdown,
// or a propagated error). Callers use this to release per-agent
// resources (event hubs, transcripts, etc.) without polling the runtime.
OnExit func(err error)
}
SpawnConfig configures a new teammate spawn.
type SpawnResult ¶
SpawnResult is returned synchronously after the teammate is registered and its goroutine has been launched. The teammate itself runs in the background; callers use TaskRT.Stop(TaskID) or Registry.UnregisterAgent(AgentName) to terminate it.
func Spawn ¶
func Spawn(parentCtx context.Context, cfg SpawnConfig) (*SpawnResult, error)
Spawn registers a teammate Entry, allocates its mailbox + name binding, and launches the long-lived Run goroutine. Returns immediately after launch.
On failure (no active team, duplicate name, depth exceeded), nothing is registered and no goroutine is started. On success the goroutine owns the Entry's terminal state — it will mark Completed/Failed and unregister the name when Run returns.
type TurnExecutor ¶
type TurnExecutor func(ctx context.Context, msgs []agentcore.AgentMessage) ([]agentcore.AgentMessage, error)
TurnExecutor runs one turn of the underlying agent loop and returns the messages produced by THAT turn only (not including the input). The runner stitches the produced messages onto its running history for the next turn.
Implementations typically wrap agentcore.AgentLoop. Tests inject a stub. The executor must honour ctx cancellation — a cancelled context means the teammate's lifecycle has been aborted and the executor should return promptly.