Skip to content

feat: A2A v2 — external agents, armed indicator, delegation that fires (CLEAN-95) - #86

Merged
maksymhryzodub-prog merged 35 commits into
mainfrom
feat/CLEAN-95-a2a-protocol-upgrade
Sep 16, 2026
Merged

maksymhryzodub-prog merged 35 commits into
mainfrom
feat/CLEAN-95-a2a-protocol-upgrade

Conversation

@maksymhryzodub-prog

Copy link
Copy Markdown
Contributor

Summary

A2A v2 (spec: specs/014-a2a-protocol-upgrade/). Production baseline that motivated it: Rancher + Skyhunter connected, empty card, zero delegations ever.

  • Delegation that fires (FR-011): the ask_agent description now carries the give-up rule — never answer "I don't know" while a plausible peer is untried; a named peer is always asked. The tool records which peer set the running pod actually received.
  • Armed / pending restart: new GET /agents/:id/peers/state compares the served membership hash with the current peer set; the A2A tab shows the chip and the restart banner gained a one-click Restart now.
  • External agents by URL: POST /agents/:id/peers accepts {url, token?}; POST peers/preview reads a card without saving (the operator reviews the exact text first). Both URL forms canonicalize to one identity; re-import updates in place (partial unique index enforces it); external rows are badged, have no live status, and delegate with the stored write-only credential.
  • SSRF hardening (from the commit-time security review): outbound A2A fetches refuse redirects; IPv6 literals allowed only in global unicast 2000::/3; curl-style numeric IPv4 shorthand refused; pre-flight DNS check on all three external paths; A2A_ALLOW_PRIVATE_PEERS=true lifts the guard for local dev only.
  • Tab renamed Peers → A2A including the URL key; legacy ?tab=peers deep links are aliased.

Validation

  • 230 peer-slice tests green; full api suite green; api tsc, admin + app typechecks green.
  • Quickstart §3 ran live against the dev API with a mock external agent: preview-without-save, import, credential-leak check, re-import→same row, state, refresh, delete.
  • Negative paths live: PEER_URL_INVALID (SSRF), PEER_BODY, PEER_URL_UNREACHABLE in 74 ms.

Remaining

  • T037 — production validation (quickstart §5–§6): deploy, give Skyhunter a described card, re-ask "сколько crews в skyhunter", record before/after on CLEAN-95.

Jira: https://dreamvention.atlassian.net/browse/CLEAN-95

🤖 Generated with Claude Code

maksymhryzodub-prog and others added 30 commits September 14, 2026 12:29
…CLEAN-74)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SZ9dCrzZqd5SwR4erLYe12
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SZ9dCrzZqd5SwR4erLYe12
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Claude-Session: https://claude.ai/code/session_01SZ9dCrzZqd5SwR4erLYe12
… listing (CLEAN-74)

Phase 1-2 (part): env vars, the agent/peer slice skeleton with the A2A 1.0
protocol types, the AgentPeer + AgentDelegation models and their additive
migration, getApiPublicUrl on the infra config gateway, and a per-request
tool-listing filter in the MCP runtime.

PUBLIC_API_URL is reused rather than a new variable: CLEAN-75 already
introduced it for the OAuth callback, and it is the same public origin an
agent card URL needs. It was undocumented, so it is now in .env.example and
the k8s deployment too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SZ9dCrzZqd5SwR4erLYe12
…t (CLEAN-74)

Phase 2 (rest). The hub now remembers which turn an agent is in the middle of,
learned purely from the thinking events it already relays. That is what lets
the API add a delegation step to a timeline the runtime opened, instead of
minting a turnId of its own — a step under an unknown turn closes the
runtime's block in every console that renders thinking.

The register/resolve/timeout dance of POST /message/sync moves into
BridleSyncService so the A2A server can wait for an agent reply without being
an HTTP controller. The route's own behaviour is unchanged, timeout sentence
included; the service reports a timeout as a flag instead, because a peer that
answers "Timeout: no response from agent" as if it were an answer is the
silent failure this feature exists to prevent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SZ9dCrzZqd5SwR4erLYe12
US1. Every agent now has an A2A 1.0 card, derived on every read from its name,
description, template skills and bound knowledge bases — so an edit shows up
without a "regenerate" button existing at all. It is served at the spec's
well-known path, prefixed per agent because one host serves many, and it needs
a credential: anonymous discovery would let anyone enumerate what an
installation can do.

