diff --git a/.specify/feature.json b/.specify/feature.json index aac2774b..35781f75 100644 --- a/.specify/feature.json +++ b/.specify/feature.json @@ -1,3 +1,3 @@ { - "feature_directory": "specs/013-a2a-agent-peers" + "feature_directory": "specs/014-a2a-protocol-upgrade" } diff --git a/README.md b/README.md index 2825021d..0f8e6e15 100644 --- a/README.md +++ b/README.md @@ -94,22 +94,31 @@ that is templates. Edit any of those and the card follows. It is served at the A2A standard path, `/a2a/agents//.well-known/agent-card.json`, and needs a credential — there is no anonymous discovery. -- **Connecting a peer** happens in the admin console, on an agent's **Peers** - tab. Pick another agent, read its card, confirm. Connections are one-way: - connecting B to A says nothing about what B may ask of A. +- **Connecting a peer** happens in the admin console, on an agent's **A2A** + tab (formerly Peers — old `?tab=peers` links still land). Pick another + agent of this ranch, or **import an external A2A agent by its address** — + paste the URL, read its card, optionally give the access credential it + expects, confirm. Re-importing the same address updates the entry in place. + Connections are one-way: connecting B to A says nothing about what B may + ask of A. - **Delegating** is a tool the API serves to the runtime, so no agent image - changes. A pod reads its tool list once at boot, so **restart the agent - after connecting or removing a peer** — the console says so too. + changes. A pod reads its tool list once at boot, so a new peer needs a + restart — the tab shows **armed / pending restart** and the banner carries + a one-click **Restart now**. The tool also carries the delegation policy: + an agent that cannot answer must try a plausible peer before saying + "I don't know". - **Watching it happen**: a delegation appears in the chat's thinking timeline while it runs, naming the peer, what its card promised, why it was - chosen and how long the wait is. Past delegations are listed on the Peers - tab. -- **Limits**: peers are agents of the same installation; a chain may be three - hops long; an agent already in a chain refuses to re-enter it. + chosen and how long the wait is. Past delegations are listed on the A2A + tab; external peers are marked in both places. +- **Limits**: a chain may be three hops long; an agent already in a chain + refuses to re-enter it. External addresses must be publicly reachable — + private hosts are refused (`A2A_ALLOW_PRIVATE_PEERS=true` lifts that for + local development). Set `PUBLIC_API_URL` to the origin other agents reach this API on — every -card URL is built from it. See `specs/013-a2a-agent-peers/` for the full -specification. +card URL is built from it. See `specs/013-a2a-agent-peers/` and +`specs/014-a2a-protocol-upgrade/` for the full specifications. ## Project Structure diff --git a/admin/slices/agent/agent/components/agent/workspace/Canvas.vue b/admin/slices/agent/agent/components/agent/workspace/Canvas.vue index ea5960a2..06273d8c 100644 --- a/admin/slices/agent/agent/components/agent/workspace/Canvas.vue +++ b/admin/slices/agent/agent/components/agent/workspace/Canvas.vue @@ -63,7 +63,7 @@ const restartUnderway = computed( - + diff --git a/admin/slices/agent/agent/components/agent/workspace/sections.ts b/admin/slices/agent/agent/components/agent/workspace/sections.ts index 5bd1a77d..db6c504c 100644 --- a/admin/slices/agent/agent/components/agent/workspace/sections.ts +++ b/admin/slices/agent/agent/components/agent/workspace/sections.ts @@ -5,13 +5,15 @@ * * The `value` strings are a URL contract: `?tab=` deep links predate * this screen, so the original nine are byte-identical — including `chat`, - * which is the default tab again. `logs` is the one new value. + * which is the default tab again. `logs` is the one new value, and `peers` + * became `a2a` (CLEAN-95) with a legacy alias in `toAgentTab`, so the old + * links keep landing. */ /** Tabs that can say how much they hold before you open them. */ export type SectionCountKey = | 'knowledge' - | 'peers' + | 'a2a' | 'files' | 'secrets' | 'channels'; @@ -42,10 +44,10 @@ export const AGENT_TABS = [ primary: true, }, { - value: 'peers', - title: 'Peers', - desc: 'Other agents this one can delegate to.', - countKey: 'peers', + value: 'a2a', + title: 'A2A', + desc: 'Agent-to-agent: card, peers & delegations.', + countKey: 'a2a', primary: true, }, { @@ -124,9 +126,12 @@ const TAB_VALUES: readonly string[] = AGENT_TABS.map((t) => t.value); * the default rather than erroring — a stale link should land somewhere * sensible, not on a broken screen. */ +// Renamed values old deep links may still carry (CLEAN-95). +const LEGACY_TAB_ALIASES: Record = { peers: 'a2a' }; + export function toAgentTab(value: unknown): AgentTab { const v = Array.isArray(value) ? value[0] : value; - return typeof v === 'string' && TAB_VALUES.includes(v) - ? (v as AgentTab) - : DEFAULT_TAB; + if (typeof v !== 'string') return DEFAULT_TAB; + if (v in LEGACY_TAB_ALIASES) return LEGACY_TAB_ALIASES[v]; + return TAB_VALUES.includes(v) ? (v as AgentTab) : DEFAULT_TAB; } diff --git a/admin/slices/agent/agent/composables/useAgentSectionCounts.ts b/admin/slices/agent/agent/composables/useAgentSectionCounts.ts index 09dbf6e9..177eb174 100644 --- a/admin/slices/agent/agent/composables/useAgentSectionCounts.ts +++ b/admin/slices/agent/agent/composables/useAgentSectionCounts.ts @@ -117,7 +117,7 @@ export function useAgentSectionCounts( files: files.value, secrets: secrets.value, channels: channels.value, - peers: peers.value, + a2a: peers.value, }; }); diff --git a/admin/slices/agent/peer/components/peer/CardView.vue b/admin/slices/agent/peer/components/peer/CardView.vue index a73ea567..78c17c67 100644 --- a/admin/slices/agent/peer/components/peer/CardView.vue +++ b/admin/slices/agent/peer/components/peer/CardView.vue @@ -52,12 +52,12 @@ function skillTag(tags: string[]): string | null { - +

