Repository navigation
A2A Capstone · Aurora Grid: an outage desk built from independent agents #37
akash-coded
started this conversation in
Hands-on Labs
Replies: 1 comment
|
Stage2: A-2038@L10469 MINGW64 /d/projects/Learning |
0 replies
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
Exercise: Aurora Grid, an A2A Outage Desk
Language: Python 3.10+, Bash
Topics: A2A protocol, Agent Card, JSON-RPC 2.0, tasks and messages, agent discovery and registries, Strands / LangGraph / LangChain
Level: Intermediate
The case
A storm has knocked out power across three feeder segments. Aurora Grid runs an outage desk with three specialists who already exist, already work, and were built by three different teams in three different frameworks.
Nobody is rewriting anyone else's agent. That constraint is the entire reason A2A exists.
Part 0: three pictures before any code
Picture 1: which layer you are working on
flowchart TB subgraph L3["Framework layer: how ONE agent thinks"] S["Strands<br/>tool loop"] G["LangGraph<br/>state machine"] C["LangChain<br/>LCEL chain"] end subgraph L2["Protocol layer: how agents ASK each other"] A["A2A<br/>Agent Card, Message, Task, Artifact"] end subgraph L1["Transport layer: how bytes MOVE"] J["JSON-RPC 2.0 over HTTP<br/>also gRPC, also HTTP+JSON"] end S --> A G --> A C --> A A --> JMental model. The framework is the chef. A2A is the menu and the order slip. JSON-RPC is the waiter carrying it.
You can fire the chef and reopen tomorrow with a new one. The menu still works, because the menu never described the kitchen.
The sibling question. MCP is what is in your hands. A2A is who is in the room. MCP wires one agent down to tools and data. A2A wires one agent across to another agent that has its own model, its own memory, and its own deploy schedule.
Picture 2: what you are building
flowchart LR CO["Coordinator<br/>(script, no agent logic)"] RG["Registry :9100<br/>ASK by tag"] L["Fault Locator :9101<br/>Strands"] D["Crew Dispatcher :9102<br/>LangGraph"] N["Customer Notifier :9103<br/>LangChain"] CO -->|"GET /agents?tag="| RG RG -.->|"KNOCK /.well-known/agent-card.json"| L RG -.->|KNOCK| D RG -.->|KNOCK| N CO -->|"JSON-RPC message/send"| L CO -->|"JSON-RPC message/send"| D CO -->|"JSON-RPC message/send"| NFour processes, four terminals. That is not an inconvenience to work around. A2A agents are independent deployables, and folding them into one process would hide the only thing this exercise teaches.
Picture 3: the Agent Card as four questions
Eight fields are required by the spec. They answer four questions.
name,description,versionurl,preferredTransporturlreachable only from your own laptopskills[]withid,name,description,tagstags, which makes you invisible to a registrycapabilities,defaultInputModes,defaultOutputModes,securitySchemesstreaming: trueand never streamingMental model. A card is a passport, not a manual. It states what you may ask for. It never states how the work gets done.
Setup
One version fact that will bite you
pip install a2a-sdkon its own gives you 1.1.2, which implements A2A spec 1.0 and replaced every Pydantic type with protobuf.strands-agents 1.42.0pinsa2a-sdk<0.4.0, so the install above correctly resolves to 0.3.26.AgentCarda2a_pb2.AgentCardA2AStarletteApplication(...).build()create_jsonrpc_routes(...)pluscreate_agent_card_routes(...)a2a.server.appsWhen a blog post does not match your code, check the version before you check yourself.
Files you will create
aurora_common.pylab1_agents.pylab2_serve.pylab3_registry.pylab4_coordinator.pylab5_wire.shlab6_debug.pyEach
# ____marker is one missing line. Directly above it sits a lettered bank. Exactly one option is right.aurora_common.pyCopy this verbatim. No blanks. Read it once, because every other file imports from it.
Stage 0: read, do not type
No code runs in this stage.
Q0.1 Trace the round trip
sequenceDiagram participant C as Coordinator participant D as Crew Dispatcher :9102 C->>D: GET /.well-known/agent-card.json D-->>C: 200, AgentCard JSON C->>D: POST / {"method":"message/send"} D-->>C: 200, result.kind = "task", state = "input-required" C->>D: POST / {"method":"message/send", taskId, contextId} D-->>C: 200, result.kind = "task", state = "completed"One line each:
message/sendcarry ataskIdwhen the first one did not?taskId, what HTTP status comes back?Q0.2 Spot the wrong arrow
One edge below is wrong. Name it and say what it should be.
flowchart LR A["Coordinator"] -->|1. message/send| B["Registry :9100"] B -->|2. returns matching cards| A A -->|3. message/send| C["Crew Dispatcher :9102"]Q0.3 Message or Task
For each request, decide whether the agent should reply with a bare
Messageor open aTask. State the rule you used.Stage 1: three agents, zero protocol
File:
lab1_agents.py. Blanks 1 to 5.Nothing in this file knows what A2A is. Stage 2 wraps these exact three objects without editing their logic.
Agent(model, tools)StateGraph, two nodesprompt | model | parserCheckpoint
Run it. Expected in mock mode:
If
traceprints one entry instead of two, blank 3 is wrong. Nothing raised. Work out what happened to the first write before you look at anything else.Stage 2: wrap them in A2A
File:
lab2_serve.py. Blanks 6 to 11.Two ways to become an A2A server
flowchart TB subgraph P1["Path A: the framework ships it"] SA["Strands Agent"] --> AS["A2AServer(agent=...)"] --> SRV1["HTTP server, card, task store, all of it"] end subgraph P2["Path B: you write 20 lines"] ANY["Anything callable<br/>graph, chain, plain function"] --> EX["AgentExecutor.execute()"] EX --> DRH["DefaultRequestHandler"] CARD["AgentCard you hand write"] --> APP["A2AStarletteApplication"] DRH --> APP --> SRV2["HTTP server"] endPath B is the one that matters. It proves A2A has no opinion about what sits behind the executor. The Customer Notifier has no agent loop at all, and the protocol cannot tell.
The Task state machine
stateDiagram-v2 [*] --> submitted submitted --> working working --> input_required: needs a fact from the caller working --> auth_required: needs a credential input_required --> working: caller replies WITH taskId auth_required --> working working --> completed working --> failed working --> canceled submitted --> rejected: refused up front completed --> [*] failed --> [*] canceled --> [*] rejected --> [*]Four ways out, two ways to wait.
completedcanceledfailedrejectedinput-requiredauth-requiredThe gap between
failedandinput-requiredis the single most common design mistake in this stage. A missing feeder ID is not a failure. It is a question.The executor shape, memorised
Checkpoint
curl -s localhost:9101/.well-known/agent-card.json | python -m json.toolConfirm three things:
protocolVersionis0.3.0,skills[0].tagsis not empty,urlmatches the port you started.Stage 3: the door the spec refuses to build
File:
lab3_registry.py. Blanks 12 to 14.The A2A spec names three discovery strategies and standardises exactly one of them.
flowchart TB Q["I need an agent"] --> D1{"Do I know its domain?"} D1 -->|yes| K["KNOCK<br/>GET /.well-known/agent-card.json<br/>STANDARDISED, RFC 8615"] D1 -->|no| D2{"Do I know the capability?"} D2 -->|yes| A["ASK<br/>query a registry by skill tag<br/>NOT standardised, you build it"] D2 -->|no| DI["DIAL<br/>hardcoded URL, env var, config file<br/>nothing to standardise"]KNOCK, ASK, DIAL. Three doors, one acronym, and only the first has a spec behind it.
Notice the recursion in the file below: the registry populates itself by KNOCKing on a DIALed seed list. Someone always has to know the first address. There is no discovery without a bootstrap.
Why the spec left this open. It says registries return cards "based on various criteria" and stops. No endpoint, no query grammar, no registration call. Enterprises need selective disclosure, signed cards, and approval workflows that no single API shape survives.
The practical consequence: your registry is your governance surface. Skill attestation, prompt injection scanning, tenant scoping, and card signature verification all live here, because there is nowhere else for them to live.
Checkpoint
Now kill the notifier process and hit
/refreshagain. It should appear underunreachableand vanish from/agents. A registry that never re-KNOCKs is a list of ghosts.Stage 4: the coordinator
File:
lab4_coordinator.py. Blanks 15 to 18.The coordinator has no model, no tools, and no prompt. It is pure protocol.
sequenceDiagram autonumber participant CO as Coordinator participant RG as Registry participant L as Locator participant D as Dispatcher participant N as Notifier CO->>RG: GET /agents?tag=locate RG-->>CO: Fault Locator card CO->>L: message/send "Lights out on Maple Street" L-->>CO: Task completed, artifact "feeder F-114" CO->>RG: GET /agents?tag=dispatch RG-->>CO: Crew Dispatcher card CO->>D: message/send "Send a crew, it is dark" D-->>CO: Task input-required, "Which feeder segment?" CO->>D: message/send + taskId + contextId D-->>CO: Task completed, artifact "Crew ALPHA-2, P1, ETA 45" CO->>RG: GET /agents?tag=comms RG-->>CO: Customer Notifier card CO->>N: message/send N-->>CO: Task completed, artifact SMSWhere the answer lives
completedtask.artifacts[].parts[].root.textinput-requiredtask.status.message.parts[].root.textTwo places. A client that checks only one prints nothing on half its traffic.
Checkpoint
If the resumed turn prints
NEW task - context lost, blank 17 is wrong.Stage 5: the wire
File:
lab5_wire.sh. No blanks. Trace it.Everything the SDKs did for you in Stages 2 to 4 was this, typed on your behalf.
JSON-RPC 2.0, complete
Out: four keys. Never more, never fewer.
{ "jsonrpc": "2.0", "id": "wire-1", "method": "message/send", "params": { "message": { "kind": "message", "messageId": "m-1", "role": "user", "parts": [{"kind": "text", "text": "..."}] } } }Back:
jsonrpc,id, and exactly one ofresultorerror. Never both.Mental model. Every envelope carries a stamp (
jsonrpc), a return address (id), a verb (method), and a parcel (params).The ten methods, grouped
message/send,message/streamtasks/get,tasks/cancel,tasks/resubscribetasks/pushNotificationConfig/set/get/list/deleteagent/getAuthenticatedExtendedCardOne mismatch worth memorising: the JSON-RPC method is
agent/getAuthenticatedExtendedCard, the HTTP path is/agent/authenticatedExtendedCard. Not the same string.The error code split
flowchart LR E["error.code"] --> B1{"which band?"} B1 -->|"-32700 to -32603"| T["The ENVELOPE was wrong.<br/>Parse, schema, unknown method, bad params.<br/>JSON-RPC's own codes."] B1 -->|"-32001 to -32007"| A["The envelope was FINE.<br/>The ask was wrong.<br/>A2A's own codes."]jsonrpckey"method": "message/sned"messagewith nopartstasks/geton an id never issuedtasks/cancelon a task in a terminal statepushNotifications: falsesupportsAuthenticatedExtendedCardis absentTrace questions
-32002with a reason attached. Which task states can you cancel from?Stage 6: four bugs that raise nothing
File:
lab6_debug.py.Every bug below returns HTTP 200 and a well formed JSON-RPC result. None throws. That is why they pass review and die in production. Each has a one line fix.
The fifth bug, which you will hit for real
Strands
A2AServerdefaults to port 9000. AgentCore Runtime is required to bind 9000. Start both and the second dies withaddress already in use, or worse, the first silently takes the traffic.This exercise uses 9100 to 9103 for exactly that reason. When you diagnose it live,
lsof -i :9000 -sTCP:LISTENis the correct incantation. Without-sTCP:LISTENyou will match your own process and kill your own kernel.Checkpoint: 16 questions
Q1 (MCQ) Which statement about the Agent Card is true?
agent/getCardmessage/sendresponseQ2 (multi-select) Which of these are required fields on an
AgentCard? Choose all.nameproviderskillssecuritySchemesdefaultInputModesiconUrlurlQ3 (code reading MCQ) What does this print?
['a', 'b']['b']['a'][]Q4 (true or false) Mark each.
input-requiredis a terminal task statecontextIdcan span severaltaskIdvaluesmessage/sendwith a bareMessageand no task-32601means the agent understood the method and declined itQ5 (matching) Match the code to the failure. Two entries on the right are distractors.
enable_a2a_compliant_streamingleft at default[]foreverAgentSkill(..., tags=[])task.historygains one entry per stream chunktask_id" ".join(...)over artifact parts-32602 Invalid parametersQ6 (ordering) Put the executor calls in the order a first turn that needs more input would make them.
await updater.start_work()await updater.requires_input(...)await updater.submit()context.get_user_input()Q7 (ordering) Same executor, a second turn that completes. Which one line from Q6 must not run again, and why?
Q8 (trace a flow) Using the Stage 4 sequence diagram, how many HTTP requests does the coordinator make for one full outage, counting registry calls?
Q9 (pick the correct diagram) Which state transition is legal?
completed --> workinginput-required --> workingrejected --> submittedcanceled --> completedQ10 (predict output) The dispatcher is asked "pole down on Mill Road, feeder F-333". Give
priority,crew, andETA.Q11 (code review) Review this executor. Name two defects.
Q12 (debugging) One line is wrong. Give the line and its one line fix.
Q13 (debugging) One line is wrong. Give the line and its one line fix.
Q14 (scenario) Aurora Grid acquires a second utility. Its Crew Dispatcher runs in a different data centre, behind a load balancer that strips path prefixes, and it is written in Java.
Q15 (scenario) The Customer Notifier starts taking 90 seconds under storm load. Polling
tasks/getevery second from 40 coordinators is melting it.Q16 (write code) Write the smallest
AgentCardthat a registry filtering ontag="restore"will match, for an agent namedRestoration Verifieron port 9104. Ten lines or fewer.Where this fails
An honest list. None of it is solved by this exercise.
tagsare marketing copy until someone verifies themAgentCardSignatureplus an issuer allowlist held by the registryurlis a promise, not a facturlas a hint-32603 Internal errortells you nothing about which of five downstream agents broketask.historylength in CI, not just on the final textinput-requiredhas no timeoutOne page summary
GET /.well-known/agent-card.jsonrole,parts[],messageIdmessage/sendid,contextId,status.state,history[]tasks/get,tasks/cancelname,parts[]appendedjsonrpc,id,method,paramsurlThree doors: KNOCK a domain, ASK a registry, DIAL a config.
Four exits: completed, canceled, failed, rejected.
Two waits: input-required, auth-required.
Two error bands: -326xx is the envelope, -320xx is the ask.
All reactions