Agentic AI

Workflows And Multi Agent Systems

Agent Communication

When agent A hands work to agent B, B does not know what A knows. Everything B needs must be transferred explicitly.

JrCodex·7 min read

Jr Codex Agentic AI Notes

Level: Advanced Prerequisites: Chapter 3: Multi-Agent Architectures Time to complete: ~20 minutes


Table of Contents

  1. The Context Transfer Problem
  2. Shared State vs Message Passing
  3. Designing a Handoff
  4. The Handoff Contract
  5. Returning Results Upward
  6. Communication Failure Modes
  7. Summary & Next Steps

1. The Context Transfer Problem

When agent A hands work to agent B, B does not know what A knows. Everything B needs must be transferred explicitly.

The Two Ways to Get It Wrong
─────────────────────────────────────────
  UNDER-TRANSFER
    "Research the competitor."
    B does not know WHICH competitor, why, what
    depth is wanted, or what A already found. It
    guesses, and produces something unusable.

  OVER-TRANSFER
    A forwards its entire 30,000-token transcript.
    B's context is now full before it starts, and
    every advantage of separating them is gone.

  The correct amount is a deliberate design
  decision, not a default.
─────────────────────────────────────────
Why This Chapter Exists
─────────────────────────────────────────
  Chapter 3 said the supervisor should see
  summaries. This is the same problem in the other
  direction — and it is where most multi-agent
  systems actually fail.

  Not in the architecture. In the handoffs.
─────────────────────────────────────────

2. Shared State vs Message Passing

The Two Models
─────────────────────────────────────────
  SHARED STATE
    All agents read and write one state object.

    +  no serialisation between agents
    +  naturally consistent
    +  easy to checkpoint (Chapter 2)
    -  every agent can see everything, so context
       isolation is LOST unless you filter on read
    -  concurrent writes need reducers (Module 5,
       Chapter 3)

  MESSAGE PASSING
    Agents exchange explicit messages.

    +  genuine isolation — B sees only what it was
       sent
    +  the interface is explicit and reviewable
    -  more plumbing
    -  state is distributed and harder to snapshot
─────────────────────────────────────────
# The practical hybrid: shared state for coordination, FILTERED views per agent.
class SharedState(TypedDict):
    goal: str
    findings: Annotated[list, operator.add]        # accumulates across agents
    plan: dict
    agent_scratch: dict                            # NAMESPACED, private per agent
 
def view_for(state: SharedState, agent_name: str) -> dict:
    """Each agent gets what it needs — not the whole state."""
    return {
        "goal": state["goal"],
        "findings": [f for f in state["findings"] if f["relevant_to"] == agent_name],
        "my_scratch": state["agent_scratch"].get(agent_name, {}),
    }
The Recommendation
─────────────────────────────────────────
  Store in shared state, so checkpointing and
  observability stay simple.

  READ through a filtered view, so context isolation
  — Chapter 3's main reason for multi-agent —
  survives.

  Giving every agent the full state is the most
  common way to build a multi-agent system that
  costs more than a single agent and performs worse.
─────────────────────────────────────────

3. Designing a Handoff

from pydantic import BaseModel, Field
 
class Handoff(BaseModel):
    """The explicit contract between two agents."""
    to_agent: str
    objective: str = Field(description="ONE concrete outcome, checkable as done")
    context: str = Field(description="What the receiver needs to know. FACTS only, "
                                     "not the sender's reasoning.")
    constraints: list[str] = Field(default_factory=list)
    expected_output: str = Field(description="Shape and detail of the required result")
    already_tried: list[str] = Field(default_factory=list,
                                     description="Approaches that failed, so they are "
                                                 "not repeated")
    budget_steps: int = 8
def make_handoff(client, state, target_agent, subtask) -> Handoff:
    return client.chat.completions.parse(
        model="gpt-4o",
        messages=[{"role": "user", "content":
            f"Write a handoff brief for the {target_agent} agent.\n"
            f"SUBTASK: {subtask}\n"
            f"WHAT WE KNOW: {relevant_findings(state, target_agent)}\n"
            f"WHAT FAILED SO FAR: {state['failures']}\n\n"
            f"Include only what the receiver needs. Do not include your reasoning."}],
        response_format=Handoff, temperature=0,
    ).choices[0].message.parsed
The Field That Earns Its Place
─────────────────────────────────────────
  `already_tried`.

  Without it, agent B independently repeats the
  three searches agent A already ran and that
  already failed. This is the single most common
  waste in multi-agent systems, and it is invisible
  unless you are reading traces.

  It is Module 3's "DO NOT RETRY" scratchpad
  section, crossing an agent boundary.
