team

package
v1.6.11 Latest Latest
Warning

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

Go to latest
Published: Jun 4, 2026 License: Apache-2.0 Imports: 8 Imported by: 0

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

View Source
const TeamLeadName = "team-lead"

TeamLeadName is the canonical name of the team coordinator. Reserved — teammates may not be created with this name.

Variables

View Source
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.

View Source
var ErrClosed = errors.New("team: mailbox closed")

ErrClosed is returned by Send and Wait once the mailbox is closed.

View Source
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

func IdentityFromContext(ctx context.Context) *task.Identity

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

func IsTeammate(ctx context.Context) bool

IsTeammate reports whether ctx is inside a teammate goroutine.

func Run

func Run(ctx context.Context, cfg RunConfig) error

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:

  1. Mark Entry running + non-idle.
  2. Execute one turn with: prior history + new user prompt.
  3. Append produced messages to history.
  4. Mark Entry idle + (optionally) forward Protocol.EncodeIdle output to the leader's mailbox so the leader can react to the teammate's turn.
  5. Wait on our mailbox for the next message (or ctx cancellation).
  6. 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

func WithIdentity(parent context.Context, id *task.Identity) context.Context

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

type Context struct {
	Name        string
	Description string
	LeaderName  string
	CreatedAt   time.Time
}

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 NewMailbox

func NewMailbox() *Mailbox

NewMailbox creates an empty mailbox ready for use.

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

func (m *Mailbox) Drain() []Message

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) Len

func (m *Mailbox) Len() int

Len reports the current queue depth. Mainly for tests and diagnostics.

func (*Mailbox) Send

func (m *Mailbox) Send(msg Message) error

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

func (m *Mailbox) Wait(ctx context.Context) error

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

func (m *Mailbox) WaitFor(ctx context.Context, timeout time.Duration) error

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

type Message struct {
	From      string
	Text      string
	Color     string
	Summary   string
	Timestamp time.Time
}

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

func (r *Registry) AgentNames() []string

AgentNames returns the registered teammate names (including the leader) in stable alphabetical order. Used for broadcasts and listings.

func (*Registry) CreateTeam

func (r *Registry) CreateTeam(name, description, leaderTaskID string) error

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

func (r *Registry) DeleteTeam() error

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) HasTeam

func (r *Registry) HasTeam() bool

HasTeam reports whether a team is currently active.

func (*Registry) Mailbox

func (r *Registry) Mailbox(name string) *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

func (r *Registry) RegisterAgent(name, taskID string) error

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

func (r *Registry) RenameTeam(newName, newDescription string) error

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

func (r *Registry) TaskID(name string) (string, bool)

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) Team

func (r *Registry) Team() *Context

Team returns a snapshot of the active team, or nil if none.

func (*Registry) TeammateNames

func (r *Registry) TeammateNames() []string

TeammateNames returns registered teammate names excluding the leader. Used by broadcast logic (leader sending to "*" should not echo to self).

func (*Registry) UnregisterAgent

func (r *Registry) UnregisterAgent(name string) error

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

type SpawnResult struct {
	TaskID  string
	AgentID string // name@team
}

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.

Jump to

Keyboard shortcuts

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