« Phase 03 · Warmup · Track Overview

Deep Dive — Mechanism & Internals


Table of Contents


1. The generator-shaped handler

AgentHandler = Callable[[A2AServer, Task, Message], Iterable[StreamEvent]]

An agent author writes one function that yields events. The platform serves it two ways:

def message_stream(...):        # yields as they come
    ...
    for event in self.handler(self, self.tasks[task.task_id], message):
        yield event
    yield self.tasks[task.task_id]

def message_send(...):          # drains and returns the last Task
    for event in self.message_stream(...):
        if isinstance(event, Task): final = event
    return final

Two properties fall out that are worth naming:

send and stream cannot diverge. They are the same code path, so an agent that behaves differently under the two modes is impossible by construction. The test test_send_and_stream_agree asserts it, and in production this is the class of bug where a streaming client sees an artifact the blocking client does not.

The final Task is always the last event. The stream's terminator is the authoritative object, so a client that only cares about the outcome can ignore every intermediate event and take the last one. That is why message_send is a one-line drain rather than a re-implementation.

The handler receives self.tasks[task.task_id] rather than the local task variable, because the stored task is the one the server has been mutating — a subtle aliasing bug if you pass the local.

2. Task creation and the chain

task = Task(
    task_id=self._next_id("task"),
    context_id=context_id or self._next_id("ctx"),
    status=TaskStatus(TaskState.SUBMITTED, timestamp=self._next_tick()),
    history=(message,),
    delegation_chain=caller.delegation_chain + (caller.agent_id,),
)

The last line is the security property of the whole phase. The chain is constructed by the server from the caller's verified context, not read from anything the caller can shape freely. The lab's test_the_callee_cannot_forge_the_chain pins it.

In the lab, CallerContext is a parameter — which is a stand-in for "a verified token." The substitution in Phase 08 is exact: replace the parameter with claims read from a validated JWT, and the same line becomes unforgeable.

context_id or self._next_id("ctx") is how a caller groups tasks: pass the same context_id to put three delegations under one investigation; omit it and the server mints one. Note it is caller-supplied, which is fine — a context id is a correlation key, not a credential, and colliding with someone else's context gains an attacker nothing they could not already see.

Ordering matters in message_stream: check_delegation runs before any task is created. The test test_a_denied_delegation_never_creates_a_task asserts server.tasks == {} after a refusal. A denied delegation that leaves a submitted task behind pollutes every dashboard and gives an attacker a way to enumerate task ids.

3. The lifecycle table, and two deliberate omissions

TASK_TRANSITIONS = {
    SUBMITTED:      {WORKING, REJECTED, CANCELED, FAILED, AUTH_REQUIRED},
    WORKING:        {INPUT_REQUIRED, AUTH_REQUIRED, COMPLETED, CANCELED, FAILED},
    INPUT_REQUIRED: {WORKING, CANCELED, FAILED},
    AUTH_REQUIRED:  {WORKING, CANCELED, FAILED, REJECTED},
}

Two edges are absent on purpose, and both are tested:

SUBMITTED → COMPLETED. A completion with no working state is a completion with no evidence of work. The cost is one status transition; the benefit is that every completed task has a middle in its history, which is what an auditor reads.

INPUT_REQUIRED → REJECTED. Rejection is an admission decision — "I will not take this task." A callee that has already asked a clarifying question has admitted it; abandoning at that point is a FAILED. Allowing the edge would let a callee retroactively claim it never started, which is both dishonest and a metrics problem (rejection rate is a discovery signal; failure rate is an engineering signal, and conflating them hides both).

AUTH_REQUIRED → REJECTED is legal, because that path represents "I asked for a credential and the answer determined I may not do this" — a genuine admission outcome.

4. Admission returns everything

check_delegation returns a List[DelegationDenial], not the first failure. Same reasoning as the Phase 00 admission pipeline: the point is to measure defence in depth, and a caller fixing one problem at a time is a caller making four round trips.

The five checks are independent, which makes them composable and individually testable:

CheckPredicate
DEPTH_EXCEEDEDlen(chain) >= max_depth
CYCLE_DETECTEDcard.name in chain
TENANT_NOT_PERMITTEDcard.tenants and caller.tenant not in card.tenants
CLASSIFICATION_EXCEEDEDrank(card.max) < rank(caller.data)
NO_ACCEPTABLE_AUTHneither oauth2 nor mtls in card.security_schemes

The depth predicate uses >=, so max_depth=4 admits a chain of 3 and refuses a chain of 4 — the new hop would make it 4 deep, and the limit counts hops, not intermediate agents. That boundary is tested both ways because off-by-one on a depth limit is either a loop or a false refusal.

The classification comparison is rank(card) < rank(caller): the card must be cleared at least as high as the data. Written the other way it reads plausibly and inverts the control, which is the kind of bug that survives review because both sides look symmetric.

5. Discovery ranking

overlap = max((len(wanted & set(skill.tags)) for skill in card.skills), default=0)

An agent's score is its best skill's overlap, not the sum. Summing would rank a generalist with six weakly-related skills above a specialist with one exact match — the opposite of what a caller wants. The max makes discovery answer "who is best at this," which is the question.

Ties break on name, so the result is deterministic and diffable. And the caller is excluded outright (card.name == caller.agent_id), which prevents the degenerate self-delegation cycle before the cycle check even runs.

