« Phase 03 · Warmup · Track Overview
Deep Dive — Mechanism & Internals
Table of Contents
- 1. The generator-shaped handler
- 2. Task creation and the chain
- 3. The lifecycle table, and two deliberate omissions
- 4. Admission returns everything
- 5. Discovery ranking
- 6. Push delivery as a side effect of set_status
- 7. The adapter layer and the round-trip proof
- 8. A traced delegation
- 9. Invariants and complexity
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:
| Check | Predicate |
|---|---|
DEPTH_EXCEEDED | len(chain) >= max_depth |
CYCLE_DETECTED | card.name in chain |
TENANT_NOT_PERMITTED | card.tenants and caller.tenant not in card.tenants |
CLASSIFICATION_EXCEEDED | rank(card.max) < rank(caller.data) |
NO_ACCEPTABLE_AUTH | neither 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:
| Table | Injective? |
|---|---|
A2A_STATE_TO_INTERNAL | yes — 8 states, 8 internal |
INTERNAL_TO_A2A_STATE | yes — a true inverse, tested by round-tripping every member |
ACP_STATUS_TO_INTERNAL | yes — 6 statuses |
INTERNAL_TO_ACP_STATUS | no — 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_acpdrops a field (saydelegation_chain), the round trip differs. - If
acp_to_internalinvents one, likewise. - The empty-task case (
test_an_empty_task_still_converts) catches the naive implementation that always emits a text part, producingoutput_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",):
| # | Where | Event | State |
|---|---|---|---|
| 1 | message_stream | check_delegation → [] | — |
| 2 | message_stream | task created, chain = ("orchestrator", "payments-investigator") | submitted |
| 3 | yielded | TaskStatusUpdate(submitted) | submitted |
| 4 | handler | set_status(WORKING) | working |
| 5 | yielded | TaskStatusUpdate(working) | working |
| 6 | handler | add_artifact(sanctions-screening) | working |
| 7 | yielded | TaskArtifactUpdate(last_chunk=True) | working |
| 8 | handler | set_status(COMPLETED, message) | completed |
| 9 | yielded | TaskStatusUpdate(completed, final=True) | completed |
| 10 | message_stream | yield 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):
- Terminal states accept nothing.
- No trap states — every non-terminal state reaches a terminal one.
submitted → completedandinput-required → rejectedare illegal.- A denied delegation creates no task.
delegation_chain == caller.chain + (caller.agent_id,).to_json()emits exactly ten keys, none of them platform metadata.- Discovery never returns the caller and is order-independent.
check_delegationreturns all applicable denials.- Every
TaskStateround-trips through the internal vocabulary. A2A → internal → ACP → internalis identity, including for an empty task.sendandstreamagree on final state and artifacts.
Complexity:
| Operation | Cost |
|---|---|
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.