─────────────────────────────────────────

4. The Handoff Contract

The Five Questions Every Handoff Must Answer
─────────────────────────────────────────
  1. WHAT is the outcome?        objective
  2. WHAT do I need to know?     context
  3. WHAT must I not do?         constraints
  4. WHAT shape is the answer?   expected_output
  5. WHAT is already ruled out?  already_tried

  A handoff missing any of these produces
  predictable failures — and you can diagnose a bad
  multi-agent run by checking which question the
  handoff failed to answer.
─────────────────────────────────────────
The Test
─────────────────────────────────────────
  "Could a competent colleague who has never seen
   this task do the work from this brief alone?"

  If not, the handoff is under-specified. If the
  brief is longer than the work, you should not have
  delegated it.
─────────────────────────────────────────

5. Returning Results Upward

The return path needs as much design as the outbound one.

class AgentResult(BaseModel):
    status: Literal["completed", "partial", "blocked", "failed"]
    findings: list[str] = Field(description="Facts discovered. Each independently useful.")
    summary: str = Field(description="Under 150 words. What the supervisor needs.")
    confidence: Literal["high", "medium", "low"]
    sources: list[str] = Field(description="Tool calls or documents supporting each finding")
    blocked_by: str | None = None
    cost_cents: float = 0.0
Why status Has Four Values
─────────────────────────────────────────
  A boolean success flag loses the distinction that
  matters most.

  "partial" tells the supervisor to use what came
  back AND route the remainder elsewhere.
  "blocked" tells it that retrying will not help and
  something must change.
  "failed" tells it to try a different approach.

  Collapsing these into success/failure makes the
  supervisor's next decision a guess.
─────────────────────────────────────────
Confidence Must Propagate
─────────────────────────────────────────
  A low-confidence finding that reaches the
  supervisor as a bare statement becomes a
  high-confidence input to the final answer.

  Uncertainty laundering through summarisation is a
  quiet, serious failure mode — the system as a
  whole ends up more confident than any of its
  parts.

  Carry `confidence` through every layer, and have
  the synthesiser state it.
─────────────────────────────────────────

6. Communication Failure Modes

The Five
─────────────────────────────────────────
  TELEPHONE          each summarisation drops
                     nuance; by layer three the
                     finding has changed meaning.
                     Fix: keep hierarchies shallow
                     (Chapter 3), carry sources.

  UNCERTAINTY        "possibly X" becomes "X".
  LAUNDERING         Fix: propagate confidence as a
                     structured field.

  DUPLICATE WORK     two agents research the same
                     thing. Fix: `already_tried`,
                     and shared findings.

  ORPHANED           A delegates, B fails silently,
  SUBTASKS           nobody notices. Fix: every
                     handoff must return an
                     AgentResult; treat a missing
                     one as a failure.

  CONTEXT            handoffs grow until every agent
  BLOAT              receives everything. Fix: cap
                     handoff size and enforce it.
─────────────────────────────────────────
def validate_handoff(h: Handoff, enc, max_tokens=1_500) -> Handoff:
    """Enforce the cap, rather than trusting the sender to be concise."""
    size = len(enc.encode(h.context))
    if size > max_tokens:
        h.context = compress_to(h.context, max_tokens, enc)
        log.warning("handoff to %s truncated from %d tokens", h.to_agent, size)
    if not h.expected_output:
        raise ValueError("handoff missing expected_output")     # question 4 unanswered
    return h

7. Summary & Next Steps

Key Takeaways

  • Multi-agent systems usually fail at the handoffs rather than the architecture: under-transfer produces guessing, over-transfer destroys the context isolation that motivated the split.
  • Store coordination data in shared state for checkpointing, but read through per-agent filtered views so isolation survives.
  • Every handoff must answer five questions — objective, context, constraints, expected output, and what is already ruled out — and already_tried prevents the most common waste.
  • Results need a four-value status and a propagated confidence field; collapsing either makes the supervisor's next decision a guess and launders uncertainty into false confidence.

Concept Check

  1. Why is giving every agent the full shared state a self-defeating design?
  2. What does already_tried prevent, and which single-agent mechanism is it the cross-agent version of?
  3. Explain uncertainty laundering and why a boolean success flag contributes to it.

Next Chapter

Chapter 5: Multi-Agent Failure Modes


Jr Codex — 1-on-1 Personalized Coaching | Back to Module Index | Back to Agentic AI Index