Multi-Agent Orchestration: Designing Teams and Delegation in Code
Hang 40 tools off one agent and inflate its system prompt to 3,000 lines, and past a certain point adding more makes it worse. Tool selection wobbles, instructions get skipped, and the longer the context grows the more of the beginning it forgets. The answer that surfaces here is "split the agent up."
But the moment you split, new problems appear. Who assigns work to whom? How do results come back? How do you stop infinite delegation? This article is about that plumbing.
First — When You Should Not Split
Let's start backwards. Multi-agent isn't free. Every delegation adds at least two more LLM calls, and information leaks as context crosses a process boundary. If any of the following applies, just keep one agent.
- Work that finishes with a handful of tools — splitting only adds latency
- Work needing fine-grained shared context — what fits in a delegation message is less than you think
- A prompt that's merely long — that's a prompt-editing problem, not an agent-count problem
Splitting usually earns its cost when the expertise genuinely differs (research vs code review), the work parallelizes (analyze ten files at once), or permissions need separating (an agent that can place orders and one that can't).
Capability Tiers — What Is This Agent Allowed to Do?
Give every agent "call anyone you like" and control becomes impossible. So split collaboration capability into tiers.
type OrchestrationMode string
const (
// Can only spawn copies of itself
ModeSpawn OrchestrationMode = "spawn"
// Can delegate to explicitly linked agents
ModeDelegate OrchestrationMode = "delegate"
// All of the above, plus the shared task board
ModeTeam OrchestrationMode = "team"
)
The important part is that nobody configures this by hand. It's derived from relationships.
// Priority: team > delegate > spawn
func resolveMode(ctx context.Context, agentID uuid.UUID) OrchestrationMode {
if team, _ := teamStore.GetTeamForAgent(ctx, agentID); team != nil {
return ModeTeam // belongs to a team → team mode
}
if links, _ := linkStore.ListOutbound(ctx, agentID); len(links) > 0 {
return ModeDelegate // has outbound links → delegate mode
}
return ModeSpawn // otherwise, self-clone only
}
And based on the mode, hide the tools entirely. If you expose a tool the agent lacks permission for and reject it at execution time, the LLM keeps retrying it and burns turns. Making it invisible up front is far cleaner.
func denyTools(mode OrchestrationMode) map[string]bool {
switch mode {
case ModeSpawn:
return map[string]bool{"delegate": true, "team_tasks": true}
case ModeDelegate:
return map[string]bool{"team_tasks": true}
default: // ModeTeam
return nil
}
}
Delegation — Sync or Async?
The delegation tool's interface is surprisingly small. To whom, what work, and do you wait?
{
"name": "delegate",
"parameters": {
"agent_key": { "type": "string", "description": "delegation target" },
"task": { "type": "string", "description": "work to hand off" },
"mode": { "type": "string", "enum": ["async", "sync"] }
}
}
- async (default) — fire and forget. The delegating agent moves on immediately and receives the result later as a notification. Right for parallel work
- sync — wait for the result. Use it only when the next step depends on that result
There's a reason the default must be async. Sync delegations stack like a call stack. A waits on B, B waits on C, and all three are parked holding LLM context. Cost and latency multiply. Reach for sync only when the reason to wait is explicit.
A Shared Task Board — Making Delegation Traceable
With delegation alone, nobody knows what work is in flight. So add a board. The lead creates work, members pick it up, results attach on completion.
type Task struct {
ID uuid.UUID
TeamID uuid.UUID
Subject string
Assignee string // agent key of the owner
Status string // pending / in_progress / done / failed
Result string // summary on completion
BlockedBy []uuid.UUID // prerequisite tasks
}
Here's a rule I'd push hard for: reject any delegation that isn't linked to a task on the board.
func (d *DelegateTool) Execute(ctx context.Context, args map[string]any) *Result {
taskID, _ := args["team_task_id"].(string)
if isTeamMode(ctx) && taskID == "" {
return Fail("team mode requires team_task_id on every delegation")
}
// ... run the delegation; auto-close the linked task on completion
}
Without enforcement the LLM takes the easy path—skip the board, delegate directly. Then days later there's no way to trace where a result came from. Auto-completing the linked task when the delegation finishes keeps the record while removing the manual bookkeeping.
Stopping Runaway Recursion — Depth and Concurrency
The most expensive multi-agent incident is runaway recursion. A delegates to B, B delegates back to A, and an infinite loop runs up the bill. Defend in two layers.
const maxDepth = 3
func (o *Orchestrator) Delegate(ctx context.Context, req Request) error {
// ① depth limit — length of the delegation chain
if req.Depth >= maxDepth {
return fmt.Errorf("delegation depth exceeded (max=%d)", maxDepth)
}
// ② concurrency limit — simultaneous runs per link
if o.running(req.LinkID) >= req.MaxConcurrent {
return errors.New("concurrent delegation limit exceeded")
}
req.Depth++
return o.dispatch(ctx, req)
}
Depth has to propagate inside the delegation request. If each agent only knows its own depth, nobody can see the whole chain. Blocking cyclic links (A→B→A) at link-creation time is safer still.
Collecting Results — Return Them in Batches
When five members finish in parallel, the lead gets five notifications. Spinning an LLM turn for each is waste. Collect them in a short window and deliver once instead.
type BatchQueue[T any] struct {
mu sync.Mutex
items []T
window time.Duration // e.g. 2 seconds
flushFn func([]T)
}
func (q *BatchQueue[T]) Add(item T) {
q.mu.Lock()
defer q.mu.Unlock()
q.items = append(q.items, item)
if len(q.items) == 1 {
time.AfterFunc(q.window, q.flush) // start the timer on the first item only
}
}
One two-second window drops the lead's LLM calls from five to one, and lets the lead compare all five results inside a single context. Synthesis quality goes up along with it.
Summary
- Before splitting, check whether splitting earns its cost — without differing expertise, parallelism, or permission separation, stay with one agent
- Split collaboration capability into tiers (spawn / delegate / team) and resolve them automatically from relationships
- Don't reject unauthorized tools — hide them outright
- Default delegation to async — sync stacks up and multiplies cost
- Reject board-less delegations and auto-close tasks on completion; traceability comes free
- Stop runaway recursion with depth and concurrency limits, propagating depth inside the request
- Batch parallel results in a short window — fewer calls and better synthesis
The hard part of multi-agent isn't the LLM—it's the plumbing. Who can do what, where failures go, when things stop. Nail none of that down in code and even the smartest model leaves you with an unpredictable system.
댓글
댓글 쓰기