Filtering happens before ranking, exactly as in Phase 02: tenant and classification exclusions are applied while building the candidate list, not afterwards. A card the caller may not use never enters the ranking, so it cannot appear in a truncated result and cannot leak by timing.

6. Push delivery as a side effect of set_status

def set_status(self, task, state, message=None):
    ...
    config = self.push_configs.get(task.task_id)
    if config is not None:
        self.pushed.append((config.url, status))
    return updated

Delivery is attached to the state transition, not to the handler. That means an agent author cannot forget to notify — every status change notifies, or none do. The alternative (the handler calls notify()) produces exactly the bug you would predict: the happy path notifies and the error path does not, so a caller waiting on a webhook hangs forever precisely when something went wrong.

In production self.pushed.append(...) is an HTTP POST with retries, backoff and idempotency on the receiver. The lab records it instead so the test can assert delivery without a network — and recording the (url, status) pair rather than just the status is what lets the test verify the allow-list actually constrained the destination.

set_push_config has four refusals in a deliberate order: unsupported capability, unknown task (via tasks_get), disallowed host, missing token. Checking the capability first means an agent that does not support push never leaks whether a task id exists.

7. The adapter layer and the round-trip proof

Four tables, and their asymmetry is the lesson:

TableInjective?
A2A_STATE_TO_INTERNALyes — 8 states, 8 internal
INTERNAL_TO_A2A_STATEyes — a true inverse, tested by round-tripping every member
ACP_STATUS_TO_INTERNALyes — 6 statuses
INTERNAL_TO_ACP_STATUSno — 8 internal → 6 ACP

The non-injective one collapses rejected → failed and awaiting_auth → awaiting. Both are declared and tested (test_states_with_no_acp_equivalent_degrade_predictably), which is the whole point: a lossy mapping is acceptable, an undeclared one is a defect you discover when someone asks why a rejected task appears as failed in a report.

The round-trip test is A2A → internal → ACP → internal, asserted equal. It catches leakage in both directions:

  • If internal_to_acp drops a field (say delegation_chain), the round trip differs.
  • If acp_to_internal invents one, likewise.
  • The empty-task case (test_an_empty_task_still_converts) catches the naive implementation that always emits a text part, producing output_text == "" on the way back rather than the original absent value.

That last one is why internal_to_acp emits a text part only when output_text is non-empty and a JSON part only when structured is non-empty. Conditional emission is what makes the mapping an involution on the empty case.

8. A traced delegation

client.delegate_streaming(caller, "Screen the beneficiary of PMT-771") with caller.delegation_chain = ("orchestrator",):

#WhereEventState
1message_streamcheck_delegation[]
2message_streamtask created, chain = ("orchestrator", "payments-investigator")submitted
3yieldedTaskStatusUpdate(submitted)submitted
4handlerset_status(WORKING)working
5yieldedTaskStatusUpdate(working)working
6handleradd_artifact(sanctions-screening)working
7yieldedTaskArtifactUpdate(last_chunk=True)working
8handlerset_status(COMPLETED, message)completed
9yieldedTaskStatusUpdate(completed, final=True)completed
10message_streamyield self.tasks[id]completed

The artifact carries two parts:

text: "Acme Trading FZE: 1 possible match (score 0.83) against SDN list"
data: {"matches": 1, "top_score": 0.83, "list": "SDN", "recommendation": "manual_review"}

The calling agent branches on data["recommendation"] deterministically. The text part exists for the human and for the model's narrative. Both are needed and they are not redundant — that duality is what makes agent-to-agent results usable by code and by a model, and it is the detail most implementations get wrong by emitting only prose.

Timestamps come from _next_tick(), a monotonic counter rather than a clock, so two identical runs produce identical tasks (test_two_identical_servers_produce_identical_tasks). In production these are wall-clock times; the lab's counter is what makes the test an equality assertion.

9. Invariants and complexity

Invariants (each tested):

  1. Terminal states accept nothing.
  2. No trap states — every non-terminal state reaches a terminal one.
  3. submitted → completed and input-required → rejected are illegal.
  4. A denied delegation creates no task.
  5. delegation_chain == caller.chain + (caller.agent_id,).
  6. to_json() emits exactly ten keys, none of them platform metadata.
  7. Discovery never returns the caller and is order-independent.
  8. check_delegation returns all applicable denials.
  9. Every TaskState round-trips through the internal vocabulary.
  10. A2A → internal → ACP → internal is identity, including for an empty task.
  11. send and stream agree on final state and artifacts.

Complexity:

OperationCost
advance\( O(1) \)
discover\( O(A \cdot S) \) over agents × skills, plus an \( O(A \log A) \) sort
check_delegation\( O(D) \) in chain depth
message_stream\( O(H) \) in handler events; each set_status is \( O(1) \) amortized
set_status\( O(1) \) — but rebuilds the task tuple, so \( O(H) \) in history length
a2a_to_internal\( O(A \cdot P) \) artifacts × parts
round trip\( O(P) \)

The one to watch is set_status: replace(task, history=task.history + (message,)) copies the history tuple each time, so a task with n status changes does \( O(n^2) \) tuple work. Irrelevant at conversational scale, and the right fix in production is an append-only event log with a small mutable header — the same shape as the session store in Phase 01.