The JSON-RPC endpoint turns an inbound task into one synchronous conversation
with the runtime. Every failure is a stated task state rather than silence: an
offline peer fails at once instead of after the full wait, a timeout says so,
and a chain that would loop or run too deep is refused before the hub is
touched. The caller is a language model — it would fill any silence itself.

Loop and depth checks landed here rather than as a placeholder for US5: the
rule is fifteen lines and belongs on the receiving side of every hop, which is
also the only place that cannot be talked past by the agent doing the asking.

Both A2A routes answer raw bodies. The response interceptor gained a
@RawResponse escape hatch for exactly this: a route obeying somebody else's
published protocol. It already injected Reflector without using it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SZ9dCrzZqd5SwR4erLYe12
…-74)

US2 (API half). A connection is created before its card is read, because the
card route needs the credential — so reading the card IS the proof that the
connection works, and a failed read deletes the row. "Connected" therefore
always means "we checked", and an operator can never hold a peer that quietly
does not answer.

Refresh keeps the old snapshot when a read fails: a stale description is worth
more than none, and the operator is told what broke. Removing a connection is
what revokes the credential — there is no second copy of it anywhere.

The controller projects each connection into its DTO field by field. The
service already drops the credential; naming the fields means a later change
to either side cannot put it back. A poisoned stub in the spec proves it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SZ9dCrzZqd5SwR4erLYe12
… (CLEAN-74)

US2 (console half). The tab leads with the agent's own card, because "what do
other agents see of me" is the question an operator has before they can judge
anybody else's. The picker shows a candidate's card before anything is saved:
connecting decides what a model will be told it can ask for, so the operator
reads the same words it will.

Two things the UI says that nothing else would. A pod reads its tool list once
at boot, so a connected peer is invisible until the agent restarts — the tab
says so rather than leaving someone to wonder why nothing happened. And two
peers whose descriptions read alike get an amber note, because the model is
then choosing between them by chance.

The console gateway unwraps errors rather than only data: the generated client
returns a failure in `error` instead of throwing, and "Could not read the card
of «B». The card answered 401" is precisely the sentence that would have been
dropped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SZ9dCrzZqd5SwR4erLYe12
US3 and the server half of US4. The tool is served through the MCP channel
agents already use, so peers ship without rebuilding a single runtime image.
It is listed only for agents that actually have peers: a description saying
"you have none" would still be an advertisement for colleagues that do not
exist, and models act on tool lists rather than on caveats inside them.

Ordering carries the guarantees. The audit row opens before the outbound call,
so a crash mid-flight still leaves evidence. The step is pushed before the
wait, so the chat never looks idle. The row is finished exactly once, whatever
happened.

Every failure comes back as a sentence ending in "do not answer on its
behalf". That instruction is the only thing standing between a peer being
unreachable and the calling model quietly inventing what it would have said.

The step joins the turn the runtime already opened, learned from the hub. With
no turn to join the delegation runs anyway and the step is simply not shown —
inventing a turnId would close the runtime's own block in every console.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SZ9dCrzZqd5SwR4erLYe12
US4 (console half). The delegation step gets a layout in the admin chat: the
peer, the card skills that made it the choice, the agent's own reason, the
task as the peer received it, and a clock that runs while the answer is
awaited. The clock ticks only while waiting — a frozen number reads as a
stalled chat, which is the dead air this timeline exists to remove.

The markdown detail is suppressed for these steps and only these: it exists so
surfaces without this layout still get the whole story, and showing both here
would say everything twice.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SZ9dCrzZqd5SwR4erLYe12
…N-74)

Lint on the new slice found a real defect rather than only style: the
"card never mentions peers" assertion had a literal backspace where a word
boundary was meant, so it had been matching nothing. It now checks the skill
text word by word, which is also readable without knowing regex trivia.

The spec harnesses hold their mocks directly instead of reading methods off a
casted interface. The two controller specs keep one justified rule disable:
Nest's Reflector genuinely wants the method reference.

Two research facts are marked superseded rather than quietly edited, because
the branch was rebased onto work that changed them: the user console does
render thinking steps now (CLEAN-76), which turns the graceful-degradation
case from hypothetical into something a real surface exercises; and the public
API origin is the existing PUBLIC_API_URL, not a new variable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SZ9dCrzZqd5SwR4erLYe12
…t (CLEAN-74)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SZ9dCrzZqd5SwR4erLYe12
…LEAN-90)

