Skip to content

feat(a2a): send a2a requests to remote aura agents (hub and spoke) - #703

Open
ericlake wants to merge 19 commits into
nightlyfrom
a2a-sender
Open

ericlake wants to merge 19 commits into
nightlyfrom
a2a-sender

Conversation

@ericlake

@ericlake ericlake commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

Summary

AURA could receive A2A requests but not send them. This adds the sending half so a hub AURA can delegate to spoke AURAs, the missing piece for the hub-and-spoke rollout design.

  • [a2a.remote.<name>] config (aura-config): url, description, optional model (sent as x-aura-model), static headers, headers_from_request, and per-remote poll_interval_secs / timeout_secs overrides of the [a2a] defaults; [a2a].max_response_bytes (default 64 KiB) caps what a remote answer may put in the model's context. Validated at load with the client's own URL and header parsers; x-aura-model, a2a-version, host, content-length, and content-type are reserved and cannot be set or mapped from the request; URLs with credentials, a query string, or a fragment are refused.
  • ask_agent tool (aura::a2a): one rig tool over every configured remote. It calls SendMessage on the remote's v1.0 JSON-RPC binding, polls GetTask until the task settles, and returns the final artifact text as a JSON outcome with task_id / context_id so the model can continue the exchange. Failed, rejected, and canceled tasks use the existing Tool returned an error: convention.
  • Cancellation and budgets: bound to the request's RequestCancellation token and to timeout_secs; either sends a best-effort CancelTask to the spoke, including when cancellation lands while SendMessage is still in flight. If the call's future is dropped instead (an orchestration per-call timeout), a drop guard cancels the open remote task in the background. GetTask must answer for the task that was opened; a transient poll failure (transport error, 5xx, 429) is retried twice.
  • Client hygiene: redirects are never followed (a 3xx is an error, so a configured API key header cannot travel to another host); response bodies are capped at 8 MiB; the protocol headers are inserted, not appended, so exactly one x-aura-model reaches the wire.
  • Registration: next to the MCP tools in add_all_tools, through the same wrapper chain (an MCP tool also named ask_agent fails the build instead of silently shadowing). A single agent gets every remote. An orchestration worker gets ask_agent only when [orchestration.worker.<name>].remotes names remotes, and its tool is built over exactly that subset, so it can address only those; a worker that names none gets no ask_agent, like vector_stores opts a worker into RAG. mcp_filter governs MCP tools only. The coordinator lists ask_agent under a worker exactly when that worker will have it, with a per-worker remote listing in full visibility mode, derived from config without building clients. A worker without its own mcp_filter inherits the base filter in planning and in the scratchpad gate exactly as the runtime does. headers_from_request resolves per request exactly as for MCP.
  • Dependency choice: only the lightweight a2a-lf types crate plus the workspace reqwest. The upstream a2a-client-lf crate was not adopted because it pulls tonic/prost via a2a-pb into every consumer of the core crate (including the CLI). The a2a pins move to the workspace table and are by commit, not tag.

Local testing (hub + two spokes, no Docker)

Nothing below is checked in; it's the harness the test plan was run with. Three configs, three processes, all on localhost. Spoke B loads a directory of two agents so the hub's model = "release-verifier" selection through x-aura-model is exercised; marker tokens in the system prompts make the results checkable without depending on exact wording.

hub.toml
[agent]
name = "Hub"
alias = "hub"
system_prompt = """
You are HUB, the coordinating agent in a hub-and-spoke AURA deployment.
You have exactly one tool, ask_agent, which sends a request to a remote
spoke agent and returns that agent's answer.

CRITICAL RULES (must follow):
1. Whenever the user mentions a spoke (spoke_a or spoke_b) or asks anything
   about an environment, call ask_agent immediately. Write a self-contained
   prompt: the spoke cannot see this conversation.
2. When ask_agent returns, reply with the spoke's response text verbatim,
   including any marker tokens it contains, then stop.
3. Never guess or invent what a spoke would say. If ask_agent fails, report
   the error text you received.
"""
turn_depth = 4

