Conversation
|
bb87ed4 to
b9f7261
Compare
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>
4b1295c to
44cf5f7
Compare
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>
|
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 --debugAnd then connect a local aura cli to it ./target/debug/aura --api-url http://localhost:8080 |
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>
This comment has been minimized.
This comment has been minimized.
|
One design point on how
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 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"]
|
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>
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>
| ### 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` |
There was a problem hiding this comment.
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!
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, optionalmodel(sent asx-aura-model), staticheaders,headers_from_request, and per-remotepoll_interval_secs/timeout_secsoverrides 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, andcontent-typeare reserved and cannot be set or mapped from the request; URLs with credentials, a query string, or a fragment are refused.ask_agenttool (aura::a2a): one rig tool over every configured remote. It callsSendMessageon the remote's v1.0 JSON-RPC binding, pollsGetTaskuntil the task settles, and returns thefinalartifact text as a JSON outcome withtask_id/context_idso the model can continue the exchange. Failed, rejected, and canceled tasks use the existingTool returned an error:convention.RequestCancellationtoken and totimeout_secs; either sends a best-effortCancelTaskto the spoke, including when cancellation lands whileSendMessageis 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.GetTaskmust answer for the task that was opened; a transient poll failure (transport error, 5xx, 429) is retried twice.x-aura-modelreaches the wire.add_all_tools, through the same wrapper chain (an MCP tool also namedask_agentfails the build instead of silently shadowing). A single agent gets every remote. An orchestration worker getsask_agentonly when[orchestration.worker.<name>].remotesnames remotes, and its tool is built over exactly that subset, so it can address only those; a worker that names none gets noask_agent, likevector_storesopts a worker into RAG.mcp_filtergoverns MCP tools only. The coordinator listsask_agentunder 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 ownmcp_filterinherits the base filter in planning and in the scratchpad gate exactly as the runtime does.headers_from_requestresolves per request exactly as for MCP.a2a-lftypes crate plus the workspace reqwest. The upstreama2a-client-lfcrate 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 throughx-aura-modelis exercised; marker tokens in the system prompts make the results checkable without depending on exact wording.hub.tomlspoke-a.tomlspoke-b/assistant.tomlspoke-b/verifier.tomlBuild (
cmakeis needed for the sentencepiece dependency; on GCC 16 alsoexport CXXFLAGS="-include cstdint"), then withOPENAI_API_KEYexported in each terminal:--debugshows the A2A traffic on the spokes (SendMessagearriving as a task withrequest_id="a2a_<task id>", the selected agent in theinvoke_agentspan, the hub'sGetTaskpolls). Then drive the hub:Expected checks: both spokes serve
/.well-known/agent-card.jsonwith aJSONRPC1.0interface ending in/a2a/v1/rpc; the hub relays spoke A's marker; spoke B answers as the verifier; theaura.tool_completepayload forask_agenthassuccess: trueand aresultJSON with the spoke'stask_idandcontext_id. Cancelling the hub request mid-call (Ctrl-C on curl) shows aCancelTaskreaching the spoke.Test plan
aura::a2aagainst 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 →CancelTaskincluding mid-send, and drop of the call future →CancelTaskaura-configparse/validation tests for[a2a];rig_builderheader-resolution testsx-aura-model, and streamsaura.tool_requested/tool_start/tool_completeforask_agentcargo +nightly fmt --check,cargo clippy --workspace --all-targets --all-features -- -D warnings,cargo test --workspaceFollow-ups (not in this PR)
mezmo/documentation(aura/) for[a2a]andask_agent.SendStreamingMessage) to relay spoke progress instead of polling; surfacing the remote's status messages asaura.progressin the meantime.ask_agentoutput, so an oversized answer offloads to disk like MCP output instead of being truncated./a2aroutes stays a gateway concern (Kong key-auth or mTLS), as in the design doc.