ConnectPeerDto used @IsUUID which rejected real agent ids (`agent-…`).
Admin create/edit now writes config.description for the A2A card.

Co-authored-by: Cursor <cursoragent@cursor.com>
… (CLEAN-91)

Implements the 'A2A Peers' design canvas: card summary header with
address copy and advertise warning, peers drawn as a tree off a hub
node with status dots and per-peer Feed filter, delegation feed that
polls every 5 s with Pause/Resume and expandable task/reason/response
rows, and the Add peer picker moved into a modal (card preview before
Connect kept).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…N-90)

The committed clients were missing the peer endpoints entirely in app
and partially in admin; regenerated from the merged api so both match
what build:api produces locally and in CI.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…N-74)

Same reason as on the CLEAN-90 branch: the committed clients were
missing peer endpoints (app entirely); regenerated from this branch's
api so they match what build:api produces.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…d-and-description

# Conflicts:
#	admin/slices/setup/api/data/repositories/api/schemas.gen.ts
#	admin/slices/setup/api/data/repositories/api/types.gen.ts
#	app/slices/setup/api/data/repositories/api/schemas.gen.ts
#	app/slices/setup/api/data/repositories/api/types.gen.ts
The Add peer preview rendered below the whole candidate list — under
the fold with ten agents, so selecting looked like nothing happened.
Each row is an accordion now: the card and Connect button open right
under the click, and an Info pill with a chevron says the row is
clickable and which one is open.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…AN-91)

DateTimeAgo stacks the absolute date over the relative one, which
wrapped awkwardly after the "card read" label and inside the feed's
meta line. New formatTimeAgo renders a single relative phrase with the
full date in the tooltip.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… helper (CLEAN-91)

Same useTimeAgoIntl the stacked DateTimeAgo already uses, so the
wording matches and the label keeps ticking without a re-render;
drops the formatTimeAgo duplicate added one commit earlier.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
0.3.51 is already tagged; a matching tag would silently skip the
release run.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… (CLEAN-95)

Production baseline captured 2026-09-16: Rancher+Skyhunter connected,
empty card, zero delegations ever. Three clarification markers open
(Q1 замещение scope, Q2 prefer-the-peer policy, Q3 restart-to-arm).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… one-click restart (CLEAN-95)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…(CLEAN-95)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…legacy alias (CLEAN-95)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…R-011 tool text (CLEAN-95)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Security-review findings on the previous commit: outbound fetches now
refuse redirects (a 302 was a clean guard bypass); IPv6 literals are
allowed only in global unicast 2000::/3 (covers ::ffff-mapped v4,
unabbreviated loopback, ULA, link-local); curl-style numeric IPv4
shorthand is refused; and a pre-flight DNS check catches public names
resolving to private addresses on all three external paths. Residual
rebinding TOCTOU documented as accepted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
maksymhryzodub-prog and others added 5 commits September 16, 2026 19:53
…tion transport (CLEAN-95)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…by URL (CLEAN-95)

Tab renamed Peers → A2A (key too; legacy ?tab=peers aliased). Armed /
pending-restart chip from peers/state; restart banner is a one-click
action now. Add peer dialog gains the import-by-URL path with the card
preview before Connect; external rows badged, no live status, no
agent link; feed filter keys on the connection id and marks external
rows.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…DK (CLEAN-95)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The 'advertises nothing' surfaces now say the consequence (reachable
by name only, never by topic) and the fix, in place: the A2A tab
explains what fills the card (description via Edit, template skills,
knowledge bases) with a jump to the Knowledge tab; the picker warns
at the moment of Connect — internal candidates link straight to their
Knowledge tab, external ones say to ask the owner and Re-read; peer
rows carry the same guidance. CardView keeps the bare fact so the
embedding surfaces don't repeat it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@maksymhryzodub-prog
maksymhryzodub-prog changed the base branch from feat/CLEAN-94-add-peer-inline-info to main September 16, 2026 17:44
@maksymhryzodub-prog
maksymhryzodub-prog merged commit 3282b41 into main Sep 16, 2026
1 check passed
@maksymhryzodub-prog
maksymhryzodub-prog deleted the feat/CLEAN-95-a2a-protocol-upgrade branch September 16, 2026 17:46
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.

1 participant