[agent.llm]
provider = "openai"
api_key = "{{ env.OPENAI_API_KEY }}"
model = "gpt-5.1"
temperature = 0.0

[a2a]
poll_interval_secs = 1
timeout_secs = 120

[a2a.remote.spoke_a]
url = "{{ env.SPOKE_A_URL | default: 'http://127.0.0.1:8081' }}"
description = "The assistant agent running in environment A. Ask it about environment A or about itself."

[a2a.remote.spoke_a.headers]
x-hub-token = "{{ env.HUB_SPOKE_TOKEN | default: 'dev-hub-token' }}"

[a2a.remote.spoke_a.headers_from_request]
"x-request-id" = "x-request-id"

[a2a.remote.spoke_b]
url = "{{ env.SPOKE_B_URL | default: 'http://127.0.0.1:8082' }}"
# Selects the release-verifier agent config on spoke B (x-aura-model).
model = "release-verifier"
description = "The release verifier running in environment B. Ask it to verify a release or to identify itself."
spoke-a.toml
[agent]
name = "Spoke A Assistant"
alias = "spoke-a"
system_prompt = """
You are SPOKE-A-ASSISTANT, the assistant agent deployed in environment A.
You are reached by a hub agent over the A2A protocol.

CRITICAL RULES (must follow):
1. Every reply must begin with the exact token SPOKE-A-ASSISTANT.
2. When asked who or what you are, reply exactly:
   SPOKE-A-ASSISTANT: I am spoke-a, the assistant agent in environment A.
3. Answer other questions briefly, in one or two sentences, after the token.
"""
turn_depth = 2

[agent.llm]
provider = "openai"
api_key = "{{ env.OPENAI_API_KEY }}"
model = "gpt-5.1"
temperature = 0.0
spoke-b/assistant.toml
[agent]
name = "Spoke B Assistant"
alias = "spoke-b"
system_prompt = """
You are SPOKE-B-ASSISTANT, the assistant agent deployed in environment B.

CRITICAL RULES (must follow):
1. Every reply must begin with the exact token SPOKE-B-ASSISTANT.
2. When asked who or what you are, reply exactly:
   SPOKE-B-ASSISTANT: I am spoke-b, the assistant agent in environment B.
3. Answer other questions briefly, in one or two sentences, after the token.
"""
turn_depth = 2

[agent.llm]
provider = "openai"
api_key = "{{ env.OPENAI_API_KEY }}"
model = "gpt-5.1"
temperature = 0.0
spoke-b/verifier.toml
[agent]
name = "Spoke B Release Verifier"
alias = "release-verifier"
system_prompt = """
You are SPOKE-B-VERIFIER, the release verifier deployed in environment B.
You are reached by a hub agent over the A2A protocol.

CRITICAL RULES (must follow):
1. Every reply must begin with the exact token SPOKE-B-VERIFIER.
2. When asked who or what you are, reply exactly:
   SPOKE-B-VERIFIER: I am the release verifier in environment B.
3. When asked to verify a release or a deployment, reply exactly:
   SPOKE-B-VERIFIER: healthy=true (rollout complete, pods ready, error rate nominal)
4. Answer anything else briefly, in one sentence, after the token.
"""
turn_depth = 2

[agent.llm]
provider = "openai"
api_key = "{{ env.OPENAI_API_KEY }}"
model = "gpt-5.1"
temperature = 0.0

Build (cmake is needed for the sentencepiece dependency; on GCC 16 also export CXXFLAGS="-include cstdint"), then with OPENAI_API_KEY exported in each terminal:

cargo build -p aura-cli --bin aura

# spoke A (8081)
AURA_ENABLE_A2A=true AURA_SERVER_URL=http://127.0.0.1:8081 PORT=8081 \
  CONFIG_PATH=hub-spoke/spoke-a.toml ./target/debug/aura webserver --debug