- Nothing advertised. Another agent has no way to tell when to ask this - one — give it template skills or a knowledge base. + Nothing advertised — this text is all a delegating agent gets to + match a question against.

diff --git a/admin/slices/agent/peer/components/peer/Delegations.vue b/admin/slices/agent/peer/components/peer/Delegations.vue index 6211f3ef..11ed97c8 100644 --- a/admin/slices/agent/peer/components/peer/Delegations.vue +++ b/admin/slices/agent/peer/components/peer/Delegations.vue @@ -50,7 +50,9 @@ const rows = computed(() => store.delegations(props.agentId)); const shown = computed(() => rows.value.filter( (d) => - (!props.peerFilter || d.peerAgentId === props.peerFilter.id) && + // The connection id works for both origins — external peers have no + // agent id in this installation (CLEAN-95). + (!props.peerFilter || d.peerId === props.peerFilter.id) && (outcomeFilter.value === 'all' || (outcomeFilter.value === 'answered' ? d.status === 'answered' @@ -192,8 +194,17 @@ function toggle(id: string) { @click="toggle(row.id)" >
- - {{ row.peerName }} + + {{ row.peerName + }}· ext - + · {{ duration(row.durationMs) }} (); const emit = defineEmits<{ 'update:open': [value: boolean]; connected: [] }>(); @@ -60,12 +67,22 @@ watch( selected.value = null; preview.value = null; error.value = null; + url.value = ''; + urlToken.value = ''; + urlPreview.value = null; + urlError.value = null; void store.loadCandidates(props.agentId); }, ); -async function select(candidate: IAgentPeerCandidate) { - if (candidate.connected) return; +async function toggle(candidate: IAgentPeerCandidate) { + if (candidate.connected || connecting.value) return; + if (selected.value?.id === candidate.id) { + selected.value = null; + preview.value = null; + error.value = null; + return; + } selected.value = candidate; preview.value = null; error.value = null; @@ -96,9 +113,75 @@ async function connect() { } } +// ── Import an external agent by address (CLEAN-95) ────────────── +const url = ref(''); +const urlToken = ref(''); +const urlPreview = ref(null); +const urlPreviewing = ref(false); +const urlImporting = ref(false); +const urlError = ref(null); + +// A changed address invalidates the card already shown for the old one. +watch(url, () => { + urlPreview.value = null; + urlError.value = null; +}); + +async function previewUrl() { + if (!url.value.trim()) return; + urlPreviewing.value = true; + urlError.value = null; + urlPreview.value = null; + try { + urlPreview.value = await store.previewByUrl( + props.agentId, + url.value.trim(), + urlToken.value.trim() || undefined, + ); + } catch (err) { + urlError.value = + err instanceof Error ? err.message : 'Could not read that address'; + } finally { + urlPreviewing.value = false; + } +} + +async function importUrl() { + if (!urlPreview.value) return; + urlImporting.value = true; + urlError.value = null; + try { + const imported = await store.importByUrl( + props.agentId, + url.value.trim(), + urlToken.value.trim() || undefined, + ); + toast.success(`«${imported.peerName}» connected — card read`); + emit('connected'); + } catch (err) { + urlError.value = + err instanceof Error ? err.message : 'Could not import that agent'; + } finally { + urlImporting.value = false; + } +} + function statusVariant(status: string) { return AGENT_STATUS_VARIANT[status as AgentStatusTypes] ?? 'outline'; } + +/** + * The moment of maximum leverage for an empty card is BEFORE Connect: the + * operator is looking at exactly the text the delegating model will read, + * and can still fix the agent first (CLEAN-95). Connecting stays allowed — + * sometimes the card fills up later — but never unknowingly. + */ +const previewAdvertisesNothing = computed( + () => Boolean(preview.value) && preview.value!.skills.length === 0, +); +const urlPreviewAdvertisesNothing = computed( + () => Boolean(urlPreview.value) && urlPreview.value!.skills.length === 0, +); - - Advertises nothing — this agent has no way to tell when to ask it. - + - card read - + Advertises nothing — reachable by name only, never by topic. + + + + + card read
diff --git a/admin/slices/agent/peer/components/peer/Tab.vue b/admin/slices/agent/peer/components/peer/Tab.vue index 9c3edb14..9327715a 100644 --- a/admin/slices/agent/peer/components/peer/Tab.vue +++ b/admin/slices/agent/peer/components/peer/Tab.vue @@ -33,6 +33,12 @@ const delegations = computed(() => store.delegations(props.agent.id)); const pendingRestart = computed(() => agentStore.isPendingRestart(props.agent.id), ); +const peersState = computed(() => store.peersState(props.agent.id)); +/** null while unknown — the chip only renders on a definite answer. */ +const armed = computed(() => + peers.value.length && peersState.value ? peersState.value.armed : null, +); +const restarting = ref(false); const address = computed( () => ownCard.value?.supportedInterfaces?.[0]?.url ?? null, @@ -46,9 +52,10 @@ const needAttention = computed( .length, ); +// The connection id is the stable per-peer key for both origins — external +// peers have no agent id here (CLEAN-95). function taskCount(peer: IAgentPeer): number { - return delegations.value.filter((d) => d.peerAgentId === peer.peerAgentId) - .length; + return delegations.value.filter((d) => d.peerId === peer.id).length; } /** @@ -83,8 +90,33 @@ const warnings = computed>(() => { onMounted(() => { void store.load(props.agent.id); void store.loadDelegations(props.agent.id); + void store.loadState(props.agent.id); }); +// The pod re-reads its tool list at boot, so a completed restart is the +// moment the armed indicator can flip — re-read it then. +watch( + () => props.agent.status, + (status, prev) => { + if (status === 'running' && prev !== 'running') { + void store.loadState(props.agent.id); + } + }, +); + +async function restartNow() { + restarting.value = true; + try { + await agentStore.restart(props.agent.id); + } catch (err) { + toast.error( + err instanceof Error ? err.message : 'Could not restart the agent', + ); + } finally { + restarting.value = false; + } +} + let copiedTimer: ReturnType | undefined; onUnmounted(() => clearTimeout(copiedTimer)); @@ -102,9 +134,9 @@ async function copyAddress() { function toggleFilter(peer: IAgentPeer) { peerFilter.value = - peerFilter.value?.id === peer.peerAgentId + peerFilter.value?.id === peer.id ? null - : { id: peer.peerAgentId, name: peer.peerName }; + : { id: peer.id, name: peer.peerName }; } async function refresh(peer: IAgentPeer) { @@ -128,7 +160,7 @@ async function confirmRemoval() { actionError.value = null; try { await store.remove(props.agent.id, peer.id); - if (peerFilter.value?.id === peer.peerAgentId) peerFilter.value = null; + if (peerFilter.value?.id === peer.id) peerFilter.value = null; confirmingRemoval.value = null; toast.success(`«${peer.peerName}» disconnected`); } catch (err) { @@ -142,6 +174,13 @@ async function confirmRemoval() { function onConnected() { adding.value = false; } + +// Tab state lives in `?tab=` (useAgentTab), so any component under the page +// can send the operator to the fix — here: bind a knowledge base. +const { setTab } = useAgentTab(); +function goToKnowledge() { + setTab('knowledge'); +}