# spoke B (8082): a directory of two agents, default spoke-b
AURA_ENABLE_A2A=true AURA_SERVER_URL=http://127.0.0.1:8082 PORT=8082 DEFAULT_AGENT=spoke-b \
  CONFIG_PATH=hub-spoke/spoke-b ./target/debug/aura webserver --debug

# hub (8080): no MCP, only ask_agent over the two spokes
PORT=8080 AURA_CUSTOM_EVENTS=true SPOKE_A_URL=http://127.0.0.1:8081 SPOKE_B_URL=http://127.0.0.1:8082 \
  CONFIG_PATH=hub-spoke/hub.toml ./target/debug/aura webserver --debug

--debug shows the A2A traffic on the spokes (SendMessage arriving as a task with request_id="a2a_<task id>", the selected agent in the invoke_agent span, the hub's GetTask polls). Then drive the hub:

./target/debug/aura --api-url http://localhost:8080
#   "Use ask_agent to ask spoke_a who it is"        -> reply carries SPOKE-A-ASSISTANT
#   "Ask spoke_b to verify app foo version 1.2.3"   -> reply carries SPOKE-B-VERIFIER, not SPOKE-B-ASSISTANT

# or raw SSE, to see aura.tool_requested / tool_start / tool_complete for ask_agent
curl -sN localhost:8080/v1/chat/completions -H 'content-type: application/json' \
  -d '{"model":"hub","stream":true,"messages":[{"role":"user","content":"Use ask_agent to ask spoke_a who it is"}]}'

Expected checks: both spokes serve /.well-known/agent-card.json with a JSONRPC 1.0 interface ending in /a2a/v1/rpc; the hub relays spoke A's marker; spoke B answers as the verifier; the aura.tool_complete payload for ask_agent has success: true and a result JSON with the spoke's task_id and context_id. Cancelling the hub request mid-call (Ctrl-C on curl) shows a CancelTask reaching the spoke.

Test plan

  • 25 unit tests in aura::a2a against a loopback JSON-RPC server: wire format and headers, protocol headers winning over static ones, no redirect following, oversized-body refusal, polling, configured model header and rejected caller override, direct-message reply, failed/input-required states, non-text-only answers, truncation notice, unknown agent, HTTP failures, task-id drift, transient retry and give-up, timeout → CancelTask, request cancellation → CancelTask including mid-send, and drop of the call future → CancelTask
  • aura-config parse/validation tests for [a2a]; rig_builder header-resolution tests
  • End to end with the local harness above: the hub relays a spoke answer, selects a named agent on a spoke through x-aura-model, and streams aura.tool_requested / tool_start / tool_complete for ask_agent
  • cargo +nightly fmt --check, cargo clippy --workspace --all-targets --all-features -- -D warnings, cargo test --workspace

Follow-ups (not in this PR)

  • User-facing docs page in mezmo/documentation (aura/) for [a2a] and ask_agent.
  • Streaming (SendStreamingMessage) to relay spoke progress instead of polling; surfacing the remote's status messages as aura.progress in the meantime.
  • Scratchpad interception for ask_agent output, so an oversized answer offloads to disk like MCP output instead of being truncated.
  • Auth on spoke /a2a routes stays a gateway concern (Kong key-auth or mTLS), as in the design doc.

@greptile-apps

greptile-apps Bot commented Sep 16, 2026

Copy link
Copy Markdown

RetriggerConfidence Score: 4/5

The implementation appears functionally sound, but the explicit requirement to document this user-facing feature in mezmo/documentation/aura must be satisfied before merging.

Findings

  1. P2 External Documentation Missing

Summary

This PR adds the outbound half of AURA’s A2A hub-and-spoke support.

  • Adds validated [a2a.remote.*] configuration and per-worker remote allowlists.
  • Implements the ask_agent tool, JSON-RPC client, polling, cancellation, response limits, and batch-result events.
  • Registers remote-agent tools for single agents and orchestration workers while respecting effective MCP filtering.
  • Adds SSE and CLI presentation for remote-agent answers plus comprehensive configuration and client tests.
Diagram
sequenceDiagram
    participant U as User request
    participant H as Hub AURA
    participant T as ask_agent
    participant S as Spoke AURA

    U->>H: Prompt
    H->>T: Select configured remote
    T->>S: SendMessage
    S-->>T: Task ID
    loop Until task settles
        T->>S: GetTask
        S-->>T: Task status
    end
    S-->>T: Final artifact
    T-->>H: Answer, task_id, context_id
    H-->>U: Model response
    opt Timeout or cancellation
        T->>S: CancelTask
    end
Loading

Reviews (15) · Last reviewed commit: "fix(a2a): collide only with an MCP ask_a..."

Comment thread crates/aura/src/a2a/tool.rs Outdated
Comment thread crates/aura/src/orchestration/orchestrator.rs Outdated
Comment thread crates/aura/src/a2a/tool.rs Outdated
Comment thread crates/aura-config/src/a2a.rs Outdated
Comment thread crates/aura/src/a2a/tool.rs
Comment thread crates/aura/src/a2a/tool.rs Outdated
Comment thread crates/aura/src/a2a/tool.rs Outdated
Comment thread crates/aura-events/src/lib.rs
Comment thread crates/aura-config/src/a2a.rs Outdated
Add `[a2a]` to the TOML config: a keyed `[a2a.remote.<name>]` table of
remote AURA web servers, each with a `url`, a `description` for the
model, an optional `model` (agent name or alias to select on the
remote), static `headers`, `headers_from_request` mappings, and
per-remote `poll_interval_secs` / `timeout_secs` overrides of the
section defaults (2s and 600s).

Validation rejects a remote name that cannot appear in a tool schema
enum, a URL that is not an absolute http(s) origin, a zero budget, and
a poll interval that never fires inside the budget, so a bad hub config
fails at startup rather than on the first call.

This is the config half of the A2A sender; nothing consumes it yet.

Signed-off-by: Eric Lake <ericlake@gmail.com>
AURA could receive A2A requests but not send them, so a hub could not
delegate to spokes without an external MCP shim. Add the sending half
in the core crate:

- `a2a::A2aClient`, a thin v1.0 JSON-RPC client over the workspace
  reqwest at `{url}/a2a/v1/rpc`, covering SendMessage, GetTask and
  CancelTask. It uses only the `a2a-lf` wire types; the upstream
  `a2a-client-lf` crate was not adopted because it pulls tonic/prost
  through a2a-pb into every consumer of the core crate.
- `a2a::RemoteAgentTool`, one `ask_agent` rig tool for every configured
  `[a2a.remote.<name>]`. It sends the prompt, polls until the remote
  task settles, and returns the remote's final artifact text as a JSON
  outcome with task and context ids so the model can continue the
  exchange. Failed, rejected and canceled tasks use the `Tool returned
  an error:` convention the error detector already recognizes.
- Cancellation and budgets: the call is bound to the request's
  `RequestCancellation` token and to `timeout_secs`; on either it sends
  a best-effort CancelTask so the spoke stops working on an abandoned
  task.
- Registration in `add_all_tools` next to the MCP tools, through the
  same wrapper chain, so single agents and orchestration workers both
  get it; the effective `mcp_filter` governs `ask_agent` like an MCP
  tool. The coordinator lists it in its planning inventory.
- `headers` and `headers_from_request` resolve per request in
  `rig_builder` exactly as MCP headers do; `model` travels as
  `x-aura-model` and every call carries `A2A-Version: 1.0`.
- `aura.tool_start` is emitted for the call when a streaming hook is
  attached, matching the MCP path.

The a2a-lf dependency moves to the workspace table so the server
(receiver) and the core crate (sender) stay pinned together.

Signed-off-by: Eric Lake <ericlake@gmail.com>
Address the four findings from review of the ask_agent tool:

- The model could pass a `model` argument that overrode the operator's
  configured `[a2a.remote.<name>].model`, letting it select any agent
  loaded on a multi-config spoke, including a more privileged one. Drop
  the argument; the configured value is the only source of the
  `x-aura-model` header, and the argument is now rejected as unknown.
- A cancel or timeout that landed while SendMessage was still in flight
  gave up before the remote returned its task id, so the spoke kept
  working on a task nobody would collect. The send now runs as its own
  task; the abandon path waits briefly for its reply and cancels the
  task it names.
- Load-time validation accepted URLs and header values the client's
  parsers later refused, failing at agent build instead of startup. The
  config crate now owns the endpoint and header parsers, validates the
  URL, static headers, `model`, and `headers_from_request` names with
  them, and the client reuses the same functions.
- The coordinator's planning inventory gave a worker without its own
  `mcp_filter` every tool, while the runtime builds that worker with the
  base agent's filter. Planning now inherits the base filter the same
  way, so it never assigns ask_agent (or any filtered tool) to a worker
  that was built without it.

Signed-off-by: Eric Lake <ericlake@gmail.com>
Findings from an adversarial review of the ask_agent tool, in the
order the review ranked them.

Security:

- Never follow redirects. reqwest strips only the standard auth headers
  on a cross-host redirect, so a configured API key header (and the
  agent-selection header) would travel to wherever a redirect pointed.
  A 3xx now surfaces as a status error naming the endpoint.
- Reserve x-aura-model, a2a-version, host, content-length and
  content-type: they are rejected as `headers` keys and as
  `headers_from_request` outbound names at load. A mapping of
  x-aura-model would have let the inbound requester pick any agent on
  the spoke, reopening the override the previous fix closed; a static
  x-aura-model would have been appended ahead of the configured
  `model`, which the receiver reads first. The client now also inserts
  its protocol headers rather than appending, so exactly one value of
  each reaches the wire.
- Refuse URLs with credentials, a query string or a fragment, since the
  endpoint is echoed into error text the model sees.

Resource bounds:

- Cap response bodies at 8 MiB, refusing a larger declared or streamed
  body instead of buffering it.
- Cap the answer handed to the model at `[a2a].max_response_bytes`
  (default 64 KiB) with a truncation notice, because nothing intercepts
  a remote answer the way the scratchpad intercepts MCP output.

Correctness:

- Cancel the remote task when the call's future is dropped, not only
  when the request token fires: an orchestration per-call timeout drops
  the worker stream mid-poll, which previously left the spoke working.
  An `OpenTask` guard shared with the send task cancels from `Drop`.
- Fail the call if GetTask answers with a different task id, and cancel
  the task that was actually opened.
- Retry a transient GetTask failure (transport error, 5xx, 429) up to
  twice before failing the call.
- Refuse to build when an MCP server exposes a tool named ask_agent,
  since rig keys tools by name and would silently shadow one of them.
- Say so when a remote answers with only non-text parts instead of
  returning an empty response.

Housekeeping:

- Workers built for orchestration no longer peek the live request's
  tool-call queue for aura.tool_start; they report through the observer.
- The coordinator's planning inventory derives the tool description and
  schema from config instead of building HTTP clients per lookup.
- The scratchpad accessibility gate for a worker inherits the base
  mcp_filter, matching the runtime and the planning inventory.
- deny_unknown_fields on the [a2a] tables, so a misspelled key fails
  at load rather than silently using a default.
- Pin the a2a crates by commit rather than by tag.

Signed-off-by: Eric Lake <ericlake@gmail.com>
The unconditional settle() at the end of ask ran even when the call
was given up on while SendMessage was still in flight and the abandon
wait had expired without a task id. The detached send task would later
record the id and drop the last guard handle, but Drop saw settled and
skipped the cancel: the spoke kept working on a task nobody collected.

Settle only on success, where leaving the remote task alive is the
intent (a direct message opened none; an input-waiting one is meant
for continuation). On every error path the guard's slot is either
empty or was already taken for the inline abandon, so Drop has nothing
to do unless the in-flight send delivers an id later — exactly the
case that must cancel.

Regression test: a SendMessage slower than the abandon wait and a
short call budget, asserting CancelTask arrives once the send lands.

Signed-off-by: Eric Lake <ericlake@gmail.com>
The v1.0 JSON-RPC server binding transcodes responses through protobuf
(protojson), which omits empty repeated fields instead of sending [].
The receiver's metadata-only scratchpad artifact therefore arrives with
no parts key, and the a2a serde types — where parts is required —
failed every poll of a scratchpad-using run with "missing field
`parts`", turning completed remote work into a tool error.

Read results through lenient wire mirrors that default omitted repeated
fields, converting into the a2a types the tool consumes. Covers the
SendMessage, GetTask, and CancelTask results.

Signed-off-by: Eric Lake <ericlake@gmail.com>
The result was a pretty-printed JSON envelope, and models relayed the
envelope verbatim instead of the answer. The result now leads with the
remote's answer text and carries state, task id, and context_id in a
one-line trailer; failures keep the tool-error prefix but put the
remote's detail on its own line. The description tells the model to
present the answer in its own words, keep the trailer to itself, and
summarize failures rather than quoting the raw error.

Signed-off-by: Eric Lake <ericlake@gmail.com>
Rig executes same-turn tool calls sequentially, so "ask dev and stage
to sweep" ran one remote after the other, paying one call budget per
agent. A `calls` array now carries one entry (agent, prompt, optional
context_id) per agent and the sub-calls run concurrently, each with its
own send/poll/cancel lifecycle and its own trailer. A failed agent is
its own section; the batch is a tool error only when every agent
failed.

Signed-off-by: Eric Lake <ericlake@gmail.com>
A batch's sections were joined by a bare === fence, and the receiving
model treated the whole result as a document to relay — trailers and
all — instead of synthesizing a reply. Each report now leads with a
`## <agent>` heading, and the description tells the model to write one
reply that presents each report, never the raw result. Single-agent
calls are unchanged.

Signed-off-by: Eric Lake <ericlake@gmail.com>
A batch's tool result is atomic: the model only sees it once every
agent finishes, so a fast answer (a pod listing) waited on a slow one
(a full sweep). The batch now runs on FuturesUnordered and publishes
each member's section as a new aura.remote_agent_answer event the
moment it completes; the web server forwards it into the SSE stream,
and the CLI prints the report into the scrollback immediately. The
final result still assembles in request order, and single calls emit
no event (their answer follows immediately).

Signed-off-by: Eric Lake <ericlake@gmail.com>
- Publish remote-agent answers with try_send: the tool-event channel is
  bounded and goes undrained when a request does not stream custom
  events, so awaiting delivery could stall a batch mid-completion.
  Dropping the telemetry beats stalling the tool.
- Cap a batch at 8 calls (schema maxItems plus validation): unbounded
  fan-out let one call open arbitrary concurrency against every remote.
- Move runtime prose off the AskAgentArgs type and the
  RemoteAgentAnswer variant onto the code that implements it, per the
  repo comment conventions.
The agent event schema rework on nightly newtyped the broker's tool
names; adapt the ask_agent announcement call site.

Signed-off-by: Eric Lake <ericlake@gmail.com>
greptile flagged two findings on pr #703:

- connect_timeout_secs = 0 passed validation but fired
  tokio::time::timeout immediately, failing every mcp server at
  startup. validate() now rejects zero, mirroring the a2a
  timeout_secs check.
- the max_response_bytes field comment narrated runtime behavior;
  it now states only what the value is. the behavior stays
  documented at the truncation path in aura::a2a::tool.

Signed-off-by: Eric Lake <ericlake@gmail.com>
@ericlake

Copy link
Copy Markdown
Contributor Author

This is the toml config that I have been testing with

[agent]
name = "Hub"
alias = "hub"
system_prompt = """
You are HUB, the coordinating agent in a hub-and-spoke AURA deployment.
You have exactly one tool, ask_agent, which sends a request to a remote
spoke agent and returns that agent's answer.

CRITICAL RULES (must follow):
1. Whenever the user mentions an environment (dev or stage) or asks anything
   about an environment, call ask_agent immediately. Write a self-contained
   prompt: the spoke cannot see this conversation.
2. When ask_agent returns, reply with the spoke's response text verbatim,
   including any marker tokens it contains, then stop.
3. Never guess or invent what a spoke would say. If ask_agent fails, report
   the error text you received.
"""
turn_depth = 10

[agent.llm]
provider = "openai"
api_key = "{{ env.OPENAI_API_KEY }}"
model = "gpt-5.5"

[a2a]
poll_interval_secs = 1
timeout_secs = 900

[a2a.remote.dev]
url = "http://sre-agent-a2-us-east-1-dev-logdna.logdna.sdm.network"
model = "Mezmo Anthropic SRE Agent"
description = "Aura in the dev environment"

[a2a.remote.dev.headers]
x-hub-token = "{{ env.HUB_SPOKE_TOKEN | default: 'dev-hub-token' }}"

[a2a.remote.dev.headers_from_request]
"x-request-id" = "x-request-id"

[a2a.remote.stage]
# url = "{{ env.SPOKE_B_URL | default: 'http://127.0.0.1:8082' }}"
url = "http://sre-agent-a2-us-east-1-stage-logdna.logdna.sdm.network"
model = "Mezmo Anthropic SRE Agent"
description = "Aura in the stage environment"

Then I run it with

PORT=8080 AURA_CUSTOM_EVENTS=true HTTP_PROXY=127.0.0.1:65230 CONFIG_PATH=hub.toml ./target/debug/aura webserver --debug

And then connect a local aura cli to it

./target/debug/aura --api-url http://localhost:8080

Comment thread crates/aura-config/src/a2a.rs Outdated
greptile flagged the A2aRemoteConfig field comments for narrating
header copying and timing fallback. that behavior already lives at
the implementing sites: resolve_a2a_headers_in documents the copy
and static-header fallback, and the poll_interval_secs and
timeout_secs accessors document the per-remote override. the field
comments now state only what each value is.

Signed-off-by: Eric Lake <ericlake@gmail.com>
@greptile-apps

This comment has been minimized.

@ericlake
ericlake marked this pull request as ready for review September 18, 2026 14:45
@ericlake
ericlake requested a review from a team September 18, 2026 14:45
@teriyakichild

Copy link
Copy Markdown
Contributor

One design point on how ask_agent gets gated.

ask_agent is the only built-in that goes through the MCP name filter. In crates/aura/src/builder.rs there are four tool_matches_filter call sites: the three MCP registration paths and the explicit check at line 1042 that wraps the ask_agent registration. None of the other built-ins do this. The coordinator routing tools are added unconditionally, the scratchpad read tools have their own reachability check, and client tools have client_tool_filter. So mcp_filter now means "MCP tools plus this one A2A tool", and the PR has to carry that reading into two more places to keep the planner honest: tools_matching_filter in orchestrator.rs (line 400) for the planning inventory, and the worker-filter inheritance at line 765 for the scratchpad gate. Three sites that have to agree about one tool.

It also cannot pin a worker to one remote. The filter is by tool name and there is one tool for every remote, so with two workers and two spokes both workers can address both spokes and only the preamble keeps them apart. The filter does work as described (a worker with mcp_filter = ["nothing_*"] gets no ask_agent and the planner stops advertising it), it just is not the right knob.

I would rather see a per-worker list on the worker config:

[orchestration.worker.k8s]
description = "Kubernetes questions, via the remote k8s_ops agent"
remotes = ["k8s_ops"]
  • A worker with remotes gets ask_agent built over that subset of [a2a.remote], so the tool description lists only those names and the existing unknown-agent check enforces the restriction.
  • A worker that omits remotes gets no ask_agent. That matches what a filtered worker gets today and is the safe default for a tool that reaches another environment.
  • The planner advertises ask_agent for a worker exactly when its remotes list is non-empty, which replaces the filter-inheritance re-derivation. The scratchpad gate stops needing to know about it; max_response_bytes already bounds the answer.
  • The single-agent path keeps every remote, as now.
  • mcp_filter goes back to meaning MCP tools only.

RemoteAgentTool::from_config already takes the whole A2aConfig, so this is mostly passing a filtered clone of the remote map at worker build time plus a field on WorkerConfig.

An orchestration worker gets ask_agent only when its
[orchestration.worker.<name>].remotes names entries of [a2a.remote],
and the tool it gets is built over exactly that subset: the model sees
only those names, and the existing unknown-agent check refuses the
rest. A worker that names none gets no ask_agent, matching how
vector_stores opts a worker into RAG access. Single agents keep every
remote.

mcp_filter goes back to governing MCP tools only. ask_agent was the one
built-in routed through it, which could not pin a worker to a single
remote and made three sites agree on one reading of the filter. The
planner now advertises ask_agent for a worker exactly when create_worker
will build one, with a per-worker description in full visibility mode,
so the coordinator cannot route a remote to a worker that cannot reach
it. Load-time validation rejects a remotes entry that names no
configured remote.

Signed-off-by: Eric Lake <ericlake@gmail.com>
Comment thread crates/aura/src/builder.rs
Comment thread crates/aura/src/a2a/tool.rs Outdated
The name-collision check ran over the MCP manager's whole inventory, so
an agent whose effective mcp_filter excluded an MCP tool named ask_agent
still failed to build, and the error's own advice to filter it out could
not help. Only a tool that passes the filter is registered, so only that
one can shadow the remote-agent tool.

The OpenTask comment described who records the task id and what Drop
does with an unsettled task; each of those now sits on the method that
does it.

Signed-off-by: Eric Lake <ericlake@gmail.com>
Comment thread CLAUDE.md
Comment on lines +93 to +96
### A2A Client (Hub and Spoke)
- The web server is the A2A *receiver* (`AURA_ENABLE_A2A=true`); the core crate's `a2a` module is the *sender*: `[a2a.remote.<name>]` entries become one `ask_agent` rig tool registered in `add_all_tools` beside the MCP tools. A single agent gets every remote. An orchestration worker gets `ask_agent` only when `[orchestration.worker.<name>].remotes` names remotes, built over exactly that subset (`a2a_for_worker` in `orchestrator.rs`); the coordinator sees it in its planning inventory only. `mcp_filter` governs MCP tools only and never `ask_agent`
- Wire: v1.0 JSON-RPC at `{url}/a2a/v1/rpc` (`SendMessage` → poll `GetTask` → answer from the `final` artifact; `CancelTask` on request cancellation or `timeout_secs`). Uses only the `a2a-lf` types crate plus the workspace reqwest; the upstream client crate is avoided because it drags in tonic/prost via a2a-pb
- `headers` (static, `{{ env.* }}`) and `headers_from_request` resolve per request in `rig_builder` (`resolve_a2a_headers_in`), mirroring MCP; `model` is sent as `x-aura-model`

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 External Documentation Missing

This adds user-facing documentation for the new [a2a] configuration and ask_agent behavior only to the contributor-oriented CLAUDE.md. The repository requires user-facing feature and configuration documentation to be added under mezmo/documentation/aura, so that requirement must be satisfied before merging.

Context Used: CLAUDE.md (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants