From 4ac0499694cb2f384dd2866a7c0ebf2ac7fcd986 Mon Sep 17 00:00:00 2001 From: "Maksym Hryzodub [DREAM]" Date: Thu, 10 Sep 2026 15:35:44 +0300 Subject: [PATCH 01/31] docs(spec): A2A agent cards, peers, visible delegation (CLEAN-74) Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01SZ9dCrzZqd5SwR4erLYe12 --- .specify/feature.json | 2 +- .../checklists/requirements.md | 42 ++++ specs/013-a2a-agent-peers/spec.md | 206 ++++++++++++++++++ 3 files changed, 249 insertions(+), 1 deletion(-) create mode 100644 specs/013-a2a-agent-peers/checklists/requirements.md create mode 100644 specs/013-a2a-agent-peers/spec.md diff --git a/.specify/feature.json b/.specify/feature.json index 4acba296..aac2774b 100644 --- a/.specify/feature.json +++ b/.specify/feature.json @@ -1,3 +1,3 @@ { - "feature_directory": "specs/012-multi-range-aggregate" + "feature_directory": "specs/013-a2a-agent-peers" } diff --git a/specs/013-a2a-agent-peers/checklists/requirements.md b/specs/013-a2a-agent-peers/checklists/requirements.md new file mode 100644 index 00000000..1e4fed96 --- /dev/null +++ b/specs/013-a2a-agent-peers/checklists/requirements.md @@ -0,0 +1,42 @@ +# Specification Quality Checklist: Agent-to-agent (A2A) — agent cards, peer agents, and delegation you can see + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-09-10 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- Validated 2026-09-10 against the spec as written. The three scope decisions + that would otherwise have been clarification markers (what "фиксируем + знания" means, Ranch-to-Ranch only, admin-console surface) were settled in + the discussion before the spec and are recorded in the Overview and + Assumptions. +- "Thinking timeline" and "well-known address" are product terms already used + by the existing thinking feature (CLEAN-10) and by the A2A protocol; they + name behaviour, not technology. +- Items marked incomplete require spec updates before `/speckit-clarify` or `/speckit-plan` diff --git a/specs/013-a2a-agent-peers/spec.md b/specs/013-a2a-agent-peers/spec.md new file mode 100644 index 00000000..0524cacd --- /dev/null +++ b/specs/013-a2a-agent-peers/spec.md @@ -0,0 +1,206 @@ +# Feature Specification: Agent-to-agent (A2A) — agent cards, peer agents, and delegation you can see + +**Feature Branch**: `feat/CLEAN-74-a2a-agent-peers` + +**Created**: 2026-09-10 + +**Status**: Draft — decisions settled in the discussion that preceded this spec; ready for `/speckit-plan` + +**Tracker**: [CLEAN-74](https://dreamvention.atlassian.net/browse/CLEAN-74) — `[ADMIN]`, labels `admin`, `api` + +**Input**: User description: "В ранче поддержку протокола agent to agent. В агенте карточка - что может делать и тд. Передаю карточку, теперь я могу давать эти задачи ему. Карточку генерим и подключаем. Потом демка: два агента - передаём карточку, фиксируем знания" — refined in discussion: "фиксируем знания" means the connecting agent remembers only the peer's card, not its knowledge ("чтобы не заставлять агента А знать и делать то, что может Б"); Ranch-to-Ranch inside one installation; peers are managed in the admin console; and the delegation must be visible in the chat's thinking area ("там где shimmer") as a step showing which agent was chosen, what its card says, and why it was chosen — "чтобы поддерживать высокий UX". + +## Overview + +Today a Ranch agent is an island. It can use tools (MCP servers, knowledge bases, browser) but it cannot ask another agent to do something. When two agents each hold part of what a user needs, the operator has to either merge them into one over-loaded agent or manually shuttle answers between two chats. + +This feature gives agents **colleagues, not clones**. Nothing is copied or moved between agents: each agent keeps its own runtime, tools and knowledge. Instead, every Ranch agent gets an **agent card** — its business card: who it is, what it can do, where to reach it. An operator **connects** another agent's card to an agent, and from then on that agent can **delegate** a task to the peer the way a person hands work to a colleague whose card they hold. The result comes back and is used in the answer. + +The mental model to explain it with: *MCP is the tools in the agent's hands; A2A is the colleagues at the next desk.* Copying an agent already exists in Ranch as templates and template install — that is a different feature and is not touched here. + +The visible half matters as much as the protocol half. When an agent delegates, the person watching the chat must see it happen in the thinking timeline: a step naming the peer, what its card promised, the reason the agent picked it, and how long the wait took. A delegation that happens silently is a bug, not a shortcut. + +**In scope**: cards for every agent; a peers list per agent in the admin console (pick, preview, connect, refresh, remove); the ability of an agent to delegate to a connected peer during a chat turn; the delegation step in the thinking timeline; loop and depth protection; explicit failure when a peer cannot answer; a demo scenario that proves it end-to-end. + +**Out of scope, deliberately**: peers outside this Ranch installation (entering a foreign card URL by hand); streaming of the peer's partial answer back into the caller; writing the peer's answers into the caller's knowledge bases; any peers surface in the user (`app`) console; changing the agent runtime image. + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Every agent has a card that says what it can do (Priority: P1) + +An operator opens an agent in the admin console and finds its **agent card**: the agent's name, a description, the list of things it can do (its skills), and the address another agent would use to reach it. The card is not written by hand — it is derived from what the agent already is: its name, its description, the skills of its template, and the knowledge bases bound to it. When any of those change, the card changes with them. The same card is available at a standard, well-known address for that agent, so any A2A-aware client with the right credential could discover it the same way. + +**Why this priority**: The card is the unit everything else exchanges. Without a card there is nothing to connect and nothing for the delegating agent to reason about. It is also independently valuable: it is the first honest one-screen summary of "what is this agent for". + +**Independent Test**: Open any agent, view its card, compare it with the agent's settings (name, description, template skills, bound knowledge bases). Change the description or bind another knowledge base; reopen the card and confirm it reflects the change. Request the card at its well-known address with a valid credential and get the same content. + +**Acceptance Scenarios**: + +1. **Given** an agent with a template that has two skills and one bound knowledge base, **When** the operator views its card, **Then** the card lists the agent's name and description, one skill per template skill, and the knowledge base as a further skill ("can answer questions about «base name»"), each with a short description usable by another agent to decide when to call. +2. **Given** the card is open, **When** the operator changes the agent's description or binds/unbinds a knowledge base, **Then** the card shows the new content the next time it is viewed, with no manual "regenerate" step. +3. **Given** an A2A-aware client holding a valid peer credential for the agent, **When** it requests the card at the agent's well-known address, **Then** it receives the same card, including the address to send tasks to and the credential scheme it must use. +4. **Given** a request for the card with no credential, **When** it arrives, **Then** it is refused; anonymous discovery is not offered in this feature. + +--- + +### User Story 2 - Connect a peer: hand agent A the card of agent B (Priority: P1) + +On agent A's page the operator opens **Peers**, chooses agent B from the agents of this installation, and sees B's card before confirming: name, description, skills. On confirm, B becomes a peer of A. The list shows every peer with the skills it advertises and when its card was last read. The operator can **refresh** a peer's card (re-read it) and **remove** a peer. Connections are directed — connecting B to A does not make A a peer of B — and an agent can have any number of peers. + +**Why this priority**: This is the "передаю карточку" step from the request and the only way delegation gets authorised. It is the main admin work of the feature. + +**Independent Test**: On agent A, connect agent B; confirm B appears in A's peers with its skills. Open agent B and confirm A is *not* listed there. Change B's description, press refresh on A's peer entry and see the new description. Remove B and confirm A's peers list is empty and B's card is no longer reachable with the credential that was issued for that connection. + +**Acceptance Scenarios**: + +1. **Given** agent A open on Peers, **When** the operator picks agent B from the list of the installation's agents, **Then** B's card is fetched and shown for review (name, description, skills) before anything is saved. +2. **Given** the review of B's card, **When** the operator confirms, **Then** B is saved as a peer of A together with a snapshot of the card as read at that moment and a credential that lets A reach B; the list shows B with its skills and the time of the snapshot. +3. **Given** B is a peer of A, **When** the operator opens B's Peers, **Then** A is not listed — the connection is one-way. +4. **Given** B's card changed since it was connected, **When** the operator presses refresh on the entry, **Then** the snapshot is replaced with the current card and the snapshot time updates; until refresh, A keeps working from the old snapshot. +5. **Given** B is a peer of A, **When** the operator removes it, **Then** A can no longer delegate to B and the credential issued for that connection stops working. +6. **Given** the picker, **When** the operator looks for A itself in the list, **Then** an agent cannot be connected as its own peer, and an agent already connected is shown as connected rather than offered again. +7. **Given** an agent that was connected as a peer is deleted from the installation, **When** the operator views the peers list of any agent that held it, **Then** the entry is marked as gone and can be removed; delegation to it is refused with a clear reason. + +--- + +### User Story 3 - Agent A delegates to peer B and answers with the result (Priority: P1) + +A person chats with agent A and asks something that A cannot do but B, a connected peer, can. A recognises from B's card that B is the right colleague, sends B the task, waits for the answer, and replies to the person using B's result, saying that the answer came from B. A never gets B's tools, files, or knowledge — only B's answer. If B cannot be reached or fails, A says so plainly instead of quietly answering from its own guesswork. + +**Why this priority**: This is the point of the feature — "теперь я могу давать эти задачи ему". Stories 1 and 2 exist to make this possible. + +**Independent Test**: Give B a knowledge base about a topic A knows nothing about. Connect B to A. Ask A a question from that topic and confirm A's answer contains the fact and attributes it to B. Stop B and ask again; confirm A reports that B was unavailable rather than inventing an answer. + +**Acceptance Scenarios**: + +1. **Given** B (with a bound knowledge base on topic X) is a peer of A and the person asks A about topic X, **When** A works on the reply, **Then** A sends the question to B, receives B's answer with its citations, and replies to the person with that answer while stating that it came from B. +2. **Given** A has three peers with distinct skills, **When** the person asks about a topic that only one peer covers, **Then** A delegates to that peer and not to the others. +3. **Given** the person's request contains two independent questions that two different peers cover, **When** A works on the reply, **Then** A may ask both peers in the same turn and combine their answers. +4. **Given** B is not running or does not answer within the platform's reply limit, **When** A delegates to it, **Then** A receives an explicit failure naming the peer and the cause, and A's reply to the person says that B could not be reached. +5. **Given** B answers with a question of its own (it needs clarification), **When** the answer reaches A, **Then** A treats it as B's reply — it may relay the question to the person or answer it and ask B again in the same turn, continuing the same conversation with B. +6. **Given** the person asks A something A can do itself, **When** A works on the reply, **Then** A answers itself without delegating; peers are for what A cannot do, not a default hop. + +--- + +### User Story 4 - The person watching sees the delegation happen (Priority: P1) + +While A is thinking, the chat's thinking timeline — the same place today's "thinking" shimmer and steps live — gains a **delegation step** the moment A decides to ask a peer. The step names the peer, shows what its card promised that made it the choice, A's own one-line reason for choosing it, a live status (asking → waiting → answered / failed) and the elapsed time. When the answer arrives, the step completes and the result feeds the rest of the turn. After the turn ends, the collapsed thinking summary still lists that a delegation took place, and expanding it shows the step again. + +**Why this priority**: The user called this out as an important part of the feature's UX. A delegation that cannot be seen is indistinguishable from a slow answer, and the reason for choosing a peer is the only way an operator can judge whether the cards are written well. + +**Independent Test**: Trigger a delegation from Story 3 while watching the chat. Confirm the delegation step appears before the answer, shows peer name, card summary, reason, and a running wait, then finishes. Trigger a failed delegation and confirm the same step ends in a failed state with the cause. + +**Acceptance Scenarios**: + +1. **Given** A decides to delegate to B, **When** the decision is made, **Then** within a second a step appears in A's thinking timeline reading "Asking «B»" with B's card summary (the skills that matched), A's reason for choosing B, and a status of "waiting". +2. **Given** the step is waiting, **When** the wait continues, **Then** the elapsed time is visible and the thinking indicator keeps animating; there is no period where the chat looks idle. +3. **Given** B answers, **When** the answer arrives, **Then** the step switches to "answered" with the total time and a short excerpt of what came back, and A's reply follows below. +4. **Given** B fails or times out, **When** the failure arrives, **Then** the step switches to "failed" with the cause in product wording, not raw error text. +5. **Given** the turn is complete, **When** the person expands the collapsed thinking summary, **Then** the delegation step is still there with the same content. +6. **Given** a chat surface that already renders thinking steps but has no special delegation rendering, **When** a delegation happens, **Then** it still shows as an ordinary named step with its detail — the delegation step degrades gracefully. + +--- + +### User Story 5 - Delegation cannot run away (Priority: P2) + +Agents delegating to each other must not loop or fan out without bound. If A delegates to B and B would delegate back to A for the same request, B refuses that hop. Chains deeper than a small fixed depth are refused. Every delegation is recorded on the caller's side so that an operator can see who asked whom, when, and how long it took. + +**Why this priority**: Needed before more than a demo pair of agents is connected; the demo itself works without it, so it follows the P1 stories. + +**Independent Test**: Connect A→B and B→A. Ask A something that makes it call B, and give B a card that tempts it to call A back. Confirm the second hop is refused and B answers on its own. Connect a chain A→B→C→D→E and confirm the hop beyond the depth limit is refused with a clear reason. + +**Acceptance Scenarios**: + +1. **Given** A→B and B→A are both connected, **When** A delegates to B for a request and B tries to delegate the same request back to A, **Then** B's attempt is refused with a "would loop" reason and B answers without A. +2. **Given** a chain of peers, **When** a delegation would exceed a depth of 3 hops from the original chat, **Then** it is refused with a "too deep" reason and the last agent answers on its own. +3. **Given** any delegation, **When** it completes or fails, **Then** the caller's records show the peer, the moment it started, the duration, and the outcome, so an operator can audit delegation without reading chat text. + +--- + +### User Story 6 - The demo: two agents, one card, one question (Priority: P2) + +A repeatable walkthrough the team can run in front of an audience in under five minutes: create agent B with a knowledge base on a topic, create agent A without it, show A cannot answer, connect B's card to A, ask again, watch the delegation step, and get the answer with B's citation. + +**Why this priority**: The request names the demo explicitly ("потом демка"). It is the acceptance test of the whole feature, but it delivers nothing on its own. + +**Independent Test**: Follow the walkthrough on a fresh installation and confirm every step behaves as described without improvisation. + +**Acceptance Scenarios**: + +1. **Given** A without peers and a question on topic X, **When** asked, **Then** A says it does not know (no delegation step appears). +2. **Given** B connected as A's peer, **When** the same question is asked, **Then** the delegation step appears, and A answers with B's content and citation. +3. **Given** the demo is run twice on the same installation, **When** the second run starts, **Then** nothing from the first run has to be undone by hand beyond removing the peer. + +--- + +### Edge Cases + +- The peer exists but is not deployed or is restarting: its card is still readable (the card describes the agent, not its uptime), but delegation fails fast with "peer not running", not after the full reply limit. +- The peer's reply limit: a delegation waits at most as long as the platform's synchronous reply limit (today two minutes); a longer job ends in a "timed out" failure that A reports. +- Card snapshot drift: A works from the snapshot taken at connect or last refresh, never from a live read, so a peer editing its description mid-turn does not change A's behaviour until refresh. +- Two peers whose cards read the same: the platform does not block it, but the peers list warns that the two descriptions are indistinguishable, because A will choose between them by chance. +- A peer with no skills at all (no template skills, no knowledge bases): its card is still valid with an empty skills list, and the connect review says so; the operator can still connect it. +- The credential issued at connect is per connection: removing the peer revokes it; re-connecting issues a new one; a credential from one connection cannot be used to reach another agent. +- Self-connection and duplicate connection are refused at the picker and again at save. +- The person's message to A contains attachments: they are not forwarded to the peer in this feature; A sends the peer a text task it composed, and the delegation step shows that text. +- Delegation inside a public share-link chat or the embed: behaves exactly as in the admin chat, but the delegation step renders with the surface's existing generic step styling (Story 4, scenario 6). +- A's own card lists nothing about its peers: having colleagues is not a skill, and advertising them would invite chains. +- Concurrent delegations from one turn: allowed; each has its own step and its own time; loop and depth checks apply to each independently. + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: Every agent MUST have an agent card derived from its current state: name, description, one skill per skill of its template, and one skill per bound knowledge base. The card MUST include the address for sending tasks and the credential scheme required. It MUST reflect changes to the underlying agent on the next read with no manual regeneration. +- **FR-002**: The card MUST be readable at a standard well-known address for that agent by any client presenting a valid credential for it (an operator session, or a peer credential issued for that agent). Requests with no valid credential MUST be refused. +- **FR-003**: The admin console MUST show an agent's card on the agent's page in the same terms another agent would read it (name, description, skills, address). +- **FR-004**: The admin console MUST let an operator connect another agent of the same installation as a peer: choose from a list, preview the fetched card, confirm. The list MUST exclude the agent itself and mark agents already connected. +- **FR-005**: A peer connection MUST be directed (A→B does not imply B→A), MUST allow any number of peers per agent, and MUST store a snapshot of the peer's card and the time it was read. +- **FR-006**: The operator MUST be able to refresh a peer's card snapshot and to remove a peer. Removing a peer MUST revoke the credential issued for that connection. +- **FR-007**: Connecting a peer MUST issue a credential scoped to exactly that (caller, peer) pair; the peer MUST accept tasks only from callers presenting a credential issued for it. +- **FR-008**: During a chat turn, an agent MUST be able to delegate a text task to any of its connected peers and receive the peer's reply, including the peer's citations when the peer provides them. The delegating agent MUST NOT gain access to the peer's tools, files or knowledge bases — only to its reply. +- **FR-009**: The delegating agent MUST be given, at the start of each turn, the list of its peers with the skills and descriptions from their card snapshots, so that it can decide when and whom to ask without an extra lookup. +- **FR-010**: A delegation MUST be able to continue a conversation with the same peer within the same turn (the peer's follow-up question answered and re-sent), and MUST start a fresh conversation with the peer in a new turn. +- **FR-011**: When a peer is not running, refuses the task, fails, or does not reply within the platform's synchronous reply limit, the delegating agent MUST receive an explicit failure naming the peer and the cause, and MUST NOT be able to mistake it for an answer. +- **FR-012**: Every delegation request MUST carry the chain of agents already involved in the current person's request. A peer MUST refuse a task that would place an agent already in the chain back into it, or that would make the chain longer than 3 hops, and the refusal MUST state which rule applied. +- **FR-013**: Each delegation MUST appear in the caller's thinking timeline as a step, emitted when the decision to delegate is made, carrying: the peer's name, the card content that matched (skills), the caller's one-line reason for the choice, the task text sent, a status that moves through waiting → answered or failed, and the elapsed time. On completion the step MUST carry a short excerpt of the reply or the failure cause in product wording. +- **FR-014**: The delegation step MUST travel through the existing thinking-step mechanism so that every chat surface that renders thinking steps shows it; the admin chat MUST render it with the dedicated delegation layout (peer, card summary, reason, status, time). +- **FR-015**: After a turn completes, the delegation step MUST remain visible inside the turn's collapsed thinking summary and when re-expanded, for as long as that turn's thinking steps are kept today. +- **FR-016**: Each delegation MUST be recorded on the caller's side with peer, start time, duration and outcome, so an operator can audit delegations without reading chat text. +- **FR-017**: Delegation MUST NOT be offered by default: an agent with no connected peers MUST behave exactly as it does today, with no delegation tool, step, or wording appearing anywhere. +- **FR-018**: The agent runtime image MUST NOT need to change for delegation to work; the capability MUST be provided through the tools the platform already serves to agents. +- **FR-019**: The card MUST NOT list the agent's peers. + +### Key Entities + +- **Agent card**: the public description of one agent as read by another agent — name, description, skills (each with a name and a description that says when to ask), task address, credential scheme. Derived, not stored; a **card snapshot** is the copy a peer connection keeps. +- **Peer connection**: a directed link from a caller agent to a peer agent: the snapshot of the peer's card, when it was read, the credential issued for this pair, and a "gone" marker when the peer no longer exists. +- **Delegation**: one task handed from a caller to a peer during one chat turn: the task text, the chain of agents so far, the peer's reply or the failure, start time and duration. May continue as a conversation with the peer within the same turn. +- **Delegation step**: the entry in the caller's thinking timeline that represents a delegation to the person watching: peer, matched card content, reason, task text, status, elapsed time, excerpt or cause. +- **Peer credential**: the proof a caller presents to a peer; issued at connect, scoped to one (caller, peer) pair, revoked at removal. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: An operator can go from "agent B exists" to "B is a peer of A with a reviewed card" in under one minute and at most three interactions (open Peers, pick B, confirm). +- **SC-002**: In the demo scenario, 100% of runs produce an answer from A that contains B's fact and names B as the source; 0% of runs produce that fact from A without a delegation step. +- **SC-003**: The delegation step appears in the chat within one second of the agent deciding to delegate, and there is no moment during the wait when the chat shows no activity indication. +- **SC-004**: When the peer is stopped, 100% of delegations end in an explicit "peer unavailable" outcome that is visible both in the thinking step and in A's reply; 0% of such turns produce a fabricated answer presented as coming from the peer. +- **SC-005**: With three peers holding clearly distinct cards, A picks the right peer for a topic that only one of them covers in at least 9 of 10 test questions. +- **SC-006**: With A→B and B→A connected, 100% of attempted back-hops are refused, and no request ever involves more than 3 agents beyond the one the person is talking to. +- **SC-007**: An agent with no peers shows no behavioural or visual difference from before this feature in its existing acceptance scenarios (no regressions). +- **SC-008**: A person shown a delegation step for the first time can state, without help, which agent was asked and why, in at least 4 of 5 hallway tests. + +## Assumptions + +- **Protocol shape**: the exchange between agents follows the A2A protocol's shapes — a card at a well-known address, tasks sent as messages with a task status and a reply — so that a second Ranch installation, or a foreign A2A agent, later needs only a credential arrangement, not a redesign. Planning decides how much of the protocol surface (task states, streaming) is honoured beyond the synchronous request-and-reply used here. +- **Where the client lives**: the delegating side is a tool the platform serves to the agent runtime through the tool channel it already uses (the same way knowledge search is served), whose description lists the agent's peers. This is why the runtime image does not change. +- **Card skills**: template skills become card skills as-is; a bound knowledge base becomes a card skill named after the base with the base's description; an agent's description is the card's description. No separate hand-edited card text in this feature. +- **Credential scheme**: a platform-issued, per-connection secret presented as a bearer credential; operators never see or type it. Anonymous card discovery is off. +- **Reply limit**: delegations are bounded by the platform's existing synchronous chat reply limit (two minutes today). Longer peer jobs are a later feature. +- **Depth limit**: 3 hops beyond the agent the person is talking to. Chosen as the smallest number that still allows A→B→C. +- **"Фиксируем знания"** means the caller remembers the peer's card and nothing else; peer replies are not written into any knowledge base. +- **Surfaces**: the peers list and the card view live in the admin console on the agent's page; the user console is unchanged except that its chat, if it renders thinking steps, shows the delegation step in its generic style. +- **Persistence of the step**: delegation steps live as long as today's thinking steps do; making them part of the durable transcript is not required here, but the delegation record (FR-016) is durable. +- **Same installation only**: the picker lists this installation's agents; the address stored in a snapshot is nonetheless a full address, so nothing in the data model assumes locality. From 5f87cf0c33ae69a0fc37109bf2438b88275cd440 Mon Sep 17 00:00:00 2001 From: "Maksym Hryzodub [DREAM]" Date: Thu, 10 Sep 2026 19:13:55 +0300 Subject: [PATCH 02/31] =?UTF-8?q?docs(plan):=20A2A=20peers=20=E2=80=94=20r?= =?UTF-8?q?esearch,=20data=20model,=20contracts,=20quickstart=20(CLEAN-74)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01SZ9dCrzZqd5SwR4erLYe12 --- .../013-a2a-agent-peers/contracts/a2a-api.md | 205 ++++++++++++++++++ specs/013-a2a-agent-peers/data-model.md | 132 +++++++++++ specs/013-a2a-agent-peers/plan.md | 139 ++++++++++++ specs/013-a2a-agent-peers/quickstart.md | 94 ++++++++ specs/013-a2a-agent-peers/research.md | 110 ++++++++++ 5 files changed, 680 insertions(+) create mode 100644 specs/013-a2a-agent-peers/contracts/a2a-api.md create mode 100644 specs/013-a2a-agent-peers/data-model.md create mode 100644 specs/013-a2a-agent-peers/plan.md create mode 100644 specs/013-a2a-agent-peers/quickstart.md create mode 100644 specs/013-a2a-agent-peers/research.md diff --git a/specs/013-a2a-agent-peers/contracts/a2a-api.md b/specs/013-a2a-agent-peers/contracts/a2a-api.md new file mode 100644 index 00000000..b2ee9ae3 --- /dev/null +++ b/specs/013-a2a-agent-peers/contracts/a2a-api.md @@ -0,0 +1,205 @@ +# Contracts: A2A endpoints, peers REST, `ask_agent` tool, delegation step + +**Ticket**: [CLEAN-74](https://dreamvention.atlassian.net/browse/CLEAN-74) · Model: [data-model.md](../data-model.md) · Decisions: [research.md](../research.md) §3 + +All REST responses are wrapped by the global interceptor as `{ success: true, data }`; the A2A JSON-RPC endpoint and the card are **exempt** (raw bodies, the protocol owns the envelope). Errors on REST are Nest-standard `{ statusCode, message, code? }`. + +## 1. Agent card + +`GET /a2a/agents/:agentId/.well-known/agent-card.json` + +Auth (`A2aCardGuard`): `Authorization: Bearer ` **or** `Bearer ap_…` whose `AgentPeer.peerAgentId === :agentId`. Anything else → `401 { code: 'A2A_UNAUTHORIZED' }`. Unknown agent → `404`. + +Response `200 application/json` — an A2A 1.0 `AgentCard`, no envelope: + +```json +{ + "name": "Support Bot", + "description": "Answers customer questions about orders and returns.", + "version": "1", + "supportedInterfaces": [ + { "url": "https://api.ranch.example/a2a/agents/6f1c…", "protocolBinding": "JSONRPC", "protocolVersion": "1.0" } + ], + "capabilities": { "streaming": false, "pushNotifications": false, "extensions": [] }, + "defaultInputModes": ["text/plain"], + "defaultOutputModes": ["text/plain"], + "skills": [ + { "id": "skill:2b…", "name": "Order lookup", "description": "Finds an order by number or email.", "tags": ["skill"] }, + { "id": "knowledge:9a…", "name": "Returns policy", "description": "Answers questions about «Returns policy»: 2026 policy PDF and FAQ.", "tags": ["knowledge"] } + ], + "securitySchemes": { "peerBearer": { "httpAuthSecurityScheme": { "scheme": "bearer" } } }, + "securityRequirements": [ { "schemes": { "peerBearer": { "list": [] } } } ], + "provider": { "organization": "Ranch", "url": "https://api.ranch.example" } +} +``` + +Field derivation: data-model §3. The card never lists peers. + +## 2. A2A JSON-RPC endpoint + +`POST /a2a/agents/:agentId` · `Content-Type: application/json` · request header `A2A-Version: 1.0` (absent → treated as 0.3 by the spec; this server answers `-32009 VersionNotSupported` for anything but `1.0`). + +Auth (`A2aPeerGuard`): `Bearer ap_…` whose row has `peerAgentId === :agentId`. The row's `agentId` is the **caller** for the rest of the request. Console JWTs are **not** accepted here. Failure → `401 { code: 'A2A_UNAUTHORIZED' }` (HTTP, before JSON-RPC parsing). + +### 2.1 `SendMessage` + +Request: + +```json +{ + "jsonrpc": "2.0", "id": 1, "method": "SendMessage", + "params": { + "message": { + "messageId": "m-…", "role": "ROLE_USER", + "parts": [ { "text": "What is the return window for shoes?" } ], + "contextId": "ctx-…", + "metadata": { "ranch": { "chain": [""], "reason": "Peer holds the returns policy base" } } + }, + "configuration": { "acceptedOutputModes": ["text/plain"], "returnImmediately": false } + } +} +``` + +Rules, in order: + +| Check | Outcome | +|---|---| +| `returnImmediately: true`, or any part without `text` | `-32004 UnsupportedOperation` / `-32005 ContentTypeNotSupported` | +| `metadata.ranch.chain` contains `:agentId` | task `TASK_STATE_REJECTED`, status message `"would loop: is already in the chain"`, `metadata.ranch.rejection = 'loop'` | +| `metadata.ranch.chain.length >= 3` | task `TASK_STATE_REJECTED`, `"too deep: chain limit is 3 hops"`, `rejection = 'depth'` | +| `!hub.isAgentConnected(:agentId)` | task `TASK_STATE_FAILED`, `"peer not running"`, `metadata.ranch.failure = 'not_running'` | +| runtime does not answer within the sync limit (120 s) | task `TASK_STATE_FAILED`, `"timed out after 120s"`, `failure = 'timeout'` | +| reply received | task `TASK_STATE_COMPLETED`, one artifact | + +The message text (all `text` parts joined by blank lines) is sent to the runtime through `BridleSyncService.sendAndAwait` with `clientId = peer::`, `capabilities: []`, and the `chain` **extended with `:agentId`** stored on the task metadata so the peer's own `ask_agent` (if any) forwards it. `contextId` absent → minted (`ctx-`). + +Response (blocking, final task): + +```json +{ + "jsonrpc": "2.0", "id": 1, + "result": { + "task": { + "id": "…delegation/task id…", "contextId": "ctx-…", + "status": { "state": "TASK_STATE_COMPLETED", "timestamp": "2026-09-10T12:00:03.120Z" }, + "artifacts": [ + { "artifactId": "reply", "name": "reply", "parts": [ { "text": "Shoes can be returned within 30 days …" } ] } + ], + "history": [], + "metadata": { "ranch": { "chain": ["", ""], "durationMs": 3120 } } + } + } +} +``` + +Rejected/failed tasks put the cause in `status.message`: `{ "messageId": "…", "role": "ROLE_AGENT", "parts": [ { "text": "would loop: …" } ] }` and have no artifacts. + +### 2.2 `GetTask` + +`params: { "id": "" }` → the same task object while it is in the in-memory store (10 min), else `-32001 TaskNotFound`. + +### 2.3 Everything else + +`SendStreamingMessage`, `SubscribeToTask`, `CancelTask`, `ListTasks`, push-config methods, `GetExtendedAgentCard` → `-32004 UnsupportedOperation` (`CancelTask` on a known task: `-32002`). Unknown method → `-32601`. Malformed body → `-32700` / `-32600`. + +## 3. Peers REST (admin console) + +All under `@UseGuards(JwtAuthGuard, RolesGuard)` + `@Roles(Owner, Admin)`. Response DTOs never include `token`. + +| Method | Path | operationId | Body / query | Returns | +|---|---|---|---|---| +| `GET` | `/agents/:agentId/peers` | `listAgentPeers` | — | `AgentPeerDto[]` | +| `GET` | `/agents/:agentId/peers/candidates` | `listAgentPeerCandidates` | — | `AgentPeerCandidateDto[]` — every other agent of the installation with `connected: boolean` | +| `GET` | `/agents/:agentId/card` | `getAgentCard` | — | `AgentCardDto` — this agent's own card (same JSON as §1, wrapped) — the "Agent card" view and the picker preview (`/agents/:peerId/card`) | +| `POST` | `/agents/:agentId/peers` | `connectAgentPeer` | `{ peerAgentId }` | `201 AgentPeerDto` — reads the peer's card **over HTTP at its card URL using the freshly minted credential** (proves the credential and URL work), stores the snapshot | +| `POST` | `/agents/:agentId/peers/:peerId/refresh` | `refreshAgentPeer` | — | `AgentPeerDto` with a new snapshot and `cardReadAt` | +| `DELETE` | `/agents/:agentId/peers/:peerId` | `removeAgentPeer` | — | `204` — deletes the row (revokes the credential) | +| `GET` | `/agents/:agentId/delegations` | `listAgentDelegations` | `?limit=20` | `AgentDelegationDto[]` newest first | + +`AgentPeerDto`: `{ id, agentId, peerAgentId, peerName, peerStatus, peerExists, card: AgentCardDto, cardUrl, cardReadAt, createdAt }` — `peerStatus` is the live agent status string so the list can show "not running". + +`AgentPeerCandidateDto`: `{ id, name, status, connected }`. + +`AgentDelegationDto`: `{ id, peerAgentId, peerName, task, reason, status, errorCode, excerpt, startedAt, finishedAt, durationMs }`. + +Errors: `400 { code: 'PEER_SELF' }`, `409 { code: 'PEER_EXISTS' }`, `404 { code: 'PEER_NOT_FOUND' }` (peer agent or connection), `502 { code: 'PEER_CARD_UNREACHABLE', message }` when the card fetch at connect/refresh fails (nothing is stored on connect failure; on refresh failure the old snapshot is kept). + +## 4. MCP tool `ask_agent` (served to the agent runtime) + +Listed for a caller only when it has ≥ 1 peer (`isListedForRequest`). Calls from a non-agent principal or for a peer not connected to the caller return `isError`. + +Static description (used when the caller has no peers — which, given the listing filter, only happens if a pod cached the tool before its last peer was removed): + +> Ask one of your connected peer agents to do a task you cannot do yourself. You currently have no peers connected; do not call this tool. + +Dynamic description (per request, from card snapshots): + +> Ask one of your connected peer agents to do a task you cannot do yourself, and use their reply in your answer, saying it came from them. Call this when the user asks about something that a peer's skills below cover and your own tools do not. Do not call it for things you can do yourself. Send the peer a self-contained task in plain text — it does not see this conversation. If several independent questions go to different peers, call this tool for each in the same turn. Give a one-line `reason` naming the skill that made you choose this peer; it is shown to the user. +> +> Your peers: +> - "Support Bot" (peer: `6f1c…`) — Answers customer questions about orders and returns. Skills: Order lookup (Finds an order by number or email); Returns policy (Answers questions about «Returns policy»: 2026 policy PDF and FAQ) +> - … + +Input schema (zod): + +```ts +z.object({ + peer: z.string().describe('Peer id from the list above, or the peer\'s exact name'), + task: z.string().min(1).describe('Self-contained task text for the peer'), + reason: z.string().min(1).describe('One line: why this peer — name the matching skill'), + context_id: z.string().optional().describe('Continue an earlier exchange with the same peer in this turn: pass the context_id from its previous result'), +}) +``` + +Result text (success): + +``` +Reply from «Support Bot» (context_id: ctx-…, 3.1s): + +Shoes can be returned within 30 days … +``` + +Result text (`isError: true`), one of: + +``` +Could not reach «Support Bot»: peer not running. Tell the user you could not get this from Support Bot; do not guess on its behalf. +Could not reach «Support Bot»: timed out after 120s. … +«Support Bot» refused the task: would loop: … / too deep: … +No peer matches "…". Your peers are: … +``` + +Side effects, in order: create `AgentDelegation` (`waiting`) → push delegation step (`active`) if an active turn is known → HTTP `SendMessage` to the snapshot URL with `Bearer `, `A2A-Version: 1.0`, `metadata.ranch.chain = [...inboundChain, callerId]` (inbound chain = the chain the caller itself was called with, if this turn is itself a delegation; else `[]`) → finalise the row → push the step (`done`). + +## 5. Delegation thinking step (WebSocket `thinking` event) + +Pushed by the API with `hub.sendToClient(clientId, agentId, event)`: + +```json +{ + "type": "thinking", "clientId": "admin", "turnId": "", "ts": 1757505600000, + "step": { + "id": "delegation:5e2a…", "label": "Asking «Support Bot»", "state": "active", + "kind": "delegation", + "delegation": { + "delegationId": "5e2a…", "peerAgentId": "6f1c…", "peerName": "Support Bot", + "matchedSkills": [ { "id": "knowledge:9a…", "name": "Returns policy" } ], + "reason": "Support Bot holds the returns policy base", + "task": "What is the return window for shoes?", + "status": "waiting", "startedAt": 1757505600000 + }, + "detail": "**Peer:** Support Bot — Returns policy\n**Why:** Support Bot holds the returns policy base\n**Task:** What is the return window for shoes?\n**Status:** waiting…" + } +} +``` + +Final push: same `id`, `state: "done"`, `label` `Answered by «Support Bot»` / `Could not reach «Support Bot»` / `«Support Bot» refused the task`, `delegation.status` final, `durationMs`, `excerpt`, and `detail` rewritten with status, time and excerpt/cause. No `done: true` is ever sent by the API — the turn's terminal event stays the runtime's. + +Admin rendering (`admin/slices/bridle/components/bridle/DelegationStep.vue`): peer name as the label with a peer icon; a row of matched-skill badges; "why" line; the task in a quiet quote; status pill with live elapsed time (`now - startedAt` while `waiting`, `durationMs` after); excerpt or cause. Generic renderers show `label` + markdown `detail`. + +## 6. Configuration + +| Key | Where | Default | Used for | +|---|---|---|---| +| `api_public_url` | infra setting (`infrastructure` group) → env `API_PUBLIC_URL` → fallback integration `ranch_api_url` | local: `http://localhost:3333` (via `ranch_api_url` default `http://host.k3d.internal:3333` in-cluster) | absolute `supportedInterfaces[0].url`, `cardUrl` | +| `A2A_SYNC_TIMEOUT_MS` | env | `120000` (same as the bridle sync route) | delegation wait | +| `A2A_MAX_CHAIN` | env | `3` | depth rule | diff --git a/specs/013-a2a-agent-peers/data-model.md b/specs/013-a2a-agent-peers/data-model.md new file mode 100644 index 00000000..120655b2 --- /dev/null +++ b/specs/013-a2a-agent-peers/data-model.md @@ -0,0 +1,132 @@ +# Data model: agent cards, peers, delegations, delegation step + +**Ticket**: [CLEAN-74](https://dreamvention.atlassian.net/browse/CLEAN-74) · Decisions: [research.md](./research.md) §3 · Wire shapes: [contracts/a2a-api.md](./contracts/a2a-api.md) + +Two new Prisma models (one additive migration), one derived object (the card), one in-memory object (the task), and one additive shape on an existing wire type (the thinking step). + +## 1. `AgentPeer` — a directed connection (new table) + +Fragment: `api/src/slices/agent/peer/peer.prisma`, imports `Agent` from `../agent/agent`. + +| Field | Type | Notes | +|---|---|---| +| `id` | `String @id @default(uuid())` | Peer connection id — also the `jti`-like identity the credential is scoped to | +| `agentId` | `String` | **Caller** (the agent that holds the card). FK → `Agent`, `onDelete: Cascade` | +| `peerAgentId` | `String` | **Peer** (the agent whose card is held). FK → `Agent`, `onDelete: Cascade` — see "gone" below | +| `token` | `String @unique` | Pair credential `ap_` + 43 base64url chars, plaintext (R4). Presented by the caller as `Authorization: Bearer` to the peer's A2A endpoint. Never returned by any DTO | +| `cardSnapshot` | `Json` | The peer's `AgentCard` as read at connect/refresh (contracts §1). The tool and the picker read this, never a live card | +| `cardUrl` | `String` | Absolute URL the snapshot was read from — `/a2a/agents//.well-known/agent-card.json` | +| `cardReadAt` | `DateTime` | Snapshot time; shown in the list | +| `createdAt` / `updatedAt` | `DateTime` | Prisma defaults | + +Constraints and rules: + +- `@@unique([agentId, peerAgentId])` — one connection per direction (spec Story 2 scenario 6). The reverse direction is a different row. +- `agentId != peerAgentId` — enforced in `PeerService.connect` (self-connection refused with `PEER_SELF`); also refused in the picker. +- **Directed**: reading peers of A returns rows where `agentId = A`. Nothing is inferred from rows where `peerAgentId = A`. +- **"Gone" peer**: the FK cascades, so a deleted peer agent removes the row. The "marked as gone" state of the spec is therefore transient and derived: the list endpoint joins the peer agent and reports `peerExists: false` only when the agent row is missing at read time (which cannot happen after cascade) — in practice the entry disappears and the tool's description no longer lists it. Kept in the DTO for the second-installation future where the peer is remote. +- Indexes: `@@index([agentId])`, `@@index([peerAgentId])`. + +Relations added on `Agent` (in `agent.prisma`): `peers AgentPeer[] @relation("AgentPeerCaller")`, `peerOf AgentPeer[] @relation("AgentPeerTarget")`. + +## 2. `AgentDelegation` — one task handed to a peer (new table) + +Fragment: `api/src/slices/agent/peer/delegation.prisma` (same slice). Shape copies `PaddockEvaluation` (status / startedAt / finishedAt / error). + +| Field | Type | Notes | +|---|---|---| +| `id` | `String @id @default(uuid())` | Also the A2A `taskId` returned to the caller and the thinking step id suffix | +| `agentId` | `String` | Caller. FK → `Agent`, `onDelete: Cascade`, `@@index` | +| `peerId` | `String?` | FK → `AgentPeer`, `onDelete: SetNull` — the record outlives a removed connection | +| `peerAgentId` | `String` | Denormalised so the audit row still names the peer after `peerId` is nulled | +| `peerName` | `String` | Peer name at the time (from the snapshot) | +| `contextId` | `String` | A2A `contextId`; same value across follow-ups within one turn (FR-010) | +| `turnId` | `String?` | Caller's thinking turn, when known (R8) | +| `clientId` | `String?` | Caller's chat client the step was pushed to, when known | +| `task` | `String` | Text sent to the peer | +| `reason` | `String` | The caller model's one-line reason (tool argument) | +| `matchedSkills` | `Json` | `[{ id, name }]` — snapshot skills quoted in the step | +| `status` | `String` | `waiting` → `answered` \| `failed` \| `rejected` (see §2.1) | +| `errorCode` | `String?` | `PEER_NOT_RUNNING` \| `PEER_TIMEOUT` \| `PEER_REJECTED_LOOP` \| `PEER_REJECTED_DEPTH` \| `PEER_UNAUTHORIZED` \| `PEER_UNREACHABLE` \| `PEER_ERROR` | +| `excerpt` | `String?` | First 300 characters of the reply, or the failure cause in product wording | +| `startedAt` | `DateTime @default(now())` | | +| `finishedAt` | `DateTime?` | | +| `durationMs` | `Int?` | `finishedAt - startedAt` | + +Indexes: `@@index([agentId, startedAt])`. + +### 2.1 Status transitions + +``` +waiting ──answered──► answered (peer task TASK_STATE_COMPLETED) + │ + ├──failed────────► failed (PEER_NOT_RUNNING | PEER_TIMEOUT | PEER_UNREACHABLE | PEER_UNAUTHORIZED | PEER_ERROR) + │ + └──rejected──────► rejected (peer task TASK_STATE_REJECTED: PEER_REJECTED_LOOP | PEER_REJECTED_DEPTH) +``` + +A row is created in `waiting` **before** the HTTP call (so a crash mid-call still leaves an audit row) and finalised exactly once. There is no `canceled`: the caller cannot cancel in this feature. + +Retention: none in this feature; rows are small and bounded by chat volume. A prune is a later concern. + +## 3. `AgentCard` — derived, never stored except as a snapshot + +Built by `AgentCardService.build(agentId)` from `Agent`, `Template`, `Skill[]`, `Knowledge[]`. Exact JSON in [contracts §1](./contracts/a2a-api.md#1-agent-card). Derivation rules: + +| Card field | Source | +|---|---| +| `name` | `agent.name` | +| `description` | `agent.config.description` if present, else `template.description` — never empty (falls back to `"Ranch agent «»"`) | +| `version` | `template.version ?? '1'` | +| `supportedInterfaces[0]` | `{ url: /a2a/agents/, protocolBinding: 'JSONRPC', protocolVersion: '1.0' }` | +| `capabilities` | `{ streaming: false, pushNotifications: false, extensions: [] }` | +| `defaultInputModes` / `defaultOutputModes` | `['text/plain']` | +| `skills[]` from template skills | `{ id: 'skill:', name: skill.title, description: skill.description ?? skill.title, tags: ['skill'] }` | +| `skills[]` from knowledge bases | `{ id: 'knowledge:', name: kb.name, description: 'Answers questions about «»' + (kb.description ? ': ' + kb.description : ''), tags: ['knowledge'] }` — effective bases = `agent.knowledgeIds` if non-empty else `template.defaultKnowledgeIds`, filtered through `findExistingByIds` | +| `securitySchemes` | `{ peerBearer: { httpAuthSecurityScheme: { scheme: 'bearer' } } }` | +| `securityRequirements` | `[{ schemes: { peerBearer: { list: [] } } }]` | +| `provider` | `{ organization: 'Ranch', url: }` | + +Rules: skills may be empty (spec edge case); the card never mentions peers (FR-019); the card is rebuilt on every read (FR-001) — no cache. + +## 4. `A2aTask` — in-memory, per receiving agent + +`Map` in `A2aTaskStore`, TTL 10 minutes, swept lazily on access and by a 1-minute interval. Holds exactly what `SendMessage` returned so `GetTask` can repeat it. `contextId` is the caller-supplied value (or minted when absent). Not persisted (R12). + +## 5. Delegation thinking step — additive fields on `IBridleThinkingStep` + +`api/src/slices/bridle/domain/bridle.types.ts` (mirrored in `admin/slices/bridle/stores/bridle.ts`): + +```ts +interface IBridleThinkingStep { + id: string; // 'delegation:' for delegation steps + label: string; // 'Asking «B»' | 'Answered by «B»' | 'Could not reach «B»' + detail?: string; // markdown fallback for generic renderers (see contracts §4) + state: 'active' | 'done'; + kind?: 'delegation'; // NEW, optional — absent on runtime steps + delegation?: { // NEW, present iff kind === 'delegation' + delegationId: string; + peerAgentId: string; + peerName: string; + matchedSkills: { id: string; name: string }[]; + reason: string; + task: string; + status: 'waiting' | 'answered' | 'failed' | 'rejected'; + startedAt: number; // epoch ms — the client derives elapsed time while waiting + durationMs?: number; + excerpt?: string; // reply excerpt (answered) or cause in product wording (failed/rejected) + }; +} +``` + +The step is emitted twice per delegation with the same `id`: once at start (`state: 'active'`, `status: 'waiting'`) and once at the end (`state: 'done'`, final status). The admin store already replaces a step by id. + +## 6. Identity strings used across the feature + +| String | Format | Where | +|---|---|---| +| Peer credential | `ap_` + 43 base64url chars | `AgentPeer.token`; bearer to `/a2a/agents/:peerAgentId` | +| Bridle client id for a peer conversation | `peer::` | registered by the A2A server on the peer agent; gives the peer's runtime one conversation per (caller, context) | +| Thinking step id | `delegation:` | caller's thinking timeline | +| Card skill ids | `skill:`, `knowledge:` | card and `matchedSkills` | +| Chain | `metadata.ranch.chain: string[]` of agent ids, oldest first, caller last | every `SendMessage` | diff --git a/specs/013-a2a-agent-peers/plan.md b/specs/013-a2a-agent-peers/plan.md new file mode 100644 index 00000000..546f2d09 --- /dev/null +++ b/specs/013-a2a-agent-peers/plan.md @@ -0,0 +1,139 @@ +# Implementation Plan: Agent-to-agent (A2A) — agent cards, peer agents, and delegation you can see + +**Branch**: `feat/CLEAN-74-a2a-agent-peers` | **Date**: 2026-09-10 | **Spec**: [spec.md](./spec.md) + +**Input**: Feature specification from `specs/013-a2a-agent-peers/spec.md` | **Jira**: [CLEAN-74](https://dreamvention.atlassian.net/browse/CLEAN-74) + +## Summary + +Give every Ranch agent an **A2A 1.0 agent card** (derived from name, description, template skills and bound knowledge bases; served at `/a2a/agents/:id/.well-known/agent-card.json` behind a credential) and a **JSON-RPC endpoint** (`SendMessage`, `GetTask`) that turns an inbound task into a synchronous bridle conversation on that agent. Let an operator **connect peers** in the admin console (new `Peers` tab: own card, candidate picker with card preview, list with refresh/remove, recent delegations); each connection is a directed `AgentPeer` row holding a card snapshot and a pair-scoped `ap_` credential. Let the agent **delegate** through one new MCP tool, `ask_agent`, served by the API's existing MCP runtime with a per-request description listing its peers, listed only for agents that have peers, calling the peer over HTTP at the snapshot URL with a `chain` for loop/depth refusal. Make the delegation **visible**: the hub learns the active turn from the thinking stream it already relays, and the tool pushes a structured `delegation` thinking step (peer, matched skills, reason, task, status, elapsed, excerpt) that the admin chat renders with a dedicated layout and every other surface renders as a plain step. Every delegation is an `AgentDelegation` audit row. Decisions R1–R12 in [research.md](./research.md) §3. + +## Technical Context + +**Language/Version**: TypeScript — NestJS 10 API on Bun (Jest tests, Express 5, `@modelcontextprotocol/sdk` server already mounted at `POST /mcp/mcp`, socket.io hub); Nuxt 4 / Vue 3 SPA (`ssr: false`) for `admin` + +**Primary Dependencies**: NestJS + Prisma (`prisma-import` per-slice fragments), class-validator/Swagger DTOs, zod (tool schemas), the bridle hub (`IBridleGateway`), the MCP registry (`@Tool`, `IDynamicallyDescribedTool`). **No new package**: A2A 1.0 types are hand-written from the proto (R1); HTTP client is global `fetch`. Console: Pinia store → service → gateway → `@hey-api/client-axios` SDK (`build:api`), shadcn-vue kit (no Dialog/Command — hand-built on reka-ui like `ConfirmDialog`), `socket.io-client` for the thinking stream + +**Storage**: PostgreSQL via Prisma — two new tables `AgentPeer`, `AgentDelegation` (one additive migration `20260910120000_agent_peer_delegation`), two back-relations on `Agent`; A2A tasks in memory (10 min TTL); the card is derived on every read + +**Testing**: API — Jest, colocated `*.spec.ts`, hand-rolled stubs (`cd api && bun run test -- peer agentCard a2a askAgent bridleSync bridle.gateway mcp-tools`); admin has no runner — verification = `bun run build:api && bun run typecheck`, then [quickstart.md](./quickstart.md) §3–6 by hand (local, then once in-cluster) + +**Target Platform**: Linux API in k8s (single replica — required already by the stateful MCP transport) behind `api.ranch.cleanslice.org`; admin console SPA; agent pods on the same cluster reach the API at `ranch_api_url` + +**Project Type**: Web application — monorepo slices `api` + `admin` (the `app` console is untouched) + +**Performance Goals**: connecting a peer = one card fetch over HTTP (< 1 s); a delegation adds one HTTP round-trip on top of the peer's own reply time; the thinking step is pushed within the same tick as the tool call (< 1 s to the browser, SC-003); `tools/list` gains one indexed query per listing (peers of the caller) + +**Constraints**: the runtime image does not change (FR-018) — everything reaches the agent through the tool channel it already uses; tools are listed once per pod session, so peer changes apply on restart (R7, surfaced by `markPendingRestart`); the sync reply limit stays 120 s; the card never lists peers (FR-019); the credential is never returned by a DTO; the API is the presenter of the pair credential, hence plaintext storage like `sl_` (R4); `admin/` copy is raw English; no A2A streaming, push notifications, or attachments forwarded to peers in this feature + +**Scale/Scope**: tens of agents, a handful of peers each; delegations bounded by chat volume; chain depth ≤ 3 + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +`.specify/memory/constitution.md` is an unfilled template — no project-specific gates. Applied baseline: CleanSlice layering (controller → domain service → abstract gateway; Prisma types only in mappers; DTOs never leak into domain), one new slice per concern (`agent/peer` owns peers, cards, A2A, the tool and the delegation record — one concern: "agents talking to agents"), targeted improvements to code the feature must touch (extract the sync wait into a service; per-request tool listing filter honoured only by the new tool), no speculative abstractions (no streaming, no push, no task persistence, no cross-installation URL entry, no cancel), tests for every new server path. **PASS** pre-Phase-0 and post-Phase-1. + +## Project Structure + +### Documentation (this feature) + +```text +specs/013-a2a-agent-peers/ +├── plan.md # This file +├── research.md # §1 audit with file refs, §2 A2A 1.0 as published, §3 decisions R1–R12, §4 risks +├── data-model.md # AgentPeer, AgentDelegation, derived AgentCard, in-memory task, delegation step fields +├── quickstart.md # unit tests, migration/regen, curl checks, the demo walkthrough, in-cluster check +├── contracts/ +│ └── a2a-api.md # card JSON, JSON-RPC SendMessage/GetTask + rules, peers REST, ask_agent tool, thinking step, config +├── checklists/requirements.md +└── tasks.md # Phase 2 output (/speckit-tasks — NOT created here) +``` + +### Source Code (repository root) + +```text +api/ +├── .env.example # + API_PUBLIC_URL, A2A_SYNC_TIMEOUT_MS, A2A_MAX_CHAIN +├── prisma/migrations/20260910120000_agent_peer_delegation/migration.sql # additive: AgentPeer, AgentDelegation +└── src/ + ├── app.module.ts # + PeerModule + └── slices/ + ├── agent/peer/ # NEW slice — peers, cards, A2A, ask_agent, delegations + │ ├── peer.prisma # model AgentPeer + │ ├── delegation.prisma # model AgentDelegation + │ ├── peer.module.ts # providers: services, gateways, tool, guards; imports Agent/Template/Skill/Knowledge/Bridle/Setting modules (forwardRef where cyclic) + │ ├── domain/ + │ │ ├── a2a.types.ts # AgentCard, AgentSkill, Task, TaskState, Message, Part, JSON-RPC envelope/error codes (A2A 1.0 names) + │ │ ├── peer.types.ts # IAgentPeerData, IAgentDelegationData, PeerErrorCodes, DelegationStatus, DelegationErrorCodes + │ │ ├── peer.gateway.ts # abstract IPeerGateway (list/find/create/updateSnapshot/delete, findByToken, candidates) + │ │ ├── delegation.gateway.ts # abstract IDelegationGateway (create, finish, listRecent) + │ │ ├── agentCard.service.ts (+spec) # build(agentId) — data-model §3 + │ │ ├── peer.service.ts (+spec) # connect (mint ap_, fetch card via A2aClient, store), refresh, remove, list, candidates + │ │ ├── a2a.client.ts (+spec) # fetch card / SendMessage over HTTP with bearer + A2A-Version; maps HTTP/JSON-RPC failures to DelegationErrorCodes + │ │ ├── a2a.server.service.ts (+spec) # SendMessage/GetTask handling: chain rules, isAgentConnected pre-check, BridleSyncService, task mapping + │ │ ├── a2aTask.store.ts # in-memory tasks, TTL + │ │ ├── delegation.service.ts (+spec) # run(callerId, args, inboundChain): row waiting → step active → client call → row final → step done + │ │ ├── delegationStep.ts # builds label/detail/delegation fields for the thinking step + │ │ └── index.ts + │ ├── data/ + │ │ ├── peer.gateway.ts (+spec) # Prisma impl + peer.mapper.ts + │ │ └── delegation.gateway.ts (+spec) # Prisma impl + delegation.mapper.ts + │ ├── guards/ + │ │ ├── a2aCard.guard.ts (+spec) # console Owner/Admin JWT OR ap_ scoped to :agentId + │ │ └── a2aPeer.guard.ts (+spec) # ap_ scoped to :agentId only; sets req.peer = { callerAgentId, peerId } + │ ├── dtos/ # agentPeer.dto, agentPeerCandidate.dto, agentCard.dto, connectPeer.dto, agentDelegation.dto, index + │ ├── peer.controller.ts (+spec) # /agents/:agentId/peers*, /agents/:agentId/card, /agents/:agentId/delegations (Owner/Admin) + │ ├── a2a.controller.ts (+spec) # GET /a2a/agents/:agentId/.well-known/agent-card.json, POST /a2a/agents/:agentId (raw bodies, no envelope) + │ └── askAgent.tool.ts (+spec) # @Tool ask_agent; describeForRequest; isListedForRequest; delegates to DelegationService + ├── agent/agent/agent.prisma # + peers / peerOf relations + ├── mcp/ + │ ├── interfaces/conditional-listing.interface.ts # NEW IConditionallyListedTool + isConditionallyListed() + │ └── services/handlers/mcp-tools.handler.ts (+spec) # tools/list filters by isListedForRequest; tools/call refuses unlisted + ├── bridle/ + │ ├── domain/bridle.types.ts # IBridleThinkingStep + kind?/delegation?; IBridleOutgoingEvent thinking fields typed + │ ├── domain/bridle.gateway.ts # + findActiveTurn(agentId) + │ ├── data/bridle.gateway.ts (+spec) # active-turn map maintained from thinking events / unregisterClient + │ ├── domain/bridleSync.service.ts (+spec) # NEW sendAndAwait({agentId, clientId, text, parts, capabilities, timeoutMs}) + │ ├── bridle.controller.ts # message/sync delegates to BridleSyncService (behaviour unchanged) + │ └── bridle.module.ts # provides/exports BridleSyncService + └── setting/domain/infraConfig.gateway.ts # + getApiPublicUrl() (settings → API_PUBLIC_URL → ranch_api_url) + +admin/ +└── slices/ + ├── agent/peer/ # NEW admin slice (same shape as agent/agentChannel) + │ ├── nuxt.config.ts # alias '#peer', stores dir + │ ├── index.d.ts, plugins/di.ts # $peerService + │ ├── domain/{peer.types.ts, peer.service.ts, index.ts} + │ ├── data/{peer.gateway.ts, index.ts} # generated SDK: listAgentPeers, listAgentPeerCandidates, getAgentCard, connectAgentPeer, refreshAgentPeer, removeAgentPeer, listAgentDelegations + │ ├── stores/peer.ts # per-agent peers, candidates, own card, delegations; connect/refresh/remove → agentStore.markPendingRestart() + │ └── components/peer/ + │ ├── Tab.vue # sections: own card, peers list, add-peer flow, recent delegations + │ ├── CardView.vue # name/description/skills badges/address — used for own card and picker preview + │ ├── Picker.vue # candidate list (filter input + rows), selects one → preview → Connect + │ ├── Row.vue # peer row: name, status, skills badges, read-at, refresh/remove + │ └── Delegations.vue # recent delegations table + ├── agent/agent/components/agent/workspace/ + │ ├── sections.ts # + { value: 'peers', title: 'Peers', countKey: 'peers', primary: true } + │ └── Canvas.vue # + v-else-if="tab === 'peers'" → + ├── agent/agent/composables/useAgentSectionCounts.ts # + peers count + └── bridle/ + ├── stores/bridle.ts # IBridleThinkingStep mirror + kind/delegation + ├── components/bridle/DelegationStep.vue # NEW dedicated layout with live elapsed timer + └── components/bridle/Provider.vue # renders DelegationStep when step.kind === 'delegation', else today's row +``` + +**Structure Decision**: one new API slice `agent/peer` holds everything that is "agents talking to agents" (peers, cards, the A2A surface, the tool, the audit row) so the feature can be read in one place and removed in one place; the three touch points outside it (MCP listing filter, bridle active-turn + sync service, infra config URL) are each a small, testable extension of code that already exists. On the console, a sibling slice `agent/peer` mirrors the API slice and plugs into the agent workspace by one tab entry and one canvas branch, following `agentChannel`; the delegation rendering lives in the bridle slice next to the thinking timeline it extends. + +## Build order (for `/speckit-tasks`) + +1. **Foundations** — Prisma fragments + migration; `a2a.types.ts`; `getApiPublicUrl`; `IConditionallyListedTool` + handler filter; `BridleSyncService` extraction (controller unchanged); `findActiveTurn` in the hub; thinking step fields. Each with its spec. Nothing user-visible yet; `bun run test` green. +2. **Card + A2A server** (User Story 1) — `AgentCardService`, guards, `A2aController` (card + JSON-RPC), `A2aServerService`, task store. Verifiable with curl (quickstart §3). +3. **Peers** (User Story 2) — gateways, `PeerService`, `A2aClient.fetchCard`, `PeerController`, DTOs, swagger regen, admin slice + tab + picker + card view. +4. **Delegation** (User Stories 3, 5) — `A2aClient.sendMessage`, `DelegationService`, `askAgent.tool.ts`, delegation rows + endpoint, chain rules live end to end. +5. **Visible step** (User Story 4) — `delegationStep.ts` push, admin `DelegationStep.vue`, Provider branch, Recent delegations panel. +6. **Demo + cluster** (User Story 6) — quickstart §4–6, ticket comment with results, PR. + +## Complexity Tracking + +No constitution violations to justify. Two deliberate touches outside the new slice are recorded here so they are not mistaken for scope creep: the MCP per-request listing filter (needed for FR-017, applied to the new tool only) and the extraction of the bridle sync wait into a service (needed because the A2A server must wait for a reply without being an HTTP controller). diff --git a/specs/013-a2a-agent-peers/quickstart.md b/specs/013-a2a-agent-peers/quickstart.md new file mode 100644 index 00000000..9dfff8d7 --- /dev/null +++ b/specs/013-a2a-agent-peers/quickstart.md @@ -0,0 +1,94 @@ +# Quickstart validation: CLEAN-74 agent cards, peers, visible delegation + +Prerequisites: local stack per `README.md` (`make dev`, or `ranch dev`), two deployable agents, one knowledge base with at least one indexed document on a topic the other agent knows nothing about. Contracts: [contracts/a2a-api.md](./contracts/a2a-api.md); model: [data-model.md](./data-model.md). + +Set in `api/.env.dev` (optional; defaults work locally): + +```bash +API_PUBLIC_URL=http://localhost:3333 # what goes into cards; must be reachable from the API itself +A2A_SYNC_TIMEOUT_MS=120000 +A2A_MAX_CHAIN=3 +``` + +## 1. Unit tests (fast loop) + +```bash +cd api && bun run test -- peer agentCard a2a askAgent bridleSync bridle.gateway mcp-tools +``` + +Expected green: + +- `PeerService`: mints `ap_` + 43 base64url chars; refuses self (`PEER_SELF`) and duplicates (`PEER_EXISTS`); connect stores the snapshot only after a successful card fetch; refresh keeps the old snapshot on fetch failure; remove deletes the row. +- `AgentCardService`: template skills → `skill:*`, effective knowledge bases → `knowledge:*` (agent override wins over template defaults, missing ids dropped); empty skills allowed; `supportedInterfaces[0].url` absolute; no peers anywhere in the card. +- `A2aController`: card needs Owner/Admin JWT or a peer credential scoped to that agent; JSON-RPC needs the peer credential only; `A2A-Version` other than `1.0` → `-32009`; loop → `TASK_STATE_REJECTED` (`loop`); chain ≥ 3 → `TASK_STATE_REJECTED` (`depth`); agent offline → `TASK_STATE_FAILED` (`not_running`) without touching the hub send; timeout → `TASK_STATE_FAILED` (`timeout`); reply → `TASK_STATE_COMPLETED` with one text artifact; `GetTask` returns the same task, unknown → `-32001`; every other method → `-32004`. +- `AskAgentTool`: not listed for an agent with no peers; description lists peers with skills; unknown peer name → `isError` listing the peers; success text carries name, context id, time; failure texts tell the model not to guess; a delegation row is created before the HTTP call and finalised after; the step is pushed twice with the same id when an active turn is known and not at all when none is. +- `BridleSyncService`: resolves on `message` and on `stream_end`, accumulates `stream` chunks, unregisters on timeout, uses a private socket id. +- `BridleGateway.findActiveTurn`: set on a `thinking` step, cleared on `done` and on client unregister, most recent wins. +- `McpToolsHandler`: a tool whose `isListedForRequest` returns `false` is absent from `tools/list` and its `tools/call` returns `isError`. + +## 2. Migration, typecheck, client regen + +```bash +cd api && bun run migrate # creates AgentPeer, AgentDelegation (additive) +cd api && bunx tsc --noEmit && bun run build && bun run generate:swagger +cd admin && bun run build:api && bun run typecheck +``` + +Expected: the generated SDK has `listAgentPeers`, `listAgentPeerCandidates`, `getAgentCard`, `connectAgentPeer`, `refreshAgentPeer`, `removeAgentPeer`, `listAgentDelegations`; `admin` typechecks with the new `peers` tab and `DelegationStep.vue`. + +## 3. Card and peers by hand (API only) + +```bash +TOKEN= # from the admin login response +B=; A= + +# B's own card, as an operator +curl -s -H "Authorization: Bearer $TOKEN" localhost:3333/agents/$B/card | jq .data.skills + +# B's card at its well-known address (still needs a credential) +curl -s -o /dev/null -w "%{http_code}\n" localhost:3333/a2a/agents/$B/.well-known/agent-card.json # 401 +curl -s -H "Authorization: Bearer $TOKEN" localhost:3333/a2a/agents/$B/.well-known/agent-card.json | jq .name + +# connect B to A, then list +curl -s -X POST -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ + -d "{\"peerAgentId\":\"$B\"}" localhost:3333/agents/$A/peers | jq '.data | {peerName, cardReadAt, skills: .card.skills | length}' +curl -s -H "Authorization: Bearer $TOKEN" localhost:3333/agents/$B/peers | jq '.data | length' # 0 — directed +curl -s -X POST -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ + -d "{\"peerAgentId\":\"$A\"}" localhost:3333/agents/$A/peers -w "%{http_code}\n" # 400 PEER_SELF +``` + +Expected: the card lists one `skill:*` per template skill and one `knowledge:*` per bound base; the peers list of B is empty; self-connect is refused; a second connect of B to A returns 409. + +Then, with the pair credential read from the DB (`select token from "AgentPeer"`), send a task as A would: + +```bash +AP=ap_... +curl -s -X POST localhost:3333/a2a/agents/$B -H "Authorization: Bearer $AP" -H 'A2A-Version: 1.0' -H 'Content-Type: application/json' -d '{ + "jsonrpc":"2.0","id":1,"method":"SendMessage", + "params":{"message":{"messageId":"m1","role":"ROLE_USER","parts":[{"text":"What is the return window for shoes?"}], + "metadata":{"ranch":{"chain":["'$A'"]}}}}}' | jq '.result.task.status.state, .result.task.artifacts[0].parts[0].text' +``` + +Expected with B running: `TASK_STATE_COMPLETED` and B's answer. With B stopped: `TASK_STATE_FAILED` and `status.message` "peer not running" **immediately**, not after 120 s. With `"chain":["'$B'"]`: `TASK_STATE_REJECTED` "would loop". With a 3-element chain: `TASK_STATE_REJECTED` "too deep". With a console JWT instead of `ap_`: 401. + +## 4. The demo, end to end (admin console) + +1. Agent **B**: template with at least one skill; bind the knowledge base; deploy; ask B a question from the base in its chat and confirm it answers with a citation. +2. Agent **A**: no knowledge base. Deploy. Ask the same question → A says it does not know; **no delegation step** appears in the thinking block. +3. On A → **Peers** tab: the "Agent card" section at the top shows A's own card. Press **Add peer**, pick B, the preview shows B's name, description and skills; **Connect**. B appears in the list with skills and "read just now". The restart banner appears. +4. **Restart A** (the list of tools is read at boot — research R7). When A is back, ask the same question. +5. Watch the thinking block: within a second a step **Asking «B»** appears with the matched skill badge, A's reason, the task text, and a running timer. It then turns into **Answered by «B»** with the duration and an excerpt. A's reply contains the fact and names B as the source. +6. Collapse the thinking block and expand it again: the delegation step is still there with the same content. +7. Stop B. Ask again: the step ends as **Could not reach «B»** with "peer not running", and A's reply says it could not get this from B. +8. On A → Peers → **Recent delegations** shows both rows (answered, failed) with durations. +9. On B → Peers: A is **not** listed. Remove B from A's peers, restart A, ask again: A says it does not know, no step. + +Success criteria checked here: SC-001 (≤ 3 interactions to connect), SC-002/SC-004 (attribution and honest failure), SC-003 (step ≤ 1 s, timer never idle), SC-007 (step 2 and step 9 look exactly like before the feature). + +## 5. In-cluster check + +On the dev cluster, repeat §4 steps 3–5 once. What is being verified: `api_public_url` resolves to a URL the API pod can reach itself (the card's `supportedInterfaces[0].url` works from inside the cluster), and a delegation completes while the MCP session of A's pod stays alive. Also confirm on a real pod that the runtime emits at least one thinking step before `ask_agent` runs — if the delegation step never appears although the reply is correct, that assumption (research §4) has failed and the step falls back to nothing; record it on the ticket. + +## 6. Loop and depth, live + +Connect A → B **and** B → A. Restart both. Give B a card that tempts it to call A (describe A as the expert on the question). Ask A: the step on A shows **Answered by «B»** (B tried A, got "would loop", answered itself). B's Recent delegations shows one `rejected` row with `PEER_REJECTED_LOOP`. diff --git a/specs/013-a2a-agent-peers/research.md b/specs/013-a2a-agent-peers/research.md new file mode 100644 index 00000000..c6f8f418 --- /dev/null +++ b/specs/013-a2a-agent-peers/research.md @@ -0,0 +1,110 @@ +# Research: A2A in Ranch — what exists, what the protocol says, and the decisions + +**Ticket**: [CLEAN-74](https://dreamvention.atlassian.net/browse/CLEAN-74) +**Date**: 2026-09-10 +**Scope**: current-state audit of the four things the feature touches (MCP runtime, bridle hub + thinking, auth/credentials, admin agent page), the A2A 1.0 protocol as published, and the Phase 0 decisions R1–R12 that the plan builds on. Facts carry file references; decisions carry rationale and rejected alternatives. + +--- + +## 1. What exists today + +### 1.1 MCP runtime — how an agent gets a tool from the API + +| Fact | Where | +|---|---| +| Tools are methods decorated `@Tool({ name, description, parameters: z.object(...) })`; discovery is implicit — any provider in any module loaded by `AppModule` is scanned at bootstrap | `api/src/slices/mcp/decorators/tool.decorator.ts:25-30`, `api/src/slices/mcp/services/mcp-registry.service.ts:41-81` | +| Call signature is positional `(args, context, httpRequest)`; `args` are **not** zod-validated at runtime; return `{ content: [{type:'text', text}], isError? }` | `mcp-tools.handler.ts:107-112`, `reins/knowledge/knowledge.tool.ts:25-37` | +| Per-request description: `IDynamicallyDescribedTool.describeForRequest(httpRequest)` runs on every `tools/list`; a thrown error falls back to the static text | `mcp/interfaces/dynamic-description.interface.ts:15-25`, `mcp-tools.handler.ts:31-69` | +| **No per-agent tool filtering** — `tools/list` returns `registry.getTools()` unfiltered; recorded as a deferred gap twice | `mcp-tools.handler.ts:36`, `specs/009-attachment-parse-quality/plan.md:67`, `docs/superpowers/specs/2026-05-13-knowledge-mcp-integration-design.md:148` | +| Caller identity = `req.user.sub === 'agent:'` from the 365-day service token; `extractAgentId` is copy-pasted per tool | `knowledge.tool.ts:169-175`, `bridle/attachment.tool.ts:531-537`, `user/auth/domain/auth.service.ts:214-228` | +| **No turn context** reaches a tool call: only `Authorization` and `mcp-session-id` headers; `turnId` exists only on the bridle WebSocket | `mcp/transport/streamable-http.controller.factory.ts:206,343,380`, `bridle/domain/bridle.types.ts:143-153` | +| The pod reads `tools/list` once at connect (runtime is a separate repo, `ghcr.io/cleanslice/runtime`); the server advertises `listChanged` but never emits it → a new tool or a changed dynamic description is seen **after a pod restart** | `specs/001-stabilize-agent-startup/research.md:28`, `mcp/utils/capabilities-builder.ts:25-29` | +| The built-in Ranch MCP server row points every agent at the same URL (`RANCH_MCP_URL ?? http://api:3001/mcp/mcp`); the list is baked into pod env at deploy | `mcpServer/domain/mcpServer.seeder.ts:24-41`, `workflow/data/argo-workflow.gateway.ts:89-154` | +| Tests: Jest, plain `new Tool(fakes…)`, request faked as `{ user: { sub: 'agent:x' } }` | `knowledge.tool.spec.ts:19-23, 31-92, 117` | + +### 1.2 Bridle hub — sending to an agent and waiting + +| Fact | Where | +|---|---| +| `sendToAgent` does **not** throw when the agent is offline — it fabricates an assistant "Agent is not connected" message back to the client | `bridle/data/bridle.gateway.ts:173-227` | +| The synchronous wait (register client → resolve on `message`/`stream_end` → 120 s timeout) lives **inline in the controller**; there is no service to reuse | `bridle/bridle.controller.ts:335-402` | +| `IBridleGateway` is exported by `BridleModule`, has `isAgentConnected(agentId)` for a fast pre-check, and `sendToClient(clientId, agentId, payload: unknown)` for API-side pushes | `bridle/domain/bridle.gateway.ts:26-125`, `bridle/bridle.module.ts:92`, `data/bridle.gateway.ts:126-128, 229-234` | +| Clients are keyed `clientId + agentId`; a registered client record carries `prompt` and `capabilities` that are forwarded to the runtime (the runtime emits thinking only if the client declared `'thinking'`) | `data/bridle.gateway.ts:54-58, 200-209`, `bridle.types.ts:96-101, 216-229` | + +### 1.3 Thinking timeline — the wire and the admin rendering + +| Fact | Where | +|---|---| +| Step `{ id, label, detail?, state: 'active'\|'done' }`; event `{ type:'thinking', clientId, turnId, step?, done?, ts }`; **`turnId` is minted by the runtime per loop run**, the hub stores nothing per turn | `bridle.types.ts:124-153`, `specs/005-shimmer-thinking-ui/contracts/thinking-event.md:16,68`, `data/bridle.gateway.ts:43-64` | +| The agent-WS handler forwards the payload verbatim (no DTO, no whitelist stripping) — extra structured fields survive to the browser | `bridle/handlers/bridleAgentWs.handler.ts:185-195`, `api/src/main.ts:45-51` | +| Admin store: a step with an already-known `id` **updates in place**; a step with a new `turnId` **closes every other open turn** — an invented turnId would freeze the runtime's own block | `admin/slices/bridle/stores/bridle.ts:570-616` (esp. 580-598) | +| Admin renders `label` as text and `detail` as markdown (`v-html="renderMarkdown(...)"`), shimmer while `state==='active'`, block collapses when `status==='done'` | `admin/slices/bridle/components/bridle/Provider.vue:553-605, 96-98` | +| Thinking blocks are session-only (never persisted, never replayed); the user console (`app/`) has no WebSocket and renders no thinking; the embed SDK is external | `stores/bridle.ts:80-94`, `app/slices/bridle/data/bridle.gateway.ts:55-84` | +| Transcript JSONL knows `tool_call`/`tool_result` but no thinking; end-user history deliberately excludes tool events | `agent/file/domain/transcriptReader.service.ts:19-43`, `chat/myChat.controller.ts:135-137` | + +### 1.4 Auth, credentials, slice conventions + +| Fact | Where | +|---|---| +| Console JWT payload `{ sub, email, roles, sid? }`; `JwtAuthGuard` is stateless; admin routes = `@UseGuards(JwtAuthGuard, RolesGuard)` + `@Roles(Owner, Admin)` | `user/auth/domain/auth.types.ts:4-16`, `guards/jwtAuth.guard.ts:32-56`, `agent/agent/agent.controller.ts:69-73` | +| Non-user bearer precedents: share link `sl_` + 32 bytes base64url stored **plaintext, unique**, validated per request, revoked by row change; API key `rk_` stored as sha256 with scopes | `agent/shareLink/domain/shareLink.service.ts:90-105,152`, `user/apiKey/domain/apiKey.service.ts:11-54` | +| Guard that accepts either a console JWT or a second credential kind, with `agentId` taken from the route | `bridle/guards/bridleChatAuth.guard.ts:58-108` | +| `agent/secret` is the pod's env-secret store (plaintext on GET, no relations) — not a fit for a per-peer credential | `agent/secret/secret.controller.ts:22, 92-99` | +| Slice layout reference (`agent/shareLink`): `*.prisma` fragment, `domain/{types,gateway(abstract class),service,index}`, `data/{gateway,mapper}`, `dtos/`, controller, module registered flat in `AppModule`; `prisma-import` merges fragments (`bun run generate`); migrations hand-named and additive | `api/src/slices/agent/shareLink/*`, `api/src/app.module.ts:12,63`, `api/package.json:24-26,34-39`, `api/prisma/migrations/20260907120000_agent_share_link/` | +| OpenAPI: `@ApiOperation({ operationId })` lowerCamelCase unique; regen chain `cd api && bun run build && bun run generate:swagger` → `cd admin && bun run build:api` (client at `admin/slices/setup/api/data/repositories/api/`) | `shareLink.controller.ts:76-84`, `admin/openapi-ts.config.ts:5-9` | +| Template → skills is an implicit m-n flattened to `skillIds`; skill records via `ISkillGateway.findByIds`; knowledge names via `IKnowledgeGateway.findExistingByIds`; effective knowledge = `agent.knowledgeIds` else `template.defaultKnowledgeIds` | `agent/template/domain/template.types.ts:14`, `agentDeploy.service.ts:248-253`, `knowledge.tool.ts:177-183` | +| **No public base URL of the API exists.** Closest: integration setting `ranch_api_url` (pod-facing, default `http://host.k3d.internal:3333`); `IInfraConfigGateway` resolves settings → env → default | `workflow/data/argo-workflow.gateway.ts:40-44,195`, `setting/domain/infraConfig.gateway.ts:15-28` | +| No table fits a "delegation" record: `Usage` is a daily rollup with `@@unique([agentId, model, date])`; `log` has no storage. Closest shape: `PaddockEvaluation` (status, startedAt, finishedAt, errorMessage, cascade FK) | `usage/usage.prisma:4-21`, `paddock/evaluation/evaluation.prisma:3-28` | + +### 1.5 Admin agent page + +| Fact | Where | +|---|---| +| Tabs are one `AGENT_TABS` array (`value` is a URL contract, `primary` flag, optional `countKey`); a new tab = entry + `v-else-if` branch in `Canvas.vue` + a component folder (Nuxt derives the global name from the path) | `admin/slices/agent/agent/components/agent/workspace/sections.ts:17-120`, `Canvas.vue:56-116` | +| Closest patterns: Knowledge tab (inline edit, `agentStore.update`, inline error string, Skeleton/Table/dashed-empty), agentChannel provider (load/loadError, inline add form, `agentStore.markPendingRestart()` after a change that needs a restart) | `agent/knowledge/Tab.vue`, `agentChannel/components/agentChannel/Provider.vue:132-167` | +| Chain store → service → gateway → generated SDK; errors inline, toasts rare; UI kit has Card/Badge/Button/Input/Checkbox/Skeleton/Table, **no** Dialog/Command/Popover (dialogs hand-built on reka-ui, `ConfirmDialog` exists) | `agent/agent/{stores,domain,data}/*`, `common/components/confirm/Dialog.vue` | +| A slice = directory with `nuxt.config.ts` (alias, stores auto-import, i18n module); `plugins/di.ts` provides the service; components under `components/**` need no registration; admin copy is raw English | `admin/registerSlices.ts:11-42`, `admin/slices/agent/agent/nuxt.config.ts:6-16`, `CLAUDE.md` | +| Admin has no test runner; verification is `bun run build:api && bun run typecheck` | `admin/package.json:13-15` | + +## 2. The protocol as published (A2A 1.0, read 2026-09-10) + +Sources: `https://a2a-protocol.org/latest/specification/`, normative proto `specification/a2a.proto` in `a2aproject/A2A` (spec §1.4), releases page. Latest released **1.0.1** (2026-05-28); 0.3.0 (2025-07-30) is the previous line and is what most tutorials still show. + +- **Card** at `/.well-known/agent-card.json` (renamed from `agent.json` in 0.3). v1.0 required: `name`, `description`, `supportedInterfaces[]` (`{ url, protocolBinding: 'JSONRPC'|'GRPC'|'HTTP+JSON', protocolVersion: '1.0' }`, first = preferred), `version`, `capabilities` (`streaming?`, `pushNotifications?`, `extensions[]`, `extendedAgentCard?`), `defaultInputModes`, `defaultOutputModes`, `skills[]` (`id`, `name`, `description`, `tags` required; `examples`, `inputModes`, `outputModes` optional). Optional: `provider`, `documentationUrl`, `securitySchemes` (map), `securityRequirements[]`, `iconUrl`, `signatures`. **Gone from the top level in 1.0**: `url`, `preferredTransport`, `protocolVersion`, `supportsAuthenticatedExtendedCard`, `capabilities.stateTransitionHistory`. +- **JSON-RPC methods** (1.0, PascalCase): `SendMessage`, `SendStreamingMessage`, `GetTask`, `ListTasks`, `CancelTask`, `SubscribeToTask`, push-config CRUD, `GetExtendedAgentCard`. Clients send header `A2A-Version: 1.0`; an absent header means 0.3. `SendMessage` params `{ message, configuration?: { acceptedOutputModes[], historyLength?, returnImmediately (default false = blocking) }, metadata?, tenant? }`; result is a wrapper `{ task }` **or** `{ message }`. +- **Task** `{ id, contextId, status: { state, message?, timestamp }, artifacts[], history[], metadata }`. States are ProtoJSON enum names: `TASK_STATE_SUBMITTED`, `_WORKING`, `_COMPLETED`, `_FAILED`, `_CANCELED`, `_INPUT_REQUIRED`, `_REJECTED`, `_AUTH_REQUIRED`. +- **Message** `{ messageId, role: 'ROLE_USER'|'ROLE_AGENT', parts[], contextId?, taskId?, metadata?, extensions[], referenceTaskIds[] }`. **Parts have no `kind`** in 1.0 — oneof by key: `{ text }`, `{ raw }`, `{ url }`, `{ data }` (+ `metadata`, `filename`, `mediaType`). Artifact `{ artifactId, parts (≥1), name?, description?, metadata? }`. +- **Errors**: `-32001` TaskNotFound, `-32002` TaskNotCancelable, `-32003` PushNotificationNotSupported, `-32004` UnsupportedOperation, `-32005` ContentTypeNotSupported, `-32006` InvalidAgentResponse, `-32007` ExtendedAgentCardNotConfigured, `-32008` ExtensionSupportRequired, `-32009` VersionNotSupported; plus standard `-32700/-32600/-32601/-32602/-32603`. +- **Transport**: JSON-RPC 2.0 over HTTP POST to the interface `url` (SSE for streaming); gRPC and HTTP+JSON bindings also exist. TypeScript SDK `@a2a-js/sdk` 1.1.0 (2026-08-26) has client + server helpers (`DefaultRequestHandler`, `AgentExecutor`, `InMemoryTaskStore`, express adapter) and a 0.3 compat layer. + +## 3. Decisions (Phase 0) + +**R1 — Speak A2A 1.0, hand-rolled, no SDK.** The surface Ranch needs is three things: a card, blocking `SendMessage`, `GetTask`. `@a2a-js/sdk`'s server half assumes its own executor/task-store/express wiring and would sit awkwardly inside a Nest controller with our guards and response interceptor; its client half is ~40 lines of fetch we would wrap anyway. Types are written once in `a2a.types.ts` from the proto names above. *Rejected*: 0.3 shapes (the version tutorials show) — the released spec is 1.0 and the SDK targets it; an SDK dependency — friction outweighs three methods. *Reconsider* if streaming or push notifications enter scope. + +**R2 — Card address is path-prefixed per agent: `GET /a2a/agents/:agentId/.well-known/agent-card.json`; tasks go to `POST /a2a/agents/:agentId` (JSON-RPC).** One API host serves many agents, so the domain-root well-known location cannot name one agent; the spec allows `supportedInterfaces[].url` anywhere, and the well-known path under a prefix keeps discovery recognisable. The card's `supportedInterfaces[0].url` is absolute, built from a new **`api_public_url`** infra setting (settings → env `API_PUBLIC_URL` → fallback to the existing `ranch_api_url` integration value, which is reachable in-cluster and in local dev). *Rejected*: a root `/.well-known/agent-card.json` listing all agents — not a card; storing a relative URL — breaks the "second installation later needs only auth" promise. + +**R3 — Card requires a credential; two kinds are accepted.** A console JWT with Owner/Admin (operators previewing a card in the picker) or a **peer credential** issued for that agent (a caller reading the card of its peer). No anonymous discovery (spec FR-002). The same guard protects the JSON-RPC endpoint, where only a peer credential is accepted. + +**R4 — Peer credential = `ap_` + 32 random bytes base64url, stored plaintext and unique on the `AgentPeer` row, scoped to exactly one (caller, peer) pair.** Same shape and storage as the share link (`sl_`), because the API is both issuer and presenter: the delegating tool runs inside the API and must read the credential back to present it, so a hash-only store would force a second secret. Deleting the row revokes it; re-connecting mints a new one. *Rejected*: sha256-only like `rk_` (the presenter is the API itself, cannot recover the secret); a JWT with `aud` (revocation would still need a row lookup, so no gain); reusing the pod service token (it authenticates the pod to the API, not agent A to agent B, and cannot be revoked per pair). + +**R5 — The client is one MCP tool, `ask_agent`, in a new slice `agent/peer`, with a per-request description that lists the caller's peers and their card-snapshot skills.** This is the knowledge-tool pattern and needs no runtime change (spec FR-018). Arguments: `peer` (peer id, or the peer's name — matched case-insensitively against the snapshot), `task` (text sent to the peer), `reason` (one line: why this peer — becomes part of the visible step), optional `context_id` (to continue a conversation with the same peer within a turn, spec FR-010). The tool calls the peer's `SendMessage` over HTTP at the URL in the snapshot, presenting the pair credential and `A2A-Version: 1.0`, with `metadata.ranch = { chain, depth }`. *Rejected*: a second `list_peers` tool (the description already lists them; a second call per turn costs latency for no information); a runtime-native A2A client (separate repo, every template image). + +**R6 — Per-request tool listing filter in the MCP slice.** Add an optional `isListedForRequest(httpRequest): Promise` (interface `IConditionallyListedTool`, duck-typed like the dynamic description) and honour it in `tools/list`; `tools/call` on a tool that is not listed for the caller returns an `isError` result. `ask_agent` is listed only for agents that have at least one peer — this is what makes spec FR-017 ("no delegation tool for a peerless agent") true rather than merely "the description says don't". The filter closes the deferred gap named in two earlier specs but is applied only to the new tool here; other tools keep today's behaviour. + +**R7 — Restart to apply.** Because the pod lists tools once at connect, an agent sees a newly connected first peer, or a changed peer list, after a restart — exactly like a template's MCP list today. The Peers tab calls `agentStore.markPendingRestart()` after connect/remove/refresh so the existing restart banner appears. The demo walkthrough connects B before deploying A, or restarts A once. *Rejected*: emitting `notifications/tools/list_changed` (the runtime's handling is unknown and unverifiable from this repo); making the description static and the peer list a separate call (see R5). + +**R8 — The hub learns the active turn from the thinking stream it already relays.** `IBridleGateway` gains `findActiveTurn(agentId): { clientId, turnId } | null`; the implementation records `(agentId, clientId) → turnId` on every `thinking` event without `done`, clears it on `done`, and on client unregister; when several are active it returns the most recent. `ask_agent` uses it to emit its **delegation step** via `sendToClient(clientId, agentId, thinkingEvent)` with a stable step id (`delegation:`) so later updates replace the step in place (admin store semantics). If no active turn is known — the person is chatting from a surface without the `thinking` capability, or the runtime has emitted no step yet in this turn — no step is emitted and the delegation still runs (spec Story 4 scenario 6 degrades gracefully). *Known limitation*: two people chatting with the same agent at the same moment cannot be told apart from a tool call; the most recent turn wins. *Rejected*: an invented turnId (the admin store would close the runtime's own block); a `_meta.turnId` on tool calls (runtime change, out of scope — noted as the clean follow-up). + +**R9 — The delegation step is an ordinary thinking step plus structured fields.** `{ id, label, detail, state }` stays the contract every surface understands: `label` = `Asking «B»`, `detail` = a markdown summary (peer, matched skills, reason, task, status, time, excerpt/cause) so the generic renderer and the embed show something sensible. Two additive fields, `kind: 'delegation'` and `delegation: { … }` (data-model.md), let the admin chat render the dedicated layout. The runtime's own generic "tool call" step for `ask_agent`, if it emits one, is left alone — it carries no card or reason. + +**R10 — Extract the synchronous wait into `BridleSyncService.sendAndAwait()`.** Moved from the controller (register client with a private socket id, resolve on `message`/`stream_end`, timeout) into a domain service in the bridle slice, used by the existing route (unchanged behaviour: timeout still resolves to the "Timeout" text) and by the A2A server (which pre-checks `isAgentConnected` and turns "not connected" and timeout into `TASK_STATE_FAILED` with a stated cause instead of a fabricated reply). Peer conversations register with `clientId = peer::` and **no** `thinking` capability, so the peer's runtime does not stream steps to a client that cannot show them. + +**R11 — Loop and depth checks happen on the server side of every hop, from `metadata.ranch.chain`.** The caller puts `[…chain, callerAgentId]` in the message metadata; the receiving agent rejects with `TASK_STATE_REJECTED` and a stated rule (`would loop` if it is already in the chain; `too deep` if the chain length is ≥ 3) before touching the runtime. The check is server-side because the caller's own tool is also a server in the next hop — one place, one rule (spec FR-012). + +**R12 — Delegations are durable rows in a new `AgentDelegation` table; tasks for `GetTask` are an in-memory map with a short TTL.** The row is the audit record (spec FR-016) and the source for the "recent delegations" list on the Peers tab. `GetTask` exists for protocol completeness; a blocking `SendMessage` already returns the final task, so keeping completed tasks in memory for 10 minutes is enough. *Rejected*: writing tasks to the DB (no reader), reusing `Usage` (unique key collides), a `history` on the task (spec says no). + +## 4. Open risks carried into the plan + +- **Runtime step timing (R8).** The delegation step depends on the runtime having emitted at least one thinking step in the turn before the tool runs. Spec 005 says the runtime emits a step per tool call; quickstart §4 verifies this on a real pod. If it does not hold, the fallback is the generic degraded behaviour, and the follow-up is `_meta.turnId` on tool calls. +- **HTTP to self.** The tool calls the peer's URL even inside one installation; in-cluster that is the `ranch_api_url`/`api_public_url` value. The API is single-replica and the MCP transport is stateful; a self-call is an ordinary async HTTP request and holds no lock, but quickstart §5 checks a delegation completes end to end in the cluster, not only locally. +- **Restart to apply (R7)** is a demo-visible step; the walkthrough is written around it. From 9c8c32f68b50cdc4d4e3d2d94b47d239364a326b Mon Sep 17 00:00:00 2001 From: "Maksym Hryzodub [DREAM]" Date: Thu, 10 Sep 2026 19:25:49 +0300 Subject: [PATCH 03/31] docs(tasks): A2A peers task list (CLEAN-74) Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01SZ9dCrzZqd5SwR4erLYe12 --- specs/013-a2a-agent-peers/tasks.md | 269 +++++++++++++++++++++++++++++ 1 file changed, 269 insertions(+) create mode 100644 specs/013-a2a-agent-peers/tasks.md diff --git a/specs/013-a2a-agent-peers/tasks.md b/specs/013-a2a-agent-peers/tasks.md new file mode 100644 index 00000000..9904cde5 --- /dev/null +++ b/specs/013-a2a-agent-peers/tasks.md @@ -0,0 +1,269 @@ +# Tasks: Agent-to-agent (A2A) — agent cards, peer agents, and delegation you can see (CLEAN-74) + +**Input**: Design documents from `specs/013-a2a-agent-peers/` + +**Prerequisites**: plan.md, spec.md, research.md (§1 audit with file refs, §3 decisions R1–R12), data-model.md, contracts/a2a-api.md, quickstart.md + +**Tests**: API tests are included (plan: "tests for every new server path"; Jest, colocated `*.spec.ts`, hand-rolled stubs — `knowledge.tool.spec.ts:31-92` and `shareLink.controller.spec.ts:59-195` patterns). Admin has no test runner; verification is `cd admin && bun run build:api && bun run typecheck` plus quickstart. + +**Organization**: Grouped by user story. US1 (card + A2A server), US2 (peers in admin), US3 (delegation) and US4 (visible step) are all P1 and together are the demo; US5 (loop/depth + audit) and US6 (demo walkthrough) are P2. Foundational work carries the Prisma models, the A2A types, the MCP listing filter, the bridle sync service and the hub's active-turn tracking because more than one story reads each of them. + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: Can run in parallel (different files, no dependencies) +- **[Story]**: US1 … US6 from spec.md +- File paths are repo-relative; API = `api/src/…`, console = `admin/slices/…` + +## Path Conventions + +- New API slice: `api/src/slices/agent/peer/{peer.prisma, delegation.prisma, peer.module.ts, domain/, data/, dtos/, guards/, peer.controller.ts, a2a.controller.ts, askAgent.tool.ts}` — layout of `api/src/slices/agent/shareLink/` +- Touched API slices: `api/src/slices/mcp/{interfaces/, services/handlers/mcp-tools.handler.ts}`, `api/src/slices/bridle/{domain/, data/bridle.gateway.ts, bridle.controller.ts, bridle.module.ts}`, `api/src/slices/setting/domain/infraConfig.gateway.ts`, `api/src/slices/agent/agent/agent.prisma`, `api/src/app.module.ts` +- New admin slice: `admin/slices/agent/peer/{nuxt.config.ts, index.d.ts, plugins/di.ts, domain/, data/, stores/peer.ts, components/peer/}` — layout of `admin/slices/agent/agentChannel/` +- Touched admin: `admin/slices/agent/agent/components/agent/workspace/{sections.ts, Canvas.vue}`, `admin/slices/agent/agent/composables/useAgentSectionCounts.ts`, `admin/slices/bridle/{stores/bridle.ts, components/bridle/Provider.vue, components/bridle/DelegationStep.vue}` +- Path aliases in API: `#/agent/peer/domain`, `#mcp`; tool import `import { Tool } from '#mcp'` + +--- + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: Env, constants, slice skeleton and the protocol types so every later task has a home. + +- [ ] T001 Append to `api/.env.example` with one-line comments: `API_PUBLIC_URL=http://localhost:3333` (URL other agents use to reach this API; falls back to `ranch_api_url`), `A2A_SYNC_TIMEOUT_MS=120000`, `A2A_MAX_CHAIN=3`; mirror `API_PUBLIC_URL` into the env block of `k8s/deploy/30-api.yaml` next to `JWT_EXPIRES_IN` +- [ ] T002 [P] Create the slice skeleton `api/src/slices/agent/peer/` with `domain/index.ts` barrel, `domain/peer.types.ts` holding: `PEER_TOKEN_PREFIX = 'ap_'`, `PEER_TOKEN_BYTES = 32`, `PEER_TOKEN_RE = /^ap_[A-Za-z0-9_-]{43}$/`, `PeerErrorCodes = { Self: 'PEER_SELF', Exists: 'PEER_EXISTS', NotFound: 'PEER_NOT_FOUND', CardUnreachable: 'PEER_CARD_UNREACHABLE', Unauthorized: 'A2A_UNAUTHORIZED' } as const`, `DelegationStatus = 'waiting' | 'answered' | 'failed' | 'rejected'`, `DelegationErrorCodes = { NotRunning: 'PEER_NOT_RUNNING', Timeout: 'PEER_TIMEOUT', RejectedLoop: 'PEER_REJECTED_LOOP', RejectedDepth: 'PEER_REJECTED_DEPTH', Unauthorized: 'PEER_UNAUTHORIZED', Unreachable: 'PEER_UNREACHABLE', Error: 'PEER_ERROR' } as const`, `IAgentPeerData { id, agentId, peerAgentId, token, cardSnapshot: AgentCard, cardUrl, cardReadAt: string, createdAt: string, updatedAt: string }`, `IAgentDelegationData` (every column of data-model §2 with ISO-string dates), `IMatchedSkill { id, name }`; plus an empty `peer.module.ts` shaped like `api/src/slices/agent/shareLink/shareLink.module.ts:21-33` (`forwardRef(() => AgentModule)`) +- [ ] T003 [P] Write `api/src/slices/agent/peer/domain/a2a.types.ts` from research §2 / contracts §1–2 (A2A 1.0 names, no `kind` on parts): `A2A_VERSION = '1.0'`, `A2A_VERSION_HEADER = 'A2A-Version'`, `AgentInterface`, `AgentCapabilities`, `AgentSkill`, `AgentCard`, `A2aPart = { text: string; metadata?; mediaType? } | { raw: string; … } | { url: string; … } | { data: unknown; … }`, `A2aMessage { messageId, role: 'ROLE_USER' | 'ROLE_AGENT', parts, contextId?, taskId?, metadata?, extensions?, referenceTaskIds? }`, `A2aTaskState` string-literal union of the nine `TASK_STATE_*` values, `A2aTaskStatus { state, message?, timestamp }`, `A2aArtifact { artifactId, parts, name?, description?, metadata? }`, `A2aTask { id, contextId, status, artifacts, history, metadata }`, `SendMessageParams { message, configuration?: { acceptedOutputModes?, historyLength?, returnImmediately? }, metadata?, tenant? }`, `SendMessageResult = { task: A2aTask } | { message: A2aMessage }`, `GetTaskParams { id, historyLength? }`, `JsonRpcRequest { jsonrpc: '2.0', id, method, params? }`, `JsonRpcResponse`, `A2aErrorCodes = { ParseError: -32700, InvalidRequest: -32600, MethodNotFound: -32601, InvalidParams: -32602, Internal: -32603, TaskNotFound: -32001, TaskNotCancelable: -32002, PushNotSupported: -32003, Unsupported: -32004, ContentTypeNotSupported: -32005, InvalidAgentResponse: -32006, VersionNotSupported: -32009 } as const`, `RanchTaskMetadata { chain: string[]; reason?: string; rejection?: 'loop' | 'depth'; failure?: 'not_running' | 'timeout'; durationMs? }` and helpers `textOfParts(parts): string` (joins `text` parts with `\n\n`), `hasNonTextPart(parts): boolean`; export from `domain/index.ts` + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Persistence, the four touch points outside the new slice, and the tool-listing filter. Every story reads at least one of these. + +**⚠️ CRITICAL**: No user story work can begin until this phase is complete + +- [ ] T004 Add Prisma fragments (depends on T002): `api/src/slices/agent/peer/peer.prisma` — `import { Agent } from "../agent/agent"` and `model AgentPeer { id String @id @default(uuid()); agentId String; agent Agent @relation("AgentPeerCaller", fields: [agentId], references: [id], onDelete: Cascade); peerAgentId String; peerAgent Agent @relation("AgentPeerTarget", fields: [peerAgentId], references: [id], onDelete: Cascade); token String @unique; cardSnapshot Json; cardUrl String; cardReadAt DateTime; createdAt DateTime @default(now()); updatedAt DateTime @updatedAt; delegations AgentDelegation[]; @@unique([agentId, peerAgentId]); @@index([agentId]); @@index([peerAgentId]) }`; `api/src/slices/agent/peer/delegation.prisma` — `import { Agent } from "../agent/agent"`, `import { AgentPeer } from "./peer"` and `model AgentDelegation { id String @id @default(uuid()); agentId String; agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade); peerId String?; peer AgentPeer? @relation(fields: [peerId], references: [id], onDelete: SetNull); peerAgentId String; peerName String; contextId String; turnId String?; clientId String?; task String; reason String; matchedSkills Json @default("[]"); status String @default("waiting"); errorCode String?; excerpt String?; startedAt DateTime @default(now()); finishedAt DateTime?; durationMs Int?; @@index([agentId, startedAt]) }`; in `api/src/slices/agent/agent/agent.prisma` add `import { AgentPeer } from "../peer/peer"`, `import { AgentDelegation } from "../peer/delegation"` and the back-relations `peers AgentPeer[] @relation("AgentPeerCaller")`, `peerOf AgentPeer[] @relation("AgentPeerTarget")`, `delegations AgentDelegation[]` +- [ ] T005 Run `cd api && bun run migrate`, rename the generated folder to `api/prisma/migrations/20260910120000_agent_peer_delegation/`, prepend `-- Additive: new tables AgentPeer (directed peer connection + pair credential + card snapshot) and AgentDelegation (audit row per delegated task). Safe on an existing database.`, confirm `bun run generate` succeeds (depends on T004) +- [ ] T006 [P] Add `getApiPublicUrl(): Promise` to `api/src/slices/setting/domain/infraConfig.gateway.ts` and its implementation, following the existing getters' precedence (settings group `infrastructure` name `api_public_url` → env `API_PUBLIC_URL` → the `ranch_api_url` integration value the workflow gateway reads at `api/src/slices/workflow/data/argo-workflow.gateway.ts:195` → `http://localhost:3333`), trailing slash stripped; extend the existing infraConfig spec (or add `infraConfig.gateway.spec.ts`) with the three-level precedence for this key +- [ ] T007 [P] Add `api/src/slices/mcp/interfaces/conditional-listing.interface.ts`: `interface IConditionallyListedTool { isListedForRequest(httpRequest: Request): Promise }` + duck-typed `isConditionallyListed(obj): obj is IConditionallyListedTool` (mirror of `dynamic-description.interface.ts:15-25`); export from `api/src/slices/mcp/index.ts` next to `IDynamicallyDescribedTool` +- [ ] T008 Update `api/src/slices/mcp/services/handlers/mcp-tools.handler.ts` (depends on T007): in the `tools/list` handler (`:31-69`) resolve each provider instance and skip the tool when `isConditionallyListed(instance) && !(await instance.isListedForRequest(httpRequest))` (a throw counts as listed, logged at debug like the description fallback at `:54-60`); in the `tools/call` handler (`:107-112`) run the same check before invoking and return `{ content: [{ type: 'text', text: 'Tool "" is not available to this caller.' }], isError: true }` when unlisted +- [ ] T009 Write `api/src/slices/mcp/services/handlers/mcp-tools.handler.spec.ts` (new; construct the handler with a stubbed registry/module-ref in the style of `mcp-registry.service.spec.ts:16-35`): a tool whose `isListedForRequest` resolves `false` is absent from `tools/list` and its `tools/call` returns `isError`; a tool without the method is listed as before; a throwing `isListedForRequest` keeps the tool listed (depends on T008) +- [ ] T010 [P] Extend `api/src/slices/bridle/domain/bridle.types.ts`: on `IBridleThinkingStep` (`:124-133`) add `kind?: 'delegation'` and `delegation?: IBridleDelegationStep` where `IBridleDelegationStep { delegationId: string; peerAgentId: string; peerName: string; matchedSkills: { id: string; name: string }[]; reason: string; task: string; status: 'waiting' | 'answered' | 'failed' | 'rejected'; startedAt: number; durationMs?: number; excerpt?: string }`; add `IActiveTurn { clientId: string; turnId: string; ts: number }`; make the `thinking` member of `IBridleOutgoingEvent` (`:105-119`) carry `turnId`, `step?`, `done?` so API-side emitters need no cast +- [ ] T011 Add `abstract findActiveTurn(agentId: string): IActiveTurn | null` to `api/src/slices/bridle/domain/bridle.gateway.ts` (doc: "most recent turn that has emitted a thinking step and not yet its terminal `done`; null when unknown") and implement in `api/src/slices/bridle/data/bridle.gateway.ts`: a `Map` keyed `${agentId}${clientId}`; in `handleAgentEvent` (`:236-244`) when `data.type === 'thinking'` set the entry on a step event and delete it on `done: true`; delete in `unregisterClient` (`:58`); `findActiveTurn` returns the entry with the greatest `ts` for that agent (depends on T010) +- [ ] T012 Extend `api/src/slices/bridle/data/bridle.gateway.spec.ts` (or create it beside the gateway if absent): `findActiveTurn` is null before any thinking event, set after a step event, updated to the newest of two clients, cleared by `done: true`, cleared by `unregisterClient` (depends on T011) +- [ ] T013 Create `api/src/slices/bridle/domain/bridleSync.service.ts` with `@Injectable() BridleSyncService` over `IBridleGateway`: `sendAndAwait(input: { agentId: string; clientId: string; text: string; parts?: BridlePart[]; attachments?; capabilities?: string[]; isAdmin?: boolean; timeoutMs?: number }): Promise<{ text: string; messageId: string; ts: number; timedOut: boolean }>` — the register-with-private-socket-id / resolve-on-`message`-or-`stream_end` / accumulate-`stream` / timeout / unregister logic lifted verbatim from `api/src/slices/bridle/bridle.controller.ts:341-379` (default timeout 120 000 from env `A2A_SYNC_TIMEOUT_MS` is **not** read here — the caller passes it); `timedOut: true` and `text = chunks.join('')` on timeout; `registerClient` receives the caller's `capabilities` (default `[]`) so a peer conversation never declares `thinking`; export from `domain/index.ts`; provide and export it in `api/src/slices/bridle/bridle.module.ts` +- [ ] T014 Refactor `api/src/slices/bridle/bridle.controller.ts:335-402` (`sendMessageSync`) to call `BridleSyncService.sendAndAwait` with `capabilities: ['streaming']` and map `timedOut` to today's `'Timeout: no response from agent'` text so the HTTP behaviour is byte-identical; keep attachment expansion before the call (depends on T013) +- [ ] T015 Write `api/src/slices/bridle/domain/bridleSync.service.spec.ts` with a hub stub that captures the registered callback: resolves on `message` with `{ text, messageId, ts }`; resolves on `stream_end` with the joined `stream` chunks; times out → `timedOut: true`, callback unregistered with the private socket id; `capabilities` passed through to `registerClient` (depends on T013); run `cd api && bun run test -- bridle.controller` to confirm the existing controller spec still passes after T014 +- [ ] T016 Register `PeerModule` in `api/src/app.module.ts` imports next to `ShareLinkModule` and make `peer.module.ts` import `forwardRef(() => AgentModule)`, `TemplateModule`, `SkillModule`, `KnowledgeModule`, `BridleModule`, `SettingModule`; if `SkillModule`/`KnowledgeModule` do not export `ISkillGateway`/`IKnowledgeGateway` yet, add the export in their module files (`api/src/slices/skill/skill.module.ts`, `api/src/slices/reins/knowledge/knowledge.module.ts`) — verify with `cd api && bunx tsc --noEmit` (depends on T002, T005) + +**Checkpoint**: `cd api && bun run test -- mcp-tools bridleSync bridle.gateway bridle.controller infraConfig` green; migration applied; app boots with an empty `PeerModule`. + +--- + +## Phase 3: User Story 1 — Every agent has a card that says what it can do (Priority: P1) 🎯 MVP + +**Goal**: Build the A2A 1.0 card from agent + template + skills + knowledge bases; serve it at the well-known path behind a credential; answer `SendMessage`/`GetTask` on the JSON-RPC endpoint by running a synchronous bridle conversation. + +**Independent Test**: quickstart §3 first block (own card via REST, 401 without credential, card at the well-known path with an owner JWT) and the `SendMessage` curl with B running (completed), stopped (failed at once), and a 3-element chain (rejected). + +### Card + +- [ ] T017 [P] [US1] Implement `AgentCardService` in `api/src/slices/agent/peer/domain/agentCard.service.ts` over `IAgentGateway`, `ITemplateGateway`, `ISkillGateway`, `IKnowledgeGateway`, `IInfraConfigGateway`: `build(agentId): Promise` exactly per data-model §3 (`description` fallback chain, `version`, `supportedInterfaces[0] = { url: `${apiPublicUrl}/a2a/agents/${id}`, protocolBinding: 'JSONRPC', protocolVersion: '1.0' }`, capabilities all false, `text/plain` modes, `skill:*` from `skillGateway.findByIds(template.skillIds)`, `knowledge:*` from `findExistingByIds(agent.knowledgeIds.length ? agent.knowledgeIds : template.defaultKnowledgeIds)`, `securitySchemes.peerBearer`, `securityRequirements`, `provider`); throws `NotFoundException` for an unknown agent; a `cardUrlFor(agentId)` helper returning `${apiPublicUrl}/a2a/agents/${id}/.well-known/agent-card.json` +- [ ] T018 [P] [US1] Write `api/src/slices/agent/peer/domain/agentCard.service.spec.ts` with object-literal gateway stubs: two template skills → two `skill:*` entries with `name = title`, `description = description ?? title`, `tags: ['skill']`; agent `knowledgeIds` non-empty wins over template defaults; missing knowledge ids dropped; empty skills allowed; description falls back to template then to `Ranch agent «name»`; URL absolute and trailing-slash-free; `JSON.stringify(card)` contains no `peer` key; `cardUrlFor` shape (depends on T017) + +### A2A server + +- [ ] T019 [P] [US1] Implement `A2aTaskStore` in `api/src/slices/agent/peer/domain/a2aTask.store.ts`: `put(task)`, `get(id): A2aTask | null`, TTL `10 * 60_000` ms (constant `A2A_TASK_TTL_MS`), lazy sweep on `get` + `setInterval(60_000).unref()` started in `onModuleInit`, cleared in `onModuleDestroy` +- [ ] T020 [P] [US1] Implement `A2aCardGuard` in `api/src/slices/agent/peer/guards/a2aCard.guard.ts` (pattern `api/src/slices/bridle/guards/bridleChatAuth.guard.ts:58-108`): reads `Authorization: Bearer`; if it matches `PEER_TOKEN_RE` → `IPeerGateway.findByToken`, allow only when `row.peerAgentId === req.params.agentId` and set `req.peer = { peerId: row.id, callerAgentId: row.agentId }`; else `JwtService.verify` and allow only `Owner|Admin` roles (`hasAtLeastRole` from `api/src/slices/user/auth/guards/roles.guard.ts`); any failure → `UnauthorizedException({ code: 'A2A_UNAUTHORIZED', message })`; and `A2aPeerGuard` in `guards/a2aPeer.guard.ts` — same but **only** the `ap_` branch +- [ ] T021 [P] [US1] Write `api/src/slices/agent/peer/guards/a2aCard.guard.spec.ts` and `a2aPeer.guard.spec.ts` (stub pattern `bridleChatAuth.guard.spec.ts:26-55`): no header → 401 `A2A_UNAUTHORIZED`; `ap_` scoped to another agent → 401; `ap_` scoped to `:agentId` → allowed with `req.peer` set; owner JWT → allowed on the card guard, refused on the peer guard; user-role JWT → refused on both (depends on T020) +- [ ] T022 [US1] Implement `A2aServerService` in `api/src/slices/agent/peer/domain/a2a.server.service.ts` over `IBridleGateway`, `BridleSyncService`, `IAgentGateway`, `A2aTaskStore`, `ConfigService`: `sendMessage(agentId, callerAgentId, params: SendMessageParams): Promise` applying contracts §2.1 in order — `returnImmediately` → throw `A2aRpcError(Unsupported)`; non-text part → `A2aRpcError(ContentTypeNotSupported)`; chain rules **as a pluggable `checkChain(chain, agentId)` that this task implements as a no-op returning null** (US5 fills it); `!isAgentConnected(agentId)` → task `TASK_STATE_FAILED` with status message `peer not running`, `metadata.ranch.failure = 'not_running'` (no hub send); else `sendAndAwait({ agentId, clientId: `peer:${callerAgentId}:${contextId}`, text: textOfParts(parts), capabilities: [], timeoutMs: A2A_SYNC_TIMEOUT_MS })` → `timedOut` → `TASK_STATE_FAILED` `timed out after s` `failure = 'timeout'`; reply → `TASK_STATE_COMPLETED` with one artifact `{ artifactId: 'reply', name: 'reply', parts: [{ text }] }`; every task carries `id = randomUUID()`, `contextId` (given or `ctx-`), `status.timestamp` ISO, `history: []`, `metadata.ranch = { chain: [...inbound, agentId], durationMs }`; stores the task; `getTask(id)` → task or `A2aRpcError(TaskNotFound)`; define `class A2aRpcError extends Error { constructor(public code: number, message: string, public data?: unknown) }` in `domain/a2a.types.ts` (depends on T003, T013, T019) +- [ ] T023 [US1] Write `api/src/slices/agent/peer/domain/a2a.server.service.spec.ts` with hub/sync/task-store stubs: `returnImmediately` → `-32004`; `{ raw }` part → `-32005`; offline agent → failed `not_running` and `sendAndAwait` never called; timeout → failed `timeout`; reply → completed, artifact text equals the reply, `clientId` is `peer::`, `capabilities` empty; `contextId` minted when absent and preserved when given; `chain` extended with the agent id; `getTask` unknown → `-32001` (depends on T022) +- [ ] T024 [US1] Implement `A2aController` in `api/src/slices/agent/peer/a2a.controller.ts` (`@Controller('a2a/agents')`, `@ApiTags('a2a')`, `@ApiExcludeController()` so the SDK does not generate it): `GET :agentId/.well-known/agent-card.json` under `@UseGuards(A2aCardGuard)` returning the raw card; `POST :agentId` under `@UseGuards(A2aPeerGuard)` that (a) checks `A2A-Version` header equals `'1.0'` else JSON-RPC error `-32009`, (b) validates the envelope (`jsonrpc === '2.0'`, string `method`) else `-32600`, (c) dispatches `SendMessage` → `{ result: { task } }`, `GetTask` → `{ result: { task } }`, `CancelTask` → `-32002` if known else `-32001`, every other known A2A method name → `-32004`, unknown → `-32601`, (d) maps `A2aRpcError` to `{ jsonrpc, id, error: { code, message, data } }` and any other throw to `-32603`; **bypass the response envelope** for both routes — check how `api/src/slices/setup/error/response.interceptor.ts:16-19` decides to wrap and add a `@RawResponse()` metadata decorator it honours (or reuse an existing skip mechanism if one is found there) (depends on T017, T020, T022) +- [ ] T025 [US1] Write `api/src/slices/agent/peer/a2a.controller.spec.ts` (`new A2aController(stubs)` + `Reflector` guard-metadata assertions as in `shareLink.controller.spec.ts:21-26`): card route guarded by `A2aCardGuard`, JSON-RPC route by `A2aPeerGuard`; missing/other `A2A-Version` → `-32009`; `SendMessage` returns `{ result: { task } }` unwrapped; `GetTask` unknown → `-32001`; `SendStreamingMessage` → `-32004`; unknown method → `-32601`; an unexpected throw → `-32603`; the raw-response marker is present on both routes (depends on T024) +- [ ] T026 [US1] Wire `peer.module.ts` providers/controllers for this story: `AgentCardService`, `A2aTaskStore`, `A2aServerService`, `A2aCardGuard`, `A2aPeerGuard`, `A2aController`, plus a temporary in-memory `IPeerGateway` stub is **not** allowed — instead implement `IPeerGateway.findByToken` now via T030's gateway if US2 runs first, otherwise mark the card guard's `ap_` branch as depending on T030 and verify the owner-JWT path with quickstart §3 lines 1–4 (depends on T016, T024) + +**Checkpoint**: quickstart §3 — own card via `GET /agents/:id/card` is deferred to US2 (REST controller); the well-known card answers 401 without a credential and 200 with an owner JWT; `SendMessage` with an owner JWT is 401 (peer credential only). + +--- + +## Phase 4: User Story 2 — Connect a peer: hand agent A the card of agent B (Priority: P1) + +**Goal**: Directed peer connections managed in the admin console: candidates, card preview, connect (mints the credential and reads the card over HTTP), refresh, remove, own-card view. + +**Independent Test**: quickstart §3 second block (connect, directed list, self refused, duplicate 409) and the admin Peers tab: add B to A, see skills and read-at, B's tab shows nothing, refresh updates the snapshot, remove clears the list and the credential stops working. + +### API + +- [ ] T027 [P] [US2] Define `abstract class IPeerGateway` in `api/src/slices/agent/peer/domain/peer.gateway.ts`: `listByAgent(agentId): Promise`, `findById(id)`, `findByPair(agentId, peerAgentId)`, `findByToken(token)`, `create(input: { agentId, peerAgentId, token, cardSnapshot, cardUrl, cardReadAt })`, `updateSnapshot(id, { cardSnapshot, cardUrl, cardReadAt })`, `delete(id)`; and `abstract class IDelegationGateway` in `domain/delegation.gateway.ts`: `create(input)`, `finish(id, { status, errorCode?, excerpt?, finishedAt, durationMs })`, `listRecent(agentId, limit)`; export both from `domain/index.ts` +- [ ] T028 [P] [US2] Implement `PeerMapper` (`data/peer.mapper.ts`) and `DelegationMapper` (`data/delegation.mapper.ts`) — Prisma record → domain data with ISO strings, the only files importing `@prisma/client` types (pattern `shareLink.mapper.ts`) +- [ ] T029 [US2] Implement `PeerGateway extends IPeerGateway` in `api/src/slices/agent/peer/data/peer.gateway.ts` and `DelegationGateway` in `data/delegation.gateway.ts` over `PrismaService` (`prisma.agentPeer.*`, `prisma.agentDelegation.*`), `listByAgent` ordered by `createdAt asc`, `listRecent` ordered by `startedAt desc` with `take: limit` (depends on T027, T028) +- [ ] T030 [US2] Write `api/src/slices/agent/peer/data/peer.gateway.spec.ts` with a `makePrismaStub()` (pattern `shareLink.gateway.spec.ts:7-70`) enforcing `@unique token` and `@@unique([agentId, peerAgentId])` (throw `{ code: 'P2002' }`): create/findByPair/findByToken round-trips, `updateSnapshot` changes only the three fields, `delete`; and `data/delegation.gateway.spec.ts`: `finish` sets status/finishedAt/durationMs, `listRecent` newest first and limited (depends on T029) +- [ ] T031 [US2] Implement `A2aClient` (card half) in `api/src/slices/agent/peer/domain/a2a.client.ts`: `fetchCard(cardUrl, token): Promise` using global `fetch` with `Authorization: Bearer `, `Accept: application/json`, a 10 s `AbortSignal.timeout`; non-2xx → `PeerCardUnreachableError(status, bodyExcerpt)`; malformed JSON or missing `name`/`skills`/`supportedInterfaces` → same error with reason; network error → same; define the error class in `domain/peer.types.ts` +- [ ] T032 [US2] Implement `PeerService` in `api/src/slices/agent/peer/domain/peer.service.ts` over `IPeerGateway`, `IAgentGateway`, `AgentCardService`, `A2aClient`: `list(agentId)` → peers joined with the live peer agent (`peerName`, `peerStatus`, `peerExists`); `candidates(agentId)` → every other agent `{ id, name, status, connected }`; `ownCard(agentId)` → `AgentCardService.build`; `connect(agentId, peerAgentId)` → refuse self (`BadRequestException({ code: PEER_SELF })`), unknown peer agent (`NotFoundException({ code: PEER_NOT_FOUND })`), duplicate (`ConflictException({ code: PEER_EXISTS })`); mint `ap_` + `randomBytes(32).toString('base64url')` (pattern `shareLink.service.ts:152`); **create the row first** (so the credential exists), then `fetchCard(cardUrlFor(peerAgentId), token)`; on failure delete the row and throw `BadGatewayException({ code: PEER_CARD_UNREACHABLE, message })`; on success `updateSnapshot`; `refresh(agentId, peerId)` → fetch with the row's token, keep the old snapshot and rethrow `502` on failure; `remove(agentId, peerId)` → 404 if the row's `agentId` differs; the returned data never includes `token` (strip in a `toPublic()` helper) (depends on T017, T027, T031) +- [ ] T033 [US2] Write `api/src/slices/agent/peer/domain/peer.service.spec.ts` with in-memory gateway stubs and a `fetchCard` jest.fn: token format `^ap_[A-Za-z0-9_-]{43}$`; self → `PEER_SELF`; duplicate → `PEER_EXISTS`; connect with a failing card fetch leaves **no** row and throws `PEER_CARD_UNREACHABLE`; connect success stores the snapshot and `cardReadAt`; refresh failure keeps the old snapshot; remove on a foreign row → 404; `list` output has no `token` key; `candidates` excludes the agent itself and marks connected ones (depends on T032); also write `domain/a2a.client.spec.ts` with a mocked `global.fetch`: 200 JSON → card; 401 → unreachable with status; timeout/abort → unreachable +- [ ] T034 [P] [US2] Create DTOs in `api/src/slices/agent/peer/dtos/`: `agentCard.dto.ts` (`AgentCardDto` mirroring `AgentCard` with `@ApiProperty` — nested `AgentSkillDto`, `AgentInterfaceDto`, `AgentCapabilitiesDto`), `agentPeer.dto.ts` (`AgentPeerDto { id, agentId, peerAgentId, peerName, peerStatus, peerExists, card: AgentCardDto, cardUrl, cardReadAt, createdAt }`), `agentPeerCandidate.dto.ts` (`{ id, name, status, connected }`), `connectPeer.dto.ts` (`@IsUUID() peerAgentId`), `agentDelegation.dto.ts` (`{ id, peerAgentId, peerName, task, reason, status, errorCode, excerpt, startedAt, finishedAt, durationMs }`), `listDelegations.query.dto.ts` (`@IsOptional() @IsInt() @Min(1) @Max(100) limit = 20`), `index.ts` +- [ ] T035 [US2] Implement `PeerController` in `api/src/slices/agent/peer/peer.controller.ts` (`@Controller('agents/:agentId')`, `@ApiTags('peers')`, `@ApiBearerAuth()`, class-level `@UseGuards(JwtAuthGuard, RolesGuard)` + `@Roles(Owner, Admin)`): `GET peers` `listAgentPeers`, `GET peers/candidates` `listAgentPeerCandidates`, `GET card` `getAgentCard`, `POST peers` `connectAgentPeer` (201), `POST peers/:peerId/refresh` `refreshAgentPeer`, `DELETE peers/:peerId` `removeAgentPeer` (204), `GET delegations` `listAgentDelegations` (reads `IDelegationGateway.listRecent`; returns `[]` until US5 writes rows) — `@ApiOperation({ operationId })` and `@ApiOkResponse({ type })` on each (pattern `shareLink.controller.ts:76-84`) (depends on T032, T034) +- [ ] T036 [US2] Write `api/src/slices/agent/peer/peer.controller.spec.ts` (`new PeerController(serviceStub)` + `Reflector` metadata: guards, roles, HTTP codes on every route; `ConnectPeerDto` validated via `plainToInstance` + `validate`; a "poisoned" service stub carrying `token` proves no DTO leaks it — pattern `shareLink.controller.spec.ts:243-259`) (depends on T035) +- [ ] T037 [US2] Complete `peer.module.ts` providers (`PeerMapper`, `DelegationMapper`, `{ provide: IPeerGateway, useClass: PeerGateway }`, `{ provide: IDelegationGateway, useClass: DelegationGateway }`, `A2aClient`, `PeerService`) and controllers (`PeerController`); then regenerate: `cd api && bun run build && bun run generate:swagger`, `cd admin && bun run build:api` — confirm the seven `operationId`s appear in `admin/slices/setup/api/data/repositories/api/sdk.gen.ts` (depends on T029, T035) + +### Admin + +- [ ] T038 [P] [US2] Create the admin slice skeleton `admin/slices/agent/peer/`: `nuxt.config.ts` (alias `'#peer'`, `imports.dirs: ['/stores']`, i18n module + empty `i18n/locales/en.json` — copy of `admin/slices/agent/agentChannel/nuxt.config.ts`), `index.d.ts` declaring `$peerService: PeerService` on `NuxtApp`, `domain/peer.types.ts` (`IAgentPeer`, `IAgentPeerCandidate`, `IAgentCard`, `IAgentSkill`, `IAgentDelegation` — plain interfaces mirroring the DTOs), `domain/index.ts` +- [ ] T039 [US2] Implement `admin/slices/agent/peer/data/peer.gateway.ts` (`extends BaseGateway`, generated `PeersService` from `#api/data`, every call inside `this.execute` + `unwrapEnvelope` — pattern `admin/slices/agent/agent/data/agent.gateway.ts:50-55`): `list(agentId)`, `candidates(agentId)`, `card(agentId)`, `connect(agentId, peerAgentId)`, `refresh(agentId, peerId)`, `remove(agentId, peerId)`, `delegations(agentId, limit)`; `domain/peer.service.ts` pass-through; `plugins/di.ts` providing `$peerService` (pattern `admin/slices/agent/agent/plugins/di.ts:11-28`) (depends on T037, T038) +- [ ] T040 [US2] Implement Pinia store `admin/slices/agent/peer/stores/peer.ts` (`defineStore('peer', () => …)` setup style, `createServiceGetter('$peerService')`): state keyed by agent id — `peers`, `candidates`, `ownCard`, `delegations`, `loading`, `error: string | null`; actions `load(agentId)` (peers + own card, `Promise.allSettled`), `loadCandidates(agentId)`, `previewCard(peerAgentId)` (calls `card(peerAgentId)`), `connect(agentId, peerAgentId)`, `refresh(agentId, peerId)`, `remove(agentId, peerId)`, `loadDelegations(agentId)`; `connect`/`refresh`/`remove` call `useAgentStore().markPendingRestart()` on success (pattern `agentChannel/components/agentChannel/Provider.vue:132-167`); errors become inline strings from the API `message` (depends on T039) +- [ ] T041 [P] [US2] Create `admin/slices/agent/peer/components/peer/CardView.vue`: props `{ card: IAgentCard; compact?: boolean }` — name, description, a `Badge variant="outline"` per skill with the description in `title`, tags rendered as a muted suffix (`skill` / `knowledge`), the interface URL in a `font-mono text-xs` line; "No skills advertised" line when `skills` is empty (pattern `overview/KnowledgeCard.vue`) +- [ ] T042 [P] [US2] Create `admin/slices/agent/peer/components/peer/Row.vue`: props `{ peer: IAgentPeer; busy?: boolean }`, emits `refresh`, `remove` — name (link to `/agents/`), status pill using `AGENT_STATUS_VARIANT` from `admin/slices/agent/agent/utils/agentFormat.ts`, skill badges (first 4 + "+n"), `cardReadAt` via the existing `TimeAgo` component (`admin/slices/common/components/date/TimeAgo.vue`), a `DropdownMenu` with Refresh card / Remove; when two peers in the list share an identical `description` show an amber "indistinguishable from «X»" note (spec edge case) — the comparison is done by the parent and passed as `warning?: string` +- [ ] T043 [US2] Create `admin/slices/agent/peer/components/peer/Picker.vue`: props `{ agentId: string }`, emits `connected` — loads candidates via the store; a filter `Input` when > 6 candidates; rows with name + status + "connected" badge (disabled when connected or when `id === agentId`); selecting a row calls `previewCard` and shows `CardView` below with **Connect** / **Cancel** buttons; connect error inline; on success emits `connected` (depends on T040, T041) +- [ ] T044 [US2] Create `admin/slices/agent/peer/components/peer/Tab.vue`: props `{ agent: IAgentData }`; sections in a `flex flex-col gap-6`: (1) `Card` "Agent card" with `CardView :card="ownCard"` and a one-line explainer "What other agents read about this agent. Built from its name, description, template skills and knowledge bases."; (2) `Card` "Peers" with an **Add peer** button toggling `Picker`, the `Row` list (Skeleton while loading, dashed empty state "No peers yet. Connect another agent's card to let this agent delegate tasks to it."), a `ConfirmDialog` (`admin/slices/common/components/confirm/Dialog.vue`) on remove, and the note "Changes apply after the agent restarts." when `agentStore.pendingRestart` is set; (3) placeholder `Delegations` section rendered only when the store has rows (component arrives in US5); `onMounted` → `store.load(agent.id)` (depends on T040, T042, T043) +- [ ] T045 [US2] Register the tab: add `{ value: 'peers', title: 'Peers', desc: 'Other agents this one can delegate to', countKey: 'peers', primary: true }` after `knowledge` in `admin/slices/agent/agent/components/agent/workspace/sections.ts` `AGENT_TABS`, extend `SectionCountKey` with `'peers'`; add the `v-else-if="tab === 'peers'"` branch mounting `` in `workspace/Canvas.vue:56-116`; add a `peers` loader (`peerStore.list(agent.id).length`) to `admin/slices/agent/agent/composables/useAgentSectionCounts.ts:46-75` under the same `allSettled`/`catch(() => null)` shape (depends on T044) +- [ ] T046 [US2] `cd admin && bun run build:api && bun run typecheck`; then run quickstart §3 second block and the admin part of the US2 independent test locally (depends on T045) + +**Checkpoint**: A can hold B's card; B's tab is empty; refresh/remove work; the credential stops working after remove (the well-known card returns 401 for the old `ap_`). + +--- + +## Phase 5: User Story 3 — Agent A delegates to peer B and answers with the result (Priority: P1) + +**Goal**: The `ask_agent` MCP tool, listed only for agents with peers, whose description lists the peers' snapshot skills; it runs a delegation over HTTP to the peer's JSON-RPC endpoint and returns the reply or an explicit failure the model cannot mistake for an answer. + +**Independent Test**: spec Story 3 — B with a knowledge base, connected to A; ask A about the topic → A answers with the fact and names B; stop B → A says B could not be reached. + +- [ ] T047 [US3] Implement the message half of `A2aClient` in `api/src/slices/agent/peer/domain/a2a.client.ts`: `sendMessage(interfaceUrl, token, params: SendMessageParams, timeoutMs): Promise` — POST JSON-RPC `{ jsonrpc: '2.0', id: , method: 'SendMessage', params }` with `Authorization: Bearer`, `A2A-Version: 1.0`, `Content-Type: application/json`, `AbortSignal.timeout(timeoutMs + 5_000)`; HTTP 401/403 → `DelegationError(Unauthorized)`; other non-2xx or network/abort → `DelegationError(Unreachable, detail)`; JSON-RPC `error` → `DelegationError(Error, error.message)`; result without `task` → `DelegationError(Error, 'peer returned a message, not a task')`; define `class DelegationError extends Error { constructor(public code: DelegationErrorCode, message) }` in `peer.types.ts` (depends on T031) +- [ ] T048 [US3] Extend `api/src/slices/agent/peer/domain/a2a.client.spec.ts` with a mocked `global.fetch` for `sendMessage`: sends the three headers and the envelope; 401 → `PEER_UNAUTHORIZED`; 500 → `PEER_UNREACHABLE`; JSON-RPC error → `PEER_ERROR` with the message; `{ result: { task } }` → the task (depends on T047) +- [ ] T049 [US3] Implement `DelegationService` in `api/src/slices/agent/peer/domain/delegation.service.ts` over `IPeerGateway`, `IDelegationGateway`, `A2aClient`, `IBridleGateway`, `ConfigService`: `run(input: { callerAgentId; peer: string; task: string; reason: string; contextId?: string; inboundChain: string[] }): Promise` — resolve the peer among `listByAgent(callerAgentId)` by id or case-insensitive `cardSnapshot.name` (no match → outcome `{ kind: 'no_match', peers }`); compute `matchedSkills` = snapshot skills whose `name` or `description` shares a word (≥ 4 letters, case-insensitive) with `reason`+`task`, capped at 3, falling back to the first skill; `contextId = input.contextId ?? 'ctx-' + uuid`; **create the delegation row (`waiting`)**; call the step emitter hook `onStart(row, matchedSkills)` (no-op until US4); `sendMessage(snapshot.supportedInterfaces[0].url, row.token, { message: { messageId: uuid, role: 'ROLE_USER', parts: [{ text: task }], contextId, metadata: { ranch: { chain: [...inboundChain, callerAgentId], reason } } }, configuration: { acceptedOutputModes: ['text/plain'], returnImmediately: false } }, timeoutMs)`; map the returned task: `TASK_STATE_COMPLETED` → `answered` with `text = textOfParts(artifacts[0].parts)` and `excerpt = text.slice(0, 300)`; `TASK_STATE_REJECTED` → `rejected` with `errorCode` from `metadata.ranch.rejection` (`loop` → `PEER_REJECTED_LOOP`, `depth` → `PEER_REJECTED_DEPTH`) and `excerpt = status.message text`; `TASK_STATE_FAILED` → `failed` with `errorCode` from `metadata.ranch.failure` (`not_running` → `PEER_NOT_RUNNING`, `timeout` → `PEER_TIMEOUT`, else `PEER_ERROR`); a thrown `DelegationError` → `failed` with its code; **finish the row exactly once**; call `onFinish(row)`; return `{ kind: 'done', status, text?, errorCode?, excerpt, peerName, contextId, durationMs }`; `inboundChain` is resolved by the caller (T051) (depends on T027, T047) +- [ ] T050 [US3] Write `api/src/slices/agent/peer/domain/delegation.service.spec.ts`: peer matched by id and by name (case-insensitive); no match → `no_match` with the peer names; row created `waiting` **before** `sendMessage` is invoked (assert call order with a shared array); completed → `answered`, excerpt ≤ 300 chars, row finished with `durationMs`; rejected loop/depth → codes; failed not_running/timeout → codes; thrown `DelegationError` → `failed` with its code and the row still finished; `chain` sent = `[...inbound, caller]`; `contextId` reused when given (depends on T049) +- [ ] T051 [US3] Implement `AskAgentTool` in `api/src/slices/agent/peer/askAgent.tool.ts` (`@Injectable`, implements `IDynamicallyDescribedTool` and `IConditionallyListedTool`): `extractAgentId(req)` (copy of `knowledge.tool.ts:169-175`); `isListedForRequest` → agent principal with ≥ 1 peer; `describeForRequest` → contracts §4 dynamic text built from `listByAgent` snapshots (name, peer id, description, `Skills: name (description); …`), `null` when no peers; `@Tool({ name: 'ask_agent', description: , parameters: z.object({ peer, task, reason, context_id }) })` method `ask(args, _ctx, req)` — non-agent → `err('ask_agent can only be called by an agent runtime.')`; validate `args` with the zod schema (`safeParse`, first issue message on failure); `inboundChain`: read from the caller's **own** in-flight delegation context — a `Map` on `A2aServerService` set for the duration of `sendAndAwait` (`enterChain(agentId, chain)` / `exitChain`), `[]` when absent; call `DelegationService.run`; render results exactly as contracts §4 (success text with name, `context_id`, seconds; `isError` texts for failed/rejected/no_match, each ending with the "do not guess on its behalf" instruction); register the tool in `peer.module.ts` providers (depends on T008, T022, T049) +- [ ] T052 [US3] Write `api/src/slices/agent/peer/askAgent.tool.spec.ts` (harness pattern `knowledge.tool.spec.ts:31-92`, request `{ user: { sub: 'agent:a' } }`): not listed / listed by peer count; `describeForRequest` lists two peers with skills and returns `null` for none; a user principal → `isError`; missing `reason` → `isError` with the zod message; `answered` → text contains reply, name, `context_id`; `failed` `PEER_NOT_RUNNING` → `isError` text contains "peer not running" and "do not guess"; `rejected` → "refused the task"; `no_match` → lists the peers; `inboundChain` forwarded when the caller is mid-delegation (depends on T051) +- [ ] T053 [US3] Add `enterChain/exitChain` bookkeeping to `A2aServerService.sendMessage` around `sendAndAwait` (try/finally) and cover it in `a2a.server.service.spec.ts`; then run `cd api && bun run test -- peer agentCard a2a askAgent` and the spec Story 3 independent test locally with two agents (restart A after connecting B — research R7) (depends on T022, T051) + +**Checkpoint**: A answers a topic-X question with B's fact and names B; with B stopped, A says it could not reach B. No visible step yet — that is US4. + +--- + +## Phase 6: User Story 4 — The person watching sees the delegation happen (Priority: P1) + +**Goal**: A structured delegation step in the caller's thinking timeline, pushed at start and at finish with the same id, rendered with a dedicated layout in the admin chat and as a plain step elsewhere. + +**Independent Test**: spec Story 4 — trigger a delegation while watching A's admin chat: "Asking «B»" with skill badges, reason, task and a running timer appears before the answer, then turns into "Answered by «B»" with duration and excerpt; a failed delegation ends as "Could not reach «B»" with the cause; the collapsed block re-expands with the step intact. + +- [ ] T054 [P] [US4] Implement `api/src/slices/agent/peer/domain/delegationStep.ts`: `buildDelegationStep(row: IAgentDelegationData, matchedSkills, phase: 'start' | 'finish'): IBridleThinkingStep` — `id: 'delegation:' + row.id`; `label` `Asking «name»` / `Answered by «name»` / `Could not reach «name»` / `«name» refused the task`; `state` `active`/`done`; `kind: 'delegation'`; `delegation` per data-model §5 (`startedAt` epoch ms, `durationMs`, `excerpt`); `detail` markdown per contracts §5 (`**Peer:** … — skills`, `**Why:**`, `**Task:**`, `**Status:** waiting…` or `answered in 3.1s` + excerpt or `failed: cause`); a `causeText(errorCode)` map to product wording (`peer not running`, `timed out after Ns`, `would loop`, `too deep`, `not authorised`, `could not be reached`, `error`) +- [ ] T055 [P] [US4] Write `api/src/slices/agent/peer/domain/delegationStep.spec.ts`: ids stable across phases; labels per status; `state` active on start and done on finish; `detail` contains peer, why, task and, on finish, the excerpt or cause; `kind`/`delegation` present (depends on T054) +- [ ] T056 [US4] Wire the emitter into `DelegationService` (`domain/delegation.service.ts`): on `run`, `const turn = hub.findActiveTurn(callerAgentId)`; store `turnId`/`clientId` on the row when known; `onStart` → `hub.sendToClient(turn.clientId, callerAgentId, { type: 'thinking', clientId: turn.clientId, turnId: turn.turnId, ts: Date.now(), step: buildDelegationStep(row, skills, 'start') })`; `onFinish` → same with `'finish'`; when `turn` is null push nothing and log at debug `no active turn for , delegation step not shown`; never send `done: true` (depends on T049, T054) +- [ ] T057 [US4] Extend `api/src/slices/agent/peer/domain/delegation.service.spec.ts`: with an active turn two `sendToClient` calls with the same `step.id`, first `active`/`waiting`, second `done`/final status, `turnId` and `clientId` from `findActiveTurn` and persisted on the row; with no active turn zero calls and the delegation still completes; no call carries `done: true` (depends on T056) +- [ ] T058 [P] [US4] Mirror the type in `admin/slices/bridle/stores/bridle.ts:64-69`: add `kind?: 'delegation'` and `delegation?: IBridleDelegationStep` (same fields as T010) to `IBridleThinkingStep`; no store logic change (steps already replace by id at `:580-585`) +- [ ] T059 [US4] Create `admin/slices/bridle/components/bridle/DelegationStep.vue`: props `{ step: IBridleThinkingStep }` (with `step.delegation` required by a guard in the parent); layout — a peer icon (`IconUsers` from `@tabler/icons-vue`) + `label`; a row of `Badge variant="outline"` for `matchedSkills`; "Why: " in `text-muted-foreground`; the task in a `border-l-2 pl-3 italic` quote; a status pill (`waiting` amber + shimmer, `answered` green, `failed`/`rejected` red) with elapsed time — while `waiting`, a `useIntervalFn(…, 250)` (`@vueuse/core`) ticks `Date.now() - startedAt` formatted `s.s s`; after, `durationMs`; the `excerpt` (answered) or cause (failed/rejected) as a final line; respects `prefers-reduced-motion` like the existing shimmer (depends on T058) +- [ ] T060 [US4] In `admin/slices/bridle/components/bridle/Provider.vue:574-604` render `` in place of the label/detail row for that step, keeping the existing row for every other step; ensure the block-level "is thinking…" shimmer and collapse behaviour are untouched; `cd admin && bun run typecheck` (depends on T059) +- [ ] T061 [US4] Run the spec Story 4 independent test locally (quickstart §4 steps 4–7) and record on the ticket whether the runtime emitted a step before `ask_agent` ran (research §4 risk); if the step never appears although the delegation succeeded, note the `_meta.turnId` follow-up on the ticket (depends on T056, T060) + +**Checkpoint**: The demo's visible half works end to end in the admin chat; generic surfaces show "Asking «B»" with the markdown detail. + +--- + +## Phase 7: User Story 5 — Delegation cannot run away (Priority: P2) + +**Goal**: Loop and depth refusal on the receiving side of every hop, and the audit trail readable in the admin console. + +**Independent Test**: spec Story 5 — A→B and B→A connected; B's attempt to call A back is refused with "would loop" and B answers alone; a chain beyond 3 hops is refused with "too deep"; A's Peers tab lists the delegations with peer, time, duration and outcome. + +- [ ] T062 [US5] Implement `checkChain(chain: string[], agentId: string): { rejection: 'loop' | 'depth'; message: string } | null` in `api/src/slices/agent/peer/domain/a2a.server.service.ts` (replacing the US1 no-op): `chain.includes(agentId)` → `loop`, `would loop: is already in the chain`; `chain.length >= A2A_MAX_CHAIN` (env, default 3) → `depth`, `too deep: chain limit is hops`; on rejection build a task `TASK_STATE_REJECTED` with `status.message = { messageId, role: 'ROLE_AGENT', parts: [{ text }] }`, `metadata.ranch.rejection`, no artifacts, **before** the `isAgentConnected` check and without touching the hub (depends on T022) +- [ ] T063 [US5] Extend `api/src/slices/agent/peer/domain/a2a.server.service.spec.ts`: chain containing the agent → rejected `loop`, `sendAndAwait` not called; chain of 3 → rejected `depth`; chain of 2 → proceeds; `A2A_MAX_CHAIN=1` respected; the rejection message names the agent (depends on T062) +- [ ] T064 [P] [US5] Create `admin/slices/agent/peer/components/peer/Delegations.vue`: props `{ agentId: string }`; loads `store.loadDelegations(agentId)` on mount and exposes a **Refresh** button; `Table` with columns Peer (link), Task (truncated with `title`), Outcome (status pill + `errorCode` in product wording via a small map), Started (`TimeAgo`), Duration (`s.s s`); dashed empty state "No delegations yet."; mount it as section (3) of `components/peer/Tab.vue` unconditionally (depends on T040, T044) +- [ ] T065 [US5] `cd admin && bun run typecheck`; run quickstart §6 (loop, live) and check A's and B's Recent delegations show the `answered` and `rejected` rows (depends on T062, T064) + +**Checkpoint**: Loops and depth are refused server-side; the audit list is visible. + +--- + +## Phase 8: User Story 6 — The demo: two agents, one card, one question (Priority: P2) + +**Goal**: A repeatable five-minute walkthrough, verified locally and once in-cluster. + +**Independent Test**: quickstart §4 followed step by step on a fresh installation without improvisation; §5 once on the dev cluster. + +- [ ] T066 [US6] Run quickstart §4 steps 1–9 end to end locally; fix anything that needs improvisation (copy, empty states, restart hint timing) in the files it points to; record timings for SC-001 and SC-003 on the ticket +- [ ] T067 [US6] Run quickstart §5 on the dev cluster: set `API_PUBLIC_URL` (or the `infrastructure/api_public_url` setting) to the in-cluster API URL, repeat §4 steps 3–5, confirm the card URL is reachable from the API pod and the delegation completes; note the result on the ticket (depends on T066) +- [ ] T068 [P] [US6] Add a short "Peers and A2A" section to `README.md` under the agent slices list (`api/src/slices/agent/peer` — what a card is, how to connect a peer, the restart-to-apply note, the `API_PUBLIC_URL` variable) and list the env vars in `docs/operations/` if an env reference exists there + +--- + +## Phase 9: Polish & Cross-Cutting Concerns + +- [ ] T069 [P] Lint and format: `cd api && bun run lint && bun run format`; confirm `cd api && bun run test` is fully green (not only the filtered runs) and `cd admin && bun run typecheck` passes +- [ ] T070 [P] Security pass on the new surface: `token` absent from every DTO and log line (grep `token` in `api/src/slices/agent/peer`); the A2A JSON-RPC route rejects console JWTs; `A2aCardGuard` refuses `User`-role JWTs; peer credential compared with a constant-time check where a lookup by unique column is not already used; `fetch` targets only the stored `cardUrl`/interface URL (no user-supplied URLs reach `fetch` in this feature) +- [ ] T071 Post the closing ticket comment on CLEAN-74 (what shipped, restart-to-apply limitation, the R8 runtime-step finding from T061), open the PR into `main` with the ticket link, quickstart results, and the attribution footer; move the ticket to In Review + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: no dependencies; T002 and T003 in parallel with T001 +- **Foundational (Phase 2)**: T004→T005 (Prisma) and T016 gate the new slice; T006–T015 are four independent threads (infra URL / MCP filter / thinking types + active turn / sync service) — **blocks all stories** +- **US1 (Phase 3)**: after Phase 2. The card guard's `ap_` branch needs `IPeerGateway.findByToken` (T027–T029 from US2); build T027–T029 first if US1 must be fully testable before US2 +- **US2 (Phase 4)**: after Phase 2 and T017 (card service) — the admin half (T038–T046) after T037's SDK regen +- **US3 (Phase 5)**: after US1 (A2A server) and US2 (peers + client card half) +- **US4 (Phase 6)**: after US3 (delegation service) and T010/T011 (types, active turn); admin half (T058–T060) can be built in parallel with T054–T057 +- **US5 (Phase 7)**: T062–T063 after US1; T064–T065 after US2 + US3 +- **US6 (Phase 8)**: after US1–US5 +- **Polish (Phase 9)**: last + +### Parallel Opportunities + +- Phase 2: T006 ∥ T007→T008→T009 ∥ T010→T011→T012 ∥ T013→T014→T015 +- US1: T017/T018 ∥ T019 ∥ T020/T021, then T022→T023→T024→T025 +- US2: T027/T028 ∥ T034; T031 ∥ T029; admin T038 ∥ T041 ∥ T042 while the API half lands +- US4: T054/T055 ∥ T058→T059 +- US5: T062/T063 ∥ T064 + +--- + +## Parallel Example: Foundational + +```bash +# Four independent threads after the migration (T005) lands: +Task: "T006 getApiPublicUrl in api/src/slices/setting/domain/infraConfig.gateway.ts" +Task: "T007–T009 IConditionallyListedTool + mcp-tools.handler filter + spec" +Task: "T010–T012 thinking step fields + findActiveTurn in the bridle gateway + spec" +Task: "T013–T015 BridleSyncService extraction + controller refactor + spec" +``` + +## Parallel Example: User Story 2 (admin half while the API half lands) + +```bash +Task: "T038 admin slice skeleton admin/slices/agent/peer/" +Task: "T041 CardView.vue" +Task: "T042 Row.vue" +# then, once T037 regenerated the SDK: +Task: "T039 gateway + service + di → T040 store → T043 Picker → T044 Tab → T045 register tab" +``` + +--- + +## Implementation Strategy + +### MVP First (US1 + US2 + US3 + US4 — the demo) + +1. Phase 1 + Phase 2 (foundations, all four threads) +2. US1 (card + A2A server) — verify with curl +3. US2 (peers, admin tab) — verify in the console +4. US3 (delegation) — A answers with B's fact +5. US4 (visible step) — the part the user singled out +6. **STOP and VALIDATE**: quickstart §4 locally + +### Incremental Delivery + +- After US1: any A2A-aware client with an owner JWT can read a Ranch agent's card +- After US2: operators can connect peers and see cards; credentials work end to end +- After US3: delegation works, visible only in the reply text +- After US4: delegation visible in the thinking timeline (demo-ready) +- After US5: safe to connect more than a demo pair +- After US6: walkthrough verified locally and in-cluster; PR + +--- + +## Notes + +- Restart-to-apply (research R7): after connecting or removing a peer, restart the caller before testing the tool; the Peers tab shows the existing restart banner +- Never log or return `AgentPeer.token`; DTO specs carry the "poisoned stub" check for it +- The A2A routes bypass the `{ success, data }` envelope; every other new route uses it +- `admin/` copy is raw English; no `en.json` keys for this feature +- Commit per phase with `feat(peer): … (CLEAN-74)` / `feat(admin): … (CLEAN-74)`; ticket comments at each checkpoint (large task) From 2ea2d428e493707bfdd232bc6ca0676b1e3689e7 Mon Sep 17 00:00:00 2001 From: "Maksym Hryzodub [DREAM]" Date: Thu, 10 Sep 2026 19:26:12 +0300 Subject: [PATCH 04/31] docs(tasks): absolute paths on T028/T056 (CLEAN-74) Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01SZ9dCrzZqd5SwR4erLYe12 --- specs/013-a2a-agent-peers/tasks.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/specs/013-a2a-agent-peers/tasks.md b/specs/013-a2a-agent-peers/tasks.md index 9904cde5..034ba8f1 100644 --- a/specs/013-a2a-agent-peers/tasks.md +++ b/specs/013-a2a-agent-peers/tasks.md @@ -93,7 +93,7 @@ ### API - [ ] T027 [P] [US2] Define `abstract class IPeerGateway` in `api/src/slices/agent/peer/domain/peer.gateway.ts`: `listByAgent(agentId): Promise`, `findById(id)`, `findByPair(agentId, peerAgentId)`, `findByToken(token)`, `create(input: { agentId, peerAgentId, token, cardSnapshot, cardUrl, cardReadAt })`, `updateSnapshot(id, { cardSnapshot, cardUrl, cardReadAt })`, `delete(id)`; and `abstract class IDelegationGateway` in `domain/delegation.gateway.ts`: `create(input)`, `finish(id, { status, errorCode?, excerpt?, finishedAt, durationMs })`, `listRecent(agentId, limit)`; export both from `domain/index.ts` -- [ ] T028 [P] [US2] Implement `PeerMapper` (`data/peer.mapper.ts`) and `DelegationMapper` (`data/delegation.mapper.ts`) — Prisma record → domain data with ISO strings, the only files importing `@prisma/client` types (pattern `shareLink.mapper.ts`) +- [ ] T028 [P] [US2] Implement `PeerMapper` in `api/src/slices/agent/peer/data/peer.mapper.ts` and `DelegationMapper` in `api/src/slices/agent/peer/data/delegation.mapper.ts` — Prisma record → domain data with ISO strings, the only files importing `@prisma/client` types (pattern `shareLink.mapper.ts`) - [ ] T029 [US2] Implement `PeerGateway extends IPeerGateway` in `api/src/slices/agent/peer/data/peer.gateway.ts` and `DelegationGateway` in `data/delegation.gateway.ts` over `PrismaService` (`prisma.agentPeer.*`, `prisma.agentDelegation.*`), `listByAgent` ordered by `createdAt asc`, `listRecent` ordered by `startedAt desc` with `take: limit` (depends on T027, T028) - [ ] T030 [US2] Write `api/src/slices/agent/peer/data/peer.gateway.spec.ts` with a `makePrismaStub()` (pattern `shareLink.gateway.spec.ts:7-70`) enforcing `@unique token` and `@@unique([agentId, peerAgentId])` (throw `{ code: 'P2002' }`): create/findByPair/findByToken round-trips, `updateSnapshot` changes only the three fields, `delete`; and `data/delegation.gateway.spec.ts`: `finish` sets status/finishedAt/durationMs, `listRecent` newest first and limited (depends on T029) - [ ] T031 [US2] Implement `A2aClient` (card half) in `api/src/slices/agent/peer/domain/a2a.client.ts`: `fetchCard(cardUrl, token): Promise` using global `fetch` with `Authorization: Bearer `, `Accept: application/json`, a 10 s `AbortSignal.timeout`; non-2xx → `PeerCardUnreachableError(status, bodyExcerpt)`; malformed JSON or missing `name`/`skills`/`supportedInterfaces` → same error with reason; network error → same; define the error class in `domain/peer.types.ts` @@ -146,7 +146,7 @@ - [ ] T054 [P] [US4] Implement `api/src/slices/agent/peer/domain/delegationStep.ts`: `buildDelegationStep(row: IAgentDelegationData, matchedSkills, phase: 'start' | 'finish'): IBridleThinkingStep` — `id: 'delegation:' + row.id`; `label` `Asking «name»` / `Answered by «name»` / `Could not reach «name»` / `«name» refused the task`; `state` `active`/`done`; `kind: 'delegation'`; `delegation` per data-model §5 (`startedAt` epoch ms, `durationMs`, `excerpt`); `detail` markdown per contracts §5 (`**Peer:** … — skills`, `**Why:**`, `**Task:**`, `**Status:** waiting…` or `answered in 3.1s` + excerpt or `failed: cause`); a `causeText(errorCode)` map to product wording (`peer not running`, `timed out after Ns`, `would loop`, `too deep`, `not authorised`, `could not be reached`, `error`) - [ ] T055 [P] [US4] Write `api/src/slices/agent/peer/domain/delegationStep.spec.ts`: ids stable across phases; labels per status; `state` active on start and done on finish; `detail` contains peer, why, task and, on finish, the excerpt or cause; `kind`/`delegation` present (depends on T054) -- [ ] T056 [US4] Wire the emitter into `DelegationService` (`domain/delegation.service.ts`): on `run`, `const turn = hub.findActiveTurn(callerAgentId)`; store `turnId`/`clientId` on the row when known; `onStart` → `hub.sendToClient(turn.clientId, callerAgentId, { type: 'thinking', clientId: turn.clientId, turnId: turn.turnId, ts: Date.now(), step: buildDelegationStep(row, skills, 'start') })`; `onFinish` → same with `'finish'`; when `turn` is null push nothing and log at debug `no active turn for , delegation step not shown`; never send `done: true` (depends on T049, T054) +- [ ] T056 [US4] Wire the emitter into `DelegationService` in `api/src/slices/agent/peer/domain/delegation.service.ts`: on `run`, `const turn = hub.findActiveTurn(callerAgentId)`; store `turnId`/`clientId` on the row when known; `onStart` → `hub.sendToClient(turn.clientId, callerAgentId, { type: 'thinking', clientId: turn.clientId, turnId: turn.turnId, ts: Date.now(), step: buildDelegationStep(row, skills, 'start') })`; `onFinish` → same with `'finish'`; when `turn` is null push nothing and log at debug `no active turn for , delegation step not shown`; never send `done: true` (depends on T049, T054) - [ ] T057 [US4] Extend `api/src/slices/agent/peer/domain/delegation.service.spec.ts`: with an active turn two `sendToClient` calls with the same `step.id`, first `active`/`waiting`, second `done`/final status, `turnId` and `clientId` from `findActiveTurn` and persisted on the row; with no active turn zero calls and the delegation still completes; no call carries `done: true` (depends on T056) - [ ] T058 [P] [US4] Mirror the type in `admin/slices/bridle/stores/bridle.ts:64-69`: add `kind?: 'delegation'` and `delegation?: IBridleDelegationStep` (same fields as T010) to `IBridleThinkingStep`; no store logic change (steps already replace by id at `:580-585`) - [ ] T059 [US4] Create `admin/slices/bridle/components/bridle/DelegationStep.vue`: props `{ step: IBridleThinkingStep }` (with `step.delegation` required by a guard in the parent); layout — a peer icon (`IconUsers` from `@tabler/icons-vue`) + `label`; a row of `Badge variant="outline"` for `matchedSkills`; "Why: " in `text-muted-foreground`; the task in a `border-l-2 pl-3 italic` quote; a status pill (`waiting` amber + shimmer, `answered` green, `failed`/`rejected` red) with elapsed time — while `waiting`, a `useIntervalFn(…, 250)` (`@vueuse/core`) ticks `Date.now() - startedAt` formatted `s.s s`; after, `durationMs`; the `excerpt` (answered) or cause (failed/rejected) as a final line; respects `prefers-reduced-motion` like the existing shimmer (depends on T058) From 76d2ef9f8473cedac76e128b24d6c24b0bfe4f64 Mon Sep 17 00:00:00 2001 From: "Maksym Hryzodub [DREAM]" Date: Mon, 14 Sep 2026 12:38:13 +0300 Subject: [PATCH 05/31] =?UTF-8?q?feat(peer):=20A2A=20foundations=20?= =?UTF-8?q?=E2=80=94=20models,=20protocol=20types,=20per-caller=20tool=20l?= =?UTF-8?q?isting=20(CLEAN-74)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01SZ9dCrzZqd5SwR4erLYe12 --- api/.env.example | 11 + .../migration.sql | 68 ++++ api/src/slices/agent/agent/agent.prisma | 7 + api/src/slices/agent/peer/delegation.prisma | 47 +++ api/src/slices/agent/peer/domain/a2a.types.ts | 318 ++++++++++++++++++ api/src/slices/agent/peer/domain/index.ts | 2 + .../slices/agent/peer/domain/peer.types.ts | 199 +++++++++++ api/src/slices/agent/peer/peer.module.ts | 19 ++ api/src/slices/agent/peer/peer.prisma | 41 +++ .../conditional-listing.interface.ts | 33 ++ api/src/slices/mcp/interfaces/index.ts | 1 + .../handlers/mcp-tools.handler.spec.ts | 238 +++++++++++++ .../services/handlers/mcp-tools.handler.ts | 40 ++- .../setting/data/infraConfig.gateway.spec.ts | 119 +++++++ .../setting/data/infraConfig.gateway.ts | 39 +++ .../setting/domain/infraConfig.gateway.ts | 9 + k8s/deploy/30-api.yaml | 4 + specs/013-a2a-agent-peers/plan.md | 4 +- specs/013-a2a-agent-peers/tasks.md | 2 +- 19 files changed, 1192 insertions(+), 9 deletions(-) create mode 100644 api/prisma/migrations/20260914120000_agent_peer_delegation/migration.sql create mode 100644 api/src/slices/agent/peer/delegation.prisma create mode 100644 api/src/slices/agent/peer/domain/a2a.types.ts create mode 100644 api/src/slices/agent/peer/domain/index.ts create mode 100644 api/src/slices/agent/peer/domain/peer.types.ts create mode 100644 api/src/slices/agent/peer/peer.module.ts create mode 100644 api/src/slices/agent/peer/peer.prisma create mode 100644 api/src/slices/mcp/interfaces/conditional-listing.interface.ts create mode 100644 api/src/slices/mcp/services/handlers/mcp-tools.handler.spec.ts create mode 100644 api/src/slices/setting/data/infraConfig.gateway.spec.ts diff --git a/api/.env.example b/api/.env.example index e9abc5c6..51a1e4e2 100644 --- a/api/.env.example +++ b/api/.env.example @@ -51,3 +51,14 @@ REINS_S3_BUCKET=ranch-reins-sources # it can read; with MinIO only single-page scans work (sent inline). # REINS_OCR_ENABLED=true # REINS_OCR_MAX_PAGES=500 + +# Public origin a browser and other agents reach this API on. Introduced for the +# OAuth MCP connect callback (CLEAN-75); agent-to-agent (CLEAN-74) also builds +# every agent card URL from it. Overridden by the infrastructure/api_public_url +# setting; falls back to the ranch_api_url integration value when unset. +PUBLIC_API_URL=http://localhost:3333 +# Agent-to-agent (A2A, CLEAN-74). How long a delegation waits for the peer's +# reply — same ceiling as the synchronous chat route. +A2A_SYNC_TIMEOUT_MS=120000 +# Hops allowed beyond the agent the person is talking to. 3 still allows A→B→C. +A2A_MAX_CHAIN=3 diff --git a/api/prisma/migrations/20260914120000_agent_peer_delegation/migration.sql b/api/prisma/migrations/20260914120000_agent_peer_delegation/migration.sql new file mode 100644 index 00000000..9cf55954 --- /dev/null +++ b/api/prisma/migrations/20260914120000_agent_peer_delegation/migration.sql @@ -0,0 +1,68 @@ +-- Additive: new tables AgentPeer (directed peer connection + pair credential + +-- card snapshot) and AgentDelegation (audit row per delegated task), both with +-- FKs to Agent. Safe on an existing database. + +-- CreateTable +CREATE TABLE "AgentPeer" ( + "id" TEXT NOT NULL, + "agentId" TEXT NOT NULL, + "peerAgentId" TEXT NOT NULL, + "token" TEXT NOT NULL, + "cardSnapshot" JSONB NOT NULL, + "cardUrl" TEXT NOT NULL, + "cardReadAt" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "AgentPeer_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "AgentDelegation" ( + "id" TEXT NOT NULL, + "agentId" TEXT NOT NULL, + "peerId" TEXT, + "peerAgentId" TEXT NOT NULL, + "peerName" TEXT NOT NULL, + "contextId" TEXT NOT NULL, + "turnId" TEXT, + "clientId" TEXT, + "task" TEXT NOT NULL, + "reason" TEXT NOT NULL, + "matchedSkills" JSONB NOT NULL DEFAULT '[]', + "status" TEXT NOT NULL DEFAULT 'waiting', + "errorCode" TEXT, + "excerpt" TEXT, + "startedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "finishedAt" TIMESTAMP(3), + "durationMs" INTEGER, + + CONSTRAINT "AgentDelegation_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "AgentPeer_token_key" ON "AgentPeer"("token"); + +-- CreateIndex +CREATE INDEX "AgentPeer_agentId_idx" ON "AgentPeer"("agentId"); + +-- CreateIndex +CREATE INDEX "AgentPeer_peerAgentId_idx" ON "AgentPeer"("peerAgentId"); + +-- CreateIndex +CREATE UNIQUE INDEX "AgentPeer_agentId_peerAgentId_key" ON "AgentPeer"("agentId", "peerAgentId"); + +-- CreateIndex +CREATE INDEX "AgentDelegation_agentId_startedAt_idx" ON "AgentDelegation"("agentId", "startedAt"); + +-- AddForeignKey +ALTER TABLE "AgentPeer" ADD CONSTRAINT "AgentPeer_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "AgentPeer" ADD CONSTRAINT "AgentPeer_peerAgentId_fkey" FOREIGN KEY ("peerAgentId") REFERENCES "Agent"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "AgentDelegation" ADD CONSTRAINT "AgentDelegation_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "AgentDelegation" ADD CONSTRAINT "AgentDelegation_peerId_fkey" FOREIGN KEY ("peerId") REFERENCES "AgentPeer"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/api/src/slices/agent/agent/agent.prisma b/api/src/slices/agent/agent/agent.prisma index 40f34852..534f8ac7 100644 --- a/api/src/slices/agent/agent/agent.prisma +++ b/api/src/slices/agent/agent/agent.prisma @@ -5,6 +5,8 @@ import { PaddockScenario } from "../../paddock/scenario/scenario" import { PaddockEvaluation } from "../../paddock/evaluation/evaluation" import { ChatSession } from "../../chat/chat" import { AgentShareLink } from "../shareLink/shareLink" +import { AgentPeer } from "../peer/peer" +import { AgentDelegation } from "../peer/delegation" model Agent { id String @id @default(uuid()) @@ -53,4 +55,9 @@ model Agent { chatSessions ChatSession[] // At most one public share link per agent (CLEAN-66). shareLink AgentShareLink? + // Agents this one may delegate to, and the ones that may delegate to it + // (CLEAN-74). Directed: the two lists are unrelated. + peers AgentPeer[] @relation("AgentPeerCaller") + peerOf AgentPeer[] @relation("AgentPeerTarget") + delegations AgentDelegation[] } diff --git a/api/src/slices/agent/peer/delegation.prisma b/api/src/slices/agent/peer/delegation.prisma new file mode 100644 index 00000000..b660a6c9 --- /dev/null +++ b/api/src/slices/agent/peer/delegation.prisma @@ -0,0 +1,47 @@ +import { Agent } from "../agent/agent" +import { AgentPeer } from "./peer" + +// One task handed to a peer during one chat turn (CLEAN-74) — the durable +// audit record behind "who asked whom, when, and how long it took" (FR-016), +// and the source of the Recent delegations list on the Peers tab. +// +// The row is created in `waiting` BEFORE the outbound call, so a crash +// mid-call still leaves a trace, and finalised exactly once. peerAgentId and +// peerName are denormalised so the record still names the peer after the +// connection it went through has been removed (peerId then goes null). +// +// Shape follows PaddockEvaluation: status + startedAt/finishedAt + error. +model AgentDelegation { + id String @id @default(uuid()) + // The caller. + agentId String + agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade) + // The connection used; null once that connection is gone. + peerId String? + peer AgentPeer? @relation(fields: [peerId], references: [id], onDelete: SetNull) + peerAgentId String + peerName String + // A2A contextId — the same value across follow-ups within one turn. + contextId String + // The caller's thinking turn and chat client, when one was known. Null when + // the delegation ran with no visible timeline to push a step into. + turnId String? + clientId String? + task String + // The calling model's one-line reason for choosing this peer; shown to the + // person in the delegation step. + reason String + // [{ id, name }] — the card skills quoted in that step. + matchedSkills Json @default("[]") + // waiting → answered | failed | rejected. + status String @default("waiting") + // DelegationErrorCodes value; null while waiting and on success. + errorCode String? + // First 300 chars of the reply, or the cause in product wording. + excerpt String? + startedAt DateTime @default(now()) + finishedAt DateTime? + durationMs Int? + + @@index([agentId, startedAt]) +} diff --git a/api/src/slices/agent/peer/domain/a2a.types.ts b/api/src/slices/agent/peer/domain/a2a.types.ts new file mode 100644 index 00000000..198ba40f --- /dev/null +++ b/api/src/slices/agent/peer/domain/a2a.types.ts @@ -0,0 +1,318 @@ +// Agent2Agent (A2A) protocol contract, version 1.0 (CLEAN-74). +// +// Hand-written from the normative proto of the published 1.0 spec rather than +// taken from `@a2a-js/sdk`: Ranch speaks three methods (card, SendMessage, +// GetTask) and the SDK's server half wants its own executor, task store and +// express wiring, which would fight this project's Nest guards and response +// interceptor. See specs/013-a2a-agent-peers/research.md R1. +// +// Names below are 1.0, NOT 0.3 — the version most tutorials still show. The +// differences that bite: the card has `supportedInterfaces[]` instead of a +// top-level `url`/`preferredTransport`, parts carry no `kind` discriminator, +// task states are ProtoJSON enum names (`TASK_STATE_*`) and roles are +// `ROLE_USER` / `ROLE_AGENT`. +// +// Pure types + constants: no Nest, no Prisma, no DTOs. + +/** Protocol version this server speaks and requires from callers. */ +export const A2A_VERSION = '1.0'; + +/** Header every 1.0 client must send. An absent header means 0.3 per spec. */ +export const A2A_VERSION_HEADER = 'A2A-Version'; + +/** Spec-mandated discovery path, appended to an agent's A2A base URL. */ +export const A2A_CARD_PATH = '.well-known/agent-card.json'; + +// ── Agent card ────────────────────────────────────────────────── + +/** One way to reach the agent. The first entry is the preferred one. */ +export interface IA2aAgentInterface { + url: string; + /** 'JSONRPC' | 'GRPC' | 'HTTP+JSON', or a URI for a custom binding. */ + protocolBinding: string; + /** Major.Minor only — never a patch. */ + protocolVersion: string; + tenant?: string; +} + +export interface IA2aAgentCapabilities { + streaming?: boolean; + pushNotifications?: boolean; + extensions?: unknown[]; + extendedAgentCard?: boolean; +} + +/** One thing the agent can do, written so another agent can decide when to ask. */ +export interface IA2aAgentSkill { + id: string; + name: string; + description: string; + tags: string[]; + examples?: string[]; + inputModes?: string[]; + outputModes?: string[]; +} + +export interface IA2aAgentProvider { + organization: string; + url?: string; +} + +/** + * The public description of one agent as another agent reads it. Ranch derives + * it on every read from the agent, its template skills and its bound knowledge + * bases — it is never stored, except as the snapshot a peer connection keeps. + * It deliberately says nothing about the agent's own peers (spec FR-019). + */ +export interface IA2aAgentCard { + name: string; + description: string; + version: string; + supportedInterfaces: IA2aAgentInterface[]; + capabilities: IA2aAgentCapabilities; + defaultInputModes: string[]; + defaultOutputModes: string[]; + skills: IA2aAgentSkill[]; + securitySchemes?: Record; + securityRequirements?: unknown[]; + provider?: IA2aAgentProvider; + documentationUrl?: string; + iconUrl?: string; +} + +// ── Messages and parts ────────────────────────────────────────── + +/** + * A part is a oneof on the key that is present — 1.0 removed the `kind` + * discriminator 0.3 had. Ranch only ever sends and accepts text parts. + */ +export interface IA2aTextPart { + text: string; + metadata?: Record; + mediaType?: string; + filename?: string; +} + +export interface IA2aRawPart { + raw: string; + metadata?: Record; + mediaType?: string; + filename?: string; +} + +export interface IA2aUrlPart { + url: string; + metadata?: Record; + mediaType?: string; + filename?: string; +} + +export interface IA2aDataPart { + data: unknown; + metadata?: Record; + mediaType?: string; + filename?: string; +} + +export type A2aPart = IA2aTextPart | IA2aRawPart | IA2aUrlPart | IA2aDataPart; + +export const A2aRoles = { + User: 'ROLE_USER', + Agent: 'ROLE_AGENT', +} as const; + +export type A2aRole = (typeof A2aRoles)[keyof typeof A2aRoles]; + +export interface IA2aMessage { + messageId: string; + role: A2aRole; + parts: A2aPart[]; + contextId?: string; + taskId?: string; + metadata?: Record; + extensions?: string[]; + referenceTaskIds?: string[]; +} + +export interface IA2aArtifact { + artifactId: string; + parts: A2aPart[]; + name?: string; + description?: string; + metadata?: Record; + extensions?: string[]; +} + +// ── Tasks ─────────────────────────────────────────────────────── + +/** ProtoJSON enum names, which is what 1.0 puts on the wire. */ +export const A2aTaskStates = { + Unspecified: 'TASK_STATE_UNSPECIFIED', + Submitted: 'TASK_STATE_SUBMITTED', + Working: 'TASK_STATE_WORKING', + Completed: 'TASK_STATE_COMPLETED', + Failed: 'TASK_STATE_FAILED', + Canceled: 'TASK_STATE_CANCELED', + InputRequired: 'TASK_STATE_INPUT_REQUIRED', + Rejected: 'TASK_STATE_REJECTED', + AuthRequired: 'TASK_STATE_AUTH_REQUIRED', +} as const; + +export type A2aTaskState = (typeof A2aTaskStates)[keyof typeof A2aTaskStates]; + +export interface IA2aTaskStatus { + state: A2aTaskState; + message?: IA2aMessage; + /** ISO 8601 UTC with a trailing Z. */ + timestamp: string; +} + +/** + * Ranch-specific task metadata, carried under `metadata.ranch` so it never + * collides with another implementation's keys. `chain` is what makes the loop + * and depth rules possible: every hop appends its own agent id. + */ +export interface IRanchTaskMetadata { + chain: string[]; + reason?: string; + rejection?: 'loop' | 'depth'; + failure?: 'not_running' | 'timeout'; + durationMs?: number; +} + +export interface IA2aTask { + id: string; + contextId: string; + status: IA2aTaskStatus; + artifacts: IA2aArtifact[]; + history: IA2aMessage[]; + metadata?: { ranch?: IRanchTaskMetadata } & Record; +} + +// ── Method params ─────────────────────────────────────────────── + +export interface IA2aSendMessageConfiguration { + acceptedOutputModes?: string[]; + historyLength?: number; + /** 1.0 replaced 0.3's `blocking` with this inverted flag; default false. */ + returnImmediately?: boolean; +} + +export interface IA2aSendMessageParams { + message: IA2aMessage; + configuration?: IA2aSendMessageConfiguration; + metadata?: Record; + tenant?: string; +} + +export interface IA2aGetTaskParams { + id: string; + historyLength?: number; +} + +/** A blocking SendMessage answers with a task; the message form is unused here. */ +export type A2aSendMessageResult = + | { task: IA2aTask } + | { message: IA2aMessage }; + +export const A2aMethods = { + SendMessage: 'SendMessage', + SendStreamingMessage: 'SendStreamingMessage', + GetTask: 'GetTask', + ListTasks: 'ListTasks', + CancelTask: 'CancelTask', + SubscribeToTask: 'SubscribeToTask', + CreateTaskPushNotificationConfig: 'CreateTaskPushNotificationConfig', + GetTaskPushNotificationConfig: 'GetTaskPushNotificationConfig', + ListTaskPushNotificationConfigs: 'ListTaskPushNotificationConfigs', + DeleteTaskPushNotificationConfig: 'DeleteTaskPushNotificationConfig', + GetExtendedAgentCard: 'GetExtendedAgentCard', +} as const; + +export type A2aMethod = (typeof A2aMethods)[keyof typeof A2aMethods]; + +/** Every method name the spec defines — used to tell "not supported" apart + * from "no such method". */ +export const A2A_KNOWN_METHODS: readonly string[] = Object.values(A2aMethods); + +// ── JSON-RPC envelope ─────────────────────────────────────────── + +export type JsonRpcId = string | number | null; + +export interface IJsonRpcRequest { + jsonrpc: '2.0'; + id?: JsonRpcId; + method: string; + params?: unknown; +} + +export interface IJsonRpcError { + code: number; + message: string; + data?: unknown; +} + +export interface IJsonRpcResponse { + jsonrpc: '2.0'; + id: JsonRpcId; + result?: unknown; + error?: IJsonRpcError; +} + +/** Standard JSON-RPC codes plus the A2A-specific ones (spec §5.4). */ +export const A2aErrorCodes = { + ParseError: -32700, + InvalidRequest: -32600, + MethodNotFound: -32601, + InvalidParams: -32602, + Internal: -32603, + TaskNotFound: -32001, + TaskNotCancelable: -32002, + PushNotificationNotSupported: -32003, + UnsupportedOperation: -32004, + ContentTypeNotSupported: -32005, + InvalidAgentResponse: -32006, + ExtendedAgentCardNotConfigured: -32007, + ExtensionSupportRequired: -32008, + VersionNotSupported: -32009, +} as const; + +export type A2aErrorCode = (typeof A2aErrorCodes)[keyof typeof A2aErrorCodes]; + +/** Thrown inside the A2A server; the controller turns it into a JSON-RPC error. */ +export class A2aRpcError extends Error { + constructor( + public readonly code: number, + message: string, + public readonly data?: unknown, + ) { + super(message); + this.name = 'A2aRpcError'; + } +} + +// ── Helpers ───────────────────────────────────────────────────── + +export function isTextPart(part: A2aPart): part is IA2aTextPart { + return typeof (part as IA2aTextPart).text === 'string'; +} + +/** Every text part joined by a blank line; non-text parts are ignored. */ +export function textOfParts(parts: A2aPart[] | undefined): string { + if (!Array.isArray(parts)) return ''; + return parts + .filter(isTextPart) + .map((p) => p.text) + .join('\n\n'); +} + +/** True when any part is something this server cannot read. */ +export function hasNonTextPart(parts: A2aPart[] | undefined): boolean { + if (!Array.isArray(parts)) return false; + return parts.some((p) => !isTextPart(p)); +} + +/** ISO 8601 UTC with the trailing Z the spec asks for. */ +export function a2aTimestamp(at: Date = new Date()): string { + return at.toISOString(); +} diff --git a/api/src/slices/agent/peer/domain/index.ts b/api/src/slices/agent/peer/domain/index.ts new file mode 100644 index 00000000..d027660a --- /dev/null +++ b/api/src/slices/agent/peer/domain/index.ts @@ -0,0 +1,2 @@ +export * from './a2a.types'; +export * from './peer.types'; diff --git a/api/src/slices/agent/peer/domain/peer.types.ts b/api/src/slices/agent/peer/domain/peer.types.ts new file mode 100644 index 00000000..cf9dd625 --- /dev/null +++ b/api/src/slices/agent/peer/domain/peer.types.ts @@ -0,0 +1,199 @@ +// Peer-connection domain contract (CLEAN-74). Pure types + constants: no +// Prisma, no DTOs, no Nest — the mappers convert records, the controllers +// convert DTOs. +// +// A peer connection is directed: "A holds B's card" says nothing about B. It +// carries a snapshot of the peer's card (so a peer editing its description +// mid-turn cannot change how A behaves until someone presses Refresh) and a +// credential scoped to exactly this (caller, peer) pair. + +import type { IA2aAgentCard } from './a2a.types'; + +/** Token prefix. Makes a peer credential impossible to confuse with a share + * link (`sl_`), an API key (`rk_`), a session secret (`rs_`) or a JWT. */ +export const PEER_TOKEN_PREFIX = 'ap_'; + +/** Entropy of the secret: 32 random bytes ⇒ 43 base64url chars (~256 bits). */ +export const PEER_TOKEN_BYTES = 32; + +/** Shape a presented credential must have before it is worth a lookup. */ +export const PEER_TOKEN_RE = /^ap_[A-Za-z0-9_-]{43}$/; + +/** + * Bridle client id a peer conversation registers under. One conversation per + * (caller, context) pair, and the prefix keeps it apart from `admin`, `share-`, + * `anon-`, `http-` and `sync-` ids in the chat history. + */ +export const PEER_CLIENT_PREFIX = 'peer:'; + +export function peerClientId(callerAgentId: string, contextId: string): string { + return `${PEER_CLIENT_PREFIX}${callerAgentId}:${contextId}`; +} + +/** Thinking-step id prefix, so a delegation's two pushes replace each other. */ +export const DELEGATION_STEP_PREFIX = 'delegation:'; + +/** Default hops allowed beyond the agent the person is talking to (env + * `A2A_MAX_CHAIN`). 3 is the smallest number that still allows A→B→C. */ +export const DEFAULT_MAX_CHAIN = 3; + +/** Default delegation wait (env `A2A_SYNC_TIMEOUT_MS`) — the same ceiling the + * synchronous chat route uses. */ +export const DEFAULT_A2A_TIMEOUT_MS = 120_000; + +/** How much of a peer's reply travels into the visible step and the audit row. */ +export const DELEGATION_EXCERPT_CHARS = 300; + +/** Machine-readable codes carried in the error body so the console can tell + * the failure modes apart without parsing messages. */ +export const PeerErrorCodes = { + Self: 'PEER_SELF', + Exists: 'PEER_EXISTS', + NotFound: 'PEER_NOT_FOUND', + CardUnreachable: 'PEER_CARD_UNREACHABLE', + Unauthorized: 'A2A_UNAUTHORIZED', +} as const; + +export type PeerErrorCode = (typeof PeerErrorCodes)[keyof typeof PeerErrorCodes]; + +/** One persisted peer connection. Dates are ISO strings — the domain never + * handles `Date` instances so the state can be serialised as-is. */ +export interface IAgentPeerData { + id: string; + /** The caller: the agent that holds the card. */ + agentId: string; + /** The peer: the agent whose card is held. */ + peerAgentId: string; + /** Pair credential. Never leaves the API — no DTO carries it. */ + token: string; + cardSnapshot: IA2aAgentCard; + cardUrl: string; + cardReadAt: string; + createdAt: string; + updatedAt: string; +} + +/** A peer connection joined with what the peer agent looks like right now. */ +export interface IAgentPeerView { + id: string; + agentId: string; + peerAgentId: string; + peerName: string; + /** AgentStatusTypes as a plain string; 'running' ⇒ delegation can succeed. */ + peerStatus: string; + /** False when the peer agent is gone (only reachable for a remote peer). */ + peerExists: boolean; + card: IA2aAgentCard; + cardUrl: string; + cardReadAt: string; + createdAt: string; +} + +/** An agent offered in the "add peer" picker. */ +export interface IAgentPeerCandidate { + id: string; + name: string; + status: string; + connected: boolean; +} + +// ── Delegations ───────────────────────────────────────────────── + +export const DelegationStatuses = { + Waiting: 'waiting', + Answered: 'answered', + Failed: 'failed', + Rejected: 'rejected', +} as const; + +export type DelegationStatus = + (typeof DelegationStatuses)[keyof typeof DelegationStatuses]; + +/** Why a delegation did not produce an answer. Rendered to the person in + * product wording, never as raw error text. */ +export const DelegationErrorCodes = { + NotRunning: 'PEER_NOT_RUNNING', + Timeout: 'PEER_TIMEOUT', + RejectedLoop: 'PEER_REJECTED_LOOP', + RejectedDepth: 'PEER_REJECTED_DEPTH', + Unauthorized: 'PEER_UNAUTHORIZED', + Unreachable: 'PEER_UNREACHABLE', + Error: 'PEER_ERROR', +} as const; + +export type DelegationErrorCode = + (typeof DelegationErrorCodes)[keyof typeof DelegationErrorCodes]; + +/** A card skill quoted in the visible step as the reason a peer was chosen. */ +export interface IMatchedSkill { + id: string; + name: string; +} + +/** One task handed to a peer during one chat turn — the durable audit row. */ +export interface IAgentDelegationData { + id: string; + agentId: string; + /** Null once the connection it was made through has been removed. */ + peerId: string | null; + peerAgentId: string; + peerName: string; + contextId: string; + /** The caller's thinking turn, when one was known at the time. */ + turnId: string | null; + clientId: string | null; + task: string; + reason: string; + matchedSkills: IMatchedSkill[]; + status: DelegationStatus; + errorCode: DelegationErrorCode | null; + excerpt: string | null; + startedAt: string; + finishedAt: string | null; + durationMs: number | null; +} + +export interface ICreateDelegationData { + agentId: string; + peerId: string; + peerAgentId: string; + peerName: string; + contextId: string; + turnId?: string | null; + clientId?: string | null; + task: string; + reason: string; + matchedSkills: IMatchedSkill[]; +} + +export interface IFinishDelegationData { + status: DelegationStatus; + errorCode?: DelegationErrorCode | null; + excerpt?: string | null; + finishedAt: Date; + durationMs: number; +} + +// ── Errors raised by the outbound client ──────────────────────── + +/** The peer's card could not be read at connect or refresh time. */ +export class PeerCardUnreachableError extends Error { + constructor( + message: string, + public readonly status?: number, + ) { + super(message); + this.name = 'PeerCardUnreachableError'; + } +} + +/** A delegation could not be completed. `code` decides the product wording. */ +export class DelegationError extends Error { + constructor( + public readonly code: DelegationErrorCode, + message: string, + ) { + super(message); + this.name = 'DelegationError'; + } +} diff --git a/api/src/slices/agent/peer/peer.module.ts b/api/src/slices/agent/peer/peer.module.ts new file mode 100644 index 00000000..a3452486 --- /dev/null +++ b/api/src/slices/agent/peer/peer.module.ts @@ -0,0 +1,19 @@ +import { Module, forwardRef } from '@nestjs/common'; +import { AuthModule } from '#/user/auth/auth.module'; +import { AgentModule } from '#/agent/agent/agent.module'; + +/** + * Agents talking to agents (CLEAN-74): agent cards, peer connections, the A2A + * server surface, the `ask_agent` tool and the delegation audit trail. + * + * AgentModule is a forwardRef for the same reason ShareLinkModule needs one: + * AgentModule → BridleModule → … → this module → AgentModule. AuthModule stays + * plain — it brings the guards for the owner-side controller and is in no cycle. + */ +@Module({ + imports: [AuthModule, forwardRef(() => AgentModule)], + controllers: [], + providers: [], + exports: [], +}) +export class PeerModule {} diff --git a/api/src/slices/agent/peer/peer.prisma b/api/src/slices/agent/peer/peer.prisma new file mode 100644 index 00000000..5188863b --- /dev/null +++ b/api/src/slices/agent/peer/peer.prisma @@ -0,0 +1,41 @@ +import { Agent } from "../agent/agent" +import { AgentDelegation } from "./delegation" + +// One directed peer connection (CLEAN-74): "agentId holds peerAgentId's card +// and may delegate to it". The reverse direction is a separate row — connecting +// B to A says nothing about A's own peers. +// +// The token is stored in plaintext, like the share link and unlike an API key, +// because here the API is both issuer AND presenter: `ask_agent` runs inside +// the API and must read the credential back to present it to the peer. A +// hash-only store would force a second, recoverable secret alongside it. +// Never returned by a DTO, never logged. See research.md R4. +// +// cardSnapshot is the peer's A2A card as read at connect or last Refresh. The +// delegating agent reasons from the snapshot, never from a live read, so a peer +// editing its description mid-turn cannot change behaviour until someone +// presses Refresh. +model AgentPeer { + id String @id @default(uuid()) + // The caller. + agentId String + agent Agent @relation("AgentPeerCaller", fields: [agentId], references: [id], onDelete: Cascade) + // The peer. + peerAgentId String + peerAgent Agent @relation("AgentPeerTarget", fields: [peerAgentId], references: [id], onDelete: Cascade) + // `ap_` + 32 random bytes base64url (~256 bits). Scoped to this pair only; + // deleting the row revokes it. + token String @unique + cardSnapshot Json + cardUrl String + cardReadAt DateTime + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + delegations AgentDelegation[] + + // One connection per direction. + @@unique([agentId, peerAgentId]) + @@index([agentId]) + @@index([peerAgentId]) +} diff --git a/api/src/slices/mcp/interfaces/conditional-listing.interface.ts b/api/src/slices/mcp/interfaces/conditional-listing.interface.ts new file mode 100644 index 00000000..20512b10 --- /dev/null +++ b/api/src/slices/mcp/interfaces/conditional-listing.interface.ts @@ -0,0 +1,33 @@ +// @scope:api +// @slice:mcp +// @layer:application +// @type:interface + +import type { Request } from 'express'; + +/** + * Tools may implement this to disappear entirely for callers they do not + * apply to. The MCP tools/list handler asks every tool that implements it and + * omits the ones that answer false; tools/call refuses the same tools. + * + * Existing tools all answer "not for you" at call time, with an error string + * the model reads only after wasting a turn on it. That is the right shape for + * a tool that merely refuses some arguments. It is the wrong shape for a tool + * whose very presence is a claim about the agent — `ask_agent` offered to an + * agent with no peers advertises colleagues it does not have (CLEAN-74, + * FR-017), and no amount of description text un-advertises them. + * + * A tool that throws here stays listed: a broken check must not silently + * remove a working tool. + */ +export interface IConditionallyListedTool { + isListedForRequest(httpRequest: Request): Promise; +} + +export function isConditionallyListed( + value: unknown, +): value is IConditionallyListedTool { + if (typeof value !== 'object' || value === null) return false; + const obj = value as Record; + return typeof obj.isListedForRequest === 'function'; +} diff --git a/api/src/slices/mcp/interfaces/index.ts b/api/src/slices/mcp/interfaces/index.ts index 25d786c8..4050a880 100644 --- a/api/src/slices/mcp/interfaces/index.ts +++ b/api/src/slices/mcp/interfaces/index.ts @@ -2,3 +2,4 @@ export * from './mcp-options.interface'; export * from './mcp-tool.interface'; export * from './http-adapter.interface'; export * from './dynamic-description.interface'; +export * from './conditional-listing.interface'; diff --git a/api/src/slices/mcp/services/handlers/mcp-tools.handler.spec.ts b/api/src/slices/mcp/services/handlers/mcp-tools.handler.spec.ts new file mode 100644 index 00000000..1738e39b --- /dev/null +++ b/api/src/slices/mcp/services/handlers/mcp-tools.handler.spec.ts @@ -0,0 +1,238 @@ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { + CallToolRequestSchema, + ListToolsRequestSchema, +} from '@modelcontextprotocol/sdk/types.js'; +import { ModuleRef } from '@nestjs/core'; +import { Request } from 'express'; +import { z } from 'zod'; +import { McpToolsHandler } from './mcp-tools.handler'; +import { McpRegistryService } from '../mcp-registry.service'; + +/** + * Per-caller tool listing (CLEAN-74). The rules that matter: + * - a tool that says it is not for this caller is absent from tools/list AND + * refused by tools/call, because a pod caches the list at connect and can + * hold a name that stopped applying to it; + * - a tool that says nothing behaves exactly as before; + * - a tool whose check throws stays listed — a broken check must never be + * able to silently remove a working tool. + */ + +type Handler = (request: unknown) => Promise; + +interface FakeTool { + name: string; + instance: Record; + resolveThrows?: boolean; +} + +function makeHarness(tools: FakeTool[]) { + const discovered = tools.map((t) => ({ + type: 'tool' as const, + metadata: { + name: t.name, + description: `static description of ${t.name}`, + parameters: z.object({}), + }, + providerClass: t.name as unknown as symbol, + methodName: 'run', + })); + + const registry = { + getTools: () => discovered, + findTool: (name: string) => discovered.find((d) => d.metadata.name === name), + } as unknown as McpRegistryService; + + const moduleRef = { + registerRequestByContextId: jest.fn(), + resolve: jest.fn(async (token: unknown) => { + const tool = tools.find((t) => t.name === token); + if (!tool) throw new Error(`no provider for ${String(token)}`); + if (tool.resolveThrows) throw new Error('resolve blew up'); + return tool.instance; + }), + } as unknown as ModuleRef; + + const handlers = new Map(); + const mcpServer = { + server: { + transport: { sessionId: undefined }, + setRequestHandler: (schema: unknown, handler: Handler) => { + handlers.set(schema, handler); + }, + }, + } as unknown as McpServer; + + const httpRequest = { user: { sub: 'agent:a1' } } as unknown as Request; + + const handler = new McpToolsHandler(moduleRef, registry); + handler.registerHandlers(mcpServer, httpRequest); + + const list = () => handlers.get(ListToolsRequestSchema)!({}); + const call = (name: string) => + handlers.get(CallToolRequestSchema)!({ + params: { name, arguments: {} }, + }); + + return { list, call, httpRequest }; +} + +const ok = (text: string) => ({ content: [{ type: 'text', text }] }); + +describe('McpToolsHandler — per-caller listing', () => { + it('lists a tool that declares no opinion, with its static description', async () => { + const { list } = makeHarness([ + { name: 'plain', instance: { run: jest.fn(async () => ok('ran')) } }, + ]); + + const result = await list(); + + expect(result.tools).toHaveLength(1); + expect(result.tools[0]).toMatchObject({ + name: 'plain', + description: 'static description of plain', + }); + }); + + it('omits a tool that is not listed for this caller', async () => { + const { list } = makeHarness([ + { name: 'plain', instance: { run: jest.fn(async () => ok('ran')) } }, + { + name: 'ask_agent', + instance: { + run: jest.fn(async () => ok('delegated')), + isListedForRequest: jest.fn(async () => false), + }, + }, + ]); + + const result = await list(); + + expect(result.tools.map((t: { name: string }) => t.name)).toEqual(['plain']); + }); + + it('lists a tool that answers true, and passes the request to the check', async () => { + const isListedForRequest = jest.fn(async () => true); + const { list, httpRequest } = makeHarness([ + { + name: 'ask_agent', + instance: { run: jest.fn(), isListedForRequest }, + }, + ]); + + const result = await list(); + + expect(result.tools).toHaveLength(1); + expect(isListedForRequest).toHaveBeenCalledWith(httpRequest); + }); + + it('keeps a tool listed when its own check throws', async () => { + const { list } = makeHarness([ + { + name: 'ask_agent', + instance: { + run: jest.fn(), + isListedForRequest: jest.fn(async () => { + throw new Error('gateway down'); + }), + }, + }, + ]); + + const result = await list(); + + expect(result.tools.map((t: { name: string }) => t.name)).toEqual([ + 'ask_agent', + ]); + }); + + it('keeps a tool listed when the provider cannot be resolved', async () => { + const { list } = makeHarness([ + { name: 'plain', instance: {}, resolveThrows: true }, + ]); + + const result = await list(); + + expect(result.tools).toHaveLength(1); + expect(result.tools[0].description).toBe('static description of plain'); + }); + + it('still applies a dynamic description to a listed tool', async () => { + const { list } = makeHarness([ + { + name: 'ask_agent', + instance: { + run: jest.fn(), + isListedForRequest: jest.fn(async () => true), + describeForRequest: jest.fn(async () => 'your peers: «B»'), + }, + }, + ]); + + const result = await list(); + + expect(result.tools[0].description).toBe('your peers: «B»'); + }); + + it('never asks for a description of a tool it has already excluded', async () => { + const describeForRequest = jest.fn(async () => 'never used'); + const { list } = makeHarness([ + { + name: 'ask_agent', + instance: { + run: jest.fn(), + isListedForRequest: jest.fn(async () => false), + describeForRequest, + }, + }, + ]); + + await list(); + + expect(describeForRequest).not.toHaveBeenCalled(); + }); +}); + +describe('McpToolsHandler — per-caller calling', () => { + it('refuses a call to a tool that is not listed for this caller', async () => { + const run = jest.fn(async () => ok('delegated')); + const { call } = makeHarness([ + { + name: 'ask_agent', + instance: { run, isListedForRequest: jest.fn(async () => false) }, + }, + ]); + + const result = await call('ask_agent'); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('not available to this caller'); + expect(run).not.toHaveBeenCalled(); + }); + + it('runs a tool that is listed for this caller', async () => { + const run = jest.fn(async () => ok('delegated')); + const { call } = makeHarness([ + { + name: 'ask_agent', + instance: { run, isListedForRequest: jest.fn(async () => true) }, + }, + ]); + + const result = await call('ask_agent'); + + expect(result.content[0].text).toBe('delegated'); + expect(run).toHaveBeenCalledTimes(1); + }); + + it('runs a tool that declares no opinion', async () => { + const run = jest.fn(async () => ok('ran')); + const { call } = makeHarness([{ name: 'plain', instance: { run } }]); + + const result = await call('plain'); + + expect(result.content[0].text).toBe('ran'); + expect(run).toHaveBeenCalledTimes(1); + }); +}); diff --git a/api/src/slices/mcp/services/handlers/mcp-tools.handler.ts b/api/src/slices/mcp/services/handlers/mcp-tools.handler.ts index fb792500..a7f039ed 100644 --- a/api/src/slices/mcp/services/handlers/mcp-tools.handler.ts +++ b/api/src/slices/mcp/services/handlers/mcp-tools.handler.ts @@ -12,6 +12,7 @@ import { zodToJsonSchema } from 'zod-to-json-schema'; import { McpRegistryService } from '../mcp-registry.service'; import { McpHandlerBase } from './mcp-handler.base'; import { isDynamicallyDescribed } from '../../interfaces/dynamic-description.interface'; +import { isConditionallyListed } from '../../interfaces/conditional-listing.interface'; @Injectable({ scope: Scope.REQUEST }) export class McpToolsHandler extends McpHandlerBase { @@ -32,19 +33,26 @@ export class McpToolsHandler extends McpHandlerBase { const contextId = ContextIdFactory.getByRequest(httpRequest); this.moduleRef.registerRequestByContextId(httpRequest, contextId); - const tools = await Promise.all( + const listed = await Promise.all( this.registry.getTools().map(async (tool) => { let description = tool.metadata.description; // Tools may opt into per-caller descriptions by implementing - // IDynamicallyDescribedTool. Failure to resolve or describe falls - // back to the static decorator description so a broken tool can't - // hide the rest of the list. + // IDynamicallyDescribedTool, and out of the list entirely by + // implementing IConditionallyListedTool. Failure to resolve, + // describe or decide falls back to listing the tool with its + // static decorator description, so a broken tool can't hide + // itself or the rest of the list. try { const instance = await this.moduleRef.resolve( tool.providerClass, contextId, { strict: false }, ); + if (isConditionallyListed(instance)) { + const listedForCaller = + await instance.isListedForRequest(httpRequest); + if (!listedForCaller) return null; + } if (isDynamicallyDescribed(instance)) { const dyn = await instance.describeForRequest(httpRequest); if (typeof dyn === 'string' && dyn.length > 0) { @@ -53,7 +61,7 @@ export class McpToolsHandler extends McpHandlerBase { } } catch (e) { this.logger.debug( - `describeForRequest failed for ${tool.metadata.name}: ${ + `tools/list hooks failed for ${tool.metadata.name}: ${ e instanceof Error ? e.message : String(e) }`, ); @@ -69,7 +77,7 @@ export class McpToolsHandler extends McpHandlerBase { ); return { - tools, + tools: listed.filter((tool) => tool !== null), }; }); @@ -104,6 +112,26 @@ export class McpToolsHandler extends McpHandlerBase { ); } + // A pod lists tools once at connect and caches them, so a caller + // can hold a name that stopped applying to it since. Refuse here + // rather than run it: for ask_agent that is the difference between + // "you have no peers" and delegating through a removed connection. + if (isConditionallyListed(toolInstance)) { + const listedForCaller = + await toolInstance.isListedForRequest(httpRequest); + if (!listedForCaller) { + return { + content: [ + { + type: 'text', + text: `Tool "${request.params.name}" is not available to this caller.`, + }, + ], + isError: true, + }; + } + } + const result = await toolInstance[toolInfo.methodName].call( toolInstance, request.params.arguments, diff --git a/api/src/slices/setting/data/infraConfig.gateway.spec.ts b/api/src/slices/setting/data/infraConfig.gateway.spec.ts new file mode 100644 index 00000000..59e8c448 --- /dev/null +++ b/api/src/slices/setting/data/infraConfig.gateway.spec.ts @@ -0,0 +1,119 @@ +import { ConfigService } from '@nestjs/config'; +import { InfraConfigGateway } from './infraConfig.gateway'; +import { ISettingGateway } from '../domain/setting.gateway'; + +/** + * getApiPublicUrl (CLEAN-74) is the one infra key with a four-step chain: + * settings → env PUBLIC_API_URL → integrations.ranch_api_url → localhost. + * The extra step matters because a configured cluster already tells agents + * where the API lives, and an agent card URL built from a wrong origin is + * unreachable in a way nothing else in the product would reveal. + */ +type SettingRow = { value: unknown } | null; + +function makeHarness(options: { + settings?: Record; + env?: Record; + findByKeyThrows?: boolean; +}) { + const settings = options.settings ?? {}; + const env = options.env ?? {}; + + const findByKey = jest.fn( + async (group: string, name: string): Promise => { + if (options.findByKeyThrows) throw new Error('db down'); + return settings[`${group}/${name}`] ?? null; + }, + ); + + const settingGateway = { + findByKey, + findByGroup: jest.fn(async () => []), + upsert: jest.fn(async () => undefined), + } as unknown as ISettingGateway; + + const configService = { + get: (key: string) => env[key], + } as unknown as ConfigService; + + const gateway = new InfraConfigGateway(settingGateway, configService); + return { gateway, findByKey }; +} + +describe('InfraConfigGateway.getApiPublicUrl', () => { + it('prefers the infrastructure setting over everything else', async () => { + const { gateway } = makeHarness({ + settings: { + 'infrastructure/api_public_url': { value: 'https://api.example.test' }, + 'integrations/ranch_api_url': { value: 'http://never.used' }, + }, + env: { PUBLIC_API_URL: 'http://never.used.either' }, + }); + + await expect(gateway.getApiPublicUrl()).resolves.toBe( + 'https://api.example.test', + ); + }); + + it('falls back to PUBLIC_API_URL when no setting is stored', async () => { + const { gateway } = makeHarness({ + env: { PUBLIC_API_URL: 'https://api.from-env.test' }, + }); + + await expect(gateway.getApiPublicUrl()).resolves.toBe( + 'https://api.from-env.test', + ); + }); + + it('falls back to the ranch_api_url integration agents already dial', async () => { + const { gateway, findByKey } = makeHarness({ + settings: { + 'integrations/ranch_api_url': { value: 'http://ranch-api.platform:3333' }, + }, + }); + + await expect(gateway.getApiPublicUrl()).resolves.toBe( + 'http://ranch-api.platform:3333', + ); + expect(findByKey).toHaveBeenCalledWith('integrations', 'ranch_api_url'); + }); + + it('falls back to localhost when nothing is configured anywhere', async () => { + const { gateway } = makeHarness({}); + + await expect(gateway.getApiPublicUrl()).resolves.toBe( + 'http://localhost:3333', + ); + }); + + it('strips trailing slashes so card URLs never double up', async () => { + const { gateway } = makeHarness({ + env: { PUBLIC_API_URL: 'https://api.example.test///' }, + }); + + await expect(gateway.getApiPublicUrl()).resolves.toBe( + 'https://api.example.test', + ); + }); + + it('survives a settings database outage and uses the default', async () => { + const { gateway } = makeHarness({ findByKeyThrows: true }); + + await expect(gateway.getApiPublicUrl()).resolves.toBe( + 'http://localhost:3333', + ); + }); + + it('ignores an empty stored value rather than returning an empty origin', async () => { + const { gateway } = makeHarness({ + settings: { + 'infrastructure/api_public_url': { value: '' }, + 'integrations/ranch_api_url': { value: 'https://api.integration.test' }, + }, + }); + + await expect(gateway.getApiPublicUrl()).resolves.toBe( + 'https://api.integration.test', + ); + }); +}); diff --git a/api/src/slices/setting/data/infraConfig.gateway.ts b/api/src/slices/setting/data/infraConfig.gateway.ts index f0a4a75b..e87e3310 100644 --- a/api/src/slices/setting/data/infraConfig.gateway.ts +++ b/api/src/slices/setting/data/infraConfig.gateway.ts @@ -8,6 +8,11 @@ import { ISettingGateway } from '../domain/setting.gateway'; const GROUP = 'infrastructure'; const CACHE_TTL_MS = 30_000; +// Last resort when neither a setting, PUBLIC_API_URL nor the ranch_api_url +// integration says where this API lives — matches the dev server port. +const API_PUBLIC_URL_FALLBACK = 'http://localhost:3333'; + +const TRAILING_SLASHES = /[/]+$/; interface KeySpec { setting: string; @@ -60,6 +65,15 @@ const KEYS = { default: 'ranch-reins-sources', parse: (s: string) => s, } as KeySpec, + // Public origin of this API. Empty default on purpose: an unset value + // falls through to the integrations.ranch_api_url the agents already + // dial, so a cluster that configured one gets working card URLs for free. + api_public_url: { + setting: 'api_public_url', + env: 'PUBLIC_API_URL', + default: '', + parse: (s: string) => s, + } as KeySpec, }; type KeyName = keyof typeof KEYS; @@ -114,6 +128,31 @@ export class InfraConfigGateway getReinsBucket(): Promise { return this.resolve('reins_bucket'); } + /** + * settings → env PUBLIC_API_URL → integrations.ranch_api_url → localhost, + * always without a trailing slash. The integration step exists because that + * value is already the API URL as agent pods see it, which is a far better + * guess on a configured cluster than any built-in default. + */ + async getApiPublicUrl(): Promise { + const configured = await this.resolve('api_public_url'); + if (configured) return configured.replace(TRAILING_SLASHES, ''); + + try { + const fallback = await this.settings.findByKey( + 'integrations', + 'ranch_api_url', + ); + if (typeof fallback?.value === 'string' && fallback.value.length > 0) { + return fallback.value.replace(TRAILING_SLASHES, ''); + } + } catch (err) { + this.logger.warn( + `Settings DB unavailable for "ranch_api_url", falling back to the default API URL: ${(err as Error).message}`, + ); + } + return API_PUBLIC_URL_FALLBACK; + } private async resolve( key: K, diff --git a/api/src/slices/setting/domain/infraConfig.gateway.ts b/api/src/slices/setting/domain/infraConfig.gateway.ts index c2ae2567..347a6331 100644 --- a/api/src/slices/setting/domain/infraConfig.gateway.ts +++ b/api/src/slices/setting/domain/infraConfig.gateway.ts @@ -21,6 +21,15 @@ export abstract class IInfraConfigGateway { abstract getLightragApiKey(): Promise; abstract getReinsBucket(): Promise; + /** + * Public origin other agents and browsers reach this API on. Feeds the A2A + * agent-card URLs (CLEAN-74) and, in spirit, the OAuth callback of CLEAN-75. + * Resolution adds one step to the usual chain: settings → env PUBLIC_API_URL + * → the `integrations.ranch_api_url` value agents already dial → localhost. + * Returned without a trailing slash. + */ + abstract getApiPublicUrl(): Promise; + /** * Drop the in-memory cache. Useful when settings are upserted via the * settings controller and we want subsequent reads to see fresh values. diff --git a/k8s/deploy/30-api.yaml b/k8s/deploy/30-api.yaml index 72d0d213..c7a99e79 100644 --- a/k8s/deploy/30-api.yaml +++ b/k8s/deploy/30-api.yaml @@ -66,6 +66,10 @@ spec: key: JWT_SECRET - name: JWT_EXPIRES_IN value: "15m" + # Public origin browsers and peer agents reach ranch-api on: OAuth MCP + # callback redirect_uri (CLEAN-75) and agent card URLs (CLEAN-74). + - name: PUBLIC_API_URL + value: "https://api.ranch.cleanslice.org" - name: SESSION_IDLE_DAYS value: "7" - name: SESSION_ABSOLUTE_DAYS diff --git a/specs/013-a2a-agent-peers/plan.md b/specs/013-a2a-agent-peers/plan.md index 546f2d09..b391a78a 100644 --- a/specs/013-a2a-agent-peers/plan.md +++ b/specs/013-a2a-agent-peers/plan.md @@ -14,7 +14,7 @@ Give every Ranch agent an **A2A 1.0 agent card** (derived from name, description **Primary Dependencies**: NestJS + Prisma (`prisma-import` per-slice fragments), class-validator/Swagger DTOs, zod (tool schemas), the bridle hub (`IBridleGateway`), the MCP registry (`@Tool`, `IDynamicallyDescribedTool`). **No new package**: A2A 1.0 types are hand-written from the proto (R1); HTTP client is global `fetch`. Console: Pinia store → service → gateway → `@hey-api/client-axios` SDK (`build:api`), shadcn-vue kit (no Dialog/Command — hand-built on reka-ui like `ConfirmDialog`), `socket.io-client` for the thinking stream -**Storage**: PostgreSQL via Prisma — two new tables `AgentPeer`, `AgentDelegation` (one additive migration `20260910120000_agent_peer_delegation`), two back-relations on `Agent`; A2A tasks in memory (10 min TTL); the card is derived on every read +**Storage**: PostgreSQL via Prisma — two new tables `AgentPeer`, `AgentDelegation` (one additive migration `20260914120000_agent_peer_delegation`), two back-relations on `Agent`; A2A tasks in memory (10 min TTL); the card is derived on every read **Testing**: API — Jest, colocated `*.spec.ts`, hand-rolled stubs (`cd api && bun run test -- peer agentCard a2a askAgent bridleSync bridle.gateway mcp-tools`); admin has no runner — verification = `bun run build:api && bun run typecheck`, then [quickstart.md](./quickstart.md) §3–6 by hand (local, then once in-cluster) @@ -55,7 +55,7 @@ specs/013-a2a-agent-peers/ ```text api/ ├── .env.example # + API_PUBLIC_URL, A2A_SYNC_TIMEOUT_MS, A2A_MAX_CHAIN -├── prisma/migrations/20260910120000_agent_peer_delegation/migration.sql # additive: AgentPeer, AgentDelegation +├── prisma/migrations/20260914120000_agent_peer_delegation/migration.sql # additive: AgentPeer, AgentDelegation └── src/ ├── app.module.ts # + PeerModule └── slices/ diff --git a/specs/013-a2a-agent-peers/tasks.md b/specs/013-a2a-agent-peers/tasks.md index 034ba8f1..58db8431 100644 --- a/specs/013-a2a-agent-peers/tasks.md +++ b/specs/013-a2a-agent-peers/tasks.md @@ -41,7 +41,7 @@ **⚠️ CRITICAL**: No user story work can begin until this phase is complete - [ ] T004 Add Prisma fragments (depends on T002): `api/src/slices/agent/peer/peer.prisma` — `import { Agent } from "../agent/agent"` and `model AgentPeer { id String @id @default(uuid()); agentId String; agent Agent @relation("AgentPeerCaller", fields: [agentId], references: [id], onDelete: Cascade); peerAgentId String; peerAgent Agent @relation("AgentPeerTarget", fields: [peerAgentId], references: [id], onDelete: Cascade); token String @unique; cardSnapshot Json; cardUrl String; cardReadAt DateTime; createdAt DateTime @default(now()); updatedAt DateTime @updatedAt; delegations AgentDelegation[]; @@unique([agentId, peerAgentId]); @@index([agentId]); @@index([peerAgentId]) }`; `api/src/slices/agent/peer/delegation.prisma` — `import { Agent } from "../agent/agent"`, `import { AgentPeer } from "./peer"` and `model AgentDelegation { id String @id @default(uuid()); agentId String; agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade); peerId String?; peer AgentPeer? @relation(fields: [peerId], references: [id], onDelete: SetNull); peerAgentId String; peerName String; contextId String; turnId String?; clientId String?; task String; reason String; matchedSkills Json @default("[]"); status String @default("waiting"); errorCode String?; excerpt String?; startedAt DateTime @default(now()); finishedAt DateTime?; durationMs Int?; @@index([agentId, startedAt]) }`; in `api/src/slices/agent/agent/agent.prisma` add `import { AgentPeer } from "../peer/peer"`, `import { AgentDelegation } from "../peer/delegation"` and the back-relations `peers AgentPeer[] @relation("AgentPeerCaller")`, `peerOf AgentPeer[] @relation("AgentPeerTarget")`, `delegations AgentDelegation[]` -- [ ] T005 Run `cd api && bun run migrate`, rename the generated folder to `api/prisma/migrations/20260910120000_agent_peer_delegation/`, prepend `-- Additive: new tables AgentPeer (directed peer connection + pair credential + card snapshot) and AgentDelegation (audit row per delegated task). Safe on an existing database.`, confirm `bun run generate` succeeds (depends on T004) +- [ ] T005 Run `cd api && bun run migrate`, rename the generated folder to `api/prisma/migrations/20260914120000_agent_peer_delegation/`, prepend `-- Additive: new tables AgentPeer (directed peer connection + pair credential + card snapshot) and AgentDelegation (audit row per delegated task). Safe on an existing database.`, confirm `bun run generate` succeeds (depends on T004) - [ ] T006 [P] Add `getApiPublicUrl(): Promise` to `api/src/slices/setting/domain/infraConfig.gateway.ts` and its implementation, following the existing getters' precedence (settings group `infrastructure` name `api_public_url` → env `API_PUBLIC_URL` → the `ranch_api_url` integration value the workflow gateway reads at `api/src/slices/workflow/data/argo-workflow.gateway.ts:195` → `http://localhost:3333`), trailing slash stripped; extend the existing infraConfig spec (or add `infraConfig.gateway.spec.ts`) with the three-level precedence for this key - [ ] T007 [P] Add `api/src/slices/mcp/interfaces/conditional-listing.interface.ts`: `interface IConditionallyListedTool { isListedForRequest(httpRequest: Request): Promise }` + duck-typed `isConditionallyListed(obj): obj is IConditionallyListedTool` (mirror of `dynamic-description.interface.ts:15-25`); export from `api/src/slices/mcp/index.ts` next to `IDynamicallyDescribedTool` - [ ] T008 Update `api/src/slices/mcp/services/handlers/mcp-tools.handler.ts` (depends on T007): in the `tools/list` handler (`:31-69`) resolve each provider instance and skip the tool when `isConditionallyListed(instance) && !(await instance.isListedForRequest(httpRequest))` (a throw counts as listed, logged at debug like the description fallback at `:54-60`); in the `tools/call` handler (`:107-112`) run the same check before invoking and return `{ content: [{ type: 'text', text: 'Tool "" is not available to this caller.' }], isError: true }` when unlisted From 53909356095b94b3217a591917dcee9ab513202c Mon Sep 17 00:00:00 2001 From: "Maksym Hryzodub [DREAM]" Date: Mon, 14 Sep 2026 12:44:36 +0300 Subject: [PATCH 06/31] feat(bridle): active-turn tracking and an extractable synchronous wait (CLEAN-74) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01SZ9dCrzZqd5SwR4erLYe12 --- api/src/app.module.ts | 2 + api/src/slices/agent/peer/peer.module.ts | 15 +- .../slices/bridle/bridle.controller.spec.ts | 27 +++ api/src/slices/bridle/bridle.controller.ts | 88 +++---- api/src/slices/bridle/bridle.module.ts | 11 +- .../slices/bridle/data/bridle.gateway.spec.ts | 164 +++++++++++++ api/src/slices/bridle/data/bridle.gateway.ts | 43 ++++ .../slices/bridle/domain/bridle.gateway.ts | 14 ++ api/src/slices/bridle/domain/bridle.types.ts | 49 ++++ .../bridle/domain/bridleSync.service.spec.ts | 222 ++++++++++++++++++ .../bridle/domain/bridleSync.service.ts | 117 +++++++++ api/src/slices/bridle/domain/index.ts | 6 + specs/013-a2a-agent-peers/tasks.md | 32 +-- 13 files changed, 714 insertions(+), 76 deletions(-) create mode 100644 api/src/slices/bridle/domain/bridleSync.service.spec.ts create mode 100644 api/src/slices/bridle/domain/bridleSync.service.ts diff --git a/api/src/app.module.ts b/api/src/app.module.ts index 815bc380..86eb0900 100644 --- a/api/src/app.module.ts +++ b/api/src/app.module.ts @@ -10,6 +10,7 @@ import { InitModule } from './slices/setup/init/init.module'; import { AgentModule } from './slices/agent/agent/agent.module'; import { AgentChannelModule } from './slices/agent/agentChannel/agentChannel.module'; import { ShareLinkModule } from './slices/agent/shareLink/shareLink.module'; +import { PeerModule } from './slices/agent/peer/peer.module'; import { TemplateModule } from './slices/agent/template/template.module'; import { TemplateFileModule } from './slices/agent/templateFile/templateFile.module'; import { TemplateInstallModule } from './slices/agent/templateInstall/templateInstall.module'; @@ -62,6 +63,7 @@ import { UserBrowserStateModule } from './slices/user/browserState/browserState. AgentModule, AgentChannelModule, ShareLinkModule, + PeerModule, FileModule, SecretModule, LogModule, diff --git a/api/src/slices/agent/peer/peer.module.ts b/api/src/slices/agent/peer/peer.module.ts index a3452486..27cf97bd 100644 --- a/api/src/slices/agent/peer/peer.module.ts +++ b/api/src/slices/agent/peer/peer.module.ts @@ -1,6 +1,11 @@ import { Module, forwardRef } from '@nestjs/common'; import { AuthModule } from '#/user/auth/auth.module'; import { AgentModule } from '#/agent/agent/agent.module'; +import { TemplateModule } from '#/agent/template/template.module'; +import { SkillModule } from '#/skill/skill.module'; +import { KnowledgeModule } from '#/reins/knowledge/knowledge.module'; +import { BridleModule } from '#/bridle/bridle.module'; +import { SettingModule } from '#/setting/setting.module'; /** * Agents talking to agents (CLEAN-74): agent cards, peer connections, the A2A @@ -11,7 +16,15 @@ import { AgentModule } from '#/agent/agent/agent.module'; * plain — it brings the guards for the owner-side controller and is in no cycle. */ @Module({ - imports: [AuthModule, forwardRef(() => AgentModule)], + imports: [ + AuthModule, + forwardRef(() => AgentModule), + TemplateModule, + SkillModule, + KnowledgeModule, + forwardRef(() => BridleModule), + SettingModule, + ], controllers: [], providers: [], exports: [], diff --git a/api/src/slices/bridle/bridle.controller.spec.ts b/api/src/slices/bridle/bridle.controller.spec.ts index dd522543..c34405c2 100644 --- a/api/src/slices/bridle/bridle.controller.spec.ts +++ b/api/src/slices/bridle/bridle.controller.spec.ts @@ -128,6 +128,32 @@ function makeController(stubs: IStubs = {}) { }, }; + // The synchronous wait moved into BridleSyncService (CLEAN-74). The stub + // records into the same `registered` list the hub does and forwards to + // `sendToAgent`, because what these cases check is which chat identity the + // controller resolved — that question outlived the refactor. + const sync = { + sendAndAwait: jest.fn( + async (input: { + clientId: string; + agentId: string; + text: string; + }) => { + registered.push({ + clientId: input.clientId, + agentId: input.agentId, + socketId: 'sync-stub', + }); + sent.push({ + clientId: input.clientId, + agentId: input.agentId, + text: input.text, + }); + return { text: 'pong', messageId: 'm1', ts: 1, timedOut: false }; + }, + ), + }; + const controller = new BridleController( hub as never, jwt, @@ -135,6 +161,7 @@ function makeController(stubs: IStubs = {}) { transcriptReader as never, attachments, shareLinks, + sync as never, ); return { diff --git a/api/src/slices/bridle/bridle.controller.ts b/api/src/slices/bridle/bridle.controller.ts index f135e81a..91d54fe7 100644 --- a/api/src/slices/bridle/bridle.controller.ts +++ b/api/src/slices/bridle/bridle.controller.ts @@ -39,6 +39,7 @@ import { } from '@nestjs/swagger'; import { JwtService } from '@nestjs/jwt'; import { + BridleSyncService, IBridleGateway, BridleAttachmentService, MAX_ATTACHMENT_BYTES, @@ -155,6 +156,7 @@ export class BridleController { private readonly transcriptReader: TranscriptReaderService, private readonly attachments: BridleAttachmentService, private readonly shareLinks: ShareLinkService, + private readonly sync: BridleSyncService, ) {} /** @@ -339,66 +341,36 @@ export class BridleController { ) { const requester = await this.resolveRequester(req, agentId); const clientId = requester.clientId ?? 'sync-' + crypto.randomUUID(); - // Distinct from clientId: this HTTP call shares the clientId+agentId map - // key with any concurrently-open WS session for the same visitor (e.g. - // the chat widget open in another tab), so registerClient/unregisterClient - // need their own socket-equivalent identity to avoid one call's cleanup - // wiping the other's live registration. - const socketId = 'sync-' + crypto.randomUUID(); - const chunks: string[] = []; - - return new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - this.hub.unregisterClient(clientId, agentId, socketId); - resolve({ - text: chunks.join('') || 'Timeout: no response from agent', - messageId: '', - ts: Date.now(), - }); - }, 120_000); - - this.hub.registerClient( - clientId, - agentId, - socketId, - (data: unknown) => { - const event = data as Record; - if (event.type === 'message' || event.type === 'stream_end') { - clearTimeout(timeout); - this.hub.unregisterClient(clientId, agentId, socketId); - resolve({ - text: event.text ?? chunks.join(''), - messageId: event.messageId, - ts: event.ts, - }); - } else if (event.type === 'stream') { - chunks.push((event.text as string) ?? ''); - } - }, - false, - ); - const base = body.parts ?? buildParts(body.text, body.images); - // Expanding before the send keeps the failure ordering sane: a bad or - // missing attachment rejects the request instead of leaving the caller - // waiting out the 120s timeout for a message the agent never got. - this.attachments - .expand(agentId, body.text, body.attachmentIds, requester) - .then((expanded) => { - this.hub.sendToAgent( - clientId, - agentId, - expanded.text, - [...base, ...expanded.parts], - expanded.attachments, - ); - }) - .catch((err: Error) => { - clearTimeout(timeout); - this.hub.unregisterClient(clientId, agentId, socketId); - reject(err); - }); + const base = body.parts ?? buildParts(body.text, body.images); + // Expanding before the send keeps the failure ordering sane: a bad or + // missing attachment rejects the request instead of leaving the caller + // waiting out the 120s timeout for a message the agent never got. + const expanded = await this.attachments.expand( + agentId, + body.text, + body.attachmentIds, + requester, + ); + + const reply = await this.sync.sendAndAwait({ + agentId, + clientId, + text: expanded.text, + parts: [...base, ...expanded.parts], + attachments: expanded.attachments, }); + + return { + // This route has always answered a timeout with a sentence in the + // reply body rather than an error status; kept verbatim so no caller + // has to learn a new shape. + text: reply.timedOut && !reply.text + ? 'Timeout: no response from agent' + : reply.text, + messageId: reply.messageId, + ts: reply.ts, + }; } /** diff --git a/api/src/slices/bridle/bridle.module.ts b/api/src/slices/bridle/bridle.module.ts index 25db9d71..54fd3cb1 100644 --- a/api/src/slices/bridle/bridle.module.ts +++ b/api/src/slices/bridle/bridle.module.ts @@ -9,6 +9,7 @@ import { IBridleAttachmentGateway, BridleAttachmentService, } from './domain'; +import { BridleSyncService } from './domain/bridleSync.service'; import { BridleGateway, BridleAttachmentGateway } from './data'; import { BridleApiKeyGuard } from './guards/bridleApiKey.guard'; import { BridleChatAuthGuard } from './guards/bridleChatAuth.guard'; @@ -79,6 +80,9 @@ import { ShareLinkModule } from '#/agent/shareLink/shareLink.module'; { provide: IBridleGateway, useClass: BridleGateway }, { provide: IBridleAttachmentGateway, useClass: BridleAttachmentGateway }, BridleAttachmentService, + // Extracted from the sync HTTP route so the A2A server (CLEAN-74) can + // wait for an agent reply without being a controller. + BridleSyncService, // MCP tool: query_attachment. Discovered by the #mcp registry like every // @Tool provider; served on the same endpoint as the Ranch/Knowledge // built-ins and auto-attached through the Documents entry (seeder). @@ -89,6 +93,11 @@ import { ShareLinkModule } from '#/agent/shareLink/shareLink.module'; BridleChatAuthGuard, ], controllers: [BridleController], - exports: [IBridleGateway, IBridleAttachmentGateway, BridleApiKeyGuard], + exports: [ + IBridleGateway, + IBridleAttachmentGateway, + BridleApiKeyGuard, + BridleSyncService, + ], }) export class BridleModule {} diff --git a/api/src/slices/bridle/data/bridle.gateway.spec.ts b/api/src/slices/bridle/data/bridle.gateway.spec.ts index e3e64386..037041fa 100644 --- a/api/src/slices/bridle/data/bridle.gateway.spec.ts +++ b/api/src/slices/bridle/data/bridle.gateway.spec.ts @@ -58,3 +58,167 @@ describe('BridleGateway.sendToAgent', () => { expect('attachments' in received[0]).toBe(false); }); }); + +/** + * Active-turn tracking (CLEAN-74). The hub learns which turn is open purely + * from the thinking events it already relays, so API-side code can add a + * delegation step to the timeline the person is watching. Getting this wrong + * is not subtle: a step published under an unknown turnId closes the + * runtime's own block in every console that renders thinking. + */ +describe('BridleGateway.findActiveTurn', () => { + const step = { id: 's1', label: 'Searching', state: 'active' as const }; + + function hubWithClient(clientId = 'admin', agentId = 'agent-1') { + const gateway = new BridleGateway(); + gateway.registerClient(clientId, agentId, 'socket-1', () => {}, true); + return gateway; + } + + it('knows nothing before the agent publishes a step', () => { + const gateway = hubWithClient(); + + expect(gateway.findActiveTurn('agent-1')).toBeNull(); + }); + + it('remembers the turn a step belongs to', () => { + const gateway = hubWithClient(); + + gateway.handleAgentEvent('agent-1', { + type: 'thinking', + clientId: 'admin', + turnId: 'turn-1', + step, + ts: 1000, + }); + + expect(gateway.findActiveTurn('agent-1')).toEqual({ + clientId: 'admin', + turnId: 'turn-1', + ts: 1000, + }); + }); + + it('follows the turn as later steps arrive', () => { + const gateway = hubWithClient(); + + gateway.handleAgentEvent('agent-1', { + type: 'thinking', + clientId: 'admin', + turnId: 'turn-1', + step, + ts: 1000, + }); + gateway.handleAgentEvent('agent-1', { + type: 'thinking', + clientId: 'admin', + turnId: 'turn-2', + step: { ...step, id: 's2' }, + ts: 2000, + }); + + expect(gateway.findActiveTurn('agent-1')?.turnId).toBe('turn-2'); + }); + + it('forgets the turn once the agent ends it', () => { + const gateway = hubWithClient(); + + gateway.handleAgentEvent('agent-1', { + type: 'thinking', + clientId: 'admin', + turnId: 'turn-1', + step, + ts: 1000, + }); + gateway.handleAgentEvent('agent-1', { + type: 'thinking', + clientId: 'admin', + turnId: 'turn-1', + done: true, + ts: 1500, + }); + + expect(gateway.findActiveTurn('agent-1')).toBeNull(); + }); + + it('forgets the turn when the watching client disconnects', () => { + const gateway = hubWithClient(); + + gateway.handleAgentEvent('agent-1', { + type: 'thinking', + clientId: 'admin', + turnId: 'turn-1', + step, + ts: 1000, + }); + gateway.unregisterClient('admin', 'agent-1', 'socket-1'); + + expect(gateway.findActiveTurn('agent-1')).toBeNull(); + }); + + it('picks the most recent turn when two people chat with one agent', () => { + const gateway = new BridleGateway(); + gateway.registerClient('admin', 'agent-1', 'socket-1', () => {}, true); + gateway.registerClient('visitor', 'agent-1', 'socket-2', () => {}, false); + + gateway.handleAgentEvent('agent-1', { + type: 'thinking', + clientId: 'admin', + turnId: 'turn-admin', + step, + ts: 1000, + }); + gateway.handleAgentEvent('agent-1', { + type: 'thinking', + clientId: 'visitor', + turnId: 'turn-visitor', + step: { ...step, id: 's2' }, + ts: 2000, + }); + + expect(gateway.findActiveTurn('agent-1')).toMatchObject({ + clientId: 'visitor', + turnId: 'turn-visitor', + }); + }); + + it('keeps turns of different agents apart', () => { + const gateway = new BridleGateway(); + gateway.registerClient('admin', 'agent-1', 'socket-1', () => {}, true); + gateway.registerClient('admin', 'agent-2', 'socket-2', () => {}, true); + + gateway.handleAgentEvent('agent-2', { + type: 'thinking', + clientId: 'admin', + turnId: 'turn-2', + step, + ts: 1000, + }); + + expect(gateway.findActiveTurn('agent-1')).toBeNull(); + expect(gateway.findActiveTurn('agent-2')?.turnId).toBe('turn-2'); + }); + + it('still relays the thinking event it learned the turn from', () => { + const gateway = new BridleGateway(); + const received: Array> = []; + gateway.registerClient( + 'admin', + 'agent-1', + 'socket-1', + (data) => received.push(data as Record), + true, + ); + + gateway.handleAgentEvent('agent-1', { + type: 'thinking', + clientId: 'admin', + turnId: 'turn-1', + step, + ts: 1000, + }); + + expect(received).toHaveLength(1); + expect(received[0]).toMatchObject({ type: 'thinking', turnId: 'turn-1' }); + }); +}); diff --git a/api/src/slices/bridle/data/bridle.gateway.ts b/api/src/slices/bridle/data/bridle.gateway.ts index d1f636e4..e913123c 100644 --- a/api/src/slices/bridle/data/bridle.gateway.ts +++ b/api/src/slices/bridle/data/bridle.gateway.ts @@ -14,6 +14,7 @@ import type { IBridleClientData, BridlePart, IBridleAttachment, + IActiveTurn, } from '../domain/bridle.types'; import { randomUUID } from 'crypto'; @@ -57,6 +58,14 @@ export class BridleGateway extends IBridleGateway { return `${clientId}\u0000${agentId}`; } + /** + * Turns in flight, keyed like `clients`. Written from the thinking events + * the hub already relays, so the API can drop a step of its own into the + * timeline a person is watching (CLEAN-74) instead of minting a turnId that + * would close the runtime's own block in every console. + */ + private activeTurns = new Map(); + /** Pending sync requests awaiting agent ack: requestId → pending */ private pendingSyncs = new Map(); @@ -165,6 +174,7 @@ export class BridleGateway extends IBridleGateway { return; } this.clients.delete(key); + this.activeTurns.delete(key); this.logger.log( `Browser client unregistered: ${clientId} agentId=${agentId} (total: ${this.clients.size})`, ); @@ -237,12 +247,45 @@ export class BridleGateway extends IBridleGateway { const clientId = data.clientId; if (!clientId) return; + if (data.type === 'thinking') { + this.trackTurn(agentId, clientId, data); + } + const client = this.clients.get(this.clientKey(clientId, agentId)); if (client) { client.send(data); } } + /** A step opens or refreshes the turn; the terminal `done` closes it. */ + private trackTurn( + agentId: string, + clientId: string, + data: IBridleOutgoingEvent, + ): void { + const key = this.clientKey(clientId, agentId); + if (data.done === true || typeof data.turnId !== 'string') { + this.activeTurns.delete(key); + return; + } + this.activeTurns.set(key, { + agentId, + clientId, + turnId: data.turnId, + ts: typeof data.ts === 'number' ? data.ts : Date.now(), + }); + } + + findActiveTurn(agentId: string): IActiveTurn | null { + let newest: (IActiveTurn & { agentId: string }) | null = null; + for (const turn of this.activeTurns.values()) { + if (turn.agentId !== agentId) continue; + if (!newest || turn.ts > newest.ts) newest = turn; + } + if (!newest) return null; + return { clientId: newest.clientId, turnId: newest.turnId, ts: newest.ts }; + } + setDebug(agentId: string, enabled: boolean): void { const agentSend = this.agents.get(agentId)?.send; if (!agentSend) { diff --git a/api/src/slices/bridle/domain/bridle.gateway.ts b/api/src/slices/bridle/domain/bridle.gateway.ts index 12cb84f7..1367fe67 100644 --- a/api/src/slices/bridle/domain/bridle.gateway.ts +++ b/api/src/slices/bridle/domain/bridle.gateway.ts @@ -7,6 +7,7 @@ import type { IBridleDebugEvent, BridlePart, IBridleAttachment, + IActiveTurn, } from './bridle.types'; export interface ISyncAgentResult { @@ -119,6 +120,19 @@ export abstract class IBridleGateway { * the token is already persisted as a secret, so a fresh boot picks it up. */ abstract notifyMcpConnected(agentId: string, serverName: string): void; + /** + * The turn this agent is in the middle of, for the client that is watching + * it — or null when nothing is known. Lets API-side code (CLEAN-74) add a + * step to a timeline the runtime opened, instead of inventing a turn of its + * own: a step under an unknown turnId would close the runtime's block in + * every console that renders thinking. + * + * Derived purely from the thinking events already passing through the hub, + * so it costs nothing and stays correct without the runtime knowing. With + * two people chatting to one agent at once, the most recent turn wins — + * a tool call carries no clue which conversation it belongs to. + */ + abstract findActiveTurn(agentId: string): IActiveTurn | null; /** * Tell the running agent to drop its local copy of a session (file + * in-memory cache) for the given bridle channel. Sent after the transcript diff --git a/api/src/slices/bridle/domain/bridle.types.ts b/api/src/slices/bridle/domain/bridle.types.ts index 71ac340a..ef855790 100644 --- a/api/src/slices/bridle/domain/bridle.types.ts +++ b/api/src/slices/bridle/domain/bridle.types.ts @@ -116,10 +116,40 @@ export interface IBridleOutgoingEvent { parts?: BridlePart[]; messageId?: string; ts?: number; + /** `thinking` only — see IBridleThinkingEvent. Typed here so the API can + * publish a step of its own (CLEAN-74) without casting. */ + turnId?: string; + step?: IBridleThinkingStep; + done?: boolean; } // ── Thinking (live reasoning steps) ────────────────────────── +/** + * The structured half of a delegation step (CLEAN-74): everything the admin + * chat needs to render "who was asked, what their card promised, why they + * were picked, and how it went" without parsing prose. Surfaces that know + * nothing about delegation still show the step, because `label` and `detail` + * carry the same story in words. + */ +export interface IBridleDelegationStep { + delegationId: string; + peerAgentId: string; + peerName: string; + /** The card skills that made this peer the choice. */ + matchedSkills: { id: string; name: string }[]; + /** The calling model's one-line reason, shown to the person verbatim. */ + reason: string; + /** The self-contained task text the peer received. */ + task: string; + status: 'waiting' | 'answered' | 'failed' | 'rejected'; + /** Epoch ms — the client ticks its own elapsed time while waiting. */ + startedAt: number; + durationMs?: number; + /** Reply excerpt when answered; the cause in product wording otherwise. */ + excerpt?: string; +} + /** One published unit of agent work inside a thinking timeline. */ export interface IBridleThinkingStep { /** Stable per-step id — the `done` update reuses the `active` event's id. */ @@ -130,6 +160,25 @@ export interface IBridleThinkingStep { * params or prompts — this event is NOT admin-gated (unlike `debug`). */ detail?: string; state: 'active' | 'done'; + /** + * Set by steps the API itself publishes rather than the runtime. Absent on + * every step an agent emits, which is what keeps this additive. + */ + kind?: 'delegation'; + /** Present exactly when `kind === 'delegation'`. */ + delegation?: IBridleDelegationStep; +} + +/** + * What the hub remembers about a turn in flight, so API-side code can add a + * step to the timeline the person is already watching (CLEAN-74). The runtime + * mints `turnId`; the hub only observes it passing through. + */ +export interface IActiveTurn { + clientId: string; + turnId: string; + /** When the last step of this turn was seen — newest wins. */ + ts: number; } /** diff --git a/api/src/slices/bridle/domain/bridleSync.service.spec.ts b/api/src/slices/bridle/domain/bridleSync.service.spec.ts new file mode 100644 index 00000000..04a4cace --- /dev/null +++ b/api/src/slices/bridle/domain/bridleSync.service.spec.ts @@ -0,0 +1,222 @@ +import { BridleSyncService } from './bridleSync.service'; +import type { IBridleGateway } from './bridle.gateway'; + +/** + * The register/resolve/timeout dance the synchronous chat route has always + * done, now shared with the A2A server (CLEAN-74). What must hold: exactly one + * outcome per call, the registration always cleaned up, and a timeout reported + * as a flag rather than smuggled back as if it were the agent's answer. + */ +type Send = (data: unknown) => void; + +function makeHarness() { + const registrations: Array<{ + clientId: string; + agentId: string; + socketId: string; + isAdmin: boolean; + capabilities?: string[]; + }> = []; + const unregistrations: Array<{ + clientId: string; + agentId: string; + socketId: string; + }> = []; + const sends: Array> = []; + let emit: Send = () => {}; + + const hub = { + registerClient: jest.fn( + ( + clientId: string, + agentId: string, + socketId: string, + send: Send, + isAdmin: boolean, + _prompt?: string, + capabilities?: string[], + ) => { + registrations.push({ + clientId, + agentId, + socketId, + isAdmin, + capabilities, + }); + emit = send; + }, + ), + unregisterClient: jest.fn( + (clientId: string, agentId: string, socketId: string) => { + unregistrations.push({ clientId, agentId, socketId }); + }, + ), + sendToAgent: jest.fn( + ( + clientId: string, + agentId: string, + text: string, + parts: unknown, + attachments: unknown, + ) => { + sends.push({ clientId, agentId, text, parts, attachments }); + }, + ), + } as unknown as IBridleGateway; + + const service = new BridleSyncService(hub); + return { + service, + hub, + registrations, + unregistrations, + sends, + emit: (data: unknown) => emit(data), + }; +} + +const base = { agentId: 'agent-1', clientId: 'peer:caller:ctx-1', text: 'hi' }; + +describe('BridleSyncService.sendAndAwait', () => { + beforeEach(() => jest.useRealTimers()); + + it('resolves on a plain message', async () => { + const h = makeHarness(); + + const pending = h.service.sendAndAwait(base); + h.emit({ type: 'message', text: 'the answer', messageId: 'm1', ts: 42 }); + + await expect(pending).resolves.toEqual({ + text: 'the answer', + messageId: 'm1', + ts: 42, + timedOut: false, + }); + }); + + it('accumulates stream chunks and resolves on stream_end', async () => { + const h = makeHarness(); + + const pending = h.service.sendAndAwait(base); + h.emit({ type: 'stream', text: 'the ' }); + h.emit({ type: 'stream', text: 'answer' }); + h.emit({ type: 'stream_end', messageId: 'm2', ts: 7 }); + + await expect(pending).resolves.toMatchObject({ + text: 'the answer', + messageId: 'm2', + timedOut: false, + }); + }); + + it('prefers the terminal event text over the accumulated chunks', async () => { + const h = makeHarness(); + + const pending = h.service.sendAndAwait(base); + h.emit({ type: 'stream', text: 'partial' }); + h.emit({ type: 'message', text: 'final', messageId: 'm3', ts: 9 }); + + await expect(pending).resolves.toMatchObject({ text: 'final' }); + }); + + it('sends the message to the agent after registering to hear the reply', async () => { + const h = makeHarness(); + + const pending = h.service.sendAndAwait({ ...base, parts: [] }); + h.emit({ type: 'message', text: 'ok', messageId: 'm', ts: 1 }); + await pending; + + expect(h.registrations).toHaveLength(1); + expect(h.sends).toHaveLength(1); + expect(h.sends[0]).toMatchObject({ + clientId: 'peer:caller:ctx-1', + agentId: 'agent-1', + text: 'hi', + }); + }); + + it('registers under its own socket id so a live tab is never unregistered', async () => { + const h = makeHarness(); + + const pending = h.service.sendAndAwait(base); + h.emit({ type: 'message', text: 'ok', messageId: 'm', ts: 1 }); + await pending; + + expect(h.registrations[0].socketId).toMatch(/^sync-/); + expect(h.registrations[0].socketId).not.toBe(h.registrations[0].clientId); + expect(h.unregistrations[0].socketId).toBe(h.registrations[0].socketId); + }); + + it('declares no capabilities by default, so a peer gets no thinking stream', async () => { + const h = makeHarness(); + + const pending = h.service.sendAndAwait(base); + h.emit({ type: 'message', text: 'ok', messageId: 'm', ts: 1 }); + await pending; + + expect(h.registrations[0].capabilities).toEqual([]); + expect(h.registrations[0].isAdmin).toBe(false); + }); + + it('passes the caller capabilities through when it has some', async () => { + const h = makeHarness(); + + const pending = h.service.sendAndAwait({ + ...base, + capabilities: ['streaming', 'thinking'], + isAdmin: true, + }); + h.emit({ type: 'message', text: 'ok', messageId: 'm', ts: 1 }); + await pending; + + expect(h.registrations[0].capabilities).toEqual(['streaming', 'thinking']); + expect(h.registrations[0].isAdmin).toBe(true); + }); + + it('reports a timeout as a flag and unregisters', async () => { + const h = makeHarness(); + + const pending = h.service.sendAndAwait({ ...base, timeoutMs: 5 }); + + await expect(pending).resolves.toMatchObject({ + text: '', + messageId: '', + timedOut: true, + }); + expect(h.unregistrations).toHaveLength(1); + }); + + it('returns what had streamed in when the wait runs out mid-answer', async () => { + const h = makeHarness(); + + const pending = h.service.sendAndAwait({ ...base, timeoutMs: 20 }); + h.emit({ type: 'stream', text: 'half an ans' }); + + await expect(pending).resolves.toMatchObject({ + text: 'half an ans', + timedOut: true, + }); + }); + + it('ignores a late reply after a timeout instead of resolving twice', async () => { + const h = makeHarness(); + + const pending = h.service.sendAndAwait({ ...base, timeoutMs: 5 }); + const first = await pending; + h.emit({ type: 'message', text: 'too late', messageId: 'm', ts: 1 }); + + expect(first.timedOut).toBe(true); + expect(h.unregistrations).toHaveLength(1); + }); + + it('unregisters exactly once when several terminal events arrive', async () => { + const h = makeHarness(); + + const pending = h.service.sendAndAwait(base); + h.emit({ type: 'message', text: 'first', messageId: 'm1', ts: 1 }); + h.emit({ type: 'stream_end', text: 'second', messageId: 'm2', ts: 2 }); + await pending; + + expect(h.unregistrations).toHaveLength(1); + }); +}); diff --git a/api/src/slices/bridle/domain/bridleSync.service.ts b/api/src/slices/bridle/domain/bridleSync.service.ts new file mode 100644 index 00000000..30455377 --- /dev/null +++ b/api/src/slices/bridle/domain/bridleSync.service.ts @@ -0,0 +1,117 @@ +import { Injectable } from '@nestjs/common'; +import * as crypto from 'crypto'; +import { IBridleGateway } from './bridle.gateway'; +import type { BridlePart, IBridleAttachment } from './bridle.types'; + +/** Same ceiling the synchronous chat route has always used. */ +export const DEFAULT_SYNC_TIMEOUT_MS = 120_000; + +export interface ISendAndAwaitInput { + agentId: string; + /** Conversation identity. Decides history, access and continuity. */ + clientId: string; + text: string; + parts?: BridlePart[]; + attachments?: IBridleAttachment[]; + /** + * What the caller can render. The runtime gates optional output on this: + * a caller that cannot show thinking steps must not declare `'thinking'`, + * or the agent streams a timeline into a void. + */ + capabilities?: string[]; + isAdmin?: boolean; + timeoutMs?: number; +} + +export interface ISendAndAwaitResult { + text: string; + messageId: string; + ts: number; + /** True when the wait ran out. `text` then holds whatever had streamed in. */ + timedOut: boolean; +} + +/** + * "Send one message to an agent and wait for its answer." + * + * This lived inline in the HTTP controller, which was fine while HTTP was the + * only caller. The A2A server (CLEAN-74) needs the same wait without being a + * controller: an inbound task from a peer agent has to reach the runtime and + * come back as one reply. Extracting it keeps one implementation of the + * register/resolve/timeout dance rather than a copy that drifts. + * + * The one behavioural difference from the old inline code is that a timeout is + * reported as a flag instead of a fixed sentence. The HTTP route still renders + * its own sentence; A2A turns the flag into a failed task with a stated cause, + * because a peer that answers "Timeout: no response from agent" as if it were + * an answer is exactly the silent failure this feature must not have. + */ +@Injectable() +export class BridleSyncService { + constructor(private readonly hub: IBridleGateway) {} + + sendAndAwait(input: ISendAndAwaitInput): Promise { + const { + agentId, + clientId, + text, + parts = [], + attachments, + capabilities = [], + isAdmin = false, + timeoutMs = DEFAULT_SYNC_TIMEOUT_MS, + } = input; + + // Distinct from clientId: this call shares the clientId+agentId map key + // with any concurrently-open WS session for the same visitor (e.g. the + // chat widget open in another tab), so registerClient/unregisterClient + // need their own socket-equivalent identity to avoid one call's cleanup + // wiping the other's live registration. + const socketId = 'sync-' + crypto.randomUUID(); + const chunks: string[] = []; + + return new Promise((resolve) => { + let settled = false; + const finish = (result: ISendAndAwaitResult) => { + if (settled) return; + settled = true; + clearTimeout(timer); + this.hub.unregisterClient(clientId, agentId, socketId); + resolve(result); + }; + + const timer = setTimeout(() => { + finish({ + text: chunks.join(''), + messageId: '', + ts: Date.now(), + timedOut: true, + }); + }, timeoutMs); + + this.hub.registerClient( + clientId, + agentId, + socketId, + (data: unknown) => { + const event = data as Record; + if (event.type === 'message' || event.type === 'stream_end') { + finish({ + text: (event.text as string) ?? chunks.join(''), + messageId: (event.messageId as string) ?? '', + ts: (event.ts as number) ?? Date.now(), + timedOut: false, + }); + } else if (event.type === 'stream') { + chunks.push((event.text as string) ?? ''); + } + }, + isAdmin, + undefined, + capabilities, + ); + + this.hub.sendToAgent(clientId, agentId, text, parts, attachments); + }); + } +} diff --git a/api/src/slices/bridle/domain/index.ts b/api/src/slices/bridle/domain/index.ts index dcedd7ae..b3d1ec12 100644 --- a/api/src/slices/bridle/domain/index.ts +++ b/api/src/slices/bridle/domain/index.ts @@ -2,6 +2,12 @@ export * from './bridle.types'; export * from './attachment.constants'; export * from './attachmentBlocks'; export * from './sheetStructure'; +export { + BridleSyncService, + DEFAULT_SYNC_TIMEOUT_MS, + type ISendAndAwaitInput, + type ISendAndAwaitResult, +} from './bridleSync.service'; export { IBridleGateway, type ISyncAgentResult, diff --git a/specs/013-a2a-agent-peers/tasks.md b/specs/013-a2a-agent-peers/tasks.md index 58db8431..e44ad40e 100644 --- a/specs/013-a2a-agent-peers/tasks.md +++ b/specs/013-a2a-agent-peers/tasks.md @@ -28,9 +28,9 @@ **Purpose**: Env, constants, slice skeleton and the protocol types so every later task has a home. -- [ ] T001 Append to `api/.env.example` with one-line comments: `API_PUBLIC_URL=http://localhost:3333` (URL other agents use to reach this API; falls back to `ranch_api_url`), `A2A_SYNC_TIMEOUT_MS=120000`, `A2A_MAX_CHAIN=3`; mirror `API_PUBLIC_URL` into the env block of `k8s/deploy/30-api.yaml` next to `JWT_EXPIRES_IN` -- [ ] T002 [P] Create the slice skeleton `api/src/slices/agent/peer/` with `domain/index.ts` barrel, `domain/peer.types.ts` holding: `PEER_TOKEN_PREFIX = 'ap_'`, `PEER_TOKEN_BYTES = 32`, `PEER_TOKEN_RE = /^ap_[A-Za-z0-9_-]{43}$/`, `PeerErrorCodes = { Self: 'PEER_SELF', Exists: 'PEER_EXISTS', NotFound: 'PEER_NOT_FOUND', CardUnreachable: 'PEER_CARD_UNREACHABLE', Unauthorized: 'A2A_UNAUTHORIZED' } as const`, `DelegationStatus = 'waiting' | 'answered' | 'failed' | 'rejected'`, `DelegationErrorCodes = { NotRunning: 'PEER_NOT_RUNNING', Timeout: 'PEER_TIMEOUT', RejectedLoop: 'PEER_REJECTED_LOOP', RejectedDepth: 'PEER_REJECTED_DEPTH', Unauthorized: 'PEER_UNAUTHORIZED', Unreachable: 'PEER_UNREACHABLE', Error: 'PEER_ERROR' } as const`, `IAgentPeerData { id, agentId, peerAgentId, token, cardSnapshot: AgentCard, cardUrl, cardReadAt: string, createdAt: string, updatedAt: string }`, `IAgentDelegationData` (every column of data-model §2 with ISO-string dates), `IMatchedSkill { id, name }`; plus an empty `peer.module.ts` shaped like `api/src/slices/agent/shareLink/shareLink.module.ts:21-33` (`forwardRef(() => AgentModule)`) -- [ ] T003 [P] Write `api/src/slices/agent/peer/domain/a2a.types.ts` from research §2 / contracts §1–2 (A2A 1.0 names, no `kind` on parts): `A2A_VERSION = '1.0'`, `A2A_VERSION_HEADER = 'A2A-Version'`, `AgentInterface`, `AgentCapabilities`, `AgentSkill`, `AgentCard`, `A2aPart = { text: string; metadata?; mediaType? } | { raw: string; … } | { url: string; … } | { data: unknown; … }`, `A2aMessage { messageId, role: 'ROLE_USER' | 'ROLE_AGENT', parts, contextId?, taskId?, metadata?, extensions?, referenceTaskIds? }`, `A2aTaskState` string-literal union of the nine `TASK_STATE_*` values, `A2aTaskStatus { state, message?, timestamp }`, `A2aArtifact { artifactId, parts, name?, description?, metadata? }`, `A2aTask { id, contextId, status, artifacts, history, metadata }`, `SendMessageParams { message, configuration?: { acceptedOutputModes?, historyLength?, returnImmediately? }, metadata?, tenant? }`, `SendMessageResult = { task: A2aTask } | { message: A2aMessage }`, `GetTaskParams { id, historyLength? }`, `JsonRpcRequest { jsonrpc: '2.0', id, method, params? }`, `JsonRpcResponse`, `A2aErrorCodes = { ParseError: -32700, InvalidRequest: -32600, MethodNotFound: -32601, InvalidParams: -32602, Internal: -32603, TaskNotFound: -32001, TaskNotCancelable: -32002, PushNotSupported: -32003, Unsupported: -32004, ContentTypeNotSupported: -32005, InvalidAgentResponse: -32006, VersionNotSupported: -32009 } as const`, `RanchTaskMetadata { chain: string[]; reason?: string; rejection?: 'loop' | 'depth'; failure?: 'not_running' | 'timeout'; durationMs? }` and helpers `textOfParts(parts): string` (joins `text` parts with `\n\n`), `hasNonTextPart(parts): boolean`; export from `domain/index.ts` +- [X] T001 Append to `api/.env.example` with one-line comments: `API_PUBLIC_URL=http://localhost:3333` (URL other agents use to reach this API; falls back to `ranch_api_url`), `A2A_SYNC_TIMEOUT_MS=120000`, `A2A_MAX_CHAIN=3`; mirror `API_PUBLIC_URL` into the env block of `k8s/deploy/30-api.yaml` next to `JWT_EXPIRES_IN` +- [X] T002 [P] Create the slice skeleton `api/src/slices/agent/peer/` with `domain/index.ts` barrel, `domain/peer.types.ts` holding: `PEER_TOKEN_PREFIX = 'ap_'`, `PEER_TOKEN_BYTES = 32`, `PEER_TOKEN_RE = /^ap_[A-Za-z0-9_-]{43}$/`, `PeerErrorCodes = { Self: 'PEER_SELF', Exists: 'PEER_EXISTS', NotFound: 'PEER_NOT_FOUND', CardUnreachable: 'PEER_CARD_UNREACHABLE', Unauthorized: 'A2A_UNAUTHORIZED' } as const`, `DelegationStatus = 'waiting' | 'answered' | 'failed' | 'rejected'`, `DelegationErrorCodes = { NotRunning: 'PEER_NOT_RUNNING', Timeout: 'PEER_TIMEOUT', RejectedLoop: 'PEER_REJECTED_LOOP', RejectedDepth: 'PEER_REJECTED_DEPTH', Unauthorized: 'PEER_UNAUTHORIZED', Unreachable: 'PEER_UNREACHABLE', Error: 'PEER_ERROR' } as const`, `IAgentPeerData { id, agentId, peerAgentId, token, cardSnapshot: AgentCard, cardUrl, cardReadAt: string, createdAt: string, updatedAt: string }`, `IAgentDelegationData` (every column of data-model §2 with ISO-string dates), `IMatchedSkill { id, name }`; plus an empty `peer.module.ts` shaped like `api/src/slices/agent/shareLink/shareLink.module.ts:21-33` (`forwardRef(() => AgentModule)`) +- [X] T003 [P] Write `api/src/slices/agent/peer/domain/a2a.types.ts` from research §2 / contracts §1–2 (A2A 1.0 names, no `kind` on parts): `A2A_VERSION = '1.0'`, `A2A_VERSION_HEADER = 'A2A-Version'`, `AgentInterface`, `AgentCapabilities`, `AgentSkill`, `AgentCard`, `A2aPart = { text: string; metadata?; mediaType? } | { raw: string; … } | { url: string; … } | { data: unknown; … }`, `A2aMessage { messageId, role: 'ROLE_USER' | 'ROLE_AGENT', parts, contextId?, taskId?, metadata?, extensions?, referenceTaskIds? }`, `A2aTaskState` string-literal union of the nine `TASK_STATE_*` values, `A2aTaskStatus { state, message?, timestamp }`, `A2aArtifact { artifactId, parts, name?, description?, metadata? }`, `A2aTask { id, contextId, status, artifacts, history, metadata }`, `SendMessageParams { message, configuration?: { acceptedOutputModes?, historyLength?, returnImmediately? }, metadata?, tenant? }`, `SendMessageResult = { task: A2aTask } | { message: A2aMessage }`, `GetTaskParams { id, historyLength? }`, `JsonRpcRequest { jsonrpc: '2.0', id, method, params? }`, `JsonRpcResponse`, `A2aErrorCodes = { ParseError: -32700, InvalidRequest: -32600, MethodNotFound: -32601, InvalidParams: -32602, Internal: -32603, TaskNotFound: -32001, TaskNotCancelable: -32002, PushNotSupported: -32003, Unsupported: -32004, ContentTypeNotSupported: -32005, InvalidAgentResponse: -32006, VersionNotSupported: -32009 } as const`, `RanchTaskMetadata { chain: string[]; reason?: string; rejection?: 'loop' | 'depth'; failure?: 'not_running' | 'timeout'; durationMs? }` and helpers `textOfParts(parts): string` (joins `text` parts with `\n\n`), `hasNonTextPart(parts): boolean`; export from `domain/index.ts` --- @@ -40,19 +40,19 @@ **⚠️ CRITICAL**: No user story work can begin until this phase is complete -- [ ] T004 Add Prisma fragments (depends on T002): `api/src/slices/agent/peer/peer.prisma` — `import { Agent } from "../agent/agent"` and `model AgentPeer { id String @id @default(uuid()); agentId String; agent Agent @relation("AgentPeerCaller", fields: [agentId], references: [id], onDelete: Cascade); peerAgentId String; peerAgent Agent @relation("AgentPeerTarget", fields: [peerAgentId], references: [id], onDelete: Cascade); token String @unique; cardSnapshot Json; cardUrl String; cardReadAt DateTime; createdAt DateTime @default(now()); updatedAt DateTime @updatedAt; delegations AgentDelegation[]; @@unique([agentId, peerAgentId]); @@index([agentId]); @@index([peerAgentId]) }`; `api/src/slices/agent/peer/delegation.prisma` — `import { Agent } from "../agent/agent"`, `import { AgentPeer } from "./peer"` and `model AgentDelegation { id String @id @default(uuid()); agentId String; agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade); peerId String?; peer AgentPeer? @relation(fields: [peerId], references: [id], onDelete: SetNull); peerAgentId String; peerName String; contextId String; turnId String?; clientId String?; task String; reason String; matchedSkills Json @default("[]"); status String @default("waiting"); errorCode String?; excerpt String?; startedAt DateTime @default(now()); finishedAt DateTime?; durationMs Int?; @@index([agentId, startedAt]) }`; in `api/src/slices/agent/agent/agent.prisma` add `import { AgentPeer } from "../peer/peer"`, `import { AgentDelegation } from "../peer/delegation"` and the back-relations `peers AgentPeer[] @relation("AgentPeerCaller")`, `peerOf AgentPeer[] @relation("AgentPeerTarget")`, `delegations AgentDelegation[]` -- [ ] T005 Run `cd api && bun run migrate`, rename the generated folder to `api/prisma/migrations/20260914120000_agent_peer_delegation/`, prepend `-- Additive: new tables AgentPeer (directed peer connection + pair credential + card snapshot) and AgentDelegation (audit row per delegated task). Safe on an existing database.`, confirm `bun run generate` succeeds (depends on T004) -- [ ] T006 [P] Add `getApiPublicUrl(): Promise` to `api/src/slices/setting/domain/infraConfig.gateway.ts` and its implementation, following the existing getters' precedence (settings group `infrastructure` name `api_public_url` → env `API_PUBLIC_URL` → the `ranch_api_url` integration value the workflow gateway reads at `api/src/slices/workflow/data/argo-workflow.gateway.ts:195` → `http://localhost:3333`), trailing slash stripped; extend the existing infraConfig spec (or add `infraConfig.gateway.spec.ts`) with the three-level precedence for this key -- [ ] T007 [P] Add `api/src/slices/mcp/interfaces/conditional-listing.interface.ts`: `interface IConditionallyListedTool { isListedForRequest(httpRequest: Request): Promise }` + duck-typed `isConditionallyListed(obj): obj is IConditionallyListedTool` (mirror of `dynamic-description.interface.ts:15-25`); export from `api/src/slices/mcp/index.ts` next to `IDynamicallyDescribedTool` -- [ ] T008 Update `api/src/slices/mcp/services/handlers/mcp-tools.handler.ts` (depends on T007): in the `tools/list` handler (`:31-69`) resolve each provider instance and skip the tool when `isConditionallyListed(instance) && !(await instance.isListedForRequest(httpRequest))` (a throw counts as listed, logged at debug like the description fallback at `:54-60`); in the `tools/call` handler (`:107-112`) run the same check before invoking and return `{ content: [{ type: 'text', text: 'Tool "" is not available to this caller.' }], isError: true }` when unlisted -- [ ] T009 Write `api/src/slices/mcp/services/handlers/mcp-tools.handler.spec.ts` (new; construct the handler with a stubbed registry/module-ref in the style of `mcp-registry.service.spec.ts:16-35`): a tool whose `isListedForRequest` resolves `false` is absent from `tools/list` and its `tools/call` returns `isError`; a tool without the method is listed as before; a throwing `isListedForRequest` keeps the tool listed (depends on T008) -- [ ] T010 [P] Extend `api/src/slices/bridle/domain/bridle.types.ts`: on `IBridleThinkingStep` (`:124-133`) add `kind?: 'delegation'` and `delegation?: IBridleDelegationStep` where `IBridleDelegationStep { delegationId: string; peerAgentId: string; peerName: string; matchedSkills: { id: string; name: string }[]; reason: string; task: string; status: 'waiting' | 'answered' | 'failed' | 'rejected'; startedAt: number; durationMs?: number; excerpt?: string }`; add `IActiveTurn { clientId: string; turnId: string; ts: number }`; make the `thinking` member of `IBridleOutgoingEvent` (`:105-119`) carry `turnId`, `step?`, `done?` so API-side emitters need no cast -- [ ] T011 Add `abstract findActiveTurn(agentId: string): IActiveTurn | null` to `api/src/slices/bridle/domain/bridle.gateway.ts` (doc: "most recent turn that has emitted a thinking step and not yet its terminal `done`; null when unknown") and implement in `api/src/slices/bridle/data/bridle.gateway.ts`: a `Map` keyed `${agentId}${clientId}`; in `handleAgentEvent` (`:236-244`) when `data.type === 'thinking'` set the entry on a step event and delete it on `done: true`; delete in `unregisterClient` (`:58`); `findActiveTurn` returns the entry with the greatest `ts` for that agent (depends on T010) -- [ ] T012 Extend `api/src/slices/bridle/data/bridle.gateway.spec.ts` (or create it beside the gateway if absent): `findActiveTurn` is null before any thinking event, set after a step event, updated to the newest of two clients, cleared by `done: true`, cleared by `unregisterClient` (depends on T011) -- [ ] T013 Create `api/src/slices/bridle/domain/bridleSync.service.ts` with `@Injectable() BridleSyncService` over `IBridleGateway`: `sendAndAwait(input: { agentId: string; clientId: string; text: string; parts?: BridlePart[]; attachments?; capabilities?: string[]; isAdmin?: boolean; timeoutMs?: number }): Promise<{ text: string; messageId: string; ts: number; timedOut: boolean }>` — the register-with-private-socket-id / resolve-on-`message`-or-`stream_end` / accumulate-`stream` / timeout / unregister logic lifted verbatim from `api/src/slices/bridle/bridle.controller.ts:341-379` (default timeout 120 000 from env `A2A_SYNC_TIMEOUT_MS` is **not** read here — the caller passes it); `timedOut: true` and `text = chunks.join('')` on timeout; `registerClient` receives the caller's `capabilities` (default `[]`) so a peer conversation never declares `thinking`; export from `domain/index.ts`; provide and export it in `api/src/slices/bridle/bridle.module.ts` -- [ ] T014 Refactor `api/src/slices/bridle/bridle.controller.ts:335-402` (`sendMessageSync`) to call `BridleSyncService.sendAndAwait` with `capabilities: ['streaming']` and map `timedOut` to today's `'Timeout: no response from agent'` text so the HTTP behaviour is byte-identical; keep attachment expansion before the call (depends on T013) -- [ ] T015 Write `api/src/slices/bridle/domain/bridleSync.service.spec.ts` with a hub stub that captures the registered callback: resolves on `message` with `{ text, messageId, ts }`; resolves on `stream_end` with the joined `stream` chunks; times out → `timedOut: true`, callback unregistered with the private socket id; `capabilities` passed through to `registerClient` (depends on T013); run `cd api && bun run test -- bridle.controller` to confirm the existing controller spec still passes after T014 -- [ ] T016 Register `PeerModule` in `api/src/app.module.ts` imports next to `ShareLinkModule` and make `peer.module.ts` import `forwardRef(() => AgentModule)`, `TemplateModule`, `SkillModule`, `KnowledgeModule`, `BridleModule`, `SettingModule`; if `SkillModule`/`KnowledgeModule` do not export `ISkillGateway`/`IKnowledgeGateway` yet, add the export in their module files (`api/src/slices/skill/skill.module.ts`, `api/src/slices/reins/knowledge/knowledge.module.ts`) — verify with `cd api && bunx tsc --noEmit` (depends on T002, T005) +- [X] T004 Add Prisma fragments (depends on T002): `api/src/slices/agent/peer/peer.prisma` — `import { Agent } from "../agent/agent"` and `model AgentPeer { id String @id @default(uuid()); agentId String; agent Agent @relation("AgentPeerCaller", fields: [agentId], references: [id], onDelete: Cascade); peerAgentId String; peerAgent Agent @relation("AgentPeerTarget", fields: [peerAgentId], references: [id], onDelete: Cascade); token String @unique; cardSnapshot Json; cardUrl String; cardReadAt DateTime; createdAt DateTime @default(now()); updatedAt DateTime @updatedAt; delegations AgentDelegation[]; @@unique([agentId, peerAgentId]); @@index([agentId]); @@index([peerAgentId]) }`; `api/src/slices/agent/peer/delegation.prisma` — `import { Agent } from "../agent/agent"`, `import { AgentPeer } from "./peer"` and `model AgentDelegation { id String @id @default(uuid()); agentId String; agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade); peerId String?; peer AgentPeer? @relation(fields: [peerId], references: [id], onDelete: SetNull); peerAgentId String; peerName String; contextId String; turnId String?; clientId String?; task String; reason String; matchedSkills Json @default("[]"); status String @default("waiting"); errorCode String?; excerpt String?; startedAt DateTime @default(now()); finishedAt DateTime?; durationMs Int?; @@index([agentId, startedAt]) }`; in `api/src/slices/agent/agent/agent.prisma` add `import { AgentPeer } from "../peer/peer"`, `import { AgentDelegation } from "../peer/delegation"` and the back-relations `peers AgentPeer[] @relation("AgentPeerCaller")`, `peerOf AgentPeer[] @relation("AgentPeerTarget")`, `delegations AgentDelegation[]` +- [X] T005 Run `cd api && bun run migrate`, rename the generated folder to `api/prisma/migrations/20260914120000_agent_peer_delegation/`, prepend `-- Additive: new tables AgentPeer (directed peer connection + pair credential + card snapshot) and AgentDelegation (audit row per delegated task). Safe on an existing database.`, confirm `bun run generate` succeeds (depends on T004) +- [X] T006 [P] Add `getApiPublicUrl(): Promise` to `api/src/slices/setting/domain/infraConfig.gateway.ts` and its implementation, following the existing getters' precedence (settings group `infrastructure` name `api_public_url` → env `API_PUBLIC_URL` → the `ranch_api_url` integration value the workflow gateway reads at `api/src/slices/workflow/data/argo-workflow.gateway.ts:195` → `http://localhost:3333`), trailing slash stripped; extend the existing infraConfig spec (or add `infraConfig.gateway.spec.ts`) with the three-level precedence for this key +- [X] T007 [P] Add `api/src/slices/mcp/interfaces/conditional-listing.interface.ts`: `interface IConditionallyListedTool { isListedForRequest(httpRequest: Request): Promise }` + duck-typed `isConditionallyListed(obj): obj is IConditionallyListedTool` (mirror of `dynamic-description.interface.ts:15-25`); export from `api/src/slices/mcp/index.ts` next to `IDynamicallyDescribedTool` +- [X] T008 Update `api/src/slices/mcp/services/handlers/mcp-tools.handler.ts` (depends on T007): in the `tools/list` handler (`:31-69`) resolve each provider instance and skip the tool when `isConditionallyListed(instance) && !(await instance.isListedForRequest(httpRequest))` (a throw counts as listed, logged at debug like the description fallback at `:54-60`); in the `tools/call` handler (`:107-112`) run the same check before invoking and return `{ content: [{ type: 'text', text: 'Tool "" is not available to this caller.' }], isError: true }` when unlisted +- [X] T009 Write `api/src/slices/mcp/services/handlers/mcp-tools.handler.spec.ts` (new; construct the handler with a stubbed registry/module-ref in the style of `mcp-registry.service.spec.ts:16-35`): a tool whose `isListedForRequest` resolves `false` is absent from `tools/list` and its `tools/call` returns `isError`; a tool without the method is listed as before; a throwing `isListedForRequest` keeps the tool listed (depends on T008) +- [X] T010 [P] Extend `api/src/slices/bridle/domain/bridle.types.ts`: on `IBridleThinkingStep` (`:124-133`) add `kind?: 'delegation'` and `delegation?: IBridleDelegationStep` where `IBridleDelegationStep { delegationId: string; peerAgentId: string; peerName: string; matchedSkills: { id: string; name: string }[]; reason: string; task: string; status: 'waiting' | 'answered' | 'failed' | 'rejected'; startedAt: number; durationMs?: number; excerpt?: string }`; add `IActiveTurn { clientId: string; turnId: string; ts: number }`; make the `thinking` member of `IBridleOutgoingEvent` (`:105-119`) carry `turnId`, `step?`, `done?` so API-side emitters need no cast +- [X] T011 Add `abstract findActiveTurn(agentId: string): IActiveTurn | null` to `api/src/slices/bridle/domain/bridle.gateway.ts` (doc: "most recent turn that has emitted a thinking step and not yet its terminal `done`; null when unknown") and implement in `api/src/slices/bridle/data/bridle.gateway.ts`: a `Map` keyed `${agentId}${clientId}`; in `handleAgentEvent` (`:236-244`) when `data.type === 'thinking'` set the entry on a step event and delete it on `done: true`; delete in `unregisterClient` (`:58`); `findActiveTurn` returns the entry with the greatest `ts` for that agent (depends on T010) +- [X] T012 Extend `api/src/slices/bridle/data/bridle.gateway.spec.ts` (or create it beside the gateway if absent): `findActiveTurn` is null before any thinking event, set after a step event, updated to the newest of two clients, cleared by `done: true`, cleared by `unregisterClient` (depends on T011) +- [X] T013 Create `api/src/slices/bridle/domain/bridleSync.service.ts` with `@Injectable() BridleSyncService` over `IBridleGateway`: `sendAndAwait(input: { agentId: string; clientId: string; text: string; parts?: BridlePart[]; attachments?; capabilities?: string[]; isAdmin?: boolean; timeoutMs?: number }): Promise<{ text: string; messageId: string; ts: number; timedOut: boolean }>` — the register-with-private-socket-id / resolve-on-`message`-or-`stream_end` / accumulate-`stream` / timeout / unregister logic lifted verbatim from `api/src/slices/bridle/bridle.controller.ts:341-379` (default timeout 120 000 from env `A2A_SYNC_TIMEOUT_MS` is **not** read here — the caller passes it); `timedOut: true` and `text = chunks.join('')` on timeout; `registerClient` receives the caller's `capabilities` (default `[]`) so a peer conversation never declares `thinking`; export from `domain/index.ts`; provide and export it in `api/src/slices/bridle/bridle.module.ts` +- [X] T014 Refactor `api/src/slices/bridle/bridle.controller.ts:335-402` (`sendMessageSync`) to call `BridleSyncService.sendAndAwait` with `capabilities: ['streaming']` and map `timedOut` to today's `'Timeout: no response from agent'` text so the HTTP behaviour is byte-identical; keep attachment expansion before the call (depends on T013) +- [X] T015 Write `api/src/slices/bridle/domain/bridleSync.service.spec.ts` with a hub stub that captures the registered callback: resolves on `message` with `{ text, messageId, ts }`; resolves on `stream_end` with the joined `stream` chunks; times out → `timedOut: true`, callback unregistered with the private socket id; `capabilities` passed through to `registerClient` (depends on T013); run `cd api && bun run test -- bridle.controller` to confirm the existing controller spec still passes after T014 +- [X] T016 Register `PeerModule` in `api/src/app.module.ts` imports next to `ShareLinkModule` and make `peer.module.ts` import `forwardRef(() => AgentModule)`, `TemplateModule`, `SkillModule`, `KnowledgeModule`, `BridleModule`, `SettingModule`; if `SkillModule`/`KnowledgeModule` do not export `ISkillGateway`/`IKnowledgeGateway` yet, add the export in their module files (`api/src/slices/skill/skill.module.ts`, `api/src/slices/reins/knowledge/knowledge.module.ts`) — verify with `cd api && bunx tsc --noEmit` (depends on T002, T005) **Checkpoint**: `cd api && bun run test -- mcp-tools bridleSync bridle.gateway bridle.controller infraConfig` green; migration applied; app boots with an empty `PeerModule`. From 217baa382bac5d012deabfaba6b28f623e03c0a5 Mon Sep 17 00:00:00 2001 From: "Maksym Hryzodub [DREAM]" Date: Mon, 14 Sep 2026 12:54:41 +0300 Subject: [PATCH 07/31] feat(peer): agent cards and the A2A server surface (CLEAN-74) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01SZ9dCrzZqd5SwR4erLYe12 --- .../slices/agent/peer/a2a.controller.spec.ts | 259 ++++++++++++++ api/src/slices/agent/peer/a2a.controller.ts | 157 +++++++++ .../peer/data/delegation.gateway.spec.ts | 185 ++++++++++ .../agent/peer/data/delegation.gateway.ts | 67 ++++ .../agent/peer/data/delegation.mapper.ts | 45 +++ api/src/slices/agent/peer/data/index.ts | 4 + .../agent/peer/data/peer.gateway.spec.ts | 234 +++++++++++++ .../slices/agent/peer/data/peer.gateway.ts | 84 +++++ api/src/slices/agent/peer/data/peer.mapper.ts | 26 ++ .../peer/domain/a2a.server.service.spec.ts | 331 ++++++++++++++++++ .../agent/peer/domain/a2a.server.service.ts | 286 +++++++++++++++ .../slices/agent/peer/domain/a2aTask.store.ts | 59 ++++ .../peer/domain/agentCard.service.spec.ts | 316 +++++++++++++++++ .../agent/peer/domain/agentCard.service.ts | 151 ++++++++ .../agent/peer/domain/delegation.gateway.ts | 25 ++ api/src/slices/agent/peer/domain/index.ts | 5 + .../slices/agent/peer/domain/peer.gateway.ts | 44 +++ .../agent/peer/guards/a2a.guards.spec.ts | 214 +++++++++++ .../slices/agent/peer/guards/a2a.guards.ts | 124 +++++++ api/src/slices/agent/peer/guards/index.ts | 1 + api/src/slices/agent/peer/peer.module.ts | 27 +- .../setup/error/rawResponse.decorator.ts | 16 + .../setup/error/response.interceptor.ts | 9 + specs/013-a2a-agent-peers/tasks.md | 30 +- 24 files changed, 2681 insertions(+), 18 deletions(-) create mode 100644 api/src/slices/agent/peer/a2a.controller.spec.ts create mode 100644 api/src/slices/agent/peer/a2a.controller.ts create mode 100644 api/src/slices/agent/peer/data/delegation.gateway.spec.ts create mode 100644 api/src/slices/agent/peer/data/delegation.gateway.ts create mode 100644 api/src/slices/agent/peer/data/delegation.mapper.ts create mode 100644 api/src/slices/agent/peer/data/index.ts create mode 100644 api/src/slices/agent/peer/data/peer.gateway.spec.ts create mode 100644 api/src/slices/agent/peer/data/peer.gateway.ts create mode 100644 api/src/slices/agent/peer/data/peer.mapper.ts create mode 100644 api/src/slices/agent/peer/domain/a2a.server.service.spec.ts create mode 100644 api/src/slices/agent/peer/domain/a2a.server.service.ts create mode 100644 api/src/slices/agent/peer/domain/a2aTask.store.ts create mode 100644 api/src/slices/agent/peer/domain/agentCard.service.spec.ts create mode 100644 api/src/slices/agent/peer/domain/agentCard.service.ts create mode 100644 api/src/slices/agent/peer/domain/delegation.gateway.ts create mode 100644 api/src/slices/agent/peer/domain/peer.gateway.ts create mode 100644 api/src/slices/agent/peer/guards/a2a.guards.spec.ts create mode 100644 api/src/slices/agent/peer/guards/a2a.guards.ts create mode 100644 api/src/slices/agent/peer/guards/index.ts create mode 100644 api/src/slices/setup/error/rawResponse.decorator.ts diff --git a/api/src/slices/agent/peer/a2a.controller.spec.ts b/api/src/slices/agent/peer/a2a.controller.spec.ts new file mode 100644 index 00000000..877250c5 --- /dev/null +++ b/api/src/slices/agent/peer/a2a.controller.spec.ts @@ -0,0 +1,259 @@ +import { Reflector } from '@nestjs/core'; +import { GUARDS_METADATA } from '@nestjs/common/constants'; +import { A2aController } from './a2a.controller'; +import { A2aCardGuard, A2aPeerGuard } from './guards/a2a.guards'; +import type { IA2aRequest } from './guards/a2a.guards'; +import { RAW_RESPONSE_METADATA_KEY } from '#/setup/error/rawResponse.decorator'; +import { + A2aErrorCodes, + A2aRpcError, + A2aTaskStates, + type IA2aTask, +} from './domain/a2a.types'; +import type { AgentCardService } from './domain/agentCard.service'; +import type { A2aServerService } from './domain/a2a.server.service'; + +/** + * The JSON-RPC edge. Its job is translation, so these cases are about shapes: + * a protocol error must arrive as a JSON-RPC error object (the spec's clients + * read `error.code`, not HTTP status), an unsupported-but-real method must be + * distinguishable from a typo, and neither route may be wrapped in Ranch's own + * `{ success, data }` envelope. + */ +const task = (id = 't1'): IA2aTask => ({ + id, + contextId: 'ctx-1', + status: { state: A2aTaskStates.Completed, timestamp: '2026-09-14T10:00:00Z' }, + artifacts: [{ artifactId: 'reply', parts: [{ text: 'the answer' }] }], + history: [], + metadata: { ranch: { chain: ['agent-a', 'agent-b'] } }, +}); + +function makeController(options: { + sendMessage?: () => Promise; + getTask?: () => IA2aTask; + knowsTask?: boolean; +} = {}) { + const cards = { + build: jest.fn(async () => ({ name: 'Support Bot' })), + } as unknown as AgentCardService; + + const server = { + sendMessage: jest.fn(options.sendMessage ?? (async () => task())), + getTask: jest.fn( + options.getTask ?? + (() => { + throw new A2aRpcError(A2aErrorCodes.TaskNotFound, 'Task not found'); + }), + ), + knowsTask: jest.fn(() => options.knowsTask ?? false), + } as unknown as A2aServerService; + + const controller = new A2aController(cards, server); + const request = { peer: { peerId: 'p1', callerAgentId: 'agent-a' } } as IA2aRequest; + + const rpc = (body: unknown) => + controller.rpc('agent-b', request, body, '1.0'); + // Separate on purpose: a default parameter would swallow the "no header + // at all" case, which is precisely the one the spec treats as A2A 0.3. + const rpcWithVersion = (body: unknown, version: string | undefined) => + controller.rpc('agent-b', request, body, version); + + return { controller, cards, server, rpc, rpcWithVersion }; +} + +const call = (method: string, params?: unknown, id: unknown = 1) => ({ + jsonrpc: '2.0', + id, + method, + ...(params === undefined ? {} : { params }), +}); + +describe('A2aController — wiring', () => { + it('guards the card with the card guard and the RPC route with the peer guard', () => { + const reflector = new Reflector(); + + expect( + reflector.get(GUARDS_METADATA, A2aController.prototype.card), + ).toEqual([A2aCardGuard]); + expect( + reflector.get(GUARDS_METADATA, A2aController.prototype.rpc), + ).toEqual([A2aPeerGuard]); + }); + + it('answers both routes outside the Ranch response envelope', () => { + const reflector = new Reflector(); + + expect( + reflector.get(RAW_RESPONSE_METADATA_KEY, A2aController.prototype.card), + ).toBe(true); + expect( + reflector.get(RAW_RESPONSE_METADATA_KEY, A2aController.prototype.rpc), + ).toBe(true); + }); + + it('hands back the card exactly as built', async () => { + const { controller, cards } = makeController(); + + await expect(controller.card('agent-b')).resolves.toEqual({ + name: 'Support Bot', + }); + expect(cards.build).toHaveBeenCalledWith('agent-b'); + }); +}); + +describe('A2aController — version handshake', () => { + it('refuses a request that does not say which protocol version it speaks', async () => { + const { rpcWithVersion } = makeController(); + + const response = await rpcWithVersion(call('SendMessage'), undefined); + + expect(response.error?.code).toBe(A2aErrorCodes.VersionNotSupported); + expect(response.result).toBeUndefined(); + }); + + it('refuses a version this server does not speak', async () => { + const { rpcWithVersion, server } = makeController(); + + const response = await rpcWithVersion(call('SendMessage'), '0.3.0'); + + expect(response.error?.code).toBe(A2aErrorCodes.VersionNotSupported); + expect(server.sendMessage).not.toHaveBeenCalled(); + }); +}); + +describe('A2aController — dispatch', () => { + it('sends a message on behalf of the agent the credential named', async () => { + const { rpc, server } = makeController(); + + const response = await rpc(call('SendMessage', { message: { parts: [] } })); + + expect(response.result).toEqual({ task: task() }); + expect(server.sendMessage).toHaveBeenCalledWith( + 'agent-b', + 'agent-a', + { message: { parts: [] } }, + ); + }); + + it('reports a failed task as a result, not as an error', async () => { + const failed: IA2aTask = { + ...task(), + status: { + state: A2aTaskStates.Failed, + timestamp: '2026-09-14T10:00:00Z', + message: { + messageId: 'm', + role: 'ROLE_AGENT', + parts: [{ text: 'peer not running' }], + }, + }, + artifacts: [], + }; + const { rpc } = makeController({ sendMessage: async () => failed }); + + const response = await rpc(call('SendMessage', { message: { parts: [] } })); + + // A failure of the work is still a successful protocol exchange — the + // caller has to be able to read the cause off the task. + expect(response.error).toBeUndefined(); + expect((response.result as { task: IA2aTask }).task.status.state).toBe( + A2aTaskStates.Failed, + ); + }); + + it('returns a known task', async () => { + const { rpc } = makeController({ getTask: () => task('t7') }); + + const response = await rpc(call('GetTask', { id: 't7' })); + + expect((response.result as { task: IA2aTask }).task.id).toBe('t7'); + }); + + it('says an unknown task is not found', async () => { + const { rpc } = makeController(); + + const response = await rpc(call('GetTask', { id: 'nope' })); + + expect(response.error?.code).toBe(A2aErrorCodes.TaskNotFound); + }); + + it('tells a cancel of an unknown task apart from one it cannot cancel', async () => { + const unknown = await makeController({ knowsTask: false }).rpc( + call('CancelTask', { id: 'x' }), + ); + const known = await makeController({ knowsTask: true }).rpc( + call('CancelTask', { id: 'x' }), + ); + + expect(unknown.error?.code).toBe(A2aErrorCodes.TaskNotFound); + expect(known.error?.code).toBe(A2aErrorCodes.TaskNotCancelable); + }); + + it('answers a real method it does not implement with "unsupported"', async () => { + const { rpc } = makeController(); + + const response = await rpc(call('SendStreamingMessage')); + + expect(response.error?.code).toBe(A2aErrorCodes.UnsupportedOperation); + expect(response.error?.message).toContain('SendStreamingMessage'); + }); + + it('answers every other unimplemented protocol method the same way', async () => { + const { rpc } = makeController(); + + for (const method of [ + 'SubscribeToTask', + 'ListTasks', + 'CreateTaskPushNotificationConfig', + 'GetExtendedAgentCard', + ]) { + const response = await rpc(call(method)); + expect(response.error?.code).toBe(A2aErrorCodes.UnsupportedOperation); + } + }); + + it('answers a method that is not in the protocol with "method not found"', async () => { + const { rpc } = makeController(); + + const response = await rpc(call('DoSomethingElse')); + + expect(response.error?.code).toBe(A2aErrorCodes.MethodNotFound); + }); +}); + +describe('A2aController — malformed input', () => { + it('refuses a body that is not a JSON-RPC request', async () => { + const { rpc } = makeController(); + + await expect(rpc({ hello: 'world' })).resolves.toMatchObject({ + error: { code: A2aErrorCodes.InvalidRequest }, + }); + await expect(rpc(null)).resolves.toMatchObject({ + error: { code: A2aErrorCodes.InvalidRequest }, + }); + }); + + it('echoes the request id back, including a null one', async () => { + const { rpc } = makeController(); + + await expect(rpc(call('DoSomethingElse', undefined, 'abc'))).resolves.toMatchObject({ + jsonrpc: '2.0', + id: 'abc', + }); + await expect(rpc({ hello: 'world' })).resolves.toMatchObject({ id: null }); + }); + + it('turns an unexpected failure into an internal error rather than a crash', async () => { + const { rpc } = makeController({ + sendMessage: async () => { + throw new Error('database on fire'); + }, + }); + + const response = await rpc(call('SendMessage', { message: { parts: [] } })); + + expect(response.error?.code).toBe(A2aErrorCodes.Internal); + expect(response.error?.message).toBe('database on fire'); + }); +}); diff --git a/api/src/slices/agent/peer/a2a.controller.ts b/api/src/slices/agent/peer/a2a.controller.ts new file mode 100644 index 00000000..3d8bad40 --- /dev/null +++ b/api/src/slices/agent/peer/a2a.controller.ts @@ -0,0 +1,157 @@ +import { + Body, + Controller, + Get, + Headers, + HttpCode, + Param, + Post, + Req, + UseGuards, +} from '@nestjs/common'; +import { ApiExcludeController } from '@nestjs/swagger'; +import { RawResponse } from '#/setup/error/rawResponse.decorator'; +import { AgentCardService } from './domain/agentCard.service'; +import { A2aServerService } from './domain/a2a.server.service'; +import { + A2A_VERSION, + A2aErrorCodes, + A2aMethods, + A2aRpcError, + A2A_KNOWN_METHODS, + type IA2aAgentCard, + type IJsonRpcResponse, + type JsonRpcId, +} from './domain/a2a.types'; +import { A2aCardGuard, A2aPeerGuard } from './guards/a2a.guards'; +import type { IA2aRequest } from './guards/a2a.guards'; + +/** + * The public A2A surface of every Ranch agent (CLEAN-74). + * + * Excluded from the OpenAPI document on purpose: this is not Ranch's API, it + * is the Agent2Agent protocol, and generating console SDK methods for it would + * invite our own frontends to call it. The contract that matters lives in the + * published spec and in `specs/013-a2a-agent-peers/contracts/a2a-api.md`. + * + * Both routes answer raw bodies — an agent card and JSON-RPC envelopes are + * shapes other implementations parse, so the `{ success, data }` wrapper would + * make this server unreadable to every standard client. + */ +@ApiExcludeController() +@Controller('a2a/agents') +export class A2aController { + constructor( + private readonly cards: AgentCardService, + private readonly server: A2aServerService, + ) {} + + /** Discovery. The path is the spec's, prefixed per agent because one host + * serves many agents and the domain root cannot name one of them. */ + @RawResponse() + @UseGuards(A2aCardGuard) + @Get(':agentId/.well-known/agent-card.json') + async card(@Param('agentId') agentId: string): Promise { + return this.cards.build(agentId); + } + + /** + * Everything else. One POST, JSON-RPC inside — so protocol errors travel as + * JSON-RPC error objects with a 200, which is what the spec's clients expect, + * while credential failures stay real HTTP 401s from the guard. + */ + @RawResponse() + @UseGuards(A2aPeerGuard) + @HttpCode(200) + @Post(':agentId') + async rpc( + @Param('agentId') agentId: string, + @Req() request: IA2aRequest, + @Body() body: unknown, + @Headers('a2a-version') version?: string, + ): Promise { + const envelope = (body ?? {}) as Record; + const id = (envelope.id ?? null) as JsonRpcId; + + try { + // An absent header means 0.3 per spec, whose field names differ enough + // that answering it with 1.0 shapes would be a silent mistranslation. + if (version !== A2A_VERSION) { + throw new A2aRpcError( + A2aErrorCodes.VersionNotSupported, + `This agent speaks A2A ${A2A_VERSION}; send the ${'A2A-Version'} header`, + ); + } + + if (envelope.jsonrpc !== '2.0' || typeof envelope.method !== 'string') { + throw new A2aRpcError( + A2aErrorCodes.InvalidRequest, + 'Not a JSON-RPC 2.0 request', + ); + } + + const result = await this.dispatch( + envelope.method, + envelope.params, + agentId, + request.peer?.callerAgentId ?? '', + ); + return { jsonrpc: '2.0', id, result }; + } catch (err) { + return { jsonrpc: '2.0', id, error: toRpcError(err) }; + } + } + + private async dispatch( + method: string, + params: unknown, + agentId: string, + callerAgentId: string, + ): Promise { + switch (method) { + case A2aMethods.SendMessage: { + const task = await this.server.sendMessage( + agentId, + callerAgentId, + params as never, + ); + return { task }; + } + case A2aMethods.GetTask: { + return { task: this.server.getTask(params as never) }; + } + case A2aMethods.CancelTask: { + const id = (params as { id?: string } | undefined)?.id ?? ''; + // Telling a caller "cannot be cancelled" about a task that does not + // exist would send it looking for a task it never had. + throw this.server.knowsTask(id) + ? new A2aRpcError( + A2aErrorCodes.TaskNotCancelable, + 'This agent answers synchronously; a task is over by the time you hold it', + ) + : new A2aRpcError(A2aErrorCodes.TaskNotFound, 'Task not found'); + } + default: + if (A2A_KNOWN_METHODS.includes(method)) { + throw new A2aRpcError( + A2aErrorCodes.UnsupportedOperation, + `This agent does not support ${method}`, + ); + } + throw new A2aRpcError( + A2aErrorCodes.MethodNotFound, + `Unknown method: ${method}`, + ); + } + } +} + +function toRpcError(err: unknown): { code: number; message: string } { + if (err instanceof A2aRpcError) { + return { code: err.code, message: err.message }; + } + return { + code: A2aErrorCodes.Internal, + message: err instanceof Error ? err.message : 'Internal error', + }; +} diff --git a/api/src/slices/agent/peer/data/delegation.gateway.spec.ts b/api/src/slices/agent/peer/data/delegation.gateway.spec.ts new file mode 100644 index 00000000..77942014 --- /dev/null +++ b/api/src/slices/agent/peer/data/delegation.gateway.spec.ts @@ -0,0 +1,185 @@ +import { DelegationGateway } from './delegation.gateway'; +import { DelegationMapper } from './delegation.mapper'; +import { DelegationErrorCodes, DelegationStatuses } from '../domain'; + +// In-memory Prisma stub in the shape of peer.gateway.spec's. +function makePrismaStub() { + const rows: Record> = {}; + let seq = 0; + + const agentDelegation = { + create: jest.fn(async ({ data }: { data: Record }) => { + const id = `del-${(seq += 1)}`; + rows[id] = { + id, + peerId: null, + turnId: null, + clientId: null, + matchedSkills: [], + status: 'waiting', + errorCode: null, + excerpt: null, + startedAt: new Date(1_000_000 + seq * 1000), + finishedAt: null, + durationMs: null, + ...data, + }; + return rows[id]; + }), + update: jest.fn( + async ({ + where, + data, + }: { + where: { id: string }; + data: Record; + }) => { + const row = rows[where.id]; + if (!row) { + throw Object.assign(new Error('Record to update not found'), { + code: 'P2025', + }); + } + Object.assign(row, data); + return row; + }, + ), + findMany: jest.fn( + async ({ + where, + orderBy, + take, + }: { + where: Record; + orderBy?: Record; + take?: number; + }) => { + let matches = Object.values(rows).filter( + (r) => r.agentId === where.agentId, + ); + if (orderBy?.startedAt === 'desc') { + matches = matches.sort( + (a, b) => b.startedAt.getTime() - a.startedAt.getTime(), + ); + } + return typeof take === 'number' ? matches.slice(0, take) : matches; + }, + ), + }; + + return { agentDelegation, rows }; +} + +function makeGateway() { + const prisma = makePrismaStub(); + const gateway = new DelegationGateway(prisma as never, new DelegationMapper()); + const start = (agentId = 'a', peerName = 'Support Bot') => + gateway.create({ + agentId, + peerId: 'peer-1', + peerAgentId: 'b', + peerName, + contextId: 'ctx-1', + turnId: 'turn-1', + clientId: 'admin', + task: 'What is the return window for shoes?', + reason: 'Support Bot holds the returns policy base', + matchedSkills: [{ id: 'knowledge:9a', name: 'Returns policy' }], + }); + return { gateway, prisma, start }; +} + +describe('DelegationGateway', () => { + it('opens a delegation in the waiting state with nothing decided yet', async () => { + const { start } = makeGateway(); + + const row = await start(); + + expect(row).toMatchObject({ + status: DelegationStatuses.Waiting, + errorCode: null, + excerpt: null, + finishedAt: null, + durationMs: null, + peerName: 'Support Bot', + matchedSkills: [{ id: 'knowledge:9a', name: 'Returns policy' }], + }); + }); + + it('records an answer with its duration', async () => { + const { gateway, start } = makeGateway(); + const row = await start(); + + const finished = await gateway.finish(row.id, { + status: DelegationStatuses.Answered, + excerpt: 'Shoes can be returned within 30 days', + finishedAt: new Date('2026-09-14T10:00:03.120Z'), + durationMs: 3120, + }); + + expect(finished).toMatchObject({ + status: 'answered', + errorCode: null, + excerpt: 'Shoes can be returned within 30 days', + finishedAt: '2026-09-14T10:00:03.120Z', + durationMs: 3120, + }); + }); + + it('records a failure with the cause that produced it', async () => { + const { gateway, start } = makeGateway(); + const row = await start(); + + const finished = await gateway.finish(row.id, { + status: DelegationStatuses.Failed, + errorCode: DelegationErrorCodes.NotRunning, + excerpt: 'peer not running', + finishedAt: new Date('2026-09-14T10:00:00.200Z'), + durationMs: 200, + }); + + expect(finished).toMatchObject({ + status: 'failed', + errorCode: 'PEER_NOT_RUNNING', + excerpt: 'peer not running', + }); + }); + + it('lists an agent delegations newest first', async () => { + const { gateway, start } = makeGateway(); + await start('a', 'first'); + await start('a', 'second'); + await start('a', 'third'); + + const recent = await gateway.listRecent('a', 10); + + expect(recent.map((d) => d.peerName)).toEqual(['third', 'second', 'first']); + }); + + it('honours the limit it is given', async () => { + const { gateway, start } = makeGateway(); + await start('a', 'first'); + await start('a', 'second'); + await start('a', 'third'); + + await expect(gateway.listRecent('a', 2)).resolves.toHaveLength(2); + }); + + it('keeps one agent delegations out of another list', async () => { + const { gateway, start } = makeGateway(); + await start('a'); + await start('other'); + + await expect(gateway.listRecent('a', 10)).resolves.toHaveLength(1); + }); + + it('reads a hand-edited skills column as no skills rather than breaking', async () => { + const { gateway, prisma, start } = makeGateway(); + const row = await start(); + prisma.rows[row.id].matchedSkills = 'not an array'; + + const [listed] = await gateway.listRecent('a', 10); + + expect(listed.matchedSkills).toEqual([]); + }); +}); diff --git a/api/src/slices/agent/peer/data/delegation.gateway.ts b/api/src/slices/agent/peer/data/delegation.gateway.ts new file mode 100644 index 00000000..9f32ef1d --- /dev/null +++ b/api/src/slices/agent/peer/data/delegation.gateway.ts @@ -0,0 +1,67 @@ +import { Injectable } from '@nestjs/common'; +import type { Prisma } from '@prisma/client'; +import { PrismaService } from '#/setup/prisma/prisma.service'; +import { + IAgentDelegationData, + ICreateDelegationData, + IDelegationGateway, + IFinishDelegationData, +} from '../domain'; +import { DelegationMapper } from './delegation.mapper'; + +@Injectable() +export class DelegationGateway extends IDelegationGateway { + constructor( + private prisma: PrismaService, + private mapper: DelegationMapper, + ) { + super(); + } + + async create(input: ICreateDelegationData): Promise { + const record = await this.prisma.agentDelegation.create({ + data: { + agentId: input.agentId, + peerId: input.peerId, + peerAgentId: input.peerAgentId, + peerName: input.peerName, + contextId: input.contextId, + turnId: input.turnId ?? null, + clientId: input.clientId ?? null, + task: input.task, + reason: input.reason, + matchedSkills: input.matchedSkills as unknown as Prisma.InputJsonValue, + }, + }); + return this.mapper.toEntity(record); + } + + async finish( + id: string, + input: IFinishDelegationData, + ): Promise { + const record = await this.prisma.agentDelegation.update({ + where: { id }, + data: { + status: input.status, + errorCode: input.errorCode ?? null, + excerpt: input.excerpt ?? null, + finishedAt: input.finishedAt, + durationMs: input.durationMs, + }, + }); + return this.mapper.toEntity(record); + } + + async listRecent( + agentId: string, + limit: number, + ): Promise { + const records = await this.prisma.agentDelegation.findMany({ + where: { agentId }, + orderBy: { startedAt: 'desc' }, + take: limit, + }); + return records.map((record) => this.mapper.toEntity(record)); + } +} diff --git a/api/src/slices/agent/peer/data/delegation.mapper.ts b/api/src/slices/agent/peer/data/delegation.mapper.ts new file mode 100644 index 00000000..a479d6ae --- /dev/null +++ b/api/src/slices/agent/peer/data/delegation.mapper.ts @@ -0,0 +1,45 @@ +import { Injectable } from '@nestjs/common'; +import { AgentDelegation } from '@prisma/client'; +import type { + DelegationErrorCode, + DelegationStatus, + IAgentDelegationData, + IMatchedSkill, +} from '../domain'; + +@Injectable() +export class DelegationMapper { + toEntity(record: AgentDelegation): IAgentDelegationData { + return { + id: record.id, + agentId: record.agentId, + peerId: record.peerId, + peerAgentId: record.peerAgentId, + peerName: record.peerName, + contextId: record.contextId, + turnId: record.turnId, + clientId: record.clientId, + task: record.task, + reason: record.reason, + matchedSkills: toSkills(record.matchedSkills), + status: record.status as DelegationStatus, + errorCode: (record.errorCode as DelegationErrorCode | null) ?? null, + excerpt: record.excerpt, + startedAt: record.startedAt.toISOString(), + finishedAt: record.finishedAt ? record.finishedAt.toISOString() : null, + durationMs: record.durationMs, + }; + } +} + +/** Json in, `[{ id, name }]` out. Anything unrecognisable reads as none, so a + * hand-edited row can never break the Recent delegations list. */ +function toSkills(value: unknown): IMatchedSkill[] { + if (!Array.isArray(value)) return []; + return value.flatMap((entry) => { + if (!entry || typeof entry !== 'object') return []; + const { id, name } = entry as Record; + if (typeof id !== 'string' || typeof name !== 'string') return []; + return [{ id, name }]; + }); +} diff --git a/api/src/slices/agent/peer/data/index.ts b/api/src/slices/agent/peer/data/index.ts new file mode 100644 index 00000000..d1dfdaaa --- /dev/null +++ b/api/src/slices/agent/peer/data/index.ts @@ -0,0 +1,4 @@ +export * from './peer.mapper'; +export * from './delegation.mapper'; +export * from './peer.gateway'; +export * from './delegation.gateway'; diff --git a/api/src/slices/agent/peer/data/peer.gateway.spec.ts b/api/src/slices/agent/peer/data/peer.gateway.spec.ts new file mode 100644 index 00000000..126e8d61 --- /dev/null +++ b/api/src/slices/agent/peer/data/peer.gateway.spec.ts @@ -0,0 +1,234 @@ +import { PeerGateway } from './peer.gateway'; +import { PeerMapper } from './peer.mapper'; +import type { IA2aAgentCard } from '../domain'; + +// In-memory Prisma stub in the shape of shareLink.gateway.spec's. It enforces +// both unique constraints — `token` and the composite (agentId, peerAgentId) — +// so "one connection per direction" and "a credential belongs to one pair" are +// exercised here rather than assumed of the database. +function makePrismaStub() { + const rows: Record> = {}; + let clock = 0; + const tick = () => new Date((clock += 1000)); + + const find = (where: Record) => + Object.values(rows).find((r) => { + if (where.id !== undefined) return r.id === where.id; + if (where.token !== undefined) return r.token === where.token; + if (where.agentId_peerAgentId !== undefined) { + const { agentId, peerAgentId } = where.agentId_peerAgentId; + return r.agentId === agentId && r.peerAgentId === peerAgentId; + } + return false; + }) ?? null; + + const agentPeer = { + findUnique: jest.fn(async ({ where }: { where: Record }) => + find(where), + ), + findMany: jest.fn( + async ({ + where, + orderBy, + }: { + where: Record; + orderBy?: Record; + }) => { + const matches = Object.values(rows).filter( + (r) => r.agentId === where.agentId, + ); + if (orderBy?.createdAt === 'asc') { + matches.sort( + (a, b) => a.createdAt.getTime() - b.createdAt.getTime(), + ); + } + return matches; + }, + ), + create: jest.fn(async ({ data }: { data: Record }) => { + const clash = Object.values(rows).find( + (r) => + r.token === data.token || + (r.agentId === data.agentId && r.peerAgentId === data.peerAgentId), + ); + if (clash) { + throw Object.assign(new Error('Unique constraint failed'), { + code: 'P2002', + }); + } + const at = tick(); + const id = `peer-${Object.keys(rows).length + 1}`; + rows[id] = { id, createdAt: at, updatedAt: at, ...data }; + return rows[id]; + }), + update: jest.fn( + async ({ + where, + data, + }: { + where: Record; + data: Record; + }) => { + const row = find(where); + if (!row) { + throw Object.assign(new Error('Record to update not found'), { + code: 'P2025', + }); + } + Object.assign(row, data); + row.updatedAt = tick(); + return row; + }, + ), + delete: jest.fn(async ({ where }: { where: Record }) => { + const row = find(where); + if (!row) { + throw Object.assign(new Error('Record to delete does not exist'), { + code: 'P2025', + }); + } + delete rows[row.id as string]; + return row; + }), + }; + + return { agentPeer, rows }; +} + +const card = (name: string): IA2aAgentCard => ({ + name, + description: `${name} does things`, + version: '1', + supportedInterfaces: [ + { + url: `https://api.test/a2a/agents/${name}`, + protocolBinding: 'JSONRPC', + protocolVersion: '1.0', + }, + ], + capabilities: { streaming: false, pushNotifications: false, extensions: [] }, + defaultInputModes: ['text/plain'], + defaultOutputModes: ['text/plain'], + skills: [ + { id: 'skill:1', name: 'Order lookup', description: 'Finds orders', tags: ['skill'] }, + ], +}); + +function makeGateway() { + const prisma = makePrismaStub(); + const gateway = new PeerGateway(prisma as never, new PeerMapper()); + const connect = (agentId: string, peerAgentId: string, token: string) => + gateway.create({ + agentId, + peerAgentId, + token, + cardSnapshot: card(peerAgentId), + cardUrl: `https://api.test/a2a/agents/${peerAgentId}/.well-known/agent-card.json`, + cardReadAt: new Date('2026-09-14T10:00:00.000Z'), + }); + return { gateway, prisma, connect }; +} + +describe('PeerGateway', () => { + it('stores a connection and reads it back with its card', async () => { + const { gateway, connect } = makeGateway(); + + const created = await connect('a', 'b', 'ap_token_b'); + + expect(created).toMatchObject({ + agentId: 'a', + peerAgentId: 'b', + token: 'ap_token_b', + cardReadAt: '2026-09-14T10:00:00.000Z', + }); + expect(created.cardSnapshot.skills[0].name).toBe('Order lookup'); + await expect(gateway.findById(created.id)).resolves.toMatchObject({ + id: created.id, + }); + }); + + it('resolves a presented credential to the pair it was issued for', async () => { + const { gateway, connect } = makeGateway(); + await connect('a', 'b', 'ap_token_b'); + + const found = await gateway.findByToken('ap_token_b'); + + expect(found).toMatchObject({ agentId: 'a', peerAgentId: 'b' }); + await expect(gateway.findByToken('ap_unknown')).resolves.toBeNull(); + }); + + it('lists only the connections this agent holds, not the ones pointing at it', async () => { + const { gateway, connect } = makeGateway(); + await connect('a', 'b', 'ap_1'); + await connect('c', 'a', 'ap_2'); + + await expect(gateway.listByAgent('a')).resolves.toHaveLength(1); + await expect(gateway.listByAgent('a')).resolves.toMatchObject([ + { peerAgentId: 'b' }, + ]); + }); + + it('refuses a second connection to the same peer', async () => { + const { connect } = makeGateway(); + await connect('a', 'b', 'ap_1'); + + await expect(connect('a', 'b', 'ap_2')).rejects.toMatchObject({ + code: 'P2002', + }); + }); + + it('allows the opposite direction as its own connection', async () => { + const { gateway, connect } = makeGateway(); + await connect('a', 'b', 'ap_1'); + + await expect(connect('b', 'a', 'ap_2')).resolves.toMatchObject({ + agentId: 'b', + peerAgentId: 'a', + }); + await expect(gateway.listByAgent('b')).resolves.toHaveLength(1); + }); + + it('refuses to reuse one credential for two pairs', async () => { + const { connect } = makeGateway(); + await connect('a', 'b', 'ap_same'); + + await expect(connect('a', 'c', 'ap_same')).rejects.toMatchObject({ + code: 'P2002', + }); + }); + + it('replaces the snapshot on refresh and leaves the credential alone', async () => { + const { gateway, connect } = makeGateway(); + const created = await connect('a', 'b', 'ap_1'); + + const refreshed = await gateway.updateSnapshot(created.id, { + cardSnapshot: { ...card('b'), description: 'a new description' }, + cardUrl: created.cardUrl, + cardReadAt: new Date('2026-09-14T12:00:00.000Z'), + }); + + expect(refreshed.cardSnapshot.description).toBe('a new description'); + expect(refreshed.cardReadAt).toBe('2026-09-14T12:00:00.000Z'); + expect(refreshed.token).toBe('ap_1'); + }); + + it('kills the credential when the connection is removed', async () => { + const { gateway, connect } = makeGateway(); + const created = await connect('a', 'b', 'ap_1'); + + await gateway.delete(created.id); + + await expect(gateway.findByToken('ap_1')).resolves.toBeNull(); + await expect(gateway.listByAgent('a')).resolves.toEqual([]); + }); + + it('finds a connection by the pair it belongs to', async () => { + const { gateway, connect } = makeGateway(); + await connect('a', 'b', 'ap_1'); + + await expect(gateway.findByPair('a', 'b')).resolves.toMatchObject({ + token: 'ap_1', + }); + await expect(gateway.findByPair('b', 'a')).resolves.toBeNull(); + }); +}); diff --git a/api/src/slices/agent/peer/data/peer.gateway.ts b/api/src/slices/agent/peer/data/peer.gateway.ts new file mode 100644 index 00000000..6d635d1a --- /dev/null +++ b/api/src/slices/agent/peer/data/peer.gateway.ts @@ -0,0 +1,84 @@ +import { Injectable } from '@nestjs/common'; +import type { Prisma } from '@prisma/client'; +import { PrismaService } from '#/setup/prisma/prisma.service'; +import { + IAgentPeerData, + ICreatePeerInput, + IPeerGateway, + IUpdateSnapshotInput, +} from '../domain'; +import { PeerMapper } from './peer.mapper'; + +@Injectable() +export class PeerGateway extends IPeerGateway { + constructor( + private prisma: PrismaService, + private mapper: PeerMapper, + ) { + super(); + } + + async listByAgent(agentId: string): Promise { + const records = await this.prisma.agentPeer.findMany({ + where: { agentId }, + orderBy: { createdAt: 'asc' }, + }); + return records.map((record) => this.mapper.toEntity(record)); + } + + async findById(id: string): Promise { + const record = await this.prisma.agentPeer.findUnique({ where: { id } }); + return record ? this.mapper.toEntity(record) : null; + } + + async findByPair( + agentId: string, + peerAgentId: string, + ): Promise { + const record = await this.prisma.agentPeer.findUnique({ + where: { agentId_peerAgentId: { agentId, peerAgentId } }, + }); + return record ? this.mapper.toEntity(record) : null; + } + + async findByToken(token: string): Promise { + const record = await this.prisma.agentPeer.findUnique({ where: { token } }); + return record ? this.mapper.toEntity(record) : null; + } + + // `@@unique([agentId, peerAgentId])` turns a concurrent double-connect into + // a P2002 instead of two connections to the same peer; the caller decides + // what to do with it. + async create(input: ICreatePeerInput): Promise { + const record = await this.prisma.agentPeer.create({ + data: { + agentId: input.agentId, + peerAgentId: input.peerAgentId, + token: input.token, + cardSnapshot: input.cardSnapshot as unknown as Prisma.InputJsonValue, + cardUrl: input.cardUrl, + cardReadAt: input.cardReadAt, + }, + }); + return this.mapper.toEntity(record); + } + + async updateSnapshot( + id: string, + input: IUpdateSnapshotInput, + ): Promise { + const record = await this.prisma.agentPeer.update({ + where: { id }, + data: { + cardSnapshot: input.cardSnapshot as unknown as Prisma.InputJsonValue, + cardUrl: input.cardUrl, + cardReadAt: input.cardReadAt, + }, + }); + return this.mapper.toEntity(record); + } + + async delete(id: string): Promise { + await this.prisma.agentPeer.delete({ where: { id } }); + } +} diff --git a/api/src/slices/agent/peer/data/peer.mapper.ts b/api/src/slices/agent/peer/data/peer.mapper.ts new file mode 100644 index 00000000..4a375557 --- /dev/null +++ b/api/src/slices/agent/peer/data/peer.mapper.ts @@ -0,0 +1,26 @@ +import { Injectable } from '@nestjs/common'; +import { AgentPeer } from '@prisma/client'; +import type { IAgentPeerData } from '../domain'; +import type { IA2aAgentCard } from '../domain'; + +@Injectable() +export class PeerMapper { + // Prisma record → domain entity. Dates cross the boundary as ISO strings so + // the domain (and everything downstream of it) never juggles Date objects. + toEntity(record: AgentPeer): IAgentPeerData { + return { + id: record.id, + agentId: record.agentId, + peerAgentId: record.peerAgentId, + token: record.token, + // Stored as Json: Prisma types it as JsonValue, but every write goes + // through this slice and writes a card, so the cast is the boundary + // where that guarantee is stated once. + cardSnapshot: record.cardSnapshot as unknown as IA2aAgentCard, + cardUrl: record.cardUrl, + cardReadAt: record.cardReadAt.toISOString(), + createdAt: record.createdAt.toISOString(), + updatedAt: record.updatedAt.toISOString(), + }; + } +} diff --git a/api/src/slices/agent/peer/domain/a2a.server.service.spec.ts b/api/src/slices/agent/peer/domain/a2a.server.service.spec.ts new file mode 100644 index 00000000..03c99b8b --- /dev/null +++ b/api/src/slices/agent/peer/domain/a2a.server.service.spec.ts @@ -0,0 +1,331 @@ +import type { ConfigService } from '@nestjs/config'; +import { A2aServerService } from './a2a.server.service'; +import { A2aTaskStore } from './a2aTask.store'; +import { A2aErrorCodes, A2aRpcError, A2aTaskStates } from './a2a.types'; +import type { IA2aSendMessageParams } from './a2a.types'; +import type { IBridleGateway } from '#/bridle/domain/bridle.gateway'; +import type { BridleSyncService } from '#/bridle/domain/bridleSync.service'; +import type { IAgentGateway } from '#/agent/agent/domain'; + +/** + * The receiving half of a delegation. Every case here exists because the + * caller is a language model: a silence, an empty answer or a fabricated one + * would all be indistinguishable to it from a real reply, so each failure has + * to come back as a task state with a cause written in words. + */ +function makeHarness(options: { + connected?: boolean; + reply?: { text: string; timedOut?: boolean }; + env?: Record; + agentName?: string; +} = {}) { + const connected = options.connected ?? true; + const sendAndAwait = jest.fn(async (_input: { capabilities?: string[] }) => ({ + text: options.reply?.text ?? 'the answer', + messageId: 'm1', + ts: 1, + timedOut: options.reply?.timedOut ?? false, + })); + + const hub = { + isAgentConnected: jest.fn(() => connected), + } as unknown as IBridleGateway; + + const agents = { + findById: jest.fn(async () => ({ + id: 'agent-b', + name: options.agentName ?? 'Support Bot', + })), + } as unknown as IAgentGateway; + + const store = new A2aTaskStore(); + const config = { + get: (key: string) => options.env?.[key], + } as unknown as ConfigService; + + const service = new A2aServerService( + hub, + { sendAndAwait } as unknown as BridleSyncService, + agents, + store, + config, + ); + + return { service, sendAndAwait, hub, store }; +} + +const params = ( + overrides: Partial = {}, + configuration?: IA2aSendMessageParams['configuration'], +): IA2aSendMessageParams => ({ + message: { + messageId: 'm-in', + role: 'ROLE_USER', + parts: [{ text: 'What is the return window for shoes?' }], + ...overrides, + }, + ...(configuration ? { configuration } : {}), +}); + +const withChain = (chain: string[]) => + params({ metadata: { ranch: { chain } } }); + +describe('A2aServerService.sendMessage — the happy path', () => { + it('answers a completed task carrying the reply as one artifact', async () => { + const { service } = makeHarness({ reply: { text: 'Within 30 days.' } }); + + const task = await service.sendMessage('agent-b', 'agent-a', params()); + + expect(task.status.state).toBe(A2aTaskStates.Completed); + expect(task.artifacts).toHaveLength(1); + expect(task.artifacts[0]).toMatchObject({ + artifactId: 'reply', + parts: [{ text: 'Within 30 days.' }], + }); + }); + + it('talks to the runtime as one conversation per caller and context', async () => { + const { service, sendAndAwait } = makeHarness(); + + await service.sendMessage( + 'agent-b', + 'agent-a', + params({ contextId: 'ctx-42' }), + ); + + expect(sendAndAwait).toHaveBeenCalledWith( + expect.objectContaining({ + agentId: 'agent-b', + clientId: 'peer:agent-a:ctx-42', + text: 'What is the return window for shoes?', + }), + ); + }); + + it('declares no capabilities, so the peer streams no timeline into a void', async () => { + const { service, sendAndAwait } = makeHarness(); + + await service.sendMessage('agent-b', 'agent-a', params()); + + expect(sendAndAwait).toHaveBeenCalledWith( + expect.objectContaining({ capabilities: [] }), + ); + }); + + it('mints a context when the caller supplies none, and keeps a given one', async () => { + const { service } = makeHarness(); + + const minted = await service.sendMessage('agent-b', 'agent-a', params()); + const given = await service.sendMessage( + 'agent-b', + 'agent-a', + params({ contextId: 'ctx-42' }), + ); + + expect(minted.contextId).toMatch(/^ctx-/); + expect(given.contextId).toBe('ctx-42'); + }); + + it('appends itself to the chain it was called with', async () => { + const { service } = makeHarness(); + + const task = await service.sendMessage( + 'agent-b', + 'agent-a', + withChain(['agent-a']), + ); + + expect(task.metadata?.ranch?.chain).toEqual(['agent-a', 'agent-b']); + }); + + it('hands back no history: a peer transcript is its own business', async () => { + const { service } = makeHarness(); + + const task = await service.sendMessage('agent-b', 'agent-a', params()); + + expect(task.history).toEqual([]); + }); + + it('publishes the chain while it serves, so a second hop can continue it', async () => { + const seen: string[][] = []; + const { service, sendAndAwait } = makeHarness(); + sendAndAwait.mockImplementation(async (_input) => { + seen.push(service.currentChain('agent-b')); + return { text: 'ok', messageId: 'm', ts: 1, timedOut: false }; + }); + + await service.sendMessage('agent-b', 'agent-a', withChain(['agent-a'])); + + expect(seen).toEqual([['agent-a', 'agent-b']]); + // …and stops publishing it the moment the turn is over. + expect(service.currentChain('agent-b')).toEqual([]); + }); +}); + +describe('A2aServerService.sendMessage — honest failures', () => { + it('fails fast when the peer is not running, without waiting or sending', async () => { + const { service, sendAndAwait } = makeHarness({ connected: false }); + + const task = await service.sendMessage('agent-b', 'agent-a', params()); + + expect(task.status.state).toBe(A2aTaskStates.Failed); + expect(task.metadata?.ranch?.failure).toBe('not_running'); + expect(task.status.message?.parts[0]).toEqual({ text: 'peer not running' }); + expect(task.artifacts).toEqual([]); + expect(sendAndAwait).not.toHaveBeenCalled(); + }); + + it('fails with a stated timeout rather than an empty answer', async () => { + const { service } = makeHarness({ + reply: { text: '', timedOut: true }, + env: { A2A_SYNC_TIMEOUT_MS: '5000' }, + }); + + const task = await service.sendMessage('agent-b', 'agent-a', params()); + + expect(task.status.state).toBe(A2aTaskStates.Failed); + expect(task.metadata?.ranch?.failure).toBe('timeout'); + expect(task.status.message?.parts[0]).toEqual({ + text: 'timed out after 5s', + }); + }); + + it('rejects a task that would put an agent back into its own chain', async () => { + const { service, sendAndAwait } = makeHarness({ agentName: 'Support Bot' }); + + const task = await service.sendMessage( + 'agent-b', + 'agent-a', + withChain(['agent-b', 'agent-a']), + ); + + expect(task.status.state).toBe(A2aTaskStates.Rejected); + expect(task.metadata?.ranch?.rejection).toBe('loop'); + expect(task.status.message?.parts[0]).toEqual({ + text: 'would loop: «Support Bot» is already in this chain', + }); + expect(sendAndAwait).not.toHaveBeenCalled(); + }); + + it('rejects a chain that has already used up its hops', async () => { + const { service, sendAndAwait } = makeHarness(); + + const task = await service.sendMessage( + 'agent-b', + 'agent-a', + withChain(['a1', 'a2', 'a3']), + ); + + expect(task.status.state).toBe(A2aTaskStates.Rejected); + expect(task.metadata?.ranch?.rejection).toBe('depth'); + expect(task.status.message?.parts[0]).toEqual({ + text: 'too deep: the chain limit is 3 hops', + }); + expect(sendAndAwait).not.toHaveBeenCalled(); + }); + + it('allows a chain that still has a hop left', async () => { + const { service } = makeHarness(); + + const task = await service.sendMessage( + 'agent-b', + 'agent-a', + withChain(['a1', 'a2']), + ); + + expect(task.status.state).toBe(A2aTaskStates.Completed); + }); + + it('honours a configured chain limit', async () => { + const { service } = makeHarness({ env: { A2A_MAX_CHAIN: '1' } }); + + const task = await service.sendMessage( + 'agent-b', + 'agent-a', + withChain(['agent-a']), + ); + + expect(task.metadata?.ranch?.rejection).toBe('depth'); + }); + + it('ignores a chain a caller filled with nonsense', async () => { + const { service } = makeHarness(); + + const task = await service.sendMessage('agent-b', 'agent-a', { + message: { + messageId: 'm', + role: 'ROLE_USER', + parts: [{ text: 'hi' }], + metadata: { ranch: { chain: 'not-an-array' } }, + }, + } as unknown as IA2aSendMessageParams); + + expect(task.status.state).toBe(A2aTaskStates.Completed); + expect(task.metadata?.ranch?.chain).toEqual(['agent-b']); + }); +}); + +describe('A2aServerService.sendMessage — what it will not accept', () => { + it('refuses a non-blocking request instead of promising a push it cannot make', async () => { + const { service } = makeHarness(); + + await expect( + service.sendMessage('agent-b', 'agent-a', params({}, { + returnImmediately: true, + })), + ).rejects.toMatchObject({ code: A2aErrorCodes.UnsupportedOperation }); + }); + + it('refuses parts it cannot read', async () => { + const { service } = makeHarness(); + + await expect( + service.sendMessage( + 'agent-b', + 'agent-a', + params({ parts: [{ raw: 'ZmlsZQ==' }] }), + ), + ).rejects.toMatchObject({ + code: A2aErrorCodes.ContentTypeNotSupported, + }); + }); + + it('refuses a request with no message at all', async () => { + const { service } = makeHarness(); + + await expect( + service.sendMessage('agent-b', 'agent-a', {} as IA2aSendMessageParams), + ).rejects.toMatchObject({ code: A2aErrorCodes.InvalidParams }); + }); +}); + +describe('A2aServerService.getTask', () => { + it('hands back a task it answered', async () => { + const { service } = makeHarness(); + const task = await service.sendMessage('agent-b', 'agent-a', params()); + + expect(service.getTask({ id: task.id })).toMatchObject({ id: task.id }); + expect(service.knowsTask(task.id)).toBe(true); + }); + + it('says a task it never answered is not found', () => { + const { service } = makeHarness(); + + expect(() => service.getTask({ id: 'nope' })).toThrow(A2aRpcError); + try { + service.getTask({ id: 'nope' }); + } catch (err) { + expect((err as A2aRpcError).code).toBe(A2aErrorCodes.TaskNotFound); + } + expect(service.knowsTask('nope')).toBe(false); + }); + + it('remembers failed tasks too, not only the ones that worked', async () => { + const { service } = makeHarness({ connected: false }); + const task = await service.sendMessage('agent-b', 'agent-a', params()); + + expect(service.getTask({ id: task.id }).status.state).toBe( + A2aTaskStates.Failed, + ); + }); +}); diff --git a/api/src/slices/agent/peer/domain/a2a.server.service.ts b/api/src/slices/agent/peer/domain/a2a.server.service.ts new file mode 100644 index 00000000..91eeff8b --- /dev/null +++ b/api/src/slices/agent/peer/domain/a2a.server.service.ts @@ -0,0 +1,286 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import * as crypto from 'crypto'; +import { IBridleGateway } from '#/bridle/domain/bridle.gateway'; +import { BridleSyncService } from '#/bridle/domain/bridleSync.service'; +import { IAgentGateway } from '#/agent/agent/domain'; +import { + A2aErrorCodes, + A2aRoles, + A2aRpcError, + A2aTaskStates, + a2aTimestamp, + hasNonTextPart, + textOfParts, + type IA2aGetTaskParams, + type IA2aMessage, + type IA2aSendMessageParams, + type IA2aTask, + type IA2aTaskStatus, + type IRanchTaskMetadata, +} from './a2a.types'; +import { + DEFAULT_A2A_TIMEOUT_MS, + DEFAULT_MAX_CHAIN, + peerClientId, +} from './peer.types'; +import { A2aTaskStore } from './a2aTask.store'; + +export interface IChainRejection { + rejection: 'loop' | 'depth'; + message: string; +} + +/** + * The receiving half of A2A (CLEAN-74): an inbound task from a peer becomes + * one synchronous conversation with this agent's runtime, and comes back as a + * finished task. + * + * Every failure mode here is a *stated* task state rather than an exception, + * because the caller is a language model that will otherwise fill the silence + * itself. "peer not running" and "timed out" must arrive as facts it can + * repeat to the person, never as an empty answer. + */ +@Injectable() +export class A2aServerService { + private readonly logger = new Logger(A2aServerService.name); + + /** + * Chains of requests currently being served, per agent. `ask_agent` reads + * this to learn the chain it is a continuation of: a tool call carries no + * protocol metadata of its own, so without it a second hop would start a + * fresh chain and the depth rule would never bite. + */ + private readonly inboundChains = new Map(); + + constructor( + private readonly hub: IBridleGateway, + private readonly sync: BridleSyncService, + private readonly agents: IAgentGateway, + private readonly tasks: A2aTaskStore, + private readonly config: ConfigService, + ) {} + + get timeoutMs(): number { + const raw = Number(this.config.get('A2A_SYNC_TIMEOUT_MS')); + return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_A2A_TIMEOUT_MS; + } + + get maxChain(): number { + const raw = Number(this.config.get('A2A_MAX_CHAIN')); + return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_MAX_CHAIN; + } + + /** The chain of the request this agent is currently serving, if any. */ + currentChain(agentId: string): string[] { + return this.inboundChains.get(agentId) ?? []; + } + + async sendMessage( + agentId: string, + callerAgentId: string, + params: IA2aSendMessageParams, + ): Promise { + const message = params?.message; + if (!message || !Array.isArray(message.parts)) { + throw new A2aRpcError( + A2aErrorCodes.InvalidParams, + 'A message with parts is required', + ); + } + + // Answering later over a channel this server does not have would be worse + // than saying no: the caller would wait for a push that never comes. + if (params.configuration?.returnImmediately === true) { + throw new A2aRpcError( + A2aErrorCodes.UnsupportedOperation, + 'This agent answers blocking requests only', + ); + } + + if (hasNonTextPart(message.parts)) { + throw new A2aRpcError( + A2aErrorCodes.ContentTypeNotSupported, + 'This agent accepts text parts only', + ); + } + + const contextId = message.contextId ?? `ctx-${crypto.randomUUID()}`; + const inboundChain = readChain(message); + const startedAt = Date.now(); + + const rejection = await this.checkChain(inboundChain, agentId); + if (rejection) { + return this.finish( + agentId, + contextId, + inboundChain, + A2aTaskStates.Rejected, + rejection.message, + { rejection: rejection.rejection }, + startedAt, + ); + } + + // Asked before sending: the hub answers an offline agent with a fabricated + // "Agent is not connected" assistant message, which would reach the caller + // as if the peer had said it. Checking first turns that into a failure the + // caller must report, and saves it the full wait. + if (!this.hub.isAgentConnected(agentId)) { + return this.finish( + agentId, + contextId, + inboundChain, + A2aTaskStates.Failed, + 'peer not running', + { failure: 'not_running' }, + startedAt, + ); + } + + const chainForThisHop = [...inboundChain, agentId]; + this.inboundChains.set(agentId, chainForThisHop); + try { + const reply = await this.sync.sendAndAwait({ + agentId, + // One conversation per (caller, context): follow-ups within a turn + // keep their history, and nothing collides with a human's chat. + clientId: peerClientId(callerAgentId, contextId), + text: textOfParts(message.parts), + // A peer cannot render a thinking timeline, so the runtime must not + // stream one into a void. + capabilities: [], + timeoutMs: this.timeoutMs, + }); + + if (reply.timedOut) { + return this.finish( + agentId, + contextId, + inboundChain, + A2aTaskStates.Failed, + `timed out after ${Math.round(this.timeoutMs / 1000)}s`, + { failure: 'timeout' }, + startedAt, + ); + } + + return this.finish( + agentId, + contextId, + inboundChain, + A2aTaskStates.Completed, + undefined, + {}, + startedAt, + reply.text, + ); + } finally { + this.inboundChains.delete(agentId); + } + } + + getTask(params: IA2aGetTaskParams): IA2aTask { + const task = params?.id ? this.tasks.get(params.id) : null; + if (!task) { + throw new A2aRpcError(A2aErrorCodes.TaskNotFound, 'Task not found'); + } + return task; + } + + /** True when this server knows the task — decides -32002 vs -32001. */ + knowsTask(id: string): boolean { + return this.tasks.get(id) !== null; + } + + /** + * Loop and depth rules (FR-012), applied on the RECEIVING side of every hop. + * That placement is the point: the caller's own tool is itself a server on + * the next hop, so one implementation covers both directions and an agent + * cannot talk its way past the rule by being the one who asks. + */ + private async checkChain( + chain: string[], + agentId: string, + ): Promise { + if (chain.includes(agentId)) { + const agent = await this.agents.findById(agentId); + const name = agent?.name ?? agentId; + return { + rejection: 'loop', + message: `would loop: «${name}» is already in this chain`, + }; + } + if (chain.length >= this.maxChain) { + return { + rejection: 'depth', + message: `too deep: the chain limit is ${this.maxChain} hops`, + }; + } + return null; + } + + private finish( + agentId: string, + contextId: string, + inboundChain: string[], + state: IA2aTaskStatus['state'], + causeText: string | undefined, + ranch: Partial, + startedAt: number, + replyText?: string, + ): IA2aTask { + const durationMs = Date.now() - startedAt; + const task: IA2aTask = { + id: crypto.randomUUID(), + contextId, + status: { + state, + timestamp: a2aTimestamp(), + ...(causeText + ? { + message: { + messageId: `m-${crypto.randomUUID()}`, + role: A2aRoles.Agent, + parts: [{ text: causeText }], + } satisfies IA2aMessage, + } + : {}), + }, + artifacts: + state === A2aTaskStates.Completed + ? [ + { + artifactId: 'reply', + name: 'reply', + parts: [{ text: replyText ?? '' }], + }, + ] + : [], + // The protocol allows replaying the conversation here; Ranch does not, + // because a peer's transcript is its own business (spec: the caller gets + // the reply, never the peer's context). + history: [], + metadata: { + ranch: { chain: [...inboundChain, agentId], durationMs, ...ranch }, + }, + }; + + if (state !== A2aTaskStates.Completed) { + this.logger.log( + `A2A task ${state} for agent=${agentId}: ${causeText ?? 'no cause'}`, + ); + } + + this.tasks.put(task); + return task; + } +} + +/** `metadata.ranch.chain`, defensively — a caller controls this field. */ +function readChain(message: IA2aMessage): string[] { + const ranch = (message.metadata as Record | undefined) + ?.ranch as IRanchTaskMetadata | undefined; + if (!ranch || !Array.isArray(ranch.chain)) return []; + return ranch.chain.filter((id): id is string => typeof id === 'string'); +} diff --git a/api/src/slices/agent/peer/domain/a2aTask.store.ts b/api/src/slices/agent/peer/domain/a2aTask.store.ts new file mode 100644 index 00000000..36981131 --- /dev/null +++ b/api/src/slices/agent/peer/domain/a2aTask.store.ts @@ -0,0 +1,59 @@ +import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; +import type { IA2aTask } from './a2a.types'; + +/** How long a finished task stays answerable by GetTask. */ +export const A2A_TASK_TTL_MS = 10 * 60_000; + +const SWEEP_INTERVAL_MS = 60_000; + +/** + * Tasks this server has answered, kept in memory (CLEAN-74, research R12). + * + * The protocol wants GetTask to exist; Ranch's own delegations never need it, + * because a blocking SendMessage already hands the caller the final task. So + * the store is a courtesy to other A2A clients, not a system of record — and + * persisting rows nothing reads would be a table to migrate, back up and + * explain forever. A short TTL says exactly that in code. + */ +@Injectable() +export class A2aTaskStore implements OnModuleInit, OnModuleDestroy { + private readonly tasks = new Map< + string, + { task: IA2aTask; expiresAt: number } + >(); + + private sweeper: NodeJS.Timeout | null = null; + + onModuleInit(): void { + this.sweeper = setInterval(() => this.sweep(), SWEEP_INTERVAL_MS); + // Never hold the process open for a cache. + this.sweeper.unref?.(); + } + + onModuleDestroy(): void { + if (this.sweeper) clearInterval(this.sweeper); + this.sweeper = null; + this.tasks.clear(); + } + + put(task: IA2aTask): void { + this.tasks.set(task.id, { task, expiresAt: Date.now() + A2A_TASK_TTL_MS }); + } + + get(id: string): IA2aTask | null { + const entry = this.tasks.get(id); + if (!entry) return null; + if (entry.expiresAt <= Date.now()) { + this.tasks.delete(id); + return null; + } + return entry.task; + } + + private sweep(): void { + const now = Date.now(); + for (const [id, entry] of this.tasks) { + if (entry.expiresAt <= now) this.tasks.delete(id); + } + } +} diff --git a/api/src/slices/agent/peer/domain/agentCard.service.spec.ts b/api/src/slices/agent/peer/domain/agentCard.service.spec.ts new file mode 100644 index 00000000..90f99ed4 --- /dev/null +++ b/api/src/slices/agent/peer/domain/agentCard.service.spec.ts @@ -0,0 +1,316 @@ +import { NotFoundException } from '@nestjs/common'; +import { AgentCardService } from './agentCard.service'; +import type { IAgentGateway } from '#/agent/agent/domain'; +import type { ITemplateGateway } from '#/agent/template/domain'; +import type { ISkillGateway } from '#/skill/domain/skill.gateway'; +import type { IKnowledgeGateway } from '#/reins/knowledge/domain/knowledge.gateway'; +import type { IInfraConfigGateway } from '#/setting/domain/infraConfig.gateway'; + +/** + * The card is the unit every other part of A2A exchanges, and it is derived, + * never stored — so these cases pin the derivation itself: which description + * wins, which knowledge bases count, what a skill reads like to the agent on + * the other side, and the one thing that must never appear (peers). + */ +interface HarnessOptions { + agent?: Record | null; + template?: Record | null; + skills?: Array<{ id: string; title: string; description: string | null }>; + bases?: Array<{ id: string; name: string; description: string | null }>; + apiUrl?: string; +} + +function makeHarness(options: HarnessOptions = {}) { + const agent = options.agent === undefined + ? { + id: 'agent-1', + name: 'Support Bot', + templateId: 'tpl-1', + knowledgeIds: [] as string[], + config: {} as Record, + } + : options.agent; + + const template = options.template === undefined + ? { + id: 'tpl-1', + description: 'A template for support agents.', + version: '2', + skillIds: [] as string[], + defaultKnowledgeIds: [] as string[], + } + : options.template; + + const findByIds = jest.fn(async (ids: string[]) => + (options.skills ?? []).filter((s) => ids.includes(s.id)), + ); + const findExistingByIds = jest.fn(async (ids: string[]) => + (options.bases ?? []).filter((b) => ids.includes(b.id)), + ); + + const service = new AgentCardService( + { findById: jest.fn(async () => agent) } as unknown as IAgentGateway, + { findById: jest.fn(async () => template) } as unknown as ITemplateGateway, + { findByIds } as unknown as ISkillGateway, + { findExistingByIds } as unknown as IKnowledgeGateway, + { + getApiPublicUrl: jest.fn( + async () => options.apiUrl ?? 'https://api.ranch.test', + ), + } as unknown as IInfraConfigGateway, + ); + + return { service, findByIds, findExistingByIds }; +} + +describe('AgentCardService.build', () => { + it('describes the agent at the address another agent sends tasks to', async () => { + const { service } = makeHarness(); + + const card = await service.build('agent-1'); + + expect(card).toMatchObject({ + name: 'Support Bot', + version: '2', + defaultInputModes: ['text/plain'], + defaultOutputModes: ['text/plain'], + supportedInterfaces: [ + { + url: 'https://api.ranch.test/a2a/agents/agent-1', + protocolBinding: 'JSONRPC', + protocolVersion: '1.0', + }, + ], + provider: { organization: 'Ranch', url: 'https://api.ranch.test' }, + }); + }); + + it('advertises no streaming, because this server answers one blocking call', async () => { + const { service } = makeHarness(); + + const card = await service.build('agent-1'); + + expect(card.capabilities).toMatchObject({ + streaming: false, + pushNotifications: false, + }); + }); + + it('asks for a peer credential and offers no anonymous route in', async () => { + const { service } = makeHarness(); + + const card = await service.build('agent-1'); + + expect(card.securitySchemes).toHaveProperty('peerBearer'); + expect(card.securityRequirements).toHaveLength(1); + }); + + it('turns each template skill into a card skill', async () => { + const { service } = makeHarness({ + template: { + id: 'tpl-1', + description: 'tpl', + version: '1', + skillIds: ['s1', 's2'], + defaultKnowledgeIds: [], + }, + skills: [ + { id: 's1', title: 'Order lookup', description: 'Finds an order.' }, + { id: 's2', title: 'Refunds', description: null }, + ], + }); + + const card = await service.build('agent-1'); + + expect(card.skills).toEqual([ + { + id: 'skill:s1', + name: 'Order lookup', + description: 'Finds an order.', + tags: ['skill'], + }, + // No description of its own: the title is a better fallback than an + // empty string, which would tell the calling agent nothing at all. + { + id: 'skill:s2', + name: 'Refunds', + description: 'Refunds', + tags: ['skill'], + }, + ]); + }); + + it('phrases a knowledge base as what it lets the agent answer', async () => { + const { service } = makeHarness({ + agent: { + id: 'agent-1', + name: 'Support Bot', + templateId: 'tpl-1', + knowledgeIds: ['k1', 'k2'], + config: {}, + }, + bases: [ + { id: 'k1', name: 'Returns policy', description: '2026 policy PDF.' }, + { id: 'k2', name: 'Shipping', description: null }, + ], + }); + + const card = await service.build('agent-1'); + + expect(card.skills).toEqual([ + { + id: 'knowledge:k1', + name: 'Returns policy', + description: 'Answers questions about «Returns policy»: 2026 policy PDF.', + tags: ['knowledge'], + }, + { + id: 'knowledge:k2', + name: 'Shipping', + description: 'Answers questions about «Shipping».', + tags: ['knowledge'], + }, + ]); + }); + + it('prefers the bases bound to the agent over the template defaults', async () => { + const { service, findExistingByIds } = makeHarness({ + agent: { + id: 'agent-1', + name: 'Support Bot', + templateId: 'tpl-1', + knowledgeIds: ['own'], + config: {}, + }, + template: { + id: 'tpl-1', + description: 'tpl', + version: '1', + skillIds: [], + defaultKnowledgeIds: ['from-template'], + }, + bases: [{ id: 'own', name: 'Own base', description: null }], + }); + + const card = await service.build('agent-1'); + + expect(findExistingByIds).toHaveBeenCalledWith(['own']); + expect(card.skills.map((s) => s.id)).toEqual(['knowledge:own']); + }); + + it('falls back to the template defaults when the agent binds nothing', async () => { + const { service, findExistingByIds } = makeHarness({ + template: { + id: 'tpl-1', + description: 'tpl', + version: '1', + skillIds: [], + defaultKnowledgeIds: ['from-template'], + }, + bases: [{ id: 'from-template', name: 'Inherited', description: null }], + }); + + const card = await service.build('agent-1'); + + expect(findExistingByIds).toHaveBeenCalledWith(['from-template']); + expect(card.skills.map((s) => s.name)).toEqual(['Inherited']); + }); + + it('drops a binding whose base has been deleted instead of advertising it', async () => { + const { service } = makeHarness({ + agent: { + id: 'agent-1', + name: 'Support Bot', + templateId: 'tpl-1', + knowledgeIds: ['alive', 'deleted'], + config: {}, + }, + bases: [{ id: 'alive', name: 'Alive', description: null }], + }); + + const card = await service.build('agent-1'); + + expect(card.skills.map((s) => s.id)).toEqual(['knowledge:alive']); + }); + + it('is a valid card even with nothing to advertise', async () => { + const { service } = makeHarness(); + + const card = await service.build('agent-1'); + + expect(card.skills).toEqual([]); + expect(card.name).toBe('Support Bot'); + }); + + it('prefers the agent own description over the template one', async () => { + const { service } = makeHarness({ + agent: { + id: 'agent-1', + name: 'Support Bot', + templateId: 'tpl-1', + knowledgeIds: [], + config: { description: ' Answers order questions. ' }, + }, + }); + + const card = await service.build('agent-1'); + + expect(card.description).toBe('Answers order questions.'); + }); + + it('falls back to the template description, then to naming the agent', async () => { + const withTemplate = makeHarness(); + await expect( + withTemplate.service.build('agent-1').then((c) => c.description), + ).resolves.toBe('A template for support agents.'); + + const withNothing = makeHarness({ template: null }); + await expect( + withNothing.service.build('agent-1').then((c) => c.description), + ).resolves.toBe('Ranch agent «Support Bot».'); + }); + + it('never mentions the agent own peers', async () => { + const { service } = makeHarness({ + template: { + id: 'tpl-1', + description: 'tpl', + version: '1', + skillIds: ['s1'], + defaultKnowledgeIds: [], + }, + skills: [{ id: 's1', title: 'Order lookup', description: 'Finds.' }], + }); + + const card = await service.build('agent-1'); + + // "peerBearer" is the security scheme and belongs here. What must not + // exist is any enumeration of who this agent can delegate to: no peers + // collection, and no skill standing in for one. + expect(card).not.toHaveProperty('peers'); + expect(card).not.toHaveProperty('peerOf'); + expect(card.skills.every((s) => !s.id.startsWith('peer:'))).toBe(true); + expect( + card.skills.some((s) => /peer/i.test(s.name + s.description)), + ).toBe(false); + }); + + it('builds absolute URLs that survive a trailing slash in the setting', async () => { + const { service } = makeHarness({ apiUrl: 'https://api.ranch.test' }); + + await expect(service.cardUrlFor('agent-1')).resolves.toBe( + 'https://api.ranch.test/a2a/agents/agent-1/.well-known/agent-card.json', + ); + await expect(service.interfaceUrlFor('agent-1')).resolves.toBe( + 'https://api.ranch.test/a2a/agents/agent-1', + ); + }); + + it('refuses to invent a card for an agent that does not exist', async () => { + const { service } = makeHarness({ agent: null }); + + await expect(service.build('ghost')).rejects.toBeInstanceOf( + NotFoundException, + ); + }); +}); diff --git a/api/src/slices/agent/peer/domain/agentCard.service.ts b/api/src/slices/agent/peer/domain/agentCard.service.ts new file mode 100644 index 00000000..e6e0c342 --- /dev/null +++ b/api/src/slices/agent/peer/domain/agentCard.service.ts @@ -0,0 +1,151 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { IAgentGateway } from '#/agent/agent/domain'; +import { ITemplateGateway } from '#/agent/template/domain'; +import { ISkillGateway } from '#/skill/domain/skill.gateway'; +import { IKnowledgeGateway } from '#/reins/knowledge/domain/knowledge.gateway'; +import { IInfraConfigGateway } from '#/setting/domain/infraConfig.gateway'; +import { + A2A_CARD_PATH, + A2A_VERSION, + type IA2aAgentCard, + type IA2aAgentSkill, +} from './a2a.types'; + +/** + * Builds an agent's A2A card (CLEAN-74) from what the agent already is: its + * name and description, the skills of its template, and the knowledge bases + * bound to it. Nothing is stored — a card is rebuilt on every read, so an + * operator who edits a description or binds a base sees the change without a + * "regenerate" button existing at all (FR-001). + * + * What the card must NOT contain is as load-bearing as what it does: an + * agent's own peers never appear (FR-019). Advertising colleagues invites a + * caller to reason about a chain it cannot see the end of. + */ +@Injectable() +export class AgentCardService { + constructor( + private readonly agents: IAgentGateway, + private readonly templates: ITemplateGateway, + private readonly skills: ISkillGateway, + private readonly knowledge: IKnowledgeGateway, + private readonly infra: IInfraConfigGateway, + ) {} + + /** Where another agent sends tasks for this one. */ + async interfaceUrlFor(agentId: string): Promise { + const base = await this.infra.getApiPublicUrl(); + return `${base}/a2a/agents/${agentId}`; + } + + /** Where another agent reads this one's card — the spec's well-known path. */ + async cardUrlFor(agentId: string): Promise { + const url = await this.interfaceUrlFor(agentId); + return `${url}/${A2A_CARD_PATH}`; + } + + async build(agentId: string): Promise { + const agent = await this.agents.findById(agentId); + if (!agent) throw new NotFoundException('Agent not found'); + + const template = await this.templates.findById(agent.templateId); + + const [skills, url] = await Promise.all([ + this.buildSkills(agent.knowledgeIds, template?.skillIds ?? [], template?.defaultKnowledgeIds ?? []), + this.interfaceUrlFor(agentId), + ]); + + const apiUrl = url.slice(0, url.indexOf('/a2a/agents/')); + + return { + name: agent.name, + description: this.describe(agent.name, agent.config, template?.description), + version: template?.version ?? '1', + supportedInterfaces: [ + { url, protocolBinding: 'JSONRPC', protocolVersion: A2A_VERSION }, + ], + capabilities: { + // This server answers one blocking SendMessage at a time. Saying so is + // the point of the field: a caller that believes in streaming here + // would wait for events that never arrive. + streaming: false, + pushNotifications: false, + extensions: [], + }, + defaultInputModes: ['text/plain'], + defaultOutputModes: ['text/plain'], + skills, + securitySchemes: { + peerBearer: { httpAuthSecurityScheme: { scheme: 'bearer' } }, + }, + securityRequirements: [{ schemes: { peerBearer: { list: [] } } }], + provider: { organization: 'Ranch', url: apiUrl }, + }; + } + + /** + * An agent's description is optional in Ranch but required on a card, and an + * empty one is worse than a plain one: it is the single line another agent + * reads to decide whether to ask. Falls back to the template, then to a + * sentence that at least names the agent. + */ + private describe( + name: string, + config: Record, + templateDescription?: string, + ): string { + const own = config?.description; + if (typeof own === 'string' && own.trim()) return own.trim(); + if (templateDescription?.trim()) return templateDescription.trim(); + return `Ranch agent «${name}».`; + } + + /** + * Two sources, one list. Template skills carry their own descriptions; a + * knowledge base becomes a skill phrased as what it lets the agent answer, + * because "Returns policy" alone does not tell a caller when to ask. + * + * Effective bases follow the same rule as everywhere else in Ranch: the + * agent's own binding wins, the template's defaults apply otherwise, and + * ids of deleted bases are dropped rather than advertised. + */ + private async buildSkills( + agentKnowledgeIds: string[], + templateSkillIds: string[], + templateKnowledgeIds: string[], + ): Promise { + const knowledgeIds = agentKnowledgeIds.length + ? agentKnowledgeIds + : templateKnowledgeIds; + + const [skillRecords, baseRecords] = await Promise.all([ + templateSkillIds.length + ? this.skills.findByIds(templateSkillIds) + : Promise.resolve([]), + knowledgeIds.length + ? this.knowledge.findExistingByIds(knowledgeIds) + : Promise.resolve([]), + ]); + + const fromSkills: IA2aAgentSkill[] = skillRecords.map((skill) => ({ + id: `skill:${skill.id}`, + name: skill.title, + description: skill.description?.trim() || skill.title, + tags: ['skill'], + })); + + const fromBases: IA2aAgentSkill[] = baseRecords.map((base) => { + const detail = base.description?.trim(); + return { + id: `knowledge:${base.id}`, + name: base.name, + description: detail + ? `Answers questions about «${base.name}»: ${detail}` + : `Answers questions about «${base.name}».`, + tags: ['knowledge'], + }; + }); + + return [...fromSkills, ...fromBases]; + } +} diff --git a/api/src/slices/agent/peer/domain/delegation.gateway.ts b/api/src/slices/agent/peer/domain/delegation.gateway.ts new file mode 100644 index 00000000..93427718 --- /dev/null +++ b/api/src/slices/agent/peer/domain/delegation.gateway.ts @@ -0,0 +1,25 @@ +import type { + IAgentDelegationData, + ICreateDelegationData, + IFinishDelegationData, +} from './peer.types'; + +/** + * Persistence for the delegation audit trail (CLEAN-74, FR-016). + * + * A row is written before the outbound call and finished after it, so a crash + * mid-flight still leaves evidence that the delegation was attempted. Nothing + * updates a row twice: `finish` is the only transition out of `waiting`. + */ +export abstract class IDelegationGateway { + abstract create(input: ICreateDelegationData): Promise; + abstract finish( + id: string, + input: IFinishDelegationData, + ): Promise; + /** Newest first — what the Peers tab shows under "Recent delegations". */ + abstract listRecent( + agentId: string, + limit: number, + ): Promise; +} diff --git a/api/src/slices/agent/peer/domain/index.ts b/api/src/slices/agent/peer/domain/index.ts index d027660a..c8d433e6 100644 --- a/api/src/slices/agent/peer/domain/index.ts +++ b/api/src/slices/agent/peer/domain/index.ts @@ -1,2 +1,7 @@ export * from './a2a.types'; export * from './peer.types'; +export * from './peer.gateway'; +export * from './delegation.gateway'; +export * from './agentCard.service'; +export * from './a2aTask.store'; +export * from './a2a.server.service'; diff --git a/api/src/slices/agent/peer/domain/peer.gateway.ts b/api/src/slices/agent/peer/domain/peer.gateway.ts new file mode 100644 index 00000000..2bf3617c --- /dev/null +++ b/api/src/slices/agent/peer/domain/peer.gateway.ts @@ -0,0 +1,44 @@ +import type { IAgentPeerData } from './peer.types'; +import type { IA2aAgentCard } from './a2a.types'; + +export interface ICreatePeerInput { + agentId: string; + peerAgentId: string; + token: string; + cardSnapshot: IA2aAgentCard; + cardUrl: string; + cardReadAt: Date; +} + +export interface IUpdateSnapshotInput { + cardSnapshot: IA2aAgentCard; + cardUrl: string; + cardReadAt: Date; +} + +/** + * Persistence for directed peer connections (CLEAN-74). + * + * `findByToken` is the one read on the hot path of every inbound A2A request, + * which is why the credential is a unique column rather than a hashed one: + * a lookup, not a scan. + */ +export abstract class IPeerGateway { + /** Peers of this caller, oldest first. Never the connections pointing AT it. */ + abstract listByAgent(agentId: string): Promise; + abstract findById(id: string): Promise; + abstract findByPair( + agentId: string, + peerAgentId: string, + ): Promise; + /** Resolves a presented `ap_` credential to the pair it was issued for. */ + abstract findByToken(token: string): Promise; + /** `@@unique([agentId, peerAgentId])` turns a double-connect into P2002. */ + abstract create(input: ICreatePeerInput): Promise; + abstract updateSnapshot( + id: string, + input: IUpdateSnapshotInput, + ): Promise; + /** Deleting the row is what revokes the credential. */ + abstract delete(id: string): Promise; +} diff --git a/api/src/slices/agent/peer/guards/a2a.guards.spec.ts b/api/src/slices/agent/peer/guards/a2a.guards.spec.ts new file mode 100644 index 00000000..94e92379 --- /dev/null +++ b/api/src/slices/agent/peer/guards/a2a.guards.spec.ts @@ -0,0 +1,214 @@ +import { UnauthorizedException } from '@nestjs/common'; +import type { ExecutionContext } from '@nestjs/common'; +import type { JwtService } from '@nestjs/jwt'; +import { A2aCardGuard, A2aPeerGuard } from './a2a.guards'; +import type { IA2aRequest } from './a2a.guards'; +import type { IPeerGateway } from '../domain/peer.gateway'; +import { UserRoleTypes } from '#/user/user/domain'; + +/** + * Who may read a card and who may send a task (CLEAN-74, FR-002 / FR-007). + * The load-bearing case is the last one: a credential is issued for exactly + * one (caller, peer) pair, so presenting agent B's credential at agent C's + * endpoint must fail even though the credential itself is perfectly valid. + */ +const VALID = 'ap_' + 'x'.repeat(43); + +function contextFor(request: Partial): ExecutionContext { + return { + switchToHttp: () => ({ getRequest: () => request as IA2aRequest }), + } as unknown as ExecutionContext; +} + +function makeHarness(options: { + rows?: Array<{ id: string; token: string; agentId: string; peerAgentId: string }>; + verify?: () => unknown; +} = {}) { + const rows = options.rows ?? []; + const peers = { + findByToken: jest.fn(async (token: string) => + rows.find((r) => r.token === token) ?? null, + ), + } as unknown as IPeerGateway; + + const jwt = { + verify: jest.fn( + options.verify ?? + (() => ({ sub: 'u1', email: 'u@x', roles: [UserRoleTypes.Owner] })), + ), + } as unknown as JwtService; + + return { + cardGuard: new A2aCardGuard(jwt, peers), + peerGuard: new A2aPeerGuard(peers), + peers, + jwt, + }; +} + +const request = ( + authorization: string | undefined, + agentId = 'agent-b', +): Partial => ({ + headers: authorization ? { authorization } : {}, + params: { agentId }, +}); + +const connection = { + id: 'peer-1', + token: VALID, + agentId: 'agent-a', + peerAgentId: 'agent-b', +}; + +describe('A2aCardGuard', () => { + it('refuses a request with no credential at all', async () => { + const { cardGuard } = makeHarness(); + + await expect( + cardGuard.canActivate(contextFor(request(undefined))), + ).rejects.toBeInstanceOf(UnauthorizedException); + }); + + it('reports one code for every refusal, so nothing is learned from the difference', async () => { + const { cardGuard } = makeHarness(); + + await expect( + cardGuard.canActivate(contextFor(request(undefined))), + ).rejects.toMatchObject({ + response: { code: 'A2A_UNAUTHORIZED' }, + }); + }); + + it('lets a peer read the card of the agent it is connected to', async () => { + const { cardGuard } = makeHarness({ rows: [connection] }); + const req = request(`Bearer ${VALID}`, 'agent-b'); + + await expect(cardGuard.canActivate(contextFor(req))).resolves.toBe(true); + expect((req as IA2aRequest).peer).toEqual({ + peerId: 'peer-1', + callerAgentId: 'agent-a', + }); + }); + + it('refuses a peer credential presented at a different agent', async () => { + const { cardGuard } = makeHarness({ rows: [connection] }); + + await expect( + cardGuard.canActivate(contextFor(request(`Bearer ${VALID}`, 'agent-c'))), + ).rejects.toMatchObject({ response: { code: 'A2A_UNAUTHORIZED' } }); + }); + + it('refuses a peer-shaped credential that matches no connection', async () => { + const { cardGuard } = makeHarness({ rows: [] }); + + await expect( + cardGuard.canActivate(contextFor(request(`Bearer ${VALID}`))), + ).rejects.toBeInstanceOf(UnauthorizedException); + }); + + it('lets an owner preview a card in the console', async () => { + const { cardGuard } = makeHarness(); + const req = request('Bearer console.jwt.token'); + + await expect(cardGuard.canActivate(contextFor(req))).resolves.toBe(true); + expect((req as IA2aRequest).user?.sub).toBe('u1'); + }); + + it('lets an admin preview a card too', async () => { + const { cardGuard } = makeHarness({ + verify: () => ({ sub: 'u2', email: 'a@x', roles: [UserRoleTypes.Admin] }), + }); + + await expect( + cardGuard.canActivate(contextFor(request('Bearer console.jwt.token'))), + ).resolves.toBe(true); + }); + + it('refuses an ordinary user, who has no business reading cards', async () => { + const { cardGuard } = makeHarness({ + verify: () => ({ sub: 'u3', email: 'u@x', roles: [UserRoleTypes.User] }), + }); + + await expect( + cardGuard.canActivate(contextFor(request('Bearer console.jwt.token'))), + ).rejects.toBeInstanceOf(UnauthorizedException); + }); + + it('refuses an agent service token: an agent reads a card with its pair credential', async () => { + const { cardGuard } = makeHarness({ + verify: () => ({ + sub: 'agent:agent-a', + email: 'agent@ranch.local', + roles: [UserRoleTypes.Agent], + }), + }); + + await expect( + cardGuard.canActivate(contextFor(request('Bearer agent.jwt.token'))), + ).rejects.toBeInstanceOf(UnauthorizedException); + }); + + it('refuses a token that does not verify', async () => { + const { cardGuard } = makeHarness({ + verify: () => { + throw new Error('jwt expired'); + }, + }); + + await expect( + cardGuard.canActivate(contextFor(request('Bearer stale.jwt'))), + ).rejects.toBeInstanceOf(UnauthorizedException); + }); + + it('ignores an authorization header that is not a bearer token', async () => { + const { cardGuard } = makeHarness(); + + await expect( + cardGuard.canActivate(contextFor(request('Basic dXNlcjpwYXNz'))), + ).rejects.toBeInstanceOf(UnauthorizedException); + }); +}); + +describe('A2aPeerGuard', () => { + it('accepts the credential issued for this agent and names the caller', async () => { + const { peerGuard } = makeHarness({ rows: [connection] }); + const req = request(`Bearer ${VALID}`, 'agent-b'); + + await expect(peerGuard.canActivate(contextFor(req))).resolves.toBe(true); + expect((req as IA2aRequest).peer?.callerAgentId).toBe('agent-a'); + }); + + it('refuses a console JWT, even an owner one', async () => { + const { peerGuard } = makeHarness(); + + await expect( + peerGuard.canActivate(contextFor(request('Bearer console.jwt.token'))), + ).rejects.toMatchObject({ response: { code: 'A2A_UNAUTHORIZED' } }); + }); + + it('refuses a credential issued to reach a different agent', async () => { + const { peerGuard } = makeHarness({ rows: [connection] }); + + await expect( + peerGuard.canActivate(contextFor(request(`Bearer ${VALID}`, 'agent-c'))), + ).rejects.toBeInstanceOf(UnauthorizedException); + }); + + it('refuses a request with no credential', async () => { + const { peerGuard } = makeHarness(); + + await expect( + peerGuard.canActivate(contextFor(request(undefined))), + ).rejects.toBeInstanceOf(UnauthorizedException); + }); + + it('does not look up a credential that cannot be one', async () => { + const { peerGuard, peers } = makeHarness(); + + await expect( + peerGuard.canActivate(contextFor(request('Bearer ap_tooshort'))), + ).rejects.toBeInstanceOf(UnauthorizedException); + expect(peers.findByToken).not.toHaveBeenCalled(); + }); +}); diff --git a/api/src/slices/agent/peer/guards/a2a.guards.ts b/api/src/slices/agent/peer/guards/a2a.guards.ts new file mode 100644 index 00000000..b9b341e9 --- /dev/null +++ b/api/src/slices/agent/peer/guards/a2a.guards.ts @@ -0,0 +1,124 @@ +import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { UnauthorizedException } from '@nestjs/common'; +import type { Request } from 'express'; +import type { IAuthTokenPayload } from '#/user/auth/domain/auth.types'; +import { hasAtLeastRole, UserRoleTypes } from '#/user/user/domain'; +import { IPeerGateway } from '../domain/peer.gateway'; +import { PEER_TOKEN_RE, PeerErrorCodes } from '../domain/peer.types'; + +/** What a guard leaves behind for the A2A route handlers. */ +export interface IA2aRequest extends Request { + /** Set when the caller presented a peer credential. */ + peer?: { peerId: string; callerAgentId: string }; + /** Set when the caller presented a console JWT (card route only). */ + user?: IAuthTokenPayload; +} + +function bearer(request: Request): string | null { + const header = request.headers.authorization; + if (typeof header !== 'string') return null; + const [scheme, value] = header.split(' '); + if (scheme?.toLowerCase() !== 'bearer' || !value) return null; + return value.trim(); +} + +function refuse(message: string): never { + // One code for every way in, so a caller learns nothing from the difference + // between "no such connection" and "wrong agent" — both mean "not for you". + throw new UnauthorizedException({ + code: PeerErrorCodes.Unauthorized, + message, + }); +} + +/** + * Resolves a presented `ap_` credential and checks it was issued to reach + * THIS agent. Returns null when the header is absent or not a peer credential, + * so a caller can fall through to another identity. + */ +async function resolvePeer( + peers: IPeerGateway, + request: IA2aRequest, +): Promise<{ peerId: string; callerAgentId: string } | null> { + const token = bearer(request); + if (!token || !PEER_TOKEN_RE.test(token)) return null; + + const row = await peers.findByToken(token); + if (!row) refuse('Unknown peer credential'); + + const agentId = request.params?.agentId; + // A credential is issued for one pair. Presenting agent A's credential at + // agent C's endpoint is the whole attack this check exists to stop. + if (row.peerAgentId !== agentId) refuse('Credential is not for this agent'); + + return { peerId: row.id, callerAgentId: row.agentId }; +} + +/** + * The agent card is readable by two kinds of caller (CLEAN-74, FR-002): + * an operator previewing a card in the console (Owner/Admin JWT), and an agent + * reading the card of a peer it is connected to (`ap_` credential for that + * agent). Nobody else — anonymous discovery is deliberately not offered, so a + * card cannot be used to enumerate what an installation can do. + */ +@Injectable() +export class A2aCardGuard implements CanActivate { + constructor( + private readonly jwt: JwtService, + private readonly peers: IPeerGateway, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + + const peer = await resolvePeer(this.peers, request); + if (peer) { + request.peer = peer; + return true; + } + + const token = bearer(request); + if (!token) refuse('Missing credential'); + + let payload: IAuthTokenPayload; + try { + payload = this.jwt.verify(token); + } catch { + refuse('Invalid credential'); + } + + // Agent service tokens are deliberately not accepted here: an agent reads + // a peer's card with the credential issued for that connection, which is + // also what proves the connection still exists. + const roles = payload.roles ?? []; + const allowed = roles.some((role) => + hasAtLeastRole(role, UserRoleTypes.Admin), + ); + if (!allowed) refuse('Not allowed to read this card'); + + request.user = payload; + return true; + } +} + +/** + * The JSON-RPC endpoint takes peer credentials only. A console JWT is refused + * even for an owner: this route speaks for an agent, and "which agent is + * asking" has to come from the credential, not from a field a caller could + * choose. + */ +@Injectable() +export class A2aPeerGuard implements CanActivate { + constructor(private readonly peers: IPeerGateway) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + + const peer = await resolvePeer(this.peers, request); + if (!peer) refuse('A peer credential is required'); + + request.peer = peer; + return true; + } +} diff --git a/api/src/slices/agent/peer/guards/index.ts b/api/src/slices/agent/peer/guards/index.ts new file mode 100644 index 00000000..f5828dba --- /dev/null +++ b/api/src/slices/agent/peer/guards/index.ts @@ -0,0 +1 @@ +export * from './a2a.guards'; diff --git a/api/src/slices/agent/peer/peer.module.ts b/api/src/slices/agent/peer/peer.module.ts index 27cf97bd..bccd10bd 100644 --- a/api/src/slices/agent/peer/peer.module.ts +++ b/api/src/slices/agent/peer/peer.module.ts @@ -6,6 +6,17 @@ import { SkillModule } from '#/skill/skill.module'; import { KnowledgeModule } from '#/reins/knowledge/knowledge.module'; import { BridleModule } from '#/bridle/bridle.module'; import { SettingModule } from '#/setting/setting.module'; +import { IPeerGateway } from './domain/peer.gateway'; +import { IDelegationGateway } from './domain/delegation.gateway'; +import { AgentCardService } from './domain/agentCard.service'; +import { A2aServerService } from './domain/a2a.server.service'; +import { A2aTaskStore } from './domain/a2aTask.store'; +import { PeerGateway } from './data/peer.gateway'; +import { DelegationGateway } from './data/delegation.gateway'; +import { PeerMapper } from './data/peer.mapper'; +import { DelegationMapper } from './data/delegation.mapper'; +import { A2aCardGuard, A2aPeerGuard } from './guards/a2a.guards'; +import { A2aController } from './a2a.controller'; /** * Agents talking to agents (CLEAN-74): agent cards, peer connections, the A2A @@ -25,8 +36,18 @@ import { SettingModule } from '#/setting/setting.module'; forwardRef(() => BridleModule), SettingModule, ], - controllers: [], - providers: [], - exports: [], + controllers: [A2aController], + providers: [ + PeerMapper, + DelegationMapper, + { provide: IPeerGateway, useClass: PeerGateway }, + { provide: IDelegationGateway, useClass: DelegationGateway }, + AgentCardService, + A2aTaskStore, + A2aServerService, + A2aCardGuard, + A2aPeerGuard, + ], + exports: [AgentCardService, IPeerGateway, IDelegationGateway], }) export class PeerModule {} diff --git a/api/src/slices/setup/error/rawResponse.decorator.ts b/api/src/slices/setup/error/rawResponse.decorator.ts new file mode 100644 index 00000000..bfcad6d6 --- /dev/null +++ b/api/src/slices/setup/error/rawResponse.decorator.ts @@ -0,0 +1,16 @@ +import { SetMetadata } from '@nestjs/common'; + +export const RAW_RESPONSE_METADATA_KEY = 'raw_response'; + +/** + * Opt a route out of the `{ success, data }` envelope every other 2xx body + * carries. + * + * There is exactly one reason to reach for this: a route that speaks somebody + * else's protocol. The A2A endpoints (CLEAN-74) answer with an agent card and + * with JSON-RPC envelopes, both defined by a published spec — a client reading + * `{ success: true, data: { … } }` would see neither. Ranch's own API keeps the + * envelope, and any new use of this decorator should be able to name the + * external specification it is obeying. + */ +export const RawResponse = () => SetMetadata(RAW_RESPONSE_METADATA_KEY, true); diff --git a/api/src/slices/setup/error/response.interceptor.ts b/api/src/slices/setup/error/response.interceptor.ts index 8c0ae017..00561032 100644 --- a/api/src/slices/setup/error/response.interceptor.ts +++ b/api/src/slices/setup/error/response.interceptor.ts @@ -6,12 +6,21 @@ import { } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; import { Observable, map } from 'rxjs'; +import { RAW_RESPONSE_METADATA_KEY } from './rawResponse.decorator'; @Injectable() export class ResponseInterceptor implements NestInterceptor { constructor(private reflector: Reflector) {} intercept(context: ExecutionContext, next: CallHandler): Observable { + // A route that speaks an external protocol answers in that protocol's + // shape, not in ours — see @RawResponse. + const raw = this.reflector.getAllAndOverride( + RAW_RESPONSE_METADATA_KEY, + [context.getHandler(), context.getClass()], + ); + if (raw) return next.handle(); + return next.handle().pipe( map((data) => ({ success: true, diff --git a/specs/013-a2a-agent-peers/tasks.md b/specs/013-a2a-agent-peers/tasks.md index e44ad40e..e4b1e6b4 100644 --- a/specs/013-a2a-agent-peers/tasks.md +++ b/specs/013-a2a-agent-peers/tasks.md @@ -66,19 +66,19 @@ ### Card -- [ ] T017 [P] [US1] Implement `AgentCardService` in `api/src/slices/agent/peer/domain/agentCard.service.ts` over `IAgentGateway`, `ITemplateGateway`, `ISkillGateway`, `IKnowledgeGateway`, `IInfraConfigGateway`: `build(agentId): Promise` exactly per data-model §3 (`description` fallback chain, `version`, `supportedInterfaces[0] = { url: `${apiPublicUrl}/a2a/agents/${id}`, protocolBinding: 'JSONRPC', protocolVersion: '1.0' }`, capabilities all false, `text/plain` modes, `skill:*` from `skillGateway.findByIds(template.skillIds)`, `knowledge:*` from `findExistingByIds(agent.knowledgeIds.length ? agent.knowledgeIds : template.defaultKnowledgeIds)`, `securitySchemes.peerBearer`, `securityRequirements`, `provider`); throws `NotFoundException` for an unknown agent; a `cardUrlFor(agentId)` helper returning `${apiPublicUrl}/a2a/agents/${id}/.well-known/agent-card.json` -- [ ] T018 [P] [US1] Write `api/src/slices/agent/peer/domain/agentCard.service.spec.ts` with object-literal gateway stubs: two template skills → two `skill:*` entries with `name = title`, `description = description ?? title`, `tags: ['skill']`; agent `knowledgeIds` non-empty wins over template defaults; missing knowledge ids dropped; empty skills allowed; description falls back to template then to `Ranch agent «name»`; URL absolute and trailing-slash-free; `JSON.stringify(card)` contains no `peer` key; `cardUrlFor` shape (depends on T017) +- [X] T017 [P] [US1] Implement `AgentCardService` in `api/src/slices/agent/peer/domain/agentCard.service.ts` over `IAgentGateway`, `ITemplateGateway`, `ISkillGateway`, `IKnowledgeGateway`, `IInfraConfigGateway`: `build(agentId): Promise` exactly per data-model §3 (`description` fallback chain, `version`, `supportedInterfaces[0] = { url: `${apiPublicUrl}/a2a/agents/${id}`, protocolBinding: 'JSONRPC', protocolVersion: '1.0' }`, capabilities all false, `text/plain` modes, `skill:*` from `skillGateway.findByIds(template.skillIds)`, `knowledge:*` from `findExistingByIds(agent.knowledgeIds.length ? agent.knowledgeIds : template.defaultKnowledgeIds)`, `securitySchemes.peerBearer`, `securityRequirements`, `provider`); throws `NotFoundException` for an unknown agent; a `cardUrlFor(agentId)` helper returning `${apiPublicUrl}/a2a/agents/${id}/.well-known/agent-card.json` +- [X] T018 [P] [US1] Write `api/src/slices/agent/peer/domain/agentCard.service.spec.ts` with object-literal gateway stubs: two template skills → two `skill:*` entries with `name = title`, `description = description ?? title`, `tags: ['skill']`; agent `knowledgeIds` non-empty wins over template defaults; missing knowledge ids dropped; empty skills allowed; description falls back to template then to `Ranch agent «name»`; URL absolute and trailing-slash-free; `JSON.stringify(card)` contains no `peer` key; `cardUrlFor` shape (depends on T017) ### A2A server -- [ ] T019 [P] [US1] Implement `A2aTaskStore` in `api/src/slices/agent/peer/domain/a2aTask.store.ts`: `put(task)`, `get(id): A2aTask | null`, TTL `10 * 60_000` ms (constant `A2A_TASK_TTL_MS`), lazy sweep on `get` + `setInterval(60_000).unref()` started in `onModuleInit`, cleared in `onModuleDestroy` -- [ ] T020 [P] [US1] Implement `A2aCardGuard` in `api/src/slices/agent/peer/guards/a2aCard.guard.ts` (pattern `api/src/slices/bridle/guards/bridleChatAuth.guard.ts:58-108`): reads `Authorization: Bearer`; if it matches `PEER_TOKEN_RE` → `IPeerGateway.findByToken`, allow only when `row.peerAgentId === req.params.agentId` and set `req.peer = { peerId: row.id, callerAgentId: row.agentId }`; else `JwtService.verify` and allow only `Owner|Admin` roles (`hasAtLeastRole` from `api/src/slices/user/auth/guards/roles.guard.ts`); any failure → `UnauthorizedException({ code: 'A2A_UNAUTHORIZED', message })`; and `A2aPeerGuard` in `guards/a2aPeer.guard.ts` — same but **only** the `ap_` branch -- [ ] T021 [P] [US1] Write `api/src/slices/agent/peer/guards/a2aCard.guard.spec.ts` and `a2aPeer.guard.spec.ts` (stub pattern `bridleChatAuth.guard.spec.ts:26-55`): no header → 401 `A2A_UNAUTHORIZED`; `ap_` scoped to another agent → 401; `ap_` scoped to `:agentId` → allowed with `req.peer` set; owner JWT → allowed on the card guard, refused on the peer guard; user-role JWT → refused on both (depends on T020) -- [ ] T022 [US1] Implement `A2aServerService` in `api/src/slices/agent/peer/domain/a2a.server.service.ts` over `IBridleGateway`, `BridleSyncService`, `IAgentGateway`, `A2aTaskStore`, `ConfigService`: `sendMessage(agentId, callerAgentId, params: SendMessageParams): Promise` applying contracts §2.1 in order — `returnImmediately` → throw `A2aRpcError(Unsupported)`; non-text part → `A2aRpcError(ContentTypeNotSupported)`; chain rules **as a pluggable `checkChain(chain, agentId)` that this task implements as a no-op returning null** (US5 fills it); `!isAgentConnected(agentId)` → task `TASK_STATE_FAILED` with status message `peer not running`, `metadata.ranch.failure = 'not_running'` (no hub send); else `sendAndAwait({ agentId, clientId: `peer:${callerAgentId}:${contextId}`, text: textOfParts(parts), capabilities: [], timeoutMs: A2A_SYNC_TIMEOUT_MS })` → `timedOut` → `TASK_STATE_FAILED` `timed out after s` `failure = 'timeout'`; reply → `TASK_STATE_COMPLETED` with one artifact `{ artifactId: 'reply', name: 'reply', parts: [{ text }] }`; every task carries `id = randomUUID()`, `contextId` (given or `ctx-`), `status.timestamp` ISO, `history: []`, `metadata.ranch = { chain: [...inbound, agentId], durationMs }`; stores the task; `getTask(id)` → task or `A2aRpcError(TaskNotFound)`; define `class A2aRpcError extends Error { constructor(public code: number, message: string, public data?: unknown) }` in `domain/a2a.types.ts` (depends on T003, T013, T019) -- [ ] T023 [US1] Write `api/src/slices/agent/peer/domain/a2a.server.service.spec.ts` with hub/sync/task-store stubs: `returnImmediately` → `-32004`; `{ raw }` part → `-32005`; offline agent → failed `not_running` and `sendAndAwait` never called; timeout → failed `timeout`; reply → completed, artifact text equals the reply, `clientId` is `peer::`, `capabilities` empty; `contextId` minted when absent and preserved when given; `chain` extended with the agent id; `getTask` unknown → `-32001` (depends on T022) -- [ ] T024 [US1] Implement `A2aController` in `api/src/slices/agent/peer/a2a.controller.ts` (`@Controller('a2a/agents')`, `@ApiTags('a2a')`, `@ApiExcludeController()` so the SDK does not generate it): `GET :agentId/.well-known/agent-card.json` under `@UseGuards(A2aCardGuard)` returning the raw card; `POST :agentId` under `@UseGuards(A2aPeerGuard)` that (a) checks `A2A-Version` header equals `'1.0'` else JSON-RPC error `-32009`, (b) validates the envelope (`jsonrpc === '2.0'`, string `method`) else `-32600`, (c) dispatches `SendMessage` → `{ result: { task } }`, `GetTask` → `{ result: { task } }`, `CancelTask` → `-32002` if known else `-32001`, every other known A2A method name → `-32004`, unknown → `-32601`, (d) maps `A2aRpcError` to `{ jsonrpc, id, error: { code, message, data } }` and any other throw to `-32603`; **bypass the response envelope** for both routes — check how `api/src/slices/setup/error/response.interceptor.ts:16-19` decides to wrap and add a `@RawResponse()` metadata decorator it honours (or reuse an existing skip mechanism if one is found there) (depends on T017, T020, T022) -- [ ] T025 [US1] Write `api/src/slices/agent/peer/a2a.controller.spec.ts` (`new A2aController(stubs)` + `Reflector` guard-metadata assertions as in `shareLink.controller.spec.ts:21-26`): card route guarded by `A2aCardGuard`, JSON-RPC route by `A2aPeerGuard`; missing/other `A2A-Version` → `-32009`; `SendMessage` returns `{ result: { task } }` unwrapped; `GetTask` unknown → `-32001`; `SendStreamingMessage` → `-32004`; unknown method → `-32601`; an unexpected throw → `-32603`; the raw-response marker is present on both routes (depends on T024) -- [ ] T026 [US1] Wire `peer.module.ts` providers/controllers for this story: `AgentCardService`, `A2aTaskStore`, `A2aServerService`, `A2aCardGuard`, `A2aPeerGuard`, `A2aController`, plus a temporary in-memory `IPeerGateway` stub is **not** allowed — instead implement `IPeerGateway.findByToken` now via T030's gateway if US2 runs first, otherwise mark the card guard's `ap_` branch as depending on T030 and verify the owner-JWT path with quickstart §3 lines 1–4 (depends on T016, T024) +- [X] T019 [P] [US1] Implement `A2aTaskStore` in `api/src/slices/agent/peer/domain/a2aTask.store.ts`: `put(task)`, `get(id): A2aTask | null`, TTL `10 * 60_000` ms (constant `A2A_TASK_TTL_MS`), lazy sweep on `get` + `setInterval(60_000).unref()` started in `onModuleInit`, cleared in `onModuleDestroy` +- [X] T020 [P] [US1] Implement `A2aCardGuard` in `api/src/slices/agent/peer/guards/a2aCard.guard.ts` (pattern `api/src/slices/bridle/guards/bridleChatAuth.guard.ts:58-108`): reads `Authorization: Bearer`; if it matches `PEER_TOKEN_RE` → `IPeerGateway.findByToken`, allow only when `row.peerAgentId === req.params.agentId` and set `req.peer = { peerId: row.id, callerAgentId: row.agentId }`; else `JwtService.verify` and allow only `Owner|Admin` roles (`hasAtLeastRole` from `api/src/slices/user/auth/guards/roles.guard.ts`); any failure → `UnauthorizedException({ code: 'A2A_UNAUTHORIZED', message })`; and `A2aPeerGuard` in `guards/a2aPeer.guard.ts` — same but **only** the `ap_` branch +- [X] T021 [P] [US1] Write `api/src/slices/agent/peer/guards/a2aCard.guard.spec.ts` and `a2aPeer.guard.spec.ts` (stub pattern `bridleChatAuth.guard.spec.ts:26-55`): no header → 401 `A2A_UNAUTHORIZED`; `ap_` scoped to another agent → 401; `ap_` scoped to `:agentId` → allowed with `req.peer` set; owner JWT → allowed on the card guard, refused on the peer guard; user-role JWT → refused on both (depends on T020) +- [X] T022 [US1] Implement `A2aServerService` in `api/src/slices/agent/peer/domain/a2a.server.service.ts` over `IBridleGateway`, `BridleSyncService`, `IAgentGateway`, `A2aTaskStore`, `ConfigService`: `sendMessage(agentId, callerAgentId, params: SendMessageParams): Promise` applying contracts §2.1 in order — `returnImmediately` → throw `A2aRpcError(Unsupported)`; non-text part → `A2aRpcError(ContentTypeNotSupported)`; chain rules **as a pluggable `checkChain(chain, agentId)` that this task implements as a no-op returning null** (US5 fills it); `!isAgentConnected(agentId)` → task `TASK_STATE_FAILED` with status message `peer not running`, `metadata.ranch.failure = 'not_running'` (no hub send); else `sendAndAwait({ agentId, clientId: `peer:${callerAgentId}:${contextId}`, text: textOfParts(parts), capabilities: [], timeoutMs: A2A_SYNC_TIMEOUT_MS })` → `timedOut` → `TASK_STATE_FAILED` `timed out after s` `failure = 'timeout'`; reply → `TASK_STATE_COMPLETED` with one artifact `{ artifactId: 'reply', name: 'reply', parts: [{ text }] }`; every task carries `id = randomUUID()`, `contextId` (given or `ctx-`), `status.timestamp` ISO, `history: []`, `metadata.ranch = { chain: [...inbound, agentId], durationMs }`; stores the task; `getTask(id)` → task or `A2aRpcError(TaskNotFound)`; define `class A2aRpcError extends Error { constructor(public code: number, message: string, public data?: unknown) }` in `domain/a2a.types.ts` (depends on T003, T013, T019) +- [X] T023 [US1] Write `api/src/slices/agent/peer/domain/a2a.server.service.spec.ts` with hub/sync/task-store stubs: `returnImmediately` → `-32004`; `{ raw }` part → `-32005`; offline agent → failed `not_running` and `sendAndAwait` never called; timeout → failed `timeout`; reply → completed, artifact text equals the reply, `clientId` is `peer::`, `capabilities` empty; `contextId` minted when absent and preserved when given; `chain` extended with the agent id; `getTask` unknown → `-32001` (depends on T022) +- [X] T024 [US1] Implement `A2aController` in `api/src/slices/agent/peer/a2a.controller.ts` (`@Controller('a2a/agents')`, `@ApiTags('a2a')`, `@ApiExcludeController()` so the SDK does not generate it): `GET :agentId/.well-known/agent-card.json` under `@UseGuards(A2aCardGuard)` returning the raw card; `POST :agentId` under `@UseGuards(A2aPeerGuard)` that (a) checks `A2A-Version` header equals `'1.0'` else JSON-RPC error `-32009`, (b) validates the envelope (`jsonrpc === '2.0'`, string `method`) else `-32600`, (c) dispatches `SendMessage` → `{ result: { task } }`, `GetTask` → `{ result: { task } }`, `CancelTask` → `-32002` if known else `-32001`, every other known A2A method name → `-32004`, unknown → `-32601`, (d) maps `A2aRpcError` to `{ jsonrpc, id, error: { code, message, data } }` and any other throw to `-32603`; **bypass the response envelope** for both routes — check how `api/src/slices/setup/error/response.interceptor.ts:16-19` decides to wrap and add a `@RawResponse()` metadata decorator it honours (or reuse an existing skip mechanism if one is found there) (depends on T017, T020, T022) +- [X] T025 [US1] Write `api/src/slices/agent/peer/a2a.controller.spec.ts` (`new A2aController(stubs)` + `Reflector` guard-metadata assertions as in `shareLink.controller.spec.ts:21-26`): card route guarded by `A2aCardGuard`, JSON-RPC route by `A2aPeerGuard`; missing/other `A2A-Version` → `-32009`; `SendMessage` returns `{ result: { task } }` unwrapped; `GetTask` unknown → `-32001`; `SendStreamingMessage` → `-32004`; unknown method → `-32601`; an unexpected throw → `-32603`; the raw-response marker is present on both routes (depends on T024) +- [X] T026 [US1] Wire `peer.module.ts` providers/controllers for this story: `AgentCardService`, `A2aTaskStore`, `A2aServerService`, `A2aCardGuard`, `A2aPeerGuard`, `A2aController`, plus a temporary in-memory `IPeerGateway` stub is **not** allowed — instead implement `IPeerGateway.findByToken` now via T030's gateway if US2 runs first, otherwise mark the card guard's `ap_` branch as depending on T030 and verify the owner-JWT path with quickstart §3 lines 1–4 (depends on T016, T024) **Checkpoint**: quickstart §3 — own card via `GET /agents/:id/card` is deferred to US2 (REST controller); the well-known card answers 401 without a credential and 200 with an owner JWT; `SendMessage` with an owner JWT is 401 (peer credential only). @@ -92,10 +92,10 @@ ### API -- [ ] T027 [P] [US2] Define `abstract class IPeerGateway` in `api/src/slices/agent/peer/domain/peer.gateway.ts`: `listByAgent(agentId): Promise`, `findById(id)`, `findByPair(agentId, peerAgentId)`, `findByToken(token)`, `create(input: { agentId, peerAgentId, token, cardSnapshot, cardUrl, cardReadAt })`, `updateSnapshot(id, { cardSnapshot, cardUrl, cardReadAt })`, `delete(id)`; and `abstract class IDelegationGateway` in `domain/delegation.gateway.ts`: `create(input)`, `finish(id, { status, errorCode?, excerpt?, finishedAt, durationMs })`, `listRecent(agentId, limit)`; export both from `domain/index.ts` -- [ ] T028 [P] [US2] Implement `PeerMapper` in `api/src/slices/agent/peer/data/peer.mapper.ts` and `DelegationMapper` in `api/src/slices/agent/peer/data/delegation.mapper.ts` — Prisma record → domain data with ISO strings, the only files importing `@prisma/client` types (pattern `shareLink.mapper.ts`) -- [ ] T029 [US2] Implement `PeerGateway extends IPeerGateway` in `api/src/slices/agent/peer/data/peer.gateway.ts` and `DelegationGateway` in `data/delegation.gateway.ts` over `PrismaService` (`prisma.agentPeer.*`, `prisma.agentDelegation.*`), `listByAgent` ordered by `createdAt asc`, `listRecent` ordered by `startedAt desc` with `take: limit` (depends on T027, T028) -- [ ] T030 [US2] Write `api/src/slices/agent/peer/data/peer.gateway.spec.ts` with a `makePrismaStub()` (pattern `shareLink.gateway.spec.ts:7-70`) enforcing `@unique token` and `@@unique([agentId, peerAgentId])` (throw `{ code: 'P2002' }`): create/findByPair/findByToken round-trips, `updateSnapshot` changes only the three fields, `delete`; and `data/delegation.gateway.spec.ts`: `finish` sets status/finishedAt/durationMs, `listRecent` newest first and limited (depends on T029) +- [X] T027 [P] [US2] Define `abstract class IPeerGateway` in `api/src/slices/agent/peer/domain/peer.gateway.ts`: `listByAgent(agentId): Promise`, `findById(id)`, `findByPair(agentId, peerAgentId)`, `findByToken(token)`, `create(input: { agentId, peerAgentId, token, cardSnapshot, cardUrl, cardReadAt })`, `updateSnapshot(id, { cardSnapshot, cardUrl, cardReadAt })`, `delete(id)`; and `abstract class IDelegationGateway` in `domain/delegation.gateway.ts`: `create(input)`, `finish(id, { status, errorCode?, excerpt?, finishedAt, durationMs })`, `listRecent(agentId, limit)`; export both from `domain/index.ts` +- [X] T028 [P] [US2] Implement `PeerMapper` in `api/src/slices/agent/peer/data/peer.mapper.ts` and `DelegationMapper` in `api/src/slices/agent/peer/data/delegation.mapper.ts` — Prisma record → domain data with ISO strings, the only files importing `@prisma/client` types (pattern `shareLink.mapper.ts`) +- [X] T029 [US2] Implement `PeerGateway extends IPeerGateway` in `api/src/slices/agent/peer/data/peer.gateway.ts` and `DelegationGateway` in `data/delegation.gateway.ts` over `PrismaService` (`prisma.agentPeer.*`, `prisma.agentDelegation.*`), `listByAgent` ordered by `createdAt asc`, `listRecent` ordered by `startedAt desc` with `take: limit` (depends on T027, T028) +- [X] T030 [US2] Write `api/src/slices/agent/peer/data/peer.gateway.spec.ts` with a `makePrismaStub()` (pattern `shareLink.gateway.spec.ts:7-70`) enforcing `@unique token` and `@@unique([agentId, peerAgentId])` (throw `{ code: 'P2002' }`): create/findByPair/findByToken round-trips, `updateSnapshot` changes only the three fields, `delete`; and `data/delegation.gateway.spec.ts`: `finish` sets status/finishedAt/durationMs, `listRecent` newest first and limited (depends on T029) - [ ] T031 [US2] Implement `A2aClient` (card half) in `api/src/slices/agent/peer/domain/a2a.client.ts`: `fetchCard(cardUrl, token): Promise` using global `fetch` with `Authorization: Bearer `, `Accept: application/json`, a 10 s `AbortSignal.timeout`; non-2xx → `PeerCardUnreachableError(status, bodyExcerpt)`; malformed JSON or missing `name`/`skills`/`supportedInterfaces` → same error with reason; network error → same; define the error class in `domain/peer.types.ts` - [ ] T032 [US2] Implement `PeerService` in `api/src/slices/agent/peer/domain/peer.service.ts` over `IPeerGateway`, `IAgentGateway`, `AgentCardService`, `A2aClient`: `list(agentId)` → peers joined with the live peer agent (`peerName`, `peerStatus`, `peerExists`); `candidates(agentId)` → every other agent `{ id, name, status, connected }`; `ownCard(agentId)` → `AgentCardService.build`; `connect(agentId, peerAgentId)` → refuse self (`BadRequestException({ code: PEER_SELF })`), unknown peer agent (`NotFoundException({ code: PEER_NOT_FOUND })`), duplicate (`ConflictException({ code: PEER_EXISTS })`); mint `ap_` + `randomBytes(32).toString('base64url')` (pattern `shareLink.service.ts:152`); **create the row first** (so the credential exists), then `fetchCard(cardUrlFor(peerAgentId), token)`; on failure delete the row and throw `BadGatewayException({ code: PEER_CARD_UNREACHABLE, message })`; on success `updateSnapshot`; `refresh(agentId, peerId)` → fetch with the row's token, keep the old snapshot and rethrow `502` on failure; `remove(agentId, peerId)` → 404 if the row's `agentId` differs; the returned data never includes `token` (strip in a `toPublic()` helper) (depends on T017, T027, T031) - [ ] T033 [US2] Write `api/src/slices/agent/peer/domain/peer.service.spec.ts` with in-memory gateway stubs and a `fetchCard` jest.fn: token format `^ap_[A-Za-z0-9_-]{43}$`; self → `PEER_SELF`; duplicate → `PEER_EXISTS`; connect with a failing card fetch leaves **no** row and throws `PEER_CARD_UNREACHABLE`; connect success stores the snapshot and `cardReadAt`; refresh failure keeps the old snapshot; remove on a foreign row → 404; `list` output has no `token` key; `candidates` excludes the agent itself and marks connected ones (depends on T032); also write `domain/a2a.client.spec.ts` with a mocked `global.fetch`: 200 JSON → card; 401 → unreachable with status; timeout/abort → unreachable @@ -163,7 +163,7 @@ **Independent Test**: spec Story 5 — A→B and B→A connected; B's attempt to call A back is refused with "would loop" and B answers alone; a chain beyond 3 hops is refused with "too deep"; A's Peers tab lists the delegations with peer, time, duration and outcome. -- [ ] T062 [US5] Implement `checkChain(chain: string[], agentId: string): { rejection: 'loop' | 'depth'; message: string } | null` in `api/src/slices/agent/peer/domain/a2a.server.service.ts` (replacing the US1 no-op): `chain.includes(agentId)` → `loop`, `would loop: is already in the chain`; `chain.length >= A2A_MAX_CHAIN` (env, default 3) → `depth`, `too deep: chain limit is hops`; on rejection build a task `TASK_STATE_REJECTED` with `status.message = { messageId, role: 'ROLE_AGENT', parts: [{ text }] }`, `metadata.ranch.rejection`, no artifacts, **before** the `isAgentConnected` check and without touching the hub (depends on T022) +- [X] T062 [US5] Implement `checkChain(chain: string[], agentId: string): { rejection: 'loop' | 'depth'; message: string } | null` in `api/src/slices/agent/peer/domain/a2a.server.service.ts` (replacing the US1 no-op): `chain.includes(agentId)` → `loop`, `would loop: is already in the chain`; `chain.length >= A2A_MAX_CHAIN` (env, default 3) → `depth`, `too deep: chain limit is hops`; on rejection build a task `TASK_STATE_REJECTED` with `status.message = { messageId, role: 'ROLE_AGENT', parts: [{ text }] }`, `metadata.ranch.rejection`, no artifacts, **before** the `isAgentConnected` check and without touching the hub (depends on T022) - [ ] T063 [US5] Extend `api/src/slices/agent/peer/domain/a2a.server.service.spec.ts`: chain containing the agent → rejected `loop`, `sendAndAwait` not called; chain of 3 → rejected `depth`; chain of 2 → proceeds; `A2A_MAX_CHAIN=1` respected; the rejection message names the agent (depends on T062) - [ ] T064 [P] [US5] Create `admin/slices/agent/peer/components/peer/Delegations.vue`: props `{ agentId: string }`; loads `store.loadDelegations(agentId)` on mount and exposes a **Refresh** button; `Table` with columns Peer (link), Task (truncated with `title`), Outcome (status pill + `errorCode` in product wording via a small map), Started (`TimeAgo`), Duration (`s.s s`); dashed empty state "No delegations yet."; mount it as section (3) of `components/peer/Tab.vue` unconditionally (depends on T040, T044) - [ ] T065 [US5] `cd admin && bun run typecheck`; run quickstart §6 (loop, live) and check A's and B's Recent delegations show the `answered` and `rejected` rows (depends on T062, T064) From 34d2e254d1e92ad1817ae754d28a97b2bf8dd4cd Mon Sep 17 00:00:00 2001 From: "Maksym Hryzodub [DREAM]" Date: Mon, 14 Sep 2026 13:00:41 +0300 Subject: [PATCH 08/31] feat(peer): connect, refresh and remove peers from the console (CLEAN-74) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01SZ9dCrzZqd5SwR4erLYe12 --- .../api/data/repositories/api/client.gen.ts | 20 +- .../setup/api/data/repositories/api/index.ts | 4 +- .../api/data/repositories/api/schemas.gen.ts | 6564 ++++++++-------- .../api/data/repositories/api/sdk.gen.ts | 6247 ++++++--------- .../api/data/repositories/api/types.gen.ts | 6673 +++++++++-------- .../agent/peer/domain/a2a.client.spec.ts | 255 + .../slices/agent/peer/domain/a2a.client.ts | 192 + api/src/slices/agent/peer/domain/index.ts | 2 + .../agent/peer/domain/peer.service.spec.ts | 289 + .../slices/agent/peer/domain/peer.service.ts | 210 + .../slices/agent/peer/dtos/agentCard.dto.ts | 100 + .../agent/peer/dtos/agentDelegation.dto.ts | 68 + .../slices/agent/peer/dtos/agentPeer.dto.ts | 70 + .../agent/peer/dtos/agentPeerCandidate.dto.ts | 21 + .../slices/agent/peer/dtos/connectPeer.dto.ts | 14 + api/src/slices/agent/peer/dtos/index.ts | 6 + .../peer/dtos/listDelegations.query.dto.ts | 18 + .../slices/agent/peer/peer.controller.spec.ts | 172 + api/src/slices/agent/peer/peer.controller.ts | 195 + api/src/slices/agent/peer/peer.module.ts | 7 +- specs/013-a2a-agent-peers/tasks.md | 18 +- 21 files changed, 10822 insertions(+), 10323 deletions(-) create mode 100644 api/src/slices/agent/peer/domain/a2a.client.spec.ts create mode 100644 api/src/slices/agent/peer/domain/a2a.client.ts create mode 100644 api/src/slices/agent/peer/domain/peer.service.spec.ts create mode 100644 api/src/slices/agent/peer/domain/peer.service.ts create mode 100644 api/src/slices/agent/peer/dtos/agentCard.dto.ts create mode 100644 api/src/slices/agent/peer/dtos/agentDelegation.dto.ts create mode 100644 api/src/slices/agent/peer/dtos/agentPeer.dto.ts create mode 100644 api/src/slices/agent/peer/dtos/agentPeerCandidate.dto.ts create mode 100644 api/src/slices/agent/peer/dtos/connectPeer.dto.ts create mode 100644 api/src/slices/agent/peer/dtos/index.ts create mode 100644 api/src/slices/agent/peer/dtos/listDelegations.query.dto.ts create mode 100644 api/src/slices/agent/peer/peer.controller.spec.ts create mode 100644 api/src/slices/agent/peer/peer.controller.ts diff --git a/admin/slices/setup/api/data/repositories/api/client.gen.ts b/admin/slices/setup/api/data/repositories/api/client.gen.ts index c0ae1bab..3fa47234 100644 --- a/admin/slices/setup/api/data/repositories/api/client.gen.ts +++ b/admin/slices/setup/api/data/repositories/api/client.gen.ts @@ -1,13 +1,8 @@ // This file is auto-generated by @hey-api/openapi-ts -import type { ClientOptions } from "./types.gen"; -import { - type Config, - type ClientOptions as DefaultClientOptions, - createClient, - createConfig, -} from "@hey-api/client-axios"; -import { createClientConfig } from "../../../api.config"; +import type { ClientOptions } from './types.gen'; +import { type Config, type ClientOptions as DefaultClientOptions, createClient, createConfig } from '@hey-api/client-axios'; +import { createClientConfig } from '../../../api.config'; /** * The `createClientConfig()` function will be called on client initialization @@ -17,11 +12,6 @@ import { createClientConfig } from "../../../api.config"; * `setConfig()`. This is useful for example if you're using Next.js * to ensure your client always has the correct values. */ -export type CreateClientConfig = - ( - override?: Config, - ) => Config & T>; +export type CreateClientConfig = (override?: Config) => Config & T>; -export const client = createClient( - createClientConfig(createConfig()), -); +export const client = createClient(createClientConfig(createConfig())); \ No newline at end of file diff --git a/admin/slices/setup/api/data/repositories/api/index.ts b/admin/slices/setup/api/data/repositories/api/index.ts index da870793..e64537d2 100644 --- a/admin/slices/setup/api/data/repositories/api/index.ts +++ b/admin/slices/setup/api/data/repositories/api/index.ts @@ -1,3 +1,3 @@ // This file is auto-generated by @hey-api/openapi-ts -export * from "./types.gen"; -export * from "./sdk.gen"; +export * from './types.gen'; +export * from './sdk.gen'; \ No newline at end of file diff --git a/admin/slices/setup/api/data/repositories/api/schemas.gen.ts b/admin/slices/setup/api/data/repositories/api/schemas.gen.ts index 9048d4fc..ad850ec9 100644 --- a/admin/slices/setup/api/data/repositories/api/schemas.gen.ts +++ b/admin/slices/setup/api/data/repositories/api/schemas.gen.ts @@ -1,3811 +1,3769 @@ // This file is auto-generated by @hey-api/openapi-ts export const CreateOwnerDtoSchema = { - type: "object", - properties: { - name: { - type: "string", - example: "Jane Doe", - }, - email: { - type: "string", - example: "owner@example.com", - }, - password: { - type: "string", - example: "strongPassword1", - minLength: 8, + type: 'object', + properties: { + name: { + type: 'string', + example: 'Jane Doe' + }, + email: { + type: 'string', + example: 'owner@example.com' + }, + password: { + type: 'string', + example: 'strongPassword1', + minLength: 8 + } }, - }, - required: ["name", "email", "password"], + required: ['name', 'email', 'password'] } as const; export const LoginDtoSchema = { - type: "object", - properties: { - email: { - type: "string", - example: "jane@example.com", - }, - password: { - type: "string", - example: "strongPassword1", - minLength: 8, + type: 'object', + properties: { + email: { + type: 'string', + example: 'jane@example.com' + }, + password: { + type: 'string', + example: 'strongPassword1', + minLength: 8 + } }, - }, - required: ["email", "password"], + required: ['email', 'password'] } as const; export const RegisterDtoSchema = { - type: "object", - properties: { - name: { - type: "string", - example: "Jane Doe", - }, - email: { - type: "string", - example: "jane@example.com", - }, - password: { - type: "string", - example: "strongPassword1", - minLength: 8, + type: 'object', + properties: { + name: { + type: 'string', + example: 'Jane Doe' + }, + email: { + type: 'string', + example: 'jane@example.com' + }, + password: { + type: 'string', + example: 'strongPassword1', + minLength: 8 + } }, - }, - required: ["name", "email", "password"], + required: ['name', 'email', 'password'] } as const; export const UserRoleTypesSchema = { - type: "string", - enum: ["Owner", "Admin", "User", "Agent"], - description: - "Server filters Owner/Admin out unless the presenting API key carries the embed:mint-admin scope; plain embed keys cannot grant platform-admin to a visitor.", + type: 'string', + enum: ['Owner', 'Admin', 'User', 'Agent'], + description: 'Server filters Owner/Admin out unless the presenting API key carries the embed:mint-admin scope; plain embed keys cannot grant platform-admin to a visitor.' } as const; export const EmbedTokenDtoSchema = { - type: "object", - properties: { - sub: { - type: "string", - description: - "Subject — used as clientId for routing inside the bridle hub.", - example: "user-123", - }, - email: { - type: "string", - example: "alice@example.com", - }, - roles: { - type: "array", - description: - "Server filters Owner/Admin out unless the presenting API key carries the embed:mint-admin scope; plain embed keys cannot grant platform-admin to a visitor.", - items: { - $ref: "#/components/schemas/UserRoleTypes", - }, - }, - expiresIn: { - type: "string", - description: "Duration string (s/m/h/d). Defaults to 15m.", - example: "15m", - }, - }, - required: ["sub"], + type: 'object', + properties: { + sub: { + type: 'string', + description: 'Subject — used as clientId for routing inside the bridle hub.', + example: 'user-123' + }, + email: { + type: 'string', + example: 'alice@example.com' + }, + roles: { + type: 'array', + description: 'Server filters Owner/Admin out unless the presenting API key carries the embed:mint-admin scope; plain embed keys cannot grant platform-admin to a visitor.', + items: { + '$ref': '#/components/schemas/UserRoleTypes' + } + }, + expiresIn: { + type: 'string', + description: 'Duration string (s/m/h/d). Defaults to 15m.', + example: '15m' + } + }, + required: ['sub'] } as const; export const UpsertSettingDtoSchema = { - type: "object", - properties: { - valueType: { - type: "string", - enum: ["string", "json"], - default: "string", - }, - value: { - type: "object", - description: "String for valueType=string; any JSON for valueType=json", + type: 'object', + properties: { + valueType: { + type: 'string', + enum: ['string', 'json'], + default: 'string' + }, + value: { + type: 'object', + description: 'String for valueType=string; any JSON for valueType=json' + } }, - }, - required: ["valueType", "value"], + required: ['valueType', 'value'] } as const; export const ApiKeyScopeTypesSchema = { - type: "string", - enum: ["embed:mint", "embed:mint-admin", "admin"], + type: 'string', + enum: ['embed:mint', 'embed:mint-admin', 'admin'] } as const; export const CreateApiKeyDtoSchema = { - type: "object", - properties: { - name: { - type: "string", - example: "Marketing site embed", - }, - scopes: { - type: "array", - example: ["embed:mint"], - items: { - $ref: "#/components/schemas/ApiKeyScopeTypes", - }, - }, - expiresAt: { - type: "string", - description: "ISO date string. Omit for a non-expiring key.", - example: "2027-01-01T00:00:00.000Z", + type: 'object', + properties: { + name: { + type: 'string', + example: 'Marketing site embed' + }, + scopes: { + type: 'array', + example: ['embed:mint'], + items: { + '$ref': '#/components/schemas/ApiKeyScopeTypes' + } + }, + expiresAt: { + type: 'string', + description: 'ISO date string. Omit for a non-expiring key.', + example: '2027-01-01T00:00:00.000Z' + } }, - }, - required: ["name", "scopes"], + required: ['name', 'scopes'] } as const; export const CreateLlmCredentialDtoSchema = { - type: "object", - properties: { - provider: { - type: "string", - example: "anthropic", - }, - model: { - type: "string", - example: "claude-sonnet-4-6", - }, - apiKey: { - type: "string", - }, - fallbackModel: { - type: "string", - example: "claude-haiku-4-5", - }, - label: { - type: "string", - }, - status: { - type: "string", - enum: ["active", "disabled"], - }, - supportsChat: { - type: "boolean", - default: true, - }, - supportsEmbedding: { - type: "boolean", - default: false, + type: 'object', + properties: { + provider: { + type: 'string', + example: 'anthropic' + }, + model: { + type: 'string', + example: 'claude-sonnet-4-6' + }, + apiKey: { + type: 'string' + }, + fallbackModel: { + type: 'string', + example: 'claude-haiku-4-5' + }, + label: { + type: 'string' + }, + status: { + type: 'string', + enum: ['active', 'disabled'] + }, + supportsChat: { + type: 'boolean', + default: true + }, + supportsEmbedding: { + type: 'boolean', + default: false + } }, - }, - required: ["provider", "model", "apiKey"], + required: ['provider', 'model', 'apiKey'] } as const; export const UpdateLlmCredentialDtoSchema = { - type: "object", - properties: { - provider: { - type: "string", - example: "anthropic", - }, - model: { - type: "string", - example: "claude-sonnet-4-6", - }, - apiKey: { - type: "string", - }, - fallbackModel: { - type: "string", - example: "claude-haiku-4-5", - }, - label: { - type: "string", - }, - status: { - type: "string", - enum: ["active", "disabled"], - }, - supportsChat: { - type: "boolean", - default: true, - }, - supportsEmbedding: { - type: "boolean", - default: false, - }, - }, + type: 'object', + properties: { + provider: { + type: 'string', + example: 'anthropic' + }, + model: { + type: 'string', + example: 'claude-sonnet-4-6' + }, + apiKey: { + type: 'string' + }, + fallbackModel: { + type: 'string', + example: 'claude-haiku-4-5' + }, + label: { + type: 'string' + }, + status: { + type: 'string', + enum: ['active', 'disabled'] + }, + supportsChat: { + type: 'boolean', + default: true + }, + supportsEmbedding: { + type: 'boolean', + default: false + } + } } as const; export const LlmHealthCheckResultDtoSchema = { - type: "object", - properties: { - ok: { - type: "boolean", - example: true, - }, - latencyMs: { - type: "number", - example: 482, - }, - provider: { - type: "string", - example: "anthropic", - }, - model: { - type: "string", - example: "claude-sonnet-4-6", - }, - error: { - type: "string", - example: "Anthropic 401: invalid x-api-key", + type: 'object', + properties: { + ok: { + type: 'boolean', + example: true + }, + latencyMs: { + type: 'number', + example: 482 + }, + provider: { + type: 'string', + example: 'anthropic' + }, + model: { + type: 'string', + example: 'claude-sonnet-4-6' + }, + error: { + type: 'string', + example: 'Anthropic 401: invalid x-api-key' + } }, - }, - required: ["ok", "latencyMs", "provider", "model"], + required: ['ok', 'latencyMs', 'provider', 'model'] } as const; export const CreateTemplateDtoSchema = { - type: "object", - properties: { - name: { - type: "string", - }, - description: { - type: "string", - }, - image: { - type: "string", - example: "ghcr.io/org/agent:latest", - }, - defaultConfig: { - type: "object", - }, - defaultResources: { - type: "object", - }, - paddockConfig: { - type: "object", - }, - defaultKnowledgeIds: { - type: "array", - items: { - type: "string", - }, + type: 'object', + properties: { + name: { + type: 'string' + }, + description: { + type: 'string' + }, + image: { + type: 'string', + example: 'ghcr.io/org/agent:latest' + }, + defaultConfig: { + type: 'object' + }, + defaultResources: { + type: 'object' + }, + paddockConfig: { + type: 'object' + }, + defaultKnowledgeIds: { + type: 'array', + items: { + type: 'string' + } + } }, - }, - required: ["name", "description", "image"], + required: ['name', 'description', 'image'] } as const; export const UpdateTemplateDtoSchema = { - type: "object", - properties: { - name: { - type: "string", - }, - description: { - type: "string", - }, - image: { - type: "string", - example: "ghcr.io/org/agent:latest", - }, - defaultConfig: { - type: "object", - }, - defaultResources: { - type: "object", - }, - paddockConfig: { - type: "object", - }, - defaultKnowledgeIds: { - type: "array", - items: { - type: "string", - }, - }, - }, + type: 'object', + properties: { + name: { + type: 'string' + }, + description: { + type: 'string' + }, + image: { + type: 'string', + example: 'ghcr.io/org/agent:latest' + }, + defaultConfig: { + type: 'object' + }, + defaultResources: { + type: 'object' + }, + paddockConfig: { + type: 'object' + }, + defaultKnowledgeIds: { + type: 'array', + items: { + type: 'string' + } + } + } } as const; export const SetTemplateSkillsDtoSchema = { - type: "object", - properties: { - skillIds: { - description: "Full list of skill IDs to attach. Replaces any prior set.", - type: "array", - items: { - type: "string", - }, + type: 'object', + properties: { + skillIds: { + description: 'Full list of skill IDs to attach. Replaces any prior set.', + type: 'array', + items: { + type: 'string' + } + } }, - }, - required: ["skillIds"], + required: ['skillIds'] } as const; export const SetTemplateMcpsDtoSchema = { - type: "object", - properties: { - mcpServerIds: { - description: - "Full list of MCP server IDs to attach. Replaces any prior set.", - type: "array", - items: { - type: "string", - }, + type: 'object', + properties: { + mcpServerIds: { + description: 'Full list of MCP server IDs to attach. Replaces any prior set.', + type: 'array', + items: { + type: 'string' + } + } }, - }, - required: ["mcpServerIds"], + required: ['mcpServerIds'] } as const; export const CreateMcpServerDtoSchema = { - type: "object", - properties: { - name: { - type: "string", - }, - description: { - type: "object", - }, - url: { - type: "string", - }, - transport: { - type: "string", - enum: ["streamableHttp", "sse"], - }, - authType: { - type: "string", - enum: ["none", "bearer", "header"], - }, - authValue: { - type: "object", - }, - enabled: { - type: "boolean", + type: 'object', + properties: { + name: { + type: 'string' + }, + description: { + type: 'object' + }, + url: { + type: 'string' + }, + transport: { + type: 'string', + enum: ['streamableHttp', 'sse'] + }, + authType: { + type: 'string', + enum: ['none', 'bearer', 'header', 'oauth'] + }, + authValue: { + type: 'object' + }, + enabled: { + type: 'boolean' + } }, - }, - required: ["name", "url"], + required: ['name', 'url'] } as const; export const UpdateMcpServerDtoSchema = { - type: "object", - properties: { - name: { - type: "string", - }, - description: { - type: "object", - }, - url: { - type: "string", - }, - transport: { - type: "string", - enum: ["streamableHttp", "sse"], - }, - authType: { - type: "string", - enum: ["none", "bearer", "header"], - }, - authValue: { - type: "object", - }, - enabled: { - type: "boolean", - }, - }, + type: 'object', + properties: { + name: { + type: 'string' + }, + description: { + type: 'object' + }, + url: { + type: 'string' + }, + transport: { + type: 'string', + enum: ['streamableHttp', 'sse'] + }, + authType: { + type: 'string', + enum: ['none', 'bearer', 'header', 'oauth'] + }, + authValue: { + type: 'object' + }, + enabled: { + type: 'boolean' + } + } } as const; export const KnowledgeListItemDtoSchema = { - type: "object", - properties: { - id: { - type: "string", - }, - name: { - type: "string", - }, - description: { - type: "string", - nullable: true, - }, - indexStatus: { - type: "string", - enum: ["idle", "indexing", "ready", "failed", "empty", "partial"], - description: - "Derived from the sources: empty (nothing added), indexing (a source is being processed), partial (some sources are not searchable), ready (every source answers).", - }, - indexError: { - type: "string", - nullable: true, - }, - indexedAt: { - type: "string", - nullable: true, - }, - indexStartedAt: { - type: "string", - nullable: true, - }, - sourceCount: { - type: "number", - description: "Sources attached to this knowledge", - }, - indexedCount: { - type: "number", - description: "Sources LightRAG confirmed as processed", - }, - failedCount: { - type: "number", - description: "Sources whose last index run recorded an error", - }, - processingCount: { - type: "number", - description: - "Sources handed to LightRAG that it has not finished processing. A ready knowledge with a non-zero count is searchable but not complete yet; run Index again once the pipeline drains.", - }, - indexRunAlive: { - type: "boolean", - description: - "True while the index run that set `indexing` is still executing in the API. False with `indexing` means the run is gone (rejected, timed out, or lost to a restart) and a new one may be started at once.", - }, - instanceState: { - type: "string", - enum: ["absent", "starting", "ready", "failed", "stopping"], - }, - instanceError: { - type: "string", - nullable: true, - }, - migrationState: { - type: "string", - enum: ["notStarted", "inProgress", "done", "failed"], - }, - createdAt: { - format: "date-time", - type: "string", - }, - updatedAt: { - format: "date-time", - type: "string", - }, - sourcesCount: { - type: "number", - }, - totalSizeBytes: { - type: "number", - }, - }, - required: [ - "id", - "name", - "description", - "indexStatus", - "indexError", - "indexedAt", - "indexStartedAt", - "sourceCount", - "indexedCount", - "failedCount", - "processingCount", - "indexRunAlive", - "instanceState", - "instanceError", - "migrationState", - "createdAt", - "updatedAt", - "sourcesCount", - "totalSizeBytes", - ], + type: 'object', + properties: { + id: { + type: 'string' + }, + name: { + type: 'string' + }, + description: { + type: 'string', + nullable: true + }, + indexStatus: { + type: 'string', + enum: ['idle', 'indexing', 'ready', 'failed', 'empty', 'partial'], + description: 'Derived from the sources: empty (nothing added), indexing (a source is being processed), partial (some sources are not searchable), ready (every source answers).' + }, + indexError: { + type: 'string', + nullable: true + }, + indexedAt: { + type: 'string', + nullable: true + }, + indexStartedAt: { + type: 'string', + nullable: true + }, + sourceCount: { + type: 'number', + description: 'Sources attached to this knowledge' + }, + indexedCount: { + type: 'number', + description: 'Sources LightRAG confirmed as processed' + }, + failedCount: { + type: 'number', + description: 'Sources whose last index run recorded an error' + }, + processingCount: { + type: 'number', + description: 'Sources handed to LightRAG that it has not finished processing. A ready knowledge with a non-zero count is searchable but not complete yet; run Index again once the pipeline drains.' + }, + indexRunAlive: { + type: 'boolean', + description: 'True while the index run that set `indexing` is still executing in the API. False with `indexing` means the run is gone (rejected, timed out, or lost to a restart) and a new one may be started at once.' + }, + instanceState: { + type: 'string', + enum: ['absent', 'starting', 'ready', 'failed', 'stopping'] + }, + instanceError: { + type: 'string', + nullable: true + }, + migrationState: { + type: 'string', + enum: ['notStarted', 'inProgress', 'done', 'failed'] + }, + createdAt: { + format: 'date-time', + type: 'string' + }, + updatedAt: { + format: 'date-time', + type: 'string' + }, + sourcesCount: { + type: 'number' + }, + totalSizeBytes: { + type: 'number' + } + }, + required: ['id', 'name', 'description', 'indexStatus', 'indexError', 'indexedAt', 'indexStartedAt', 'sourceCount', 'indexedCount', 'failedCount', 'processingCount', 'indexRunAlive', 'instanceState', 'instanceError', 'migrationState', 'createdAt', 'updatedAt', 'sourcesCount', 'totalSizeBytes'] } as const; export const KnowledgePageDtoSchema = { - type: "object", - properties: { - items: { - type: "array", - items: { - $ref: "#/components/schemas/KnowledgeListItemDto", - }, - }, - total: { - type: "number", - }, - page: { - type: "number", - }, - perPage: { - type: "number", + type: 'object', + properties: { + items: { + type: 'array', + items: { + '$ref': '#/components/schemas/KnowledgeListItemDto' + } + }, + total: { + type: 'number' + }, + page: { + type: 'number' + }, + perPage: { + type: 'number' + } }, - }, - required: ["items", "total", "page", "perPage"], + required: ['items', 'total', 'page', 'perPage'] } as const; export const GraphLabelsDtoSchema = { - type: "object", - properties: { - labels: { - type: "array", - items: { - type: "string", - }, - }, - total: { - type: "number", - }, - truncated: { - type: "boolean", + type: 'object', + properties: { + labels: { + type: 'array', + items: { + type: 'string' + } + }, + total: { + type: 'number' + }, + truncated: { + type: 'boolean' + } }, - }, - required: ["labels", "total", "truncated"], + required: ['labels', 'total', 'truncated'] } as const; export const GraphNodeDtoSchema = { - type: "object", - properties: { - id: { - type: "string", - }, - label: { - type: "string", - }, - entityType: { - type: "string", - }, - description: { - type: "string", + type: 'object', + properties: { + id: { + type: 'string' + }, + label: { + type: 'string' + }, + entityType: { + type: 'string' + }, + description: { + type: 'string' + } }, - }, - required: ["id", "label", "entityType", "description"], + required: ['id', 'label', 'entityType', 'description'] } as const; export const GraphEdgeDtoSchema = { - type: "object", - properties: { - id: { - type: "string", - }, - source: { - type: "string", - }, - target: { - type: "string", - }, - weight: { - type: "number", - }, - keywords: { - type: "string", - }, - description: { - type: "string", + type: 'object', + properties: { + id: { + type: 'string' + }, + source: { + type: 'string' + }, + target: { + type: 'string' + }, + weight: { + type: 'number' + }, + keywords: { + type: 'string' + }, + description: { + type: 'string' + } }, - }, - required: ["id", "source", "target", "weight", "keywords", "description"], + required: ['id', 'source', 'target', 'weight', 'keywords', 'description'] } as const; export const GraphDtoSchema = { - type: "object", - properties: { - nodes: { - type: "array", - items: { - $ref: "#/components/schemas/GraphNodeDto", - }, - }, - edges: { - type: "array", - items: { - $ref: "#/components/schemas/GraphEdgeDto", - }, - }, - isTruncated: { - type: "boolean", + type: 'object', + properties: { + nodes: { + type: 'array', + items: { + '$ref': '#/components/schemas/GraphNodeDto' + } + }, + edges: { + type: 'array', + items: { + '$ref': '#/components/schemas/GraphEdgeDto' + } + }, + isTruncated: { + type: 'boolean' + } }, - }, - required: ["nodes", "edges", "isTruncated"], + required: ['nodes', 'edges', 'isTruncated'] } as const; export const SourceTypeCountsDtoSchema = { - type: "object", - properties: { - file: { - type: "number", - }, - url: { - type: "number", - }, - text: { - type: "number", + type: 'object', + properties: { + file: { + type: 'number' + }, + url: { + type: 'number' + }, + text: { + type: 'number' + } }, - }, - required: ["file", "url", "text"], + required: ['file', 'url', 'text'] } as const; export const KnowledgeOverviewDtoSchema = { - type: "object", - properties: { - sourceCount: { - type: "number", - description: "Sources attached to this knowledge", - }, - indexedCount: { - type: "number", - description: "Sources LightRAG confirmed as processed", - }, - failedCount: { - type: "number", - }, - processingCount: { - type: "number", - description: "Handed to LightRAG and still in its pipeline", - }, - byType: { - $ref: "#/components/schemas/SourceTypeCountsDto", - }, - totalSizeBytes: { - type: "number", - description: "Sum of the stored files, in bytes", + type: 'object', + properties: { + sourceCount: { + type: 'number', + description: 'Sources attached to this knowledge' + }, + indexedCount: { + type: 'number', + description: 'Sources LightRAG confirmed as processed' + }, + failedCount: { + type: 'number' + }, + processingCount: { + type: 'number', + description: 'Handed to LightRAG and still in its pipeline' + }, + byType: { + '$ref': '#/components/schemas/SourceTypeCountsDto' + }, + totalSizeBytes: { + type: 'number', + description: 'Sum of the stored files, in bytes' + } }, - }, - required: [ - "sourceCount", - "indexedCount", - "failedCount", - "processingCount", - "byType", - "totalSizeBytes", - ], + required: ['sourceCount', 'indexedCount', 'failedCount', 'processingCount', 'byType', 'totalSizeBytes'] } as const; export const CreateKnowledgeDtoSchema = { - type: "object", - properties: { - name: { - type: "string", - }, - description: { - type: "string", + type: 'object', + properties: { + name: { + type: 'string' + }, + description: { + type: 'string' + } }, - }, - required: ["name"], + required: ['name'] } as const; export const UpdateKnowledgeDtoSchema = { - type: "object", - properties: { - name: { - type: "string", - }, - description: { - type: "string", - nullable: true, - }, - }, + type: 'object', + properties: { + name: { + type: 'string' + }, + description: { + type: 'string', + nullable: true + } + } } as const; export const QueryKnowledgeDtoSchema = { - type: "object", - properties: { - query: { - type: "string", - }, - mode: { - type: "string", - enum: ["hybrid", "local", "global", "naive"], - default: "hybrid", - }, - topK: { - type: "number", - default: 25, + type: 'object', + properties: { + query: { + type: 'string' + }, + mode: { + type: 'string', + enum: ['hybrid', 'local', 'global', 'naive'], + default: 'hybrid' + }, + topK: { + type: 'number', + default: 25 + } }, - }, - required: ["query"], + required: ['query'] } as const; export const KnowledgeQueryReferenceDtoSchema = { - type: "object", - properties: { - referenceId: { - type: "string", - }, - filePath: { - type: "string", - }, - sourceId: { - type: "string", - nullable: true, - }, - sourceName: { - type: "string", - nullable: true, + type: 'object', + properties: { + referenceId: { + type: 'string' + }, + filePath: { + type: 'string' + }, + sourceId: { + type: 'string', + nullable: true + }, + sourceName: { + type: 'string', + nullable: true + } }, - }, - required: ["referenceId", "filePath", "sourceId", "sourceName"], + required: ['referenceId', 'filePath', 'sourceId', 'sourceName'] } as const; export const KnowledgeQueryResultDtoSchema = { - type: "object", - properties: { - answer: { - type: "string", - nullable: true, - description: - "null when the base holds nothing relevant — see reason. Never a generated answer assembled from another base.", - }, - reason: { - type: "string", - enum: ["no_relevant_content"], - }, - knowledgeId: { - type: "string", - }, - complete: { - type: "boolean", - description: - "false while this base is still being re-processed into its own area — answers may be incomplete.", - }, - references: { - type: "array", - items: { - $ref: "#/components/schemas/KnowledgeQueryReferenceDto", - }, + type: 'object', + properties: { + answer: { + type: 'string', + nullable: true, + description: 'null when the base holds nothing relevant — see reason. Never a generated answer assembled from another base.' + }, + reason: { + type: 'string', + enum: ['no_relevant_content'] + }, + knowledgeId: { + type: 'string' + }, + complete: { + type: 'boolean', + description: 'false while this base is still being re-processed into its own area — answers may be incomplete.' + }, + references: { + type: 'array', + items: { + '$ref': '#/components/schemas/KnowledgeQueryReferenceDto' + } + } }, - }, - required: ["answer", "knowledgeId", "complete", "references"], + required: ['answer', 'knowledgeId', 'complete', 'references'] } as const; export const SourceDtoSchema = { - type: "object", - properties: { - id: { - type: "string", - }, - knowledgeId: { - type: "string", - }, - type: { - type: "string", - enum: ["file", "url", "text"], - }, - name: { - type: "string", - }, - url: { - type: "string", - nullable: true, - }, - mimeType: { - type: "string", - nullable: true, - }, - content: { - type: "string", - nullable: true, - }, - sizeBytes: { - type: "number", - nullable: true, - }, - indexed: { - type: "boolean", - description: - 'True when indexStatus is "indexed". Kept for older callers.', - }, - indexStatus: { - type: "string", - enum: ["indexed", "pending", "failed"], - }, - indexState: { - type: "string", - enum: ["queued", "processing", "indexed", "failed"], - }, - indexError: { - type: "string", - nullable: true, - description: - "Error from the last index run, null once the source indexes.", - }, - indexedAt: { - type: "string", - nullable: true, - }, - textState: { - type: "string", - enum: ["none", "pending", "ready", "failed"], - description: - "Text extraction for a PDF without a text layer: none (not a PDF, or it has its own text), pending (probing or OCR running), ready (recognised text is what gets indexed), failed (see textError).", - }, - textError: { - type: "string", - nullable: true, - }, - createdAt: { - format: "date-time", - type: "string", - }, - updatedAt: { - format: "date-time", - type: "string", - }, - }, - required: [ - "id", - "knowledgeId", - "type", - "name", - "url", - "mimeType", - "content", - "sizeBytes", - "indexed", - "indexStatus", - "indexState", - "indexError", - "indexedAt", - "textState", - "textError", - "createdAt", - "updatedAt", - ], + type: 'object', + properties: { + id: { + type: 'string' + }, + knowledgeId: { + type: 'string' + }, + type: { + type: 'string', + enum: ['file', 'url', 'text'] + }, + name: { + type: 'string' + }, + url: { + type: 'string', + nullable: true + }, + mimeType: { + type: 'string', + nullable: true + }, + content: { + type: 'string', + nullable: true + }, + sizeBytes: { + type: 'number', + nullable: true + }, + indexed: { + type: 'boolean', + description: 'True when indexStatus is "indexed". Kept for older callers.' + }, + indexStatus: { + type: 'string', + enum: ['indexed', 'pending', 'failed'] + }, + indexState: { + type: 'string', + enum: ['queued', 'processing', 'indexed', 'failed'] + }, + indexError: { + type: 'string', + nullable: true, + description: 'Error from the last index run, null once the source indexes.' + }, + indexedAt: { + type: 'string', + nullable: true + }, + textState: { + type: 'string', + enum: ['none', 'pending', 'ready', 'failed'], + description: 'Text extraction for a PDF without a text layer: none (not a PDF, or it has its own text), pending (probing or OCR running), ready (recognised text is what gets indexed), failed (see textError).' + }, + textError: { + type: 'string', + nullable: true + }, + createdAt: { + format: 'date-time', + type: 'string' + }, + updatedAt: { + format: 'date-time', + type: 'string' + } + }, + required: ['id', 'knowledgeId', 'type', 'name', 'url', 'mimeType', 'content', 'sizeBytes', 'indexed', 'indexStatus', 'indexState', 'indexError', 'indexedAt', 'textState', 'textError', 'createdAt', 'updatedAt'] } as const; export const SourcePageDtoSchema = { - type: "object", - properties: { - items: { - type: "array", - items: { - $ref: "#/components/schemas/SourceDto", - }, - }, - total: { - type: "number", - description: "Rows matching the filter across all pages", - }, - page: { - type: "number", - }, - perPage: { - type: "number", + type: 'object', + properties: { + items: { + type: 'array', + items: { + '$ref': '#/components/schemas/SourceDto' + } + }, + total: { + type: 'number', + description: 'Rows matching the filter across all pages' + }, + page: { + type: 'number' + }, + perPage: { + type: 'number' + } }, - }, - required: ["items", "total", "page", "perPage"], + required: ['items', 'total', 'page', 'perPage'] } as const; export const ImportJobDtoSchema = { - type: "object", - properties: { - id: { - type: "string", - }, - knowledgeId: { - type: "string", - }, - kind: { - type: "string", - enum: ["archive", "extraction"], - }, - status: { - type: "string", - enum: ["running", "done", "failed"], - }, - detected: { - type: "number", - description: "Ingestable entries found up front", - }, - added: { - type: "number", - }, - skipped: { - type: "number", - description: "Entries skipped because a source with that name exists", - }, - failed: { - type: "number", - }, - errors: { - description: 'First failures as ": ", capped', - type: "array", - items: { - type: "string", - }, - }, - startedAt: { - format: "date-time", - type: "string", - }, - finishedAt: { - type: "string", - nullable: true, - }, - }, - required: [ - "id", - "knowledgeId", - "kind", - "status", - "detected", - "added", - "skipped", - "failed", - "errors", - "startedAt", - "finishedAt", - ], + type: 'object', + properties: { + id: { + type: 'string' + }, + knowledgeId: { + type: 'string' + }, + kind: { + type: 'string', + enum: ['archive', 'extraction'] + }, + status: { + type: 'string', + enum: ['running', 'done', 'failed'] + }, + detected: { + type: 'number', + description: 'Ingestable entries found up front' + }, + added: { + type: 'number' + }, + skipped: { + type: 'number', + description: 'Entries skipped because a source with that name exists' + }, + failed: { + type: 'number' + }, + errors: { + description: 'First failures as ": ", capped', + type: 'array', + items: { + type: 'string' + } + }, + startedAt: { + format: 'date-time', + type: 'string' + }, + finishedAt: { + type: 'string', + nullable: true + } + }, + required: ['id', 'knowledgeId', 'kind', 'status', 'detected', 'added', 'skipped', 'failed', 'errors', 'startedAt', 'finishedAt'] } as const; export const CreateSourceDtoSchema = { - type: "object", - properties: { - type: { - type: "string", - enum: ["file", "url", "text"], - }, - name: { - type: "string", - }, - url: { - type: "string", - }, - content: { - type: "string", + type: 'object', + properties: { + type: { + type: 'string', + enum: ['file', 'url', 'text'] + }, + name: { + type: 'string' + }, + url: { + type: 'string' + }, + content: { + type: 'string' + } }, - }, - required: ["type", "name"], + required: ['type', 'name'] } as const; export const AddFilesResultDtoSchema = { - type: "object", - properties: { - added: { - type: "number", - example: 8, - description: "Files uploaded and registered.", - }, - skipped: { - type: "number", - example: 2, - description: - "Files skipped because a file source with the same name already exists on this knowledge.", - }, - failed: { - type: "number", - example: 1, - description: "Files that failed to upload.", - }, - errors: { - example: ["broken.pdf: S3 upload failed"], - description: "One line per failed file.", - type: "array", - items: { - type: "string", - }, - }, - }, - required: ["added", "skipped", "failed", "errors"], + type: 'object', + properties: { + added: { + type: 'number', + example: 8, + description: 'Files uploaded and registered.' + }, + skipped: { + type: 'number', + example: 2, + description: 'Files skipped because a file source with the same name already exists on this knowledge.' + }, + failed: { + type: 'number', + example: 1, + description: 'Files that failed to upload.' + }, + errors: { + example: ['broken.pdf: S3 upload failed'], + description: 'One line per failed file.', + type: 'array', + items: { + type: 'string' + } + } + }, + required: ['added', 'skipped', 'failed', 'errors'] } as const; export const AddFromSitemapDtoSchema = { - type: "object", - properties: { - sitemapUrl: { - type: "string", - example: "https://developer.paypal.com/sitemap.xml", - }, - urlPrefix: { - type: "string", - example: "https://developer.paypal.com/docs/checkout/", + type: 'object', + properties: { + sitemapUrl: { + type: 'string', + example: 'https://developer.paypal.com/sitemap.xml' + }, + urlPrefix: { + type: 'string', + example: 'https://developer.paypal.com/docs/checkout/' + } }, - }, - required: ["sitemapUrl"], + required: ['sitemapUrl'] } as const; export const AddFromSitemapResultDtoSchema = { - type: "object", - properties: { - added: { - type: "number", - example: 47, - }, - discovered: { - type: "number", - example: 51, + type: 'object', + properties: { + added: { + type: 'number', + example: 47 + }, + discovered: { + type: 'number', + example: 51 + } }, - }, - required: ["added", "discovered"], + required: ['added', 'discovered'] } as const; export const AddFromArchiveResultDtoSchema = { - type: "object", - properties: { - detected: { - type: "number", - example: 288, - description: - "Number of ingestable files detected in the archive. Import runs in the background; poll GET .../sources/imports for progress.", + type: 'object', + properties: { + detected: { + type: 'number', + example: 288, + description: 'Number of ingestable files detected in the archive. Import runs in the background; poll GET .../sources/imports for progress.' + }, + started: { + type: 'boolean', + example: true + }, + jobId: { + type: 'string', + description: 'Id of the background import job (see GET .../sources/imports)' + } }, - started: { - type: "boolean", - example: true, + required: ['detected', 'started', 'jobId'] +} as const; + +export const AgentDtoSchema = { + type: 'object', + properties: { + id: { + type: 'string' + }, + name: { + type: 'string' + }, + templateId: { + type: 'string' + }, + llmCredentialId: { + type: 'object', + nullable: true + }, + status: { + type: 'string', + enum: ['pending', 'deploying', 'running', 'failed', 'stopped', 'unreachable'] + }, + statusReason: { + type: 'string', + nullable: true, + description: `Human-readable reason accompanying status='failed' or 'unreachable' (e.g. "startup did not produce a running agent within 5 minutes", "ImagePullBackOff", "pod is running but the runtime never connected to the bridle hub…"). Also set during 'deploying' when bridle integration settings are empty. Null otherwise.` + }, + workflowId: { + type: 'object', + nullable: true + }, + firstDeployedAt: { + type: 'string', + nullable: true, + description: 'When this agent was first successfully deployed. Null ⇒ the agent has never been deployed.' + }, + lastDeployStartedAt: { + type: 'string', + nullable: true, + description: 'When the current/last deploy was started. Anchor of the server-side deploy grace window.' + }, + launchContext: { + type: 'string', + nullable: true, + enum: ['initial', 'restart'], + description: "Why the current/last deploy ran: 'initial' = first-ever start, 'restart' = any subsequent deploy (restart, start after stop, config-change redeploy). Null only for agents never deployed since this field existed." + }, + lastPullAt: { + type: 'string', + nullable: true, + description: 'When the running pod last pulled its working copy of the agent files from S3 (recorded at runtime boot). Null ⇒ agent not restarted since this field shipped. Files-tab freshness hint + sync-conflict baseline.' + }, + lastSyncAt: { + type: 'string', + nullable: true, + description: 'When the last successful Sync push completed. Null ⇒ never synced since this field shipped.' + }, + config: { + type: 'object' + }, + resources: { + type: 'object' + }, + debugEnabled: { + type: 'boolean', + description: 'When true, the agent runtime emits prompt-debug snapshots to admin clients via the bridle hub.' + }, + isPublic: { + type: 'boolean', + description: 'When true, the agent is visible on the public landing page to unauthenticated visitors.' + }, + allowedOrigins: { + description: 'Origins (scheme + host + port) authorized to open browser WebSockets to this bot without a JWT. Only consulted when isPublic=true.', + example: ['https://bridle.cleanslice.org', 'http://localhost:5173'], + type: 'array', + items: { + type: 'string' + } + }, + knowledgeIds: { + type: 'array', + items: { + type: 'string' + } + }, + isAdmin: { + type: 'boolean' + }, + createdAt: { + format: 'date-time', + type: 'string' + }, + updatedAt: { + format: 'date-time', + type: 'string' + } }, - jobId: { - type: "string", - description: - "Id of the background import job (see GET .../sources/imports)", + required: ['id', 'name', 'templateId', 'status', 'statusReason', 'workflowId', 'firstDeployedAt', 'lastDeployStartedAt', 'launchContext', 'lastPullAt', 'lastSyncAt', 'config', 'resources', 'debugEnabled', 'isPublic', 'allowedOrigins', 'knowledgeIds', 'isAdmin', 'createdAt', 'updatedAt'] +} as const; + +export const AgentPodStatusDtoSchema = { + type: 'object', + properties: { + agentId: { + type: 'string', + example: 'agent-abc-123' + }, + podName: { + type: 'string', + example: 'agent-agent-abc-123' + }, + phase: { + type: 'string', + enum: ['Pending', 'Running', 'Succeeded', 'Failed', 'Unknown'], + example: 'Running' + }, + ready: { + type: 'boolean', + example: true + }, + restartCount: { + type: 'number', + example: 0 + }, + startedAt: { + type: 'string', + nullable: true, + example: '2026-04-30T10:15:00Z' + }, + lastTerminationReason: { + type: 'string', + nullable: true, + example: 'OOMKilled' + }, + containerWaitingReason: { + type: 'string', + nullable: true, + example: 'CrashLoopBackOff' + }, + message: { + type: 'string', + nullable: true + }, + observedAt: { + type: 'string', + example: '2026-04-30T10:30:00Z' + } }, - }, - required: ["detected", "started", "jobId"], -} as const; - -export const AgentDtoSchema = { - type: "object", - properties: { - id: { - type: "string", - }, - name: { - type: "string", - }, - templateId: { - type: "string", - }, - llmCredentialId: { - type: "object", - nullable: true, - }, - status: { - type: "string", - enum: [ - "pending", - "deploying", - "running", - "failed", - "stopped", - "unreachable", - ], - }, - statusReason: { - type: "string", - nullable: true, - description: `Human-readable reason accompanying status='failed' or 'unreachable' (e.g. "startup did not produce a running agent within 5 minutes", "ImagePullBackOff", "pod is running but the runtime never connected to the bridle hub…"). Also set during 'deploying' when bridle integration settings are empty. Null otherwise.`, - }, - workflowId: { - type: "object", - nullable: true, - }, - firstDeployedAt: { - type: "string", - nullable: true, - description: - "When this agent was first successfully deployed. Null ⇒ the agent has never been deployed.", - }, - lastDeployStartedAt: { - type: "string", - nullable: true, - description: - "When the current/last deploy was started. Anchor of the server-side deploy grace window.", - }, - launchContext: { - type: "string", - nullable: true, - enum: ["initial", "restart"], - description: - "Why the current/last deploy ran: 'initial' = first-ever start, 'restart' = any subsequent deploy (restart, start after stop, config-change redeploy). Null only for agents never deployed since this field existed.", - }, - lastPullAt: { - type: "string", - nullable: true, - description: - "When the running pod last pulled its working copy of the agent files from S3 (recorded at runtime boot). Null ⇒ agent not restarted since this field shipped. Files-tab freshness hint + sync-conflict baseline.", - }, - lastSyncAt: { - type: "string", - nullable: true, - description: - "When the last successful Sync push completed. Null ⇒ never synced since this field shipped.", - }, - config: { - type: "object", - }, - resources: { - type: "object", - }, - debugEnabled: { - type: "boolean", - description: - "When true, the agent runtime emits prompt-debug snapshots to admin clients via the bridle hub.", - }, - isPublic: { - type: "boolean", - description: - "When true, the agent is visible on the public landing page to unauthenticated visitors.", - }, - allowedOrigins: { - description: - "Origins (scheme + host + port) authorized to open browser WebSockets to this bot without a JWT. Only consulted when isPublic=true.", - example: ["https://bridle.cleanslice.org", "http://localhost:5173"], - type: "array", - items: { - type: "string", - }, - }, - knowledgeIds: { - type: "array", - items: { - type: "string", - }, - }, - isAdmin: { - type: "boolean", - }, - createdAt: { - format: "date-time", - type: "string", - }, - updatedAt: { - format: "date-time", - type: "string", - }, - }, - required: [ - "id", - "name", - "templateId", - "status", - "statusReason", - "workflowId", - "firstDeployedAt", - "lastDeployStartedAt", - "launchContext", - "lastPullAt", - "lastSyncAt", - "config", - "resources", - "debugEnabled", - "isPublic", - "allowedOrigins", - "knowledgeIds", - "isAdmin", - "createdAt", - "updatedAt", - ], -} as const; - -export const AgentPodStatusDtoSchema = { - type: "object", - properties: { - agentId: { - type: "string", - example: "agent-abc-123", - }, - podName: { - type: "string", - example: "agent-agent-abc-123", - }, - phase: { - type: "string", - enum: ["Pending", "Running", "Succeeded", "Failed", "Unknown"], - example: "Running", - }, - ready: { - type: "boolean", - example: true, - }, - restartCount: { - type: "number", - example: 0, - }, - startedAt: { - type: "string", - nullable: true, - example: "2026-04-30T10:15:00Z", - }, - lastTerminationReason: { - type: "string", - nullable: true, - example: "OOMKilled", - }, - containerWaitingReason: { - type: "string", - nullable: true, - example: "CrashLoopBackOff", - }, - message: { - type: "string", - nullable: true, - }, - observedAt: { - type: "string", - example: "2026-04-30T10:30:00Z", - }, - }, - required: [ - "agentId", - "podName", - "phase", - "ready", - "restartCount", - "startedAt", - "lastTerminationReason", - "containerWaitingReason", - "message", - "observedAt", - ], + required: ['agentId', 'podName', 'phase', 'ready', 'restartCount', 'startedAt', 'lastTerminationReason', 'containerWaitingReason', 'message', 'observedAt'] } as const; export const AgentStatusDtoSchema = { - type: "object", - properties: { - agent: { - description: "Agent DB record (id, name, status, launchContext, etc.)", - allOf: [ - { - $ref: "#/components/schemas/AgentDto", + type: 'object', + properties: { + agent: { + description: 'Agent DB record (id, name, status, launchContext, etc.)', + allOf: [ + { + '$ref': '#/components/schemas/AgentDto' + } + ] }, - ], - }, - pod: { - nullable: true, - description: - "Live pod status; null if no pod is currently running for this agent.", - allOf: [ - { - $ref: "#/components/schemas/AgentPodStatusDto", + pod: { + nullable: true, + description: 'Live pod status; null if no pod is currently running for this agent.', + allOf: [ + { + '$ref': '#/components/schemas/AgentPodStatusDto' + } + ] }, - ], - }, - bridleConnected: { - type: "boolean", - description: - "Whether the agent runtime currently holds a live connection to the bridle hub. In-memory truth of the API process — false for a few seconds after an API restart until runtimes reconnect.", - example: true, + bridleConnected: { + type: 'boolean', + description: 'Whether the agent runtime currently holds a live connection to the bridle hub. In-memory truth of the API process — false for a few seconds after an API restart until runtimes reconnect.', + example: true + } }, - }, - required: ["agent", "pod", "bridleConnected"], + required: ['agent', 'pod', 'bridleConnected'] } as const; export const NodeCapacityDtoSchema = { - type: "object", - properties: { - name: { - type: "string", - example: "k3s-agent-gnk", - }, - freeCpuMilli: { - type: "number", - example: 3200, - description: "Allocatable CPU minus summed pod requests, in millicores", - }, - freeMemBytes: { - type: "number", - example: 6442450944, - description: "Allocatable memory minus summed pod requests, in bytes", - }, - freeSlots: { - type: "number", - example: 12, - description: "How many more agent pods fit on this node", + type: 'object', + properties: { + name: { + type: 'string', + example: 'k3s-agent-gnk' + }, + freeCpuMilli: { + type: 'number', + example: 3200, + description: 'Allocatable CPU minus summed pod requests, in millicores' + }, + freeMemBytes: { + type: 'number', + example: 6442450944, + description: 'Allocatable memory minus summed pod requests, in bytes' + }, + freeSlots: { + type: 'number', + example: 12, + description: 'How many more agent pods fit on this node' + } }, - }, - required: ["name", "freeCpuMilli", "freeMemBytes", "freeSlots"], + required: ['name', 'freeCpuMilli', 'freeMemBytes', 'freeSlots'] } as const; export const ClusterCapacityDtoSchema = { - type: "object", - properties: { - freeAgentSlots: { - type: "number", - example: 12, - description: - "How many more agents can start right now, across all agent nodes", - }, - usedAgentSlots: { - type: "number", - example: 8, - description: "Agents currently holding a slot (live pods + deploying)", - }, - totalAgentSlots: { - type: "number", - example: 20, - description: "usedAgentSlots + freeAgentSlots under current cluster load", - }, - slotCpuMilli: { - type: "number", - example: 100, - description: "CPU request one agent slot reserves, in millicores", - }, - slotMemBytes: { - type: "number", - example: 536870912, - description: "Memory request one agent slot reserves, in bytes", - }, - nodes: { - type: "array", - items: { - $ref: "#/components/schemas/NodeCapacityDto", - }, - }, - observedAt: { - type: "string", - example: "2026-07-29T12:00:00.000Z", - }, - }, - required: [ - "freeAgentSlots", - "usedAgentSlots", - "totalAgentSlots", - "slotCpuMilli", - "slotMemBytes", - "nodes", - "observedAt", - ], + type: 'object', + properties: { + freeAgentSlots: { + type: 'number', + example: 12, + description: 'How many more agents can start right now, across all agent nodes' + }, + usedAgentSlots: { + type: 'number', + example: 8, + description: 'Agents currently holding a slot (live pods + deploying)' + }, + totalAgentSlots: { + type: 'number', + example: 20, + description: 'usedAgentSlots + freeAgentSlots under current cluster load' + }, + slotCpuMilli: { + type: 'number', + example: 100, + description: 'CPU request one agent slot reserves, in millicores' + }, + slotMemBytes: { + type: 'number', + example: 536870912, + description: 'Memory request one agent slot reserves, in bytes' + }, + nodes: { + type: 'array', + items: { + '$ref': '#/components/schemas/NodeCapacityDto' + } + }, + observedAt: { + type: 'string', + example: '2026-07-29T12:00:00.000Z' + } + }, + required: ['freeAgentSlots', 'usedAgentSlots', 'totalAgentSlots', 'slotCpuMilli', 'slotMemBytes', 'nodes', 'observedAt'] } as const; export const AgentPodMetricsDtoSchema = { - type: "object", - properties: { - cpuMilli: { - type: "number", - example: 234, - description: "Current CPU usage in millicores", - }, - memBytes: { - type: "number", - example: 471859200, - description: "Current memory usage in bytes", - }, - cpuLimitMilli: { - type: "number", - example: 2000, - description: "CPU limit in millicores", - }, - memLimitBytes: { - type: "number", - example: 2147483648, - description: "Memory limit in bytes", + type: 'object', + properties: { + cpuMilli: { + type: 'number', + example: 234, + description: 'Current CPU usage in millicores' + }, + memBytes: { + type: 'number', + example: 471859200, + description: 'Current memory usage in bytes' + }, + cpuLimitMilli: { + type: 'number', + example: 2000, + description: 'CPU limit in millicores' + }, + memLimitBytes: { + type: 'number', + example: 2147483648, + description: 'Memory limit in bytes' + } }, - }, - required: ["cpuMilli", "memBytes", "cpuLimitMilli", "memLimitBytes"], + required: ['cpuMilli', 'memBytes', 'cpuLimitMilli', 'memLimitBytes'] } as const; export const AgentNodeMetricsDtoSchema = { - type: "object", - properties: { - name: { - type: "string", - example: "k3s-agent-gnk", - }, - diskAvailBytes: { - type: "number", - example: 157109764096, - }, - diskCapacityBytes: { - type: "number", - example: 163817959424, + type: 'object', + properties: { + name: { + type: 'string', + example: 'k3s-agent-gnk' + }, + diskAvailBytes: { + type: 'number', + example: 157109764096 + }, + diskCapacityBytes: { + type: 'number', + example: 163817959424 + } }, - }, - required: ["name", "diskAvailBytes", "diskCapacityBytes"], + required: ['name', 'diskAvailBytes', 'diskCapacityBytes'] } as const; export const AgentMetricsDtoSchema = { - type: "object", - properties: { - pod: { - $ref: "#/components/schemas/AgentPodMetricsDto", - }, - node: { - $ref: "#/components/schemas/AgentNodeMetricsDto", + type: 'object', + properties: { + pod: { + '$ref': '#/components/schemas/AgentPodMetricsDto' + }, + node: { + '$ref': '#/components/schemas/AgentNodeMetricsDto' + } }, - }, - required: ["pod", "node"], + required: ['pod', 'node'] } as const; export const AgentEnvVarDtoSchema = { - type: "object", - properties: { - name: { - type: "string", - example: "LOG_LEVEL", - }, - value: { - type: "string", - example: "debug", + type: 'object', + properties: { + name: { + type: 'string', + example: 'LOG_LEVEL' + }, + value: { + type: 'string', + example: 'debug' + } }, - }, - required: ["name", "value"], + required: ['name', 'value'] } as const; export const AgentMcpDtoSchema = { - type: "object", - properties: { - name: { - type: "string", - description: "Unique MCP server name (key in the runtime registry).", - }, - transport: { - type: "string", - enum: ["streamableHttp", "sse"], - description: "Transport protocol the runtime should use to connect.", - }, - url: { - type: "string", - description: "MCP server endpoint URL.", - }, - authType: { - type: "string", - enum: ["none", "bearer", "header"], - description: "Auth scheme for the connection.", - }, - authValue: { - type: "string", - nullable: true, - description: - "Auth credential. For `bearer`: raw token (runtime adds the `Bearer ` prefix). For `header`: literal `Header-Name: value` line. `null` when authType is `none`.", - }, - enabled: { - type: "boolean", - description: - "Always `true` in this list — disabled servers are filtered server-side. Kept for forward compatibility.", + type: 'object', + properties: { + id: { + type: 'string', + description: 'MCP server id. For `oauth` servers the runtime keys the per-agent token secret by this id (`mcpOauth:`).' + }, + name: { + type: 'string', + description: 'Unique MCP server name (key in the runtime registry).' + }, + transport: { + type: 'string', + enum: ['streamableHttp', 'sse'], + description: 'Transport protocol the runtime should use to connect.' + }, + url: { + type: 'string', + description: 'MCP server endpoint URL.' + }, + authType: { + type: 'string', + enum: ['none', 'bearer', 'header', 'oauth'], + description: 'Auth scheme. For `oauth` the runtime holds no static credential — it refreshes its own bearer from the per-agent token secret keyed by `id`.' + }, + authValue: { + type: 'string', + nullable: true, + description: 'Auth credential. For `bearer`: raw token (runtime adds the `Bearer ` prefix). For `header`: literal `Header-Name: value` line. `null` when authType is `none`.' + }, + enabled: { + type: 'boolean', + description: 'Always `true` in this list — disabled servers are filtered server-side. Kept for forward compatibility.' + } }, - }, - required: ["name", "transport", "url", "authType", "authValue", "enabled"], + required: ['id', 'name', 'transport', 'url', 'authType', 'authValue', 'enabled'] } as const; export const AgentResourcesDtoSchema = { - type: "object", - properties: { - cpu: { - type: "string", - example: "500m", - }, - memory: { - type: "string", - example: "512Mi", + type: 'object', + properties: { + cpu: { + type: 'string', + example: '500m' + }, + memory: { + type: 'string', + example: '512Mi' + } }, - }, - required: ["cpu", "memory"], + required: ['cpu', 'memory'] } as const; export const CreateAgentDtoSchema = { - type: "object", - properties: { - name: { - type: "string", - }, - templateId: { - type: "string", - }, - llmCredentialId: { - type: "string", - }, - config: { - type: "object", - }, - resources: { - $ref: "#/components/schemas/AgentResourcesDto", - }, - isPublic: { - type: "boolean", - description: - "When true, the agent is visible on the public landing page to unauthenticated visitors.", - }, - allowedOrigins: { - description: - "Origins (scheme + host + port) authorized to open browser WebSockets to this bot without a JWT. Only consulted when isPublic=true.", - example: ["https://bridle.cleanslice.org", "http://localhost:5173"], - type: "array", - items: { - type: "string", - }, - }, - knowledgeIds: { - type: "array", - items: { - type: "string", - }, - }, - isAdmin: { - type: "boolean", - description: - "When true, the agent is created as the Ranch admin on first deploy: any existing admin is demoted (and redeployed without RANCH_ADMIN), and this agent boots with RANCH_ADMIN=true + a service token. Single-admin invariant is enforced.", + type: 'object', + properties: { + name: { + type: 'string' + }, + templateId: { + type: 'string' + }, + llmCredentialId: { + type: 'string' + }, + config: { + type: 'object' + }, + resources: { + '$ref': '#/components/schemas/AgentResourcesDto' + }, + isPublic: { + type: 'boolean', + description: 'When true, the agent is visible on the public landing page to unauthenticated visitors.' + }, + allowedOrigins: { + description: 'Origins (scheme + host + port) authorized to open browser WebSockets to this bot without a JWT. Only consulted when isPublic=true.', + example: ['https://bridle.cleanslice.org', 'http://localhost:5173'], + type: 'array', + items: { + type: 'string' + } + }, + knowledgeIds: { + type: 'array', + items: { + type: 'string' + } + }, + isAdmin: { + type: 'boolean', + description: 'When true, the agent is created as the Ranch admin on first deploy: any existing admin is demoted (and redeployed without RANCH_ADMIN), and this agent boots with RANCH_ADMIN=true + a service token. Single-admin invariant is enforced.' + } }, - }, - required: ["name", "templateId"], + required: ['name', 'templateId'] } as const; export const UpdateAgentDtoSchema = { - type: "object", - properties: { - name: { - type: "string", - }, - templateId: { - type: "string", - }, - llmCredentialId: { - type: "string", - }, - config: { - type: "object", - }, - resources: { - $ref: "#/components/schemas/AgentResourcesDto", - }, - isPublic: { - type: "boolean", - description: - "When true, the agent is visible on the public landing page to unauthenticated visitors.", - }, - allowedOrigins: { - description: - "Origins (scheme + host + port) authorized to open browser WebSockets to this bot without a JWT. Only consulted when isPublic=true.", - example: ["https://bridle.cleanslice.org", "http://localhost:5173"], - type: "array", - items: { - type: "string", - }, - }, - knowledgeIds: { - type: "array", - items: { - type: "string", - }, - }, - isAdmin: { - type: "boolean", - description: - "When true, the agent is created as the Ranch admin on first deploy: any existing admin is demoted (and redeployed without RANCH_ADMIN), and this agent boots with RANCH_ADMIN=true + a service token. Single-admin invariant is enforced.", - }, - debugEnabled: { - type: "boolean", - description: - "Enable debug mode — emits the prompt-debug event stream and verbose pod logs (LOG_LEVEL=debug). The verbose-log half applies on the next agent restart; the prompt-debug stream flips live.", - }, - }, + type: 'object', + properties: { + name: { + type: 'string' + }, + templateId: { + type: 'string' + }, + llmCredentialId: { + type: 'string' + }, + config: { + type: 'object' + }, + resources: { + '$ref': '#/components/schemas/AgentResourcesDto' + }, + isPublic: { + type: 'boolean', + description: 'When true, the agent is visible on the public landing page to unauthenticated visitors.' + }, + allowedOrigins: { + description: 'Origins (scheme + host + port) authorized to open browser WebSockets to this bot without a JWT. Only consulted when isPublic=true.', + example: ['https://bridle.cleanslice.org', 'http://localhost:5173'], + type: 'array', + items: { + type: 'string' + } + }, + knowledgeIds: { + type: 'array', + items: { + type: 'string' + } + }, + isAdmin: { + type: 'boolean', + description: 'When true, the agent is created as the Ranch admin on first deploy: any existing admin is demoted (and redeployed without RANCH_ADMIN), and this agent boots with RANCH_ADMIN=true + a service token. Single-admin invariant is enforced.' + }, + debugEnabled: { + type: 'boolean', + description: 'Enable debug mode — emits the prompt-debug event stream and verbose pod logs (LOG_LEVEL=debug). The verbose-log half applies on the next agent restart; the prompt-debug stream flips live.' + } + } } as const; export const FileChunkDtoSchema = { - type: "object", - properties: { - path: { - type: "string", - example: "data/sessions/bridle:admin.jsonl", - }, - content: { - type: "string", - description: "UTF-8 slice of the file from `offset`.", - }, - size: { - type: "number", - example: 262144, - description: "Byte length of `content`.", - }, - totalSize: { - type: "number", - example: 393216, - description: "Full byte length of the file.", - }, - offset: { - type: "number", - example: 0, - description: "Byte offset of the first byte of `content`.", - }, - nextOffset: { - type: "number", - nullable: true, - example: 262144, - description: - "Pass as `offset` on the next request. `null` when there is no more data.", - }, - hasMore: { - type: "boolean", - example: true, - }, - updatedAt: { - type: "string", - format: "date-time", - }, - }, - required: [ - "path", - "content", - "size", - "totalSize", - "offset", - "nextOffset", - "hasMore", - "updatedAt", - ], + type: 'object', + properties: { + path: { + type: 'string', + example: 'data/sessions/bridle:admin.jsonl' + }, + content: { + type: 'string', + description: 'UTF-8 slice of the file from `offset`.' + }, + size: { + type: 'number', + example: 262144, + description: 'Byte length of `content`.' + }, + totalSize: { + type: 'number', + example: 393216, + description: 'Full byte length of the file.' + }, + offset: { + type: 'number', + example: 0, + description: 'Byte offset of the first byte of `content`.' + }, + nextOffset: { + type: 'number', + nullable: true, + example: 262144, + description: 'Pass as `offset` on the next request. `null` when there is no more data.' + }, + hasMore: { + type: 'boolean', + example: true + }, + updatedAt: { + type: 'string', + format: 'date-time' + } + }, + required: ['path', 'content', 'size', 'totalSize', 'offset', 'nextOffset', 'hasMore', 'updatedAt'] } as const; export const SaveFileDtoSchema = { - type: "object", - properties: { - content: { - type: "string", - description: "Full file content as text", + type: 'object', + properties: { + content: { + type: 'string', + description: 'Full file content as text' + } }, - }, - required: ["content"], + required: ['content'] } as const; export const DeleteFilesDtoSchema = { - type: "object", - properties: { - deleted: { - type: "number", - example: 3, - description: "Number of S3 objects deleted by this request.", + type: 'object', + properties: { + deleted: { + type: 'number', + example: 3, + description: 'Number of S3 objects deleted by this request.' + } }, - }, - required: ["deleted"], + required: ['deleted'] } as const; export const SyncFilesBodyDtoSchema = { - type: "object", - properties: { - confirm: { - type: "boolean", - description: - "Set to true to run the sync even when at-risk files were reported (the operator explicitly accepted the overwrite risk). Without it a non-empty at-risk list makes the endpoint answer 409 and skip the sync.", - }, - }, + type: 'object', + properties: { + confirm: { + type: 'boolean', + description: 'Set to true to run the sync even when at-risk files were reported (the operator explicitly accepted the overwrite risk). Without it a non-empty at-risk list makes the endpoint answer 409 and skip the sync.' + } + } } as const; export const AtRiskFileDtoSchema = { - type: "object", - properties: { - path: { - type: "string", - example: "SOUL.md", - }, - updatedAt: { - type: "string", - format: "date-time", - description: "When the S3 (shared) copy of this file was last modified", + type: 'object', + properties: { + path: { + type: 'string', + example: 'SOUL.md' + }, + updatedAt: { + type: 'string', + format: 'date-time', + description: 'When the S3 (shared) copy of this file was last modified' + } }, - }, - required: ["path", "updatedAt"], + required: ['path', 'updatedAt'] } as const; export const SyncConflictDtoSchema = { - type: "object", - properties: { - requiresConfirmation: { - type: "boolean", - description: - "Always true: the sync was NOT executed — resend with confirm=true to proceed", - }, - atRisk: { - description: - "S3 files modified after the pod last pulled/pushed. A sync MAY overwrite or delete them if the pod also changed them locally.", - type: "array", - items: { - $ref: "#/components/schemas/AtRiskFileDto", - }, - }, - baseline: { - type: "string", - format: "date-time", - description: - "Reference moment the S3 copies were compared against (max of last boot pull minus margin and last completed sync)", - }, - }, - required: ["requiresConfirmation", "atRisk", "baseline"], + type: 'object', + properties: { + requiresConfirmation: { + type: 'boolean', + description: 'Always true: the sync was NOT executed — resend with confirm=true to proceed' + }, + atRisk: { + description: 'S3 files modified after the pod last pulled/pushed. A sync MAY overwrite or delete them if the pod also changed them locally.', + type: 'array', + items: { + '$ref': '#/components/schemas/AtRiskFileDto' + } + }, + baseline: { + type: 'string', + format: 'date-time', + description: 'Reference moment the S3 copies were compared against (max of last boot pull minus margin and last completed sync)' + } + }, + required: ['requiresConfirmation', 'atRisk', 'baseline'] } as const; export const BridleTextPartDtoSchema = { - type: "object", - properties: { - type: { - type: "string", - enum: ["text", "image", "file"], - example: "text", - }, - text: { - type: "string", + type: 'object', + properties: { + type: { + type: 'string', + enum: ['text', 'image', 'file'], + example: 'text' + }, + text: { + type: 'string' + } }, - }, - required: ["type", "text"], + required: ['type', 'text'] } as const; export const BridleImagePartDtoSchema = { - type: "object", - properties: { - type: { - type: "string", - enum: ["text", "image", "file"], - example: "image", - }, - base64: { - type: "string", - description: "Base64-encoded image data", - }, - mediaType: { - type: "string", - description: "MIME type", - example: "image/jpeg", + type: 'object', + properties: { + type: { + type: 'string', + enum: ['text', 'image', 'file'], + example: 'image' + }, + base64: { + type: 'string', + description: 'Base64-encoded image data' + }, + mediaType: { + type: 'string', + description: 'MIME type', + example: 'image/jpeg' + } }, - }, - required: ["type", "base64", "mediaType"], + required: ['type', 'base64', 'mediaType'] } as const; export const SendMessageDtoSchema = { - type: "object", - properties: { - text: { - type: "string", - description: "Message text (plain-text shorthand)", - }, - parts: { - description: "Rich content parts. If omitted, built from text + images.", - type: "array", - items: { - $ref: "#/components/schemas/BridleTextPartDto", - }, - }, - images: { - description: "Attached images (legacy — prefer parts)", - type: "array", - items: { - $ref: "#/components/schemas/BridleImagePartDto", - }, - }, - attachmentIds: { - description: - "Ids from POST /api/agent/{agentId}/attachment. The API expands them server-side into parts — images as image content, text files with their contents inlined into the message, everything else as a named reference — and appends them to whatever `parts` resolved to. Omit the field and the request behaves exactly as before.", - type: "array", - items: { - type: "string", - }, - }, - }, - required: ["text"], + type: 'object', + properties: { + text: { + type: 'string', + description: 'Message text (plain-text shorthand)' + }, + parts: { + description: 'Rich content parts. If omitted, built from text + images.', + type: 'array', + items: { + '$ref': '#/components/schemas/BridleTextPartDto' + } + }, + images: { + description: 'Attached images (legacy — prefer parts)', + type: 'array', + items: { + '$ref': '#/components/schemas/BridleImagePartDto' + } + }, + attachmentIds: { + description: 'Ids from POST /api/agent/{agentId}/attachment. The API expands them server-side into parts — images as image content, text files with their contents inlined into the message, everything else as a named reference — and appends them to whatever `parts` resolved to. Omit the field and the request behaves exactly as before.', + type: 'array', + items: { + type: 'string' + } + } + }, + required: ['text'] } as const; export const BridleAttachmentDtoSchema = { - type: "object", - properties: { - id: { - type: "string", - description: "Attachment id, also the storage key stem", - }, - name: { - type: "string", - description: "Original filename, for display", - }, - mimeType: { - type: "string", - description: "Resolved MIME type", - example: "image/png", - }, - size: { - type: "number", - description: "Size in bytes", - }, - kind: { - type: "string", - enum: ["image", "text", "binary"], - description: - "How the attachment reaches the agent: image content, inlined text, or a named reference", - }, - url: { - type: "string", - description: - "Path of the authenticated download route. Never a direct storage URL.", - }, - readableByAgent: { - type: "boolean", - description: - "False when the agent will see only the file name, not its contents", - }, - }, - required: [ - "id", - "name", - "mimeType", - "size", - "kind", - "url", - "readableByAgent", - ], + type: 'object', + properties: { + id: { + type: 'string', + description: 'Attachment id, also the storage key stem' + }, + name: { + type: 'string', + description: 'Original filename, for display' + }, + mimeType: { + type: 'string', + description: 'Resolved MIME type', + example: 'image/png' + }, + size: { + type: 'number', + description: 'Size in bytes' + }, + kind: { + type: 'string', + enum: ['image', 'text', 'binary'], + description: 'How the attachment reaches the agent: image content, inlined text, or a named reference' + }, + url: { + type: 'string', + description: 'Path of the authenticated download route. Never a direct storage URL.' + }, + readableByAgent: { + type: 'boolean', + description: 'False when the agent will see only the file name, not its contents' + } + }, + required: ['id', 'name', 'mimeType', 'size', 'kind', 'url', 'readableByAgent'] } as const; export const BridleHealthDtoSchema = { - type: "object", - properties: { - ok: { - type: "boolean", - example: true, - }, - agentConnected: { - type: "boolean", - description: "Whether any agent runtime is connected via WebSocket", - }, - browserClients: { - type: "number", - description: "Number of browser clients connected", + type: 'object', + properties: { + ok: { + type: 'boolean', + example: true + }, + agentConnected: { + type: 'boolean', + description: 'Whether any agent runtime is connected via WebSocket' + }, + browserClients: { + type: 'number', + description: 'Number of browser clients connected' + } }, - }, - required: ["ok", "agentConnected", "browserClients"], + required: ['ok', 'agentConnected', 'browserClients'] } as const; export const BridleAgentHealthDtoSchema = { - type: "object", - properties: { - ok: { - type: "boolean", - example: true, - }, - agentConnected: { - type: "boolean", - description: "Whether this agent is connected via WebSocket", - }, - browserClients: { - type: "number", - description: "Number of browser clients connected to this agent", - }, - agentId: { - type: "string", - description: "Bot identifier", + type: 'object', + properties: { + ok: { + type: 'boolean', + example: true + }, + agentConnected: { + type: 'boolean', + description: 'Whether this agent is connected via WebSocket' + }, + browserClients: { + type: 'number', + description: 'Number of browser clients connected to this agent' + }, + agentId: { + type: 'string', + description: 'Bot identifier' + } }, - }, - required: ["ok", "agentConnected", "browserClients", "agentId"], + required: ['ok', 'agentConnected', 'browserClients', 'agentId'] } as const; export const TranscriptAttachmentDtoSchema = { - type: "object", - properties: { - id: { - type: "string", - example: "0b53c9a4-7f4e-4bb1-a6b1-6a1f2f9c8f21", - }, - name: { - type: "string", - example: "screenshot.png", - }, - mimeType: { - type: "string", - example: "image/png", - }, - size: { - type: "number", - example: 48213, - }, - kind: { - type: "string", - enum: ["image", "text", "binary"], - example: "image", + type: 'object', + properties: { + id: { + type: 'string', + example: '0b53c9a4-7f4e-4bb1-a6b1-6a1f2f9c8f21' + }, + name: { + type: 'string', + example: 'screenshot.png' + }, + mimeType: { + type: 'string', + example: 'image/png' + }, + size: { + type: 'number', + example: 48213 + }, + kind: { + type: 'string', + enum: ['image', 'text', 'binary'], + example: 'image' + } }, - }, - required: ["id", "name", "mimeType", "size", "kind"], + required: ['id', 'name', 'mimeType', 'size', 'kind'] } as const; export const TranscriptMessageDtoSchema = { - type: "object", - properties: { - id: { - type: "string", - example: "c94dbcf2-64f1-4e84-9723-c94e2d815f61", - }, - role: { - type: "string", - enum: ["user", "assistant"], - example: "assistant", - }, - text: { - type: "string", - example: "Hello, how can I help?", - description: - "For user messages: what the person typed. Attachment contents the API inlined for the model are not included — see `agentText`.", - }, - ts: { - type: "number", - example: 1777562539964, - description: "Unix epoch milliseconds.", - }, - attachments: { - description: - "Stored-attachment references for files sent with this message. Fetch the bytes via GET /api/agent/{agentId}/attachment/{id}.", - type: "array", - items: { - $ref: "#/components/schemas/TranscriptAttachmentDto", - }, - }, - agentText: { - type: "string", - description: - "User messages with attachments only: the full text the model received (typed text plus the inlined attachment blocks). For inspection; not meant to be rendered as the bubble.", - }, - }, - required: ["id", "role", "text", "ts"], + type: 'object', + properties: { + id: { + type: 'string', + example: 'c94dbcf2-64f1-4e84-9723-c94e2d815f61' + }, + role: { + type: 'string', + enum: ['user', 'assistant'], + example: 'assistant' + }, + text: { + type: 'string', + example: 'Hello, how can I help?', + description: 'For user messages: what the person typed. Attachment contents the API inlined for the model are not included — see `agentText`.' + }, + ts: { + type: 'number', + example: 1777562539964, + description: 'Unix epoch milliseconds.' + }, + attachments: { + description: 'Stored-attachment references for files sent with this message. Fetch the bytes via GET /api/agent/{agentId}/attachment/{id}.', + type: 'array', + items: { + '$ref': '#/components/schemas/TranscriptAttachmentDto' + } + }, + agentText: { + type: 'string', + description: 'User messages with attachments only: the full text the model received (typed text plus the inlined attachment blocks). For inspection; not meant to be rendered as the bubble.' + } + }, + required: ['id', 'role', 'text', 'ts'] } as const; export const TranscriptResponseDtoSchema = { - type: "object", - properties: { - messages: { - type: "array", - items: { - $ref: "#/components/schemas/TranscriptMessageDto", - }, - }, - channel: { - type: "string", - example: "admin", - description: "Channel the transcript was loaded from.", - }, - nextCursor: { - type: "string", - nullable: true, - description: - "Pass back as `cursor` to fetch the previous page. `null` when no older messages.", - }, - hasMore: { - type: "boolean", - example: false, + type: 'object', + properties: { + messages: { + type: 'array', + items: { + '$ref': '#/components/schemas/TranscriptMessageDto' + } + }, + channel: { + type: 'string', + example: 'admin', + description: 'Channel the transcript was loaded from.' + }, + nextCursor: { + type: 'string', + nullable: true, + description: 'Pass back as `cursor` to fetch the previous page. `null` when no older messages.' + }, + hasMore: { + type: 'boolean', + example: false + } }, - }, - required: ["messages", "channel", "nextCursor", "hasMore"], + required: ['messages', 'channel', 'nextCursor', 'hasMore'] } as const; export const ShareLinkDtoSchema = { - type: "object", - properties: { - active: { - type: "boolean", - description: - "True while the link accepts visitors. False when the agent was never shared or the link has been revoked.", - example: true, - }, - token: { - type: "string", - nullable: true, - description: - "The share secret. Exposed only while the link is active — a revoked token is dead and is never handed back, so this is null whenever active is false.", - example: "sl_mCV1jC5G3nre2dz7hEx7Y8PnbwfyZTVaTKJ8L2SAaDU", - }, - createdAt: { - type: "string", - format: "date-time", - nullable: true, - description: "When the link row was first created; null if never shared.", - example: "2026-09-07T10:00:00.000Z", - }, - revokedAt: { - type: "string", - format: "date-time", - nullable: true, - description: "When the link was revoked; null while it is active.", - example: null, - }, - rotatedAt: { - type: "string", - format: "date-time", - nullable: true, - description: - "When the token was last replaced; null until the first regenerate.", - example: null, - }, - rotationCount: { - type: "number", - description: "How many times the token has been replaced.", - example: 0, - }, - }, - required: [ - "active", - "token", - "createdAt", - "revokedAt", - "rotatedAt", - "rotationCount", - ], + type: 'object', + properties: { + active: { + type: 'boolean', + description: 'True while the link accepts visitors. False when the agent was never shared or the link has been revoked.', + example: true + }, + token: { + type: 'string', + nullable: true, + description: 'The share secret. Exposed only while the link is active — a revoked token is dead and is never handed back, so this is null whenever active is false.', + example: 'sl_mCV1jC5G3nre2dz7hEx7Y8PnbwfyZTVaTKJ8L2SAaDU' + }, + createdAt: { + type: 'string', + format: 'date-time', + nullable: true, + description: 'When the link row was first created; null if never shared.', + example: '2026-09-07T10:00:00.000Z' + }, + revokedAt: { + type: 'string', + format: 'date-time', + nullable: true, + description: 'When the link was revoked; null while it is active.', + example: null + }, + rotatedAt: { + type: 'string', + format: 'date-time', + nullable: true, + description: 'When the token was last replaced; null until the first regenerate.', + example: null + }, + rotationCount: { + type: 'number', + description: 'How many times the token has been replaced.', + example: 0 + } + }, + required: ['active', 'token', 'createdAt', 'revokedAt', 'rotatedAt', 'rotationCount'] } as const; export const ShareResolveRequestDtoSchema = { - type: "object", - properties: { - token: { - type: "string", - description: - "The share token from the link (`sl_` + 43 url-safe characters).", - pattern: "^sl_[A-Za-z0-9_-]{43}$", - example: "sl_mCV1jC5G3nre2dz7hEx7Y8PnbwfyZTVaTKJ8L2SAaDU", + type: 'object', + properties: { + token: { + type: 'string', + description: 'The share token from the link (`sl_` + 43 url-safe characters).', + pattern: '^sl_[A-Za-z0-9_-]{43}$', + example: 'sl_mCV1jC5G3nre2dz7hEx7Y8PnbwfyZTVaTKJ8L2SAaDU' + } }, - }, - required: ["token"], + required: ['token'] } as const; export const ShareResolvedDtoSchema = { - type: "object", - properties: { - agentId: { - type: "string", - description: "Id of the shared agent — used for the chat requests.", - example: "a1b2c3d4-0000-4000-8000-000000000001", - }, - agentName: { - type: "string", - description: "Display name of the shared agent.", - example: "Support bot", - }, - agentStatus: { - type: "string", - description: - "The agent's persisted status (running | unreachable | deploying | stopped | failed | …). 'running' means the chat is live.", - example: "running", + type: 'object', + properties: { + agentId: { + type: 'string', + description: 'Id of the shared agent — used for the chat requests.', + example: 'a1b2c3d4-0000-4000-8000-000000000001' + }, + agentName: { + type: 'string', + description: 'Display name of the shared agent.', + example: 'Support bot' + }, + agentStatus: { + type: 'string', + description: "The agent's persisted status (running | unreachable | deploying | stopped | failed | …). 'running' means the chat is live.", + example: 'running' + } }, - }, - required: ["agentId", "agentName", "agentStatus"], + required: ['agentId', 'agentName', 'agentStatus'] } as const; export const ImportSkillUrlDtoSchema = { - type: "object", - properties: { - url: { - type: "string", - description: - "GitHub URL — folder (tree//) or file (blob//). The folder must contain a SKILL.md or README.md.", - example: - "https://github.com/supabase/agent-skills/tree/main/skills/supabase-postgres-best-practices", - }, - name: { - type: "string", - }, - overwrite: { - type: "boolean", - description: - "If true and a skill with the same slug already exists, fully replace it instead of returning 409.", + type: 'object', + properties: { + url: { + type: 'string', + description: 'GitHub URL — folder (tree//) or file (blob//). The folder must contain a SKILL.md or README.md.', + example: 'https://github.com/supabase/agent-skills/tree/main/skills/supabase-postgres-best-practices' + }, + name: { + type: 'string' + }, + overwrite: { + type: 'boolean', + description: 'If true and a skill with the same slug already exists, fully replace it instead of returning 409.' + } }, - }, - required: ["url"], + required: ['url'] } as const; export const ImportSkillDtoSchema = { - type: "object", - properties: { - repo: { - type: "string", - example: "anthropics/skills", - description: "GitHub owner/repo as returned by /skills/search", - }, - path: { - type: "string", - example: "pdf-skill/SKILL.md", - description: "Path to the SKILL.md file inside the repo", - }, - name: { - type: "string", - description: - "Override the auto-derived slug. Lowercase letters, digits and dashes.", - }, - overwrite: { - type: "boolean", - description: - "If true and a skill with the same slug already exists, fully replace it instead of returning 409.", + type: 'object', + properties: { + repo: { + type: 'string', + example: 'anthropics/skills', + description: 'GitHub owner/repo as returned by /skills/search' + }, + path: { + type: 'string', + example: 'pdf-skill/SKILL.md', + description: 'Path to the SKILL.md file inside the repo' + }, + name: { + type: 'string', + description: 'Override the auto-derived slug. Lowercase letters, digits and dashes.' + }, + overwrite: { + type: 'boolean', + description: 'If true and a skill with the same slug already exists, fully replace it instead of returning 409.' + } }, - }, - required: ["repo", "path"], + required: ['repo', 'path'] } as const; export const CreateSkillDtoSchema = { - type: "object", - properties: { - name: { - type: "string", - example: "devops", - description: "Unique slug — lowercase letters, digits, dashes", - }, - title: { - type: "string", - example: "DevOps engineer", - }, - body: { - type: "string", - description: "Markdown body of the skill", - }, - description: { - type: "string", + type: 'object', + properties: { + name: { + type: 'string', + example: 'devops', + description: 'Unique slug — lowercase letters, digits, dashes' + }, + title: { + type: 'string', + example: 'DevOps engineer' + }, + body: { + type: 'string', + description: 'Markdown body of the skill' + }, + description: { + type: 'string' + } }, - }, - required: ["name", "title", "body"], + required: ['name', 'title', 'body'] } as const; export const UpdateSkillDtoSchema = { - type: "object", - properties: { - name: { - type: "string", - example: "devops", - description: "Unique slug — lowercase letters, digits, dashes", - }, - title: { - type: "string", - example: "DevOps engineer", - }, - body: { - type: "string", - description: "Markdown body of the skill", - }, - description: { - type: "string", - }, - }, + type: 'object', + properties: { + name: { + type: 'string', + example: 'devops', + description: 'Unique slug — lowercase letters, digits, dashes' + }, + title: { + type: 'string', + example: 'DevOps engineer' + }, + body: { + type: 'string', + description: 'Markdown body of the skill' + }, + description: { + type: 'string' + } + } } as const; export const ChatSessionDtoSchema = { - type: "object", - properties: { - id: { - type: "string", - example: "chat-9f2c…", - }, - agentId: { - type: "string", - }, - channel: { - type: "string", - enum: ["bridle", "telegram", "slack", "internal"], - example: "bridle", - }, - externalUserId: { - type: "string", - example: "admin", - }, - sessionKey: { - type: "string", - example: "bridle:admin", - }, - title: { - type: "object", - nullable: true, - }, - preview: { - type: "object", - nullable: true, - description: "Last message text, truncated", - }, - lastRole: { - type: "string", - enum: ["user", "assistant"], - nullable: true, - }, - lastMessageAt: { - format: "date-time", - type: "string", - description: "Unix ms via ISO", - example: "2026-07-15T09:12:00.000Z", - }, - messageCount: { - type: "number", - description: "Monotonic lifetime total", - }, - userMessageCount: { - type: "number", - }, - summary: { - type: "object", - nullable: true, - }, - summaryAt: { - type: "object", - nullable: true, - }, - insights: { - type: "object", - nullable: true, - description: "topics/sentiment/resolved/language", - }, - archived: { - type: "boolean", - }, - createdAt: { - format: "date-time", - type: "string", - }, - updatedAt: { - format: "date-time", - type: "string", - }, - }, - required: [ - "id", - "agentId", - "channel", - "externalUserId", - "sessionKey", - "lastMessageAt", - "messageCount", - "userMessageCount", - "archived", - "createdAt", - "updatedAt", - ], + type: 'object', + properties: { + id: { + type: 'string', + example: 'chat-9f2c…' + }, + agentId: { + type: 'string' + }, + channel: { + type: 'string', + enum: ['bridle', 'telegram', 'slack', 'internal'], + example: 'bridle' + }, + externalUserId: { + type: 'string', + example: 'admin' + }, + sessionKey: { + type: 'string', + example: 'bridle:admin' + }, + title: { + type: 'object', + nullable: true + }, + preview: { + type: 'object', + nullable: true, + description: 'Last message text, truncated' + }, + lastRole: { + type: 'string', + enum: ['user', 'assistant'], + nullable: true + }, + lastMessageAt: { + format: 'date-time', + type: 'string', + description: 'Unix ms via ISO', + example: '2026-07-15T09:12:00.000Z' + }, + messageCount: { + type: 'number', + description: 'Monotonic lifetime total' + }, + userMessageCount: { + type: 'number' + }, + summary: { + type: 'object', + nullable: true + }, + summaryAt: { + type: 'object', + nullable: true + }, + insights: { + type: 'object', + nullable: true, + description: 'topics/sentiment/resolved/language' + }, + archived: { + type: 'boolean' + }, + createdAt: { + format: 'date-time', + type: 'string' + }, + updatedAt: { + format: 'date-time', + type: 'string' + } + }, + required: ['id', 'agentId', 'channel', 'externalUserId', 'sessionKey', 'lastMessageAt', 'messageCount', 'userMessageCount', 'archived', 'createdAt', 'updatedAt'] } as const; export const ChatListResponseDtoSchema = { - type: "object", - properties: { - items: { - type: "array", - items: { - $ref: "#/components/schemas/ChatSessionDto", - }, - }, - total: { - type: "number", - example: 128, - }, - page: { - type: "number", - example: 1, - }, - perPage: { - type: "number", - example: 50, + type: 'object', + properties: { + items: { + type: 'array', + items: { + '$ref': '#/components/schemas/ChatSessionDto' + } + }, + total: { + type: 'number', + example: 128 + }, + page: { + type: 'number', + example: 1 + }, + perPage: { + type: 'number', + example: 50 + } }, - }, - required: ["items", "total", "page", "perPage"], + required: ['items', 'total', 'page', 'perPage'] } as const; export const ChatMessageDtoSchema = { - type: "object", - properties: { - id: { - type: "string", - example: "c94dbcf2-…", - }, - role: { - type: "string", - enum: [ - "user", - "assistant", - "summary", - "tool_call", - "tool_result", - "system", - ], - example: "assistant", - }, - text: { - type: "string", - example: "Hello, how can I help?", - description: - "For user messages: what the person typed, without the attachment contents the API inlined for the model.", - }, - ts: { - type: "number", - example: 1777562539964, - description: "Unix epoch ms", - }, - attachments: { - description: - "Files sent with this message (metadata only; history has no download route).", - type: "array", - items: { - $ref: "#/components/schemas/TranscriptAttachmentDto", - }, - }, - agentText: { - type: "string", - description: - "Admin debug views only (present when `types` includes tool events): the full text the model received for a user message with attachments.", - }, - }, - required: ["id", "role", "text", "ts"], + type: 'object', + properties: { + id: { + type: 'string', + example: 'c94dbcf2-…' + }, + role: { + type: 'string', + enum: ['user', 'assistant', 'summary', 'tool_call', 'tool_result', 'system'], + example: 'assistant' + }, + text: { + type: 'string', + example: 'Hello, how can I help?', + description: 'For user messages: what the person typed, without the attachment contents the API inlined for the model.' + }, + ts: { + type: 'number', + example: 1777562539964, + description: 'Unix epoch ms' + }, + attachments: { + description: 'Files sent with this message (metadata only; history has no download route).', + type: 'array', + items: { + '$ref': '#/components/schemas/TranscriptAttachmentDto' + } + }, + agentText: { + type: 'string', + description: 'Admin debug views only (present when `types` includes tool events): the full text the model received for a user message with attachments.' + } + }, + required: ['id', 'role', 'text', 'ts'] } as const; export const ChatMessagesResponseDtoSchema = { - type: "object", - properties: { - messages: { - type: "array", - items: { - $ref: "#/components/schemas/ChatMessageDto", - }, - }, - nextCursor: { - type: "object", - nullable: true, - description: "Pass to fetch the previous (older) page", - }, - hasMore: { - type: "boolean", + type: 'object', + properties: { + messages: { + type: 'array', + items: { + '$ref': '#/components/schemas/ChatMessageDto' + } + }, + nextCursor: { + type: 'object', + nullable: true, + description: 'Pass to fetch the previous (older) page' + }, + hasMore: { + type: 'boolean' + } }, - }, - required: ["messages", "hasMore"], + required: ['messages', 'hasMore'] } as const; export const SyncChatsDtoSchema = { - type: "object", - properties: { - agentId: { - type: "string", - description: "Reconcile only this agent; omit for all agents", - }, - }, + type: 'object', + properties: { + agentId: { + type: 'string', + description: 'Reconcile only this agent; omit for all agents' + } + } } as const; export const SyncChatsResponseDtoSchema = { - type: "object", - properties: { - scannedAgents: { - type: "number", - }, - scannedFiles: { - type: "number", - }, - upserted: { - type: "number", - }, - skipped: { - type: "number", + type: 'object', + properties: { + scannedAgents: { + type: 'number' + }, + scannedFiles: { + type: 'number' + }, + upserted: { + type: 'number' + }, + skipped: { + type: 'number' + } }, - }, - required: ["scannedAgents", "scannedFiles", "upserted", "skipped"], + required: ['scannedAgents', 'scannedFiles', 'upserted', 'skipped'] } as const; export const CreateChatFeedbackDtoSchema = { - type: "object", - properties: { - messageId: { - type: "string", - description: "Event.id of the rated assistant message", - }, - rating: { - type: "number", - enum: [1, -1], - description: "1 = 👍, -1 = 👎", - }, - comment: { - type: "string", + type: 'object', + properties: { + messageId: { + type: 'string', + description: 'Event.id of the rated assistant message' + }, + rating: { + type: 'number', + enum: [1, -1], + description: '1 = 👍, -1 = 👎' + }, + comment: { + type: 'string' + } }, - }, - required: ["messageId", "rating"], + required: ['messageId', 'rating'] } as const; export const ChatFeedbackDtoSchema = { - type: "object", - properties: { - id: { - type: "string", - }, - messageId: { - type: "string", - }, - rating: { - type: "number", - enum: [1, -1], - }, - comment: { - type: "object", - nullable: true, - }, - source: { - type: "string", - example: "admin", - }, - authorId: { - type: "object", - nullable: true, - }, - createdAt: { - format: "date-time", - type: "string", + type: 'object', + properties: { + id: { + type: 'string' + }, + messageId: { + type: 'string' + }, + rating: { + type: 'number', + enum: [1, -1] + }, + comment: { + type: 'object', + nullable: true + }, + source: { + type: 'string', + example: 'admin' + }, + authorId: { + type: 'object', + nullable: true + }, + createdAt: { + format: 'date-time', + type: 'string' + } }, - }, - required: ["id", "messageId", "rating", "source", "createdAt"], + required: ['id', 'messageId', 'rating', 'source', 'createdAt'] } as const; export const TelegramChannelConfigDtoSchema = { - type: "object", - properties: { - botToken: { - type: "string", - description: "Telegram bot HTTP API token (issued by @BotFather).", - }, - botName: { - type: "string", - description: "Public bot username without @ — shown on landing pages.", - }, - adminIds: { - type: "string", - description: - "Comma-separated Telegram chat IDs treated as bot admins by the runtime.", + type: 'object', + properties: { + botToken: { + type: 'string', + description: 'Telegram bot HTTP API token (issued by @BotFather).' + }, + botName: { + type: 'string', + description: 'Public bot username without @ — shown on landing pages.' + }, + adminIds: { + type: 'string', + description: 'Comma-separated Telegram chat IDs treated as bot admins by the runtime.' + } }, - }, - required: ["botToken"], + required: ['botToken'] } as const; export const AgentChannelDtoSchema = { - type: "object", - properties: { - type: { - type: "string", - enum: ["telegram"], - description: - "Channel type. Discriminator — config shape depends on this. v1 only telegram.", - }, - config: { - $ref: "#/components/schemas/TelegramChannelConfigDto", - }, - connected: { - type: "boolean", - nullable: true, - description: - "Live state reported by the runtime (data/channels/status.json). true = polling/connected, false = last start attempt failed (see statusReason), null = unknown (no status reported yet). Read-only — ignored on PUT.", - }, - statusReason: { - type: "string", - nullable: true, - description: - "Failure reason when connected=false (e.g. an invalid token). Read-only.", - }, - statusUpdatedAt: { - type: "number", - nullable: true, - description: "Unix ms of the last status change. Read-only.", - }, - }, - required: ["type", "config"], + type: 'object', + properties: { + type: { + type: 'string', + enum: ['telegram'], + description: 'Channel type. Discriminator — config shape depends on this. v1 only telegram.' + }, + config: { + '$ref': '#/components/schemas/TelegramChannelConfigDto' + }, + connected: { + type: 'boolean', + nullable: true, + description: 'Live state reported by the runtime (data/channels/status.json). true = polling/connected, false = last start attempt failed (see statusReason), null = unknown (no status reported yet). Read-only — ignored on PUT.' + }, + statusReason: { + type: 'string', + nullable: true, + description: 'Failure reason when connected=false (e.g. an invalid token). Read-only.' + }, + statusUpdatedAt: { + type: 'number', + nullable: true, + description: 'Unix ms of the last status change. Read-only.' + } + }, + required: ['type', 'config'] } as const; export const SetAgentChannelsDtoSchema = { - type: "object", - properties: { - channels: { - description: - "Replace the full set of channels. Pass [] to clear all channels.", - type: "array", - items: { - $ref: "#/components/schemas/AgentChannelDto", - }, + type: 'object', + properties: { + channels: { + description: 'Replace the full set of channels. Pass [] to clear all channels.', + type: 'array', + items: { + '$ref': '#/components/schemas/AgentChannelDto' + } + } }, - }, - required: ["channels"], + required: ['channels'] } as const; export const AssignableUserRoleTypesSchema = { - type: "string", - enum: ["Admin", "User"], + type: 'string', + enum: ['Admin', 'User'] } as const; export const CreateUserDtoSchema = { - type: "object", - properties: { - name: { - type: "string", - example: "Jane Doe", - }, - email: { - type: "string", - example: "jane@example.com", - }, - password: { - type: "string", - example: "strongPassword1", - minLength: 8, - }, - role: { - example: "User", - allOf: [ - { - $ref: "#/components/schemas/AssignableUserRoleTypes", + type: 'object', + properties: { + name: { + type: 'string', + example: 'Jane Doe' + }, + email: { + type: 'string', + example: 'jane@example.com' }, - ], + password: { + type: 'string', + example: 'strongPassword1', + minLength: 8 + }, + role: { + example: 'User', + allOf: [ + { + '$ref': '#/components/schemas/AssignableUserRoleTypes' + } + ] + } }, - }, - required: ["name", "email", "password"], + required: ['name', 'email', 'password'] } as const; export const UpdateUserDtoSchema = { - type: "object", - properties: { - name: { - type: "string", - example: "Jane Doe", - }, - email: { - type: "string", - example: "jane@example.com", - }, - password: { - type: "string", - example: "strongPassword1", - minLength: 8, - }, - status: { - type: "string", - enum: ["active", "invited", "disabled"], - }, - }, + type: 'object', + properties: { + name: { + type: 'string', + example: 'Jane Doe' + }, + email: { + type: 'string', + example: 'jane@example.com' + }, + password: { + type: 'string', + example: 'strongPassword1', + minLength: 8 + }, + status: { + type: 'string', + enum: ['active', 'invited', 'disabled'] + } + } } as const; export const UpdateUserRoleDtoSchema = { - type: "object", - properties: { - role: { - example: "User", - allOf: [ - { - $ref: "#/components/schemas/AssignableUserRoleTypes", - }, - ], + type: 'object', + properties: { + role: { + example: 'User', + allOf: [ + { + '$ref': '#/components/schemas/AssignableUserRoleTypes' + } + ] + } }, - }, - required: ["role"], + required: ['role'] } as const; export const SaveTemplateFileDtoSchema = { - type: "object", - properties: { - content: { - type: "string", - description: "Full file content as text", + type: 'object', + properties: { + content: { + type: 'string', + description: 'Full file content as text' + } }, - }, - required: ["content"], + required: ['content'] } as const; export const InstallDeclaredSkillDtoSchema = { - type: "object", - properties: { - id: { - type: "string", - }, - resolved: { - type: "boolean", + type: 'object', + properties: { + id: { + type: 'string' + }, + resolved: { + type: 'boolean' + } }, - }, - required: ["id", "resolved"], + required: ['id', 'resolved'] } as const; export const InstallDeclaredMcpDtoSchema = { - type: "object", - properties: { - id: { - type: "string", - }, - resolved: { - type: "boolean", + type: 'object', + properties: { + id: { + type: 'string' + }, + resolved: { + type: 'boolean' + } }, - }, - required: ["id", "resolved"], + required: ['id', 'resolved'] } as const; export const InstallDeclaredSecretDtoSchema = { - type: "object", - properties: { - name: { - type: "string", - }, - required: { - type: "boolean", + type: 'object', + properties: { + name: { + type: 'string' + }, + required: { + type: 'boolean' + } }, - }, - required: ["name", "required"], + required: ['name', 'required'] } as const; export const InstallDeclaredDtoSchema = { - type: "object", - properties: { - skills: { - type: "array", - items: { - $ref: "#/components/schemas/InstallDeclaredSkillDto", - }, - }, - mcp: { - type: "array", - items: { - $ref: "#/components/schemas/InstallDeclaredMcpDto", - }, - }, - secrets: { - type: "array", - items: { - $ref: "#/components/schemas/InstallDeclaredSecretDto", - }, - }, - }, - required: ["skills", "mcp", "secrets"], + type: 'object', + properties: { + skills: { + type: 'array', + items: { + '$ref': '#/components/schemas/InstallDeclaredSkillDto' + } + }, + mcp: { + type: 'array', + items: { + '$ref': '#/components/schemas/InstallDeclaredMcpDto' + } + }, + secrets: { + type: 'array', + items: { + '$ref': '#/components/schemas/InstallDeclaredSecretDto' + } + } + }, + required: ['skills', 'mcp', 'secrets'] } as const; export const InstallFileCountsDtoSchema = { - type: "object", - properties: { - agentFiles: { - type: "number", - }, - scenarioFiles: { - type: "number", + type: 'object', + properties: { + agentFiles: { + type: 'number' + }, + scenarioFiles: { + type: 'number' + } }, - }, - required: ["agentFiles", "scenarioFiles"], + required: ['agentFiles', 'scenarioFiles'] } as const; export const InstallPreviewDtoSchema = { - type: "object", - properties: { - manifest: { - type: "object", - description: - "The parsed manifest. Returned as raw JSON so the UI can render any field without typed coupling.", - additionalProperties: true, - }, - willCreate: { - type: "boolean", - }, - willUpgrade: { - type: "boolean", - }, - existingTemplateId: { - type: "string", - }, - declared: { - $ref: "#/components/schemas/InstallDeclaredDto", - }, - files: { - $ref: "#/components/schemas/InstallFileCountsDto", - }, - warnings: { - type: "array", - items: { - type: "string", - }, - }, - }, - required: [ - "manifest", - "willCreate", - "willUpgrade", - "declared", - "files", - "warnings", - ], + type: 'object', + properties: { + manifest: { + type: 'object', + description: 'The parsed manifest. Returned as raw JSON so the UI can render any field without typed coupling.', + additionalProperties: true + }, + willCreate: { + type: 'boolean' + }, + willUpgrade: { + type: 'boolean' + }, + existingTemplateId: { + type: 'string' + }, + declared: { + '$ref': '#/components/schemas/InstallDeclaredDto' + }, + files: { + '$ref': '#/components/schemas/InstallFileCountsDto' + }, + warnings: { + type: 'array', + items: { + type: 'string' + } + } + }, + required: ['manifest', 'willCreate', 'willUpgrade', 'declared', 'files', 'warnings'] } as const; export const InstallResultDtoSchema = { - type: "object", - properties: { - templateId: { - type: "string", - }, - templateName: { - type: "string", - }, - filesUploaded: { - type: "number", - }, - scenariosSeeded: { - type: "number", - }, - mcpAttached: { - type: "array", - items: { - type: "string", - }, - }, - skillsAttached: { - type: "array", - items: { - type: "string", - }, - }, - unresolvedMcp: { - type: "array", - items: { - type: "string", - }, - }, - unresolvedSkills: { - type: "array", - items: { - type: "string", - }, - }, - warnings: { - type: "array", - items: { - type: "string", - }, - }, - }, - required: [ - "templateId", - "templateName", - "filesUploaded", - "scenariosSeeded", - "mcpAttached", - "skillsAttached", - "unresolvedMcp", - "unresolvedSkills", - "warnings", - ], + type: 'object', + properties: { + templateId: { + type: 'string' + }, + templateName: { + type: 'string' + }, + filesUploaded: { + type: 'number' + }, + scenariosSeeded: { + type: 'number' + }, + mcpAttached: { + type: 'array', + items: { + type: 'string' + } + }, + skillsAttached: { + type: 'array', + items: { + type: 'string' + } + }, + unresolvedMcp: { + type: 'array', + items: { + type: 'string' + } + }, + unresolvedSkills: { + type: 'array', + items: { + type: 'string' + } + }, + warnings: { + type: 'array', + items: { + type: 'string' + } + } + }, + required: ['templateId', 'templateName', 'filesUploaded', 'scenariosSeeded', 'mcpAttached', 'skillsAttached', 'unresolvedMcp', 'unresolvedSkills', 'warnings'] } as const; export const InstallFromGitDtoSchema = { - type: "object", - properties: { - gitUrl: { - type: "string", - description: - "Git URL — https://, http://, git@host:..., or ssh://host/.../repo.git", - example: "https://github.com/CleanSlice/agent-templates.git", - }, - gitRef: { - type: "string", - description: - "Optional ref — branch, tag, or short SHA. Defaults to the remote default branch.", - example: "main", - }, - params: { - type: "object", - description: - 'Operator-supplied params (e.g. {"language":"ru"}). Validated against the manifest at install time.', - additionalProperties: true, - }, - secrets: { - type: "object", - description: - 'Operator-supplied secrets (e.g. {"MCP_RANCH_AUTH":"sk-..."}). Used to resolve $secret:NAME references in the manifest at install time (currently for mcp[].authValue). Never echoed back.', - additionalProperties: true, - }, - }, - required: ["gitUrl"], + type: 'object', + properties: { + gitUrl: { + type: 'string', + description: 'Git URL — https://, http://, git@host:..., or ssh://host/.../repo.git', + example: 'https://github.com/CleanSlice/agent-templates.git' + }, + gitRef: { + type: 'string', + description: 'Optional ref — branch, tag, or short SHA. Defaults to the remote default branch.', + example: 'main' + }, + params: { + type: 'object', + description: 'Operator-supplied params (e.g. {"language":"ru"}). Validated against the manifest at install time.', + additionalProperties: true + }, + secrets: { + type: 'object', + description: 'Operator-supplied secrets (e.g. {"MCP_RANCH_AUTH":"sk-..."}). Used to resolve $secret:NAME references in the manifest at install time (currently for mcp[].authValue). Never echoed back.', + additionalProperties: true + } + }, + required: ['gitUrl'] } as const; export const CreatePaddockScenarioMessageDtoSchema = { - type: "object", - properties: { - text: { - type: "string", - }, - from: { - type: "string", - }, - delayMs: { - type: "number", + type: 'object', + properties: { + text: { + type: 'string' + }, + from: { + type: 'string' + }, + delayMs: { + type: 'number' + } }, - }, - required: ["text", "from"], + required: ['text', 'from'] } as const; export const CreatePaddockSuccessCriterionDtoSchema = { - type: "object", - properties: { - dimension: { - type: "string", - enum: [ - "correctness", - "tool_usage", - "soul_compliance", - "response_quality", - "error_handling", - ], - }, - description: { - type: "string", - }, - weight: { - type: "number", + type: 'object', + properties: { + dimension: { + type: 'string', + enum: ['correctness', 'tool_usage', 'soul_compliance', 'response_quality', 'error_handling'] + }, + description: { + type: 'string' + }, + weight: { + type: 'number' + } }, - }, - required: ["dimension", "description", "weight"], + required: ['dimension', 'description', 'weight'] } as const; export const CreatePaddockScenarioSetupDtoSchema = { - type: "object", - properties: { - files: { - type: "object", - additionalProperties: { - type: "string", - }, - }, - env: { - type: "object", - additionalProperties: { - type: "string", - }, - }, - tools: { - type: "array", - items: { - type: "string", - }, - }, - }, + type: 'object', + properties: { + files: { + type: 'object', + additionalProperties: { + type: 'string' + } + }, + env: { + type: 'object', + additionalProperties: { + type: 'string' + } + }, + tools: { + type: 'array', + items: { + type: 'string' + } + } + } } as const; export const CreatePaddockScenarioDtoSchema = { - type: "object", - properties: { - templateId: { - type: "string", - nullable: true, - }, - agentId: { - type: "string", - nullable: true, - }, - category: { - type: "string", - enum: [ - "tool_use", - "memory", - "conversation", - "patching_workflow", - "edge_case", - "multi_turn", - "error_recovery", - ], - }, - difficulty: { - type: "string", - enum: ["easy", "medium", "hard", "adversarial"], - }, - name: { - type: "string", - }, - description: { - type: "string", - }, - expectedBehavior: { - type: "string", - }, - messages: { - type: "array", - items: { - $ref: "#/components/schemas/CreatePaddockScenarioMessageDto", - }, - }, - successCriteria: { - type: "array", - items: { - $ref: "#/components/schemas/CreatePaddockSuccessCriterionDto", - }, - }, - setup: { - nullable: true, - allOf: [ - { - $ref: "#/components/schemas/CreatePaddockScenarioSetupDto", - }, - ], - }, - }, - required: [ - "category", - "difficulty", - "name", - "description", - "expectedBehavior", - "messages", - "successCriteria", - ], + type: 'object', + properties: { + templateId: { + type: 'string', + nullable: true + }, + agentId: { + type: 'string', + nullable: true + }, + category: { + type: 'string', + enum: ['tool_use', 'memory', 'conversation', 'patching_workflow', 'edge_case', 'multi_turn', 'error_recovery'] + }, + difficulty: { + type: 'string', + enum: ['easy', 'medium', 'hard', 'adversarial'] + }, + name: { + type: 'string' + }, + description: { + type: 'string' + }, + expectedBehavior: { + type: 'string' + }, + messages: { + type: 'array', + items: { + '$ref': '#/components/schemas/CreatePaddockScenarioMessageDto' + } + }, + successCriteria: { + type: 'array', + items: { + '$ref': '#/components/schemas/CreatePaddockSuccessCriterionDto' + } + }, + setup: { + nullable: true, + allOf: [ + { + '$ref': '#/components/schemas/CreatePaddockScenarioSetupDto' + } + ] + } + }, + required: ['category', 'difficulty', 'name', 'description', 'expectedBehavior', 'messages', 'successCriteria'] } as const; export const GeneratePaddockScenarioDtoSchema = { - type: "object", - properties: { - description: { - type: "string", - description: - "Free-form description of the problem / behavior the user wants to test.", - }, - templateId: { - type: "string", - nullable: true, - }, - agentId: { - type: "string", - nullable: true, - }, - category: { - type: "string", - enum: [ - "tool_use", - "memory", - "conversation", - "patching_workflow", - "edge_case", - "multi_turn", - "error_recovery", - ], - }, - difficulty: { - type: "string", - enum: ["easy", "medium", "hard", "adversarial"], - }, - credentialId: { - type: "string", - description: - "Optional LlmCredential id; if omitted, the first active Anthropic credential is used.", - }, - }, - required: ["description"], + type: 'object', + properties: { + description: { + type: 'string', + description: 'Free-form description of the problem / behavior the user wants to test.' + }, + templateId: { + type: 'string', + nullable: true + }, + agentId: { + type: 'string', + nullable: true + }, + category: { + type: 'string', + enum: ['tool_use', 'memory', 'conversation', 'patching_workflow', 'edge_case', 'multi_turn', 'error_recovery'] + }, + difficulty: { + type: 'string', + enum: ['easy', 'medium', 'hard', 'adversarial'] + }, + credentialId: { + type: 'string', + description: 'Optional LlmCredential id; if omitted, the first active Anthropic credential is used.' + } + }, + required: ['description'] } as const; export const UpdatePaddockScenarioDtoSchema = { - type: "object", - properties: { - category: { - type: "string", - enum: [ - "tool_use", - "memory", - "conversation", - "patching_workflow", - "edge_case", - "multi_turn", - "error_recovery", - ], - }, - difficulty: { - type: "string", - enum: ["easy", "medium", "hard", "adversarial"], - }, - name: { - type: "string", - }, - description: { - type: "string", - }, - expectedBehavior: { - type: "string", - }, - messages: { - type: "array", - items: { - $ref: "#/components/schemas/CreatePaddockScenarioMessageDto", - }, - }, - successCriteria: { - type: "array", - items: { - $ref: "#/components/schemas/CreatePaddockSuccessCriterionDto", - }, - }, - setup: { - nullable: true, - allOf: [ - { - $ref: "#/components/schemas/CreatePaddockScenarioSetupDto", - }, - ], - }, - }, + type: 'object', + properties: { + category: { + type: 'string', + enum: ['tool_use', 'memory', 'conversation', 'patching_workflow', 'edge_case', 'multi_turn', 'error_recovery'] + }, + difficulty: { + type: 'string', + enum: ['easy', 'medium', 'hard', 'adversarial'] + }, + name: { + type: 'string' + }, + description: { + type: 'string' + }, + expectedBehavior: { + type: 'string' + }, + messages: { + type: 'array', + items: { + '$ref': '#/components/schemas/CreatePaddockScenarioMessageDto' + } + }, + successCriteria: { + type: 'array', + items: { + '$ref': '#/components/schemas/CreatePaddockSuccessCriterionDto' + } + }, + setup: { + nullable: true, + allOf: [ + { + '$ref': '#/components/schemas/CreatePaddockScenarioSetupDto' + } + ] + } + } +} as const; + +export const AgentInterfaceDtoSchema = { + type: 'object', + properties: { + url: { + type: 'string', + description: 'Where another agent sends tasks for this one.', + example: 'https://api.ranch.example/a2a/agents/6f1c…' + }, + protocolBinding: { + type: 'string', + description: 'Transport binding.', + example: 'JSONRPC' + }, + protocolVersion: { + type: 'string', + description: 'A2A protocol version.', + example: '1.0' + } + }, + required: ['url', 'protocolBinding', 'protocolVersion'] +} as const; + +export const AgentCapabilitiesDtoSchema = { + type: 'object', + properties: { + streaming: { + type: 'boolean', + description: 'Whether the agent streams partial answers. Ranch: false.', + example: false + }, + pushNotifications: { + type: 'boolean', + description: 'Whether the agent can call back when a task finishes. Ranch: false.', + example: false + } + } +} as const; + +export const AgentSkillDtoSchema = { + type: 'object', + properties: { + id: { + type: 'string', + description: 'Stable id of the skill. Prefixed by where it came from: `skill:` for a template skill, `knowledge:` for a bound knowledge base.', + example: 'knowledge:9a1f…' + }, + name: { + type: 'string', + description: 'Short name.', + example: 'Returns policy' + }, + description: { + type: 'string', + description: 'What this lets the agent do, written so another agent can decide when to ask.', + example: 'Answers questions about «Returns policy»: 2026 policy PDF.' + }, + tags: { + description: 'Origin tags: `skill` or `knowledge`.', + example: ['knowledge'], + type: 'array', + items: { + type: 'string' + } + } + }, + required: ['id', 'name', 'description', 'tags'] +} as const; + +export const AgentCardDtoSchema = { + type: 'object', + properties: { + name: { + type: 'string', + example: 'Support Bot' + }, + description: { + type: 'string', + description: "The agent's own description, falling back to its template's.", + example: 'Answers customer questions about orders and returns.' + }, + version: { + type: 'string', + description: "The template's version.", + example: '1' + }, + supportedInterfaces: { + type: 'array', + items: { + '$ref': '#/components/schemas/AgentInterfaceDto' + } + }, + capabilities: { + '$ref': '#/components/schemas/AgentCapabilitiesDto' + }, + defaultInputModes: { + example: ['text/plain'], + type: 'array', + items: { + type: 'string' + } + }, + defaultOutputModes: { + example: ['text/plain'], + type: 'array', + items: { + type: 'string' + } + }, + skills: { + description: 'One entry per template skill and per bound knowledge base. May be empty: an agent with nothing to advertise still has a valid card.', + type: 'array', + items: { + '$ref': '#/components/schemas/AgentSkillDto' + } + } + }, + required: ['name', 'description', 'version', 'supportedInterfaces', 'capabilities', 'defaultInputModes', 'defaultOutputModes', 'skills'] +} as const; + +export const AgentPeerDtoSchema = { + type: 'object', + properties: { + id: { + type: 'string', + description: 'Id of the connection, not of either agent.' + }, + agentId: { + type: 'string', + description: 'The agent that holds the card (the caller).' + }, + peerAgentId: { + type: 'string', + description: 'The agent whose card is held (the peer).' + }, + peerName: { + type: 'string', + description: "The peer's current name. Falls back to the name on the stored card when the agent itself is gone.", + example: 'Support Bot' + }, + peerStatus: { + type: 'string', + description: "The peer's live agent status. 'running' means a delegation can succeed right now; anything else means it would fail fast.", + example: 'running' + }, + peerExists: { + type: 'boolean', + description: 'False when the peer agent no longer exists in this installation.', + example: true + }, + card: { + description: 'The card as read at connect time or at the last refresh — NOT a live read. The delegating agent reasons from this snapshot, so a peer editing its description mid-turn cannot change behaviour until someone presses Refresh.', + allOf: [ + { + '$ref': '#/components/schemas/AgentCardDto' + } + ] + }, + cardUrl: { + type: 'string', + description: 'Where the snapshot was read from.', + example: 'https://api.ranch.example/a2a/agents/6f1c…/.well-known/agent-card.json' + }, + cardReadAt: { + type: 'string', + format: 'date-time', + description: 'When the snapshot was taken.', + example: '2026-09-14T10:00:00.000Z' + }, + createdAt: { + type: 'string', + format: 'date-time', + example: '2026-09-14T10:00:00.000Z' + } + }, + required: ['id', 'agentId', 'peerAgentId', 'peerName', 'peerStatus', 'peerExists', 'card', 'cardUrl', 'cardReadAt', 'createdAt'] } as const; -export const SecretEntryDtoSchema = { - type: "object", - properties: { - name: { - type: "string", - example: "user-abc/openai_api_key", +export const AgentPeerCandidateDtoSchema = { + type: 'object', + properties: { + id: { + type: 'string' + }, + name: { + type: 'string', + example: 'Support Bot' + }, + status: { + type: 'string', + description: 'Live agent status.', + example: 'running' + }, + connected: { + type: 'boolean', + description: 'True when this agent is already a peer — shown as connected rather than offered again.', + example: false + } + }, + required: ['id', 'name', 'status', 'connected'] +} as const; + +export const ConnectPeerDtoSchema = { + type: 'object', + properties: { + peerAgentId: { + type: 'string', + description: 'The agent to connect. Must be another agent of this installation: foreign card URLs are not accepted in this feature, and an agent cannot be its own peer.' + } }, - value: { - type: "string", - example: "sk-...", + required: ['peerAgentId'] +} as const; + +export const AgentDelegationDtoSchema = { + type: 'object', + properties: { + id: { + type: 'string' + }, + peerAgentId: { + type: 'string', + description: 'The peer that was asked.' + }, + peerName: { + type: 'string', + example: 'Support Bot' + }, + task: { + type: 'string', + description: 'The self-contained task the peer received.', + example: 'What is the return window for shoes?' + }, + reason: { + type: 'string', + description: "The calling model's one-line reason for choosing this peer.", + example: 'Support Bot holds the returns policy base' + }, + status: { + type: 'string', + description: 'waiting | answered | failed | rejected.', + example: 'answered' + }, + errorCode: { + type: 'string', + nullable: true, + description: 'Why it did not produce an answer: PEER_NOT_RUNNING, PEER_TIMEOUT, PEER_REJECTED_LOOP, PEER_REJECTED_DEPTH, PEER_UNAUTHORIZED, PEER_UNREACHABLE or PEER_ERROR. Null while waiting and on success.', + example: null + }, + excerpt: { + type: 'string', + nullable: true, + description: 'The first part of the reply, or the cause in product wording.', + example: 'Shoes can be returned within 30 days…' + }, + startedAt: { + type: 'string', + format: 'date-time', + example: '2026-09-14T10:00:00.000Z' + }, + finishedAt: { + type: 'string', + format: 'date-time', + nullable: true, + example: '2026-09-14T10:00:03.120Z' + }, + durationMs: { + type: 'number', + nullable: true, + example: 3120 + } }, - updatedAt: { - type: "string", - format: "date-time", - nullable: true, + required: ['id', 'peerAgentId', 'peerName', 'task', 'reason', 'status', 'errorCode', 'excerpt', 'startedAt', 'finishedAt', 'durationMs'] +} as const; + +export const SecretEntryDtoSchema = { + type: 'object', + properties: { + name: { + type: 'string', + example: 'user-abc/openai_api_key' + }, + value: { + type: 'string', + example: 'sk-...' + }, + updatedAt: { + type: 'string', + format: 'date-time', + nullable: true + } }, - }, - required: ["name", "value", "updatedAt"], + required: ['name', 'value', 'updatedAt'] } as const; export const SecretListDtoSchema = { - type: "object", - properties: { - provider: { - type: "string", - enum: ["aws", "file"], - example: "file", - }, - secrets: { - type: "array", - items: { - $ref: "#/components/schemas/SecretEntryDto", - }, + type: 'object', + properties: { + provider: { + type: 'string', + enum: ['aws', 'file'], + example: 'file' + }, + secrets: { + type: 'array', + items: { + '$ref': '#/components/schemas/SecretEntryDto' + } + } }, - }, - required: ["provider", "secrets"], + required: ['provider', 'secrets'] } as const; export const SetSecretDtoSchema = { - type: "object", - properties: { - key: { - type: "string", - example: "instagram:password", - description: - "Secret key. Upsert: an existing key is overwritten, a new key is created.", - }, - value: { - type: "string", - example: "sk-...", - description: "Secret value to store.", + type: 'object', + properties: { + key: { + type: 'string', + example: 'instagram:password', + description: 'Secret key. Upsert: an existing key is overwritten, a new key is created.' + }, + value: { + type: 'string', + example: 'sk-...', + description: 'Secret value to store.' + } }, - }, - required: ["key", "value"], + required: ['key', 'value'] } as const; export const DeleteSecretDtoSchema = { - type: "object", - properties: { - key: { - type: "string", - example: "instagram:password", - description: "Secret key to delete. No-op if the key does not exist.", + type: 'object', + properties: { + key: { + type: 'string', + example: 'instagram:password', + description: 'Secret key to delete. No-op if the key does not exist.' + } }, - }, - required: ["key"], + required: ['key'] } as const; export const ReplaceSecretsDtoSchema = { - type: "object", - properties: { - store: { - type: "object", - additionalProperties: { - type: "string", - }, - example: { - "instagram:password": "p@ss", - "paypal:api_token": "sk-...", - }, - description: - "Full secret store for the agent — replaces everything. Pass {} to clear. Mirrors AWS Secrets Manager's plaintext-edit semantics.", - }, - }, - required: ["store"], + type: 'object', + properties: { + store: { + type: 'object', + additionalProperties: { + type: 'string' + }, + example: { + 'instagram:password': 'p@ss', + 'paypal:api_token': 'sk-...' + }, + description: "Full secret store for the agent — replaces everything. Pass {} to clear. Mirrors AWS Secrets Manager's plaintext-edit semantics." + } + }, + required: ['store'] } as const; export const ReportUsageDtoSchema = { - type: "object", - properties: { - date: { - type: "string", - example: "2026-04-22", - }, - byModel: { - type: "object", - description: - "Per-model usage. Key is the canonical model name (e.g. claude-sonnet-4-6).", - example: { - "claude-sonnet-4-6": { - inputTokens: 12345, - outputTokens: 6789, - callCount: 42, + type: 'object', + properties: { + date: { + type: 'string', + example: '2026-04-22' }, - }, + byModel: { + type: 'object', + description: 'Per-model usage. Key is the canonical model name (e.g. claude-sonnet-4-6).', + example: { + 'claude-sonnet-4-6': { + inputTokens: 12345, + outputTokens: 6789, + callCount: 42 + } + } + } }, - }, - required: ["date", "byModel"], + required: ['date', 'byModel'] } as const; -export const RunPaddockJudgeOverrideDtoSchema = { - type: "object", - properties: { - credentialIds: { - type: "array", - items: { - type: "string", - }, - }, - threshold: { - type: "number", +export const StartMcpOauthDtoSchema = { + type: 'object', + properties: { + agentId: { + type: 'string', + description: 'Agent that will own the connection. The stored refresh token is scoped to this agent.' + } }, - maxLlmCalls: { - type: "number", + required: ['agentId'] +} as const; + +export const StartMcpOauthResultDtoSchema = { + type: 'object', + properties: { + authorizeUrl: { + type: 'string', + description: 'Authorization URL to hand the user. They open it, log in at the provider, and the callback stores the token.' + } }, - maxTimeMs: { - type: "number", + required: ['authorizeUrl'] +} as const; + +export const McpOauthStatusDtoSchema = { + type: 'object', + properties: { + connected: { + type: 'boolean' + } }, - }, + required: ['connected'] +} as const; + +export const RunPaddockJudgeOverrideDtoSchema = { + type: 'object', + properties: { + credentialIds: { + type: 'array', + items: { + type: 'string' + } + }, + threshold: { + type: 'number' + }, + maxLlmCalls: { + type: 'number' + }, + maxTimeMs: { + type: 'number' + } + } } as const; export const RunPaddockEvaluationDtoSchema = { - type: "object", - properties: { - agentId: { - type: "string", - }, - scenarioIds: { - description: - "Optional subset of scenario IDs. If omitted, runs the agent’s template scenarios merged with agent overrides.", - type: "array", - items: { - type: "string", - }, - }, - judgeOverride: { - $ref: "#/components/schemas/RunPaddockJudgeOverrideDto", + type: 'object', + properties: { + agentId: { + type: 'string' + }, + scenarioIds: { + description: 'Optional subset of scenario IDs. If omitted, runs the agent’s template scenarios merged with agent overrides.', + type: 'array', + items: { + type: 'string' + } + }, + judgeOverride: { + '$ref': '#/components/schemas/RunPaddockJudgeOverrideDto' + } }, - }, - required: ["agentId"], + required: ['agentId'] } as const; export const BrowserSessionDtoSchema = { - type: "object", - properties: { - id: { - type: "string", - example: "browser-7b8c2e22-...", - }, - userId: { - type: "string", - }, - accountKey: { - type: "string", - example: "instagram:miybot", - }, - status: { - type: "string", - enum: ["idle", "active", "needs_login", "expired", "stuck"], - }, - lastUsedAt: { - format: "date-time", - type: "string", - }, - createdAt: { - format: "date-time", - type: "string", - }, - updatedAt: { - format: "date-time", - type: "string", - }, - }, - required: [ - "id", - "userId", - "accountKey", - "status", - "lastUsedAt", - "createdAt", - "updatedAt", - ], + type: 'object', + properties: { + id: { + type: 'string', + example: 'browser-7b8c2e22-...' + }, + userId: { + type: 'string' + }, + accountKey: { + type: 'string', + example: 'instagram:miybot' + }, + status: { + type: 'string', + enum: ['idle', 'active', 'needs_login', 'expired', 'stuck'] + }, + lastUsedAt: { + format: 'date-time', + type: 'string' + }, + createdAt: { + format: 'date-time', + type: 'string' + }, + updatedAt: { + format: 'date-time', + type: 'string' + } + }, + required: ['id', 'userId', 'accountKey', 'status', 'lastUsedAt', 'createdAt', 'updatedAt'] } as const; export const OpenSessionDtoSchema = { - type: "object", - properties: { - accountKey: { - type: "string", - description: - 'Account identifier scoped to the calling user, e.g. "instagram:miybot" or "paypal:main". Must match /^[a-zA-Z0-9_:\\-]+$/ — anything else is rejected before it can touch the profile filesystem path.', - example: "instagram:miybot", - }, - loginUrl: { - type: "string", - description: `Page to navigate to in the pre-warmed Chrome. Drives what the VNC viewer renders when the user opens the returned vncUrl — set this to the service's login page (e.g. "https://www.instagram.com/accounts/login/"). Defaults to about:blank if omitted; the user can still type a URL in the address bar.`, - example: "https://www.instagram.com/accounts/login/", + type: 'object', + properties: { + accountKey: { + type: 'string', + description: 'Account identifier scoped to the calling user, e.g. "instagram:miybot" or "paypal:main". Must match /^[a-zA-Z0-9_:\\-]+$/ — anything else is rejected before it can touch the profile filesystem path.', + example: 'instagram:miybot' + }, + loginUrl: { + type: 'string', + description: `Page to navigate to in the pre-warmed Chrome. Drives what the VNC viewer renders when the user opens the returned vncUrl — set this to the service's login page (e.g. "https://www.instagram.com/accounts/login/"). Defaults to about:blank if omitted; the user can still type a URL in the address bar.`, + example: 'https://www.instagram.com/accounts/login/' + } }, - }, - required: ["accountKey"], + required: ['accountKey'] } as const; export const BrowserSessionConnectionDtoSchema = { - type: "object", - properties: { - session: { - $ref: "#/components/schemas/BrowserSessionDto", - }, - cdpUrl: { - type: "string", - description: - "CDP WebSocket URL. Carries a single-use launch payload and the pool token — never expose to UI, only to authenticated runtime calls.", - }, - vncUrl: { - type: "object", - description: - "Live VNC URL (signed JWT, 15 min TTL). Send to the end user when they need to finish a 2FA/CAPTCHA flow manually.", - nullable: true, + type: 'object', + properties: { + session: { + '$ref': '#/components/schemas/BrowserSessionDto' + }, + cdpUrl: { + type: 'string', + description: 'CDP WebSocket URL. Carries a single-use launch payload and the pool token — never expose to UI, only to authenticated runtime calls.' + }, + vncUrl: { + type: 'object', + description: 'Live VNC URL (signed JWT, 15 min TTL). Send to the end user when they need to finish a 2FA/CAPTCHA flow manually.', + nullable: true + } }, - }, - required: ["session", "cdpUrl"], + required: ['session', 'cdpUrl'] } as const; export const SetStatusDtoSchema = { - type: "object", - properties: { - status: { - type: "string", - enum: ["idle", "active", "needs_login", "expired", "stuck"], + type: 'object', + properties: { + status: { + type: 'string', + enum: ['idle', 'active', 'needs_login', 'expired', 'stuck'] + } }, - }, - required: ["status"], + required: ['status'] } as const; export const OpenInternalSessionDtoSchema = { - type: "object", - properties: { - accountKey: { - type: "string", - description: - 'Account identifier scoped to the calling user, e.g. "instagram:miybot" or "paypal:main". Must match /^[a-zA-Z0-9_:\\-]+$/ — anything else is rejected before it can touch the profile filesystem path.', - example: "instagram:miybot", - }, - loginUrl: { - type: "string", - description: `Page to navigate to in the pre-warmed Chrome. Drives what the VNC viewer renders when the user opens the returned vncUrl — set this to the service's login page (e.g. "https://www.instagram.com/accounts/login/"). Defaults to about:blank if omitted; the user can still type a URL in the address bar.`, - example: "https://www.instagram.com/accounts/login/", - }, - userId: { - type: "string", - description: - "Owning user. Trusted only because the bridle key gates this endpoint — the runtime forwards ctx.from from the authenticated chat session.", + type: 'object', + properties: { + accountKey: { + type: 'string', + description: 'Account identifier scoped to the calling user, e.g. "instagram:miybot" or "paypal:main". Must match /^[a-zA-Z0-9_:\\-]+$/ — anything else is rejected before it can touch the profile filesystem path.', + example: 'instagram:miybot' + }, + loginUrl: { + type: 'string', + description: `Page to navigate to in the pre-warmed Chrome. Drives what the VNC viewer renders when the user opens the returned vncUrl — set this to the service's login page (e.g. "https://www.instagram.com/accounts/login/"). Defaults to about:blank if omitted; the user can still type a URL in the address bar.`, + example: 'https://www.instagram.com/accounts/login/' + }, + userId: { + type: 'string', + description: 'Owning user. Trusted only because the bridle key gates this endpoint — the runtime forwards ctx.from from the authenticated chat session.' + } }, - }, - required: ["accountKey", "userId"], + required: ['accountKey', 'userId'] } as const; export const InternalSetStatusDtoSchema = { - type: "object", - properties: { - status: { - type: "string", - enum: ["idle", "active", "needs_login", "expired", "stuck"], - }, - userId: { - type: "string", + type: 'object', + properties: { + status: { + type: 'string', + enum: ['idle', 'active', 'needs_login', 'expired', 'stuck'] + }, + userId: { + type: 'string' + } }, - }, - required: ["status", "userId"], + required: ['status', 'userId'] } as const; export const InternalSessionRefDtoSchema = { - type: "object", - properties: { - userId: { - type: "string", + type: 'object', + properties: { + userId: { + type: 'string' + } }, - }, - required: ["userId"], + required: ['userId'] } as const; export const IssueExtensionTokenDtoSchema = { - type: "object", - properties: { - agentId: { - type: "string", - description: - "Agent the extension token will be scoped to. The token only allows writing state files under this agent's S3 prefix.", - }, - userId: { - type: "string", - description: - 'User ID (within the agent context — typically the Telegram user ID for chat-driven agents, or "admin" for the admin agent). Used as the filename prefix so multiple humans can keep separate cookies inside one agent.', - }, - ttlDays: { - type: "number", - description: "Token lifetime in days. Defaults to 30; max 365.", - minimum: 1, - maximum: 365, + type: 'object', + properties: { + agentId: { + type: 'string', + description: "Agent the extension token will be scoped to. The token only allows writing state files under this agent's S3 prefix." + }, + userId: { + type: 'string', + description: 'User ID (within the agent context — typically the Telegram user ID for chat-driven agents, or "admin" for the admin agent). Used as the filename prefix so multiple humans can keep separate cookies inside one agent.' + }, + ttlDays: { + type: 'number', + description: 'Token lifetime in days. Defaults to 30; max 365.', + minimum: 1, + maximum: 365 + } }, - }, - required: ["agentId", "userId"], + required: ['agentId', 'userId'] } as const; export const IssueExtensionTokenResponseDtoSchema = { - type: "object", - properties: { - token: { - type: "string", - }, - exp: { - type: "number", - description: "Unix seconds", + type: 'object', + properties: { + token: { + type: 'string' + }, + exp: { + type: 'number', + description: 'Unix seconds' + } }, - }, - required: ["token", "exp"], + required: ['token', 'exp'] } as const; export const StorageStateCookieDtoSchema = { - type: "object", - properties: { - name: { - type: "string", - }, - value: { - type: "string", - }, - domain: { - type: "string", - example: ".instagram.com", - }, - path: { - type: "string", - example: "/", - }, - expires: { - type: "number", - description: "Unix seconds, or -1 for session cookie", - }, - httpOnly: { - type: "boolean", - }, - secure: { - type: "boolean", - }, - sameSite: { - type: "string", - enum: ["Strict", "Lax", "None"], + type: 'object', + properties: { + name: { + type: 'string' + }, + value: { + type: 'string' + }, + domain: { + type: 'string', + example: '.instagram.com' + }, + path: { + type: 'string', + example: '/' + }, + expires: { + type: 'number', + description: 'Unix seconds, or -1 for session cookie' + }, + httpOnly: { + type: 'boolean' + }, + secure: { + type: 'boolean' + }, + sameSite: { + type: 'string', + enum: ['Strict', 'Lax', 'None'] + } }, - }, - required: [ - "name", - "value", - "domain", - "path", - "expires", - "httpOnly", - "secure", - "sameSite", - ], + required: ['name', 'value', 'domain', 'path', 'expires', 'httpOnly', 'secure', 'sameSite'] } as const; export const ImportStateDtoSchema = { - type: "object", - properties: { - profile: { - type: "string", - description: - "Profile name. Must match the value the agent will pass as `profile` to browser_play. Sanitized server-side before being used as a filename.", - example: "instagram:miybot", - }, - cookies: { - description: - "Cookies in Playwright storageState shape. Re-used 1:1 by the agent runtime when launching Chromium with { storageState: }.", - type: "array", - items: { - $ref: "#/components/schemas/StorageStateCookieDto", - }, - }, - origins: { - description: - "Per-origin localStorage entries. The extension does not collect these today (would need a per-origin content script), but we accept them so future versions can fill them in without an API bump.", - type: "array", - items: { - type: "string", - }, - }, - userAgent: { - type: "string", - description: - "User-Agent string of the browser the cookies were exported from. The agent runtime applies this to its Playwright context so Instagram/Facebook/banks see the SAME browser fingerprint as the one that issued the session — a mismatch (Mac Chrome → Linux HeadlessChrome) is the main reason replayed cookies still hit the login page.", - maxLength: 512, - }, - }, - required: ["profile", "cookies"], + type: 'object', + properties: { + profile: { + type: 'string', + description: 'Profile name. Must match the value the agent will pass as `profile` to browser_play. Sanitized server-side before being used as a filename.', + example: 'instagram:miybot' + }, + cookies: { + description: 'Cookies in Playwright storageState shape. Re-used 1:1 by the agent runtime when launching Chromium with { storageState: }.', + type: 'array', + items: { + '$ref': '#/components/schemas/StorageStateCookieDto' + } + }, + origins: { + description: 'Per-origin localStorage entries. The extension does not collect these today (would need a per-origin content script), but we accept them so future versions can fill them in without an API bump.', + type: 'array', + items: { + type: 'string' + } + }, + userAgent: { + type: 'string', + description: 'User-Agent string of the browser the cookies were exported from. The agent runtime applies this to its Playwright context so Instagram/Facebook/banks see the SAME browser fingerprint as the one that issued the session — a mismatch (Mac Chrome → Linux HeadlessChrome) is the main reason replayed cookies still hit the login page.', + maxLength: 512 + } + }, + required: ['profile', 'cookies'] } as const; export const ImportStateResponseDtoSchema = { - type: "object", - properties: { - ok: { - type: "boolean", - }, - path: { - type: "string", - description: - "Relative path under the agent S3 prefix where the storageState was saved. The runtime sees this as `/` after the next S3 sync tick.", - example: "browser-state/55212224-instagram_miybot.json", - }, - cookies: { - type: "number", + type: 'object', + properties: { + ok: { + type: 'boolean' + }, + path: { + type: 'string', + description: 'Relative path under the agent S3 prefix where the storageState was saved. The runtime sees this as `/` after the next S3 sync tick.', + example: 'browser-state/55212224-instagram_miybot.json' + }, + cookies: { + type: 'number' + } }, - }, - required: ["ok", "path", "cookies"], + required: ['ok', 'path', 'cookies'] } as const; export const CatalogueItemDtoSchema = { - type: "object", - properties: { - service: { - type: "string", - example: "instagram", - description: - "Stable service key. Use this in POST /integrations/accounts.", - }, - title: { - type: "string", - example: "Instagram", - }, - description: { - type: "string", - }, - iconUrl: { - type: "string", - example: "/icons/integrations/instagram.svg", - }, - mechanism: { - type: "string", - enum: ["browser", "secret"], - }, - loginUrl: { - type: "string", - description: - "Browser-mechanism: URL the pool Chrome navigates to before the user opens the VNC view.", - }, - accountKeyHint: { - type: "string", - description: - "Browser-mechanism: hint text the UI shows under the accountKey input.", - }, - domains: { - description: - "Browser-mechanism: domains the Chrome extension auto-matches the current tab against (suffix match).", - type: "array", - items: { - type: "string", - }, - }, - secretEnvKey: { - type: "string", - description: - "Secret-mechanism: env var name the runtime exposes to agents.", - }, - secretHelp: { - type: "string", - description: - "Secret-mechanism: short help string the UI shows under the secret input.", - }, - }, - required: ["service", "title", "description", "iconUrl", "mechanism"], + type: 'object', + properties: { + service: { + type: 'string', + example: 'instagram', + description: 'Stable service key. Use this in POST /integrations/accounts.' + }, + title: { + type: 'string', + example: 'Instagram' + }, + description: { + type: 'string' + }, + iconUrl: { + type: 'string', + example: '/icons/integrations/instagram.svg' + }, + mechanism: { + type: 'string', + enum: ['browser', 'secret'] + }, + loginUrl: { + type: 'string', + description: 'Browser-mechanism: URL the pool Chrome navigates to before the user opens the VNC view.' + }, + accountKeyHint: { + type: 'string', + description: 'Browser-mechanism: hint text the UI shows under the accountKey input.' + }, + domains: { + description: 'Browser-mechanism: domains the Chrome extension auto-matches the current tab against (suffix match).', + type: 'array', + items: { + type: 'string' + } + }, + secretEnvKey: { + type: 'string', + description: 'Secret-mechanism: env var name the runtime exposes to agents.' + }, + secretHelp: { + type: 'string', + description: 'Secret-mechanism: short help string the UI shows under the secret input.' + } + }, + required: ['service', 'title', 'description', 'iconUrl', 'mechanism'] } as const; export const IntegrationAccountDtoSchema = { - type: "object", - properties: { - id: { - type: "string", - }, - userId: { - type: "string", - }, - service: { - type: "string", - example: "instagram", - }, - accountKey: { - type: "string", - example: "miybot", - description: - "Account label scoped to (userId, service). For browser-mechanism this is also the BrowserSession.accountKey suffix.", - }, - mechanism: { - type: "string", - enum: ["browser", "secret"], - }, - label: { - type: "object", - }, - status: { - type: "string", - enum: ["pending", "connected", "needs_login", "revoked"], - description: - "pending: row exists but underlying credential not yet stored. connected: credential ready. needs_login: cookies expired or session lost. revoked: user disconnected.", - }, - createdAt: { - format: "date-time", - type: "string", - }, - updatedAt: { - format: "date-time", - type: "string", - }, - }, - required: [ - "id", - "userId", - "service", - "accountKey", - "mechanism", - "status", - "createdAt", - "updatedAt", - ], + type: 'object', + properties: { + id: { + type: 'string' + }, + userId: { + type: 'string' + }, + service: { + type: 'string', + example: 'instagram' + }, + accountKey: { + type: 'string', + example: 'miybot', + description: 'Account label scoped to (userId, service). For browser-mechanism this is also the BrowserSession.accountKey suffix.' + }, + mechanism: { + type: 'string', + enum: ['browser', 'secret'] + }, + label: { + type: 'object' + }, + status: { + type: 'string', + enum: ['pending', 'connected', 'needs_login', 'revoked'], + description: 'pending: row exists but underlying credential not yet stored. connected: credential ready. needs_login: cookies expired or session lost. revoked: user disconnected.' + }, + createdAt: { + format: 'date-time', + type: 'string' + }, + updatedAt: { + format: 'date-time', + type: 'string' + } + }, + required: ['id', 'userId', 'service', 'accountKey', 'mechanism', 'status', 'createdAt', 'updatedAt'] } as const; export const ConnectIntegrationDtoSchema = { - type: "object", - properties: { - service: { - type: "string", - example: "instagram", - description: "Catalogue key (see GET /integrations/catalogue).", - }, - accountKey: { - type: "string", - example: "miybot", - description: - "User-chosen label for this account. Becomes part of BrowserSession.accountKey for browser-mechanism services. Same charset rules as browser sessions.", - }, - label: { - type: "string", - description: "Human-friendly label shown in the admin UI list.", - maxLength: 120, + type: 'object', + properties: { + service: { + type: 'string', + example: 'instagram', + description: 'Catalogue key (see GET /integrations/catalogue).' + }, + accountKey: { + type: 'string', + example: 'miybot', + description: 'User-chosen label for this account. Becomes part of BrowserSession.accountKey for browser-mechanism services. Same charset rules as browser sessions.' + }, + label: { + type: 'string', + description: 'Human-friendly label shown in the admin UI list.', + maxLength: 120 + } }, - }, - required: ["service", "accountKey"], + required: ['service', 'accountKey'] } as const; export const LoginInstructionDtoSchema = { - type: "object", - properties: { - accountId: { - type: "string", - description: "IntegrationAccount.id these instructions are for.", - }, - siteUrl: { - type: "string", - description: - "Direct link to the service login page (catalogue.loginUrl) — the user opens this in their normal browser.", - }, - helpUrl: { - type: "string", - description: - "Admin-UI route that walks the user through the connect flow (install extension if needed, log in on siteUrl, send cookies).", - }, - instructions: { - type: "string", - description: - "Plain-text instructions the agent can paraphrase for chat channels where helpUrl is not directly clickable. Three short lines.", + type: 'object', + properties: { + accountId: { + type: 'string', + description: 'IntegrationAccount.id these instructions are for.' + }, + siteUrl: { + type: 'string', + description: 'Direct link to the service login page (catalogue.loginUrl) — the user opens this in their normal browser.' + }, + helpUrl: { + type: 'string', + description: 'Admin-UI route that walks the user through the connect flow (install extension if needed, log in on siteUrl, send cookies).' + }, + instructions: { + type: 'string', + description: 'Plain-text instructions the agent can paraphrase for chat channels where helpUrl is not directly clickable. Three short lines.' + } }, - }, - required: ["accountId", "siteUrl", "helpUrl", "instructions"], + required: ['accountId', 'siteUrl', 'helpUrl', 'instructions'] } as const; export const ImportCookiesCookieDtoSchema = { - type: "object", - properties: { - name: { - type: "string", - }, - value: { - type: "string", - }, - domain: { - type: "string", - example: ".instagram.com", - }, - path: { - type: "string", - example: "/", - }, - expires: { - type: "number", - description: "Unix seconds, or -1 for session cookie", - }, - httpOnly: { - type: "boolean", - }, - secure: { - type: "boolean", - }, - sameSite: { - type: "string", - enum: ["Strict", "Lax", "None"], + type: 'object', + properties: { + name: { + type: 'string' + }, + value: { + type: 'string' + }, + domain: { + type: 'string', + example: '.instagram.com' + }, + path: { + type: 'string', + example: '/' + }, + expires: { + type: 'number', + description: 'Unix seconds, or -1 for session cookie' + }, + httpOnly: { + type: 'boolean' + }, + secure: { + type: 'boolean' + }, + sameSite: { + type: 'string', + enum: ['Strict', 'Lax', 'None'] + } }, - }, - required: [ - "name", - "value", - "domain", - "path", - "expires", - "httpOnly", - "secure", - "sameSite", - ], + required: ['name', 'value', 'domain', 'path', 'expires', 'httpOnly', 'secure', 'sameSite'] } as const; export const ImportCookiesDtoSchema = { - type: "object", - properties: { - cookies: { - description: - "Cookies in Playwright storageState shape. Either harvested by the Ranch extension or pasted from any third-party cookie-exporter (Cookie-Editor, EditThisCookie, etc.).", - type: "array", - items: { - $ref: "#/components/schemas/ImportCookiesCookieDto", - }, - }, - origins: { - description: - "Per-origin localStorage entries. Most extensions do not collect these — accepted optionally for future-proofing.", - type: "array", - items: { - type: "string", - }, - }, - userAgent: { - type: "string", - description: - "User-Agent of the browser that issued the cookies. CRITICAL — Instagram/Meta invalidate sessions whose UA shifts between captures and replays. The runtime applies this verbatim to its Playwright context.", - maxLength: 512, - }, - }, - required: ["cookies"], + type: 'object', + properties: { + cookies: { + description: 'Cookies in Playwright storageState shape. Either harvested by the Ranch extension or pasted from any third-party cookie-exporter (Cookie-Editor, EditThisCookie, etc.).', + type: 'array', + items: { + '$ref': '#/components/schemas/ImportCookiesCookieDto' + } + }, + origins: { + description: 'Per-origin localStorage entries. Most extensions do not collect these — accepted optionally for future-proofing.', + type: 'array', + items: { + type: 'string' + } + }, + userAgent: { + type: 'string', + description: 'User-Agent of the browser that issued the cookies. CRITICAL — Instagram/Meta invalidate sessions whose UA shifts between captures and replays. The runtime applies this verbatim to its Playwright context.', + maxLength: 512 + } + }, + required: ['cookies'] } as const; export const SaveSecretDtoSchema = { - type: "object", - properties: { - value: { - type: "string", - description: - "Raw credential value (API key, token, etc.). Stored in the per-user secret store under a key derived from (service, accountKey). Never echoed back through the API once written.", - maxLength: 4096, + type: 'object', + properties: { + value: { + type: 'string', + description: 'Raw credential value (API key, token, etc.). Stored in the per-user secret store under a key derived from (service, accountKey). Never echoed back through the API once written.', + maxLength: 4096 + } }, - }, - required: ["value"], + required: ['value'] } as const; export const ResolvedSecretsDtoSchema = { - type: "object", - properties: { - env: { - type: "object", - description: - "Map of env-var name → secret value, ready for the runtime to merge into an agent process env. Empty object if the user has no matching secret-mechanism integrations.", - additionalProperties: { - type: "string", - }, - example: { - OPENAI_API_KEY: "sk-…", - GITHUB_TOKEN: "ghp_…", - }, - }, - }, - required: ["env"], + type: 'object', + properties: { + env: { + type: 'object', + description: 'Map of env-var name → secret value, ready for the runtime to merge into an agent process env. Empty object if the user has no matching secret-mechanism integrations.', + additionalProperties: { + type: 'string' + }, + example: { + OPENAI_API_KEY: 'sk-…', + GITHUB_TOKEN: 'ghp_…' + } + } + }, + required: ['env'] } as const; export const RuntimeAccountDtoSchema = { - type: "object", - properties: { - service: { - type: "string", - example: "x", - }, - accountKey: { - type: "string", - example: "dimzhuk", - }, - profile: { - type: "string", - example: "x:dimzhuk", - description: - "Exact value to pass as `profile` to browser_play (browser-mechanism only).", - }, - mechanism: { - type: "string", - enum: ["browser", "secret"], - }, - status: { - type: "string", - enum: ["pending", "connected", "needs_login", "revoked"], + type: 'object', + properties: { + service: { + type: 'string', + example: 'x' + }, + accountKey: { + type: 'string', + example: 'dimzhuk' + }, + profile: { + type: 'string', + example: 'x:dimzhuk', + description: 'Exact value to pass as `profile` to browser_play (browser-mechanism only).' + }, + mechanism: { + type: 'string', + enum: ['browser', 'secret'] + }, + status: { + type: 'string', + enum: ['pending', 'connected', 'needs_login', 'revoked'] + } }, - }, - required: ["service", "accountKey", "profile", "mechanism", "status"], + required: ['service', 'accountKey', 'profile', 'mechanism', 'status'] } as const; export const ListAccountsResponseDtoSchema = { - type: "object", - properties: { - accounts: { - type: "array", - items: { - $ref: "#/components/schemas/RuntimeAccountDto", - }, + type: 'object', + properties: { + accounts: { + type: 'array', + items: { + '$ref': '#/components/schemas/RuntimeAccountDto' + } + } }, - }, - required: ["accounts"], + required: ['accounts'] } as const; export const RequestLoginBodyDtoSchema = { - type: "object", - properties: { - service: { - type: "string", - description: - "Catalogue service key the agent wants login instructions for.", - }, - accountKey: { - type: "string", - description: - "AccountKey on this integration. Runtime derives it from the `:` profile string the tool was called with.", + type: 'object', + properties: { + service: { + type: 'string', + description: 'Catalogue service key the agent wants login instructions for.' + }, + accountKey: { + type: 'string', + description: 'AccountKey on this integration. Runtime derives it from the `:` profile string the tool was called with.' + } }, - }, - required: ["service", "accountKey"], + required: ['service', 'accountKey'] } as const; export const IssueIntegrationExtensionTokenDtoSchema = { - type: "object", - properties: { - ttlDays: { - type: "number", - description: "Token lifetime in days. Defaults to 30; max 365.", - minimum: 1, - maximum: 365, - }, - }, + type: 'object', + properties: { + ttlDays: { + type: 'number', + description: 'Token lifetime in days. Defaults to 30; max 365.', + minimum: 1, + maximum: 365 + } + } } as const; export const IssueIntegrationExtensionTokenResponseDtoSchema = { - type: "object", - properties: { - token: { - type: "string", - }, - exp: { - type: "number", - description: "Unix seconds", + type: 'object', + properties: { + token: { + type: 'string' + }, + exp: { + type: 'number', + description: 'Unix seconds' + } }, - }, - required: ["token", "exp"], + required: ['token', 'exp'] } as const; export const ExtensionImportStateDtoSchema = { - type: "object", - properties: { - cookies: { - description: - "Cookies in Playwright storageState shape. Either harvested by the Ranch extension or pasted from any third-party cookie-exporter (Cookie-Editor, EditThisCookie, etc.).", - type: "array", - items: { - $ref: "#/components/schemas/ImportCookiesCookieDto", - }, - }, - origins: { - description: - "Per-origin localStorage entries. Most extensions do not collect these — accepted optionally for future-proofing.", - type: "array", - items: { - type: "string", - }, - }, - userAgent: { - type: "string", - description: - "User-Agent of the browser that issued the cookies. CRITICAL — Instagram/Meta invalidate sessions whose UA shifts between captures and replays. The runtime applies this verbatim to its Playwright context.", - maxLength: 512, - }, - service: { - type: "string", - example: "instagram", - description: - "Catalogue service key (auto-detected by the extension from the current tab URL via catalogue.domains).", - }, - accountKey: { - type: "string", - example: "miybot", - description: - "User-chosen accountKey for this integration. Same charset as POST /integrations/accounts.", - }, - }, - required: ["cookies", "service", "accountKey"], + type: 'object', + properties: { + cookies: { + description: 'Cookies in Playwright storageState shape. Either harvested by the Ranch extension or pasted from any third-party cookie-exporter (Cookie-Editor, EditThisCookie, etc.).', + type: 'array', + items: { + '$ref': '#/components/schemas/ImportCookiesCookieDto' + } + }, + origins: { + description: 'Per-origin localStorage entries. Most extensions do not collect these — accepted optionally for future-proofing.', + type: 'array', + items: { + type: 'string' + } + }, + userAgent: { + type: 'string', + description: 'User-Agent of the browser that issued the cookies. CRITICAL — Instagram/Meta invalidate sessions whose UA shifts between captures and replays. The runtime applies this verbatim to its Playwright context.', + maxLength: 512 + }, + service: { + type: 'string', + example: 'instagram', + description: 'Catalogue service key (auto-detected by the extension from the current tab URL via catalogue.domains).' + }, + accountKey: { + type: 'string', + example: 'miybot', + description: 'User-chosen accountKey for this integration. Same charset as POST /integrations/accounts.' + } + }, + required: ['cookies', 'service', 'accountKey'] } as const; export const ExtensionImportStateResponseDtoSchema = { - type: "object", - properties: { - ok: { - type: "boolean", - }, - accountId: { - type: "string", - description: "IntegrationAccount id (created or reused).", - }, - service: { - type: "string", - }, - accountKey: { - type: "string", - }, - cookies: { - type: "number", + type: 'object', + properties: { + ok: { + type: 'boolean' + }, + accountId: { + type: 'string', + description: 'IntegrationAccount id (created or reused).' + }, + service: { + type: 'string' + }, + accountKey: { + type: 'string' + }, + cookies: { + type: 'number' + } }, - }, - required: ["ok", "accountId", "service", "accountKey", "cookies"], -} as const; + required: ['ok', 'accountId', 'service', 'accountKey', 'cookies'] +} as const; \ No newline at end of file diff --git a/admin/slices/setup/api/data/repositories/api/sdk.gen.ts b/admin/slices/setup/api/data/repositories/api/sdk.gen.ts index bab07fff..2f09395e 100644 --- a/admin/slices/setup/api/data/repositories/api/sdk.gen.ts +++ b/admin/slices/setup/api/data/repositories/api/sdk.gen.ts @@ -1,3915 +1,2526 @@ // This file is auto-generated by @hey-api/openapi-ts -import { - type Options as ClientOptions, - type TDataShape, - type Client, - formDataBodySerializer, -} from "@hey-api/client-axios"; -import type { - HealthControllerCheckData, - InitControllerStatusData, - InitControllerInitData, - AuthControllerLoginData, - AuthControllerRegisterData, - AuthControllerRefreshData, - AuthControllerLogoutData, - AuthControllerMeData, - AuthControllerEmbedTokenData, - SettingControllerFindAllData, - SettingControllerFindByGroupData, - SettingControllerRemoveData, - SettingControllerFindByKeyData, - SettingControllerUpsertData, - ApiKeyControllerFindAllData, - ApiKeyControllerCreateData, - ApiKeyControllerRemoveData, - ApiKeyControllerRemoveResponse, - LlmControllerFindAllData, - LlmControllerCreateData, - LlmControllerRemoveData, - LlmControllerFindByIdData, - LlmControllerUpdateData, - HealthCheckLlmCredentialData, - HealthCheckLlmCredentialResponse, - TemplateControllerFindAllData, - TemplateControllerCreateData, - TemplateControllerRemoveData, - TemplateControllerFindByIdData, - TemplateControllerUpdateData, - TemplateControllerSetSkillsData, - TemplateControllerSetMcpsData, - McpServerControllerFindAllData, - McpServerControllerCreateData, - McpServerControllerRemoveData, - McpServerControllerFindByIdData, - McpServerControllerUpdateData, - GetKnowledgesData, - GetKnowledgesResponse, - CreateKnowledgeData, - GetKnowledgeStatusData, - GetGraphLabelsData, - GetGraphLabelsResponse, - GetGraphData, - GetGraphResponse, - GetKnowledgeOverviewData, - GetKnowledgeOverviewResponse, - DeleteKnowledgeData, - DeleteKnowledgeResponse, - GetKnowledgeData, - UpdateKnowledgeData, - IndexKnowledgeData, - QueryKnowledgeData, - QueryKnowledgeResponse, - GetKnowledgeSourcesData, - GetKnowledgeSourcesResponse, - AddKnowledgeSourceData, - GetKnowledgeSourceImportsData, - GetKnowledgeSourceImportsResponse, - ExportKnowledgeSourcesData, - GetKnowledgeSourceContentData, - ReindexKnowledgeSourceData, - ExtractKnowledgeSourceTextData, - AddKnowledgeFileSourcesData, - AddKnowledgeFileSourcesResponse, - AddKnowledgeSourcesFromSitemapData, - AddKnowledgeSourcesFromSitemapResponse, - AddKnowledgeSourcesFromArchiveData, - AddKnowledgeSourcesFromArchiveResponse, - DeleteKnowledgeSourceData, - DeleteKnowledgeSourceResponse, - AgentControllerFindAllData, - AgentControllerFindAllResponse, - AgentControllerCreateData, - AgentControllerFindPublicData, - AgentControllerFindPublicResponse, - AgentControllerStatusData, - AgentControllerStatusResponse, - AgentControllerStatusStreamData, - GetClusterCapacityData, - GetClusterCapacityResponse, - AgentControllerRemoveData, - AgentControllerFindByIdData, - AgentControllerFindByIdResponse, - AgentControllerUpdateData, - GetAgentMetricsData, - GetAgentMetricsResponse, - GetAgentEnvData, - GetAgentEnvResponse, - GetAgentMcpsData, - GetAgentMcpsResponse, - AgentControllerFindAdminData, - AgentControllerDemoteAdminData, - AgentControllerPromoteAdminData, - AgentControllerRestartData, - AgentControllerStopData, - AgentControllerStartData, - RestartByTemplateData, - FileControllerListData, - FileControllerDeleteData, - FileControllerDeleteResponse, - FileControllerReadData, - FileControllerReadResponse, - FileControllerSaveData, - FileControllerSyncData, - FileControllerSyncError, - ExportAgentFilesData, - SendBridleMessageData, - SendBridleMessageSyncData, - UploadBridleAttachmentData, - UploadBridleAttachmentResponse, - GetBridleAttachmentData, - BridleHealthData, - BridleHealthResponse, - BridleAgentHealthData, - BridleAgentHealthResponse, - ListAgentsData, - ResetBridleTranscriptData, - ResetBridleTranscriptResponse, - GetBridleTranscriptData, - GetBridleTranscriptResponse, - ArchiveBridleTranscriptData, - RevokeAgentShareLinkData, - RevokeAgentShareLinkResponse, - GetAgentShareLinkData, - GetAgentShareLinkResponse, - CreateAgentShareLinkData, - CreateAgentShareLinkResponse, - RegenerateAgentShareLinkData, - RegenerateAgentShareLinkResponse, - ResolveShareLinkData, - ResolveShareLinkResponse, - SkillControllerFindAllData, - SkillControllerCreateData, - SkillControllerListSourcesData, - SkillControllerSearchData, - SkillControllerImportFromUrlData, - SkillControllerImportFromGithubData, - SkillControllerRemoveData, - SkillControllerFindByIdData, - SkillControllerUpdateData, - FindDependentAgentsData, - GetChatsData, - GetChatsResponse, - GetChatData, - GetChatResponse, - GetChatMessagesData, - GetChatMessagesResponse, - SyncChatsData, - SyncChatsResponse, - SummarizeChatData, - SummarizeChatResponse, - GetMyChatFeedbackData, - GetMyChatFeedbackResponse, - CreateChatFeedbackData, - CreateChatFeedbackResponse, - DeleteChatFeedbackData, - DeleteChatFeedbackResponse, - ExportChatData, - GetMyChatsData, - GetMyChatsResponse, - GetMyChatData, - GetMyChatResponse, - GetMyChatMessagesData, - GetMyChatMessagesResponse, - SyncMyChatsData, - SyncMyChatsResponse, - ListMyChatFeedbackData, - ListMyChatFeedbackResponse, - CreateMyChatFeedbackData, - CreateMyChatFeedbackResponse, - DeleteMyChatFeedbackData, - DeleteMyChatFeedbackResponse, - ExportMyChatData, - GetAgentChannelsData, - GetAgentChannelsResponse, - SetAgentChannelsData, - SetAgentChannelsResponse, - UserControllerFindAllData, - UserControllerCreateData, - UserControllerRemoveData, - UserControllerFindByIdData, - UserControllerUpdateData, - UserControllerUpdateRoleData, - TemplateFileControllerListData, - TemplateFileControllerReadData, - TemplateFileControllerSaveData, - TemplateFileControllerUploadData, - PreviewTemplateInstallData, - PreviewTemplateInstallResponse, - InstallTemplateData, - InstallTemplateResponse, - PreviewTemplateInstallFromGitData, - PreviewTemplateInstallFromGitResponse, - InstallTemplateFromGitData, - InstallTemplateFromGitResponse, - DownloadTemplateData, - PaddockScenarioControllerFindAllData, - PaddockScenarioControllerCreateData, - PaddockScenarioControllerRemoveData, - PaddockScenarioControllerFindByIdData, - PaddockScenarioControllerUpdateData, - PaddockScenarioControllerGenerateData, - SecretControllerDeleteData, - SecretControllerDeleteResponse, - SecretControllerListData, - SecretControllerListResponse, - SecretControllerSetData, - SecretControllerSetResponse, - SecretControllerReplaceData, - SecretControllerReplaceResponse, - LogControllerGetLogsData, - UsageControllerFindForAgentData, - UsageControllerReportData, - UsageControllerReportResponse, - UsageControllerFindForCredentialData, - UsageControllerFindOverviewData, - RancherControllerStatusData, - RancherControllerEnsureTemplateData, - UpgradeControllerStatusData, - UpgradeControllerRunData, - PaddockEvaluationControllerListData, - PaddockEvaluationControllerStartData, - PaddockEvaluationControllerGetData, - PaddockEvaluationControllerReportData, - PaddockEvaluationControllerLogsData, - PaddockEvaluationControllerScenarioData, - PaddockEvaluationControllerTraceData, - PaddockEvaluationControllerAbortData, - PaddockEvaluationControllerRerunData, - ListBrowserSessionsData, - ListBrowserSessionsResponse, - OpenBrowserSessionData, - OpenBrowserSessionResponse, - DeleteBrowserSessionData, - DeleteBrowserSessionResponse, - GetBrowserSessionData, - GetBrowserSessionResponse, - ResetBrowserSessionData, - ResetBrowserSessionResponse, - SetBrowserSessionStatusData, - SetBrowserSessionStatusResponse, - MintBrowserSessionVncUrlData, - OpenBrowserSessionInternalData, - SetBrowserSessionStatusInternalData, - ResetBrowserSessionInternalData, - HarvestBrowserSessionInternalData, - CleanupBrowserSessionsInternalData, - IssueBrowserExtensionTokenData, - IssueBrowserExtensionTokenResponse, - ImportBrowserStorageStateData, - ImportBrowserStorageStateResponse, - ListIntegrationCatalogueData, - ListIntegrationCatalogueResponse, - ListIntegrationAccountsData, - ListIntegrationAccountsResponse, - ConnectIntegrationData, - ConnectIntegrationResponse, - DisconnectIntegrationData, - DisconnectIntegrationResponse, - GetIntegrationAccountData, - GetIntegrationAccountResponse, - OpenIntegrationLoginData, - OpenIntegrationLoginResponse, - ImportIntegrationCookiesData, - ImportIntegrationCookiesResponse, - SaveIntegrationSecretData, - SaveIntegrationSecretResponse, - ResolveIntegrationSecretsData, - ResolveIntegrationSecretsResponse, - ListIntegrationAccountsForRuntimeData, - ListIntegrationAccountsForRuntimeResponse, - RequestIntegrationLoginData, - RequestIntegrationLoginResponse, - ResolveIntegrationBrowserStateData, - IssueIntegrationExtensionTokenData, - IssueIntegrationExtensionTokenResponse, - ImportIntegrationExtensionStateData, - ImportIntegrationExtensionStateResponse, - DownloadIntegrationExtensionData, - SseControllerSseData, - SseControllerMessagesData, - SseControllerDebugSessionsData, - StreamableHttpControllerHandleDeleteRequestData, - StreamableHttpControllerHandleGetRequestData, - StreamableHttpControllerHandlePostRequestData, -} from "./types.gen"; -import { client as _heyApiClient } from "./client.gen"; - -export type Options< - TData extends TDataShape = TDataShape, - ThrowOnError extends boolean = boolean, -> = ClientOptions & { - /** - * You can provide a client instance returned by `createClient()` instead of - * individual options. This might be also useful if you want to implement a - * custom client. - */ - client?: Client; - /** - * You can pass arbitrary values through the `meta` object. This can be - * used to access values that aren't defined as part of the SDK function. - */ - meta?: Record; +import { type Options as ClientOptions, type TDataShape, type Client, formDataBodySerializer } from '@hey-api/client-axios'; +import type { HealthControllerCheckData, InitControllerStatusData, InitControllerInitData, AuthControllerLoginData, AuthControllerRegisterData, AuthControllerRefreshData, AuthControllerLogoutData, AuthControllerMeData, AuthControllerEmbedTokenData, SettingControllerFindAllData, SettingControllerFindByGroupData, SettingControllerRemoveData, SettingControllerFindByKeyData, SettingControllerUpsertData, ApiKeyControllerFindAllData, ApiKeyControllerCreateData, ApiKeyControllerRemoveData, ApiKeyControllerRemoveResponse, LlmControllerFindAllData, LlmControllerCreateData, LlmControllerRemoveData, LlmControllerFindByIdData, LlmControllerUpdateData, HealthCheckLlmCredentialData, HealthCheckLlmCredentialResponse, TemplateControllerFindAllData, TemplateControllerCreateData, TemplateControllerRemoveData, TemplateControllerFindByIdData, TemplateControllerUpdateData, TemplateControllerSetSkillsData, TemplateControllerSetMcpsData, McpServerControllerFindAllData, McpServerControllerCreateData, McpServerControllerRemoveData, McpServerControllerFindByIdData, McpServerControllerUpdateData, GetKnowledgesData, GetKnowledgesResponse, CreateKnowledgeData, GetKnowledgeStatusData, GetGraphLabelsData, GetGraphLabelsResponse, GetGraphData, GetGraphResponse, GetKnowledgeOverviewData, GetKnowledgeOverviewResponse, DeleteKnowledgeData, DeleteKnowledgeResponse, GetKnowledgeData, UpdateKnowledgeData, IndexKnowledgeData, QueryKnowledgeData, QueryKnowledgeResponse, GetKnowledgeSourcesData, GetKnowledgeSourcesResponse, AddKnowledgeSourceData, GetKnowledgeSourceImportsData, GetKnowledgeSourceImportsResponse, ExportKnowledgeSourcesData, GetKnowledgeSourceContentData, ReindexKnowledgeSourceData, ExtractKnowledgeSourceTextData, AddKnowledgeFileSourcesData, AddKnowledgeFileSourcesResponse, AddKnowledgeSourcesFromSitemapData, AddKnowledgeSourcesFromSitemapResponse, AddKnowledgeSourcesFromArchiveData, AddKnowledgeSourcesFromArchiveResponse, DeleteKnowledgeSourceData, DeleteKnowledgeSourceResponse, AgentControllerFindAllData, AgentControllerFindAllResponse, AgentControllerCreateData, AgentControllerFindPublicData, AgentControllerFindPublicResponse, AgentControllerStatusData, AgentControllerStatusResponse, AgentControllerStatusStreamData, GetClusterCapacityData, GetClusterCapacityResponse, AgentControllerRemoveData, AgentControllerFindByIdData, AgentControllerFindByIdResponse, AgentControllerUpdateData, GetAgentMetricsData, GetAgentMetricsResponse, GetAgentEnvData, GetAgentEnvResponse, GetAgentMcpsData, GetAgentMcpsResponse, AgentControllerFindAdminData, AgentControllerDemoteAdminData, AgentControllerPromoteAdminData, AgentControllerRestartData, AgentControllerStopData, AgentControllerStartData, RestartByTemplateData, FileControllerListData, FileControllerDeleteData, FileControllerDeleteResponse, FileControllerReadData, FileControllerReadResponse, FileControllerSaveData, FileControllerSyncData, FileControllerSyncError, ExportAgentFilesData, SendBridleMessageData, SendBridleMessageSyncData, UploadBridleAttachmentData, UploadBridleAttachmentResponse, GetBridleAttachmentData, BridleHealthData, BridleHealthResponse, BridleAgentHealthData, BridleAgentHealthResponse, ListAgentsData, ResetBridleTranscriptData, ResetBridleTranscriptResponse, GetBridleTranscriptData, GetBridleTranscriptResponse, ArchiveBridleTranscriptData, RevokeAgentShareLinkData, RevokeAgentShareLinkResponse, GetAgentShareLinkData, GetAgentShareLinkResponse, CreateAgentShareLinkData, CreateAgentShareLinkResponse, RegenerateAgentShareLinkData, RegenerateAgentShareLinkResponse, ResolveShareLinkData, ResolveShareLinkResponse, SkillControllerFindAllData, SkillControllerCreateData, SkillControllerListSourcesData, SkillControllerSearchData, SkillControllerImportFromUrlData, SkillControllerImportFromGithubData, SkillControllerRemoveData, SkillControllerFindByIdData, SkillControllerUpdateData, FindDependentAgentsData, GetChatsData, GetChatsResponse, GetChatData, GetChatResponse, GetChatMessagesData, GetChatMessagesResponse, SyncChatsData, SyncChatsResponse, SummarizeChatData, SummarizeChatResponse, GetMyChatFeedbackData, GetMyChatFeedbackResponse, CreateChatFeedbackData, CreateChatFeedbackResponse, DeleteChatFeedbackData, DeleteChatFeedbackResponse, ExportChatData, GetMyChatsData, GetMyChatsResponse, GetMyChatData, GetMyChatResponse, GetMyChatMessagesData, GetMyChatMessagesResponse, SyncMyChatsData, SyncMyChatsResponse, ListMyChatFeedbackData, ListMyChatFeedbackResponse, CreateMyChatFeedbackData, CreateMyChatFeedbackResponse, DeleteMyChatFeedbackData, DeleteMyChatFeedbackResponse, ExportMyChatData, GetAgentChannelsData, GetAgentChannelsResponse, SetAgentChannelsData, SetAgentChannelsResponse, UserControllerFindAllData, UserControllerCreateData, UserControllerRemoveData, UserControllerFindByIdData, UserControllerUpdateData, UserControllerUpdateRoleData, TemplateFileControllerListData, TemplateFileControllerReadData, TemplateFileControllerSaveData, TemplateFileControllerUploadData, PreviewTemplateInstallData, PreviewTemplateInstallResponse, InstallTemplateData, InstallTemplateResponse, PreviewTemplateInstallFromGitData, PreviewTemplateInstallFromGitResponse, InstallTemplateFromGitData, InstallTemplateFromGitResponse, DownloadTemplateData, PaddockScenarioControllerFindAllData, PaddockScenarioControllerCreateData, PaddockScenarioControllerRemoveData, PaddockScenarioControllerFindByIdData, PaddockScenarioControllerUpdateData, PaddockScenarioControllerGenerateData, GetAgentCardData, GetAgentCardResponse, ListAgentPeersData, ListAgentPeersResponse, ConnectAgentPeerData, ConnectAgentPeerResponse, ListAgentPeerCandidatesData, ListAgentPeerCandidatesResponse, RefreshAgentPeerData, RefreshAgentPeerResponse, RemoveAgentPeerData, RemoveAgentPeerResponse, ListAgentDelegationsData, ListAgentDelegationsResponse, SecretControllerDeleteData, SecretControllerDeleteResponse, SecretControllerListData, SecretControllerListResponse, SecretControllerSetData, SecretControllerSetResponse, SecretControllerReplaceData, SecretControllerReplaceResponse, LogControllerGetLogsData, UsageControllerFindForAgentData, UsageControllerReportData, UsageControllerReportResponse, UsageControllerFindForCredentialData, UsageControllerFindOverviewData, RancherControllerStatusData, RancherControllerEnsureTemplateData, UpgradeControllerStatusData, UpgradeControllerRunData, StartMcpOauthData, StartMcpOauthResponse, McpOauthCallbackData, McpOauthStatusData, McpOauthStatusResponse, PaddockEvaluationControllerListData, PaddockEvaluationControllerStartData, PaddockEvaluationControllerGetData, PaddockEvaluationControllerReportData, PaddockEvaluationControllerLogsData, PaddockEvaluationControllerScenarioData, PaddockEvaluationControllerTraceData, PaddockEvaluationControllerAbortData, PaddockEvaluationControllerRerunData, ListBrowserSessionsData, ListBrowserSessionsResponse, OpenBrowserSessionData, OpenBrowserSessionResponse, DeleteBrowserSessionData, DeleteBrowserSessionResponse, GetBrowserSessionData, GetBrowserSessionResponse, ResetBrowserSessionData, ResetBrowserSessionResponse, SetBrowserSessionStatusData, SetBrowserSessionStatusResponse, MintBrowserSessionVncUrlData, OpenBrowserSessionInternalData, SetBrowserSessionStatusInternalData, ResetBrowserSessionInternalData, HarvestBrowserSessionInternalData, CleanupBrowserSessionsInternalData, IssueBrowserExtensionTokenData, IssueBrowserExtensionTokenResponse, ImportBrowserStorageStateData, ImportBrowserStorageStateResponse, ListIntegrationCatalogueData, ListIntegrationCatalogueResponse, ListIntegrationAccountsData, ListIntegrationAccountsResponse, ConnectIntegrationData, ConnectIntegrationResponse, DisconnectIntegrationData, DisconnectIntegrationResponse, GetIntegrationAccountData, GetIntegrationAccountResponse, OpenIntegrationLoginData, OpenIntegrationLoginResponse, ImportIntegrationCookiesData, ImportIntegrationCookiesResponse, SaveIntegrationSecretData, SaveIntegrationSecretResponse, ResolveIntegrationSecretsData, ResolveIntegrationSecretsResponse, ListIntegrationAccountsForRuntimeData, ListIntegrationAccountsForRuntimeResponse, RequestIntegrationLoginData, RequestIntegrationLoginResponse, ResolveIntegrationBrowserStateData, IssueIntegrationExtensionTokenData, IssueIntegrationExtensionTokenResponse, ImportIntegrationExtensionStateData, ImportIntegrationExtensionStateResponse, DownloadIntegrationExtensionData, SseControllerSseData, SseControllerMessagesData, SseControllerDebugSessionsData, StreamableHttpControllerHandleDeleteRequestData, StreamableHttpControllerHandleGetRequestData, StreamableHttpControllerHandlePostRequestData } from './types.gen'; +import { client as _heyApiClient } from './client.gen'; + +export type Options = ClientOptions & { + /** + * You can provide a client instance returned by `createClient()` instead of + * individual options. This might be also useful if you want to implement a + * custom client. + */ + client?: Client; + /** + * You can pass arbitrary values through the `meta` object. This can be + * used to access values that aren't defined as part of the SDK function. + */ + meta?: Record; }; export class HealthService { - public static healthControllerCheck( - options?: Options, - ) { - return (options?.client ?? _heyApiClient).get< - unknown, - unknown, - ThrowOnError - >({ - url: "/health", - ...options, - }); - } + public static healthControllerCheck(options?: Options) { + return (options?.client ?? _heyApiClient).get({ + url: '/health', + ...options + }); + } + } export class SetupService { - /** - * Check whether the system needs initial owner setup - */ - public static initControllerStatus( - options?: Options, - ) { - return (options?.client ?? _heyApiClient).get< - unknown, - unknown, - ThrowOnError - >({ - url: "/setup/status", - ...options, - }); - } - - /** - * Create the first owner. Fails if one already exists. Sets the session cookie like login. - */ - public static initControllerInit( - options: Options, - ) { - return (options.client ?? _heyApiClient).post< - unknown, - unknown, - ThrowOnError - >({ - url: "/setup/init", - ...options, - headers: { - "Content-Type": "application/json", - ...options?.headers, - }, - }); - } + /** + * Check whether the system needs initial owner setup + */ + public static initControllerStatus(options?: Options) { + return (options?.client ?? _heyApiClient).get({ + url: '/setup/status', + ...options + }); + } + + /** + * Create the first owner. Fails if one already exists. Sets the session cookie like login. + */ + public static initControllerInit(options: Options) { + return (options.client ?? _heyApiClient).post({ + url: '/setup/init', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + }); + } + } export class AuthService { - /** - * Authenticate: returns a short-lived access token and sets the httpOnly session cookie used by POST /auth/refresh - */ - public static authControllerLogin( - options: Options, - ) { - return (options.client ?? _heyApiClient).post< - unknown, - unknown, - ThrowOnError - >({ - url: "/auth/login", - ...options, - headers: { - "Content-Type": "application/json", - ...options?.headers, - }, - }); - } - - /** - * Self-service signup. Requires the 'auth.registration_enabled' setting to be true; otherwise 403. Sets the session cookie like login. - */ - public static authControllerRegister( - options: Options, - ) { - return (options.client ?? _heyApiClient).post< - unknown, - unknown, - ThrowOnError - >({ - url: "/auth/register", - ...options, - headers: { - "Content-Type": "application/json", - ...options?.headers, - }, - }); - } - - /** - * Renew the access token from the session cookie alone (works after the access token expired). Slides the session inactivity window and re-sets the cookie. No body; send credentials. - */ - public static authControllerRefresh( - options?: Options, - ) { - return (options?.client ?? _heyApiClient).post< - unknown, - unknown, - ThrowOnError - >({ - url: "/auth/refresh", - ...options, - }); - } - - /** - * Revoke the session behind the cookie and clear it. Always 200; `revoked` is false when nothing live matched. - */ - public static authControllerLogout( - options?: Options, - ) { - return (options?.client ?? _heyApiClient).post< - unknown, - unknown, - ThrowOnError - >({ - url: "/auth/logout", - ...options, - }); - } - - /** - * Get the current authenticated user - */ - public static authControllerMe( - options?: Options, - ) { - return (options?.client ?? _heyApiClient).get< - unknown, - unknown, - ThrowOnError - >({ - url: "/auth/me", - ...options, - }); - } - - /** - * Mint a short-lived browser embed JWT for the bridle widget. Auth: API key with embed:mint scope. Owner/Admin roles are stripped from the result unless the key also carries embed:mint-admin — then they are kept and the TTL is capped at 7d. - */ - public static authControllerEmbedToken( - options: Options, - ) { - return (options.client ?? _heyApiClient).post< - unknown, - unknown, - ThrowOnError - >({ - url: "/auth/embed/token", - ...options, - headers: { - "Content-Type": "application/json", - ...options?.headers, - }, - }); - } + /** + * Authenticate: returns a short-lived access token and sets the httpOnly session cookie used by POST /auth/refresh + */ + public static authControllerLogin(options: Options) { + return (options.client ?? _heyApiClient).post({ + url: '/auth/login', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + }); + } + + /** + * Self-service signup. Requires the 'auth.registration_enabled' setting to be true; otherwise 403. Sets the session cookie like login. + */ + public static authControllerRegister(options: Options) { + return (options.client ?? _heyApiClient).post({ + url: '/auth/register', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + }); + } + + /** + * Renew the access token from the session cookie alone (works after the access token expired). Slides the session inactivity window and re-sets the cookie. No body; send credentials. + */ + public static authControllerRefresh(options?: Options) { + return (options?.client ?? _heyApiClient).post({ + url: '/auth/refresh', + ...options + }); + } + + /** + * Revoke the session behind the cookie and clear it. Always 200; `revoked` is false when nothing live matched. + */ + public static authControllerLogout(options?: Options) { + return (options?.client ?? _heyApiClient).post({ + url: '/auth/logout', + ...options + }); + } + + /** + * Get the current authenticated user + */ + public static authControllerMe(options?: Options) { + return (options?.client ?? _heyApiClient).get({ + url: '/auth/me', + ...options + }); + } + + /** + * Mint a short-lived browser embed JWT for the bridle widget. Auth: API key with embed:mint scope. Owner/Admin roles are stripped from the result unless the key also carries embed:mint-admin — then they are kept and the TTL is capped at 7d. + */ + public static authControllerEmbedToken(options: Options) { + return (options.client ?? _heyApiClient).post({ + url: '/auth/embed/token', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + }); + } + } export class SettingsService { - /** - * List all settings - */ - public static settingControllerFindAll( - options?: Options, - ) { - return (options?.client ?? _heyApiClient).get< - unknown, - unknown, - ThrowOnError - >({ - url: "/settings", - ...options, - }); - } - - /** - * List settings in a group - */ - public static settingControllerFindByGroup< - ThrowOnError extends boolean = false, - >(options: Options) { - return (options.client ?? _heyApiClient).get< - unknown, - unknown, - ThrowOnError - >({ - url: "/settings/{group}", - ...options, - }); - } - - /** - * Delete a setting - */ - public static settingControllerRemove( - options: Options, - ) { - return (options.client ?? _heyApiClient).delete< - unknown, - unknown, - ThrowOnError - >({ - url: "/settings/{group}/{name}", - ...options, - }); - } - - /** - * Get a single setting by group/name - */ - public static settingControllerFindByKey< - ThrowOnError extends boolean = false, - >(options: Options) { - return (options.client ?? _heyApiClient).get< - unknown, - unknown, - ThrowOnError - >({ - url: "/settings/{group}/{name}", - ...options, - }); - } - - /** - * Create or replace a setting - */ - public static settingControllerUpsert( - options: Options, - ) { - return (options.client ?? _heyApiClient).put< - unknown, - unknown, - ThrowOnError - >({ - url: "/settings/{group}/{name}", - ...options, - headers: { - "Content-Type": "application/json", - ...options?.headers, - }, - }); - } + /** + * List all settings + */ + public static settingControllerFindAll(options?: Options) { + return (options?.client ?? _heyApiClient).get({ + url: '/settings', + ...options + }); + } + + /** + * List settings in a group + */ + public static settingControllerFindByGroup(options: Options) { + return (options.client ?? _heyApiClient).get({ + url: '/settings/{group}', + ...options + }); + } + + /** + * Delete a setting + */ + public static settingControllerRemove(options: Options) { + return (options.client ?? _heyApiClient).delete({ + url: '/settings/{group}/{name}', + ...options + }); + } + + /** + * Get a single setting by group/name + */ + public static settingControllerFindByKey(options: Options) { + return (options.client ?? _heyApiClient).get({ + url: '/settings/{group}/{name}', + ...options + }); + } + + /** + * Create or replace a setting + */ + public static settingControllerUpsert(options: Options) { + return (options.client ?? _heyApiClient).put({ + url: '/settings/{group}/{name}', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + }); + } + } export class ApiKeysService { - /** - * List all API keys - */ - public static apiKeyControllerFindAll( - options?: Options, - ) { - return (options?.client ?? _heyApiClient).get< - unknown, - unknown, - ThrowOnError - >({ - url: "/api-keys", - ...options, - }); - } - - /** - * Create a new API key. The plaintext key is returned exactly once — only its hash is persisted. - */ - public static apiKeyControllerCreate( - options: Options, - ) { - return (options.client ?? _heyApiClient).post< - unknown, - unknown, - ThrowOnError - >({ - url: "/api-keys", - ...options, - headers: { - "Content-Type": "application/json", - ...options?.headers, - }, - }); - } - - /** - * Revoke (delete) an API key. - */ - public static apiKeyControllerRemove( - options: Options, - ) { - return (options.client ?? _heyApiClient).delete< - ApiKeyControllerRemoveResponse, - unknown, - ThrowOnError - >({ - url: "/api-keys/{id}", - ...options, - }); - } + /** + * List all API keys + */ + public static apiKeyControllerFindAll(options?: Options) { + return (options?.client ?? _heyApiClient).get({ + url: '/api-keys', + ...options + }); + } + + /** + * Create a new API key. The plaintext key is returned exactly once — only its hash is persisted. + */ + public static apiKeyControllerCreate(options: Options) { + return (options.client ?? _heyApiClient).post({ + url: '/api-keys', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + }); + } + + /** + * Revoke (delete) an API key. + */ + public static apiKeyControllerRemove(options: Options) { + return (options.client ?? _heyApiClient).delete({ + url: '/api-keys/{id}', + ...options + }); + } + } export class LlmsService { - /** - * List all LLM credentials - */ - public static llmControllerFindAll( - options?: Options, - ) { - return (options?.client ?? _heyApiClient).get< - unknown, - unknown, - ThrowOnError - >({ - url: "/llms", - ...options, - }); - } - - /** - * Create a new LLM credential - */ - public static llmControllerCreate( - options: Options, - ) { - return (options.client ?? _heyApiClient).post< - unknown, - unknown, - ThrowOnError - >({ - url: "/llms", - ...options, - headers: { - "Content-Type": "application/json", - ...options?.headers, - }, - }); - } - - /** - * Delete an LLM credential - */ - public static llmControllerRemove( - options: Options, - ) { - return (options.client ?? _heyApiClient).delete< - unknown, - unknown, - ThrowOnError - >({ - url: "/llms/{id}", - ...options, - }); - } - - /** - * Get LLM credential by ID - */ - public static llmControllerFindById( - options: Options, - ) { - return (options.client ?? _heyApiClient).get< - unknown, - unknown, - ThrowOnError - >({ - url: "/llms/{id}", - ...options, - }); - } - - /** - * Update an LLM credential - */ - public static llmControllerUpdate( - options: Options, - ) { - return (options.client ?? _heyApiClient).put< - unknown, - unknown, - ThrowOnError - >({ - url: "/llms/{id}", - ...options, - headers: { - "Content-Type": "application/json", - ...options?.headers, - }, - }); - } - - /** - * Health-check an LLM credential - */ - public static healthCheckLlmCredential( - options: Options, - ) { - return (options.client ?? _heyApiClient).post< - HealthCheckLlmCredentialResponse, - unknown, - ThrowOnError - >({ - url: "/llms/{id}/health-check", - ...options, - }); - } + /** + * List all LLM credentials + */ + public static llmControllerFindAll(options?: Options) { + return (options?.client ?? _heyApiClient).get({ + url: '/llms', + ...options + }); + } + + /** + * Create a new LLM credential + */ + public static llmControllerCreate(options: Options) { + return (options.client ?? _heyApiClient).post({ + url: '/llms', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + }); + } + + /** + * Delete an LLM credential + */ + public static llmControllerRemove(options: Options) { + return (options.client ?? _heyApiClient).delete({ + url: '/llms/{id}', + ...options + }); + } + + /** + * Get LLM credential by ID + */ + public static llmControllerFindById(options: Options) { + return (options.client ?? _heyApiClient).get({ + url: '/llms/{id}', + ...options + }); + } + + /** + * Update an LLM credential + */ + public static llmControllerUpdate(options: Options) { + return (options.client ?? _heyApiClient).put({ + url: '/llms/{id}', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + }); + } + + /** + * Health-check an LLM credential + */ + public static healthCheckLlmCredential(options: Options) { + return (options.client ?? _heyApiClient).post({ + url: '/llms/{id}/health-check', + ...options + }); + } + } export class TemplatesService { - /** - * List all agent templates - */ - public static templateControllerFindAll( - options?: Options, - ) { - return (options?.client ?? _heyApiClient).get< - unknown, - unknown, - ThrowOnError - >({ - url: "/templates", - ...options, - }); - } - - /** - * Create a new agent template - */ - public static templateControllerCreate( - options: Options, - ) { - return (options.client ?? _heyApiClient).post< - unknown, - unknown, - ThrowOnError - >({ - url: "/templates", - ...options, - headers: { - "Content-Type": "application/json", - ...options?.headers, - }, - }); - } - - /** - * Delete a template - */ - public static templateControllerRemove( - options: Options, - ) { - return (options.client ?? _heyApiClient).delete< - unknown, - unknown, - ThrowOnError - >({ - url: "/templates/{id}", - ...options, - }); - } - - /** - * Get template by ID - */ - public static templateControllerFindById< - ThrowOnError extends boolean = false, - >(options: Options) { - return (options.client ?? _heyApiClient).get< - unknown, - unknown, - ThrowOnError - >({ - url: "/templates/{id}", - ...options, - }); - } - - /** - * Update a template - */ - public static templateControllerUpdate( - options: Options, - ) { - return (options.client ?? _heyApiClient).put< - unknown, - unknown, - ThrowOnError - >({ - url: "/templates/{id}", - ...options, - headers: { - "Content-Type": "application/json", - ...options?.headers, - }, - }); - } - - /** - * Replace the skill set attached to a template. Body lists the full desired set; omitted IDs are detached. - */ - public static templateControllerSetSkills< - ThrowOnError extends boolean = false, - >(options: Options) { - return (options.client ?? _heyApiClient).put< - unknown, - unknown, - ThrowOnError - >({ - url: "/templates/{id}/skills", - ...options, - headers: { - "Content-Type": "application/json", - ...options?.headers, - }, - }); - } - - /** - * Replace the MCP servers attached to a template. Body lists the full desired set; omitted IDs are detached. Agents created from this template inherit these MCPs at deploy time. - */ - public static templateControllerSetMcps( - options: Options, - ) { - return (options.client ?? _heyApiClient).put< - unknown, - unknown, - ThrowOnError - >({ - url: "/templates/{id}/mcps", - ...options, - headers: { - "Content-Type": "application/json", - ...options?.headers, - }, - }); - } - - /** - * Preview template install — parse manifest and report what would happen, no DB or S3 writes. - */ - public static previewTemplateInstall( - options: Options, - ) { - return (options.client ?? _heyApiClient).post< - PreviewTemplateInstallResponse, - unknown, - ThrowOnError - >({ - ...formDataBodySerializer, - url: "/templates/install/preview", - ...options, - headers: { - "Content-Type": null, - ...options?.headers, - }, - }); - } - - /** - * Install a template from a zip archive. - */ - public static installTemplate( - options: Options, - ) { - return (options.client ?? _heyApiClient).post< - InstallTemplateResponse, - unknown, - ThrowOnError - >({ - ...formDataBodySerializer, - url: "/templates/install", - ...options, - headers: { - "Content-Type": null, - ...options?.headers, - }, - }); - } - - /** - * Preview template install from a git repository — clones into /tmp, parses manifest, reports what would happen, no DB or S3 writes. - */ - public static previewTemplateInstallFromGit< - ThrowOnError extends boolean = false, - >(options: Options) { - return (options.client ?? _heyApiClient).post< - PreviewTemplateInstallFromGitResponse, - unknown, - ThrowOnError - >({ - url: "/templates/install/from-git/preview", - ...options, - headers: { - "Content-Type": "application/json", - ...options?.headers, - }, - }); - } - - /** - * Install a template from a git repository. - */ - public static installTemplateFromGit( - options: Options, - ) { - return (options.client ?? _heyApiClient).post< - InstallTemplateFromGitResponse, - unknown, - ThrowOnError - >({ - url: "/templates/install/from-git", - ...options, - headers: { - "Content-Type": "application/json", - ...options?.headers, - }, - }); - } - - /** - * Download a template as a zip — round-trips template.yaml, .agent*, .paddock/config.json, and .paddock/scenarios* back into an installable bundle. - */ - public static downloadTemplate( - options: Options, - ) { - return (options.client ?? _heyApiClient).get< - unknown, - unknown, - ThrowOnError - >({ - url: "/templates/{id}/download", - ...options, - }); - } + /** + * List all agent templates + */ + public static templateControllerFindAll(options?: Options) { + return (options?.client ?? _heyApiClient).get({ + url: '/templates', + ...options + }); + } + + /** + * Create a new agent template + */ + public static templateControllerCreate(options: Options) { + return (options.client ?? _heyApiClient).post({ + url: '/templates', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + }); + } + + /** + * Delete a template + */ + public static templateControllerRemove(options: Options) { + return (options.client ?? _heyApiClient).delete({ + url: '/templates/{id}', + ...options + }); + } + + /** + * Get template by ID + */ + public static templateControllerFindById(options: Options) { + return (options.client ?? _heyApiClient).get({ + url: '/templates/{id}', + ...options + }); + } + + /** + * Update a template + */ + public static templateControllerUpdate(options: Options) { + return (options.client ?? _heyApiClient).put({ + url: '/templates/{id}', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + }); + } + + /** + * Replace the skill set attached to a template. Body lists the full desired set; omitted IDs are detached. + */ + public static templateControllerSetSkills(options: Options) { + return (options.client ?? _heyApiClient).put({ + url: '/templates/{id}/skills', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + }); + } + + /** + * Replace the MCP servers attached to a template. Body lists the full desired set; omitted IDs are detached. Agents created from this template inherit these MCPs at deploy time. + */ + public static templateControllerSetMcps(options: Options) { + return (options.client ?? _heyApiClient).put({ + url: '/templates/{id}/mcps', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + }); + } + + /** + * Preview template install — parse manifest and report what would happen, no DB or S3 writes. + */ + public static previewTemplateInstall(options: Options) { + return (options.client ?? _heyApiClient).post({ + ...formDataBodySerializer, + url: '/templates/install/preview', + ...options, + headers: { + 'Content-Type': null, + ...options?.headers + } + }); + } + + /** + * Install a template from a zip archive. + */ + public static installTemplate(options: Options) { + return (options.client ?? _heyApiClient).post({ + ...formDataBodySerializer, + url: '/templates/install', + ...options, + headers: { + 'Content-Type': null, + ...options?.headers + } + }); + } + + /** + * Preview template install from a git repository — clones into /tmp, parses manifest, reports what would happen, no DB or S3 writes. + */ + public static previewTemplateInstallFromGit(options: Options) { + return (options.client ?? _heyApiClient).post({ + url: '/templates/install/from-git/preview', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + }); + } + + /** + * Install a template from a git repository. + */ + public static installTemplateFromGit(options: Options) { + return (options.client ?? _heyApiClient).post({ + url: '/templates/install/from-git', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + }); + } + + /** + * Download a template as a zip — round-trips template.yaml, .agent*, .paddock/config.json, and .paddock/scenarios* back into an installable bundle. + */ + public static downloadTemplate(options: Options) { + return (options.client ?? _heyApiClient).get({ + url: '/templates/{id}/download', + ...options + }); + } + } export class McpServersService { - /** - * List all MCP servers registered in this Ranch. - */ - public static mcpServerControllerFindAll< - ThrowOnError extends boolean = false, - >(options?: Options) { - return (options?.client ?? _heyApiClient).get< - unknown, - unknown, - ThrowOnError - >({ - url: "/mcp-servers", - ...options, - }); - } - - /** - * Register a new MCP server. - */ - public static mcpServerControllerCreate( - options: Options, - ) { - return (options.client ?? _heyApiClient).post< - unknown, - unknown, - ThrowOnError - >({ - url: "/mcp-servers", - ...options, - headers: { - "Content-Type": "application/json", - ...options?.headers, - }, - }); - } - - /** - * Delete an MCP server. Built-in entries (e.g. the Ranch MCP itself) cannot be deleted — only disabled. - */ - public static mcpServerControllerRemove( - options: Options, - ) { - return (options.client ?? _heyApiClient).delete< - unknown, - unknown, - ThrowOnError - >({ - url: "/mcp-servers/{id}", - ...options, - }); - } - - /** - * Get an MCP server by id. - */ - public static mcpServerControllerFindById< - ThrowOnError extends boolean = false, - >(options: Options) { - return (options.client ?? _heyApiClient).get< - unknown, - unknown, - ThrowOnError - >({ - url: "/mcp-servers/{id}", - ...options, - }); - } - - /** - * Update an MCP server. - */ - public static mcpServerControllerUpdate( - options: Options, - ) { - return (options.client ?? _heyApiClient).patch< - unknown, - unknown, - ThrowOnError - >({ - url: "/mcp-servers/{id}", - ...options, - headers: { - "Content-Type": "application/json", - ...options?.headers, - }, - }); - } + /** + * List all MCP servers registered in this Ranch. + */ + public static mcpServerControllerFindAll(options?: Options) { + return (options?.client ?? _heyApiClient).get({ + url: '/mcp-servers', + ...options + }); + } + + /** + * Register a new MCP server. + */ + public static mcpServerControllerCreate(options: Options) { + return (options.client ?? _heyApiClient).post({ + url: '/mcp-servers', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + }); + } + + /** + * Delete an MCP server. Built-in entries (e.g. the Ranch MCP itself) cannot be deleted — only disabled. + */ + public static mcpServerControllerRemove(options: Options) { + return (options.client ?? _heyApiClient).delete({ + url: '/mcp-servers/{id}', + ...options + }); + } + + /** + * Get an MCP server by id. + */ + public static mcpServerControllerFindById(options: Options) { + return (options.client ?? _heyApiClient).get({ + url: '/mcp-servers/{id}', + ...options + }); + } + + /** + * Update an MCP server. + */ + public static mcpServerControllerUpdate(options: Options) { + return (options.client ?? _heyApiClient).patch({ + url: '/mcp-servers/{id}', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + }); + } + } export class KnowledgesService { - /** - * List knowledges (searchable, paged) - */ - public static getKnowledges( - options?: Options, - ) { - return (options?.client ?? _heyApiClient).get< - GetKnowledgesResponse, - unknown, - ThrowOnError - >({ - url: "/knowledges", - ...options, - }); - } - - /** - * Create knowledge - */ - public static createKnowledge( - options: Options, - ) { - return (options.client ?? _heyApiClient).post< - unknown, - unknown, - ThrowOnError - >({ - url: "/knowledges", - ...options, - headers: { - "Content-Type": "application/json", - ...options?.headers, - }, - }); - } - - /** - * Knowledge service availability and setup readiness - */ - public static getKnowledgeStatus( - options?: Options, - ) { - return (options?.client ?? _heyApiClient).get< - unknown, - unknown, - ThrowOnError - >({ - url: "/knowledges/status", - ...options, - }); - } - - /** - * List entity labels of one knowledge base - */ - public static getGraphLabels( - options: Options, - ) { - return (options.client ?? _heyApiClient).get< - GetGraphLabelsResponse, - unknown, - ThrowOnError - >({ - url: "/knowledges/{id}/graph/labels", - ...options, - }); - } - - /** - * Get the graph of one knowledge base - */ - public static getGraph( - options: Options, - ) { - return (options.client ?? _heyApiClient).get< - GetGraphResponse, - unknown, - ThrowOnError - >({ - url: "/knowledges/{id}/graph", - ...options, - }); - } - - /** - * Source counts by status and type, and total size, in one read - */ - public static getKnowledgeOverview( - options: Options, - ) { - return (options.client ?? _heyApiClient).get< - GetKnowledgeOverviewResponse, - unknown, - ThrowOnError - >({ - url: "/knowledges/{id}/overview", - ...options, - }); - } - - /** - * Delete knowledge - */ - public static deleteKnowledge( - options: Options, - ) { - return (options.client ?? _heyApiClient).delete< - DeleteKnowledgeResponse, - unknown, - ThrowOnError - >({ - url: "/knowledges/{id}", - ...options, - }); - } - - /** - * Get one knowledge - */ - public static getKnowledge( - options: Options, - ) { - return (options.client ?? _heyApiClient).get< - unknown, - unknown, - ThrowOnError - >({ - url: "/knowledges/{id}", - ...options, - }); - } - - /** - * Update knowledge - */ - public static updateKnowledge( - options: Options, - ) { - return (options.client ?? _heyApiClient).put< - unknown, - unknown, - ThrowOnError - >({ - url: "/knowledges/{id}", - ...options, - headers: { - "Content-Type": "application/json", - ...options?.headers, - }, - }); - } - - /** - * Start indexing - */ - public static indexKnowledge( - options: Options, - ) { - return (options.client ?? _heyApiClient).post< - unknown, - unknown, - ThrowOnError - >({ - url: "/knowledges/{id}/index", - ...options, - }); - } - - /** - * Query knowledge (LLM-generated answer) - */ - public static queryKnowledge( - options: Options, - ) { - return (options.client ?? _heyApiClient).post< - QueryKnowledgeResponse, - unknown, - ThrowOnError - >({ - url: "/knowledges/{id}/query", - ...options, - headers: { - "Content-Type": "application/json", - ...options?.headers, - }, - }); - } + /** + * List knowledges (searchable, paged) + */ + public static getKnowledges(options?: Options) { + return (options?.client ?? _heyApiClient).get({ + url: '/knowledges', + ...options + }); + } + + /** + * Create knowledge + */ + public static createKnowledge(options: Options) { + return (options.client ?? _heyApiClient).post({ + url: '/knowledges', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + }); + } + + /** + * Knowledge service availability and setup readiness + */ + public static getKnowledgeStatus(options?: Options) { + return (options?.client ?? _heyApiClient).get({ + url: '/knowledges/status', + ...options + }); + } + + /** + * List entity labels of one knowledge base + */ + public static getGraphLabels(options: Options) { + return (options.client ?? _heyApiClient).get({ + url: '/knowledges/{id}/graph/labels', + ...options + }); + } + + /** + * Get the graph of one knowledge base + */ + public static getGraph(options: Options) { + return (options.client ?? _heyApiClient).get({ + url: '/knowledges/{id}/graph', + ...options + }); + } + + /** + * Source counts by status and type, and total size, in one read + */ + public static getKnowledgeOverview(options: Options) { + return (options.client ?? _heyApiClient).get({ + url: '/knowledges/{id}/overview', + ...options + }); + } + + /** + * Delete knowledge + */ + public static deleteKnowledge(options: Options) { + return (options.client ?? _heyApiClient).delete({ + url: '/knowledges/{id}', + ...options + }); + } + + /** + * Get one knowledge + */ + public static getKnowledge(options: Options) { + return (options.client ?? _heyApiClient).get({ + url: '/knowledges/{id}', + ...options + }); + } + + /** + * Update knowledge + */ + public static updateKnowledge(options: Options) { + return (options.client ?? _heyApiClient).put({ + url: '/knowledges/{id}', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + }); + } + + /** + * Start indexing + */ + public static indexKnowledge(options: Options) { + return (options.client ?? _heyApiClient).post({ + url: '/knowledges/{id}/index', + ...options + }); + } + + /** + * Query knowledge (LLM-generated answer) + */ + public static queryKnowledge(options: Options) { + return (options.client ?? _heyApiClient).post({ + url: '/knowledges/{id}/query', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + }); + } + } export class KnowledgeSourcesService { - /** - * List sources (paginated) - */ - public static getKnowledgeSources( - options: Options, - ) { - return (options.client ?? _heyApiClient).get< - GetKnowledgeSourcesResponse, - unknown, - ThrowOnError - >({ - url: "/knowledges/{knowledgeId}/sources", - ...options, - }); - } - - /** - * Add source (file|url|text) - */ - public static addKnowledgeSource( - options: Options, - ) { - return (options.client ?? _heyApiClient).post< - unknown, - unknown, - ThrowOnError - >({ - ...formDataBodySerializer, - url: "/knowledges/{knowledgeId}/sources", - ...options, - headers: { - "Content-Type": null, - ...options?.headers, - }, - }); - } - - /** - * Background imports for this knowledge (running and recent) - * Progress of archive imports started through from-archive. Jobs are kept in memory for an hour after they finish. - */ - public static getKnowledgeSourceImports( - options: Options, - ) { - return (options.client ?? _heyApiClient).get< - GetKnowledgeSourceImportsResponse, - unknown, - ThrowOnError - >({ - url: "/knowledges/{knowledgeId}/sources/imports", - ...options, - }); - } - - /** - * Download selected sources as one zip - * Selection is either an explicit `ids` list or the same filter the list takes, so "select all" travels as a filter rather than every id. File and text sources become entries; url sources are recorded in the `_ranch-export.yaml` manifest, whose extension the archive importer skips so the zip round-trips through from-archive. - */ - public static exportKnowledgeSources( - options: Options, - ) { - return (options.client ?? _heyApiClient).get< - unknown, - unknown, - ThrowOnError - >({ - url: "/knowledges/{knowledgeId}/sources/export", - ...options, - }); - } - - /** - * Stream the stored bytes of a file or text source - * Streams the file straight from S3 (or the text body from the row). Not available for url sources - open the url itself. - */ - public static getKnowledgeSourceContent( - options: Options, - ) { - return (options.client ?? _heyApiClient).get< - unknown, - unknown, - ThrowOnError - >({ - url: "/knowledges/{knowledgeId}/sources/{sourceId}/content", - ...options, - }); - } - - /** - * Retry indexing a single source - * Requeues one source and re-ingests it without touching the rest of the batch. Progress is reported through the source own indexState. - */ - public static reindexKnowledgeSource( - options: Options, - ) { - return (options.client ?? _heyApiClient).post< - unknown, - unknown, - ThrowOnError - >({ - url: "/knowledges/{knowledgeId}/sources/{sourceId}/reindex", - ...options, - }); - } - - /** - * Re-run text extraction for a scanned PDF - * Probes the PDF for a text layer and, if it has none, sends it to OCR in the background. Progress is reported through the source own textState; press Index once it reads ready. - */ - public static extractKnowledgeSourceText< - ThrowOnError extends boolean = false, - >(options: Options) { - return (options.client ?? _heyApiClient).post< - unknown, - unknown, - ThrowOnError - >({ - url: "/knowledges/{knowledgeId}/sources/{sourceId}/extract", - ...options, - }); - } - - /** - * Add several file sources at once - * Accepts a multi-file selection (field "files") and creates one file-type source per upload. Runs inline and returns per-batch counts. Files whose name already exists on this knowledge are skipped; a single failed file does not abort the rest. Indexing into LightRAG happens through the normal reindex flow. - */ - public static addKnowledgeFileSources( - options: Options, - ) { - return (options.client ?? _heyApiClient).post< - AddKnowledgeFileSourcesResponse, - unknown, - ThrowOnError - >({ - url: "/knowledges/{knowledgeId}/sources/files", - ...options, - }); - } - - /** - * Add url sources from a sitemap - * Fetches a sitemap.xml (or sitemap-index), filters by optional URL prefix, then creates one url-type source per discovered page. Indexing into LightRAG happens through the normal reindex flow. - */ - public static addKnowledgeSourcesFromSitemap< - ThrowOnError extends boolean = false, - >(options: Options) { - return (options.client ?? _heyApiClient).post< - AddKnowledgeSourcesFromSitemapResponse, - unknown, - ThrowOnError - >({ - url: "/knowledges/{knowledgeId}/sources/from-sitemap", - ...options, - headers: { - "Content-Type": "application/json", - ...options?.headers, - }, - }); - } - - /** - * Bulk-import sources from a zip archive - * Accepts a .zip, extracts every ingestable file (pdf, docx, xlsx, txt, html, ...), and creates one file-type source per entry. Upload runs in the background and streams each entry to S3; the response returns immediately with the detected file count and a job id to poll via GET .../sources/imports. Indexing into LightRAG happens through the normal reindex flow. Max size: KNOWLEDGE_ARCHIVE_MAX_BYTES (default 4 GiB). - */ - public static addKnowledgeSourcesFromArchive< - ThrowOnError extends boolean = false, - >(options: Options) { - return (options.client ?? _heyApiClient).post< - AddKnowledgeSourcesFromArchiveResponse, - unknown, - ThrowOnError - >({ - url: "/knowledges/{knowledgeId}/sources/from-archive", - ...options, - }); - } - - /** - * Delete source - */ - public static deleteKnowledgeSource( - options: Options, - ) { - return (options.client ?? _heyApiClient).delete< - DeleteKnowledgeSourceResponse, - unknown, - ThrowOnError - >({ - url: "/knowledges/{knowledgeId}/sources/{sourceId}", - ...options, - }); - } + /** + * List sources (paginated) + */ + public static getKnowledgeSources(options: Options) { + return (options.client ?? _heyApiClient).get({ + url: '/knowledges/{knowledgeId}/sources', + ...options + }); + } + + /** + * Add source (file|url|text) + */ + public static addKnowledgeSource(options: Options) { + return (options.client ?? _heyApiClient).post({ + ...formDataBodySerializer, + url: '/knowledges/{knowledgeId}/sources', + ...options, + headers: { + 'Content-Type': null, + ...options?.headers + } + }); + } + + /** + * Background imports for this knowledge (running and recent) + * Progress of archive imports started through from-archive. Jobs are kept in memory for an hour after they finish. + */ + public static getKnowledgeSourceImports(options: Options) { + return (options.client ?? _heyApiClient).get({ + url: '/knowledges/{knowledgeId}/sources/imports', + ...options + }); + } + + /** + * Download selected sources as one zip + * Selection is either an explicit `ids` list or the same filter the list takes, so "select all" travels as a filter rather than every id. File and text sources become entries; url sources are recorded in the `_ranch-export.yaml` manifest, whose extension the archive importer skips so the zip round-trips through from-archive. + */ + public static exportKnowledgeSources(options: Options) { + return (options.client ?? _heyApiClient).get({ + url: '/knowledges/{knowledgeId}/sources/export', + ...options + }); + } + + /** + * Stream the stored bytes of a file or text source + * Streams the file straight from S3 (or the text body from the row). Not available for url sources - open the url itself. + */ + public static getKnowledgeSourceContent(options: Options) { + return (options.client ?? _heyApiClient).get({ + url: '/knowledges/{knowledgeId}/sources/{sourceId}/content', + ...options + }); + } + + /** + * Retry indexing a single source + * Requeues one source and re-ingests it without touching the rest of the batch. Progress is reported through the source own indexState. + */ + public static reindexKnowledgeSource(options: Options) { + return (options.client ?? _heyApiClient).post({ + url: '/knowledges/{knowledgeId}/sources/{sourceId}/reindex', + ...options + }); + } + + /** + * Re-run text extraction for a scanned PDF + * Probes the PDF for a text layer and, if it has none, sends it to OCR in the background. Progress is reported through the source own textState; press Index once it reads ready. + */ + public static extractKnowledgeSourceText(options: Options) { + return (options.client ?? _heyApiClient).post({ + url: '/knowledges/{knowledgeId}/sources/{sourceId}/extract', + ...options + }); + } + + /** + * Add several file sources at once + * Accepts a multi-file selection (field "files") and creates one file-type source per upload. Runs inline and returns per-batch counts. Files whose name already exists on this knowledge are skipped; a single failed file does not abort the rest. Indexing into LightRAG happens through the normal reindex flow. + */ + public static addKnowledgeFileSources(options: Options) { + return (options.client ?? _heyApiClient).post({ + url: '/knowledges/{knowledgeId}/sources/files', + ...options + }); + } + + /** + * Add url sources from a sitemap + * Fetches a sitemap.xml (or sitemap-index), filters by optional URL prefix, then creates one url-type source per discovered page. Indexing into LightRAG happens through the normal reindex flow. + */ + public static addKnowledgeSourcesFromSitemap(options: Options) { + return (options.client ?? _heyApiClient).post({ + url: '/knowledges/{knowledgeId}/sources/from-sitemap', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + }); + } + + /** + * Bulk-import sources from a zip archive + * Accepts a .zip, extracts every ingestable file (pdf, docx, xlsx, txt, html, ...), and creates one file-type source per entry. Upload runs in the background and streams each entry to S3; the response returns immediately with the detected file count and a job id to poll via GET .../sources/imports. Indexing into LightRAG happens through the normal reindex flow. Max size: KNOWLEDGE_ARCHIVE_MAX_BYTES (default 4 GiB). + */ + public static addKnowledgeSourcesFromArchive(options: Options) { + return (options.client ?? _heyApiClient).post({ + url: '/knowledges/{knowledgeId}/sources/from-archive', + ...options + }); + } + + /** + * Delete source + */ + public static deleteKnowledgeSource(options: Options) { + return (options.client ?? _heyApiClient).delete({ + url: '/knowledges/{knowledgeId}/sources/{sourceId}', + ...options + }); + } + } export class AgentsService { - /** - * List all agents. Public — landing/chat pages render without auth. Mutations and details still require login. - */ - public static agentControllerFindAll( - options?: Options, - ) { - return (options?.client ?? _heyApiClient).get< - AgentControllerFindAllResponse, - unknown, - ThrowOnError - >({ - url: "/agents", - ...options, - }); - } - - /** - * Create and deploy a new agent. Admin or Owner. - */ - public static agentControllerCreate( - options: Options, - ) { - return (options.client ?? _heyApiClient).post< - unknown, - unknown, - ThrowOnError - >({ - url: "/agents", - ...options, - headers: { - "Content-Type": "application/json", - ...options?.headers, - }, - }); - } - - /** - * List agents flagged as public. Used by the marketing landing page so private agents stay hidden from unauthenticated visitors. - */ - public static agentControllerFindPublic( - options?: Options, - ) { - return (options?.client ?? _heyApiClient).get< - AgentControllerFindPublicResponse, - unknown, - ThrowOnError - >({ - url: "/agents/public", - ...options, - }); - } - - /** - * Snapshot of all agents joined with live pod status. Public. - */ - public static agentControllerStatus( - options?: Options, - ) { - return (options?.client ?? _heyApiClient).get< - AgentControllerStatusResponse, - unknown, - ThrowOnError - >({ - url: "/agents/status", - ...options, - }); - } - - /** - * Live SSE stream of agent pod state changes. Public — EventSource cannot send Authorization headers. - */ - public static agentControllerStatusStream< - ThrowOnError extends boolean = false, - >(options?: Options) { - return (options?.client ?? _heyApiClient).get< - unknown, - unknown, - ThrowOnError - >({ - url: "/agents/status/stream", - ...options, - }); - } - - /** - * How many more agents fit on the cluster. Free schedulable CPU/memory on node-role=agents nodes divided by the fixed agent request floor (100m / 512Mi), minus agents still deploying without a pod. Cached ~15s; null when the Kubernetes API is unreachable. Admin or Owner — the only roles that can act on the number, and the response reveals node topology. - */ - public static getClusterCapacity( - options?: Options, - ) { - return (options?.client ?? _heyApiClient).get< - GetClusterCapacityResponse, - unknown, - ThrowOnError - >({ - url: "/agents/capacity", - ...options, - }); - } - - /** - * Stop and delete an agent. Pass `?wipeS3=true` to also drop every object under `agents/{id}/` — opt-in so accidental deletes don’t nuke files. Admin or Owner. - */ - public static agentControllerRemove( - options: Options, - ) { - return (options.client ?? _heyApiClient).delete< - unknown, - unknown, - ThrowOnError - >({ - url: "/agents/{id}", - ...options, - }); - } - - /** - * Get agent by ID. Public — chat needs agent metadata (name, status) to render. - */ - public static agentControllerFindById( - options: Options, - ) { - return (options.client ?? _heyApiClient).get< - AgentControllerFindByIdResponse, - unknown, - ThrowOnError - >({ - url: "/agents/{id}", - ...options, - }); - } - - /** - * Update agent configuration. Admin or Owner. - */ - public static agentControllerUpdate( - options: Options, - ) { - return (options.client ?? _heyApiClient).put< - unknown, - unknown, - ThrowOnError - >({ - url: "/agents/{id}", - ...options, - headers: { - "Content-Type": "application/json", - ...options?.headers, - }, - }); - } - - /** - * Live resource usage for the agent: pod CPU/memory (from metrics-server) and free disk space on the K8s node hosting the pod (from kubelet stats/summary). Returns null while no pod exists yet. - */ - public static getAgentMetrics( - options: Options, - ) { - return (options.client ?? _heyApiClient).get< - GetAgentMetricsResponse, - unknown, - ThrowOnError - >({ - url: "/agents/{id}/metrics", - ...options, - }); - } - - /** - * Env vars the agent pod receives on its next deploy. Built from the same code as the real pod manifest — the source of truth, never a hand-maintained copy. The UI masks secret values for display. - */ - public static getAgentEnv( - options: Options, - ) { - return (options.client ?? _heyApiClient).get< - GetAgentEnvResponse, - unknown, - ThrowOnError - >({ - url: "/agents/{id}/env", - ...options, - }); - } - - /** - * List of MCP servers this agent should connect to at runtime. Resolves the agent's template and returns its enabled MCP attachments. Called by the runtime on boot to populate its tool registry. Tokens with role=Agent (issued to runtimes) can only read their OWN agent — `sub` must match `agent:`. - */ - public static getAgentMcps( - options: Options, - ) { - return (options.client ?? _heyApiClient).get< - GetAgentMcpsResponse, - unknown, - ThrowOnError - >({ - url: "/agents/{id}/mcps", - ...options, - }); - } - - /** - * Get the agent currently flagged as Ranch admin (or null). - */ - public static agentControllerFindAdmin( - options?: Options, - ) { - return (options?.client ?? _heyApiClient).get< - unknown, - unknown, - ThrowOnError - >({ - url: "/agents/admin/current", - ...options, - }); - } - - /** - * Demote this agent from Ranch admin. Redeploys without RANCH_ADMIN. - */ - public static agentControllerDemoteAdmin< - ThrowOnError extends boolean = false, - >(options: Options) { - return (options.client ?? _heyApiClient).delete< - unknown, - unknown, - ThrowOnError - >({ - url: "/agents/{id}/promote-admin", - ...options, - }); - } - - /** - * Mark this agent as the Ranch admin. Clears the flag from any other agent (single-admin invariant) and redeploys with RANCH_ADMIN=true + a service token. Any previous admin is redeployed without the flag. - */ - public static agentControllerPromoteAdmin< - ThrowOnError extends boolean = false, - >(options: Options) { - return (options.client ?? _heyApiClient).post< - unknown, - unknown, - ThrowOnError - >({ - url: "/agents/{id}/promote-admin", - ...options, - }); - } - - /** - * Restart an agent. Admin or Owner. - */ - public static agentControllerRestart( - options: Options, - ) { - return (options.client ?? _heyApiClient).post< - unknown, - unknown, - ThrowOnError - >({ - url: "/agents/{id}/restart", - ...options, - }); - } - - /** - * Stop an agent without deleting it: cancels its workflow and deletes its pod to free cluster CPU/memory, then marks it `stopped`. Use this to free a slot so another agent can start. Bring it back with POST :id/start. Admin or Owner. - */ - public static agentControllerStop( - options: Options, - ) { - return (options.client ?? _heyApiClient).post< - unknown, - unknown, - ThrowOnError - >({ - url: "/agents/{id}/stop", - ...options, - }); - } - - /** - * Start a stopped agent: deploys a fresh pod and reattaches the runtime. Inverse of POST :id/stop. Admin or Owner. - */ - public static agentControllerStart( - options: Options, - ) { - return (options.client ?? _heyApiClient).post< - unknown, - unknown, - ThrowOnError - >({ - url: "/agents/{id}/start", - ...options, - }); - } - - /** - * Restart every agent that uses this template. Pulls latest template-owned files into each agent and redeploys, preserving runtime state. Concurrency capped at 5 to avoid overwhelming the cluster. Admin or Owner. - */ - public static restartByTemplate( - options: Options, - ) { - return (options.client ?? _heyApiClient).post< - unknown, - unknown, - ThrowOnError - >({ - url: "/agents/restart-by-template/{templateId}", - ...options, - }); - } - - /** - * List the agent's configured channels with live status. Config comes from agents/{id}/data/channels/.json in S3 — the runtime's per-channel layout, mutated by its channel_* tools (falls back read-only to the pre-split data/channels.json for agents configured before the convergence). Status (connected/statusReason) comes from data/channels/status.json, written by the runtime; null = unknown. Returns [] when nothing is configured. Always fresh, no caching. - */ - public static getAgentChannels( - options: Options, - ) { - return (options.client ?? _heyApiClient).get< - GetAgentChannelsResponse, - unknown, - ThrowOnError - >({ - url: "/agents/{id}/channels", - ...options, - }); - } - - /** - * Replace the agent's channels. Writes agents/{id}/data/channels/.json (read-modify-write — the runtime-owned group registry in the same file is preserved). Body is the exhaustive list — anything omitted is tombstoned (removed: true), never deleted, so a restart can't resurrect it from stale pod env vars. Pass [] to clear. Panel-side changes reach a running agent on its next restart (env re-injection at pod submit); agent-side (chat tool) changes apply immediately. - */ - public static setAgentChannels( - options: Options, - ) { - return (options.client ?? _heyApiClient).put< - SetAgentChannelsResponse, - unknown, - ThrowOnError - >({ - url: "/agents/{id}/channels", - ...options, - headers: { - "Content-Type": "application/json", - ...options?.headers, - }, - }); - } + /** + * List all agents. Public — landing/chat pages render without auth. Mutations and details still require login. + */ + public static agentControllerFindAll(options?: Options) { + return (options?.client ?? _heyApiClient).get({ + url: '/agents', + ...options + }); + } + + /** + * Create and deploy a new agent. Admin or Owner. + */ + public static agentControllerCreate(options: Options) { + return (options.client ?? _heyApiClient).post({ + url: '/agents', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + }); + } + + /** + * List agents flagged as public. Used by the marketing landing page so private agents stay hidden from unauthenticated visitors. + */ + public static agentControllerFindPublic(options?: Options) { + return (options?.client ?? _heyApiClient).get({ + url: '/agents/public', + ...options + }); + } + + /** + * Snapshot of all agents joined with live pod status. Public. + */ + public static agentControllerStatus(options?: Options) { + return (options?.client ?? _heyApiClient).get({ + url: '/agents/status', + ...options + }); + } + + /** + * Live SSE stream of agent pod state changes. Public — EventSource cannot send Authorization headers. + */ + public static agentControllerStatusStream(options?: Options) { + return (options?.client ?? _heyApiClient).get({ + url: '/agents/status/stream', + ...options + }); + } + + /** + * How many more agents fit on the cluster. Free schedulable CPU/memory on node-role=agents nodes divided by the fixed agent request floor (100m / 512Mi), minus agents still deploying without a pod. Cached ~15s; null when the Kubernetes API is unreachable. Admin or Owner — the only roles that can act on the number, and the response reveals node topology. + */ + public static getClusterCapacity(options?: Options) { + return (options?.client ?? _heyApiClient).get({ + url: '/agents/capacity', + ...options + }); + } + + /** + * Stop and delete an agent. Pass `?wipeS3=true` to also drop every object under `agents/{id}/` — opt-in so accidental deletes don’t nuke files. Admin or Owner. + */ + public static agentControllerRemove(options: Options) { + return (options.client ?? _heyApiClient).delete({ + url: '/agents/{id}', + ...options + }); + } + + /** + * Get agent by ID. Public — chat needs agent metadata (name, status) to render. + */ + public static agentControllerFindById(options: Options) { + return (options.client ?? _heyApiClient).get({ + url: '/agents/{id}', + ...options + }); + } + + /** + * Update agent configuration. Admin or Owner. + */ + public static agentControllerUpdate(options: Options) { + return (options.client ?? _heyApiClient).put({ + url: '/agents/{id}', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + }); + } + + /** + * Live resource usage for the agent: pod CPU/memory (from metrics-server) and free disk space on the K8s node hosting the pod (from kubelet stats/summary). Returns null while no pod exists yet. + */ + public static getAgentMetrics(options: Options) { + return (options.client ?? _heyApiClient).get({ + url: '/agents/{id}/metrics', + ...options + }); + } + + /** + * Env vars the agent pod receives on its next deploy. Built from the same code as the real pod manifest — the source of truth, never a hand-maintained copy. The UI masks secret values for display. + */ + public static getAgentEnv(options: Options) { + return (options.client ?? _heyApiClient).get({ + url: '/agents/{id}/env', + ...options + }); + } + + /** + * List of MCP servers this agent should connect to at runtime. Resolves the agent's template and returns its enabled MCP attachments. Called by the runtime on boot to populate its tool registry. Tokens with role=Agent (issued to runtimes) can only read their OWN agent — `sub` must match `agent:`. + */ + public static getAgentMcps(options: Options) { + return (options.client ?? _heyApiClient).get({ + url: '/agents/{id}/mcps', + ...options + }); + } + + /** + * Get the agent currently flagged as Ranch admin (or null). + */ + public static agentControllerFindAdmin(options?: Options) { + return (options?.client ?? _heyApiClient).get({ + url: '/agents/admin/current', + ...options + }); + } + + /** + * Demote this agent from Ranch admin. Redeploys without RANCH_ADMIN. + */ + public static agentControllerDemoteAdmin(options: Options) { + return (options.client ?? _heyApiClient).delete({ + url: '/agents/{id}/promote-admin', + ...options + }); + } + + /** + * Mark this agent as the Ranch admin. Clears the flag from any other agent (single-admin invariant) and redeploys with RANCH_ADMIN=true + a service token. Any previous admin is redeployed without the flag. + */ + public static agentControllerPromoteAdmin(options: Options) { + return (options.client ?? _heyApiClient).post({ + url: '/agents/{id}/promote-admin', + ...options + }); + } + + /** + * Restart an agent. Admin or Owner. + */ + public static agentControllerRestart(options: Options) { + return (options.client ?? _heyApiClient).post({ + url: '/agents/{id}/restart', + ...options + }); + } + + /** + * Stop an agent without deleting it: cancels its workflow and deletes its pod to free cluster CPU/memory, then marks it `stopped`. Use this to free a slot so another agent can start. Bring it back with POST :id/start. Admin or Owner. + */ + public static agentControllerStop(options: Options) { + return (options.client ?? _heyApiClient).post({ + url: '/agents/{id}/stop', + ...options + }); + } + + /** + * Start a stopped agent: deploys a fresh pod and reattaches the runtime. Inverse of POST :id/stop. Admin or Owner. + */ + public static agentControllerStart(options: Options) { + return (options.client ?? _heyApiClient).post({ + url: '/agents/{id}/start', + ...options + }); + } + + /** + * Restart every agent that uses this template. Pulls latest template-owned files into each agent and redeploys, preserving runtime state. Concurrency capped at 5 to avoid overwhelming the cluster. Admin or Owner. + */ + public static restartByTemplate(options: Options) { + return (options.client ?? _heyApiClient).post({ + url: '/agents/restart-by-template/{templateId}', + ...options + }); + } + + /** + * List the agent's configured channels with live status. Config comes from agents/{id}/data/channels/.json in S3 — the runtime's per-channel layout, mutated by its channel_* tools (falls back read-only to the pre-split data/channels.json for agents configured before the convergence). Status (connected/statusReason) comes from data/channels/status.json, written by the runtime; null = unknown. Returns [] when nothing is configured. Always fresh, no caching. + */ + public static getAgentChannels(options: Options) { + return (options.client ?? _heyApiClient).get({ + url: '/agents/{id}/channels', + ...options + }); + } + + /** + * Replace the agent's channels. Writes agents/{id}/data/channels/.json (read-modify-write — the runtime-owned group registry in the same file is preserved). Body is the exhaustive list — anything omitted is tombstoned (removed: true), never deleted, so a restart can't resurrect it from stale pod env vars. Pass [] to clear. Panel-side changes reach a running agent on its next restart (env re-injection at pod submit); agent-side (chat tool) changes apply immediately. + */ + public static setAgentChannels(options: Options) { + return (options.client ?? _heyApiClient).put({ + url: '/agents/{id}/channels', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + }); + } + } export class FilesService { - /** - * List files for an agent - */ - public static fileControllerList( - options: Options, - ) { - return (options.client ?? _heyApiClient).get< - unknown, - unknown, - ThrowOnError - >({ - url: "/agents/{agentId}/files", - ...options, - }); - } - - /** - * Delete a file, or a whole folder (e.g. a skill dir) when `recursive=true`. Template-managed skills are recreated on the next restart unless detached from the template first. - */ - public static fileControllerDelete( - options: Options, - ) { - return (options.client ?? _heyApiClient).delete< - FileControllerDeleteResponse, - unknown, - ThrowOnError - >({ - url: "/agents/{agentId}/files/content", - ...options, - }); - } - - /** - * Read a chunk of a file. Omit `offset`/`limit` to read the first 256 KB. Use the returned `nextOffset` to continue. - */ - public static fileControllerRead( - options: Options, - ) { - return (options.client ?? _heyApiClient).get< - FileControllerReadResponse, - unknown, - ThrowOnError - >({ - url: "/agents/{agentId}/files/content", - ...options, - }); - } - - /** - * Save a file (.md / .json only) - */ - public static fileControllerSave( - options: Options, - ) { - return (options.client ?? _heyApiClient).put< - unknown, - unknown, - ThrowOnError - >({ - url: "/agents/{agentId}/files/content", - ...options, - headers: { - "Content-Type": "application/json", - ...options?.headers, - }, - }); - } - - /** - * Ask the agent runtime to push its local files to S3. Answers 409 with the at-risk file list (and does NOT sync) when S3 holds edits newer than the pod’s last pull/push, unless `confirm` is set. - */ - public static fileControllerSync( - options: Options, - ) { - return (options.client ?? _heyApiClient).post< - unknown, - FileControllerSyncError, - ThrowOnError - >({ - url: "/agents/{agentId}/files/sync", - ...options, - headers: { - "Content-Type": "application/json", - ...options?.headers, - }, - }); - } - - /** - * Download a ZIP archive of the agent’s entire S3 prefix (files, skills, runtime state). Used as a safety net before destructive actions. - */ - public static exportAgentFiles( - options: Options, - ) { - return (options.client ?? _heyApiClient).get< - unknown, - unknown, - ThrowOnError - >({ - url: "/agents/{agentId}/files/export", - ...options, - }); - } + /** + * List files for an agent + */ + public static fileControllerList(options: Options) { + return (options.client ?? _heyApiClient).get({ + url: '/agents/{agentId}/files', + ...options + }); + } + + /** + * Delete a file, or a whole folder (e.g. a skill dir) when `recursive=true`. Template-managed skills are recreated on the next restart unless detached from the template first. + */ + public static fileControllerDelete(options: Options) { + return (options.client ?? _heyApiClient).delete({ + url: '/agents/{agentId}/files/content', + ...options + }); + } + + /** + * Read a chunk of a file. Omit `offset`/`limit` to read the first 256 KB. Use the returned `nextOffset` to continue. + */ + public static fileControllerRead(options: Options) { + return (options.client ?? _heyApiClient).get({ + url: '/agents/{agentId}/files/content', + ...options + }); + } + + /** + * Save a file (.md / .json only) + */ + public static fileControllerSave(options: Options) { + return (options.client ?? _heyApiClient).put({ + url: '/agents/{agentId}/files/content', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + }); + } + + /** + * Ask the agent runtime to push its local files to S3. Answers 409 with the at-risk file list (and does NOT sync) when S3 holds edits newer than the pod’s last pull/push, unless `confirm` is set. + */ + public static fileControllerSync(options: Options) { + return (options.client ?? _heyApiClient).post({ + url: '/agents/{agentId}/files/sync', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + }); + } + + /** + * Download a ZIP archive of the agent’s entire S3 prefix (files, skills, runtime state). Used as a safety net before destructive actions. + */ + public static exportAgentFiles(options: Options) { + return (options.client ?? _heyApiClient).get({ + url: '/agents/{agentId}/files/export', + ...options + }); + } + } export class BridleService { - /** - * Send a message to a agent (HTTP fallback — fire & forget). Accepts a bearer token or the share-link headers (`X-Share-Token` + `X-Share-Visitor`); with neither, the caller is the anonymous embed visitor and gets a throwaway channel. - */ - public static sendBridleMessage( - options: Options, - ) { - return (options.client ?? _heyApiClient).post< - unknown, - unknown, - ThrowOnError - >({ - url: "/api/agent/{agentId}/message", - ...options, - headers: { - "Content-Type": "application/json", - ...options?.headers, - }, - }); - } - - /** - * Send a message and wait for the agent response (synchronous). Accepts a bearer token or the share-link headers (`X-Share-Token` + `X-Share-Visitor`); with neither, the caller is the anonymous embed visitor and gets a throwaway channel. - */ - public static sendBridleMessageSync( - options: Options, - ) { - return (options.client ?? _heyApiClient).post< - unknown, - unknown, - ThrowOnError - >({ - url: "/api/agent/{agentId}/message/sync", - ...options, - headers: { - "Content-Type": "application/json", - ...options?.headers, - }, - }); - } - - /** - * Upload a chat attachment. Returns the id the send call references via `attachmentIds`. Requires a bearer token or the share-link headers (`X-Share-Token` + `X-Share-Visitor`). - */ - public static uploadBridleAttachment( - options: Options, - ) { - return (options.client ?? _heyApiClient).post< - UploadBridleAttachmentResponse, - unknown, - ThrowOnError - >({ - ...formDataBodySerializer, - url: "/api/agent/{agentId}/attachment", - ...options, - headers: { - "Content-Type": null, - ...options?.headers, - }, - }); - } - - /** - * Download a chat attachment. Streams the stored bytes with their original content type. Requires a bearer token or the share-link headers (`X-Share-Token` + `X-Share-Visitor`); a share visitor may only read attachments they uploaded themselves. - */ - public static getBridleAttachment( - options: Options, - ) { - return (options.client ?? _heyApiClient).get< - unknown, - unknown, - ThrowOnError - >({ - url: "/api/agent/{agentId}/attachment/{attachmentId}", - ...options, - }); - } - - /** - * Check overall hub status - */ - public static bridleHealth( - options?: Options, - ) { - return (options?.client ?? _heyApiClient).get< - BridleHealthResponse, - unknown, - ThrowOnError - >({ - url: "/api/agent/health", - ...options, - }); - } - - /** - * Check agent connection status - */ - public static bridleAgentHealth( - options: Options, - ) { - return (options.client ?? _heyApiClient).get< - BridleAgentHealthResponse, - unknown, - ThrowOnError - >({ - url: "/api/agent/{agentId}/health", - ...options, - }); - } - - /** - * List all connected agents - */ - public static listAgents( - options?: Options, - ) { - return (options?.client ?? _heyApiClient).get< - unknown, - unknown, - ThrowOnError - >({ - url: "/api/agent/list", - ...options, - }); - } - - /** - * Delete the persisted chat transcript for an agent/channel. Used to start a fresh chat — UI clears, refresh shows empty. Note: the agent runtime's in-memory session may still hold context until the next pod restart. A `share-` channel is restricted: only a bearer token or that visitor's own share headers are accepted (403 otherwise). - */ - public static resetBridleTranscript( - options: Options, - ) { - return (options.client ?? _heyApiClient).delete< - ResetBridleTranscriptResponse, - unknown, - ThrowOnError - >({ - url: "/api/agent/{agentId}/transcript", - ...options, - }); - } - - /** - * Replay the persisted chat transcript for an agent (read from the agent runtime's data/sessions/bridle:.jsonl). Paginated tail-first: omit `cursor` for the latest `limit` messages; pass the returned `nextCursor` to fetch older pages. Live updates still arrive via /ws/client. A `share-` channel is restricted: only a bearer token or that visitor's own share headers are accepted (403 otherwise). - */ - public static getBridleTranscript( - options: Options, - ) { - return (options.client ?? _heyApiClient).get< - GetBridleTranscriptResponse, - unknown, - ThrowOnError - >({ - url: "/api/agent/{agentId}/transcript", - ...options, - }); - } - - /** - * Archive the persisted chat transcript for an agent/channel — the live JSONL is moved to a timestamped sibling (`bridle:..archived.jsonl`) and the live slot starts empty. Used by the embed's "New chat" action when the visitor wants a clean slate but we still want the prior conversation for admin/audit. No-op (returns `{}`) when there's nothing to archive. A `share-` channel is restricted: only a bearer token or that visitor's own share headers are accepted (403 otherwise). - */ - public static archiveBridleTranscript( - options: Options, - ) { - return (options.client ?? _heyApiClient).post< - unknown, - unknown, - ThrowOnError - >({ - url: "/api/agent/{agentId}/transcript/archive", - ...options, - }); - } + /** + * Send a message to a agent (HTTP fallback — fire & forget). Accepts a bearer token or the share-link headers (`X-Share-Token` + `X-Share-Visitor`); with neither, the caller is the anonymous embed visitor and gets a throwaway channel. + */ + public static sendBridleMessage(options: Options) { + return (options.client ?? _heyApiClient).post({ + url: '/api/agent/{agentId}/message', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + }); + } + + /** + * Send a message and wait for the agent response (synchronous). Accepts a bearer token or the share-link headers (`X-Share-Token` + `X-Share-Visitor`); with neither, the caller is the anonymous embed visitor and gets a throwaway channel. + */ + public static sendBridleMessageSync(options: Options) { + return (options.client ?? _heyApiClient).post({ + url: '/api/agent/{agentId}/message/sync', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + }); + } + + /** + * Upload a chat attachment. Returns the id the send call references via `attachmentIds`. Requires a bearer token or the share-link headers (`X-Share-Token` + `X-Share-Visitor`). + */ + public static uploadBridleAttachment(options: Options) { + return (options.client ?? _heyApiClient).post({ + ...formDataBodySerializer, + url: '/api/agent/{agentId}/attachment', + ...options, + headers: { + 'Content-Type': null, + ...options?.headers + } + }); + } + + /** + * Download a chat attachment. Streams the stored bytes with their original content type. Requires a bearer token or the share-link headers (`X-Share-Token` + `X-Share-Visitor`); a share visitor may only read attachments they uploaded themselves. + */ + public static getBridleAttachment(options: Options) { + return (options.client ?? _heyApiClient).get({ + url: '/api/agent/{agentId}/attachment/{attachmentId}', + ...options + }); + } + + /** + * Check overall hub status + */ + public static bridleHealth(options?: Options) { + return (options?.client ?? _heyApiClient).get({ + url: '/api/agent/health', + ...options + }); + } + + /** + * Check agent connection status + */ + public static bridleAgentHealth(options: Options) { + return (options.client ?? _heyApiClient).get({ + url: '/api/agent/{agentId}/health', + ...options + }); + } + + /** + * List all connected agents + */ + public static listAgents(options?: Options) { + return (options?.client ?? _heyApiClient).get({ + url: '/api/agent/list', + ...options + }); + } + + /** + * Delete the persisted chat transcript for an agent/channel. Used to start a fresh chat — UI clears, refresh shows empty. Note: the agent runtime's in-memory session may still hold context until the next pod restart. A `share-` channel is restricted: only a bearer token or that visitor's own share headers are accepted (403 otherwise). + */ + public static resetBridleTranscript(options: Options) { + return (options.client ?? _heyApiClient).delete({ + url: '/api/agent/{agentId}/transcript', + ...options + }); + } + + /** + * Replay the persisted chat transcript for an agent (read from the agent runtime's data/sessions/bridle:.jsonl). Paginated tail-first: omit `cursor` for the latest `limit` messages; pass the returned `nextCursor` to fetch older pages. Live updates still arrive via /ws/client. A `share-` channel is restricted: only a bearer token or that visitor's own share headers are accepted (403 otherwise). + */ + public static getBridleTranscript(options: Options) { + return (options.client ?? _heyApiClient).get({ + url: '/api/agent/{agentId}/transcript', + ...options + }); + } + + /** + * Archive the persisted chat transcript for an agent/channel — the live JSONL is moved to a timestamped sibling (`bridle:..archived.jsonl`) and the live slot starts empty. Used by the embed's "New chat" action when the visitor wants a clean slate but we still want the prior conversation for admin/audit. No-op (returns `{}`) when there's nothing to archive. A `share-` channel is restricted: only a bearer token or that visitor's own share headers are accepted (403 otherwise). + */ + public static archiveBridleTranscript(options: Options) { + return (options.client ?? _heyApiClient).post({ + url: '/api/agent/{agentId}/transcript/archive', + ...options + }); + } + } export class ShareLinksService { - /** - * Stop sharing the agent. Returns the link with active: false and token: null; visitors are cut off on their very next request, with no cached decision anywhere. Idempotent — revoking twice, or an agent that was never shared, is still 200. 404 when the agent does not exist. - */ - public static revokeAgentShareLink( - options: Options, - ) { - return (options.client ?? _heyApiClient).delete< - RevokeAgentShareLinkResponse, - unknown, - ThrowOnError - >({ - url: "/agents/{agentId}/share-link", - ...options, - }); - } - - /** - * Current state of the agent's share link. Returns active: false with every field null when the agent was never shared or the link has been revoked — the token is only ever exposed while the link is active. 404 when the agent does not exist. - */ - public static getAgentShareLink( - options: Options, - ) { - return (options.client ?? _heyApiClient).get< - GetAgentShareLinkResponse, - unknown, - ThrowOnError - >({ - url: "/agents/{agentId}/share-link", - ...options, - }); - } - - /** - * Share the agent. Idempotent: an already active link is returned unchanged (same token), so pressing Share twice never invalidates a link that is already in circulation. A fresh token is minted when the agent has never been shared or the previous link was revoked. 404 when the agent does not exist. 200, not 201: the usual outcome is an existing link handed back, and the operation is idempotent. - */ - public static createAgentShareLink( - options: Options, - ) { - return (options.client ?? _heyApiClient).post< - CreateAgentShareLinkResponse, - unknown, - ThrowOnError - >({ - url: "/agents/{agentId}/share-link", - ...options, - }); - } - - /** - * Replace the token with one that has never been valid before. The previous token stops working in the same write, so anyone holding the old link loses access immediately. Revives a revoked link. 404 when the agent does not exist. - */ - public static regenerateAgentShareLink( - options: Options, - ) { - return (options.client ?? _heyApiClient).post< - RegenerateAgentShareLinkResponse, - unknown, - ThrowOnError - >({ - url: "/agents/{agentId}/share-link/regenerate", - ...options, - }); - } + /** + * Stop sharing the agent. Returns the link with active: false and token: null; visitors are cut off on their very next request, with no cached decision anywhere. Idempotent — revoking twice, or an agent that was never shared, is still 200. 404 when the agent does not exist. + */ + public static revokeAgentShareLink(options: Options) { + return (options.client ?? _heyApiClient).delete({ + url: '/agents/{agentId}/share-link', + ...options + }); + } + + /** + * Current state of the agent's share link. Returns active: false with every field null when the agent was never shared or the link has been revoked — the token is only ever exposed while the link is active. 404 when the agent does not exist. + */ + public static getAgentShareLink(options: Options) { + return (options.client ?? _heyApiClient).get({ + url: '/agents/{agentId}/share-link', + ...options + }); + } + + /** + * Share the agent. Idempotent: an already active link is returned unchanged (same token), so pressing Share twice never invalidates a link that is already in circulation. A fresh token is minted when the agent has never been shared or the previous link was revoked. 404 when the agent does not exist. 200, not 201: the usual outcome is an existing link handed back, and the operation is idempotent. + */ + public static createAgentShareLink(options: Options) { + return (options.client ?? _heyApiClient).post({ + url: '/agents/{agentId}/share-link', + ...options + }); + } + + /** + * Replace the token with one that has never been valid before. The previous token stops working in the same write, so anyone holding the old link loses access immediately. Revives a revoked link. 404 when the agent does not exist. + */ + public static regenerateAgentShareLink(options: Options) { + return (options.client ?? _heyApiClient).post({ + url: '/agents/{agentId}/share-link/regenerate', + ...options + }); + } + } export class ShareService { - /** - * Exchange a share token for the little the visitor may know about the agent behind it: id, name and status. Nothing else about the agent is exposed. An unknown token and a revoked token answer with the exact same 404 body ({ code: 'SHARE_LINK_NOT_FOUND' }), so a link that was turned off is indistinguishable from one that never existed. A malformed token is rejected as 400 before any lookup happens. - */ - public static resolveShareLink( - options: Options, - ) { - return (options.client ?? _heyApiClient).post< - ResolveShareLinkResponse, - unknown, - ThrowOnError - >({ - url: "/share/resolve", - ...options, - headers: { - "Content-Type": "application/json", - ...options?.headers, - }, - }); - } + /** + * Exchange a share token for the little the visitor may know about the agent behind it: id, name and status. Nothing else about the agent is exposed. An unknown token and a revoked token answer with the exact same 404 body ({ code: 'SHARE_LINK_NOT_FOUND' }), so a link that was turned off is indistinguishable from one that never existed. A malformed token is rejected as 400 before any lookup happens. + */ + public static resolveShareLink(options: Options) { + return (options.client ?? _heyApiClient).post({ + url: '/share/resolve', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + }); + } + } export class SkillsService { - /** - * List all skills - */ - public static skillControllerFindAll( - options?: Options, - ) { - return (options?.client ?? _heyApiClient).get< - unknown, - unknown, - ThrowOnError - >({ - url: "/skills", - ...options, - }); - } - - /** - * Create a new skill - */ - public static skillControllerCreate( - options: Options, - ) { - return (options.client ?? _heyApiClient).post< - unknown, - unknown, - ThrowOnError - >({ - url: "/skills", - ...options, - headers: { - "Content-Type": "application/json", - ...options?.headers, - }, - }); - } - - /** - * Curated GitHub repos searched for skills - */ - public static skillControllerListSources< - ThrowOnError extends boolean = false, - >(options?: Options) { - return (options?.client ?? _heyApiClient).get< - unknown, - unknown, - ThrowOnError - >({ - url: "/skills/sources", - ...options, - }); - } - - /** - * Search skills across curated GitHub repos - */ - public static skillControllerSearch( - options: Options, - ) { - return (options.client ?? _heyApiClient).get< - unknown, - unknown, - ThrowOnError - >({ - url: "/skills/search", - ...options, - }); - } - - /** - * Import a skill from any GitHub URL (folder or SKILL.md) - */ - public static skillControllerImportFromUrl< - ThrowOnError extends boolean = false, - >(options: Options) { - return (options.client ?? _heyApiClient).post< - unknown, - unknown, - ThrowOnError - >({ - url: "/skills/import-url", - ...options, - headers: { - "Content-Type": "application/json", - ...options?.headers, - }, - }); - } - - /** - * Import a skill from GitHub into the local DB - */ - public static skillControllerImportFromGithub< - ThrowOnError extends boolean = false, - >(options: Options) { - return (options.client ?? _heyApiClient).post< - unknown, - unknown, - ThrowOnError - >({ - url: "/skills/import", - ...options, - headers: { - "Content-Type": "application/json", - ...options?.headers, - }, - }); - } - - /** - * Delete a skill - */ - public static skillControllerRemove( - options: Options, - ) { - return (options.client ?? _heyApiClient).delete< - unknown, - unknown, - ThrowOnError - >({ - url: "/skills/{id}", - ...options, - }); - } - - /** - * Get skill by ID - */ - public static skillControllerFindById( - options: Options, - ) { - return (options.client ?? _heyApiClient).get< - unknown, - unknown, - ThrowOnError - >({ - url: "/skills/{id}", - ...options, - }); - } - - /** - * Update a skill - */ - public static skillControllerUpdate( - options: Options, - ) { - return (options.client ?? _heyApiClient).put< - unknown, - unknown, - ThrowOnError - >({ - url: "/skills/{id}", - ...options, - headers: { - "Content-Type": "application/json", - ...options?.headers, - }, - }); - } - - /** - * List agents that use this skill via their template. Drives the post-edit "Redeploy N agents" flow — skills are baked into agent pods at deploy time, so a skill update has no effect until those agents restart. - */ - public static findDependentAgents( - options: Options, - ) { - return (options.client ?? _heyApiClient).get< - unknown, - unknown, - ThrowOnError - >({ - url: "/skills/{id}/agents", - ...options, - }); - } + /** + * List all skills + */ + public static skillControllerFindAll(options?: Options) { + return (options?.client ?? _heyApiClient).get({ + url: '/skills', + ...options + }); + } + + /** + * Create a new skill + */ + public static skillControllerCreate(options: Options) { + return (options.client ?? _heyApiClient).post({ + url: '/skills', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + }); + } + + /** + * Curated GitHub repos searched for skills + */ + public static skillControllerListSources(options?: Options) { + return (options?.client ?? _heyApiClient).get({ + url: '/skills/sources', + ...options + }); + } + + /** + * Search skills across curated GitHub repos + */ + public static skillControllerSearch(options: Options) { + return (options.client ?? _heyApiClient).get({ + url: '/skills/search', + ...options + }); + } + + /** + * Import a skill from any GitHub URL (folder or SKILL.md) + */ + public static skillControllerImportFromUrl(options: Options) { + return (options.client ?? _heyApiClient).post({ + url: '/skills/import-url', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + }); + } + + /** + * Import a skill from GitHub into the local DB + */ + public static skillControllerImportFromGithub(options: Options) { + return (options.client ?? _heyApiClient).post({ + url: '/skills/import', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + }); + } + + /** + * Delete a skill + */ + public static skillControllerRemove(options: Options) { + return (options.client ?? _heyApiClient).delete({ + url: '/skills/{id}', + ...options + }); + } + + /** + * Get skill by ID + */ + public static skillControllerFindById(options: Options) { + return (options.client ?? _heyApiClient).get({ + url: '/skills/{id}', + ...options + }); + } + + /** + * Update a skill + */ + public static skillControllerUpdate(options: Options) { + return (options.client ?? _heyApiClient).put({ + url: '/skills/{id}', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + }); + } + + /** + * List agents that use this skill via their template. Drives the post-edit "Redeploy N agents" flow — skills are baked into agent pods at deploy time, so a skill update has no effect until those agents restart. + */ + public static findDependentAgents(options: Options) { + return (options.client ?? _heyApiClient).get({ + url: '/skills/{id}/agents', + ...options + }); + } + } export class ChatsService { - /** - * List chat sessions (index). Filter by agent, channel, search; paginated. - */ - public static getChats( - options?: Options, - ) { - return (options?.client ?? _heyApiClient).get< - GetChatsResponse, - unknown, - ThrowOnError - >({ - url: "/chats", - ...options, - }); - } - - /** - * Get one chat session (index metadata). - */ - public static getChat( - options: Options, - ) { - return (options.client ?? _heyApiClient).get< - GetChatResponse, - unknown, - ThrowOnError - >({ - url: "/chats/{id}", - ...options, - }); - } - - /** - * Replay a chat session's transcript from S3, tail-first. `summary` markers are shown inline (compaction folds old turns into them); synthetic loop-control events are filtered. Admins may add tool_call,tool_result via `types`. - */ - public static getChatMessages( - options: Options, - ) { - return (options.client ?? _heyApiClient).get< - GetChatMessagesResponse, - unknown, - ThrowOnError - >({ - url: "/chats/{id}/messages", - ...options, - }); - } - - /** - * Reconcile the chat index against S3 session files (all agents, or one). - */ - public static syncChats( - options: Options, - ) { - return (options.client ?? _heyApiClient).post< - SyncChatsResponse, - unknown, - ThrowOnError - >({ - url: "/chats/sync", - ...options, - headers: { - "Content-Type": "application/json", - ...options?.headers, - }, - }); - } - - /** - * Generate (or refresh) an LLM summary + insights for one chat, on demand. - */ - public static summarizeChat( - options: Options, - ) { - return (options.client ?? _heyApiClient).post< - SummarizeChatResponse, - unknown, - ThrowOnError - >({ - url: "/chats/{id}/summarize", - ...options, - }); - } - - /** - * List the current user’s feedback for a chat session. - */ - public static getMyChatFeedback( - options: Options, - ) { - return (options.client ?? _heyApiClient).get< - GetMyChatFeedbackResponse, - unknown, - ThrowOnError - >({ - url: "/chats/{id}/feedback", - ...options, - }); - } - - /** - * Set the current user’s 👍/👎 on an assistant message. - */ - public static createChatFeedback( - options: Options, - ) { - return (options.client ?? _heyApiClient).post< - CreateChatFeedbackResponse, - unknown, - ThrowOnError - >({ - url: "/chats/{id}/feedback", - ...options, - headers: { - "Content-Type": "application/json", - ...options?.headers, - }, - }); - } - - /** - * Clear the current user’s feedback on a message (toggle-off). - */ - public static deleteChatFeedback( - options: Options, - ) { - return (options.client ?? _heyApiClient).delete< - DeleteChatFeedbackResponse, - unknown, - ThrowOnError - >({ - url: "/chats/{id}/feedback/{messageId}", - ...options, - }); - } - - /** - * Download a chat transcript as json / markdown / csv. - */ - public static exportChat( - options: Options, - ) { - return (options.client ?? _heyApiClient).get< - unknown, - unknown, - ThrowOnError - >({ - url: "/chats/{id}/export", - ...options, - }); - } - - /** - * List the current user's own chat sessions, newest first. - */ - public static getMyChats( - options?: Options, - ) { - return (options?.client ?? _heyApiClient).get< - GetMyChatsResponse, - unknown, - ThrowOnError - >({ - url: "/me/chats", - ...options, - }); - } - - /** - * Get one of the current user's own chat sessions. - */ - public static getMyChat( - options: Options, - ) { - return (options.client ?? _heyApiClient).get< - GetMyChatResponse, - unknown, - ThrowOnError - >({ - url: "/me/chats/{id}", - ...options, - }); - } - - /** - * Replay one of the current user's own chats, tail-first. `summary` markers are shown inline; tool events are never exposed to end users. - */ - public static getMyChatMessages( - options: Options, - ) { - return (options.client ?? _heyApiClient).get< - GetMyChatMessagesResponse, - unknown, - ThrowOnError - >({ - url: "/me/chats/{id}/messages", - ...options, - }); - } - - /** - * Reconcile the current user's OWN chats from S3 into the index (self-service, non-admin). Only sessions belonging to the caller are touched. A manual fallback when realtime indexing has not caught up. - */ - public static syncMyChats( - options: Options, - ) { - return (options.client ?? _heyApiClient).post< - SyncMyChatsResponse, - unknown, - ThrowOnError - >({ - url: "/me/chats/sync", - ...options, - headers: { - "Content-Type": "application/json", - ...options?.headers, - }, - }); - } - - /** - * List the current user's own feedback for one of their chats. - */ - public static listMyChatFeedback( - options: Options, - ) { - return (options.client ?? _heyApiClient).get< - ListMyChatFeedbackResponse, - unknown, - ThrowOnError - >({ - url: "/me/chats/{id}/feedback", - ...options, - }); - } - - /** - * Set the current user's 👍/👎 on a message in their own chat. - */ - public static createMyChatFeedback( - options: Options, - ) { - return (options.client ?? _heyApiClient).post< - CreateMyChatFeedbackResponse, - unknown, - ThrowOnError - >({ - url: "/me/chats/{id}/feedback", - ...options, - headers: { - "Content-Type": "application/json", - ...options?.headers, - }, - }); - } - - /** - * Clear the current user's feedback on a message (toggle-off). - */ - public static deleteMyChatFeedback( - options: Options, - ) { - return (options.client ?? _heyApiClient).delete< - DeleteMyChatFeedbackResponse, - unknown, - ThrowOnError - >({ - url: "/me/chats/{id}/feedback/{messageId}", - ...options, - }); - } - - /** - * Download the current user's own chat as json / markdown / csv. - */ - public static exportMyChat( - options: Options, - ) { - return (options.client ?? _heyApiClient).get< - unknown, - unknown, - ThrowOnError - >({ - url: "/me/chats/{id}/export", - ...options, - }); - } + /** + * List chat sessions (index). Filter by agent, channel, search; paginated. + */ + public static getChats(options?: Options) { + return (options?.client ?? _heyApiClient).get({ + url: '/chats', + ...options + }); + } + + /** + * Get one chat session (index metadata). + */ + public static getChat(options: Options) { + return (options.client ?? _heyApiClient).get({ + url: '/chats/{id}', + ...options + }); + } + + /** + * Replay a chat session's transcript from S3, tail-first. `summary` markers are shown inline (compaction folds old turns into them); synthetic loop-control events are filtered. Admins may add tool_call,tool_result via `types`. + */ + public static getChatMessages(options: Options) { + return (options.client ?? _heyApiClient).get({ + url: '/chats/{id}/messages', + ...options + }); + } + + /** + * Reconcile the chat index against S3 session files (all agents, or one). + */ + public static syncChats(options: Options) { + return (options.client ?? _heyApiClient).post({ + url: '/chats/sync', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + }); + } + + /** + * Generate (or refresh) an LLM summary + insights for one chat, on demand. + */ + public static summarizeChat(options: Options) { + return (options.client ?? _heyApiClient).post({ + url: '/chats/{id}/summarize', + ...options + }); + } + + /** + * List the current user’s feedback for a chat session. + */ + public static getMyChatFeedback(options: Options) { + return (options.client ?? _heyApiClient).get({ + url: '/chats/{id}/feedback', + ...options + }); + } + + /** + * Set the current user’s 👍/👎 on an assistant message. + */ + public static createChatFeedback(options: Options) { + return (options.client ?? _heyApiClient).post({ + url: '/chats/{id}/feedback', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + }); + } + + /** + * Clear the current user’s feedback on a message (toggle-off). + */ + public static deleteChatFeedback(options: Options) { + return (options.client ?? _heyApiClient).delete({ + url: '/chats/{id}/feedback/{messageId}', + ...options + }); + } + + /** + * Download a chat transcript as json / markdown / csv. + */ + public static exportChat(options: Options) { + return (options.client ?? _heyApiClient).get({ + url: '/chats/{id}/export', + ...options + }); + } + + /** + * List the current user's own chat sessions, newest first. + */ + public static getMyChats(options?: Options) { + return (options?.client ?? _heyApiClient).get({ + url: '/me/chats', + ...options + }); + } + + /** + * Get one of the current user's own chat sessions. + */ + public static getMyChat(options: Options) { + return (options.client ?? _heyApiClient).get({ + url: '/me/chats/{id}', + ...options + }); + } + + /** + * Replay one of the current user's own chats, tail-first. `summary` markers are shown inline; tool events are never exposed to end users. + */ + public static getMyChatMessages(options: Options) { + return (options.client ?? _heyApiClient).get({ + url: '/me/chats/{id}/messages', + ...options + }); + } + + /** + * Reconcile the current user's OWN chats from S3 into the index (self-service, non-admin). Only sessions belonging to the caller are touched. A manual fallback when realtime indexing has not caught up. + */ + public static syncMyChats(options: Options) { + return (options.client ?? _heyApiClient).post({ + url: '/me/chats/sync', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + }); + } + + /** + * List the current user's own feedback for one of their chats. + */ + public static listMyChatFeedback(options: Options) { + return (options.client ?? _heyApiClient).get({ + url: '/me/chats/{id}/feedback', + ...options + }); + } + + /** + * Set the current user's 👍/👎 on a message in their own chat. + */ + public static createMyChatFeedback(options: Options) { + return (options.client ?? _heyApiClient).post({ + url: '/me/chats/{id}/feedback', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + }); + } + + /** + * Clear the current user's feedback on a message (toggle-off). + */ + public static deleteMyChatFeedback(options: Options) { + return (options.client ?? _heyApiClient).delete({ + url: '/me/chats/{id}/feedback/{messageId}', + ...options + }); + } + + /** + * Download the current user's own chat as json / markdown / csv. + */ + public static exportMyChat(options: Options) { + return (options.client ?? _heyApiClient).get({ + url: '/me/chats/{id}/export', + ...options + }); + } + } export class UsersService { - /** - * List all users - */ - public static userControllerFindAll( - options?: Options, - ) { - return (options?.client ?? _heyApiClient).get< - unknown, - unknown, - ThrowOnError - >({ - url: "/users", - ...options, - }); - } - - /** - * Create a user (admin sets the password) - */ - public static userControllerCreate( - options: Options, - ) { - return (options.client ?? _heyApiClient).post< - unknown, - unknown, - ThrowOnError - >({ - url: "/users", - ...options, - headers: { - "Content-Type": "application/json", - ...options?.headers, - }, - }); - } - - /** - * Remove user. Owner only. - */ - public static userControllerRemove( - options: Options, - ) { - return (options.client ?? _heyApiClient).delete< - unknown, - unknown, - ThrowOnError - >({ - url: "/users/{id}", - ...options, - }); - } - - /** - * Get user by ID - */ - public static userControllerFindById( - options: Options, - ) { - return (options.client ?? _heyApiClient).get< - unknown, - unknown, - ThrowOnError - >({ - url: "/users/{id}", - ...options, - }); - } - - /** - * Update user (name, email, password, status). Use /role to change the role. - */ - public static userControllerUpdate( - options: Options, - ) { - return (options.client ?? _heyApiClient).put< - unknown, - unknown, - ThrowOnError - >({ - url: "/users/{id}", - ...options, - headers: { - "Content-Type": "application/json", - ...options?.headers, - }, - }); - } - - /** - * Set the user's role. Owner only. - */ - public static userControllerUpdateRole( - options: Options, - ) { - return (options.client ?? _heyApiClient).put< - unknown, - unknown, - ThrowOnError - >({ - url: "/users/{id}/role", - ...options, - headers: { - "Content-Type": "application/json", - ...options?.headers, - }, - }); - } + /** + * List all users + */ + public static userControllerFindAll(options?: Options) { + return (options?.client ?? _heyApiClient).get({ + url: '/users', + ...options + }); + } + + /** + * Create a user (admin sets the password) + */ + public static userControllerCreate(options: Options) { + return (options.client ?? _heyApiClient).post({ + url: '/users', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + }); + } + + /** + * Remove user. Owner only. + */ + public static userControllerRemove(options: Options) { + return (options.client ?? _heyApiClient).delete({ + url: '/users/{id}', + ...options + }); + } + + /** + * Get user by ID + */ + public static userControllerFindById(options: Options) { + return (options.client ?? _heyApiClient).get({ + url: '/users/{id}', + ...options + }); + } + + /** + * Update user (name, email, password, status). Use /role to change the role. + */ + public static userControllerUpdate(options: Options) { + return (options.client ?? _heyApiClient).put({ + url: '/users/{id}', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + }); + } + + /** + * Set the user's role. Owner only. + */ + public static userControllerUpdateRole(options: Options) { + return (options.client ?? _heyApiClient).put({ + url: '/users/{id}/role', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + }); + } + } export class TemplateFilesService { - /** - * List files for a template - */ - public static templateFileControllerList< - ThrowOnError extends boolean = false, - >(options: Options) { - return (options.client ?? _heyApiClient).get< - unknown, - unknown, - ThrowOnError - >({ - url: "/templates/{id}/files", - ...options, - }); - } - - /** - * Read a template file - */ - public static templateFileControllerRead< - ThrowOnError extends boolean = false, - >(options: Options) { - return (options.client ?? _heyApiClient).get< - unknown, - unknown, - ThrowOnError - >({ - url: "/templates/{id}/files/content", - ...options, - }); - } - - /** - * Save a template file (.md / .json only) - */ - public static templateFileControllerSave< - ThrowOnError extends boolean = false, - >(options: Options) { - return (options.client ?? _heyApiClient).put< - unknown, - unknown, - ThrowOnError - >({ - url: "/templates/{id}/files/content", - ...options, - headers: { - "Content-Type": "application/json", - ...options?.headers, - }, - }); - } - - /** - * Replace template files in S3 with the uploaded folder. The body is multipart/form-data; pass a parallel "paths[]" field with the relative path for each file. - */ - public static templateFileControllerUpload< - ThrowOnError extends boolean = false, - >(options: Options) { - return (options.client ?? _heyApiClient).post< - unknown, - unknown, - ThrowOnError - >({ - ...formDataBodySerializer, - url: "/templates/{id}/files/upload", - ...options, - headers: { - "Content-Type": null, - ...options?.headers, - }, - }); - } + /** + * List files for a template + */ + public static templateFileControllerList(options: Options) { + return (options.client ?? _heyApiClient).get({ + url: '/templates/{id}/files', + ...options + }); + } + + /** + * Read a template file + */ + public static templateFileControllerRead(options: Options) { + return (options.client ?? _heyApiClient).get({ + url: '/templates/{id}/files/content', + ...options + }); + } + + /** + * Save a template file (.md / .json only) + */ + public static templateFileControllerSave(options: Options) { + return (options.client ?? _heyApiClient).put({ + url: '/templates/{id}/files/content', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + }); + } + + /** + * Replace template files in S3 with the uploaded folder. The body is multipart/form-data; pass a parallel "paths[]" field with the relative path for each file. + */ + public static templateFileControllerUpload(options: Options) { + return (options.client ?? _heyApiClient).post({ + ...formDataBodySerializer, + url: '/templates/{id}/files/upload', + ...options, + headers: { + 'Content-Type': null, + ...options?.headers + } + }); + } + } export class PaddockScenariosService { - /** - * List paddock scenarios. Filter by templateId or agentId; without filters returns all. - */ - public static paddockScenarioControllerFindAll< - ThrowOnError extends boolean = false, - >(options?: Options) { - return (options?.client ?? _heyApiClient).get< - unknown, - unknown, - ThrowOnError - >({ - url: "/paddock-scenarios", - ...options, - }); - } - - /** - * Create a paddock scenario scoped to either a template or an agent (XOR — exactly one). - */ - public static paddockScenarioControllerCreate< - ThrowOnError extends boolean = false, - >(options: Options) { - return (options.client ?? _heyApiClient).post< - unknown, - unknown, - ThrowOnError - >({ - url: "/paddock-scenarios", - ...options, - headers: { - "Content-Type": "application/json", - ...options?.headers, - }, - }); - } - - /** - * Delete a paddock scenario - */ - public static paddockScenarioControllerRemove< - ThrowOnError extends boolean = false, - >(options: Options) { - return (options.client ?? _heyApiClient).delete< - unknown, - unknown, - ThrowOnError - >({ - url: "/paddock-scenarios/{id}", - ...options, - }); - } - - /** - * Get a paddock scenario by id - */ - public static paddockScenarioControllerFindById< - ThrowOnError extends boolean = false, - >(options: Options) { - return (options.client ?? _heyApiClient).get< - unknown, - unknown, - ThrowOnError - >({ - url: "/paddock-scenarios/{id}", - ...options, - }); - } - - /** - * Update a paddock scenario. Scope (templateId/agentId) is immutable — create a new scenario to change scope. - */ - public static paddockScenarioControllerUpdate< - ThrowOnError extends boolean = false, - >(options: Options) { - return (options.client ?? _heyApiClient).patch< - unknown, - unknown, - ThrowOnError - >({ - url: "/paddock-scenarios/{id}", - ...options, - headers: { - "Content-Type": "application/json", - ...options?.headers, - }, - }); - } + /** + * List paddock scenarios. Filter by templateId or agentId; without filters returns all. + */ + public static paddockScenarioControllerFindAll(options?: Options) { + return (options?.client ?? _heyApiClient).get({ + url: '/paddock-scenarios', + ...options + }); + } + + /** + * Create a paddock scenario scoped to either a template or an agent (XOR — exactly one). + */ + public static paddockScenarioControllerCreate(options: Options) { + return (options.client ?? _heyApiClient).post({ + url: '/paddock-scenarios', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + }); + } + + /** + * Delete a paddock scenario + */ + public static paddockScenarioControllerRemove(options: Options) { + return (options.client ?? _heyApiClient).delete({ + url: '/paddock-scenarios/{id}', + ...options + }); + } + + /** + * Get a paddock scenario by id + */ + public static paddockScenarioControllerFindById(options: Options) { + return (options.client ?? _heyApiClient).get({ + url: '/paddock-scenarios/{id}', + ...options + }); + } + + /** + * Update a paddock scenario. Scope (templateId/agentId) is immutable — create a new scenario to change scope. + */ + public static paddockScenarioControllerUpdate(options: Options) { + return (options.client ?? _heyApiClient).patch({ + url: '/paddock-scenarios/{id}', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + }); + } + + /** + * Generate a scenario draft from a natural-language description via LLM. Result is NOT persisted — review + POST / to save. + */ + public static paddockScenarioControllerGenerate(options: Options) { + return (options.client ?? _heyApiClient).post({ + url: '/paddock-scenarios/generate', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + }); + } + +} - /** - * Generate a scenario draft from a natural-language description via LLM. Result is NOT persisted — review + POST / to save. - */ - public static paddockScenarioControllerGenerate< - ThrowOnError extends boolean = false, - >(options: Options) { - return (options.client ?? _heyApiClient).post< - unknown, - unknown, - ThrowOnError - >({ - url: "/paddock-scenarios/generate", - ...options, - headers: { - "Content-Type": "application/json", - ...options?.headers, - }, - }); - } +export class PeersService { + /** + * This agent's own A2A card, exactly as another agent would read it. Derived on every request from the name, description, template skills and bound knowledge bases — there is nothing to regenerate. + */ + public static getAgentCard(options: Options) { + return (options.client ?? _heyApiClient).get({ + url: '/agents/{agentId}/card', + ...options + }); + } + + /** + * Agents this one can delegate to, with the card snapshot taken when each was connected or last refreshed. Directed: this never lists the agents that can delegate TO this one. + */ + public static listAgentPeers(options: Options) { + return (options.client ?? _heyApiClient).get({ + url: '/agents/{agentId}/peers', + ...options + }); + } + + /** + * Connect another agent as a peer: mints a credential for this pair, reads the peer card with it, and stores the snapshot. Nothing is kept if the card cannot be read, so a saved connection always works. The agent picks the tool up on its next restart. + */ + public static connectAgentPeer(options: Options) { + return (options.client ?? _heyApiClient).post({ + url: '/agents/{agentId}/peers', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + }); + } + + /** + * Every other agent of this installation, each marked with whether it is already a peer. Excludes the agent itself. + */ + public static listAgentPeerCandidates(options: Options) { + return (options.client ?? _heyApiClient).get({ + url: '/agents/{agentId}/peers/candidates', + ...options + }); + } + + /** + * Re-read the peer's card and replace the stored snapshot. A failed read keeps the old snapshot: stale is better than nothing. + */ + public static refreshAgentPeer(options: Options) { + return (options.client ?? _heyApiClient).post({ + url: '/agents/{agentId}/peers/{peerId}/refresh', + ...options + }); + } + + /** + * Disconnect a peer. This revokes the credential issued for the pair — there is no other copy of it — so the connection cannot be used again. + */ + public static removeAgentPeer(options: Options) { + return (options.client ?? _heyApiClient).delete({ + url: '/agents/{agentId}/peers/{peerId}', + ...options + }); + } + + /** + * Recent tasks this agent handed to its peers, newest first: who was asked, why, how long it took and how it ended. + */ + public static listAgentDelegations(options: Options) { + return (options.client ?? _heyApiClient).get({ + url: '/agents/{agentId}/delegations', + ...options + }); + } + } export class SecretsService { - /** - * Delete a secret for an agent. No-op if the key does not exist. Returns the full secret list. - */ - public static secretControllerDelete( - options: Options, - ) { - return (options.client ?? _heyApiClient).delete< - SecretControllerDeleteResponse, - unknown, - ThrowOnError - >({ - url: "/agents/{agentId}/secrets", - ...options, - headers: { - "Content-Type": "application/json", - ...options?.headers, - }, - }); - } - - /** - * List secrets stored for an agent. AWS source pulls from AWS Secrets Manager (aws_secret_prefix/); file source lists S3 under agents//data/secrets/. - */ - public static secretControllerList( - options: Options, - ) { - return (options.client ?? _heyApiClient).get< - SecretControllerListResponse, - unknown, - ThrowOnError - >({ - url: "/agents/{agentId}/secrets", - ...options, - }); - } - - /** - * Create or update a secret for an agent (upsert). Returns the full secret list. - */ - public static secretControllerSet( - options: Options, - ) { - return (options.client ?? _heyApiClient).put< - SecretControllerSetResponse, - unknown, - ThrowOnError - >({ - url: "/agents/{agentId}/secrets", - ...options, - headers: { - "Content-Type": "application/json", - ...options?.headers, - }, - }); - } - - /** - * Atomic full-store replace. Mirrors AWS Secrets Manager's plaintext-edit semantics — the whole agent secret store becomes the supplied object. Returns the full secret list. - */ - public static secretControllerReplace( - options: Options, - ) { - return (options.client ?? _heyApiClient).post< - SecretControllerReplaceResponse, - unknown, - ThrowOnError - >({ - url: "/agents/{agentId}/secrets/replace", - ...options, - headers: { - "Content-Type": "application/json", - ...options?.headers, - }, - }); - } + /** + * Delete a secret for an agent. No-op if the key does not exist. Returns the full secret list. + */ + public static secretControllerDelete(options: Options) { + return (options.client ?? _heyApiClient).delete({ + url: '/agents/{agentId}/secrets', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + }); + } + + /** + * List secrets stored for an agent. AWS source pulls from AWS Secrets Manager (aws_secret_prefix/); file source lists S3 under agents//data/secrets/. + */ + public static secretControllerList(options: Options) { + return (options.client ?? _heyApiClient).get({ + url: '/agents/{agentId}/secrets', + ...options + }); + } + + /** + * Create or update a secret for an agent (upsert). Returns the full secret list. + */ + public static secretControllerSet(options: Options) { + return (options.client ?? _heyApiClient).put({ + url: '/agents/{agentId}/secrets', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + }); + } + + /** + * Atomic full-store replace. Mirrors AWS Secrets Manager's plaintext-edit semantics — the whole agent secret store becomes the supplied object. Returns the full secret list. + */ + public static secretControllerReplace(options: Options) { + return (options.client ?? _heyApiClient).post({ + url: '/agents/{agentId}/secrets/replace', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + }); + } + } export class LogsService { - /** - * Get agent pod logs - */ - public static logControllerGetLogs( - options: Options, - ) { - return (options.client ?? _heyApiClient).get< - unknown, - unknown, - ThrowOnError - >({ - url: "/agents/{agentId}/logs", - ...options, - }); - } + /** + * Get agent pod logs + */ + public static logControllerGetLogs(options: Options) { + return (options.client ?? _heyApiClient).get({ + url: '/agents/{agentId}/logs', + ...options + }); + } + } export class UsageService { - /** - * Get 30-day usage for an agent with cost - */ - public static usageControllerFindForAgent< - ThrowOnError extends boolean = false, - >(options: Options) { - return (options.client ?? _heyApiClient).get< - unknown, - unknown, - ThrowOnError - >({ - url: "/agents/{agentId}/usage", - ...options, - }); - } - - /** - * Agent reports its usage (mirrors runtime usage.json) - */ - public static usageControllerReport( - options: Options, - ) { - return (options.client ?? _heyApiClient).post< - UsageControllerReportResponse, - unknown, - ThrowOnError - >({ - url: "/agents/{agentId}/usage", - ...options, - headers: { - "Content-Type": "application/json", - ...options?.headers, - }, - }); - } - - /** - * Get 30-day usage for a single LLM credential across all agents - */ - public static usageControllerFindForCredential< - ThrowOnError extends boolean = false, - >(options: Options) { - return (options.client ?? _heyApiClient).get< - unknown, - unknown, - ThrowOnError - >({ - url: "/llms/{id}/usage", - ...options, - }); - } - - /** - * Get 30-day usage across all agents with cost - */ - public static usageControllerFindOverview< - ThrowOnError extends boolean = false, - >(options?: Options) { - return (options?.client ?? _heyApiClient).get< - unknown, - unknown, - ThrowOnError - >({ - url: "/usage/overview", - ...options, - }); - } + /** + * Get 30-day usage for an agent with cost + */ + public static usageControllerFindForAgent(options: Options) { + return (options.client ?? _heyApiClient).get({ + url: '/agents/{agentId}/usage', + ...options + }); + } + + /** + * Agent reports its usage (mirrors runtime usage.json) + */ + public static usageControllerReport(options: Options) { + return (options.client ?? _heyApiClient).post({ + url: '/agents/{agentId}/usage', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + }); + } + + /** + * Get 30-day usage for a single LLM credential across all agents + */ + public static usageControllerFindForCredential(options: Options) { + return (options.client ?? _heyApiClient).get({ + url: '/llms/{id}/usage', + ...options + }); + } + + /** + * Get 30-day usage across all agents with cost + */ + public static usageControllerFindOverview(options?: Options) { + return (options?.client ?? _heyApiClient).get({ + url: '/usage/overview', + ...options + }); + } + } export class RancherService { - /** - * Stepper state for the Rancher setup wizard: do we have an LLM, the special template, and an admin agent? - */ - public static rancherControllerStatus( - options?: Options, - ) { - return (options?.client ?? _heyApiClient).get< - unknown, - unknown, - ThrowOnError - >({ - url: "/rancher/status", - ...options, - }); - } - - /** - * Create the special Rancher template (idempotent). Returns the existing one if already created. - */ - public static rancherControllerEnsureTemplate< - ThrowOnError extends boolean = false, - >(options?: Options) { - return (options?.client ?? _heyApiClient).post< - unknown, - unknown, - ThrowOnError - >({ - url: "/rancher/template", - ...options, - }); - } + /** + * Stepper state for the Rancher setup wizard: do we have an LLM, the special template, and an admin agent? + */ + public static rancherControllerStatus(options?: Options) { + return (options?.client ?? _heyApiClient).get({ + url: '/rancher/status', + ...options + }); + } + + /** + * Create the special Rancher template (idempotent). Returns the existing one if already created. + */ + public static rancherControllerEnsureTemplate(options?: Options) { + return (options?.client ?? _heyApiClient).post({ + url: '/rancher/template', + ...options + }); + } + } export class UpgradeService { - /** - * Whether the local checkout can be upgraded in place (clean tree, on main, .git present, not in deployed mode). - */ - public static upgradeControllerStatus( - options?: Options, - ) { - return (options?.client ?? _heyApiClient).get< - unknown, - unknown, - ThrowOnError - >({ - url: "/upgrade/status", - ...options, - }); - } + /** + * Whether the local checkout can be upgraded in place (clean tree, on main, .git present, not in deployed mode). + */ + public static upgradeControllerStatus(options?: Options) { + return (options?.client ?? _heyApiClient).get({ + url: '/upgrade/status', + ...options + }); + } + + /** + * Pull latest Ranch from main, bun install, prisma migrate deploy. nest's --watch and Nuxt HMR pick up the new code automatically. Response may not return cleanly if the watcher restarts mid-call — the client should reload after a short delay. + */ + public static upgradeControllerRun(options?: Options) { + return (options?.client ?? _heyApiClient).post({ + url: '/upgrade', + ...options + }); + } + +} - /** - * Pull latest Ranch from main, bun install, prisma migrate deploy. nest's --watch and Nuxt HMR pick up the new code automatically. Response may not return cleanly if the watcher restarts mid-call — the client should reload after a short delay. - */ - public static upgradeControllerRun( - options?: Options, - ) { - return (options?.client ?? _heyApiClient).post< - unknown, - unknown, - ThrowOnError - >({ - url: "/upgrade", - ...options, - }); - } +export class McpOauthService { + /** + * Begin an OAuth connect for an agent. Returns the authorization URL the agent sends the user in chat. + */ + public static startMcpOauth(options: Options) { + return (options.client ?? _heyApiClient).post({ + url: '/mcp-servers/{serverId}/oauth/start', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + }); + } + + /** + * OAuth redirect target. Exchanges the code, stores the per-agent token, wakes the agent, and shows a return-to-chat page. + */ + public static mcpOauthCallback(options: Options) { + return (options.client ?? _heyApiClient).get({ + url: '/mcp-servers/{serverId}/oauth/callback', + ...options + }); + } + + /** + * Whether the given agent already has a usable token for this server. + */ + public static mcpOauthStatus(options: Options) { + return (options.client ?? _heyApiClient).get({ + url: '/mcp-servers/{serverId}/oauth/status', + ...options + }); + } + } export class PaddockEvaluationsService { - /** - * List paddock evaluations. Filter by agentId / templateId. - */ - public static paddockEvaluationControllerList< - ThrowOnError extends boolean = false, - >(options?: Options) { - return (options?.client ?? _heyApiClient).get< - unknown, - unknown, - ThrowOnError - >({ - url: "/paddock-evaluations", - ...options, - }); - } - - /** - * Start a paddock evaluation for an agent. Returns the evaluation record immediately; the actual run is asynchronous (poll status). - */ - public static paddockEvaluationControllerStart< - ThrowOnError extends boolean = false, - >(options: Options) { - return (options.client ?? _heyApiClient).post< - unknown, - unknown, - ThrowOnError - >({ - url: "/paddock-evaluations", - ...options, - headers: { - "Content-Type": "application/json", - ...options?.headers, - }, - }); - } - - /** - * Get evaluation status + summary + per-scenario results - */ - public static paddockEvaluationControllerGet< - ThrowOnError extends boolean = false, - >(options: Options) { - return (options.client ?? _heyApiClient).get< - unknown, - unknown, - ThrowOnError - >({ - url: "/paddock-evaluations/{id}", - ...options, - }); - } - - /** - * Fetch the full evaluation report — both structured JSON and markdown. - */ - public static paddockEvaluationControllerReport< - ThrowOnError extends boolean = false, - >(options: Options) { - return (options.client ?? _heyApiClient).get< - unknown, - unknown, - ThrowOnError - >({ - url: "/paddock-evaluations/{id}/report", - ...options, - }); - } - - /** - * Live tail of paddock CLI stdout+stderr. In-memory ring buffer (~2000 lines), keyed by the evaluation's agent. Cleared on API restart or when a fresh run starts for the same agent. - */ - public static paddockEvaluationControllerLogs< - ThrowOnError extends boolean = false, - >(options: Options) { - return (options.client ?? _heyApiClient).get< - unknown, - unknown, - ThrowOnError - >({ - url: "/paddock-evaluations/{id}/logs", - ...options, - }); - } - - /** - * Fetch one scenario as captured in this evaluation's snapshot (messages, expectedBehavior, successCriteria). Reliable across template re-seeds that change scenario UUIDs in the live `paddock_scenarios` table. - */ - public static paddockEvaluationControllerScenario< - ThrowOnError extends boolean = false, - >(options: Options) { - return (options.client ?? _heyApiClient).get< - unknown, - unknown, - ThrowOnError - >({ - url: "/paddock-evaluations/{id}/scenarios/{scenarioId}", - ...options, - }); - } - - /** - * Fetch the per-scenario execution trace (responses, tool calls, errors). Available after the run completes. - */ - public static paddockEvaluationControllerTrace< - ThrowOnError extends boolean = false, - >(options: Options) { - return (options.client ?? _heyApiClient).get< - unknown, - unknown, - ThrowOnError - >({ - url: "/paddock-evaluations/{id}/trace", - ...options, - }); - } - - /** - * Mark a running evaluation as aborted. The current scenario will finish before the run halts. - */ - public static paddockEvaluationControllerAbort< - ThrowOnError extends boolean = false, - >(options: Options) { - return (options.client ?? _heyApiClient).post< - unknown, - unknown, - ThrowOnError - >({ - url: "/paddock-evaluations/{id}/abort", - ...options, - }); - } - - /** - * Start a new evaluation re-using the exact scenarios + judge config from this one. Returns the new evaluation record immediately; the run is async. - */ - public static paddockEvaluationControllerRerun< - ThrowOnError extends boolean = false, - >(options: Options) { - return (options.client ?? _heyApiClient).post< - unknown, - unknown, - ThrowOnError - >({ - url: "/paddock-evaluations/{id}/rerun", - ...options, - }); - } + /** + * List paddock evaluations. Filter by agentId / templateId. + */ + public static paddockEvaluationControllerList(options?: Options) { + return (options?.client ?? _heyApiClient).get({ + url: '/paddock-evaluations', + ...options + }); + } + + /** + * Start a paddock evaluation for an agent. Returns the evaluation record immediately; the actual run is asynchronous (poll status). + */ + public static paddockEvaluationControllerStart(options: Options) { + return (options.client ?? _heyApiClient).post({ + url: '/paddock-evaluations', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + }); + } + + /** + * Get evaluation status + summary + per-scenario results + */ + public static paddockEvaluationControllerGet(options: Options) { + return (options.client ?? _heyApiClient).get({ + url: '/paddock-evaluations/{id}', + ...options + }); + } + + /** + * Fetch the full evaluation report — both structured JSON and markdown. + */ + public static paddockEvaluationControllerReport(options: Options) { + return (options.client ?? _heyApiClient).get({ + url: '/paddock-evaluations/{id}/report', + ...options + }); + } + + /** + * Live tail of paddock CLI stdout+stderr. In-memory ring buffer (~2000 lines), keyed by the evaluation's agent. Cleared on API restart or when a fresh run starts for the same agent. + */ + public static paddockEvaluationControllerLogs(options: Options) { + return (options.client ?? _heyApiClient).get({ + url: '/paddock-evaluations/{id}/logs', + ...options + }); + } + + /** + * Fetch one scenario as captured in this evaluation's snapshot (messages, expectedBehavior, successCriteria). Reliable across template re-seeds that change scenario UUIDs in the live `paddock_scenarios` table. + */ + public static paddockEvaluationControllerScenario(options: Options) { + return (options.client ?? _heyApiClient).get({ + url: '/paddock-evaluations/{id}/scenarios/{scenarioId}', + ...options + }); + } + + /** + * Fetch the per-scenario execution trace (responses, tool calls, errors). Available after the run completes. + */ + public static paddockEvaluationControllerTrace(options: Options) { + return (options.client ?? _heyApiClient).get({ + url: '/paddock-evaluations/{id}/trace', + ...options + }); + } + + /** + * Mark a running evaluation as aborted. The current scenario will finish before the run halts. + */ + public static paddockEvaluationControllerAbort(options: Options) { + return (options.client ?? _heyApiClient).post({ + url: '/paddock-evaluations/{id}/abort', + ...options + }); + } + + /** + * Start a new evaluation re-using the exact scenarios + judge config from this one. Returns the new evaluation record immediately; the run is async. + */ + public static paddockEvaluationControllerRerun(options: Options) { + return (options.client ?? _heyApiClient).post({ + url: '/paddock-evaluations/{id}/rerun', + ...options + }); + } + } export class BrowserService { - /** - * List the calling user’s browser sessions. - */ - public static listBrowserSessions( - options?: Options, - ) { - return (options?.client ?? _heyApiClient).get< - ListBrowserSessionsResponse, - unknown, - ThrowOnError - >({ - url: "/browser/sessions", - ...options, - }); - } - - /** - * Open or reuse a browser session for the given accountKey. Returns a CDP URL the runtime uses to connect a Playwright/CDP client. - */ - public static openBrowserSession( - options: Options, - ) { - return (options.client ?? _heyApiClient).post< - OpenBrowserSessionResponse, - unknown, - ThrowOnError - >({ - url: "/browser/sessions", - ...options, - headers: { - "Content-Type": "application/json", - ...options?.headers, - }, - }); - } - - /** - * Disconnect the account — drops the session row and schedules the profile directory for cleanup. - */ - public static deleteBrowserSession( - options: Options, - ) { - return (options.client ?? _heyApiClient).delete< - DeleteBrowserSessionResponse, - unknown, - ThrowOnError - >({ - url: "/browser/sessions/{id}", - ...options, - }); - } - - /** - * Get a single session (only sessions you own). - */ - public static getBrowserSession( - options: Options, - ) { - return (options.client ?? _heyApiClient).get< - GetBrowserSessionResponse, - unknown, - ThrowOnError - >({ - url: "/browser/sessions/{id}", - ...options, - }); - } - - /** - * Discard the current browser instance for this session and return a fresh CDP URL. Use when a browser_play call has been stuck on a 120s timeout. - */ - public static resetBrowserSession( - options: Options, - ) { - return (options.client ?? _heyApiClient).post< - ResetBrowserSessionResponse, - unknown, - ThrowOnError - >({ - url: "/browser/sessions/{id}/reset", - ...options, - }); - } - - /** - * Update session status. Called by the runtime after each tool batch (idle / needs_login / stuck), or by the admin UI to mark a session for relogin. - */ - public static setBrowserSessionStatus( - options: Options, - ) { - return (options.client ?? _heyApiClient).post< - SetBrowserSessionStatusResponse, - unknown, - ThrowOnError - >({ - url: "/browser/sessions/{id}/status", - ...options, - headers: { - "Content-Type": "application/json", - ...options?.headers, - }, - }); - } - - /** - * Mint a short-lived (15 min) VNC URL the end user can open to finish a 2FA/CAPTCHA flow manually. - */ - public static mintBrowserSessionVncUrl( - options: Options, - ) { - return (options.client ?? _heyApiClient).post< - unknown, - unknown, - ThrowOnError - >({ - url: "/browser/sessions/{id}/vnc-url", - ...options, - }); - } - - /** - * Open or reuse a session on behalf of a user. Used by the runtime before each browser_play call. - */ - public static openBrowserSessionInternal< - ThrowOnError extends boolean = false, - >(options: Options) { - return (options.client ?? _heyApiClient).post< - unknown, - unknown, - ThrowOnError - >({ - url: "/browser/internal/sessions", - ...options, - headers: { - "Content-Type": "application/json", - ...options?.headers, - }, - }); - } - - /** - * Report session status back from the runtime — typically idle after success or needs_login when Instagram redirected to /login. - */ - public static setBrowserSessionStatusInternal< - ThrowOnError extends boolean = false, - >(options: Options) { - return (options.client ?? _heyApiClient).post< - unknown, - unknown, - ThrowOnError - >({ - url: "/browser/internal/sessions/{id}/status", - ...options, - headers: { - "Content-Type": "application/json", - ...options?.headers, - }, - }); - } - - /** - * Hard-reset a stuck session and return a fresh CDP URL on the same profile. - */ - public static resetBrowserSessionInternal< - ThrowOnError extends boolean = false, - >(options: Options) { - return (options.client ?? _heyApiClient).post< - unknown, - unknown, - ThrowOnError - >({ - url: "/browser/internal/sessions/{id}/reset", - ...options, - headers: { - "Content-Type": "application/json", - ...options?.headers, - }, - }); - } - - /** - * Pull cookies off the live Chrome attached to this session in Playwright storageState shape. The runtime calls this from browser_login_done after the user signs in via the VNC URL, then writes the result to .agent/browser-state/. - */ - public static harvestBrowserSessionInternal< - ThrowOnError extends boolean = false, - >(options: Options) { - return (options.client ?? _heyApiClient).post< - unknown, - unknown, - ThrowOnError - >({ - url: "/browser/internal/sessions/{id}/harvest", - ...options, - headers: { - "Content-Type": "application/json", - ...options?.headers, - }, - }); - } - - /** - * Mark sessions idle for longer than `idleMinutes` (default 30) as expired. Called by the browser-pool-cleanup CronJob. - */ - public static cleanupBrowserSessionsInternal< - ThrowOnError extends boolean = false, - >(options?: Options) { - return (options?.client ?? _heyApiClient).post< - unknown, - unknown, - ThrowOnError - >({ - url: "/browser/internal/sessions/cleanup", - ...options, - }); - } - - /** - * Mint a long-lived JWT that authorises the Chrome extension to import cookies into the given agent + userId scope. Admin UI calls this on behalf of the logged-in user. - */ - public static issueBrowserExtensionToken< - ThrowOnError extends boolean = false, - >(options: Options) { - return (options.client ?? _heyApiClient).post< - IssueBrowserExtensionTokenResponse, - unknown, - ThrowOnError - >({ - url: "/browser/extension/token", - ...options, - headers: { - "Content-Type": "application/json", - ...options?.headers, - }, - }); - } - - /** - * Receive cookies harvested from the user's own Chrome and write them as a Playwright storageState file under the agent's S3 prefix. Authentication: Bearer minted via /browser/extension/token. - */ - public static importBrowserStorageState( - options: Options, - ) { - return (options.client ?? _heyApiClient).post< - ImportBrowserStorageStateResponse, - unknown, - ThrowOnError - >({ - url: "/browser/extension/import-state", - ...options, - headers: { - "Content-Type": "application/json", - ...options?.headers, - }, - }); - } + /** + * List the calling user’s browser sessions. + */ + public static listBrowserSessions(options?: Options) { + return (options?.client ?? _heyApiClient).get({ + url: '/browser/sessions', + ...options + }); + } + + /** + * Open or reuse a browser session for the given accountKey. Returns a CDP URL the runtime uses to connect a Playwright/CDP client. + */ + public static openBrowserSession(options: Options) { + return (options.client ?? _heyApiClient).post({ + url: '/browser/sessions', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + }); + } + + /** + * Disconnect the account — drops the session row and schedules the profile directory for cleanup. + */ + public static deleteBrowserSession(options: Options) { + return (options.client ?? _heyApiClient).delete({ + url: '/browser/sessions/{id}', + ...options + }); + } + + /** + * Get a single session (only sessions you own). + */ + public static getBrowserSession(options: Options) { + return (options.client ?? _heyApiClient).get({ + url: '/browser/sessions/{id}', + ...options + }); + } + + /** + * Discard the current browser instance for this session and return a fresh CDP URL. Use when a browser_play call has been stuck on a 120s timeout. + */ + public static resetBrowserSession(options: Options) { + return (options.client ?? _heyApiClient).post({ + url: '/browser/sessions/{id}/reset', + ...options + }); + } + + /** + * Update session status. Called by the runtime after each tool batch (idle / needs_login / stuck), or by the admin UI to mark a session for relogin. + */ + public static setBrowserSessionStatus(options: Options) { + return (options.client ?? _heyApiClient).post({ + url: '/browser/sessions/{id}/status', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + }); + } + + /** + * Mint a short-lived (15 min) VNC URL the end user can open to finish a 2FA/CAPTCHA flow manually. + */ + public static mintBrowserSessionVncUrl(options: Options) { + return (options.client ?? _heyApiClient).post({ + url: '/browser/sessions/{id}/vnc-url', + ...options + }); + } + + /** + * Open or reuse a session on behalf of a user. Used by the runtime before each browser_play call. + */ + public static openBrowserSessionInternal(options: Options) { + return (options.client ?? _heyApiClient).post({ + url: '/browser/internal/sessions', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + }); + } + + /** + * Report session status back from the runtime — typically idle after success or needs_login when Instagram redirected to /login. + */ + public static setBrowserSessionStatusInternal(options: Options) { + return (options.client ?? _heyApiClient).post({ + url: '/browser/internal/sessions/{id}/status', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + }); + } + + /** + * Hard-reset a stuck session and return a fresh CDP URL on the same profile. + */ + public static resetBrowserSessionInternal(options: Options) { + return (options.client ?? _heyApiClient).post({ + url: '/browser/internal/sessions/{id}/reset', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + }); + } + + /** + * Pull cookies off the live Chrome attached to this session in Playwright storageState shape. The runtime calls this from browser_login_done after the user signs in via the VNC URL, then writes the result to .agent/browser-state/. + */ + public static harvestBrowserSessionInternal(options: Options) { + return (options.client ?? _heyApiClient).post({ + url: '/browser/internal/sessions/{id}/harvest', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + }); + } + + /** + * Mark sessions idle for longer than `idleMinutes` (default 30) as expired. Called by the browser-pool-cleanup CronJob. + */ + public static cleanupBrowserSessionsInternal(options?: Options) { + return (options?.client ?? _heyApiClient).post({ + url: '/browser/internal/sessions/cleanup', + ...options + }); + } + + /** + * Mint a long-lived JWT that authorises the Chrome extension to import cookies into the given agent + userId scope. Admin UI calls this on behalf of the logged-in user. + */ + public static issueBrowserExtensionToken(options: Options) { + return (options.client ?? _heyApiClient).post({ + url: '/browser/extension/token', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + }); + } + + /** + * Receive cookies harvested from the user's own Chrome and write them as a Playwright storageState file under the agent's S3 prefix. Authentication: Bearer minted via /browser/extension/token. + */ + public static importBrowserStorageState(options: Options) { + return (options.client ?? _heyApiClient).post({ + url: '/browser/extension/import-state', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + }); + } + } export class IntegrationsService { - /** - * List supported services. Static — defined in api/src/slices/integration/domain/catalogue.ts and shipped with each API release. - */ - public static listIntegrationCatalogue( - options?: Options, - ) { - return (options?.client ?? _heyApiClient).get< - ListIntegrationCatalogueResponse, - unknown, - ThrowOnError - >({ - url: "/integrations/catalogue", - ...options, - }); - } - - /** - * List the calling user’s connected integration accounts. - */ - public static listIntegrationAccounts( - options?: Options, - ) { - return (options?.client ?? _heyApiClient).get< - ListIntegrationAccountsResponse, - unknown, - ThrowOnError - >({ - url: "/integrations/accounts", - ...options, - }); - } - - /** - * Start (or re-open) a connection. Idempotent on (userId, service, accountKey) — repeated calls return the existing row. Status stays "pending" until the underlying credential is stored (browser cookies harvested or secret value saved). - */ - public static connectIntegration( - options: Options, - ) { - return (options.client ?? _heyApiClient).post< - ConnectIntegrationResponse, - unknown, - ThrowOnError - >({ - url: "/integrations/accounts", - ...options, - headers: { - "Content-Type": "application/json", - ...options?.headers, - }, - }); - } - - /** - * Disconnect an integration. Browser-mechanism: also deletes the underlying BrowserSession (profile cleanup runs on its own CronJob). Secret-mechanism: also wipes the stored value from the per-user secret store. - */ - public static disconnectIntegration( - options: Options, - ) { - return (options.client ?? _heyApiClient).delete< - DisconnectIntegrationResponse, - unknown, - ThrowOnError - >({ - url: "/integrations/accounts/{id}", - ...options, - }); - } - - /** - * Fetch one account. Refreshes status from the underlying BrowserSession before returning. 404 if not owned by the caller. - */ - public static getIntegrationAccount( - options: Options, - ) { - return (options.client ?? _heyApiClient).get< - GetIntegrationAccountResponse, - unknown, - ThrowOnError - >({ - url: "/integrations/accounts/{id}", - ...options, - }); - } - - /** - * Return the help URL + login URL + textual instructions the agent forwards to the end user. Browser-mechanism only. No VNC — the user logs in inside their own Chrome and pushes cookies via the Ranch extension. Flips status to "needs_login" so the admin UI reflects the pending state. - */ - public static openIntegrationLogin( - options: Options, - ) { - return (options.client ?? _heyApiClient).post< - OpenIntegrationLoginResponse, - unknown, - ThrowOnError - >({ - url: "/integrations/accounts/{id}/login", - ...options, - }); - } - - /** - * Alternative to VNC login: accept a Playwright storageState dump that the user harvested from their own browser. The runtime falls back to this state when no per-agent state file exists, so one import covers every agent the user owns. Browser-mechanism only. - */ - public static importIntegrationCookies( - options: Options, - ) { - return (options.client ?? _heyApiClient).post< - ImportIntegrationCookiesResponse, - unknown, - ThrowOnError - >({ - url: "/integrations/accounts/{id}/import-cookies", - ...options, - headers: { - "Content-Type": "application/json", - ...options?.headers, - }, - }); - } - - /** - * Write the credential value for a secret-mechanism account. Idempotent — repeated calls overwrite (rotation). The value is never echoed back through the API. - */ - public static saveIntegrationSecret( - options: Options, - ) { - return (options.client ?? _heyApiClient).post< - SaveIntegrationSecretResponse, - unknown, - ThrowOnError - >({ - url: "/integrations/accounts/{id}/secret", - ...options, - headers: { - "Content-Type": "application/json", - ...options?.headers, - }, - }); - } - - /** - * Mint a long-lived JWT that authorises the Chrome extension to push cookies into the caller’s integration store. Admin UI calls this on behalf of the logged-in user. Mirrors /browser/extension/token but with a narrower scope and no agentId. - */ - public static issueIntegrationExtensionToken< - ThrowOnError extends boolean = false, - >(options: Options) { - return (options.client ?? _heyApiClient).post< - IssueIntegrationExtensionTokenResponse, - unknown, - ThrowOnError - >({ - url: "/integrations/extension/token", - ...options, - headers: { - "Content-Type": "application/json", - ...options?.headers, - }, - }); - } - - /** - * Receive cookies harvested from the user’s own Chrome and stash them in the per-user browser-state store, creating the IntegrationAccount on the fly if it doesn’t exist. Authentication: Bearer minted via /integrations/extension/token. - */ - public static importIntegrationExtensionState< - ThrowOnError extends boolean = false, - >(options: Options) { - return (options.client ?? _heyApiClient).post< - ImportIntegrationExtensionStateResponse, - unknown, - ThrowOnError - >({ - url: "/integrations/extension/import-state", - ...options, - headers: { - "Content-Type": "application/json", - ...options?.headers, - }, - }); - } - - /** - * Download the Ranch Cookies Chrome extension as a zip — load it unpacked in chrome://extensions/. Path source: EXTENSION_DIR env var, falls back to ranch-repo-root/extension/. - */ - public static downloadIntegrationExtension< - ThrowOnError extends boolean = false, - >(options?: Options) { - return (options?.client ?? _heyApiClient).get< - unknown, - unknown, - ThrowOnError - >({ - url: "/integrations/extension/download", - ...options, - }); - } + /** + * List supported services. Static — defined in api/src/slices/integration/domain/catalogue.ts and shipped with each API release. + */ + public static listIntegrationCatalogue(options?: Options) { + return (options?.client ?? _heyApiClient).get({ + url: '/integrations/catalogue', + ...options + }); + } + + /** + * List the calling user’s connected integration accounts. + */ + public static listIntegrationAccounts(options?: Options) { + return (options?.client ?? _heyApiClient).get({ + url: '/integrations/accounts', + ...options + }); + } + + /** + * Start (or re-open) a connection. Idempotent on (userId, service, accountKey) — repeated calls return the existing row. Status stays "pending" until the underlying credential is stored (browser cookies harvested or secret value saved). + */ + public static connectIntegration(options: Options) { + return (options.client ?? _heyApiClient).post({ + url: '/integrations/accounts', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + }); + } + + /** + * Disconnect an integration. Browser-mechanism: also deletes the underlying BrowserSession (profile cleanup runs on its own CronJob). Secret-mechanism: also wipes the stored value from the per-user secret store. + */ + public static disconnectIntegration(options: Options) { + return (options.client ?? _heyApiClient).delete({ + url: '/integrations/accounts/{id}', + ...options + }); + } + + /** + * Fetch one account. Refreshes status from the underlying BrowserSession before returning. 404 if not owned by the caller. + */ + public static getIntegrationAccount(options: Options) { + return (options.client ?? _heyApiClient).get({ + url: '/integrations/accounts/{id}', + ...options + }); + } + + /** + * Return the help URL + login URL + textual instructions the agent forwards to the end user. Browser-mechanism only. No VNC — the user logs in inside their own Chrome and pushes cookies via the Ranch extension. Flips status to "needs_login" so the admin UI reflects the pending state. + */ + public static openIntegrationLogin(options: Options) { + return (options.client ?? _heyApiClient).post({ + url: '/integrations/accounts/{id}/login', + ...options + }); + } + + /** + * Alternative to VNC login: accept a Playwright storageState dump that the user harvested from their own browser. The runtime falls back to this state when no per-agent state file exists, so one import covers every agent the user owns. Browser-mechanism only. + */ + public static importIntegrationCookies(options: Options) { + return (options.client ?? _heyApiClient).post({ + url: '/integrations/accounts/{id}/import-cookies', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + }); + } + + /** + * Write the credential value for a secret-mechanism account. Idempotent — repeated calls overwrite (rotation). The value is never echoed back through the API. + */ + public static saveIntegrationSecret(options: Options) { + return (options.client ?? _heyApiClient).post({ + url: '/integrations/accounts/{id}/secret', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + }); + } + + /** + * Mint a long-lived JWT that authorises the Chrome extension to push cookies into the caller’s integration store. Admin UI calls this on behalf of the logged-in user. Mirrors /browser/extension/token but with a narrower scope and no agentId. + */ + public static issueIntegrationExtensionToken(options: Options) { + return (options.client ?? _heyApiClient).post({ + url: '/integrations/extension/token', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + }); + } + + /** + * Receive cookies harvested from the user’s own Chrome and stash them in the per-user browser-state store, creating the IntegrationAccount on the fly if it doesn’t exist. Authentication: Bearer minted via /integrations/extension/token. + */ + public static importIntegrationExtensionState(options: Options) { + return (options.client ?? _heyApiClient).post({ + url: '/integrations/extension/import-state', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + }); + } + + /** + * Download the Ranch Cookies Chrome extension as a zip — load it unpacked in chrome://extensions/. Path source: EXTENSION_DIR env var, falls back to ranch-repo-root/extension/. + */ + public static downloadIntegrationExtension(options?: Options) { + return (options?.client ?? _heyApiClient).get({ + url: '/integrations/extension/download', + ...options + }); + } + } export class IntegrationsInternalService { - /** - * Resolve every connected secret-mechanism integration into a flat env map. Called lazily by the runtime per tool invocation — picks up rotated keys without an agent restart. Multiple accounts on one service: most recently updated wins the bare env var; each one is also exposed under a per-accountKey alias. - */ - public static resolveIntegrationSecrets( - options?: Options, - ) { - return (options?.client ?? _heyApiClient).get< - ResolveIntegrationSecretsResponse, - unknown, - ThrowOnError - >({ - url: "/integrations/internal/secrets", - ...options, - }); - } - - /** - * List every integration in the instance. The runtime’s integration_list tool exposes this so an agent passes the exact browser_play profile instead of guessing. - */ - public static listIntegrationAccountsForRuntime< - ThrowOnError extends boolean = false, - >(options?: Options) { - return (options?.client ?? _heyApiClient).get< - ListIntegrationAccountsForRuntimeResponse, - unknown, - ThrowOnError - >({ - url: "/integrations/internal/accounts", - ...options, - }); - } - - /** - * Resolve login instructions for a (service, accountKey) pair. The runtime calls this when an agent tool returns `needsLogin` — replaces the legacy VNC mint flow. Returns the help URL + site URL the agent should forward to the end user. - */ - public static requestIntegrationLogin( - options: Options, - ) { - return (options.client ?? _heyApiClient).post< - RequestIntegrationLoginResponse, - unknown, - ThrowOnError - >({ - url: "/integrations/internal/request-login", - ...options, - headers: { - "Content-Type": "application/json", - ...options?.headers, - }, - }); - } - - /** - * Look up the Playwright storageState for a given profile. Runtime hits this when no per-agent state file exists locally. 404 if nothing was imported for this profile. - */ - public static resolveIntegrationBrowserState< - ThrowOnError extends boolean = false, - >(options: Options) { - return (options.client ?? _heyApiClient).get< - unknown, - unknown, - ThrowOnError - >({ - url: "/integrations/internal/browser-state", - ...options, - }); - } + /** + * Resolve every connected secret-mechanism integration into a flat env map. Called lazily by the runtime per tool invocation — picks up rotated keys without an agent restart. Multiple accounts on one service: most recently updated wins the bare env var; each one is also exposed under a per-accountKey alias. + */ + public static resolveIntegrationSecrets(options?: Options) { + return (options?.client ?? _heyApiClient).get({ + url: '/integrations/internal/secrets', + ...options + }); + } + + /** + * List every integration in the instance. The runtime’s integration_list tool exposes this so an agent passes the exact browser_play profile instead of guessing. + */ + public static listIntegrationAccountsForRuntime(options?: Options) { + return (options?.client ?? _heyApiClient).get({ + url: '/integrations/internal/accounts', + ...options + }); + } + + /** + * Resolve login instructions for a (service, accountKey) pair. The runtime calls this when an agent tool returns `needsLogin` — replaces the legacy VNC mint flow. Returns the help URL + site URL the agent should forward to the end user. + */ + public static requestIntegrationLogin(options: Options) { + return (options.client ?? _heyApiClient).post({ + url: '/integrations/internal/request-login', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + }); + } + + /** + * Look up the Playwright storageState for a given profile. Runtime hits this when no per-agent state file exists locally. 404 if nothing was imported for this profile. + */ + public static resolveIntegrationBrowserState(options: Options) { + return (options.client ?? _heyApiClient).get({ + url: '/integrations/internal/browser-state', + ...options + }); + } + } export class McpService { - public static sseControllerSse( - options?: Options, - ) { - return (options?.client ?? _heyApiClient).get< - unknown, - unknown, - ThrowOnError - >({ - url: "/mcp/sse", - ...options, - }); - } - - public static sseControllerMessages( - options?: Options, - ) { - return (options?.client ?? _heyApiClient).post< - unknown, - unknown, - ThrowOnError - >({ - url: "/mcp/messages", - ...options, - }); - } - - public static sseControllerDebugSessions< - ThrowOnError extends boolean = false, - >(options: Options) { - return (options.client ?? _heyApiClient).get< - unknown, - unknown, - ThrowOnError - >({ - url: "/teams/{teamId}/sessions/debug", - ...options, - }); - } + public static sseControllerSse(options?: Options) { + return (options?.client ?? _heyApiClient).get({ + url: '/mcp/sse', + ...options + }); + } + + public static sseControllerMessages(options?: Options) { + return (options?.client ?? _heyApiClient).post({ + url: '/mcp/messages', + ...options + }); + } + + public static sseControllerDebugSessions(options: Options) { + return (options.client ?? _heyApiClient).get({ + url: '/teams/{teamId}/sessions/debug', + ...options + }); + } + } export class StreamableHttpService { - public static streamableHttpControllerHandleDeleteRequest< - ThrowOnError extends boolean = false, - >( - options?: Options< - StreamableHttpControllerHandleDeleteRequestData, - ThrowOnError - >, - ) { - return (options?.client ?? _heyApiClient).delete< - unknown, - unknown, - ThrowOnError - >({ - url: "/mcp/mcp", - ...options, - }); - } - - public static streamableHttpControllerHandleGetRequest< - ThrowOnError extends boolean = false, - >( - options: Options< - StreamableHttpControllerHandleGetRequestData, - ThrowOnError - >, - ) { - return (options.client ?? _heyApiClient).get< - unknown, - unknown, - ThrowOnError - >({ - url: "/mcp/mcp", - ...options, - }); - } - - public static streamableHttpControllerHandlePostRequest< - ThrowOnError extends boolean = false, - >( - options: Options< - StreamableHttpControllerHandlePostRequestData, - ThrowOnError - >, - ) { - return (options.client ?? _heyApiClient).post< - unknown, - unknown, - ThrowOnError - >({ - url: "/mcp/mcp", - ...options, - }); - } -} + public static streamableHttpControllerHandleDeleteRequest(options?: Options) { + return (options?.client ?? _heyApiClient).delete({ + url: '/mcp/mcp', + ...options + }); + } + + public static streamableHttpControllerHandleGetRequest(options: Options) { + return (options.client ?? _heyApiClient).get({ + url: '/mcp/mcp', + ...options + }); + } + + public static streamableHttpControllerHandlePostRequest(options: Options) { + return (options.client ?? _heyApiClient).post({ + url: '/mcp/mcp', + ...options + }); + } + +} \ No newline at end of file diff --git a/admin/slices/setup/api/data/repositories/api/types.gen.ts b/admin/slices/setup/api/data/repositories/api/types.gen.ts index 3fba9123..2ac34a7c 100644 --- a/admin/slices/setup/api/data/repositories/api/types.gen.ts +++ b/admin/slices/setup/api/data/repositories/api/types.gen.ts @@ -1,4941 +1,5264 @@ // This file is auto-generated by @hey-api/openapi-ts export type CreateOwnerDto = { - name: string; - email: string; - password: string; + name: string; + email: string; + password: string; }; export type LoginDto = { - email: string; - password: string; + email: string; + password: string; }; export type RegisterDto = { - name: string; - email: string; - password: string; + name: string; + email: string; + password: string; }; /** * Server filters Owner/Admin out unless the presenting API key carries the embed:mint-admin scope; plain embed keys cannot grant platform-admin to a visitor. */ export enum UserRoleTypes { - OWNER = "Owner", - ADMIN = "Admin", - USER = "User", - AGENT = "Agent", + OWNER = 'Owner', + ADMIN = 'Admin', + USER = 'User', + AGENT = 'Agent' } export type EmbedTokenDto = { - /** - * Subject — used as clientId for routing inside the bridle hub. - */ - sub: string; - email?: string; - /** - * Server filters Owner/Admin out unless the presenting API key carries the embed:mint-admin scope; plain embed keys cannot grant platform-admin to a visitor. - */ - roles?: Array; - /** - * Duration string (s/m/h/d). Defaults to 15m. - */ - expiresIn?: string; + /** + * Subject — used as clientId for routing inside the bridle hub. + */ + sub: string; + email?: string; + /** + * Server filters Owner/Admin out unless the presenting API key carries the embed:mint-admin scope; plain embed keys cannot grant platform-admin to a visitor. + */ + roles?: Array; + /** + * Duration string (s/m/h/d). Defaults to 15m. + */ + expiresIn?: string; }; export type UpsertSettingDto = { - valueType: "string" | "json"; - /** - * String for valueType=string; any JSON for valueType=json - */ - value: { - [key: string]: unknown; - }; + valueType: 'string' | 'json'; + /** + * String for valueType=string; any JSON for valueType=json + */ + value: { + [key: string]: unknown; + }; }; export enum ApiKeyScopeTypes { - "EMBED:MINT" = "embed:mint", - "EMBED:MINT_ADMIN" = "embed:mint-admin", - ADMIN = "admin", + 'EMBED:MINT' = 'embed:mint', + 'EMBED:MINT_ADMIN' = 'embed:mint-admin', + ADMIN = 'admin' } export type CreateApiKeyDto = { - name: string; - scopes: Array; - /** - * ISO date string. Omit for a non-expiring key. - */ - expiresAt?: string; + name: string; + scopes: Array; + /** + * ISO date string. Omit for a non-expiring key. + */ + expiresAt?: string; }; export type CreateLlmCredentialDto = { - provider: string; - model: string; - apiKey: string; - fallbackModel?: string; - label?: string; - status?: "active" | "disabled"; - supportsChat?: boolean; - supportsEmbedding?: boolean; + provider: string; + model: string; + apiKey: string; + fallbackModel?: string; + label?: string; + status?: 'active' | 'disabled'; + supportsChat?: boolean; + supportsEmbedding?: boolean; }; export type UpdateLlmCredentialDto = { - provider?: string; - model?: string; - apiKey?: string; - fallbackModel?: string; - label?: string; - status?: "active" | "disabled"; - supportsChat?: boolean; - supportsEmbedding?: boolean; + provider?: string; + model?: string; + apiKey?: string; + fallbackModel?: string; + label?: string; + status?: 'active' | 'disabled'; + supportsChat?: boolean; + supportsEmbedding?: boolean; }; export type LlmHealthCheckResultDto = { - ok: boolean; - latencyMs: number; - provider: string; - model: string; - error?: string; + ok: boolean; + latencyMs: number; + provider: string; + model: string; + error?: string; }; export type CreateTemplateDto = { - name: string; - description: string; - image: string; - defaultConfig?: { - [key: string]: unknown; - }; - defaultResources?: { - [key: string]: unknown; - }; - paddockConfig?: { - [key: string]: unknown; - }; - defaultKnowledgeIds?: Array; + name: string; + description: string; + image: string; + defaultConfig?: { + [key: string]: unknown; + }; + defaultResources?: { + [key: string]: unknown; + }; + paddockConfig?: { + [key: string]: unknown; + }; + defaultKnowledgeIds?: Array; }; export type UpdateTemplateDto = { - name?: string; - description?: string; - image?: string; - defaultConfig?: { - [key: string]: unknown; - }; - defaultResources?: { - [key: string]: unknown; - }; - paddockConfig?: { - [key: string]: unknown; - }; - defaultKnowledgeIds?: Array; + name?: string; + description?: string; + image?: string; + defaultConfig?: { + [key: string]: unknown; + }; + defaultResources?: { + [key: string]: unknown; + }; + paddockConfig?: { + [key: string]: unknown; + }; + defaultKnowledgeIds?: Array; }; export type SetTemplateSkillsDto = { - /** - * Full list of skill IDs to attach. Replaces any prior set. - */ - skillIds: Array; + /** + * Full list of skill IDs to attach. Replaces any prior set. + */ + skillIds: Array; }; export type SetTemplateMcpsDto = { - /** - * Full list of MCP server IDs to attach. Replaces any prior set. - */ - mcpServerIds: Array; + /** + * Full list of MCP server IDs to attach. Replaces any prior set. + */ + mcpServerIds: Array; }; export type CreateMcpServerDto = { - name: string; - description?: { - [key: string]: unknown; - }; - url: string; - transport?: "streamableHttp" | "sse"; - authType?: "none" | "bearer" | "header"; - authValue?: { - [key: string]: unknown; - }; - enabled?: boolean; + name: string; + description?: { + [key: string]: unknown; + }; + url: string; + transport?: 'streamableHttp' | 'sse'; + authType?: 'none' | 'bearer' | 'header' | 'oauth'; + authValue?: { + [key: string]: unknown; + }; + enabled?: boolean; }; export type UpdateMcpServerDto = { - name?: string; - description?: { - [key: string]: unknown; - }; - url?: string; - transport?: "streamableHttp" | "sse"; - authType?: "none" | "bearer" | "header"; - authValue?: { - [key: string]: unknown; - }; - enabled?: boolean; + name?: string; + description?: { + [key: string]: unknown; + }; + url?: string; + transport?: 'streamableHttp' | 'sse'; + authType?: 'none' | 'bearer' | 'header' | 'oauth'; + authValue?: { + [key: string]: unknown; + }; + enabled?: boolean; }; export type KnowledgeListItemDto = { - id: string; - name: string; - description: string | null; - /** - * Derived from the sources: empty (nothing added), indexing (a source is being processed), partial (some sources are not searchable), ready (every source answers). - */ - indexStatus: "idle" | "indexing" | "ready" | "failed" | "empty" | "partial"; - indexError: string | null; - indexedAt: string | null; - indexStartedAt: string | null; - /** - * Sources attached to this knowledge - */ - sourceCount: number; - /** - * Sources LightRAG confirmed as processed - */ - indexedCount: number; - /** - * Sources whose last index run recorded an error - */ - failedCount: number; - /** - * Sources handed to LightRAG that it has not finished processing. A ready knowledge with a non-zero count is searchable but not complete yet; run Index again once the pipeline drains. - */ - processingCount: number; - /** - * True while the index run that set `indexing` is still executing in the API. False with `indexing` means the run is gone (rejected, timed out, or lost to a restart) and a new one may be started at once. - */ - indexRunAlive: boolean; - instanceState: "absent" | "starting" | "ready" | "failed" | "stopping"; - instanceError: string | null; - migrationState: "notStarted" | "inProgress" | "done" | "failed"; - createdAt: string; - updatedAt: string; - sourcesCount: number; - totalSizeBytes: number; + id: string; + name: string; + description: string | null; + /** + * Derived from the sources: empty (nothing added), indexing (a source is being processed), partial (some sources are not searchable), ready (every source answers). + */ + indexStatus: 'idle' | 'indexing' | 'ready' | 'failed' | 'empty' | 'partial'; + indexError: string | null; + indexedAt: string | null; + indexStartedAt: string | null; + /** + * Sources attached to this knowledge + */ + sourceCount: number; + /** + * Sources LightRAG confirmed as processed + */ + indexedCount: number; + /** + * Sources whose last index run recorded an error + */ + failedCount: number; + /** + * Sources handed to LightRAG that it has not finished processing. A ready knowledge with a non-zero count is searchable but not complete yet; run Index again once the pipeline drains. + */ + processingCount: number; + /** + * True while the index run that set `indexing` is still executing in the API. False with `indexing` means the run is gone (rejected, timed out, or lost to a restart) and a new one may be started at once. + */ + indexRunAlive: boolean; + instanceState: 'absent' | 'starting' | 'ready' | 'failed' | 'stopping'; + instanceError: string | null; + migrationState: 'notStarted' | 'inProgress' | 'done' | 'failed'; + createdAt: string; + updatedAt: string; + sourcesCount: number; + totalSizeBytes: number; }; export type KnowledgePageDto = { - items: Array; - total: number; - page: number; - perPage: number; + items: Array; + total: number; + page: number; + perPage: number; }; export type GraphLabelsDto = { - labels: Array; - total: number; - truncated: boolean; + labels: Array; + total: number; + truncated: boolean; }; export type GraphNodeDto = { - id: string; - label: string; - entityType: string; - description: string; + id: string; + label: string; + entityType: string; + description: string; }; export type GraphEdgeDto = { - id: string; - source: string; - target: string; - weight: number; - keywords: string; - description: string; + id: string; + source: string; + target: string; + weight: number; + keywords: string; + description: string; }; export type GraphDto = { - nodes: Array; - edges: Array; - isTruncated: boolean; + nodes: Array; + edges: Array; + isTruncated: boolean; }; export type SourceTypeCountsDto = { - file: number; - url: number; - text: number; + file: number; + url: number; + text: number; }; export type KnowledgeOverviewDto = { - /** - * Sources attached to this knowledge - */ - sourceCount: number; - /** - * Sources LightRAG confirmed as processed - */ - indexedCount: number; - failedCount: number; - /** - * Handed to LightRAG and still in its pipeline - */ - processingCount: number; - byType: SourceTypeCountsDto; - /** - * Sum of the stored files, in bytes - */ - totalSizeBytes: number; + /** + * Sources attached to this knowledge + */ + sourceCount: number; + /** + * Sources LightRAG confirmed as processed + */ + indexedCount: number; + failedCount: number; + /** + * Handed to LightRAG and still in its pipeline + */ + processingCount: number; + byType: SourceTypeCountsDto; + /** + * Sum of the stored files, in bytes + */ + totalSizeBytes: number; }; export type CreateKnowledgeDto = { - name: string; - description?: string; + name: string; + description?: string; }; export type UpdateKnowledgeDto = { - name?: string; - description?: string | null; + name?: string; + description?: string | null; }; export type QueryKnowledgeDto = { - query: string; - mode?: "hybrid" | "local" | "global" | "naive"; - topK?: number; + query: string; + mode?: 'hybrid' | 'local' | 'global' | 'naive'; + topK?: number; }; export type KnowledgeQueryReferenceDto = { - referenceId: string; - filePath: string; - sourceId: string | null; - sourceName: string | null; + referenceId: string; + filePath: string; + sourceId: string | null; + sourceName: string | null; }; export type KnowledgeQueryResultDto = { - /** - * null when the base holds nothing relevant — see reason. Never a generated answer assembled from another base. - */ - answer: string | null; - reason?: "no_relevant_content"; - knowledgeId: string; - /** - * false while this base is still being re-processed into its own area — answers may be incomplete. - */ - complete: boolean; - references: Array; + /** + * null when the base holds nothing relevant — see reason. Never a generated answer assembled from another base. + */ + answer: string | null; + reason?: 'no_relevant_content'; + knowledgeId: string; + /** + * false while this base is still being re-processed into its own area — answers may be incomplete. + */ + complete: boolean; + references: Array; }; export type SourceDto = { - id: string; - knowledgeId: string; - type: "file" | "url" | "text"; - name: string; - url: string | null; - mimeType: string | null; - content: string | null; - sizeBytes: number | null; - /** - * True when indexStatus is "indexed". Kept for older callers. - */ - indexed: boolean; - indexStatus: "indexed" | "pending" | "failed"; - indexState: "queued" | "processing" | "indexed" | "failed"; - /** - * Error from the last index run, null once the source indexes. - */ - indexError: string | null; - indexedAt: string | null; - /** - * Text extraction for a PDF without a text layer: none (not a PDF, or it has its own text), pending (probing or OCR running), ready (recognised text is what gets indexed), failed (see textError). - */ - textState: "none" | "pending" | "ready" | "failed"; - textError: string | null; - createdAt: string; - updatedAt: string; + id: string; + knowledgeId: string; + type: 'file' | 'url' | 'text'; + name: string; + url: string | null; + mimeType: string | null; + content: string | null; + sizeBytes: number | null; + /** + * True when indexStatus is "indexed". Kept for older callers. + */ + indexed: boolean; + indexStatus: 'indexed' | 'pending' | 'failed'; + indexState: 'queued' | 'processing' | 'indexed' | 'failed'; + /** + * Error from the last index run, null once the source indexes. + */ + indexError: string | null; + indexedAt: string | null; + /** + * Text extraction for a PDF without a text layer: none (not a PDF, or it has its own text), pending (probing or OCR running), ready (recognised text is what gets indexed), failed (see textError). + */ + textState: 'none' | 'pending' | 'ready' | 'failed'; + textError: string | null; + createdAt: string; + updatedAt: string; }; export type SourcePageDto = { - items: Array; - /** - * Rows matching the filter across all pages - */ - total: number; - page: number; - perPage: number; + items: Array; + /** + * Rows matching the filter across all pages + */ + total: number; + page: number; + perPage: number; }; export type ImportJobDto = { - id: string; - knowledgeId: string; - kind: "archive" | "extraction"; - status: "running" | "done" | "failed"; - /** - * Ingestable entries found up front - */ - detected: number; - added: number; - /** - * Entries skipped because a source with that name exists - */ - skipped: number; - failed: number; - /** - * First failures as ": ", capped - */ - errors: Array; - startedAt: string; - finishedAt: string | null; + id: string; + knowledgeId: string; + kind: 'archive' | 'extraction'; + status: 'running' | 'done' | 'failed'; + /** + * Ingestable entries found up front + */ + detected: number; + added: number; + /** + * Entries skipped because a source with that name exists + */ + skipped: number; + failed: number; + /** + * First failures as ": ", capped + */ + errors: Array; + startedAt: string; + finishedAt: string | null; }; export type CreateSourceDto = { - type: "file" | "url" | "text"; - name: string; - url?: string; - content?: string; + type: 'file' | 'url' | 'text'; + name: string; + url?: string; + content?: string; }; export type AddFilesResultDto = { - /** - * Files uploaded and registered. - */ - added: number; - /** - * Files skipped because a file source with the same name already exists on this knowledge. - */ - skipped: number; - /** - * Files that failed to upload. - */ - failed: number; - /** - * One line per failed file. - */ - errors: Array; + /** + * Files uploaded and registered. + */ + added: number; + /** + * Files skipped because a file source with the same name already exists on this knowledge. + */ + skipped: number; + /** + * Files that failed to upload. + */ + failed: number; + /** + * One line per failed file. + */ + errors: Array; }; export type AddFromSitemapDto = { - sitemapUrl: string; - urlPrefix?: string; + sitemapUrl: string; + urlPrefix?: string; }; export type AddFromSitemapResultDto = { - added: number; - discovered: number; + added: number; + discovered: number; }; export type AddFromArchiveResultDto = { - /** - * Number of ingestable files detected in the archive. Import runs in the background; poll GET .../sources/imports for progress. - */ - detected: number; - started: boolean; - /** - * Id of the background import job (see GET .../sources/imports) - */ - jobId: string; + /** + * Number of ingestable files detected in the archive. Import runs in the background; poll GET .../sources/imports for progress. + */ + detected: number; + started: boolean; + /** + * Id of the background import job (see GET .../sources/imports) + */ + jobId: string; }; export type AgentDto = { - id: string; - name: string; - templateId: string; - llmCredentialId?: { - [key: string]: unknown; - } | null; - status: - | "pending" - | "deploying" - | "running" - | "failed" - | "stopped" - | "unreachable"; - /** - * Human-readable reason accompanying status='failed' or 'unreachable' (e.g. "startup did not produce a running agent within 5 minutes", "ImagePullBackOff", "pod is running but the runtime never connected to the bridle hub…"). Also set during 'deploying' when bridle integration settings are empty. Null otherwise. - */ - statusReason: string | null; - workflowId: { - [key: string]: unknown; - } | null; - /** - * When this agent was first successfully deployed. Null ⇒ the agent has never been deployed. - */ - firstDeployedAt: string | null; - /** - * When the current/last deploy was started. Anchor of the server-side deploy grace window. - */ - lastDeployStartedAt: string | null; - /** - * Why the current/last deploy ran: 'initial' = first-ever start, 'restart' = any subsequent deploy (restart, start after stop, config-change redeploy). Null only for agents never deployed since this field existed. - */ - launchContext: "initial" | "restart"; - /** - * When the running pod last pulled its working copy of the agent files from S3 (recorded at runtime boot). Null ⇒ agent not restarted since this field shipped. Files-tab freshness hint + sync-conflict baseline. - */ - lastPullAt: string | null; - /** - * When the last successful Sync push completed. Null ⇒ never synced since this field shipped. - */ - lastSyncAt: string | null; - config: { - [key: string]: unknown; - }; - resources: { - [key: string]: unknown; - }; - /** - * When true, the agent runtime emits prompt-debug snapshots to admin clients via the bridle hub. - */ - debugEnabled: boolean; - /** - * When true, the agent is visible on the public landing page to unauthenticated visitors. - */ - isPublic: boolean; - /** - * Origins (scheme + host + port) authorized to open browser WebSockets to this bot without a JWT. Only consulted when isPublic=true. - */ - allowedOrigins: Array; - knowledgeIds: Array; - isAdmin: boolean; - createdAt: string; - updatedAt: string; + id: string; + name: string; + templateId: string; + llmCredentialId?: { + [key: string]: unknown; + } | null; + status: 'pending' | 'deploying' | 'running' | 'failed' | 'stopped' | 'unreachable'; + /** + * Human-readable reason accompanying status='failed' or 'unreachable' (e.g. "startup did not produce a running agent within 5 minutes", "ImagePullBackOff", "pod is running but the runtime never connected to the bridle hub…"). Also set during 'deploying' when bridle integration settings are empty. Null otherwise. + */ + statusReason: string | null; + workflowId: { + [key: string]: unknown; + } | null; + /** + * When this agent was first successfully deployed. Null ⇒ the agent has never been deployed. + */ + firstDeployedAt: string | null; + /** + * When the current/last deploy was started. Anchor of the server-side deploy grace window. + */ + lastDeployStartedAt: string | null; + /** + * Why the current/last deploy ran: 'initial' = first-ever start, 'restart' = any subsequent deploy (restart, start after stop, config-change redeploy). Null only for agents never deployed since this field existed. + */ + launchContext: 'initial' | 'restart'; + /** + * When the running pod last pulled its working copy of the agent files from S3 (recorded at runtime boot). Null ⇒ agent not restarted since this field shipped. Files-tab freshness hint + sync-conflict baseline. + */ + lastPullAt: string | null; + /** + * When the last successful Sync push completed. Null ⇒ never synced since this field shipped. + */ + lastSyncAt: string | null; + config: { + [key: string]: unknown; + }; + resources: { + [key: string]: unknown; + }; + /** + * When true, the agent runtime emits prompt-debug snapshots to admin clients via the bridle hub. + */ + debugEnabled: boolean; + /** + * When true, the agent is visible on the public landing page to unauthenticated visitors. + */ + isPublic: boolean; + /** + * Origins (scheme + host + port) authorized to open browser WebSockets to this bot without a JWT. Only consulted when isPublic=true. + */ + allowedOrigins: Array; + knowledgeIds: Array; + isAdmin: boolean; + createdAt: string; + updatedAt: string; }; export type AgentPodStatusDto = { - agentId: string; - podName: string; - phase: "Pending" | "Running" | "Succeeded" | "Failed" | "Unknown"; - ready: boolean; - restartCount: number; - startedAt: string | null; - lastTerminationReason: string | null; - containerWaitingReason: string | null; - message: string | null; - observedAt: string; + agentId: string; + podName: string; + phase: 'Pending' | 'Running' | 'Succeeded' | 'Failed' | 'Unknown'; + ready: boolean; + restartCount: number; + startedAt: string | null; + lastTerminationReason: string | null; + containerWaitingReason: string | null; + message: string | null; + observedAt: string; }; export type AgentStatusDto = { - /** - * Agent DB record (id, name, status, launchContext, etc.) - */ - agent: AgentDto; - /** - * Live pod status; null if no pod is currently running for this agent. - */ - pod: AgentPodStatusDto | null; - /** - * Whether the agent runtime currently holds a live connection to the bridle hub. In-memory truth of the API process — false for a few seconds after an API restart until runtimes reconnect. - */ - bridleConnected: boolean; + /** + * Agent DB record (id, name, status, launchContext, etc.) + */ + agent: AgentDto; + /** + * Live pod status; null if no pod is currently running for this agent. + */ + pod: AgentPodStatusDto | null; + /** + * Whether the agent runtime currently holds a live connection to the bridle hub. In-memory truth of the API process — false for a few seconds after an API restart until runtimes reconnect. + */ + bridleConnected: boolean; }; export type NodeCapacityDto = { - name: string; - /** - * Allocatable CPU minus summed pod requests, in millicores - */ - freeCpuMilli: number; - /** - * Allocatable memory minus summed pod requests, in bytes - */ - freeMemBytes: number; - /** - * How many more agent pods fit on this node - */ - freeSlots: number; + name: string; + /** + * Allocatable CPU minus summed pod requests, in millicores + */ + freeCpuMilli: number; + /** + * Allocatable memory minus summed pod requests, in bytes + */ + freeMemBytes: number; + /** + * How many more agent pods fit on this node + */ + freeSlots: number; }; export type ClusterCapacityDto = { - /** - * How many more agents can start right now, across all agent nodes - */ - freeAgentSlots: number; - /** - * Agents currently holding a slot (live pods + deploying) - */ - usedAgentSlots: number; - /** - * usedAgentSlots + freeAgentSlots under current cluster load - */ - totalAgentSlots: number; - /** - * CPU request one agent slot reserves, in millicores - */ - slotCpuMilli: number; - /** - * Memory request one agent slot reserves, in bytes - */ - slotMemBytes: number; - nodes: Array; - observedAt: string; + /** + * How many more agents can start right now, across all agent nodes + */ + freeAgentSlots: number; + /** + * Agents currently holding a slot (live pods + deploying) + */ + usedAgentSlots: number; + /** + * usedAgentSlots + freeAgentSlots under current cluster load + */ + totalAgentSlots: number; + /** + * CPU request one agent slot reserves, in millicores + */ + slotCpuMilli: number; + /** + * Memory request one agent slot reserves, in bytes + */ + slotMemBytes: number; + nodes: Array; + observedAt: string; }; export type AgentPodMetricsDto = { - /** - * Current CPU usage in millicores - */ - cpuMilli: number; - /** - * Current memory usage in bytes - */ - memBytes: number; - /** - * CPU limit in millicores - */ - cpuLimitMilli: number; - /** - * Memory limit in bytes - */ - memLimitBytes: number; + /** + * Current CPU usage in millicores + */ + cpuMilli: number; + /** + * Current memory usage in bytes + */ + memBytes: number; + /** + * CPU limit in millicores + */ + cpuLimitMilli: number; + /** + * Memory limit in bytes + */ + memLimitBytes: number; }; export type AgentNodeMetricsDto = { - name: string; - diskAvailBytes: number; - diskCapacityBytes: number; + name: string; + diskAvailBytes: number; + diskCapacityBytes: number; }; export type AgentMetricsDto = { - pod: AgentPodMetricsDto; - node: AgentNodeMetricsDto; + pod: AgentPodMetricsDto; + node: AgentNodeMetricsDto; }; export type AgentEnvVarDto = { - name: string; - value: string; + name: string; + value: string; }; export type AgentMcpDto = { - /** - * Unique MCP server name (key in the runtime registry). - */ - name: string; - /** - * Transport protocol the runtime should use to connect. - */ - transport: "streamableHttp" | "sse"; - /** - * MCP server endpoint URL. - */ - url: string; - /** - * Auth scheme for the connection. - */ - authType: "none" | "bearer" | "header"; - /** - * Auth credential. For `bearer`: raw token (runtime adds the `Bearer ` prefix). For `header`: literal `Header-Name: value` line. `null` when authType is `none`. - */ - authValue: string | null; - /** - * Always `true` in this list — disabled servers are filtered server-side. Kept for forward compatibility. - */ - enabled: boolean; + /** + * MCP server id. For `oauth` servers the runtime keys the per-agent token secret by this id (`mcpOauth:`). + */ + id: string; + /** + * Unique MCP server name (key in the runtime registry). + */ + name: string; + /** + * Transport protocol the runtime should use to connect. + */ + transport: 'streamableHttp' | 'sse'; + /** + * MCP server endpoint URL. + */ + url: string; + /** + * Auth scheme. For `oauth` the runtime holds no static credential — it refreshes its own bearer from the per-agent token secret keyed by `id`. + */ + authType: 'none' | 'bearer' | 'header' | 'oauth'; + /** + * Auth credential. For `bearer`: raw token (runtime adds the `Bearer ` prefix). For `header`: literal `Header-Name: value` line. `null` when authType is `none`. + */ + authValue: string | null; + /** + * Always `true` in this list — disabled servers are filtered server-side. Kept for forward compatibility. + */ + enabled: boolean; }; export type AgentResourcesDto = { - cpu: string; - memory: string; + cpu: string; + memory: string; }; export type CreateAgentDto = { - name: string; - templateId: string; - llmCredentialId?: string; - config?: { - [key: string]: unknown; - }; - resources?: AgentResourcesDto; - /** - * When true, the agent is visible on the public landing page to unauthenticated visitors. - */ - isPublic?: boolean; - /** - * Origins (scheme + host + port) authorized to open browser WebSockets to this bot without a JWT. Only consulted when isPublic=true. - */ - allowedOrigins?: Array; - knowledgeIds?: Array; - /** - * When true, the agent is created as the Ranch admin on first deploy: any existing admin is demoted (and redeployed without RANCH_ADMIN), and this agent boots with RANCH_ADMIN=true + a service token. Single-admin invariant is enforced. - */ - isAdmin?: boolean; + name: string; + templateId: string; + llmCredentialId?: string; + config?: { + [key: string]: unknown; + }; + resources?: AgentResourcesDto; + /** + * When true, the agent is visible on the public landing page to unauthenticated visitors. + */ + isPublic?: boolean; + /** + * Origins (scheme + host + port) authorized to open browser WebSockets to this bot without a JWT. Only consulted when isPublic=true. + */ + allowedOrigins?: Array; + knowledgeIds?: Array; + /** + * When true, the agent is created as the Ranch admin on first deploy: any existing admin is demoted (and redeployed without RANCH_ADMIN), and this agent boots with RANCH_ADMIN=true + a service token. Single-admin invariant is enforced. + */ + isAdmin?: boolean; }; export type UpdateAgentDto = { - name?: string; - templateId?: string; - llmCredentialId?: string; - config?: { - [key: string]: unknown; - }; - resources?: AgentResourcesDto; - /** - * When true, the agent is visible on the public landing page to unauthenticated visitors. - */ - isPublic?: boolean; - /** - * Origins (scheme + host + port) authorized to open browser WebSockets to this bot without a JWT. Only consulted when isPublic=true. - */ - allowedOrigins?: Array; - knowledgeIds?: Array; - /** - * When true, the agent is created as the Ranch admin on first deploy: any existing admin is demoted (and redeployed without RANCH_ADMIN), and this agent boots with RANCH_ADMIN=true + a service token. Single-admin invariant is enforced. - */ - isAdmin?: boolean; - /** - * Enable debug mode — emits the prompt-debug event stream and verbose pod logs (LOG_LEVEL=debug). The verbose-log half applies on the next agent restart; the prompt-debug stream flips live. - */ - debugEnabled?: boolean; + name?: string; + templateId?: string; + llmCredentialId?: string; + config?: { + [key: string]: unknown; + }; + resources?: AgentResourcesDto; + /** + * When true, the agent is visible on the public landing page to unauthenticated visitors. + */ + isPublic?: boolean; + /** + * Origins (scheme + host + port) authorized to open browser WebSockets to this bot without a JWT. Only consulted when isPublic=true. + */ + allowedOrigins?: Array; + knowledgeIds?: Array; + /** + * When true, the agent is created as the Ranch admin on first deploy: any existing admin is demoted (and redeployed without RANCH_ADMIN), and this agent boots with RANCH_ADMIN=true + a service token. Single-admin invariant is enforced. + */ + isAdmin?: boolean; + /** + * Enable debug mode — emits the prompt-debug event stream and verbose pod logs (LOG_LEVEL=debug). The verbose-log half applies on the next agent restart; the prompt-debug stream flips live. + */ + debugEnabled?: boolean; }; export type FileChunkDto = { - path: string; - /** - * UTF-8 slice of the file from `offset`. - */ - content: string; - /** - * Byte length of `content`. - */ - size: number; - /** - * Full byte length of the file. - */ - totalSize: number; - /** - * Byte offset of the first byte of `content`. - */ - offset: number; - /** - * Pass as `offset` on the next request. `null` when there is no more data. - */ - nextOffset: number | null; - hasMore: boolean; - updatedAt: string; + path: string; + /** + * UTF-8 slice of the file from `offset`. + */ + content: string; + /** + * Byte length of `content`. + */ + size: number; + /** + * Full byte length of the file. + */ + totalSize: number; + /** + * Byte offset of the first byte of `content`. + */ + offset: number; + /** + * Pass as `offset` on the next request. `null` when there is no more data. + */ + nextOffset: number | null; + hasMore: boolean; + updatedAt: string; }; export type SaveFileDto = { - /** - * Full file content as text - */ - content: string; + /** + * Full file content as text + */ + content: string; }; export type DeleteFilesDto = { - /** - * Number of S3 objects deleted by this request. - */ - deleted: number; + /** + * Number of S3 objects deleted by this request. + */ + deleted: number; }; export type SyncFilesBodyDto = { - /** - * Set to true to run the sync even when at-risk files were reported (the operator explicitly accepted the overwrite risk). Without it a non-empty at-risk list makes the endpoint answer 409 and skip the sync. - */ - confirm?: boolean; + /** + * Set to true to run the sync even when at-risk files were reported (the operator explicitly accepted the overwrite risk). Without it a non-empty at-risk list makes the endpoint answer 409 and skip the sync. + */ + confirm?: boolean; }; export type AtRiskFileDto = { - path: string; - /** - * When the S3 (shared) copy of this file was last modified - */ - updatedAt: string; + path: string; + /** + * When the S3 (shared) copy of this file was last modified + */ + updatedAt: string; }; export type SyncConflictDto = { - /** - * Always true: the sync was NOT executed — resend with confirm=true to proceed - */ - requiresConfirmation: boolean; - /** - * S3 files modified after the pod last pulled/pushed. A sync MAY overwrite or delete them if the pod also changed them locally. - */ - atRisk: Array; - /** - * Reference moment the S3 copies were compared against (max of last boot pull minus margin and last completed sync) - */ - baseline: string; + /** + * Always true: the sync was NOT executed — resend with confirm=true to proceed + */ + requiresConfirmation: boolean; + /** + * S3 files modified after the pod last pulled/pushed. A sync MAY overwrite or delete them if the pod also changed them locally. + */ + atRisk: Array; + /** + * Reference moment the S3 copies were compared against (max of last boot pull minus margin and last completed sync) + */ + baseline: string; }; export type BridleTextPartDto = { - type: "text" | "image" | "file"; - text: string; + type: 'text' | 'image' | 'file'; + text: string; }; export type BridleImagePartDto = { - type: "text" | "image" | "file"; - /** - * Base64-encoded image data - */ - base64: string; - /** - * MIME type - */ - mediaType: string; + type: 'text' | 'image' | 'file'; + /** + * Base64-encoded image data + */ + base64: string; + /** + * MIME type + */ + mediaType: string; }; export type SendMessageDto = { - /** - * Message text (plain-text shorthand) - */ - text: string; - /** - * Rich content parts. If omitted, built from text + images. - */ - parts?: Array; - /** - * Attached images (legacy — prefer parts) - */ - images?: Array; - /** - * Ids from POST /api/agent/{agentId}/attachment. The API expands them server-side into parts — images as image content, text files with their contents inlined into the message, everything else as a named reference — and appends them to whatever `parts` resolved to. Omit the field and the request behaves exactly as before. - */ - attachmentIds?: Array; + /** + * Message text (plain-text shorthand) + */ + text: string; + /** + * Rich content parts. If omitted, built from text + images. + */ + parts?: Array; + /** + * Attached images (legacy — prefer parts) + */ + images?: Array; + /** + * Ids from POST /api/agent/{agentId}/attachment. The API expands them server-side into parts — images as image content, text files with their contents inlined into the message, everything else as a named reference — and appends them to whatever `parts` resolved to. Omit the field and the request behaves exactly as before. + */ + attachmentIds?: Array; }; export type BridleAttachmentDto = { - /** - * Attachment id, also the storage key stem - */ - id: string; - /** - * Original filename, for display - */ - name: string; - /** - * Resolved MIME type - */ - mimeType: string; - /** - * Size in bytes - */ - size: number; - /** - * How the attachment reaches the agent: image content, inlined text, or a named reference - */ - kind: "image" | "text" | "binary"; - /** - * Path of the authenticated download route. Never a direct storage URL. - */ - url: string; - /** - * False when the agent will see only the file name, not its contents - */ - readableByAgent: boolean; + /** + * Attachment id, also the storage key stem + */ + id: string; + /** + * Original filename, for display + */ + name: string; + /** + * Resolved MIME type + */ + mimeType: string; + /** + * Size in bytes + */ + size: number; + /** + * How the attachment reaches the agent: image content, inlined text, or a named reference + */ + kind: 'image' | 'text' | 'binary'; + /** + * Path of the authenticated download route. Never a direct storage URL. + */ + url: string; + /** + * False when the agent will see only the file name, not its contents + */ + readableByAgent: boolean; }; export type BridleHealthDto = { - ok: boolean; - /** - * Whether any agent runtime is connected via WebSocket - */ - agentConnected: boolean; - /** - * Number of browser clients connected - */ - browserClients: number; + ok: boolean; + /** + * Whether any agent runtime is connected via WebSocket + */ + agentConnected: boolean; + /** + * Number of browser clients connected + */ + browserClients: number; }; export type BridleAgentHealthDto = { - ok: boolean; - /** - * Whether this agent is connected via WebSocket - */ - agentConnected: boolean; - /** - * Number of browser clients connected to this agent - */ - browserClients: number; - /** - * Bot identifier - */ - agentId: string; + ok: boolean; + /** + * Whether this agent is connected via WebSocket + */ + agentConnected: boolean; + /** + * Number of browser clients connected to this agent + */ + browserClients: number; + /** + * Bot identifier + */ + agentId: string; }; export type TranscriptAttachmentDto = { - id: string; - name: string; - mimeType: string; - size: number; - kind: "image" | "text" | "binary"; + id: string; + name: string; + mimeType: string; + size: number; + kind: 'image' | 'text' | 'binary'; }; export type TranscriptMessageDto = { - id: string; - role: "user" | "assistant"; - /** - * For user messages: what the person typed. Attachment contents the API inlined for the model are not included — see `agentText`. - */ - text: string; - /** - * Unix epoch milliseconds. - */ - ts: number; - /** - * Stored-attachment references for files sent with this message. Fetch the bytes via GET /api/agent/{agentId}/attachment/{id}. - */ - attachments?: Array; - /** - * User messages with attachments only: the full text the model received (typed text plus the inlined attachment blocks). For inspection; not meant to be rendered as the bubble. - */ - agentText?: string; + id: string; + role: 'user' | 'assistant'; + /** + * For user messages: what the person typed. Attachment contents the API inlined for the model are not included — see `agentText`. + */ + text: string; + /** + * Unix epoch milliseconds. + */ + ts: number; + /** + * Stored-attachment references for files sent with this message. Fetch the bytes via GET /api/agent/{agentId}/attachment/{id}. + */ + attachments?: Array; + /** + * User messages with attachments only: the full text the model received (typed text plus the inlined attachment blocks). For inspection; not meant to be rendered as the bubble. + */ + agentText?: string; }; export type TranscriptResponseDto = { - messages: Array; - /** - * Channel the transcript was loaded from. - */ - channel: string; - /** - * Pass back as `cursor` to fetch the previous page. `null` when no older messages. - */ - nextCursor: string | null; - hasMore: boolean; + messages: Array; + /** + * Channel the transcript was loaded from. + */ + channel: string; + /** + * Pass back as `cursor` to fetch the previous page. `null` when no older messages. + */ + nextCursor: string | null; + hasMore: boolean; }; export type ShareLinkDto = { - /** - * True while the link accepts visitors. False when the agent was never shared or the link has been revoked. - */ - active: boolean; - /** - * The share secret. Exposed only while the link is active — a revoked token is dead and is never handed back, so this is null whenever active is false. - */ - token: string | null; - /** - * When the link row was first created; null if never shared. - */ - createdAt: string | null; - /** - * When the link was revoked; null while it is active. - */ - revokedAt: string | null; - /** - * When the token was last replaced; null until the first regenerate. - */ - rotatedAt: string | null; - /** - * How many times the token has been replaced. - */ - rotationCount: number; + /** + * True while the link accepts visitors. False when the agent was never shared or the link has been revoked. + */ + active: boolean; + /** + * The share secret. Exposed only while the link is active — a revoked token is dead and is never handed back, so this is null whenever active is false. + */ + token: string | null; + /** + * When the link row was first created; null if never shared. + */ + createdAt: string | null; + /** + * When the link was revoked; null while it is active. + */ + revokedAt: string | null; + /** + * When the token was last replaced; null until the first regenerate. + */ + rotatedAt: string | null; + /** + * How many times the token has been replaced. + */ + rotationCount: number; }; export type ShareResolveRequestDto = { - /** - * The share token from the link (`sl_` + 43 url-safe characters). - */ - token: string; + /** + * The share token from the link (`sl_` + 43 url-safe characters). + */ + token: string; }; export type ShareResolvedDto = { - /** - * Id of the shared agent — used for the chat requests. - */ - agentId: string; - /** - * Display name of the shared agent. - */ - agentName: string; - /** - * The agent's persisted status (running | unreachable | deploying | stopped | failed | …). 'running' means the chat is live. - */ - agentStatus: string; + /** + * Id of the shared agent — used for the chat requests. + */ + agentId: string; + /** + * Display name of the shared agent. + */ + agentName: string; + /** + * The agent's persisted status (running | unreachable | deploying | stopped | failed | …). 'running' means the chat is live. + */ + agentStatus: string; }; export type ImportSkillUrlDto = { - /** - * GitHub URL — folder (tree//) or file (blob//). The folder must contain a SKILL.md or README.md. - */ - url: string; - name?: string; - /** - * If true and a skill with the same slug already exists, fully replace it instead of returning 409. - */ - overwrite?: boolean; + /** + * GitHub URL — folder (tree//) or file (blob//). The folder must contain a SKILL.md or README.md. + */ + url: string; + name?: string; + /** + * If true and a skill with the same slug already exists, fully replace it instead of returning 409. + */ + overwrite?: boolean; }; export type ImportSkillDto = { - /** - * GitHub owner/repo as returned by /skills/search - */ - repo: string; - /** - * Path to the SKILL.md file inside the repo - */ - path: string; - /** - * Override the auto-derived slug. Lowercase letters, digits and dashes. - */ - name?: string; - /** - * If true and a skill with the same slug already exists, fully replace it instead of returning 409. - */ - overwrite?: boolean; + /** + * GitHub owner/repo as returned by /skills/search + */ + repo: string; + /** + * Path to the SKILL.md file inside the repo + */ + path: string; + /** + * Override the auto-derived slug. Lowercase letters, digits and dashes. + */ + name?: string; + /** + * If true and a skill with the same slug already exists, fully replace it instead of returning 409. + */ + overwrite?: boolean; }; export type CreateSkillDto = { - /** - * Unique slug — lowercase letters, digits, dashes - */ - name: string; - title: string; - /** - * Markdown body of the skill - */ - body: string; - description?: string; + /** + * Unique slug — lowercase letters, digits, dashes + */ + name: string; + title: string; + /** + * Markdown body of the skill + */ + body: string; + description?: string; }; export type UpdateSkillDto = { - /** - * Unique slug — lowercase letters, digits, dashes - */ - name?: string; - title?: string; - /** - * Markdown body of the skill - */ - body?: string; - description?: string; + /** + * Unique slug — lowercase letters, digits, dashes + */ + name?: string; + title?: string; + /** + * Markdown body of the skill + */ + body?: string; + description?: string; }; export type ChatSessionDto = { - id: string; - agentId: string; - channel: "bridle" | "telegram" | "slack" | "internal"; - externalUserId: string; - sessionKey: string; - title?: { - [key: string]: unknown; - } | null; - /** - * Last message text, truncated - */ - preview?: { - [key: string]: unknown; - } | null; - lastRole?: "user" | "assistant"; - /** - * Unix ms via ISO - */ - lastMessageAt: string; - /** - * Monotonic lifetime total - */ - messageCount: number; - userMessageCount: number; - summary?: { - [key: string]: unknown; - } | null; - summaryAt?: { - [key: string]: unknown; - } | null; - /** - * topics/sentiment/resolved/language - */ - insights?: { - [key: string]: unknown; - } | null; - archived: boolean; - createdAt: string; - updatedAt: string; + id: string; + agentId: string; + channel: 'bridle' | 'telegram' | 'slack' | 'internal'; + externalUserId: string; + sessionKey: string; + title?: { + [key: string]: unknown; + } | null; + /** + * Last message text, truncated + */ + preview?: { + [key: string]: unknown; + } | null; + lastRole?: 'user' | 'assistant'; + /** + * Unix ms via ISO + */ + lastMessageAt: string; + /** + * Monotonic lifetime total + */ + messageCount: number; + userMessageCount: number; + summary?: { + [key: string]: unknown; + } | null; + summaryAt?: { + [key: string]: unknown; + } | null; + /** + * topics/sentiment/resolved/language + */ + insights?: { + [key: string]: unknown; + } | null; + archived: boolean; + createdAt: string; + updatedAt: string; }; export type ChatListResponseDto = { - items: Array; - total: number; - page: number; - perPage: number; + items: Array; + total: number; + page: number; + perPage: number; }; export type ChatMessageDto = { - id: string; - role: - | "user" - | "assistant" - | "summary" - | "tool_call" - | "tool_result" - | "system"; - /** - * For user messages: what the person typed, without the attachment contents the API inlined for the model. - */ - text: string; - /** - * Unix epoch ms - */ - ts: number; - /** - * Files sent with this message (metadata only; history has no download route). - */ - attachments?: Array; - /** - * Admin debug views only (present when `types` includes tool events): the full text the model received for a user message with attachments. - */ - agentText?: string; + id: string; + role: 'user' | 'assistant' | 'summary' | 'tool_call' | 'tool_result' | 'system'; + /** + * For user messages: what the person typed, without the attachment contents the API inlined for the model. + */ + text: string; + /** + * Unix epoch ms + */ + ts: number; + /** + * Files sent with this message (metadata only; history has no download route). + */ + attachments?: Array; + /** + * Admin debug views only (present when `types` includes tool events): the full text the model received for a user message with attachments. + */ + agentText?: string; }; export type ChatMessagesResponseDto = { - messages: Array; - /** - * Pass to fetch the previous (older) page - */ - nextCursor?: { - [key: string]: unknown; - } | null; - hasMore: boolean; + messages: Array; + /** + * Pass to fetch the previous (older) page + */ + nextCursor?: { + [key: string]: unknown; + } | null; + hasMore: boolean; }; export type SyncChatsDto = { - /** - * Reconcile only this agent; omit for all agents - */ - agentId?: string; + /** + * Reconcile only this agent; omit for all agents + */ + agentId?: string; }; export type SyncChatsResponseDto = { - scannedAgents: number; - scannedFiles: number; - upserted: number; - skipped: number; + scannedAgents: number; + scannedFiles: number; + upserted: number; + skipped: number; }; export type CreateChatFeedbackDto = { - /** - * Event.id of the rated assistant message - */ - messageId: string; - /** - * 1 = 👍, -1 = 👎 - */ - rating: 1 | -1; - comment?: string; + /** + * Event.id of the rated assistant message + */ + messageId: string; + /** + * 1 = 👍, -1 = 👎 + */ + rating: 1 | -1; + comment?: string; }; export type ChatFeedbackDto = { - id: string; - messageId: string; - rating: 1 | -1; - comment?: { - [key: string]: unknown; - } | null; - source: string; - authorId?: { - [key: string]: unknown; - } | null; - createdAt: string; + id: string; + messageId: string; + rating: 1 | -1; + comment?: { + [key: string]: unknown; + } | null; + source: string; + authorId?: { + [key: string]: unknown; + } | null; + createdAt: string; }; export type TelegramChannelConfigDto = { - /** - * Telegram bot HTTP API token (issued by @BotFather). - */ - botToken: string; - /** - * Public bot username without @ — shown on landing pages. - */ - botName?: string; - /** - * Comma-separated Telegram chat IDs treated as bot admins by the runtime. - */ - adminIds?: string; + /** + * Telegram bot HTTP API token (issued by @BotFather). + */ + botToken: string; + /** + * Public bot username without @ — shown on landing pages. + */ + botName?: string; + /** + * Comma-separated Telegram chat IDs treated as bot admins by the runtime. + */ + adminIds?: string; }; export type AgentChannelDto = { - /** - * Channel type. Discriminator — config shape depends on this. v1 only telegram. - */ - type: "telegram"; - config: TelegramChannelConfigDto; - /** - * Live state reported by the runtime (data/channels/status.json). true = polling/connected, false = last start attempt failed (see statusReason), null = unknown (no status reported yet). Read-only — ignored on PUT. - */ - connected?: boolean | null; - /** - * Failure reason when connected=false (e.g. an invalid token). Read-only. - */ - statusReason?: string | null; - /** - * Unix ms of the last status change. Read-only. - */ - statusUpdatedAt?: number | null; + /** + * Channel type. Discriminator — config shape depends on this. v1 only telegram. + */ + type: 'telegram'; + config: TelegramChannelConfigDto; + /** + * Live state reported by the runtime (data/channels/status.json). true = polling/connected, false = last start attempt failed (see statusReason), null = unknown (no status reported yet). Read-only — ignored on PUT. + */ + connected?: boolean | null; + /** + * Failure reason when connected=false (e.g. an invalid token). Read-only. + */ + statusReason?: string | null; + /** + * Unix ms of the last status change. Read-only. + */ + statusUpdatedAt?: number | null; }; export type SetAgentChannelsDto = { - /** - * Replace the full set of channels. Pass [] to clear all channels. - */ - channels: Array; + /** + * Replace the full set of channels. Pass [] to clear all channels. + */ + channels: Array; }; export enum AssignableUserRoleTypes { - ADMIN = "Admin", - USER = "User", + ADMIN = 'Admin', + USER = 'User' } export type CreateUserDto = { - name: string; - email: string; - password: string; - role?: AssignableUserRoleTypes; + name: string; + email: string; + password: string; + role?: AssignableUserRoleTypes; }; export type UpdateUserDto = { - name?: string; - email?: string; - password?: string; - status?: "active" | "invited" | "disabled"; + name?: string; + email?: string; + password?: string; + status?: 'active' | 'invited' | 'disabled'; }; export type UpdateUserRoleDto = { - role: AssignableUserRoleTypes; + role: AssignableUserRoleTypes; }; export type SaveTemplateFileDto = { - /** - * Full file content as text - */ - content: string; + /** + * Full file content as text + */ + content: string; }; export type InstallDeclaredSkillDto = { - id: string; - resolved: boolean; + id: string; + resolved: boolean; }; export type InstallDeclaredMcpDto = { - id: string; - resolved: boolean; + id: string; + resolved: boolean; }; export type InstallDeclaredSecretDto = { - name: string; - required: boolean; + name: string; + required: boolean; }; export type InstallDeclaredDto = { - skills: Array; - mcp: Array; - secrets: Array; + skills: Array; + mcp: Array; + secrets: Array; }; export type InstallFileCountsDto = { - agentFiles: number; - scenarioFiles: number; + agentFiles: number; + scenarioFiles: number; }; export type InstallPreviewDto = { - /** - * The parsed manifest. Returned as raw JSON so the UI can render any field without typed coupling. - */ - manifest: { - [key: string]: unknown; - }; - willCreate: boolean; - willUpgrade: boolean; - existingTemplateId?: string; - declared: InstallDeclaredDto; - files: InstallFileCountsDto; - warnings: Array; + /** + * The parsed manifest. Returned as raw JSON so the UI can render any field without typed coupling. + */ + manifest: { + [key: string]: unknown; + }; + willCreate: boolean; + willUpgrade: boolean; + existingTemplateId?: string; + declared: InstallDeclaredDto; + files: InstallFileCountsDto; + warnings: Array; }; export type InstallResultDto = { - templateId: string; - templateName: string; - filesUploaded: number; - scenariosSeeded: number; - mcpAttached: Array; - skillsAttached: Array; - unresolvedMcp: Array; - unresolvedSkills: Array; - warnings: Array; + templateId: string; + templateName: string; + filesUploaded: number; + scenariosSeeded: number; + mcpAttached: Array; + skillsAttached: Array; + unresolvedMcp: Array; + unresolvedSkills: Array; + warnings: Array; }; export type InstallFromGitDto = { - /** - * Git URL — https://, http://, git@host:..., or ssh://host/.../repo.git - */ - gitUrl: string; - /** - * Optional ref — branch, tag, or short SHA. Defaults to the remote default branch. - */ - gitRef?: string; - /** - * Operator-supplied params (e.g. {"language":"ru"}). Validated against the manifest at install time. - */ - params?: { - [key: string]: unknown; - }; - /** - * Operator-supplied secrets (e.g. {"MCP_RANCH_AUTH":"sk-..."}). Used to resolve $secret:NAME references in the manifest at install time (currently for mcp[].authValue). Never echoed back. - */ - secrets?: { - [key: string]: unknown; - }; + /** + * Git URL — https://, http://, git@host:..., or ssh://host/.../repo.git + */ + gitUrl: string; + /** + * Optional ref — branch, tag, or short SHA. Defaults to the remote default branch. + */ + gitRef?: string; + /** + * Operator-supplied params (e.g. {"language":"ru"}). Validated against the manifest at install time. + */ + params?: { + [key: string]: unknown; + }; + /** + * Operator-supplied secrets (e.g. {"MCP_RANCH_AUTH":"sk-..."}). Used to resolve $secret:NAME references in the manifest at install time (currently for mcp[].authValue). Never echoed back. + */ + secrets?: { + [key: string]: unknown; + }; }; export type CreatePaddockScenarioMessageDto = { - text: string; - from: string; - delayMs?: number; + text: string; + from: string; + delayMs?: number; }; export type CreatePaddockSuccessCriterionDto = { - dimension: - | "correctness" - | "tool_usage" - | "soul_compliance" - | "response_quality" - | "error_handling"; - description: string; - weight: number; + dimension: 'correctness' | 'tool_usage' | 'soul_compliance' | 'response_quality' | 'error_handling'; + description: string; + weight: number; }; export type CreatePaddockScenarioSetupDto = { - files?: { - [key: string]: string; - }; - env?: { - [key: string]: string; - }; - tools?: Array; + files?: { + [key: string]: string; + }; + env?: { + [key: string]: string; + }; + tools?: Array; }; export type CreatePaddockScenarioDto = { - templateId?: string | null; - agentId?: string | null; - category: - | "tool_use" - | "memory" - | "conversation" - | "patching_workflow" - | "edge_case" - | "multi_turn" - | "error_recovery"; - difficulty: "easy" | "medium" | "hard" | "adversarial"; - name: string; - description: string; - expectedBehavior: string; - messages: Array; - successCriteria: Array; - setup?: CreatePaddockScenarioSetupDto | null; + templateId?: string | null; + agentId?: string | null; + category: 'tool_use' | 'memory' | 'conversation' | 'patching_workflow' | 'edge_case' | 'multi_turn' | 'error_recovery'; + difficulty: 'easy' | 'medium' | 'hard' | 'adversarial'; + name: string; + description: string; + expectedBehavior: string; + messages: Array; + successCriteria: Array; + setup?: CreatePaddockScenarioSetupDto | null; }; export type GeneratePaddockScenarioDto = { - /** - * Free-form description of the problem / behavior the user wants to test. - */ - description: string; - templateId?: string | null; - agentId?: string | null; - category?: - | "tool_use" - | "memory" - | "conversation" - | "patching_workflow" - | "edge_case" - | "multi_turn" - | "error_recovery"; - difficulty?: "easy" | "medium" | "hard" | "adversarial"; - /** - * Optional LlmCredential id; if omitted, the first active Anthropic credential is used. - */ - credentialId?: string; + /** + * Free-form description of the problem / behavior the user wants to test. + */ + description: string; + templateId?: string | null; + agentId?: string | null; + category?: 'tool_use' | 'memory' | 'conversation' | 'patching_workflow' | 'edge_case' | 'multi_turn' | 'error_recovery'; + difficulty?: 'easy' | 'medium' | 'hard' | 'adversarial'; + /** + * Optional LlmCredential id; if omitted, the first active Anthropic credential is used. + */ + credentialId?: string; }; export type UpdatePaddockScenarioDto = { - category?: - | "tool_use" - | "memory" - | "conversation" - | "patching_workflow" - | "edge_case" - | "multi_turn" - | "error_recovery"; - difficulty?: "easy" | "medium" | "hard" | "adversarial"; - name?: string; - description?: string; - expectedBehavior?: string; - messages?: Array; - successCriteria?: Array; - setup?: CreatePaddockScenarioSetupDto | null; + category?: 'tool_use' | 'memory' | 'conversation' | 'patching_workflow' | 'edge_case' | 'multi_turn' | 'error_recovery'; + difficulty?: 'easy' | 'medium' | 'hard' | 'adversarial'; + name?: string; + description?: string; + expectedBehavior?: string; + messages?: Array; + successCriteria?: Array; + setup?: CreatePaddockScenarioSetupDto | null; +}; + +export type AgentInterfaceDto = { + /** + * Where another agent sends tasks for this one. + */ + url: string; + /** + * Transport binding. + */ + protocolBinding: string; + /** + * A2A protocol version. + */ + protocolVersion: string; +}; + +export type AgentCapabilitiesDto = { + /** + * Whether the agent streams partial answers. Ranch: false. + */ + streaming?: boolean; + /** + * Whether the agent can call back when a task finishes. Ranch: false. + */ + pushNotifications?: boolean; +}; + +export type AgentSkillDto = { + /** + * Stable id of the skill. Prefixed by where it came from: `skill:` for a template skill, `knowledge:` for a bound knowledge base. + */ + id: string; + /** + * Short name. + */ + name: string; + /** + * What this lets the agent do, written so another agent can decide when to ask. + */ + description: string; + /** + * Origin tags: `skill` or `knowledge`. + */ + tags: Array; +}; + +export type AgentCardDto = { + name: string; + /** + * The agent's own description, falling back to its template's. + */ + description: string; + /** + * The template's version. + */ + version: string; + supportedInterfaces: Array; + capabilities: AgentCapabilitiesDto; + defaultInputModes: Array; + defaultOutputModes: Array; + /** + * One entry per template skill and per bound knowledge base. May be empty: an agent with nothing to advertise still has a valid card. + */ + skills: Array; +}; + +export type AgentPeerDto = { + /** + * Id of the connection, not of either agent. + */ + id: string; + /** + * The agent that holds the card (the caller). + */ + agentId: string; + /** + * The agent whose card is held (the peer). + */ + peerAgentId: string; + /** + * The peer's current name. Falls back to the name on the stored card when the agent itself is gone. + */ + peerName: string; + /** + * The peer's live agent status. 'running' means a delegation can succeed right now; anything else means it would fail fast. + */ + peerStatus: string; + /** + * False when the peer agent no longer exists in this installation. + */ + peerExists: boolean; + /** + * The card as read at connect time or at the last refresh — NOT a live read. The delegating agent reasons from this snapshot, so a peer editing its description mid-turn cannot change behaviour until someone presses Refresh. + */ + card: AgentCardDto; + /** + * Where the snapshot was read from. + */ + cardUrl: string; + /** + * When the snapshot was taken. + */ + cardReadAt: string; + createdAt: string; +}; + +export type AgentPeerCandidateDto = { + id: string; + name: string; + /** + * Live agent status. + */ + status: string; + /** + * True when this agent is already a peer — shown as connected rather than offered again. + */ + connected: boolean; +}; + +export type ConnectPeerDto = { + /** + * The agent to connect. Must be another agent of this installation: foreign card URLs are not accepted in this feature, and an agent cannot be its own peer. + */ + peerAgentId: string; +}; + +export type AgentDelegationDto = { + id: string; + /** + * The peer that was asked. + */ + peerAgentId: string; + peerName: string; + /** + * The self-contained task the peer received. + */ + task: string; + /** + * The calling model's one-line reason for choosing this peer. + */ + reason: string; + /** + * waiting | answered | failed | rejected. + */ + status: string; + /** + * Why it did not produce an answer: PEER_NOT_RUNNING, PEER_TIMEOUT, PEER_REJECTED_LOOP, PEER_REJECTED_DEPTH, PEER_UNAUTHORIZED, PEER_UNREACHABLE or PEER_ERROR. Null while waiting and on success. + */ + errorCode: string | null; + /** + * The first part of the reply, or the cause in product wording. + */ + excerpt: string | null; + startedAt: string; + finishedAt: string | null; + durationMs: number | null; }; export type SecretEntryDto = { - name: string; - value: string; - updatedAt: string | null; + name: string; + value: string; + updatedAt: string | null; }; export type SecretListDto = { - provider: "aws" | "file"; - secrets: Array; + provider: 'aws' | 'file'; + secrets: Array; }; export type SetSecretDto = { - /** - * Secret key. Upsert: an existing key is overwritten, a new key is created. - */ - key: string; - /** - * Secret value to store. - */ - value: string; + /** + * Secret key. Upsert: an existing key is overwritten, a new key is created. + */ + key: string; + /** + * Secret value to store. + */ + value: string; }; export type DeleteSecretDto = { - /** - * Secret key to delete. No-op if the key does not exist. - */ - key: string; + /** + * Secret key to delete. No-op if the key does not exist. + */ + key: string; }; export type ReplaceSecretsDto = { - /** - * Full secret store for the agent — replaces everything. Pass {} to clear. Mirrors AWS Secrets Manager's plaintext-edit semantics. - */ - store: { - [key: string]: string; - }; + /** + * Full secret store for the agent — replaces everything. Pass {} to clear. Mirrors AWS Secrets Manager's plaintext-edit semantics. + */ + store: { + [key: string]: string; + }; }; export type ReportUsageDto = { - date: string; - /** - * Per-model usage. Key is the canonical model name (e.g. claude-sonnet-4-6). - */ - byModel: { - [key: string]: unknown; - }; + date: string; + /** + * Per-model usage. Key is the canonical model name (e.g. claude-sonnet-4-6). + */ + byModel: { + [key: string]: unknown; + }; +}; + +export type StartMcpOauthDto = { + /** + * Agent that will own the connection. The stored refresh token is scoped to this agent. + */ + agentId: string; +}; + +export type StartMcpOauthResultDto = { + /** + * Authorization URL to hand the user. They open it, log in at the provider, and the callback stores the token. + */ + authorizeUrl: string; +}; + +export type McpOauthStatusDto = { + connected: boolean; }; export type RunPaddockJudgeOverrideDto = { - credentialIds?: Array; - threshold?: number; - maxLlmCalls?: number; - maxTimeMs?: number; + credentialIds?: Array; + threshold?: number; + maxLlmCalls?: number; + maxTimeMs?: number; }; export type RunPaddockEvaluationDto = { - agentId: string; - /** - * Optional subset of scenario IDs. If omitted, runs the agent’s template scenarios merged with agent overrides. - */ - scenarioIds?: Array; - judgeOverride?: RunPaddockJudgeOverrideDto; + agentId: string; + /** + * Optional subset of scenario IDs. If omitted, runs the agent’s template scenarios merged with agent overrides. + */ + scenarioIds?: Array; + judgeOverride?: RunPaddockJudgeOverrideDto; }; export type BrowserSessionDto = { - id: string; - userId: string; - accountKey: string; - status: "idle" | "active" | "needs_login" | "expired" | "stuck"; - lastUsedAt: string; - createdAt: string; - updatedAt: string; + id: string; + userId: string; + accountKey: string; + status: 'idle' | 'active' | 'needs_login' | 'expired' | 'stuck'; + lastUsedAt: string; + createdAt: string; + updatedAt: string; }; export type OpenSessionDto = { - /** - * Account identifier scoped to the calling user, e.g. "instagram:miybot" or "paypal:main". Must match /^[a-zA-Z0-9_:\-]+$/ — anything else is rejected before it can touch the profile filesystem path. - */ - accountKey: string; - /** - * Page to navigate to in the pre-warmed Chrome. Drives what the VNC viewer renders when the user opens the returned vncUrl — set this to the service's login page (e.g. "https://www.instagram.com/accounts/login/"). Defaults to about:blank if omitted; the user can still type a URL in the address bar. - */ - loginUrl?: string; + /** + * Account identifier scoped to the calling user, e.g. "instagram:miybot" or "paypal:main". Must match /^[a-zA-Z0-9_:\-]+$/ — anything else is rejected before it can touch the profile filesystem path. + */ + accountKey: string; + /** + * Page to navigate to in the pre-warmed Chrome. Drives what the VNC viewer renders when the user opens the returned vncUrl — set this to the service's login page (e.g. "https://www.instagram.com/accounts/login/"). Defaults to about:blank if omitted; the user can still type a URL in the address bar. + */ + loginUrl?: string; }; export type BrowserSessionConnectionDto = { - session: BrowserSessionDto; - /** - * CDP WebSocket URL. Carries a single-use launch payload and the pool token — never expose to UI, only to authenticated runtime calls. - */ - cdpUrl: string; - /** - * Live VNC URL (signed JWT, 15 min TTL). Send to the end user when they need to finish a 2FA/CAPTCHA flow manually. - */ - vncUrl?: { - [key: string]: unknown; - } | null; + session: BrowserSessionDto; + /** + * CDP WebSocket URL. Carries a single-use launch payload and the pool token — never expose to UI, only to authenticated runtime calls. + */ + cdpUrl: string; + /** + * Live VNC URL (signed JWT, 15 min TTL). Send to the end user when they need to finish a 2FA/CAPTCHA flow manually. + */ + vncUrl?: { + [key: string]: unknown; + } | null; }; export type SetStatusDto = { - status: "idle" | "active" | "needs_login" | "expired" | "stuck"; + status: 'idle' | 'active' | 'needs_login' | 'expired' | 'stuck'; }; export type OpenInternalSessionDto = { - /** - * Account identifier scoped to the calling user, e.g. "instagram:miybot" or "paypal:main". Must match /^[a-zA-Z0-9_:\-]+$/ — anything else is rejected before it can touch the profile filesystem path. - */ - accountKey: string; - /** - * Page to navigate to in the pre-warmed Chrome. Drives what the VNC viewer renders when the user opens the returned vncUrl — set this to the service's login page (e.g. "https://www.instagram.com/accounts/login/"). Defaults to about:blank if omitted; the user can still type a URL in the address bar. - */ - loginUrl?: string; - /** - * Owning user. Trusted only because the bridle key gates this endpoint — the runtime forwards ctx.from from the authenticated chat session. - */ - userId: string; + /** + * Account identifier scoped to the calling user, e.g. "instagram:miybot" or "paypal:main". Must match /^[a-zA-Z0-9_:\-]+$/ — anything else is rejected before it can touch the profile filesystem path. + */ + accountKey: string; + /** + * Page to navigate to in the pre-warmed Chrome. Drives what the VNC viewer renders when the user opens the returned vncUrl — set this to the service's login page (e.g. "https://www.instagram.com/accounts/login/"). Defaults to about:blank if omitted; the user can still type a URL in the address bar. + */ + loginUrl?: string; + /** + * Owning user. Trusted only because the bridle key gates this endpoint — the runtime forwards ctx.from from the authenticated chat session. + */ + userId: string; }; export type InternalSetStatusDto = { - status: "idle" | "active" | "needs_login" | "expired" | "stuck"; - userId: string; + status: 'idle' | 'active' | 'needs_login' | 'expired' | 'stuck'; + userId: string; }; export type InternalSessionRefDto = { - userId: string; + userId: string; }; export type IssueExtensionTokenDto = { - /** - * Agent the extension token will be scoped to. The token only allows writing state files under this agent's S3 prefix. - */ - agentId: string; - /** - * User ID (within the agent context — typically the Telegram user ID for chat-driven agents, or "admin" for the admin agent). Used as the filename prefix so multiple humans can keep separate cookies inside one agent. - */ - userId: string; - /** - * Token lifetime in days. Defaults to 30; max 365. - */ - ttlDays?: number; + /** + * Agent the extension token will be scoped to. The token only allows writing state files under this agent's S3 prefix. + */ + agentId: string; + /** + * User ID (within the agent context — typically the Telegram user ID for chat-driven agents, or "admin" for the admin agent). Used as the filename prefix so multiple humans can keep separate cookies inside one agent. + */ + userId: string; + /** + * Token lifetime in days. Defaults to 30; max 365. + */ + ttlDays?: number; }; export type IssueExtensionTokenResponseDto = { - token: string; - /** - * Unix seconds - */ - exp: number; + token: string; + /** + * Unix seconds + */ + exp: number; }; export type StorageStateCookieDto = { - name: string; - value: string; - domain: string; - path: string; - /** - * Unix seconds, or -1 for session cookie - */ - expires: number; - httpOnly: boolean; - secure: boolean; - sameSite: "Strict" | "Lax" | "None"; + name: string; + value: string; + domain: string; + path: string; + /** + * Unix seconds, or -1 for session cookie + */ + expires: number; + httpOnly: boolean; + secure: boolean; + sameSite: 'Strict' | 'Lax' | 'None'; }; export type ImportStateDto = { - /** - * Profile name. Must match the value the agent will pass as `profile` to browser_play. Sanitized server-side before being used as a filename. - */ - profile: string; - /** - * Cookies in Playwright storageState shape. Re-used 1:1 by the agent runtime when launching Chromium with { storageState: }. - */ - cookies: Array; - /** - * Per-origin localStorage entries. The extension does not collect these today (would need a per-origin content script), but we accept them so future versions can fill them in without an API bump. - */ - origins?: Array; - /** - * User-Agent string of the browser the cookies were exported from. The agent runtime applies this to its Playwright context so Instagram/Facebook/banks see the SAME browser fingerprint as the one that issued the session — a mismatch (Mac Chrome → Linux HeadlessChrome) is the main reason replayed cookies still hit the login page. - */ - userAgent?: string; + /** + * Profile name. Must match the value the agent will pass as `profile` to browser_play. Sanitized server-side before being used as a filename. + */ + profile: string; + /** + * Cookies in Playwright storageState shape. Re-used 1:1 by the agent runtime when launching Chromium with { storageState: }. + */ + cookies: Array; + /** + * Per-origin localStorage entries. The extension does not collect these today (would need a per-origin content script), but we accept them so future versions can fill them in without an API bump. + */ + origins?: Array; + /** + * User-Agent string of the browser the cookies were exported from. The agent runtime applies this to its Playwright context so Instagram/Facebook/banks see the SAME browser fingerprint as the one that issued the session — a mismatch (Mac Chrome → Linux HeadlessChrome) is the main reason replayed cookies still hit the login page. + */ + userAgent?: string; }; export type ImportStateResponseDto = { - ok: boolean; - /** - * Relative path under the agent S3 prefix where the storageState was saved. The runtime sees this as `/` after the next S3 sync tick. - */ - path: string; - cookies: number; + ok: boolean; + /** + * Relative path under the agent S3 prefix where the storageState was saved. The runtime sees this as `/` after the next S3 sync tick. + */ + path: string; + cookies: number; }; export type CatalogueItemDto = { - /** - * Stable service key. Use this in POST /integrations/accounts. - */ - service: string; - title: string; - description: string; - iconUrl: string; - mechanism: "browser" | "secret"; - /** - * Browser-mechanism: URL the pool Chrome navigates to before the user opens the VNC view. - */ - loginUrl?: string; - /** - * Browser-mechanism: hint text the UI shows under the accountKey input. - */ - accountKeyHint?: string; - /** - * Browser-mechanism: domains the Chrome extension auto-matches the current tab against (suffix match). - */ - domains?: Array; - /** - * Secret-mechanism: env var name the runtime exposes to agents. - */ - secretEnvKey?: string; - /** - * Secret-mechanism: short help string the UI shows under the secret input. - */ - secretHelp?: string; + /** + * Stable service key. Use this in POST /integrations/accounts. + */ + service: string; + title: string; + description: string; + iconUrl: string; + mechanism: 'browser' | 'secret'; + /** + * Browser-mechanism: URL the pool Chrome navigates to before the user opens the VNC view. + */ + loginUrl?: string; + /** + * Browser-mechanism: hint text the UI shows under the accountKey input. + */ + accountKeyHint?: string; + /** + * Browser-mechanism: domains the Chrome extension auto-matches the current tab against (suffix match). + */ + domains?: Array; + /** + * Secret-mechanism: env var name the runtime exposes to agents. + */ + secretEnvKey?: string; + /** + * Secret-mechanism: short help string the UI shows under the secret input. + */ + secretHelp?: string; }; export type IntegrationAccountDto = { - id: string; - userId: string; - service: string; - /** - * Account label scoped to (userId, service). For browser-mechanism this is also the BrowserSession.accountKey suffix. - */ - accountKey: string; - mechanism: "browser" | "secret"; - label?: { - [key: string]: unknown; - }; - /** - * pending: row exists but underlying credential not yet stored. connected: credential ready. needs_login: cookies expired or session lost. revoked: user disconnected. - */ - status: "pending" | "connected" | "needs_login" | "revoked"; - createdAt: string; - updatedAt: string; + id: string; + userId: string; + service: string; + /** + * Account label scoped to (userId, service). For browser-mechanism this is also the BrowserSession.accountKey suffix. + */ + accountKey: string; + mechanism: 'browser' | 'secret'; + label?: { + [key: string]: unknown; + }; + /** + * pending: row exists but underlying credential not yet stored. connected: credential ready. needs_login: cookies expired or session lost. revoked: user disconnected. + */ + status: 'pending' | 'connected' | 'needs_login' | 'revoked'; + createdAt: string; + updatedAt: string; }; export type ConnectIntegrationDto = { - /** - * Catalogue key (see GET /integrations/catalogue). - */ - service: string; - /** - * User-chosen label for this account. Becomes part of BrowserSession.accountKey for browser-mechanism services. Same charset rules as browser sessions. - */ - accountKey: string; - /** - * Human-friendly label shown in the admin UI list. - */ - label?: string; + /** + * Catalogue key (see GET /integrations/catalogue). + */ + service: string; + /** + * User-chosen label for this account. Becomes part of BrowserSession.accountKey for browser-mechanism services. Same charset rules as browser sessions. + */ + accountKey: string; + /** + * Human-friendly label shown in the admin UI list. + */ + label?: string; }; export type LoginInstructionDto = { - /** - * IntegrationAccount.id these instructions are for. - */ - accountId: string; - /** - * Direct link to the service login page (catalogue.loginUrl) — the user opens this in their normal browser. - */ - siteUrl: string; - /** - * Admin-UI route that walks the user through the connect flow (install extension if needed, log in on siteUrl, send cookies). - */ - helpUrl: string; - /** - * Plain-text instructions the agent can paraphrase for chat channels where helpUrl is not directly clickable. Three short lines. - */ - instructions: string; + /** + * IntegrationAccount.id these instructions are for. + */ + accountId: string; + /** + * Direct link to the service login page (catalogue.loginUrl) — the user opens this in their normal browser. + */ + siteUrl: string; + /** + * Admin-UI route that walks the user through the connect flow (install extension if needed, log in on siteUrl, send cookies). + */ + helpUrl: string; + /** + * Plain-text instructions the agent can paraphrase for chat channels where helpUrl is not directly clickable. Three short lines. + */ + instructions: string; }; export type ImportCookiesCookieDto = { - name: string; - value: string; - domain: string; - path: string; - /** - * Unix seconds, or -1 for session cookie - */ - expires: number; - httpOnly: boolean; - secure: boolean; - sameSite: "Strict" | "Lax" | "None"; + name: string; + value: string; + domain: string; + path: string; + /** + * Unix seconds, or -1 for session cookie + */ + expires: number; + httpOnly: boolean; + secure: boolean; + sameSite: 'Strict' | 'Lax' | 'None'; }; export type ImportCookiesDto = { - /** - * Cookies in Playwright storageState shape. Either harvested by the Ranch extension or pasted from any third-party cookie-exporter (Cookie-Editor, EditThisCookie, etc.). - */ - cookies: Array; - /** - * Per-origin localStorage entries. Most extensions do not collect these — accepted optionally for future-proofing. - */ - origins?: Array; - /** - * User-Agent of the browser that issued the cookies. CRITICAL — Instagram/Meta invalidate sessions whose UA shifts between captures and replays. The runtime applies this verbatim to its Playwright context. - */ - userAgent?: string; + /** + * Cookies in Playwright storageState shape. Either harvested by the Ranch extension or pasted from any third-party cookie-exporter (Cookie-Editor, EditThisCookie, etc.). + */ + cookies: Array; + /** + * Per-origin localStorage entries. Most extensions do not collect these — accepted optionally for future-proofing. + */ + origins?: Array; + /** + * User-Agent of the browser that issued the cookies. CRITICAL — Instagram/Meta invalidate sessions whose UA shifts between captures and replays. The runtime applies this verbatim to its Playwright context. + */ + userAgent?: string; }; export type SaveSecretDto = { - /** - * Raw credential value (API key, token, etc.). Stored in the per-user secret store under a key derived from (service, accountKey). Never echoed back through the API once written. - */ - value: string; + /** + * Raw credential value (API key, token, etc.). Stored in the per-user secret store under a key derived from (service, accountKey). Never echoed back through the API once written. + */ + value: string; }; export type ResolvedSecretsDto = { - /** - * Map of env-var name → secret value, ready for the runtime to merge into an agent process env. Empty object if the user has no matching secret-mechanism integrations. - */ - env: { - [key: string]: string; - }; + /** + * Map of env-var name → secret value, ready for the runtime to merge into an agent process env. Empty object if the user has no matching secret-mechanism integrations. + */ + env: { + [key: string]: string; + }; }; export type RuntimeAccountDto = { - service: string; - accountKey: string; - /** - * Exact value to pass as `profile` to browser_play (browser-mechanism only). - */ - profile: string; - mechanism: "browser" | "secret"; - status: "pending" | "connected" | "needs_login" | "revoked"; + service: string; + accountKey: string; + /** + * Exact value to pass as `profile` to browser_play (browser-mechanism only). + */ + profile: string; + mechanism: 'browser' | 'secret'; + status: 'pending' | 'connected' | 'needs_login' | 'revoked'; }; export type ListAccountsResponseDto = { - accounts: Array; + accounts: Array; }; export type RequestLoginBodyDto = { - /** - * Catalogue service key the agent wants login instructions for. - */ - service: string; - /** - * AccountKey on this integration. Runtime derives it from the `:` profile string the tool was called with. - */ - accountKey: string; + /** + * Catalogue service key the agent wants login instructions for. + */ + service: string; + /** + * AccountKey on this integration. Runtime derives it from the `:` profile string the tool was called with. + */ + accountKey: string; }; export type IssueIntegrationExtensionTokenDto = { - /** - * Token lifetime in days. Defaults to 30; max 365. - */ - ttlDays?: number; + /** + * Token lifetime in days. Defaults to 30; max 365. + */ + ttlDays?: number; }; export type IssueIntegrationExtensionTokenResponseDto = { - token: string; - /** - * Unix seconds - */ - exp: number; + token: string; + /** + * Unix seconds + */ + exp: number; }; export type ExtensionImportStateDto = { - /** - * Cookies in Playwright storageState shape. Either harvested by the Ranch extension or pasted from any third-party cookie-exporter (Cookie-Editor, EditThisCookie, etc.). - */ - cookies: Array; - /** - * Per-origin localStorage entries. Most extensions do not collect these — accepted optionally for future-proofing. - */ - origins?: Array; - /** - * User-Agent of the browser that issued the cookies. CRITICAL — Instagram/Meta invalidate sessions whose UA shifts between captures and replays. The runtime applies this verbatim to its Playwright context. - */ - userAgent?: string; - /** - * Catalogue service key (auto-detected by the extension from the current tab URL via catalogue.domains). - */ - service: string; - /** - * User-chosen accountKey for this integration. Same charset as POST /integrations/accounts. - */ - accountKey: string; + /** + * Cookies in Playwright storageState shape. Either harvested by the Ranch extension or pasted from any third-party cookie-exporter (Cookie-Editor, EditThisCookie, etc.). + */ + cookies: Array; + /** + * Per-origin localStorage entries. Most extensions do not collect these — accepted optionally for future-proofing. + */ + origins?: Array; + /** + * User-Agent of the browser that issued the cookies. CRITICAL — Instagram/Meta invalidate sessions whose UA shifts between captures and replays. The runtime applies this verbatim to its Playwright context. + */ + userAgent?: string; + /** + * Catalogue service key (auto-detected by the extension from the current tab URL via catalogue.domains). + */ + service: string; + /** + * User-chosen accountKey for this integration. Same charset as POST /integrations/accounts. + */ + accountKey: string; }; export type ExtensionImportStateResponseDto = { - ok: boolean; - /** - * IntegrationAccount id (created or reused). - */ - accountId: string; - service: string; - accountKey: string; - cookies: number; + ok: boolean; + /** + * IntegrationAccount id (created or reused). + */ + accountId: string; + service: string; + accountKey: string; + cookies: number; }; export type HealthControllerCheckData = { - body?: never; - path?: never; - query?: never; - url: "/health"; + body?: never; + path?: never; + query?: never; + url: '/health'; }; export type HealthControllerCheckResponses = { - 200: unknown; + 200: unknown; }; export type InitControllerStatusData = { - body?: never; - path?: never; - query?: never; - url: "/setup/status"; + body?: never; + path?: never; + query?: never; + url: '/setup/status'; }; export type InitControllerStatusResponses = { - 200: unknown; + 200: unknown; }; export type InitControllerInitData = { - body: CreateOwnerDto; - path?: never; - query?: never; - url: "/setup/init"; + body: CreateOwnerDto; + path?: never; + query?: never; + url: '/setup/init'; }; export type InitControllerInitResponses = { - 201: unknown; + 201: unknown; }; export type AuthControllerLoginData = { - body: LoginDto; - path?: never; - query?: never; - url: "/auth/login"; + body: LoginDto; + path?: never; + query?: never; + url: '/auth/login'; }; export type AuthControllerLoginResponses = { - 200: unknown; + 200: unknown; }; export type AuthControllerRegisterData = { - body: RegisterDto; - path?: never; - query?: never; - url: "/auth/register"; + body: RegisterDto; + path?: never; + query?: never; + url: '/auth/register'; }; export type AuthControllerRegisterResponses = { - 200: unknown; + 200: unknown; }; export type AuthControllerRefreshData = { - body?: never; - path?: never; - query?: never; - url: "/auth/refresh"; + body?: never; + path?: never; + query?: never; + url: '/auth/refresh'; }; export type AuthControllerRefreshErrors = { - /** - * The session cookie is missing, unknown, revoked or past its idle/absolute window. Body is `{ code: 'SESSION_MISSING' | 'SESSION_EXPIRED' | 'SESSION_INVALID', message }`. The console shows its session-ended state. - */ - 401: unknown; + /** + * The session cookie is missing, unknown, revoked or past its idle/absolute window. Body is `{ code: 'SESSION_MISSING' | 'SESSION_EXPIRED' | 'SESSION_INVALID', message }`. The console shows its session-ended state. + */ + 401: unknown; }; export type AuthControllerLogoutData = { - body?: never; - path?: never; - query?: never; - url: "/auth/logout"; + body?: never; + path?: never; + query?: never; + url: '/auth/logout'; }; export type AuthControllerLogoutResponses = { - 200: unknown; + 200: unknown; }; export type AuthControllerMeData = { - body?: never; - path?: never; - query?: never; - url: "/auth/me"; + body?: never; + path?: never; + query?: never; + url: '/auth/me'; }; export type AuthControllerMeErrors = { - /** - * Missing, expired or invalid bearer. Body is `{ code: 'TOKEN_MISSING' | 'TOKEN_EXPIRED' | 'TOKEN_INVALID', message }`. Consoles renew via POST /auth/refresh on TOKEN_EXPIRED / TOKEN_INVALID and retry once. - */ - 401: unknown; + /** + * Missing, expired or invalid bearer. Body is `{ code: 'TOKEN_MISSING' | 'TOKEN_EXPIRED' | 'TOKEN_INVALID', message }`. Consoles renew via POST /auth/refresh on TOKEN_EXPIRED / TOKEN_INVALID and retry once. + */ + 401: unknown; }; export type AuthControllerEmbedTokenData = { - body: EmbedTokenDto; - path?: never; - query?: never; - url: "/auth/embed/token"; + body: EmbedTokenDto; + path?: never; + query?: never; + url: '/auth/embed/token'; }; export type AuthControllerEmbedTokenResponses = { - 200: unknown; + 200: unknown; }; export type SettingControllerFindAllData = { - body?: never; - path?: never; - query?: never; - url: "/settings"; + body?: never; + path?: never; + query?: never; + url: '/settings'; }; export type SettingControllerFindAllResponses = { - 200: unknown; + 200: unknown; }; export type SettingControllerFindByGroupData = { - body?: never; - path: { - group: string; - }; - query?: never; - url: "/settings/{group}"; + body?: never; + path: { + group: string; + }; + query?: never; + url: '/settings/{group}'; }; export type SettingControllerFindByGroupResponses = { - 200: unknown; + 200: unknown; }; export type SettingControllerRemoveData = { - body?: never; - path: { - group: string; - name: string; - }; - query?: never; - url: "/settings/{group}/{name}"; + body?: never; + path: { + group: string; + name: string; + }; + query?: never; + url: '/settings/{group}/{name}'; }; export type SettingControllerRemoveResponses = { - 200: unknown; + 200: unknown; }; export type SettingControllerFindByKeyData = { - body?: never; - path: { - group: string; - name: string; - }; - query?: never; - url: "/settings/{group}/{name}"; + body?: never; + path: { + group: string; + name: string; + }; + query?: never; + url: '/settings/{group}/{name}'; }; export type SettingControllerFindByKeyResponses = { - 200: unknown; + 200: unknown; }; export type SettingControllerUpsertData = { - body: UpsertSettingDto; - path: { - group: string; - name: string; - }; - query?: never; - url: "/settings/{group}/{name}"; + body: UpsertSettingDto; + path: { + group: string; + name: string; + }; + query?: never; + url: '/settings/{group}/{name}'; }; export type SettingControllerUpsertResponses = { - 200: unknown; + 200: unknown; }; export type ApiKeyControllerFindAllData = { - body?: never; - path?: never; - query?: never; - url: "/api-keys"; + body?: never; + path?: never; + query?: never; + url: '/api-keys'; }; export type ApiKeyControllerFindAllResponses = { - 200: unknown; + 200: unknown; }; export type ApiKeyControllerCreateData = { - body: CreateApiKeyDto; - path?: never; - query?: never; - url: "/api-keys"; + body: CreateApiKeyDto; + path?: never; + query?: never; + url: '/api-keys'; }; export type ApiKeyControllerCreateResponses = { - 201: unknown; + 201: unknown; }; export type ApiKeyControllerRemoveData = { - body?: never; - path: { - id: string; - }; - query?: never; - url: "/api-keys/{id}"; + body?: never; + path: { + id: string; + }; + query?: never; + url: '/api-keys/{id}'; }; export type ApiKeyControllerRemoveResponses = { - 204: void; + 204: void; }; -export type ApiKeyControllerRemoveResponse = - ApiKeyControllerRemoveResponses[keyof ApiKeyControllerRemoveResponses]; +export type ApiKeyControllerRemoveResponse = ApiKeyControllerRemoveResponses[keyof ApiKeyControllerRemoveResponses]; export type LlmControllerFindAllData = { - body?: never; - path?: never; - query?: never; - url: "/llms"; + body?: never; + path?: never; + query?: never; + url: '/llms'; }; export type LlmControllerFindAllResponses = { - 200: unknown; + 200: unknown; }; export type LlmControllerCreateData = { - body: CreateLlmCredentialDto; - path?: never; - query?: never; - url: "/llms"; + body: CreateLlmCredentialDto; + path?: never; + query?: never; + url: '/llms'; }; export type LlmControllerCreateResponses = { - 201: unknown; + 201: unknown; }; export type LlmControllerRemoveData = { - body?: never; - path: { - id: string; - }; - query?: never; - url: "/llms/{id}"; + body?: never; + path: { + id: string; + }; + query?: never; + url: '/llms/{id}'; }; export type LlmControllerRemoveResponses = { - 200: unknown; + 200: unknown; }; export type LlmControllerFindByIdData = { - body?: never; - path: { - id: string; - }; - query?: never; - url: "/llms/{id}"; + body?: never; + path: { + id: string; + }; + query?: never; + url: '/llms/{id}'; }; export type LlmControllerFindByIdResponses = { - 200: unknown; + 200: unknown; }; export type LlmControllerUpdateData = { - body: UpdateLlmCredentialDto; - path: { - id: string; - }; - query?: never; - url: "/llms/{id}"; + body: UpdateLlmCredentialDto; + path: { + id: string; + }; + query?: never; + url: '/llms/{id}'; }; export type LlmControllerUpdateResponses = { - 200: unknown; + 200: unknown; }; export type HealthCheckLlmCredentialData = { - body?: never; - path: { - id: string; - }; - query?: never; - url: "/llms/{id}/health-check"; + body?: never; + path: { + id: string; + }; + query?: never; + url: '/llms/{id}/health-check'; }; export type HealthCheckLlmCredentialResponses = { - 200: LlmHealthCheckResultDto; + 200: LlmHealthCheckResultDto; }; -export type HealthCheckLlmCredentialResponse = - HealthCheckLlmCredentialResponses[keyof HealthCheckLlmCredentialResponses]; +export type HealthCheckLlmCredentialResponse = HealthCheckLlmCredentialResponses[keyof HealthCheckLlmCredentialResponses]; export type TemplateControllerFindAllData = { - body?: never; - path?: never; - query?: never; - url: "/templates"; + body?: never; + path?: never; + query?: never; + url: '/templates'; }; export type TemplateControllerFindAllResponses = { - 200: unknown; + 200: unknown; }; export type TemplateControllerCreateData = { - body: CreateTemplateDto; - path?: never; - query?: never; - url: "/templates"; + body: CreateTemplateDto; + path?: never; + query?: never; + url: '/templates'; }; export type TemplateControllerCreateResponses = { - 201: unknown; + 201: unknown; }; export type TemplateControllerRemoveData = { - body?: never; - path: { - id: string; - }; - query?: never; - url: "/templates/{id}"; + body?: never; + path: { + id: string; + }; + query?: never; + url: '/templates/{id}'; }; export type TemplateControllerRemoveResponses = { - 200: unknown; + 200: unknown; }; export type TemplateControllerFindByIdData = { - body?: never; - path: { - id: string; - }; - query?: never; - url: "/templates/{id}"; + body?: never; + path: { + id: string; + }; + query?: never; + url: '/templates/{id}'; }; export type TemplateControllerFindByIdResponses = { - 200: unknown; + 200: unknown; }; export type TemplateControllerUpdateData = { - body: UpdateTemplateDto; - path: { - id: string; - }; - query?: never; - url: "/templates/{id}"; + body: UpdateTemplateDto; + path: { + id: string; + }; + query?: never; + url: '/templates/{id}'; }; export type TemplateControllerUpdateResponses = { - 200: unknown; + 200: unknown; }; export type TemplateControllerSetSkillsData = { - body: SetTemplateSkillsDto; - path: { - id: string; - }; - query?: never; - url: "/templates/{id}/skills"; + body: SetTemplateSkillsDto; + path: { + id: string; + }; + query?: never; + url: '/templates/{id}/skills'; }; export type TemplateControllerSetSkillsResponses = { - 200: unknown; + 200: unknown; }; export type TemplateControllerSetMcpsData = { - body: SetTemplateMcpsDto; - path: { - id: string; - }; - query?: never; - url: "/templates/{id}/mcps"; + body: SetTemplateMcpsDto; + path: { + id: string; + }; + query?: never; + url: '/templates/{id}/mcps'; }; export type TemplateControllerSetMcpsResponses = { - 200: unknown; + 200: unknown; }; export type McpServerControllerFindAllData = { - body?: never; - path?: never; - query?: never; - url: "/mcp-servers"; + body?: never; + path?: never; + query?: never; + url: '/mcp-servers'; }; export type McpServerControllerFindAllResponses = { - 200: unknown; + 200: unknown; }; export type McpServerControllerCreateData = { - body: CreateMcpServerDto; - path?: never; - query?: never; - url: "/mcp-servers"; + body: CreateMcpServerDto; + path?: never; + query?: never; + url: '/mcp-servers'; }; export type McpServerControllerCreateResponses = { - 201: unknown; + 201: unknown; }; export type McpServerControllerRemoveData = { - body?: never; - path: { - id: string; - }; - query?: never; - url: "/mcp-servers/{id}"; + body?: never; + path: { + id: string; + }; + query?: never; + url: '/mcp-servers/{id}'; }; export type McpServerControllerRemoveResponses = { - 200: unknown; + 200: unknown; }; export type McpServerControllerFindByIdData = { - body?: never; - path: { - id: string; - }; - query?: never; - url: "/mcp-servers/{id}"; + body?: never; + path: { + id: string; + }; + query?: never; + url: '/mcp-servers/{id}'; }; export type McpServerControllerFindByIdResponses = { - 200: unknown; + 200: unknown; }; export type McpServerControllerUpdateData = { - body: UpdateMcpServerDto; - path: { - id: string; - }; - query?: never; - url: "/mcp-servers/{id}"; + body: UpdateMcpServerDto; + path: { + id: string; + }; + query?: never; + url: '/mcp-servers/{id}'; }; export type McpServerControllerUpdateResponses = { - 200: unknown; + 200: unknown; }; export type GetKnowledgesData = { - body?: never; - path?: never; - query?: { - search?: string; - page?: number; - perPage?: number; - }; - url: "/knowledges"; + body?: never; + path?: never; + query?: { + search?: string; + page?: number; + perPage?: number; + }; + url: '/knowledges'; }; export type GetKnowledgesResponses = { - 200: KnowledgePageDto; + 200: KnowledgePageDto; }; -export type GetKnowledgesResponse = - GetKnowledgesResponses[keyof GetKnowledgesResponses]; +export type GetKnowledgesResponse = GetKnowledgesResponses[keyof GetKnowledgesResponses]; export type CreateKnowledgeData = { - body: CreateKnowledgeDto; - path?: never; - query?: never; - url: "/knowledges"; + body: CreateKnowledgeDto; + path?: never; + query?: never; + url: '/knowledges'; }; export type CreateKnowledgeResponses = { - 201: unknown; + 201: unknown; }; export type GetKnowledgeStatusData = { - body?: never; - path?: never; - query?: never; - url: "/knowledges/status"; + body?: never; + path?: never; + query?: never; + url: '/knowledges/status'; }; export type GetKnowledgeStatusResponses = { - 200: unknown; + 200: unknown; }; export type GetGraphLabelsData = { - body?: never; - path: { - id: string; - }; - query?: { - /** - * Case-insensitive substring filter - */ - search?: string; - limit?: number; - }; - url: "/knowledges/{id}/graph/labels"; + body?: never; + path: { + id: string; + }; + query?: { + /** + * Case-insensitive substring filter + */ + search?: string; + limit?: number; + }; + url: '/knowledges/{id}/graph/labels'; }; export type GetGraphLabelsResponses = { - 200: GraphLabelsDto; + 200: GraphLabelsDto; }; -export type GetGraphLabelsResponse = - GetGraphLabelsResponses[keyof GetGraphLabelsResponses]; +export type GetGraphLabelsResponse = GetGraphLabelsResponses[keyof GetGraphLabelsResponses]; export type GetGraphData = { - body?: never; - path: { - id: string; - }; - query: { - label: string; - maxDepth?: number; - maxNodes?: number; - }; - url: "/knowledges/{id}/graph"; + body?: never; + path: { + id: string; + }; + query: { + label: string; + maxDepth?: number; + maxNodes?: number; + }; + url: '/knowledges/{id}/graph'; }; export type GetGraphResponses = { - 200: GraphDto; + 200: GraphDto; }; export type GetGraphResponse = GetGraphResponses[keyof GetGraphResponses]; export type GetKnowledgeOverviewData = { - body?: never; - path: { - id: string; - }; - query?: never; - url: "/knowledges/{id}/overview"; + body?: never; + path: { + id: string; + }; + query?: never; + url: '/knowledges/{id}/overview'; }; export type GetKnowledgeOverviewResponses = { - 200: KnowledgeOverviewDto; + 200: KnowledgeOverviewDto; }; -export type GetKnowledgeOverviewResponse = - GetKnowledgeOverviewResponses[keyof GetKnowledgeOverviewResponses]; +export type GetKnowledgeOverviewResponse = GetKnowledgeOverviewResponses[keyof GetKnowledgeOverviewResponses]; export type DeleteKnowledgeData = { - body?: never; - path: { - id: string; - }; - query?: never; - url: "/knowledges/{id}"; + body?: never; + path: { + id: string; + }; + query?: never; + url: '/knowledges/{id}'; }; export type DeleteKnowledgeResponses = { - 204: void; + 204: void; }; -export type DeleteKnowledgeResponse = - DeleteKnowledgeResponses[keyof DeleteKnowledgeResponses]; +export type DeleteKnowledgeResponse = DeleteKnowledgeResponses[keyof DeleteKnowledgeResponses]; export type GetKnowledgeData = { - body?: never; - path: { - id: string; - }; - query?: never; - url: "/knowledges/{id}"; + body?: never; + path: { + id: string; + }; + query?: never; + url: '/knowledges/{id}'; }; export type GetKnowledgeResponses = { - 200: unknown; + 200: unknown; }; export type UpdateKnowledgeData = { - body: UpdateKnowledgeDto; - path: { - id: string; - }; - query?: never; - url: "/knowledges/{id}"; + body: UpdateKnowledgeDto; + path: { + id: string; + }; + query?: never; + url: '/knowledges/{id}'; }; export type UpdateKnowledgeResponses = { - 200: unknown; + 200: unknown; }; export type IndexKnowledgeData = { - body?: never; - path: { - id: string; - }; - query?: never; - url: "/knowledges/{id}/index"; + body?: never; + path: { + id: string; + }; + query?: never; + url: '/knowledges/{id}/index'; }; export type IndexKnowledgeResponses = { - 202: unknown; + 202: unknown; }; export type QueryKnowledgeData = { - body: QueryKnowledgeDto; - path: { - id: string; - }; - query?: never; - url: "/knowledges/{id}/query"; + body: QueryKnowledgeDto; + path: { + id: string; + }; + query?: never; + url: '/knowledges/{id}/query'; }; export type QueryKnowledgeResponses = { - 200: KnowledgeQueryResultDto; + 200: KnowledgeQueryResultDto; }; -export type QueryKnowledgeResponse = - QueryKnowledgeResponses[keyof QueryKnowledgeResponses]; +export type QueryKnowledgeResponse = QueryKnowledgeResponses[keyof QueryKnowledgeResponses]; export type GetKnowledgeSourcesData = { - body?: never; - path: { - knowledgeId: string; - }; - query?: { - /** - * Case-insensitive substring match on the source name - */ - search?: string; - status?: "indexed" | "pending" | "failed"; - type?: "file" | "url" | "text"; - page?: number; - perPage?: number; - }; - url: "/knowledges/{knowledgeId}/sources"; + body?: never; + path: { + knowledgeId: string; + }; + query?: { + /** + * Case-insensitive substring match on the source name + */ + search?: string; + status?: 'indexed' | 'pending' | 'failed'; + type?: 'file' | 'url' | 'text'; + page?: number; + perPage?: number; + }; + url: '/knowledges/{knowledgeId}/sources'; }; export type GetKnowledgeSourcesResponses = { - 200: SourcePageDto; + 200: SourcePageDto; }; -export type GetKnowledgeSourcesResponse = - GetKnowledgeSourcesResponses[keyof GetKnowledgeSourcesResponses]; +export type GetKnowledgeSourcesResponse = GetKnowledgeSourcesResponses[keyof GetKnowledgeSourcesResponses]; export type AddKnowledgeSourceData = { - body: CreateSourceDto; - path: { - knowledgeId: string; - }; - query?: never; - url: "/knowledges/{knowledgeId}/sources"; + body: CreateSourceDto; + path: { + knowledgeId: string; + }; + query?: never; + url: '/knowledges/{knowledgeId}/sources'; }; export type AddKnowledgeSourceResponses = { - 201: unknown; + 201: unknown; }; export type GetKnowledgeSourceImportsData = { - body?: never; - path: { - knowledgeId: string; - }; - query?: never; - url: "/knowledges/{knowledgeId}/sources/imports"; + body?: never; + path: { + knowledgeId: string; + }; + query?: never; + url: '/knowledges/{knowledgeId}/sources/imports'; }; export type GetKnowledgeSourceImportsResponses = { - 200: Array; + 200: Array; }; -export type GetKnowledgeSourceImportsResponse = - GetKnowledgeSourceImportsResponses[keyof GetKnowledgeSourceImportsResponses]; +export type GetKnowledgeSourceImportsResponse = GetKnowledgeSourceImportsResponses[keyof GetKnowledgeSourceImportsResponses]; export type ExportKnowledgeSourcesData = { - body?: never; - path: { - knowledgeId: string; - }; - query?: { - /** - * Comma-separated source ids. When present the filter fields are ignored. - */ - ids?: string; - /** - * Case-insensitive substring match on the source name - */ - search?: string; - status?: "indexed" | "pending" | "failed"; - type?: "file" | "url" | "text"; - }; - url: "/knowledges/{knowledgeId}/sources/export"; + body?: never; + path: { + knowledgeId: string; + }; + query?: { + /** + * Comma-separated source ids. When present the filter fields are ignored. + */ + ids?: string; + /** + * Case-insensitive substring match on the source name + */ + search?: string; + status?: 'indexed' | 'pending' | 'failed'; + type?: 'file' | 'url' | 'text'; + }; + url: '/knowledges/{knowledgeId}/sources/export'; }; export type ExportKnowledgeSourcesResponses = { - 200: unknown; + 200: unknown; }; export type GetKnowledgeSourceContentData = { - body?: never; - path: { - knowledgeId: string; - sourceId: string; - }; - query?: { - /** - * "inline" lets the browser render what it can (pdf, images, text); "attachment" forces a download. - */ - disposition?: "inline" | "attachment"; - }; - url: "/knowledges/{knowledgeId}/sources/{sourceId}/content"; + body?: never; + path: { + knowledgeId: string; + sourceId: string; + }; + query?: { + /** + * "inline" lets the browser render what it can (pdf, images, text); "attachment" forces a download. + */ + disposition?: 'inline' | 'attachment'; + }; + url: '/knowledges/{knowledgeId}/sources/{sourceId}/content'; }; export type GetKnowledgeSourceContentResponses = { - 200: unknown; + 200: unknown; }; export type ReindexKnowledgeSourceData = { - body?: never; - path: { - knowledgeId: string; - sourceId: string; - }; - query?: never; - url: "/knowledges/{knowledgeId}/sources/{sourceId}/reindex"; + body?: never; + path: { + knowledgeId: string; + sourceId: string; + }; + query?: never; + url: '/knowledges/{knowledgeId}/sources/{sourceId}/reindex'; }; export type ReindexKnowledgeSourceResponses = { - 202: unknown; + 202: unknown; }; export type ExtractKnowledgeSourceTextData = { - body?: never; - path: { - knowledgeId: string; - sourceId: string; - }; - query?: never; - url: "/knowledges/{knowledgeId}/sources/{sourceId}/extract"; + body?: never; + path: { + knowledgeId: string; + sourceId: string; + }; + query?: never; + url: '/knowledges/{knowledgeId}/sources/{sourceId}/extract'; }; export type ExtractKnowledgeSourceTextResponses = { - 202: unknown; + 202: unknown; }; export type AddKnowledgeFileSourcesData = { - body?: never; - path: { - knowledgeId: string; - }; - query?: never; - url: "/knowledges/{knowledgeId}/sources/files"; + body?: never; + path: { + knowledgeId: string; + }; + query?: never; + url: '/knowledges/{knowledgeId}/sources/files'; }; export type AddKnowledgeFileSourcesResponses = { - 201: AddFilesResultDto; + 201: AddFilesResultDto; }; -export type AddKnowledgeFileSourcesResponse = - AddKnowledgeFileSourcesResponses[keyof AddKnowledgeFileSourcesResponses]; +export type AddKnowledgeFileSourcesResponse = AddKnowledgeFileSourcesResponses[keyof AddKnowledgeFileSourcesResponses]; export type AddKnowledgeSourcesFromSitemapData = { - body: AddFromSitemapDto; - path: { - knowledgeId: string; - }; - query?: never; - url: "/knowledges/{knowledgeId}/sources/from-sitemap"; + body: AddFromSitemapDto; + path: { + knowledgeId: string; + }; + query?: never; + url: '/knowledges/{knowledgeId}/sources/from-sitemap'; }; export type AddKnowledgeSourcesFromSitemapResponses = { - 201: AddFromSitemapResultDto; + 201: AddFromSitemapResultDto; }; -export type AddKnowledgeSourcesFromSitemapResponse = - AddKnowledgeSourcesFromSitemapResponses[keyof AddKnowledgeSourcesFromSitemapResponses]; +export type AddKnowledgeSourcesFromSitemapResponse = AddKnowledgeSourcesFromSitemapResponses[keyof AddKnowledgeSourcesFromSitemapResponses]; export type AddKnowledgeSourcesFromArchiveData = { - body?: never; - path: { - knowledgeId: string; - }; - query?: never; - url: "/knowledges/{knowledgeId}/sources/from-archive"; + body?: never; + path: { + knowledgeId: string; + }; + query?: never; + url: '/knowledges/{knowledgeId}/sources/from-archive'; }; export type AddKnowledgeSourcesFromArchiveResponses = { - 201: AddFromArchiveResultDto; + 201: AddFromArchiveResultDto; }; -export type AddKnowledgeSourcesFromArchiveResponse = - AddKnowledgeSourcesFromArchiveResponses[keyof AddKnowledgeSourcesFromArchiveResponses]; +export type AddKnowledgeSourcesFromArchiveResponse = AddKnowledgeSourcesFromArchiveResponses[keyof AddKnowledgeSourcesFromArchiveResponses]; export type DeleteKnowledgeSourceData = { - body?: never; - path: { - knowledgeId: string; - sourceId: string; - }; - query?: never; - url: "/knowledges/{knowledgeId}/sources/{sourceId}"; + body?: never; + path: { + knowledgeId: string; + sourceId: string; + }; + query?: never; + url: '/knowledges/{knowledgeId}/sources/{sourceId}'; }; export type DeleteKnowledgeSourceResponses = { - 204: void; + 204: void; }; -export type DeleteKnowledgeSourceResponse = - DeleteKnowledgeSourceResponses[keyof DeleteKnowledgeSourceResponses]; +export type DeleteKnowledgeSourceResponse = DeleteKnowledgeSourceResponses[keyof DeleteKnowledgeSourceResponses]; export type AgentControllerFindAllData = { - body?: never; - path?: never; - query?: never; - url: "/agents"; + body?: never; + path?: never; + query?: never; + url: '/agents'; }; export type AgentControllerFindAllResponses = { - 200: Array; + 200: Array; }; -export type AgentControllerFindAllResponse = - AgentControllerFindAllResponses[keyof AgentControllerFindAllResponses]; +export type AgentControllerFindAllResponse = AgentControllerFindAllResponses[keyof AgentControllerFindAllResponses]; export type AgentControllerCreateData = { - body: CreateAgentDto; - path?: never; - query?: never; - url: "/agents"; + body: CreateAgentDto; + path?: never; + query?: never; + url: '/agents'; }; export type AgentControllerCreateResponses = { - 201: unknown; + 201: unknown; }; export type AgentControllerFindPublicData = { - body?: never; - path?: never; - query?: never; - url: "/agents/public"; + body?: never; + path?: never; + query?: never; + url: '/agents/public'; }; export type AgentControllerFindPublicResponses = { - 200: Array; + 200: Array; }; -export type AgentControllerFindPublicResponse = - AgentControllerFindPublicResponses[keyof AgentControllerFindPublicResponses]; +export type AgentControllerFindPublicResponse = AgentControllerFindPublicResponses[keyof AgentControllerFindPublicResponses]; export type AgentControllerStatusData = { - body?: never; - path?: never; - query?: never; - url: "/agents/status"; + body?: never; + path?: never; + query?: never; + url: '/agents/status'; }; export type AgentControllerStatusResponses = { - 200: Array; + 200: Array; }; -export type AgentControllerStatusResponse = - AgentControllerStatusResponses[keyof AgentControllerStatusResponses]; +export type AgentControllerStatusResponse = AgentControllerStatusResponses[keyof AgentControllerStatusResponses]; export type AgentControllerStatusStreamData = { - body?: never; - path?: never; - query?: never; - url: "/agents/status/stream"; + body?: never; + path?: never; + query?: never; + url: '/agents/status/stream'; }; export type AgentControllerStatusStreamResponses = { - 200: unknown; + 200: unknown; }; export type GetClusterCapacityData = { - body?: never; - path?: never; - query?: never; - url: "/agents/capacity"; + body?: never; + path?: never; + query?: never; + url: '/agents/capacity'; }; export type GetClusterCapacityResponses = { - 200: ClusterCapacityDto; + 200: ClusterCapacityDto; }; -export type GetClusterCapacityResponse = - GetClusterCapacityResponses[keyof GetClusterCapacityResponses]; +export type GetClusterCapacityResponse = GetClusterCapacityResponses[keyof GetClusterCapacityResponses]; export type AgentControllerRemoveData = { - body?: never; - path: { - id: string; - }; - query: { - wipeS3: string; - }; - url: "/agents/{id}"; + body?: never; + path: { + id: string; + }; + query: { + wipeS3: string; + }; + url: '/agents/{id}'; }; export type AgentControllerRemoveResponses = { - 200: unknown; + 200: unknown; }; export type AgentControllerFindByIdData = { - body?: never; - path: { - id: string; - }; - query?: never; - url: "/agents/{id}"; + body?: never; + path: { + id: string; + }; + query?: never; + url: '/agents/{id}'; }; export type AgentControllerFindByIdResponses = { - 200: AgentDto; + 200: AgentDto; }; -export type AgentControllerFindByIdResponse = - AgentControllerFindByIdResponses[keyof AgentControllerFindByIdResponses]; +export type AgentControllerFindByIdResponse = AgentControllerFindByIdResponses[keyof AgentControllerFindByIdResponses]; export type AgentControllerUpdateData = { - body: UpdateAgentDto; - path: { - id: string; - }; - query?: never; - url: "/agents/{id}"; + body: UpdateAgentDto; + path: { + id: string; + }; + query?: never; + url: '/agents/{id}'; }; export type AgentControllerUpdateResponses = { - 200: unknown; + 200: unknown; }; export type GetAgentMetricsData = { - body?: never; - path: { - id: string; - }; - query?: never; - url: "/agents/{id}/metrics"; + body?: never; + path: { + id: string; + }; + query?: never; + url: '/agents/{id}/metrics'; }; export type GetAgentMetricsResponses = { - 200: AgentMetricsDto; + 200: AgentMetricsDto; }; -export type GetAgentMetricsResponse = - GetAgentMetricsResponses[keyof GetAgentMetricsResponses]; +export type GetAgentMetricsResponse = GetAgentMetricsResponses[keyof GetAgentMetricsResponses]; export type GetAgentEnvData = { - body?: never; - path: { - id: string; - }; - query?: never; - url: "/agents/{id}/env"; + body?: never; + path: { + id: string; + }; + query?: never; + url: '/agents/{id}/env'; }; export type GetAgentEnvResponses = { - 200: Array; + 200: Array; }; -export type GetAgentEnvResponse = - GetAgentEnvResponses[keyof GetAgentEnvResponses]; +export type GetAgentEnvResponse = GetAgentEnvResponses[keyof GetAgentEnvResponses]; export type GetAgentMcpsData = { - body?: never; - path: { - id: string; - }; - query?: never; - url: "/agents/{id}/mcps"; + body?: never; + path: { + id: string; + }; + query?: never; + url: '/agents/{id}/mcps'; }; export type GetAgentMcpsResponses = { - 200: Array; + 200: Array; }; -export type GetAgentMcpsResponse = - GetAgentMcpsResponses[keyof GetAgentMcpsResponses]; +export type GetAgentMcpsResponse = GetAgentMcpsResponses[keyof GetAgentMcpsResponses]; export type AgentControllerFindAdminData = { - body?: never; - path?: never; - query?: never; - url: "/agents/admin/current"; + body?: never; + path?: never; + query?: never; + url: '/agents/admin/current'; }; export type AgentControllerFindAdminResponses = { - 200: unknown; + 200: unknown; }; export type AgentControllerDemoteAdminData = { - body?: never; - path: { - id: string; - }; - query?: never; - url: "/agents/{id}/promote-admin"; + body?: never; + path: { + id: string; + }; + query?: never; + url: '/agents/{id}/promote-admin'; }; export type AgentControllerDemoteAdminResponses = { - 200: unknown; + 200: unknown; }; export type AgentControllerPromoteAdminData = { - body?: never; - path: { - id: string; - }; - query?: never; - url: "/agents/{id}/promote-admin"; + body?: never; + path: { + id: string; + }; + query?: never; + url: '/agents/{id}/promote-admin'; }; export type AgentControllerPromoteAdminResponses = { - 201: unknown; + 201: unknown; }; export type AgentControllerRestartData = { - body?: never; - path: { - id: string; - }; - query?: never; - url: "/agents/{id}/restart"; + body?: never; + path: { + id: string; + }; + query?: never; + url: '/agents/{id}/restart'; }; export type AgentControllerRestartResponses = { - 201: unknown; + 201: unknown; }; export type AgentControllerStopData = { - body?: never; - path: { - id: string; - }; - query?: never; - url: "/agents/{id}/stop"; + body?: never; + path: { + id: string; + }; + query?: never; + url: '/agents/{id}/stop'; }; export type AgentControllerStopResponses = { - 201: unknown; + 201: unknown; }; export type AgentControllerStartData = { - body?: never; - path: { - id: string; - }; - query?: never; - url: "/agents/{id}/start"; + body?: never; + path: { + id: string; + }; + query?: never; + url: '/agents/{id}/start'; }; export type AgentControllerStartResponses = { - 201: unknown; + 201: unknown; }; export type RestartByTemplateData = { - body?: never; - path: { - templateId: string; - }; - query?: never; - url: "/agents/restart-by-template/{templateId}"; + body?: never; + path: { + templateId: string; + }; + query?: never; + url: '/agents/restart-by-template/{templateId}'; }; export type RestartByTemplateResponses = { - 201: unknown; + 201: unknown; }; export type FileControllerListData = { - body?: never; - path: { - agentId: string; - }; - query?: never; - url: "/agents/{agentId}/files"; + body?: never; + path: { + agentId: string; + }; + query?: never; + url: '/agents/{agentId}/files'; }; export type FileControllerListResponses = { - 200: unknown; + 200: unknown; }; export type FileControllerDeleteData = { - body?: never; - path: { - agentId: string; - }; - query: { - path: string; - /** - * When true, `path` is treated as a folder and every file under it is deleted. - */ - recursive?: boolean; - }; - url: "/agents/{agentId}/files/content"; + body?: never; + path: { + agentId: string; + }; + query: { + path: string; + /** + * When true, `path` is treated as a folder and every file under it is deleted. + */ + recursive?: boolean; + }; + url: '/agents/{agentId}/files/content'; }; export type FileControllerDeleteResponses = { - 200: DeleteFilesDto; + 200: DeleteFilesDto; }; -export type FileControllerDeleteResponse = - FileControllerDeleteResponses[keyof FileControllerDeleteResponses]; +export type FileControllerDeleteResponse = FileControllerDeleteResponses[keyof FileControllerDeleteResponses]; export type FileControllerReadData = { - body?: never; - path: { - agentId: string; - }; - query: { - path: string; - /** - * Byte offset to start reading from. - */ - offset?: number; - /** - * Max bytes to return. Server caps at 512 KB. - */ - limit?: number; - }; - url: "/agents/{agentId}/files/content"; + body?: never; + path: { + agentId: string; + }; + query: { + path: string; + /** + * Byte offset to start reading from. + */ + offset?: number; + /** + * Max bytes to return. Server caps at 512 KB. + */ + limit?: number; + }; + url: '/agents/{agentId}/files/content'; }; export type FileControllerReadResponses = { - 200: FileChunkDto; + 200: FileChunkDto; }; -export type FileControllerReadResponse = - FileControllerReadResponses[keyof FileControllerReadResponses]; +export type FileControllerReadResponse = FileControllerReadResponses[keyof FileControllerReadResponses]; export type FileControllerSaveData = { - body: SaveFileDto; - path: { - agentId: string; - }; - query: { - path: string; - }; - url: "/agents/{agentId}/files/content"; + body: SaveFileDto; + path: { + agentId: string; + }; + query: { + path: string; + }; + url: '/agents/{agentId}/files/content'; }; export type FileControllerSaveResponses = { - 200: unknown; + 200: unknown; }; export type FileControllerSyncData = { - body: SyncFilesBodyDto; - path: { - agentId: string; - }; - query?: never; - url: "/agents/{agentId}/files/sync"; + body: SyncFilesBodyDto; + path: { + agentId: string; + }; + query?: never; + url: '/agents/{agentId}/files/sync'; }; export type FileControllerSyncErrors = { - /** - * S3 files newer than the pod’s working copy were found and confirm was not set. No sync was performed. - */ - 409: SyncConflictDto; + /** + * S3 files newer than the pod’s working copy were found and confirm was not set. No sync was performed. + */ + 409: SyncConflictDto; }; -export type FileControllerSyncError = - FileControllerSyncErrors[keyof FileControllerSyncErrors]; +export type FileControllerSyncError = FileControllerSyncErrors[keyof FileControllerSyncErrors]; export type ExportAgentFilesData = { - body?: never; - path: { - agentId: string; - }; - query?: never; - url: "/agents/{agentId}/files/export"; + body?: never; + path: { + agentId: string; + }; + query?: never; + url: '/agents/{agentId}/files/export'; }; export type ExportAgentFilesResponses = { - 200: unknown; + 200: unknown; }; export type SendBridleMessageData = { - body: SendMessageDto; - headers?: { + body: SendMessageDto; + headers?: { + /** + * Share-link token (`sl_…`) identifying a public share visitor. Send together with `X-Share-Visitor` instead of an `Authorization` bearer. Re-validated against the agent in the path on every request, so a revoked link stops working immediately. + */ + 'X-Share-Token'?: string; + /** + * Opaque per-browser visitor id minted by the share page. Required whenever `X-Share-Token` is sent; it selects the visitor's own `share-` chat channel and owns their attachments. + */ + 'X-Share-Visitor'?: string; + }; + path: { + agentId: string; + }; + query?: never; + url: '/api/agent/{agentId}/message'; +}; + +export type SendBridleMessageErrors = { /** - * Share-link token (`sl_…`) identifying a public share visitor. Send together with `X-Share-Visitor` instead of an `Authorization` bearer. Re-validated against the agent in the path on every request, so a revoked link stops working immediately. + * An `attachmentIds` entry is unknown, unreadable or not owned by the caller. */ - "X-Share-Token"?: string; + 400: unknown; /** - * Opaque per-browser visitor id minted by the share page. Required whenever `X-Share-Token` is sent; it selects the visitor's own `share-` chat channel and owns their attachments. + * A bearer token was offered but is expired or invalid, and no share headers were present. Body is `{ code: 'TOKEN_EXPIRED' | 'TOKEN_INVALID', message }` — the console renews via POST /auth/refresh and retries once. Requests with no credentials stay anonymous. */ - "X-Share-Visitor"?: string; - }; - path: { - agentId: string; - }; - query?: never; - url: "/api/agent/{agentId}/message"; -}; - -export type SendBridleMessageErrors = { - /** - * An `attachmentIds` entry is unknown, unreadable or not owned by the caller. - */ - 400: unknown; - /** - * A bearer token was offered but is expired or invalid, and no share headers were present. Body is `{ code: 'TOKEN_EXPIRED' | 'TOKEN_INVALID', message }` — the console renews via POST /auth/refresh and retries once. Requests with no credentials stay anonymous. - */ - 401: unknown; - /** - * Share headers were offered but rejected — revoked, unknown or foreign-agent token, or a malformed visitor id. Body is `{ code: 'SHARE_LINK_INVALID' }` or `{ code: 'SHARE_VISITOR_INVALID' }`. Never 401: a share visitor has no account to log in to. - */ - 403: unknown; + 401: unknown; + /** + * Share headers were offered but rejected — revoked, unknown or foreign-agent token, or a malformed visitor id. Body is `{ code: 'SHARE_LINK_INVALID' }` or `{ code: 'SHARE_VISITOR_INVALID' }`. Never 401: a share visitor has no account to log in to. + */ + 403: unknown; }; export type SendBridleMessageResponses = { - /** - * Accepted and forwarded to the agent. - */ - 200: unknown; + /** + * Accepted and forwarded to the agent. + */ + 200: unknown; }; export type SendBridleMessageSyncData = { - body: SendMessageDto; - headers?: { + body: SendMessageDto; + headers?: { + /** + * Share-link token (`sl_…`) identifying a public share visitor. Send together with `X-Share-Visitor` instead of an `Authorization` bearer. Re-validated against the agent in the path on every request, so a revoked link stops working immediately. + */ + 'X-Share-Token'?: string; + /** + * Opaque per-browser visitor id minted by the share page. Required whenever `X-Share-Token` is sent; it selects the visitor's own `share-` chat channel and owns their attachments. + */ + 'X-Share-Visitor'?: string; + }; + path: { + agentId: string; + }; + query?: never; + url: '/api/agent/{agentId}/message/sync'; +}; + +export type SendBridleMessageSyncErrors = { /** - * Share-link token (`sl_…`) identifying a public share visitor. Send together with `X-Share-Visitor` instead of an `Authorization` bearer. Re-validated against the agent in the path on every request, so a revoked link stops working immediately. + * An `attachmentIds` entry is unknown, unreadable or not owned by the caller. */ - "X-Share-Token"?: string; + 400: unknown; /** - * Opaque per-browser visitor id minted by the share page. Required whenever `X-Share-Token` is sent; it selects the visitor's own `share-` chat channel and owns their attachments. + * A bearer token was offered but is expired or invalid, and no share headers were present. Body is `{ code: 'TOKEN_EXPIRED' | 'TOKEN_INVALID', message }` — the console renews via POST /auth/refresh and retries once. Requests with no credentials stay anonymous. */ - "X-Share-Visitor"?: string; - }; - path: { - agentId: string; - }; - query?: never; - url: "/api/agent/{agentId}/message/sync"; -}; - -export type SendBridleMessageSyncErrors = { - /** - * An `attachmentIds` entry is unknown, unreadable or not owned by the caller. - */ - 400: unknown; - /** - * A bearer token was offered but is expired or invalid, and no share headers were present. Body is `{ code: 'TOKEN_EXPIRED' | 'TOKEN_INVALID', message }` — the console renews via POST /auth/refresh and retries once. Requests with no credentials stay anonymous. - */ - 401: unknown; - /** - * Share headers were offered but rejected — revoked, unknown or foreign-agent token, or a malformed visitor id. Body is `{ code: 'SHARE_LINK_INVALID' }` or `{ code: 'SHARE_VISITOR_INVALID' }`. Never 401: a share visitor has no account to log in to. - */ - 403: unknown; + 401: unknown; + /** + * Share headers were offered but rejected — revoked, unknown or foreign-agent token, or a malformed visitor id. Body is `{ code: 'SHARE_LINK_INVALID' }` or `{ code: 'SHARE_VISITOR_INVALID' }`. Never 401: a share visitor has no account to log in to. + */ + 403: unknown; }; export type SendBridleMessageSyncResponses = { - /** - * The agent's reply (`{ text, messageId, ts }`), or a timeout notice after 120s. - */ - 200: unknown; + /** + * The agent's reply (`{ text, messageId, ts }`), or a timeout notice after 120s. + */ + 200: unknown; }; export type UploadBridleAttachmentData = { - body: { - file: Blob | File; - }; - headers?: { + body: { + file: Blob | File; + }; + headers?: { + /** + * Share-link token (`sl_…`) identifying a public share visitor. Send together with `X-Share-Visitor` instead of an `Authorization` bearer. Re-validated against the agent in the path on every request, so a revoked link stops working immediately. + */ + 'X-Share-Token'?: string; + /** + * Opaque per-browser visitor id minted by the share page. Required whenever `X-Share-Token` is sent; it selects the visitor's own `share-` chat channel and owns their attachments. + */ + 'X-Share-Visitor'?: string; + }; + path: { + agentId: string; + }; + query?: never; + url: '/api/agent/{agentId}/attachment'; +}; + +export type UploadBridleAttachmentErrors = { /** - * Share-link token (`sl_…`) identifying a public share visitor. Send together with `X-Share-Visitor` instead of an `Authorization` bearer. Re-validated against the agent in the path on every request, so a revoked link stops working immediately. + * No `file` field, an empty file, an unsupported type, or a file over the size limit. */ - "X-Share-Token"?: string; + 400: unknown; /** - * Opaque per-browser visitor id minted by the share page. Required whenever `X-Share-Token` is sent; it selects the visitor's own `share-` chat channel and owns their attachments. + * No usable credential: no bearer token and no share headers, or a bearer that fails verification with no share headers to fall back on. */ - "X-Share-Visitor"?: string; - }; - path: { - agentId: string; - }; - query?: never; - url: "/api/agent/{agentId}/attachment"; -}; - -export type UploadBridleAttachmentErrors = { - /** - * No `file` field, an empty file, an unsupported type, or a file over the size limit. - */ - 400: unknown; - /** - * No usable credential: no bearer token and no share headers, or a bearer that fails verification with no share headers to fall back on. - */ - 401: unknown; - /** - * Share headers were offered but rejected — revoked, unknown or foreign-agent token, or a malformed visitor id. Body is `{ code: 'SHARE_LINK_INVALID' }` or `{ code: 'SHARE_VISITOR_INVALID' }`. Never 401: a share visitor has no account to log in to. - */ - 403: unknown; + 401: unknown; + /** + * Share headers were offered but rejected — revoked, unknown or foreign-agent token, or a malformed visitor id. Body is `{ code: 'SHARE_LINK_INVALID' }` or `{ code: 'SHARE_VISITOR_INVALID' }`. Never 401: a share visitor has no account to log in to. + */ + 403: unknown; }; export type UploadBridleAttachmentResponses = { - 200: BridleAttachmentDto; + 200: BridleAttachmentDto; }; -export type UploadBridleAttachmentResponse = - UploadBridleAttachmentResponses[keyof UploadBridleAttachmentResponses]; +export type UploadBridleAttachmentResponse = UploadBridleAttachmentResponses[keyof UploadBridleAttachmentResponses]; export type GetBridleAttachmentData = { - body?: never; - headers?: { + body?: never; + headers?: { + /** + * Share-link token (`sl_…`) identifying a public share visitor. Send together with `X-Share-Visitor` instead of an `Authorization` bearer. Re-validated against the agent in the path on every request, so a revoked link stops working immediately. + */ + 'X-Share-Token'?: string; + /** + * Opaque per-browser visitor id minted by the share page. Required whenever `X-Share-Token` is sent; it selects the visitor's own `share-` chat channel and owns their attachments. + */ + 'X-Share-Visitor'?: string; + }; + path: { + agentId: string; + attachmentId: string; + }; + query?: never; + url: '/api/agent/{agentId}/attachment/{attachmentId}'; +}; + +export type GetBridleAttachmentErrors = { /** - * Share-link token (`sl_…`) identifying a public share visitor. Send together with `X-Share-Visitor` instead of an `Authorization` bearer. Re-validated against the agent in the path on every request, so a revoked link stops working immediately. + * No usable credential: no bearer token and no share headers, or a bearer that fails verification with no share headers to fall back on. */ - "X-Share-Token"?: string; + 401: unknown; /** - * Opaque per-browser visitor id minted by the share page. Required whenever `X-Share-Token` is sent; it selects the visitor's own `share-` chat channel and owns their attachments. + * Share headers were offered but rejected — revoked, unknown or foreign-agent token, or a malformed visitor id. Body is `{ code: 'SHARE_LINK_INVALID' }` or `{ code: 'SHARE_VISITOR_INVALID' }`. Never 401: a share visitor has no account to log in to. */ - "X-Share-Visitor"?: string; - }; - path: { - agentId: string; - attachmentId: string; - }; - query?: never; - url: "/api/agent/{agentId}/attachment/{attachmentId}"; -}; - -export type GetBridleAttachmentErrors = { - /** - * No usable credential: no bearer token and no share headers, or a bearer that fails verification with no share headers to fall back on. - */ - 401: unknown; - /** - * Share headers were offered but rejected — revoked, unknown or foreign-agent token, or a malformed visitor id. Body is `{ code: 'SHARE_LINK_INVALID' }` or `{ code: 'SHARE_VISITOR_INVALID' }`. Never 401: a share visitor has no account to log in to. - */ - 403: unknown; - /** - * No such attachment — or one belonging to another share visitor, which answers with the same 404 so a leaked id reveals nothing. - */ - 404: unknown; + 403: unknown; + /** + * No such attachment — or one belonging to another share visitor, which answers with the same 404 so a leaked id reveals nothing. + */ + 404: unknown; }; export type GetBridleAttachmentResponses = { - /** - * The stored bytes, with the original content type and an `inline` Content-Disposition. - */ - 200: unknown; + /** + * The stored bytes, with the original content type and an `inline` Content-Disposition. + */ + 200: unknown; }; export type BridleHealthData = { - body?: never; - path?: never; - query?: never; - url: "/api/agent/health"; + body?: never; + path?: never; + query?: never; + url: '/api/agent/health'; }; export type BridleHealthResponses = { - 200: BridleHealthDto; + 200: BridleHealthDto; }; -export type BridleHealthResponse = - BridleHealthResponses[keyof BridleHealthResponses]; +export type BridleHealthResponse = BridleHealthResponses[keyof BridleHealthResponses]; export type BridleAgentHealthData = { - body?: never; - path: { - agentId: string; - }; - query?: never; - url: "/api/agent/{agentId}/health"; + body?: never; + path: { + agentId: string; + }; + query?: never; + url: '/api/agent/{agentId}/health'; }; export type BridleAgentHealthResponses = { - 200: BridleAgentHealthDto; + 200: BridleAgentHealthDto; }; -export type BridleAgentHealthResponse = - BridleAgentHealthResponses[keyof BridleAgentHealthResponses]; +export type BridleAgentHealthResponse = BridleAgentHealthResponses[keyof BridleAgentHealthResponses]; export type ListAgentsData = { - body?: never; - path?: never; - query?: never; - url: "/api/agent/list"; + body?: never; + path?: never; + query?: never; + url: '/api/agent/list'; }; export type ListAgentsResponses = { - 200: unknown; + 200: unknown; }; export type ResetBridleTranscriptData = { - body?: never; - headers?: { - /** - * Share-link token (`sl_…`) identifying a public share visitor. Send together with `X-Share-Visitor` instead of an `Authorization` bearer. Re-validated against the agent in the path on every request, so a revoked link stops working immediately. - */ - "X-Share-Token"?: string; - /** - * Opaque per-browser visitor id minted by the share page. Required whenever `X-Share-Token` is sent; it selects the visitor's own `share-` chat channel and owns their attachments. - */ - "X-Share-Visitor"?: string; - }; - path: { - agentId: string; - }; - query?: { - /** - * Session channel — defaults to "admin". - */ - channel?: string; - }; - url: "/api/agent/{agentId}/transcript"; -}; - -export type ResetBridleTranscriptErrors = { - /** - * Share headers were offered but rejected — revoked, unknown or foreign-agent token, or a malformed visitor id. Body is `{ code: 'SHARE_LINK_INVALID' }` or `{ code: 'SHARE_VISITOR_INVALID' }`. Never 401: a share visitor has no account to log in to. - */ - 403: unknown; -}; - -export type ResetBridleTranscriptResponses = { - /** - * Transcript deleted, or there was nothing to delete. - */ - 204: void; -}; - -export type ResetBridleTranscriptResponse = - ResetBridleTranscriptResponses[keyof ResetBridleTranscriptResponses]; - -export type GetBridleTranscriptData = { - body?: never; - headers?: { - /** - * Share-link token (`sl_…`) identifying a public share visitor. Send together with `X-Share-Visitor` instead of an `Authorization` bearer. Re-validated against the agent in the path on every request, so a revoked link stops working immediately. - */ - "X-Share-Token"?: string; - /** - * Opaque per-browser visitor id minted by the share page. Required whenever `X-Share-Token` is sent; it selects the visitor's own `share-` chat channel and owns their attachments. - */ - "X-Share-Visitor"?: string; - }; - path: { - agentId: string; - }; - query?: { - /** - * Session channel — defaults to "admin" for the admin app. - */ - channel?: string; + body?: never; + headers?: { + /** + * Share-link token (`sl_…`) identifying a public share visitor. Send together with `X-Share-Visitor` instead of an `Authorization` bearer. Re-validated against the agent in the path on every request, so a revoked link stops working immediately. + */ + 'X-Share-Token'?: string; + /** + * Opaque per-browser visitor id minted by the share page. Required whenever `X-Share-Token` is sent; it selects the visitor's own `share-` chat channel and owns their attachments. + */ + 'X-Share-Visitor'?: string; + }; + path: { + agentId: string; + }; + query?: { + /** + * Session channel — defaults to "admin". + */ + channel?: string; + }; + url: '/api/agent/{agentId}/transcript'; +}; + +export type ResetBridleTranscriptErrors = { /** - * Max messages to return in this page (newest first by file order). + * Share headers were offered but rejected — revoked, unknown or foreign-agent token, or a malformed visitor id. Body is `{ code: 'SHARE_LINK_INVALID' }` or `{ code: 'SHARE_VISITOR_INVALID' }`. Never 401: a share visitor has no account to log in to. */ - limit?: number; + 403: unknown; +}; + +export type ResetBridleTranscriptResponses = { /** - * Opaque cursor returned by the previous page. Omit for the latest page. + * Transcript deleted, or there was nothing to delete. */ - cursor?: string; - }; - url: "/api/agent/{agentId}/transcript"; + 204: void; +}; + +export type ResetBridleTranscriptResponse = ResetBridleTranscriptResponses[keyof ResetBridleTranscriptResponses]; + +export type GetBridleTranscriptData = { + body?: never; + headers?: { + /** + * Share-link token (`sl_…`) identifying a public share visitor. Send together with `X-Share-Visitor` instead of an `Authorization` bearer. Re-validated against the agent in the path on every request, so a revoked link stops working immediately. + */ + 'X-Share-Token'?: string; + /** + * Opaque per-browser visitor id minted by the share page. Required whenever `X-Share-Token` is sent; it selects the visitor's own `share-` chat channel and owns their attachments. + */ + 'X-Share-Visitor'?: string; + }; + path: { + agentId: string; + }; + query?: { + /** + * Session channel — defaults to "admin" for the admin app. + */ + channel?: string; + /** + * Max messages to return in this page (newest first by file order). + */ + limit?: number; + /** + * Opaque cursor returned by the previous page. Omit for the latest page. + */ + cursor?: string; + }; + url: '/api/agent/{agentId}/transcript'; }; export type GetBridleTranscriptErrors = { - /** - * Share headers were offered but rejected — revoked, unknown or foreign-agent token, or a malformed visitor id. Body is `{ code: 'SHARE_LINK_INVALID' }` or `{ code: 'SHARE_VISITOR_INVALID' }`. Never 401: a share visitor has no account to log in to. - */ - 403: unknown; + /** + * Share headers were offered but rejected — revoked, unknown or foreign-agent token, or a malformed visitor id. Body is `{ code: 'SHARE_LINK_INVALID' }` or `{ code: 'SHARE_VISITOR_INVALID' }`. Never 401: a share visitor has no account to log in to. + */ + 403: unknown; }; export type GetBridleTranscriptResponses = { - 200: TranscriptResponseDto; + 200: TranscriptResponseDto; }; -export type GetBridleTranscriptResponse = - GetBridleTranscriptResponses[keyof GetBridleTranscriptResponses]; +export type GetBridleTranscriptResponse = GetBridleTranscriptResponses[keyof GetBridleTranscriptResponses]; export type ArchiveBridleTranscriptData = { - body?: never; - headers?: { - /** - * Share-link token (`sl_…`) identifying a public share visitor. Send together with `X-Share-Visitor` instead of an `Authorization` bearer. Re-validated against the agent in the path on every request, so a revoked link stops working immediately. - */ - "X-Share-Token"?: string; - /** - * Opaque per-browser visitor id minted by the share page. Required whenever `X-Share-Token` is sent; it selects the visitor's own `share-` chat channel and owns their attachments. - */ - "X-Share-Visitor"?: string; - }; - path: { - agentId: string; - }; - query?: { - /** - * Session channel — defaults to "admin". - */ - channel?: string; - }; - url: "/api/agent/{agentId}/transcript/archive"; + body?: never; + headers?: { + /** + * Share-link token (`sl_…`) identifying a public share visitor. Send together with `X-Share-Visitor` instead of an `Authorization` bearer. Re-validated against the agent in the path on every request, so a revoked link stops working immediately. + */ + 'X-Share-Token'?: string; + /** + * Opaque per-browser visitor id minted by the share page. Required whenever `X-Share-Token` is sent; it selects the visitor's own `share-` chat channel and owns their attachments. + */ + 'X-Share-Visitor'?: string; + }; + path: { + agentId: string; + }; + query?: { + /** + * Session channel — defaults to "admin". + */ + channel?: string; + }; + url: '/api/agent/{agentId}/transcript/archive'; }; export type ArchiveBridleTranscriptErrors = { - /** - * Share headers were offered but rejected — revoked, unknown or foreign-agent token, or a malformed visitor id. Body is `{ code: 'SHARE_LINK_INVALID' }` or `{ code: 'SHARE_VISITOR_INVALID' }`. Never 401: a share visitor has no account to log in to. - */ - 403: unknown; + /** + * Share headers were offered but rejected — revoked, unknown or foreign-agent token, or a malformed visitor id. Body is `{ code: 'SHARE_LINK_INVALID' }` or `{ code: 'SHARE_VISITOR_INVALID' }`. Never 401: a share visitor has no account to log in to. + */ + 403: unknown; }; export type ArchiveBridleTranscriptResponses = { - /** - * `{ archivedPath }` for the timestamped copy, or `{}` when there was nothing to archive. - */ - 200: unknown; + /** + * `{ archivedPath }` for the timestamped copy, or `{}` when there was nothing to archive. + */ + 200: unknown; }; export type RevokeAgentShareLinkData = { - body?: never; - path: { - agentId: string; - }; - query?: never; - url: "/agents/{agentId}/share-link"; + body?: never; + path: { + agentId: string; + }; + query?: never; + url: '/agents/{agentId}/share-link'; }; export type RevokeAgentShareLinkErrors = { - /** - * Missing, malformed or expired console bearer token. - */ - 401: unknown; - /** - * The bearer token carried no subject, so there is no user to record as the actor for this write. - */ - 403: unknown; - /** - * No agent with this id. - */ - 404: unknown; + /** + * Missing, malformed or expired console bearer token. + */ + 401: unknown; + /** + * The bearer token carried no subject, so there is no user to record as the actor for this write. + */ + 403: unknown; + /** + * No agent with this id. + */ + 404: unknown; }; export type RevokeAgentShareLinkResponses = { - 200: ShareLinkDto; + 200: ShareLinkDto; }; -export type RevokeAgentShareLinkResponse = - RevokeAgentShareLinkResponses[keyof RevokeAgentShareLinkResponses]; +export type RevokeAgentShareLinkResponse = RevokeAgentShareLinkResponses[keyof RevokeAgentShareLinkResponses]; export type GetAgentShareLinkData = { - body?: never; - path: { - agentId: string; - }; - query?: never; - url: "/agents/{agentId}/share-link"; + body?: never; + path: { + agentId: string; + }; + query?: never; + url: '/agents/{agentId}/share-link'; }; export type GetAgentShareLinkErrors = { - /** - * Missing, malformed or expired console bearer token. - */ - 401: unknown; - /** - * No agent with this id. - */ - 404: unknown; + /** + * Missing, malformed or expired console bearer token. + */ + 401: unknown; + /** + * No agent with this id. + */ + 404: unknown; }; export type GetAgentShareLinkResponses = { - 200: ShareLinkDto; + 200: ShareLinkDto; }; -export type GetAgentShareLinkResponse = - GetAgentShareLinkResponses[keyof GetAgentShareLinkResponses]; +export type GetAgentShareLinkResponse = GetAgentShareLinkResponses[keyof GetAgentShareLinkResponses]; export type CreateAgentShareLinkData = { - body?: never; - path: { - agentId: string; - }; - query?: never; - url: "/agents/{agentId}/share-link"; + body?: never; + path: { + agentId: string; + }; + query?: never; + url: '/agents/{agentId}/share-link'; }; export type CreateAgentShareLinkErrors = { - /** - * Missing, malformed or expired console bearer token. - */ - 401: unknown; - /** - * The bearer token carried no subject, so there is no user to record as the actor for this write. - */ - 403: unknown; - /** - * No agent with this id. - */ - 404: unknown; + /** + * Missing, malformed or expired console bearer token. + */ + 401: unknown; + /** + * The bearer token carried no subject, so there is no user to record as the actor for this write. + */ + 403: unknown; + /** + * No agent with this id. + */ + 404: unknown; }; export type CreateAgentShareLinkResponses = { - 200: ShareLinkDto; + 200: ShareLinkDto; }; -export type CreateAgentShareLinkResponse = - CreateAgentShareLinkResponses[keyof CreateAgentShareLinkResponses]; +export type CreateAgentShareLinkResponse = CreateAgentShareLinkResponses[keyof CreateAgentShareLinkResponses]; export type RegenerateAgentShareLinkData = { - body?: never; - path: { - agentId: string; - }; - query?: never; - url: "/agents/{agentId}/share-link/regenerate"; + body?: never; + path: { + agentId: string; + }; + query?: never; + url: '/agents/{agentId}/share-link/regenerate'; }; export type RegenerateAgentShareLinkErrors = { - /** - * Missing, malformed or expired console bearer token. - */ - 401: unknown; - /** - * The bearer token carried no subject, so there is no user to record as the actor for this write. - */ - 403: unknown; - /** - * No agent with this id. - */ - 404: unknown; + /** + * Missing, malformed or expired console bearer token. + */ + 401: unknown; + /** + * The bearer token carried no subject, so there is no user to record as the actor for this write. + */ + 403: unknown; + /** + * No agent with this id. + */ + 404: unknown; }; export type RegenerateAgentShareLinkResponses = { - 200: ShareLinkDto; + 200: ShareLinkDto; }; -export type RegenerateAgentShareLinkResponse = - RegenerateAgentShareLinkResponses[keyof RegenerateAgentShareLinkResponses]; +export type RegenerateAgentShareLinkResponse = RegenerateAgentShareLinkResponses[keyof RegenerateAgentShareLinkResponses]; export type ResolveShareLinkData = { - body: ShareResolveRequestDto; - path?: never; - query?: never; - url: "/share/resolve"; + body: ShareResolveRequestDto; + path?: never; + query?: never; + url: '/share/resolve'; }; export type ResolveShareLinkErrors = { - /** - * The token is missing or does not look like a share token; rejected by validation before any lookup happens. - */ - 400: unknown; - /** - * No usable link behind this token — unknown, revoked, or pointing at an agent that no longer exists. One identical body (`{ code: 'SHARE_LINK_NOT_FOUND' }`) for all three, so a visitor cannot tell a link that was turned off from one that never existed (FR-013). - */ - 404: unknown; + /** + * The token is missing or does not look like a share token; rejected by validation before any lookup happens. + */ + 400: unknown; + /** + * No usable link behind this token — unknown, revoked, or pointing at an agent that no longer exists. One identical body (`{ code: 'SHARE_LINK_NOT_FOUND' }`) for all three, so a visitor cannot tell a link that was turned off from one that never existed (FR-013). + */ + 404: unknown; }; export type ResolveShareLinkResponses = { - 200: ShareResolvedDto; + 200: ShareResolvedDto; }; -export type ResolveShareLinkResponse = - ResolveShareLinkResponses[keyof ResolveShareLinkResponses]; +export type ResolveShareLinkResponse = ResolveShareLinkResponses[keyof ResolveShareLinkResponses]; export type SkillControllerFindAllData = { - body?: never; - path?: never; - query?: never; - url: "/skills"; + body?: never; + path?: never; + query?: never; + url: '/skills'; }; export type SkillControllerFindAllResponses = { - 200: unknown; + 200: unknown; }; export type SkillControllerCreateData = { - body: CreateSkillDto; - path?: never; - query?: never; - url: "/skills"; + body: CreateSkillDto; + path?: never; + query?: never; + url: '/skills'; }; export type SkillControllerCreateResponses = { - 201: unknown; + 201: unknown; }; export type SkillControllerListSourcesData = { - body?: never; - path?: never; - query?: never; - url: "/skills/sources"; + body?: never; + path?: never; + query?: never; + url: '/skills/sources'; }; export type SkillControllerListSourcesResponses = { - 200: unknown; + 200: unknown; }; export type SkillControllerSearchData = { - body?: never; - path?: never; - query: { - q: string; - }; - url: "/skills/search"; + body?: never; + path?: never; + query: { + q: string; + }; + url: '/skills/search'; }; export type SkillControllerSearchResponses = { - 200: unknown; + 200: unknown; }; export type SkillControllerImportFromUrlData = { - body: ImportSkillUrlDto; - path?: never; - query?: never; - url: "/skills/import-url"; + body: ImportSkillUrlDto; + path?: never; + query?: never; + url: '/skills/import-url'; }; export type SkillControllerImportFromUrlResponses = { - 201: unknown; + 201: unknown; }; export type SkillControllerImportFromGithubData = { - body: ImportSkillDto; - path?: never; - query?: never; - url: "/skills/import"; + body: ImportSkillDto; + path?: never; + query?: never; + url: '/skills/import'; }; export type SkillControllerImportFromGithubResponses = { - 201: unknown; + 201: unknown; }; export type SkillControllerRemoveData = { - body?: never; - path: { - id: string; - }; - query?: never; - url: "/skills/{id}"; + body?: never; + path: { + id: string; + }; + query?: never; + url: '/skills/{id}'; }; export type SkillControllerRemoveResponses = { - 200: unknown; + 200: unknown; }; export type SkillControllerFindByIdData = { - body?: never; - path: { - id: string; - }; - query?: never; - url: "/skills/{id}"; + body?: never; + path: { + id: string; + }; + query?: never; + url: '/skills/{id}'; }; export type SkillControllerFindByIdResponses = { - 200: unknown; + 200: unknown; }; export type SkillControllerUpdateData = { - body: UpdateSkillDto; - path: { - id: string; - }; - query?: never; - url: "/skills/{id}"; + body: UpdateSkillDto; + path: { + id: string; + }; + query?: never; + url: '/skills/{id}'; }; export type SkillControllerUpdateResponses = { - 200: unknown; + 200: unknown; }; export type FindDependentAgentsData = { - body?: never; - path: { - id: string; - }; - query?: never; - url: "/skills/{id}/agents"; + body?: never; + path: { + id: string; + }; + query?: never; + url: '/skills/{id}/agents'; }; export type FindDependentAgentsResponses = { - 200: unknown; + 200: unknown; }; export type GetChatsData = { - body?: never; - path?: never; - query?: { - /** - * Restrict to one agent - */ - agentId?: string; - channel?: "bridle" | "telegram" | "slack" | "internal"; - /** - * Matches title / preview / externalUserId - */ - search?: string; - /** - * Show archived sessions - */ - archived?: boolean; - /** - * Include internal (cron/heartbeat) sessions - */ - includeInternal?: boolean; - page?: number; - perPage?: number; - }; - url: "/chats"; + body?: never; + path?: never; + query?: { + /** + * Restrict to one agent + */ + agentId?: string; + channel?: 'bridle' | 'telegram' | 'slack' | 'internal'; + /** + * Matches title / preview / externalUserId + */ + search?: string; + /** + * Show archived sessions + */ + archived?: boolean; + /** + * Include internal (cron/heartbeat) sessions + */ + includeInternal?: boolean; + page?: number; + perPage?: number; + }; + url: '/chats'; }; export type GetChatsResponses = { - 200: ChatListResponseDto; + 200: ChatListResponseDto; }; export type GetChatsResponse = GetChatsResponses[keyof GetChatsResponses]; export type GetChatData = { - body?: never; - path: { - id: string; - }; - query?: never; - url: "/chats/{id}"; + body?: never; + path: { + id: string; + }; + query?: never; + url: '/chats/{id}'; }; export type GetChatResponses = { - 200: ChatSessionDto; + 200: ChatSessionDto; }; export type GetChatResponse = GetChatResponses[keyof GetChatResponses]; export type GetChatMessagesData = { - body?: never; - path: { - id: string; - }; - query?: { - limit?: number; - /** - * Opaque cursor from a previous page - */ - cursor?: string; - /** - * Comma-separated event types (debug toggle). Default user,assistant,summary. Admins may add tool_call,tool_result,system. - */ - types?: string; - }; - url: "/chats/{id}/messages"; + body?: never; + path: { + id: string; + }; + query?: { + limit?: number; + /** + * Opaque cursor from a previous page + */ + cursor?: string; + /** + * Comma-separated event types (debug toggle). Default user,assistant,summary. Admins may add tool_call,tool_result,system. + */ + types?: string; + }; + url: '/chats/{id}/messages'; }; export type GetChatMessagesResponses = { - 200: ChatMessagesResponseDto; + 200: ChatMessagesResponseDto; }; -export type GetChatMessagesResponse = - GetChatMessagesResponses[keyof GetChatMessagesResponses]; +export type GetChatMessagesResponse = GetChatMessagesResponses[keyof GetChatMessagesResponses]; export type SyncChatsData = { - body: SyncChatsDto; - path?: never; - query?: never; - url: "/chats/sync"; + body: SyncChatsDto; + path?: never; + query?: never; + url: '/chats/sync'; }; export type SyncChatsResponses = { - 200: SyncChatsResponseDto; + 200: SyncChatsResponseDto; }; export type SyncChatsResponse = SyncChatsResponses[keyof SyncChatsResponses]; export type SummarizeChatData = { - body?: never; - path: { - id: string; - }; - query?: never; - url: "/chats/{id}/summarize"; + body?: never; + path: { + id: string; + }; + query?: never; + url: '/chats/{id}/summarize'; }; export type SummarizeChatResponses = { - 200: ChatSessionDto; + 200: ChatSessionDto; }; -export type SummarizeChatResponse = - SummarizeChatResponses[keyof SummarizeChatResponses]; +export type SummarizeChatResponse = SummarizeChatResponses[keyof SummarizeChatResponses]; export type GetMyChatFeedbackData = { - body?: never; - path: { - id: string; - }; - query?: never; - url: "/chats/{id}/feedback"; + body?: never; + path: { + id: string; + }; + query?: never; + url: '/chats/{id}/feedback'; }; export type GetMyChatFeedbackResponses = { - 200: Array; + 200: Array; }; -export type GetMyChatFeedbackResponse = - GetMyChatFeedbackResponses[keyof GetMyChatFeedbackResponses]; +export type GetMyChatFeedbackResponse = GetMyChatFeedbackResponses[keyof GetMyChatFeedbackResponses]; export type CreateChatFeedbackData = { - body: CreateChatFeedbackDto; - path: { - id: string; - }; - query?: never; - url: "/chats/{id}/feedback"; + body: CreateChatFeedbackDto; + path: { + id: string; + }; + query?: never; + url: '/chats/{id}/feedback'; }; export type CreateChatFeedbackResponses = { - 200: ChatFeedbackDto; + 200: ChatFeedbackDto; }; -export type CreateChatFeedbackResponse = - CreateChatFeedbackResponses[keyof CreateChatFeedbackResponses]; +export type CreateChatFeedbackResponse = CreateChatFeedbackResponses[keyof CreateChatFeedbackResponses]; export type DeleteChatFeedbackData = { - body?: never; - path: { - id: string; - messageId: string; - }; - query?: never; - url: "/chats/{id}/feedback/{messageId}"; + body?: never; + path: { + id: string; + messageId: string; + }; + query?: never; + url: '/chats/{id}/feedback/{messageId}'; }; export type DeleteChatFeedbackResponses = { - 204: void; + 204: void; }; -export type DeleteChatFeedbackResponse = - DeleteChatFeedbackResponses[keyof DeleteChatFeedbackResponses]; +export type DeleteChatFeedbackResponse = DeleteChatFeedbackResponses[keyof DeleteChatFeedbackResponses]; export type ExportChatData = { - body?: never; - path: { - id: string; - }; - query?: { - /** - * Download format. json = raw messages, markdown/csv = transcript. - */ - format?: "json" | "markdown" | "csv"; - }; - url: "/chats/{id}/export"; + body?: never; + path: { + id: string; + }; + query?: { + /** + * Download format. json = raw messages, markdown/csv = transcript. + */ + format?: 'json' | 'markdown' | 'csv'; + }; + url: '/chats/{id}/export'; }; export type ExportChatResponses = { - 200: unknown; + 200: unknown; }; export type GetMyChatsData = { - body?: never; - path?: never; - query?: { - /** - * Include archived chats - */ - archived?: boolean; - page?: number; - perPage?: number; - }; - url: "/me/chats"; + body?: never; + path?: never; + query?: { + /** + * Include archived chats + */ + archived?: boolean; + page?: number; + perPage?: number; + }; + url: '/me/chats'; }; export type GetMyChatsResponses = { - 200: ChatListResponseDto; + 200: ChatListResponseDto; }; export type GetMyChatsResponse = GetMyChatsResponses[keyof GetMyChatsResponses]; export type GetMyChatData = { - body?: never; - path: { - id: string; - }; - query?: never; - url: "/me/chats/{id}"; + body?: never; + path: { + id: string; + }; + query?: never; + url: '/me/chats/{id}'; }; export type GetMyChatResponses = { - 200: ChatSessionDto; + 200: ChatSessionDto; }; export type GetMyChatResponse = GetMyChatResponses[keyof GetMyChatResponses]; export type GetMyChatMessagesData = { - body?: never; - path: { - id: string; - }; - query?: { - limit?: number; - /** - * Opaque cursor from a previous page - */ - cursor?: string; - /** - * Comma-separated event types (debug toggle). Default user,assistant,summary. Admins may add tool_call,tool_result,system. - */ - types?: string; - }; - url: "/me/chats/{id}/messages"; + body?: never; + path: { + id: string; + }; + query?: { + limit?: number; + /** + * Opaque cursor from a previous page + */ + cursor?: string; + /** + * Comma-separated event types (debug toggle). Default user,assistant,summary. Admins may add tool_call,tool_result,system. + */ + types?: string; + }; + url: '/me/chats/{id}/messages'; }; export type GetMyChatMessagesResponses = { - 200: ChatMessagesResponseDto; + 200: ChatMessagesResponseDto; }; -export type GetMyChatMessagesResponse = - GetMyChatMessagesResponses[keyof GetMyChatMessagesResponses]; +export type GetMyChatMessagesResponse = GetMyChatMessagesResponses[keyof GetMyChatMessagesResponses]; export type SyncMyChatsData = { - body: SyncChatsDto; - path?: never; - query?: never; - url: "/me/chats/sync"; + body: SyncChatsDto; + path?: never; + query?: never; + url: '/me/chats/sync'; }; export type SyncMyChatsResponses = { - 200: SyncChatsResponseDto; + 200: SyncChatsResponseDto; }; -export type SyncMyChatsResponse = - SyncMyChatsResponses[keyof SyncMyChatsResponses]; +export type SyncMyChatsResponse = SyncMyChatsResponses[keyof SyncMyChatsResponses]; export type ListMyChatFeedbackData = { - body?: never; - path: { - id: string; - }; - query?: never; - url: "/me/chats/{id}/feedback"; + body?: never; + path: { + id: string; + }; + query?: never; + url: '/me/chats/{id}/feedback'; }; export type ListMyChatFeedbackResponses = { - 200: Array; + 200: Array; }; -export type ListMyChatFeedbackResponse = - ListMyChatFeedbackResponses[keyof ListMyChatFeedbackResponses]; +export type ListMyChatFeedbackResponse = ListMyChatFeedbackResponses[keyof ListMyChatFeedbackResponses]; export type CreateMyChatFeedbackData = { - body: CreateChatFeedbackDto; - path: { - id: string; - }; - query?: never; - url: "/me/chats/{id}/feedback"; + body: CreateChatFeedbackDto; + path: { + id: string; + }; + query?: never; + url: '/me/chats/{id}/feedback'; }; export type CreateMyChatFeedbackResponses = { - 200: ChatFeedbackDto; + 200: ChatFeedbackDto; }; -export type CreateMyChatFeedbackResponse = - CreateMyChatFeedbackResponses[keyof CreateMyChatFeedbackResponses]; +export type CreateMyChatFeedbackResponse = CreateMyChatFeedbackResponses[keyof CreateMyChatFeedbackResponses]; export type DeleteMyChatFeedbackData = { - body?: never; - path: { - id: string; - messageId: string; - }; - query?: never; - url: "/me/chats/{id}/feedback/{messageId}"; + body?: never; + path: { + id: string; + messageId: string; + }; + query?: never; + url: '/me/chats/{id}/feedback/{messageId}'; }; export type DeleteMyChatFeedbackResponses = { - 204: void; + 204: void; }; -export type DeleteMyChatFeedbackResponse = - DeleteMyChatFeedbackResponses[keyof DeleteMyChatFeedbackResponses]; +export type DeleteMyChatFeedbackResponse = DeleteMyChatFeedbackResponses[keyof DeleteMyChatFeedbackResponses]; export type ExportMyChatData = { - body?: never; - path: { - id: string; - }; - query?: { - /** - * Download format. json = raw messages, markdown/csv = transcript. - */ - format?: "json" | "markdown" | "csv"; - }; - url: "/me/chats/{id}/export"; + body?: never; + path: { + id: string; + }; + query?: { + /** + * Download format. json = raw messages, markdown/csv = transcript. + */ + format?: 'json' | 'markdown' | 'csv'; + }; + url: '/me/chats/{id}/export'; }; export type ExportMyChatResponses = { - 200: unknown; + 200: unknown; }; export type GetAgentChannelsData = { - body?: never; - path: { - id: string; - }; - query?: never; - url: "/agents/{id}/channels"; + body?: never; + path: { + id: string; + }; + query?: never; + url: '/agents/{id}/channels'; }; export type GetAgentChannelsResponses = { - 200: Array; + 200: Array; }; -export type GetAgentChannelsResponse = - GetAgentChannelsResponses[keyof GetAgentChannelsResponses]; +export type GetAgentChannelsResponse = GetAgentChannelsResponses[keyof GetAgentChannelsResponses]; export type SetAgentChannelsData = { - body: SetAgentChannelsDto; - path: { - id: string; - }; - query?: never; - url: "/agents/{id}/channels"; + body: SetAgentChannelsDto; + path: { + id: string; + }; + query?: never; + url: '/agents/{id}/channels'; }; export type SetAgentChannelsResponses = { - 200: Array; + 200: Array; }; -export type SetAgentChannelsResponse = - SetAgentChannelsResponses[keyof SetAgentChannelsResponses]; +export type SetAgentChannelsResponse = SetAgentChannelsResponses[keyof SetAgentChannelsResponses]; export type UserControllerFindAllData = { - body?: never; - path?: never; - query?: never; - url: "/users"; + body?: never; + path?: never; + query?: never; + url: '/users'; }; export type UserControllerFindAllResponses = { - 200: unknown; + 200: unknown; }; export type UserControllerCreateData = { - body: CreateUserDto; - path?: never; - query?: never; - url: "/users"; + body: CreateUserDto; + path?: never; + query?: never; + url: '/users'; }; export type UserControllerCreateResponses = { - 201: unknown; + 201: unknown; }; export type UserControllerRemoveData = { - body?: never; - path: { - id: string; - }; - query?: never; - url: "/users/{id}"; + body?: never; + path: { + id: string; + }; + query?: never; + url: '/users/{id}'; }; export type UserControllerRemoveResponses = { - 200: unknown; + 200: unknown; }; export type UserControllerFindByIdData = { - body?: never; - path: { - id: string; - }; - query?: never; - url: "/users/{id}"; + body?: never; + path: { + id: string; + }; + query?: never; + url: '/users/{id}'; }; export type UserControllerFindByIdResponses = { - 200: unknown; + 200: unknown; }; export type UserControllerUpdateData = { - body: UpdateUserDto; - path: { - id: string; - }; - query?: never; - url: "/users/{id}"; + body: UpdateUserDto; + path: { + id: string; + }; + query?: never; + url: '/users/{id}'; }; export type UserControllerUpdateResponses = { - 200: unknown; + 200: unknown; }; export type UserControllerUpdateRoleData = { - body: UpdateUserRoleDto; - path: { - id: string; - }; - query?: never; - url: "/users/{id}/role"; + body: UpdateUserRoleDto; + path: { + id: string; + }; + query?: never; + url: '/users/{id}/role'; }; export type UserControllerUpdateRoleResponses = { - 200: unknown; + 200: unknown; }; export type TemplateFileControllerListData = { - body?: never; - path: { - id: string; - }; - query?: never; - url: "/templates/{id}/files"; + body?: never; + path: { + id: string; + }; + query?: never; + url: '/templates/{id}/files'; }; export type TemplateFileControllerListResponses = { - 200: unknown; + 200: unknown; }; export type TemplateFileControllerReadData = { - body?: never; - path: { - id: string; - }; - query: { - path: string; - }; - url: "/templates/{id}/files/content"; + body?: never; + path: { + id: string; + }; + query: { + path: string; + }; + url: '/templates/{id}/files/content'; }; export type TemplateFileControllerReadResponses = { - 200: unknown; + 200: unknown; }; export type TemplateFileControllerSaveData = { - body: SaveTemplateFileDto; - path: { - id: string; - }; - query: { - path: string; - }; - url: "/templates/{id}/files/content"; + body: SaveTemplateFileDto; + path: { + id: string; + }; + query: { + path: string; + }; + url: '/templates/{id}/files/content'; }; export type TemplateFileControllerSaveResponses = { - 200: unknown; + 200: unknown; }; export type TemplateFileControllerUploadData = { - body: { - files?: Array; - /** - * Relative paths matching files[] by index, e.g. ".agent/agent.md" - */ - paths?: Array; - }; - path: { - id: string; - }; - query?: never; - url: "/templates/{id}/files/upload"; + body: { + files?: Array; + /** + * Relative paths matching files[] by index, e.g. ".agent/agent.md" + */ + paths?: Array; + }; + path: { + id: string; + }; + query?: never; + url: '/templates/{id}/files/upload'; }; export type TemplateFileControllerUploadResponses = { - 201: unknown; + 201: unknown; }; export type PreviewTemplateInstallData = { - body: { - archive: Blob | File; - /** - * JSON object of operator-supplied params (e.g. {"language":"ru"}). Empty string or omitted = no params. - */ - params?: string; - }; - path?: never; - query?: never; - url: "/templates/install/preview"; + body: { + archive: Blob | File; + /** + * JSON object of operator-supplied params (e.g. {"language":"ru"}). Empty string or omitted = no params. + */ + params?: string; + }; + path?: never; + query?: never; + url: '/templates/install/preview'; }; export type PreviewTemplateInstallResponses = { - 200: InstallPreviewDto; + 200: InstallPreviewDto; }; -export type PreviewTemplateInstallResponse = - PreviewTemplateInstallResponses[keyof PreviewTemplateInstallResponses]; +export type PreviewTemplateInstallResponse = PreviewTemplateInstallResponses[keyof PreviewTemplateInstallResponses]; export type InstallTemplateData = { - body: { - archive: Blob | File; - /** - * JSON object of operator-supplied params. - */ - params?: string; - /** - * JSON object of operator-supplied secrets used to resolve $secret:NAME references in the manifest (e.g. mcp[].authValue). - */ - secrets?: string; - }; - path?: never; - query?: never; - url: "/templates/install"; + body: { + archive: Blob | File; + /** + * JSON object of operator-supplied params. + */ + params?: string; + /** + * JSON object of operator-supplied secrets used to resolve $secret:NAME references in the manifest (e.g. mcp[].authValue). + */ + secrets?: string; + }; + path?: never; + query?: never; + url: '/templates/install'; }; export type InstallTemplateResponses = { - 200: InstallResultDto; + 200: InstallResultDto; }; -export type InstallTemplateResponse = - InstallTemplateResponses[keyof InstallTemplateResponses]; +export type InstallTemplateResponse = InstallTemplateResponses[keyof InstallTemplateResponses]; export type PreviewTemplateInstallFromGitData = { - body: InstallFromGitDto; - path?: never; - query?: never; - url: "/templates/install/from-git/preview"; + body: InstallFromGitDto; + path?: never; + query?: never; + url: '/templates/install/from-git/preview'; }; export type PreviewTemplateInstallFromGitResponses = { - 200: InstallPreviewDto; + 200: InstallPreviewDto; }; -export type PreviewTemplateInstallFromGitResponse = - PreviewTemplateInstallFromGitResponses[keyof PreviewTemplateInstallFromGitResponses]; +export type PreviewTemplateInstallFromGitResponse = PreviewTemplateInstallFromGitResponses[keyof PreviewTemplateInstallFromGitResponses]; export type InstallTemplateFromGitData = { - body: InstallFromGitDto; - path?: never; - query?: never; - url: "/templates/install/from-git"; + body: InstallFromGitDto; + path?: never; + query?: never; + url: '/templates/install/from-git'; }; export type InstallTemplateFromGitResponses = { - 200: InstallResultDto; + 200: InstallResultDto; }; -export type InstallTemplateFromGitResponse = - InstallTemplateFromGitResponses[keyof InstallTemplateFromGitResponses]; +export type InstallTemplateFromGitResponse = InstallTemplateFromGitResponses[keyof InstallTemplateFromGitResponses]; export type DownloadTemplateData = { - body?: never; - path: { - id: string; - }; - query?: never; - url: "/templates/{id}/download"; + body?: never; + path: { + id: string; + }; + query?: never; + url: '/templates/{id}/download'; }; export type DownloadTemplateResponses = { - 200: unknown; + 200: unknown; }; export type PaddockScenarioControllerFindAllData = { - body?: never; - path?: never; - query?: { - templateId?: string; - agentId?: string; - }; - url: "/paddock-scenarios"; + body?: never; + path?: never; + query?: { + templateId?: string; + agentId?: string; + }; + url: '/paddock-scenarios'; }; export type PaddockScenarioControllerFindAllResponses = { - 200: unknown; + 200: unknown; }; export type PaddockScenarioControllerCreateData = { - body: CreatePaddockScenarioDto; - path?: never; - query?: never; - url: "/paddock-scenarios"; + body: CreatePaddockScenarioDto; + path?: never; + query?: never; + url: '/paddock-scenarios'; }; export type PaddockScenarioControllerCreateResponses = { - 201: unknown; + 201: unknown; }; export type PaddockScenarioControllerRemoveData = { - body?: never; - path: { - id: string; - }; - query?: never; - url: "/paddock-scenarios/{id}"; + body?: never; + path: { + id: string; + }; + query?: never; + url: '/paddock-scenarios/{id}'; }; export type PaddockScenarioControllerRemoveResponses = { - 200: unknown; + 200: unknown; }; export type PaddockScenarioControllerFindByIdData = { - body?: never; - path: { - id: string; - }; - query?: never; - url: "/paddock-scenarios/{id}"; + body?: never; + path: { + id: string; + }; + query?: never; + url: '/paddock-scenarios/{id}'; }; export type PaddockScenarioControllerFindByIdResponses = { - 200: unknown; + 200: unknown; }; export type PaddockScenarioControllerUpdateData = { - body: UpdatePaddockScenarioDto; - path: { - id: string; - }; - query?: never; - url: "/paddock-scenarios/{id}"; + body: UpdatePaddockScenarioDto; + path: { + id: string; + }; + query?: never; + url: '/paddock-scenarios/{id}'; }; export type PaddockScenarioControllerUpdateResponses = { - 200: unknown; + 200: unknown; }; export type PaddockScenarioControllerGenerateData = { - body: GeneratePaddockScenarioDto; - path?: never; - query?: never; - url: "/paddock-scenarios/generate"; + body: GeneratePaddockScenarioDto; + path?: never; + query?: never; + url: '/paddock-scenarios/generate'; }; export type PaddockScenarioControllerGenerateResponses = { - 201: unknown; + 201: unknown; +}; + +export type GetAgentCardData = { + body?: never; + path: { + agentId: string; + }; + query?: never; + url: '/agents/{agentId}/card'; +}; + +export type GetAgentCardErrors = { + /** + * Missing, malformed or expired console bearer token. + */ + 401: unknown; + /** + * No agent or connection with this id. + */ + 404: unknown; +}; + +export type GetAgentCardResponses = { + 200: AgentCardDto; +}; + +export type GetAgentCardResponse = GetAgentCardResponses[keyof GetAgentCardResponses]; + +export type ListAgentPeersData = { + body?: never; + path: { + agentId: string; + }; + query?: never; + url: '/agents/{agentId}/peers'; +}; + +export type ListAgentPeersErrors = { + /** + * Missing, malformed or expired console bearer token. + */ + 401: unknown; + /** + * No agent or connection with this id. + */ + 404: unknown; +}; + +export type ListAgentPeersResponses = { + 200: Array; +}; + +export type ListAgentPeersResponse = ListAgentPeersResponses[keyof ListAgentPeersResponses]; + +export type ConnectAgentPeerData = { + body: ConnectPeerDto; + path: { + agentId: string; + }; + query?: never; + url: '/agents/{agentId}/peers'; +}; + +export type ConnectAgentPeerErrors = { + /** + * Missing, malformed or expired console bearer token. + */ + 401: unknown; + /** + * No agent or connection with this id. + */ + 404: unknown; + /** + * Already a peer of this agent. + */ + 409: unknown; + /** + * The peer's card could not be read; nothing was saved. + */ + 502: unknown; +}; + +export type ConnectAgentPeerResponses = { + 200: AgentPeerDto; +}; + +export type ConnectAgentPeerResponse = ConnectAgentPeerResponses[keyof ConnectAgentPeerResponses]; + +export type ListAgentPeerCandidatesData = { + body?: never; + path: { + agentId: string; + }; + query?: never; + url: '/agents/{agentId}/peers/candidates'; +}; + +export type ListAgentPeerCandidatesErrors = { + /** + * Missing, malformed or expired console bearer token. + */ + 401: unknown; + /** + * No agent or connection with this id. + */ + 404: unknown; +}; + +export type ListAgentPeerCandidatesResponses = { + 200: Array; +}; + +export type ListAgentPeerCandidatesResponse = ListAgentPeerCandidatesResponses[keyof ListAgentPeerCandidatesResponses]; + +export type RefreshAgentPeerData = { + body?: never; + path: { + agentId: string; + peerId: string; + }; + query?: never; + url: '/agents/{agentId}/peers/{peerId}/refresh'; +}; + +export type RefreshAgentPeerErrors = { + /** + * Missing, malformed or expired console bearer token. + */ + 401: unknown; + /** + * No agent or connection with this id. + */ + 404: unknown; + /** + * The peer's card could not be read; the old snapshot is kept. + */ + 502: unknown; +}; + +export type RefreshAgentPeerResponses = { + 200: AgentPeerDto; +}; + +export type RefreshAgentPeerResponse = RefreshAgentPeerResponses[keyof RefreshAgentPeerResponses]; + +export type RemoveAgentPeerData = { + body?: never; + path: { + agentId: string; + peerId: string; + }; + query?: never; + url: '/agents/{agentId}/peers/{peerId}'; +}; + +export type RemoveAgentPeerErrors = { + /** + * Missing, malformed or expired console bearer token. + */ + 401: unknown; + /** + * No agent or connection with this id. + */ + 404: unknown; +}; + +export type RemoveAgentPeerResponses = { + /** + * Disconnected. + */ + 204: void; +}; + +export type RemoveAgentPeerResponse = RemoveAgentPeerResponses[keyof RemoveAgentPeerResponses]; + +export type ListAgentDelegationsData = { + body?: never; + path: { + agentId: string; + }; + query?: { + /** + * How many recent delegations to return. + */ + limit?: number; + }; + url: '/agents/{agentId}/delegations'; +}; + +export type ListAgentDelegationsErrors = { + /** + * Missing, malformed or expired console bearer token. + */ + 401: unknown; + /** + * No agent or connection with this id. + */ + 404: unknown; +}; + +export type ListAgentDelegationsResponses = { + 200: Array; }; +export type ListAgentDelegationsResponse = ListAgentDelegationsResponses[keyof ListAgentDelegationsResponses]; + export type SecretControllerDeleteData = { - body: DeleteSecretDto; - path: { - agentId: string; - }; - query?: never; - url: "/agents/{agentId}/secrets"; + body: DeleteSecretDto; + path: { + agentId: string; + }; + query?: never; + url: '/agents/{agentId}/secrets'; }; export type SecretControllerDeleteResponses = { - 200: SecretListDto; + 200: SecretListDto; }; -export type SecretControllerDeleteResponse = - SecretControllerDeleteResponses[keyof SecretControllerDeleteResponses]; +export type SecretControllerDeleteResponse = SecretControllerDeleteResponses[keyof SecretControllerDeleteResponses]; export type SecretControllerListData = { - body?: never; - path: { - agentId: string; - }; - query?: never; - url: "/agents/{agentId}/secrets"; + body?: never; + path: { + agentId: string; + }; + query?: never; + url: '/agents/{agentId}/secrets'; }; export type SecretControllerListResponses = { - 200: SecretListDto; + 200: SecretListDto; }; -export type SecretControllerListResponse = - SecretControllerListResponses[keyof SecretControllerListResponses]; +export type SecretControllerListResponse = SecretControllerListResponses[keyof SecretControllerListResponses]; export type SecretControllerSetData = { - body: SetSecretDto; - path: { - agentId: string; - }; - query?: never; - url: "/agents/{agentId}/secrets"; + body: SetSecretDto; + path: { + agentId: string; + }; + query?: never; + url: '/agents/{agentId}/secrets'; }; export type SecretControllerSetResponses = { - 200: SecretListDto; + 200: SecretListDto; }; -export type SecretControllerSetResponse = - SecretControllerSetResponses[keyof SecretControllerSetResponses]; +export type SecretControllerSetResponse = SecretControllerSetResponses[keyof SecretControllerSetResponses]; export type SecretControllerReplaceData = { - body: ReplaceSecretsDto; - path: { - agentId: string; - }; - query?: never; - url: "/agents/{agentId}/secrets/replace"; + body: ReplaceSecretsDto; + path: { + agentId: string; + }; + query?: never; + url: '/agents/{agentId}/secrets/replace'; }; export type SecretControllerReplaceResponses = { - 200: SecretListDto; + 200: SecretListDto; }; -export type SecretControllerReplaceResponse = - SecretControllerReplaceResponses[keyof SecretControllerReplaceResponses]; +export type SecretControllerReplaceResponse = SecretControllerReplaceResponses[keyof SecretControllerReplaceResponses]; export type LogControllerGetLogsData = { - body?: never; - path: { - agentId: string; - }; - query: { - tail: string; - }; - url: "/agents/{agentId}/logs"; + body?: never; + path: { + agentId: string; + }; + query: { + tail: string; + }; + url: '/agents/{agentId}/logs'; }; export type LogControllerGetLogsResponses = { - 200: unknown; + 200: unknown; }; export type UsageControllerFindForAgentData = { - body?: never; - path: { - agentId: string; - }; - query?: never; - url: "/agents/{agentId}/usage"; + body?: never; + path: { + agentId: string; + }; + query?: never; + url: '/agents/{agentId}/usage'; }; export type UsageControllerFindForAgentResponses = { - 200: unknown; + 200: unknown; }; export type UsageControllerReportData = { - body: ReportUsageDto; - headers: { - "x-bridle-api-key": string; - }; - path: { - agentId: string; - }; - query?: never; - url: "/agents/{agentId}/usage"; + body: ReportUsageDto; + headers: { + 'x-bridle-api-key': string; + }; + path: { + agentId: string; + }; + query?: never; + url: '/agents/{agentId}/usage'; }; export type UsageControllerReportResponses = { - 204: void; + 204: void; }; -export type UsageControllerReportResponse = - UsageControllerReportResponses[keyof UsageControllerReportResponses]; +export type UsageControllerReportResponse = UsageControllerReportResponses[keyof UsageControllerReportResponses]; export type UsageControllerFindForCredentialData = { - body?: never; - path: { - id: string; - }; - query?: never; - url: "/llms/{id}/usage"; + body?: never; + path: { + id: string; + }; + query?: never; + url: '/llms/{id}/usage'; }; export type UsageControllerFindForCredentialResponses = { - 200: unknown; + 200: unknown; }; export type UsageControllerFindOverviewData = { - body?: never; - path?: never; - query?: never; - url: "/usage/overview"; + body?: never; + path?: never; + query?: never; + url: '/usage/overview'; }; export type UsageControllerFindOverviewResponses = { - 200: unknown; + 200: unknown; }; export type RancherControllerStatusData = { - body?: never; - path?: never; - query?: never; - url: "/rancher/status"; + body?: never; + path?: never; + query?: never; + url: '/rancher/status'; }; export type RancherControllerStatusResponses = { - 200: unknown; + 200: unknown; }; export type RancherControllerEnsureTemplateData = { - body?: never; - path?: never; - query?: never; - url: "/rancher/template"; + body?: never; + path?: never; + query?: never; + url: '/rancher/template'; }; export type RancherControllerEnsureTemplateResponses = { - 201: unknown; + 201: unknown; }; export type UpgradeControllerStatusData = { - body?: never; - path?: never; - query?: never; - url: "/upgrade/status"; + body?: never; + path?: never; + query?: never; + url: '/upgrade/status'; }; export type UpgradeControllerStatusResponses = { - 200: unknown; + 200: unknown; }; export type UpgradeControllerRunData = { - body?: never; - path?: never; - query?: never; - url: "/upgrade"; + body?: never; + path?: never; + query?: never; + url: '/upgrade'; }; export type UpgradeControllerRunResponses = { - 201: unknown; + 201: unknown; +}; + +export type StartMcpOauthData = { + body: StartMcpOauthDto; + headers: { + 'x-bridle-api-key': string; + }; + path: { + serverId: string; + }; + query?: never; + url: '/mcp-servers/{serverId}/oauth/start'; +}; + +export type StartMcpOauthResponses = { + 200: StartMcpOauthResultDto; +}; + +export type StartMcpOauthResponse = StartMcpOauthResponses[keyof StartMcpOauthResponses]; + +export type McpOauthCallbackData = { + body?: never; + path: { + serverId: string; + }; + query: { + code: string; + state: string; + }; + url: '/mcp-servers/{serverId}/oauth/callback'; +}; + +export type McpOauthCallbackResponses = { + 200: unknown; +}; + +export type McpOauthStatusData = { + body?: never; + headers: { + 'x-bridle-api-key': string; + }; + path: { + serverId: string; + }; + query: { + agentId: string; + }; + url: '/mcp-servers/{serverId}/oauth/status'; +}; + +export type McpOauthStatusResponses = { + 200: McpOauthStatusDto; }; +export type McpOauthStatusResponse = McpOauthStatusResponses[keyof McpOauthStatusResponses]; + export type PaddockEvaluationControllerListData = { - body?: never; - path?: never; - query?: { - agentId?: string; - templateId?: string; - limit?: string; - }; - url: "/paddock-evaluations"; + body?: never; + path?: never; + query?: { + agentId?: string; + templateId?: string; + limit?: string; + }; + url: '/paddock-evaluations'; }; export type PaddockEvaluationControllerListResponses = { - 200: unknown; + 200: unknown; }; export type PaddockEvaluationControllerStartData = { - body: RunPaddockEvaluationDto; - path?: never; - query?: never; - url: "/paddock-evaluations"; + body: RunPaddockEvaluationDto; + path?: never; + query?: never; + url: '/paddock-evaluations'; }; export type PaddockEvaluationControllerStartResponses = { - 201: unknown; + 201: unknown; }; export type PaddockEvaluationControllerGetData = { - body?: never; - path: { - id: string; - }; - query?: never; - url: "/paddock-evaluations/{id}"; + body?: never; + path: { + id: string; + }; + query?: never; + url: '/paddock-evaluations/{id}'; }; export type PaddockEvaluationControllerGetResponses = { - 200: unknown; + 200: unknown; }; export type PaddockEvaluationControllerReportData = { - body?: never; - path: { - id: string; - }; - query?: never; - url: "/paddock-evaluations/{id}/report"; + body?: never; + path: { + id: string; + }; + query?: never; + url: '/paddock-evaluations/{id}/report'; }; export type PaddockEvaluationControllerReportResponses = { - 200: unknown; + 200: unknown; }; export type PaddockEvaluationControllerLogsData = { - body?: never; - path: { - id: string; - }; - query?: never; - url: "/paddock-evaluations/{id}/logs"; + body?: never; + path: { + id: string; + }; + query?: never; + url: '/paddock-evaluations/{id}/logs'; }; export type PaddockEvaluationControllerLogsResponses = { - 200: unknown; + 200: unknown; }; export type PaddockEvaluationControllerScenarioData = { - body?: never; - path: { - id: string; - scenarioId: string; - }; - query?: never; - url: "/paddock-evaluations/{id}/scenarios/{scenarioId}"; + body?: never; + path: { + id: string; + scenarioId: string; + }; + query?: never; + url: '/paddock-evaluations/{id}/scenarios/{scenarioId}'; }; export type PaddockEvaluationControllerScenarioResponses = { - 200: unknown; + 200: unknown; }; export type PaddockEvaluationControllerTraceData = { - body?: never; - path: { - id: string; - }; - query: { - scenarioId: string; - }; - url: "/paddock-evaluations/{id}/trace"; + body?: never; + path: { + id: string; + }; + query: { + scenarioId: string; + }; + url: '/paddock-evaluations/{id}/trace'; }; export type PaddockEvaluationControllerTraceResponses = { - 200: unknown; + 200: unknown; }; export type PaddockEvaluationControllerAbortData = { - body?: never; - path: { - id: string; - }; - query?: never; - url: "/paddock-evaluations/{id}/abort"; + body?: never; + path: { + id: string; + }; + query?: never; + url: '/paddock-evaluations/{id}/abort'; }; export type PaddockEvaluationControllerAbortResponses = { - 201: unknown; + 201: unknown; }; export type PaddockEvaluationControllerRerunData = { - body?: never; - path: { - id: string; - }; - query?: never; - url: "/paddock-evaluations/{id}/rerun"; + body?: never; + path: { + id: string; + }; + query?: never; + url: '/paddock-evaluations/{id}/rerun'; }; export type PaddockEvaluationControllerRerunResponses = { - 201: unknown; + 201: unknown; }; export type ListBrowserSessionsData = { - body?: never; - path?: never; - query?: never; - url: "/browser/sessions"; + body?: never; + path?: never; + query?: never; + url: '/browser/sessions'; }; export type ListBrowserSessionsResponses = { - 200: Array; + 200: Array; }; -export type ListBrowserSessionsResponse = - ListBrowserSessionsResponses[keyof ListBrowserSessionsResponses]; +export type ListBrowserSessionsResponse = ListBrowserSessionsResponses[keyof ListBrowserSessionsResponses]; export type OpenBrowserSessionData = { - body: OpenSessionDto; - path?: never; - query?: never; - url: "/browser/sessions"; + body: OpenSessionDto; + path?: never; + query?: never; + url: '/browser/sessions'; }; export type OpenBrowserSessionResponses = { - 200: BrowserSessionConnectionDto; + 200: BrowserSessionConnectionDto; }; -export type OpenBrowserSessionResponse = - OpenBrowserSessionResponses[keyof OpenBrowserSessionResponses]; +export type OpenBrowserSessionResponse = OpenBrowserSessionResponses[keyof OpenBrowserSessionResponses]; export type DeleteBrowserSessionData = { - body?: never; - path: { - id: string; - }; - query?: never; - url: "/browser/sessions/{id}"; + body?: never; + path: { + id: string; + }; + query?: never; + url: '/browser/sessions/{id}'; }; export type DeleteBrowserSessionResponses = { - 204: void; + 204: void; }; -export type DeleteBrowserSessionResponse = - DeleteBrowserSessionResponses[keyof DeleteBrowserSessionResponses]; +export type DeleteBrowserSessionResponse = DeleteBrowserSessionResponses[keyof DeleteBrowserSessionResponses]; export type GetBrowserSessionData = { - body?: never; - path: { - id: string; - }; - query?: never; - url: "/browser/sessions/{id}"; + body?: never; + path: { + id: string; + }; + query?: never; + url: '/browser/sessions/{id}'; }; export type GetBrowserSessionResponses = { - 200: BrowserSessionDto; + 200: BrowserSessionDto; }; -export type GetBrowserSessionResponse = - GetBrowserSessionResponses[keyof GetBrowserSessionResponses]; +export type GetBrowserSessionResponse = GetBrowserSessionResponses[keyof GetBrowserSessionResponses]; export type ResetBrowserSessionData = { - body?: never; - path: { - id: string; - }; - query?: never; - url: "/browser/sessions/{id}/reset"; + body?: never; + path: { + id: string; + }; + query?: never; + url: '/browser/sessions/{id}/reset'; }; export type ResetBrowserSessionResponses = { - 200: BrowserSessionConnectionDto; + 200: BrowserSessionConnectionDto; }; -export type ResetBrowserSessionResponse = - ResetBrowserSessionResponses[keyof ResetBrowserSessionResponses]; +export type ResetBrowserSessionResponse = ResetBrowserSessionResponses[keyof ResetBrowserSessionResponses]; export type SetBrowserSessionStatusData = { - body: SetStatusDto; - path: { - id: string; - }; - query?: never; - url: "/browser/sessions/{id}/status"; + body: SetStatusDto; + path: { + id: string; + }; + query?: never; + url: '/browser/sessions/{id}/status'; }; export type SetBrowserSessionStatusResponses = { - 200: BrowserSessionDto; + 200: BrowserSessionDto; }; -export type SetBrowserSessionStatusResponse = - SetBrowserSessionStatusResponses[keyof SetBrowserSessionStatusResponses]; +export type SetBrowserSessionStatusResponse = SetBrowserSessionStatusResponses[keyof SetBrowserSessionStatusResponses]; export type MintBrowserSessionVncUrlData = { - body?: never; - path: { - id: string; - }; - query?: never; - url: "/browser/sessions/{id}/vnc-url"; + body?: never; + path: { + id: string; + }; + query?: never; + url: '/browser/sessions/{id}/vnc-url'; }; export type MintBrowserSessionVncUrlResponses = { - 201: unknown; + 201: unknown; }; export type OpenBrowserSessionInternalData = { - body: OpenInternalSessionDto; - path?: never; - query?: never; - url: "/browser/internal/sessions"; + body: OpenInternalSessionDto; + path?: never; + query?: never; + url: '/browser/internal/sessions'; }; export type OpenBrowserSessionInternalResponses = { - 201: unknown; + 201: unknown; }; export type SetBrowserSessionStatusInternalData = { - body: InternalSetStatusDto; - path: { - id: string; - }; - query?: never; - url: "/browser/internal/sessions/{id}/status"; + body: InternalSetStatusDto; + path: { + id: string; + }; + query?: never; + url: '/browser/internal/sessions/{id}/status'; }; export type SetBrowserSessionStatusInternalResponses = { - 201: unknown; + 201: unknown; }; export type ResetBrowserSessionInternalData = { - body: InternalSessionRefDto; - path: { - id: string; - }; - query?: never; - url: "/browser/internal/sessions/{id}/reset"; + body: InternalSessionRefDto; + path: { + id: string; + }; + query?: never; + url: '/browser/internal/sessions/{id}/reset'; }; export type ResetBrowserSessionInternalResponses = { - 201: unknown; + 201: unknown; }; export type HarvestBrowserSessionInternalData = { - body: InternalSessionRefDto; - path: { - id: string; - }; - query?: never; - url: "/browser/internal/sessions/{id}/harvest"; + body: InternalSessionRefDto; + path: { + id: string; + }; + query?: never; + url: '/browser/internal/sessions/{id}/harvest'; }; export type HarvestBrowserSessionInternalResponses = { - 201: unknown; + 201: unknown; }; export type CleanupBrowserSessionsInternalData = { - body?: never; - path?: never; - query?: never; - url: "/browser/internal/sessions/cleanup"; + body?: never; + path?: never; + query?: never; + url: '/browser/internal/sessions/cleanup'; }; export type CleanupBrowserSessionsInternalResponses = { - 201: unknown; + 201: unknown; }; export type IssueBrowserExtensionTokenData = { - body: IssueExtensionTokenDto; - path?: never; - query?: never; - url: "/browser/extension/token"; + body: IssueExtensionTokenDto; + path?: never; + query?: never; + url: '/browser/extension/token'; }; export type IssueBrowserExtensionTokenResponses = { - 200: IssueExtensionTokenResponseDto; + 200: IssueExtensionTokenResponseDto; }; -export type IssueBrowserExtensionTokenResponse = - IssueBrowserExtensionTokenResponses[keyof IssueBrowserExtensionTokenResponses]; +export type IssueBrowserExtensionTokenResponse = IssueBrowserExtensionTokenResponses[keyof IssueBrowserExtensionTokenResponses]; export type ImportBrowserStorageStateData = { - body: ImportStateDto; - headers: { - authorization: string; - }; - path?: never; - query?: never; - url: "/browser/extension/import-state"; + body: ImportStateDto; + headers: { + authorization: string; + }; + path?: never; + query?: never; + url: '/browser/extension/import-state'; }; export type ImportBrowserStorageStateResponses = { - 200: ImportStateResponseDto; + 200: ImportStateResponseDto; }; -export type ImportBrowserStorageStateResponse = - ImportBrowserStorageStateResponses[keyof ImportBrowserStorageStateResponses]; +export type ImportBrowserStorageStateResponse = ImportBrowserStorageStateResponses[keyof ImportBrowserStorageStateResponses]; export type ListIntegrationCatalogueData = { - body?: never; - path?: never; - query?: never; - url: "/integrations/catalogue"; + body?: never; + path?: never; + query?: never; + url: '/integrations/catalogue'; }; export type ListIntegrationCatalogueResponses = { - 200: Array; + 200: Array; }; -export type ListIntegrationCatalogueResponse = - ListIntegrationCatalogueResponses[keyof ListIntegrationCatalogueResponses]; +export type ListIntegrationCatalogueResponse = ListIntegrationCatalogueResponses[keyof ListIntegrationCatalogueResponses]; export type ListIntegrationAccountsData = { - body?: never; - path?: never; - query?: never; - url: "/integrations/accounts"; + body?: never; + path?: never; + query?: never; + url: '/integrations/accounts'; }; export type ListIntegrationAccountsResponses = { - 200: Array; + 200: Array; }; -export type ListIntegrationAccountsResponse = - ListIntegrationAccountsResponses[keyof ListIntegrationAccountsResponses]; +export type ListIntegrationAccountsResponse = ListIntegrationAccountsResponses[keyof ListIntegrationAccountsResponses]; export type ConnectIntegrationData = { - body: ConnectIntegrationDto; - path?: never; - query?: never; - url: "/integrations/accounts"; + body: ConnectIntegrationDto; + path?: never; + query?: never; + url: '/integrations/accounts'; }; export type ConnectIntegrationResponses = { - 200: IntegrationAccountDto; + 200: IntegrationAccountDto; }; -export type ConnectIntegrationResponse = - ConnectIntegrationResponses[keyof ConnectIntegrationResponses]; +export type ConnectIntegrationResponse = ConnectIntegrationResponses[keyof ConnectIntegrationResponses]; export type DisconnectIntegrationData = { - body?: never; - path: { - id: string; - }; - query?: never; - url: "/integrations/accounts/{id}"; + body?: never; + path: { + id: string; + }; + query?: never; + url: '/integrations/accounts/{id}'; }; export type DisconnectIntegrationResponses = { - 204: void; + 204: void; }; -export type DisconnectIntegrationResponse = - DisconnectIntegrationResponses[keyof DisconnectIntegrationResponses]; +export type DisconnectIntegrationResponse = DisconnectIntegrationResponses[keyof DisconnectIntegrationResponses]; export type GetIntegrationAccountData = { - body?: never; - path: { - id: string; - }; - query?: never; - url: "/integrations/accounts/{id}"; + body?: never; + path: { + id: string; + }; + query?: never; + url: '/integrations/accounts/{id}'; }; export type GetIntegrationAccountResponses = { - 200: IntegrationAccountDto; + 200: IntegrationAccountDto; }; -export type GetIntegrationAccountResponse = - GetIntegrationAccountResponses[keyof GetIntegrationAccountResponses]; +export type GetIntegrationAccountResponse = GetIntegrationAccountResponses[keyof GetIntegrationAccountResponses]; export type OpenIntegrationLoginData = { - body?: never; - path: { - id: string; - }; - query?: never; - url: "/integrations/accounts/{id}/login"; + body?: never; + path: { + id: string; + }; + query?: never; + url: '/integrations/accounts/{id}/login'; }; export type OpenIntegrationLoginResponses = { - 200: LoginInstructionDto; + 200: LoginInstructionDto; }; -export type OpenIntegrationLoginResponse = - OpenIntegrationLoginResponses[keyof OpenIntegrationLoginResponses]; +export type OpenIntegrationLoginResponse = OpenIntegrationLoginResponses[keyof OpenIntegrationLoginResponses]; export type ImportIntegrationCookiesData = { - body: ImportCookiesDto; - path: { - id: string; - }; - query?: never; - url: "/integrations/accounts/{id}/import-cookies"; + body: ImportCookiesDto; + path: { + id: string; + }; + query?: never; + url: '/integrations/accounts/{id}/import-cookies'; }; export type ImportIntegrationCookiesResponses = { - 200: IntegrationAccountDto; + 200: IntegrationAccountDto; }; -export type ImportIntegrationCookiesResponse = - ImportIntegrationCookiesResponses[keyof ImportIntegrationCookiesResponses]; +export type ImportIntegrationCookiesResponse = ImportIntegrationCookiesResponses[keyof ImportIntegrationCookiesResponses]; export type SaveIntegrationSecretData = { - body: SaveSecretDto; - path: { - id: string; - }; - query?: never; - url: "/integrations/accounts/{id}/secret"; + body: SaveSecretDto; + path: { + id: string; + }; + query?: never; + url: '/integrations/accounts/{id}/secret'; }; export type SaveIntegrationSecretResponses = { - 200: IntegrationAccountDto; + 200: IntegrationAccountDto; }; -export type SaveIntegrationSecretResponse = - SaveIntegrationSecretResponses[keyof SaveIntegrationSecretResponses]; +export type SaveIntegrationSecretResponse = SaveIntegrationSecretResponses[keyof SaveIntegrationSecretResponses]; export type ResolveIntegrationSecretsData = { - body?: never; - path?: never; - query?: { - /** - * Restrict to one catalogue service (e.g. "openai"). Omit to resolve every connected secret-mechanism integration. - */ - service?: string; - }; - url: "/integrations/internal/secrets"; + body?: never; + path?: never; + query?: { + /** + * Restrict to one catalogue service (e.g. "openai"). Omit to resolve every connected secret-mechanism integration. + */ + service?: string; + }; + url: '/integrations/internal/secrets'; }; export type ResolveIntegrationSecretsResponses = { - 200: ResolvedSecretsDto; + 200: ResolvedSecretsDto; }; -export type ResolveIntegrationSecretsResponse = - ResolveIntegrationSecretsResponses[keyof ResolveIntegrationSecretsResponses]; +export type ResolveIntegrationSecretsResponse = ResolveIntegrationSecretsResponses[keyof ResolveIntegrationSecretsResponses]; export type ListIntegrationAccountsForRuntimeData = { - body?: never; - path?: never; - query?: never; - url: "/integrations/internal/accounts"; + body?: never; + path?: never; + query?: never; + url: '/integrations/internal/accounts'; }; export type ListIntegrationAccountsForRuntimeResponses = { - 200: ListAccountsResponseDto; + 200: ListAccountsResponseDto; }; -export type ListIntegrationAccountsForRuntimeResponse = - ListIntegrationAccountsForRuntimeResponses[keyof ListIntegrationAccountsForRuntimeResponses]; +export type ListIntegrationAccountsForRuntimeResponse = ListIntegrationAccountsForRuntimeResponses[keyof ListIntegrationAccountsForRuntimeResponses]; export type RequestIntegrationLoginData = { - body: RequestLoginBodyDto; - path?: never; - query?: never; - url: "/integrations/internal/request-login"; + body: RequestLoginBodyDto; + path?: never; + query?: never; + url: '/integrations/internal/request-login'; }; export type RequestIntegrationLoginResponses = { - 200: LoginInstructionDto; + 200: LoginInstructionDto; }; -export type RequestIntegrationLoginResponse = - RequestIntegrationLoginResponses[keyof RequestIntegrationLoginResponses]; +export type RequestIntegrationLoginResponse = RequestIntegrationLoginResponses[keyof RequestIntegrationLoginResponses]; export type ResolveIntegrationBrowserStateData = { - body?: never; - path?: never; - query: { - /** - * Profile identifier — same value passed to `browser_play`. For integration accounts it is composed as `:` (e.g. `instagram:miybot`). - */ - profile: string; - }; - url: "/integrations/internal/browser-state"; + body?: never; + path?: never; + query: { + /** + * Profile identifier — same value passed to `browser_play`. For integration accounts it is composed as `:` (e.g. `instagram:miybot`). + */ + profile: string; + }; + url: '/integrations/internal/browser-state'; }; export type ResolveIntegrationBrowserStateResponses = { - 200: unknown; + 200: unknown; }; export type IssueIntegrationExtensionTokenData = { - body: IssueIntegrationExtensionTokenDto; - path?: never; - query?: never; - url: "/integrations/extension/token"; + body: IssueIntegrationExtensionTokenDto; + path?: never; + query?: never; + url: '/integrations/extension/token'; }; export type IssueIntegrationExtensionTokenResponses = { - 200: IssueIntegrationExtensionTokenResponseDto; + 200: IssueIntegrationExtensionTokenResponseDto; }; -export type IssueIntegrationExtensionTokenResponse = - IssueIntegrationExtensionTokenResponses[keyof IssueIntegrationExtensionTokenResponses]; +export type IssueIntegrationExtensionTokenResponse = IssueIntegrationExtensionTokenResponses[keyof IssueIntegrationExtensionTokenResponses]; export type ImportIntegrationExtensionStateData = { - body: ExtensionImportStateDto; - headers: { - authorization: string; - }; - path?: never; - query?: never; - url: "/integrations/extension/import-state"; + body: ExtensionImportStateDto; + headers: { + authorization: string; + }; + path?: never; + query?: never; + url: '/integrations/extension/import-state'; }; export type ImportIntegrationExtensionStateResponses = { - 200: ExtensionImportStateResponseDto; + 200: ExtensionImportStateResponseDto; }; -export type ImportIntegrationExtensionStateResponse = - ImportIntegrationExtensionStateResponses[keyof ImportIntegrationExtensionStateResponses]; +export type ImportIntegrationExtensionStateResponse = ImportIntegrationExtensionStateResponses[keyof ImportIntegrationExtensionStateResponses]; export type DownloadIntegrationExtensionData = { - body?: never; - path?: never; - query?: never; - url: "/integrations/extension/download"; + body?: never; + path?: never; + query?: never; + url: '/integrations/extension/download'; }; export type DownloadIntegrationExtensionResponses = { - 200: unknown; + 200: unknown; }; export type SseControllerSseData = { - body?: never; - path?: never; - query?: never; - url: "/mcp/sse"; + body?: never; + path?: never; + query?: never; + url: '/mcp/sse'; }; export type SseControllerSseResponses = { - 200: unknown; + 200: unknown; }; export type SseControllerMessagesData = { - body?: never; - path?: never; - query?: never; - url: "/mcp/messages"; + body?: never; + path?: never; + query?: never; + url: '/mcp/messages'; }; export type SseControllerMessagesResponses = { - 201: unknown; + 201: unknown; }; export type SseControllerDebugSessionsData = { - body?: never; - path: { - teamId: string; - }; - query?: never; - url: "/teams/{teamId}/sessions/debug"; + body?: never; + path: { + teamId: string; + }; + query?: never; + url: '/teams/{teamId}/sessions/debug'; }; export type SseControllerDebugSessionsResponses = { - 200: unknown; + 200: unknown; }; export type StreamableHttpControllerHandleDeleteRequestData = { - body?: never; - path?: never; - query?: never; - url: "/mcp/mcp"; + body?: never; + path?: never; + query?: never; + url: '/mcp/mcp'; }; export type StreamableHttpControllerHandleDeleteRequestResponses = { - 200: unknown; + 200: unknown; }; export type StreamableHttpControllerHandleGetRequestData = { - body?: never; - path: { - teamId: string; - }; - query?: never; - url: "/mcp/mcp"; + body?: never; + path: { + teamId: string; + }; + query?: never; + url: '/mcp/mcp'; }; export type StreamableHttpControllerHandleGetRequestResponses = { - 200: unknown; + 200: unknown; }; export type StreamableHttpControllerHandlePostRequestData = { - body?: never; - path: { - teamId: string; - }; - query?: never; - url: "/mcp/mcp"; + body?: never; + path: { + teamId: string; + }; + query?: never; + url: '/mcp/mcp'; }; export type StreamableHttpControllerHandlePostRequestResponses = { - 201: unknown; + 201: unknown; }; export type ClientOptions = { - baseURL: string; -}; + baseURL: string; +}; \ No newline at end of file diff --git a/api/src/slices/agent/peer/domain/a2a.client.spec.ts b/api/src/slices/agent/peer/domain/a2a.client.spec.ts new file mode 100644 index 00000000..b7645d0c --- /dev/null +++ b/api/src/slices/agent/peer/domain/a2a.client.spec.ts @@ -0,0 +1,255 @@ +import { A2aClient } from './a2a.client'; +import { DelegationErrorCodes, PeerCardUnreachableError } from './peer.types'; +import { A2aTaskStates, type IA2aSendMessageParams } from './a2a.types'; + +/** + * The outbound edge. Its whole job is to turn somebody else's failure into a + * code this product can put in front of a person, so each case pins one + * translation: a dead host, a revoked credential, a peer that answered + * something other than a task. + */ +const card = { + name: 'Support Bot', + description: 'Answers order questions.', + version: '1', + supportedInterfaces: [ + { + url: 'https://api.test/a2a/agents/b', + protocolBinding: 'JSONRPC', + protocolVersion: '1.0', + }, + ], + capabilities: {}, + defaultInputModes: ['text/plain'], + defaultOutputModes: ['text/plain'], + skills: [], +}; + +const task = { + id: 't1', + contextId: 'ctx-1', + status: { state: A2aTaskStates.Completed, timestamp: '2026-09-14T10:00:00Z' }, + artifacts: [{ artifactId: 'reply', parts: [{ text: 'the answer' }] }], + history: [], +}; + +function respond(options: { + ok?: boolean; + status?: number; + json?: unknown; + text?: string; + jsonThrows?: boolean; +}) { + const status = options.status ?? 200; + return { + ok: options.ok ?? (status >= 200 && status < 300), + status, + json: async () => { + if (options.jsonThrows) throw new Error('not json'); + return options.json; + }, + text: async () => options.text ?? '', + } as unknown as Response; +} + +const params: IA2aSendMessageParams = { + message: { + messageId: 'm1', + role: 'ROLE_USER', + parts: [{ text: 'What is the return window?' }], + }, +}; + +const CARD_URL = 'https://api.test/a2a/agents/b/.well-known/agent-card.json'; +const RPC_URL = 'https://api.test/a2a/agents/b'; +const TOKEN = 'ap_' + 'x'.repeat(43); + +describe('A2aClient.fetchCard', () => { + let fetchMock: jest.Mock; + + beforeEach(() => { + fetchMock = jest.fn(); + global.fetch = fetchMock as unknown as typeof fetch; + }); + + it('reads a card with the credential issued for that connection', async () => { + fetchMock.mockResolvedValue(respond({ json: card })); + + await expect(new A2aClient().fetchCard(CARD_URL, TOKEN)).resolves.toEqual( + card, + ); + expect(fetchMock).toHaveBeenCalledWith( + CARD_URL, + expect.objectContaining({ + headers: expect.objectContaining({ Authorization: `Bearer ${TOKEN}` }), + }), + ); + }); + + it('reports an unreachable host in words an operator can act on', async () => { + fetchMock.mockRejectedValue(new Error('ECONNREFUSED')); + + await expect( + new A2aClient().fetchCard(CARD_URL, TOKEN), + ).rejects.toBeInstanceOf(PeerCardUnreachableError); + }); + + it('names a timeout as a timeout rather than an abort', async () => { + const timeout = Object.assign(new Error('aborted'), { + name: 'TimeoutError', + }); + fetchMock.mockRejectedValue(timeout); + + await expect(new A2aClient().fetchCard(CARD_URL, TOKEN)).rejects.toThrow( + /did not answer in time/, + ); + }); + + it('carries the status through when the card route refuses', async () => { + fetchMock.mockResolvedValue( + respond({ status: 401, text: 'A2A_UNAUTHORIZED' }), + ); + + await expect(new A2aClient().fetchCard(CARD_URL, TOKEN)).rejects.toMatchObject( + { status: 401 }, + ); + }); + + it('refuses a body that is not JSON', async () => { + fetchMock.mockResolvedValue(respond({ jsonThrows: true })); + + await expect(new A2aClient().fetchCard(CARD_URL, TOKEN)).rejects.toThrow( + /not valid JSON/, + ); + }); + + it('refuses a document that parses but is not an agent card', async () => { + fetchMock.mockResolvedValue(respond({ json: { hello: 'world' } })); + + await expect(new A2aClient().fetchCard(CARD_URL, TOKEN)).rejects.toThrow( + /not an agent card/, + ); + }); + + it('refuses a card with no way to reach the agent', async () => { + fetchMock.mockResolvedValue( + respond({ json: { ...card, supportedInterfaces: [] } }), + ); + + await expect(new A2aClient().fetchCard(CARD_URL, TOKEN)).rejects.toThrow( + /not an agent card/, + ); + }); +}); + +describe('A2aClient.sendMessage', () => { + let fetchMock: jest.Mock; + + beforeEach(() => { + fetchMock = jest.fn(); + global.fetch = fetchMock as unknown as typeof fetch; + }); + + it('sends a JSON-RPC SendMessage with the version header and the credential', async () => { + fetchMock.mockResolvedValue(respond({ json: { result: { task } } })); + + await new A2aClient().sendMessage(RPC_URL, TOKEN, params, 1000); + + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe(RPC_URL); + expect(init.method).toBe('POST'); + expect(init.headers).toMatchObject({ + Authorization: `Bearer ${TOKEN}`, + 'Content-Type': 'application/json', + 'A2A-Version': '1.0', + }); + expect(JSON.parse(init.body as string)).toMatchObject({ + jsonrpc: '2.0', + method: 'SendMessage', + params, + }); + }); + + it('returns the finished task', async () => { + fetchMock.mockResolvedValue(respond({ json: { result: { task } } })); + + await expect( + new A2aClient().sendMessage(RPC_URL, TOKEN, params, 1000), + ).resolves.toEqual(task); + }); + + it('calls a revoked credential what it is', async () => { + fetchMock.mockResolvedValue(respond({ status: 401 })); + + await expect( + new A2aClient().sendMessage(RPC_URL, TOKEN, params, 1000), + ).rejects.toMatchObject({ code: DelegationErrorCodes.Unauthorized }); + }); + + it('treats a forbidden answer the same way', async () => { + fetchMock.mockResolvedValue(respond({ status: 403 })); + + await expect( + new A2aClient().sendMessage(RPC_URL, TOKEN, params, 1000), + ).rejects.toMatchObject({ code: DelegationErrorCodes.Unauthorized }); + }); + + it('reports a server error as unreachable, with what it said', async () => { + fetchMock.mockResolvedValue( + respond({ status: 500, text: 'Internal Server Error' }), + ); + + await expect( + new A2aClient().sendMessage(RPC_URL, TOKEN, params, 1000), + ).rejects.toMatchObject({ + code: DelegationErrorCodes.Unreachable, + message: expect.stringContaining('500'), + }); + }); + + it('reports a network failure as unreachable', async () => { + fetchMock.mockRejectedValue(new Error('socket hang up')); + + await expect( + new A2aClient().sendMessage(RPC_URL, TOKEN, params, 1000), + ).rejects.toMatchObject({ code: DelegationErrorCodes.Unreachable }); + }); + + it('passes a protocol error through with its own message', async () => { + fetchMock.mockResolvedValue( + respond({ + json: { error: { code: -32004, message: 'This agent does not support X' } }, + }), + ); + + await expect( + new A2aClient().sendMessage(RPC_URL, TOKEN, params, 1000), + ).rejects.toMatchObject({ + code: DelegationErrorCodes.Error, + message: 'This agent does not support X', + }); + }); + + it('refuses an answer that is not a task, having nothing to poll with', async () => { + fetchMock.mockResolvedValue( + respond({ json: { result: { message: { parts: [] } } } }), + ); + + await expect( + new A2aClient().sendMessage(RPC_URL, TOKEN, params, 1000), + ).rejects.toMatchObject({ + code: DelegationErrorCodes.Error, + message: 'answered without a task', + }); + }); + + it('waits longer than the peer own limit, so its stated timeout wins', async () => { + fetchMock.mockResolvedValue(respond({ json: { result: { task } } })); + const spy = jest.spyOn(AbortSignal, 'timeout'); + + await new A2aClient().sendMessage(RPC_URL, TOKEN, params, 120_000); + + expect(spy).toHaveBeenCalledWith(125_000); + spy.mockRestore(); + }); +}); diff --git a/api/src/slices/agent/peer/domain/a2a.client.ts b/api/src/slices/agent/peer/domain/a2a.client.ts new file mode 100644 index 00000000..49a2d7cd --- /dev/null +++ b/api/src/slices/agent/peer/domain/a2a.client.ts @@ -0,0 +1,192 @@ +import { Injectable, Logger } from '@nestjs/common'; +import * as crypto from 'crypto'; +import { + A2A_VERSION, + A2A_VERSION_HEADER, + A2aMethods, + type IA2aAgentCard, + type IA2aSendMessageParams, + type IA2aTask, + type IJsonRpcResponse, +} from './a2a.types'; +import { + DelegationError, + DelegationErrorCodes, + PeerCardUnreachableError, +} from './peer.types'; + +/** A card read is a small GET; ten seconds is generous and bounds a hung peer. */ +const CARD_TIMEOUT_MS = 10_000; + +/** Headroom over the peer's own wait, so its stated timeout wins the race. */ +const CLIENT_TIMEOUT_MARGIN_MS = 5_000; + +/** Enough of a failing body to diagnose, not enough to flood a log line. */ +const BODY_EXCERPT_CHARS = 200; + +/** + * The outbound half of A2A (CLEAN-74): reading a peer's card and handing it a + * task. Every failure is translated into a code the caller can turn into + * product wording — a delegating agent must be able to tell the person *why* + * it came back empty-handed, and "TypeError: fetch failed" is not that. + */ +@Injectable() +export class A2aClient { + private readonly logger = new Logger(A2aClient.name); + + /** + * Reads a card at connect and refresh time. Validated beyond "it parsed": + * a snapshot missing skills or an interface is useless later, and failing + * now names the problem while an operator is still looking at the screen. + */ + async fetchCard(cardUrl: string, token: string): Promise { + let response: Response; + try { + response = await fetch(cardUrl, { + headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' }, + signal: AbortSignal.timeout(CARD_TIMEOUT_MS), + }); + } catch (err) { + throw new PeerCardUnreachableError( + `Could not reach the card at ${cardUrl}: ${describe(err)}`, + ); + } + + if (!response.ok) { + const body = await excerpt(response); + throw new PeerCardUnreachableError( + `The card at ${cardUrl} answered ${response.status}${body ? `: ${body}` : ''}`, + response.status, + ); + } + + let card: unknown; + try { + card = await response.json(); + } catch { + throw new PeerCardUnreachableError( + `The card at ${cardUrl} is not valid JSON`, + response.status, + ); + } + + if (!isCard(card)) { + throw new PeerCardUnreachableError( + `The document at ${cardUrl} is not an agent card`, + response.status, + ); + } + + return card; + } + + /** + * Hands a task to a peer and waits for the finished task. + * + * A peer that answers with a message instead of a task is treated as an + * error: this client asked for blocking work and has nothing to poll with, + * so a message would leave the delegation with no outcome to report. + */ + async sendMessage( + interfaceUrl: string, + token: string, + params: IA2aSendMessageParams, + timeoutMs: number, + ): Promise { + let response: Response; + try { + response = await fetch(interfaceUrl, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + [A2A_VERSION_HEADER]: A2A_VERSION, + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: crypto.randomUUID(), + method: A2aMethods.SendMessage, + params, + }), + signal: AbortSignal.timeout(timeoutMs + CLIENT_TIMEOUT_MARGIN_MS), + }); + } catch (err) { + throw new DelegationError( + DelegationErrorCodes.Unreachable, + `could not be reached: ${describe(err)}`, + ); + } + + if (response.status === 401 || response.status === 403) { + throw new DelegationError( + DelegationErrorCodes.Unauthorized, + 'refused this credential — the connection may have been removed', + ); + } + + if (!response.ok) { + const body = await excerpt(response); + throw new DelegationError( + DelegationErrorCodes.Unreachable, + `answered ${response.status}${body ? `: ${body}` : ''}`, + ); + } + + let payload: IJsonRpcResponse; + try { + payload = (await response.json()) as IJsonRpcResponse; + } catch { + throw new DelegationError( + DelegationErrorCodes.Error, + 'answered with something that is not JSON', + ); + } + + if (payload.error) { + throw new DelegationError( + DelegationErrorCodes.Error, + payload.error.message || `protocol error ${payload.error.code}`, + ); + } + + const task = (payload.result as { task?: IA2aTask } | undefined)?.task; + if (!task?.status) { + throw new DelegationError( + DelegationErrorCodes.Error, + 'answered without a task', + ); + } + + return task; + } +} + +function isCard(value: unknown): value is IA2aAgentCard { + if (!value || typeof value !== 'object') return false; + const card = value as Record; + return ( + typeof card.name === 'string' && + Array.isArray(card.skills) && + Array.isArray(card.supportedInterfaces) && + card.supportedInterfaces.length > 0 + ); +} + +function describe(err: unknown): string { + if (err instanceof Error) { + // AbortSignal.timeout rejects with TimeoutError; "aborted" alone reads as + // if somebody cancelled it. + if (err.name === 'TimeoutError') return 'it did not answer in time'; + return err.message; + } + return String(err); +} + +async function excerpt(response: Response): Promise { + try { + const text = await response.text(); + return text.slice(0, BODY_EXCERPT_CHARS).trim(); + } catch { + return ''; + } +} diff --git a/api/src/slices/agent/peer/domain/index.ts b/api/src/slices/agent/peer/domain/index.ts index c8d433e6..299c02d2 100644 --- a/api/src/slices/agent/peer/domain/index.ts +++ b/api/src/slices/agent/peer/domain/index.ts @@ -5,3 +5,5 @@ export * from './delegation.gateway'; export * from './agentCard.service'; export * from './a2aTask.store'; export * from './a2a.server.service'; +export * from './a2a.client'; +export * from './peer.service'; diff --git a/api/src/slices/agent/peer/domain/peer.service.spec.ts b/api/src/slices/agent/peer/domain/peer.service.spec.ts new file mode 100644 index 00000000..c25f3a12 --- /dev/null +++ b/api/src/slices/agent/peer/domain/peer.service.spec.ts @@ -0,0 +1,289 @@ +import { PeerService } from './peer.service'; +import { PeerCardUnreachableError, PEER_TOKEN_RE } from './peer.types'; +import type { IPeerGateway } from './peer.gateway'; +import type { AgentCardService } from './agentCard.service'; +import type { A2aClient } from './a2a.client'; +import type { IAgentGateway } from '#/agent/agent/domain'; +import type { IA2aAgentCard } from './a2a.types'; + +/** + * Connecting a peer. The rule these cases defend: "connected" must mean the + * credential was proved against the real card route, so an operator can never + * hold a connection that quietly does not work. The corollary is the failure + * path — a failed read must leave nothing behind. + */ +const card = (name: string): IA2aAgentCard => ({ + name, + description: `${name} answers things`, + version: '1', + supportedInterfaces: [ + { + url: `https://api.test/a2a/agents/${name}`, + protocolBinding: 'JSONRPC', + protocolVersion: '1.0', + }, + ], + capabilities: {}, + defaultInputModes: ['text/plain'], + defaultOutputModes: ['text/plain'], + skills: [{ id: 'skill:1', name: 'Orders', description: 'Finds', tags: ['skill'] }], +}); + +function makeHarness(options: { + agents?: Array<{ id: string; name: string; status: string }>; + fetchCardThrows?: unknown; +} = {}) { + const agents = options.agents ?? [ + { id: 'a', name: 'Caller', status: 'running' }, + { id: 'b', name: 'Support Bot', status: 'running' }, + { id: 'c', name: 'Third', status: 'stopped' }, + ]; + + const rows: Record> = {}; + let seq = 0; + + const peers = { + listByAgent: jest.fn(async (agentId: string) => + Object.values(rows).filter((r) => r.agentId === agentId), + ), + findById: jest.fn(async (id: string) => rows[id] ?? null), + findByPair: jest.fn( + async (agentId: string, peerAgentId: string) => + Object.values(rows).find( + (r) => r.agentId === agentId && r.peerAgentId === peerAgentId, + ) ?? null, + ), + findByToken: jest.fn( + async (token: string) => + Object.values(rows).find((r) => r.token === token) ?? null, + ), + create: jest.fn(async (input: Record) => { + const id = `peer-${(seq += 1)}`; + rows[id] = { + id, + ...input, + cardReadAt: input.cardReadAt.toISOString(), + createdAt: new Date(0).toISOString(), + updatedAt: new Date(0).toISOString(), + }; + return rows[id]; + }), + updateSnapshot: jest.fn( + async (id: string, input: Record) => { + Object.assign(rows[id], { + cardSnapshot: input.cardSnapshot, + cardUrl: input.cardUrl, + cardReadAt: input.cardReadAt.toISOString(), + }); + return rows[id]; + }, + ), + delete: jest.fn(async (id: string) => { + delete rows[id]; + }), + } as unknown as IPeerGateway; + + const agentGateway = { + findAll: jest.fn(async () => agents), + findById: jest.fn(async (id: string) => agents.find((a) => a.id === id) ?? null), + } as unknown as IAgentGateway; + + const cards = { + build: jest.fn(async (id: string) => card(id)), + cardUrlFor: jest.fn( + async (id: string) => + `https://api.test/a2a/agents/${id}/.well-known/agent-card.json`, + ), + } as unknown as AgentCardService; + + const fetchCard = jest.fn(async (_url: string, _token: string) => { + if (options.fetchCardThrows) throw options.fetchCardThrows; + return card('Support Bot'); + }); + const client = { fetchCard } as unknown as A2aClient; + + const service = new PeerService(peers, agentGateway, cards, client); + return { service, peers, rows, fetchCard, cards }; +} + +describe('PeerService.connect', () => { + it('mints a credential of the documented shape', async () => { + const { service, rows } = makeHarness(); + + await service.connect('a', 'b'); + + const [row] = Object.values(rows); + expect(row.token).toMatch(PEER_TOKEN_RE); + }); + + it('proves the credential works by reading the card with it', async () => { + const { service, fetchCard, rows } = makeHarness(); + + await service.connect('a', 'b'); + + const [row] = Object.values(rows); + expect(fetchCard).toHaveBeenCalledWith( + 'https://api.test/a2a/agents/b/.well-known/agent-card.json', + row.token, + ); + }); + + it('stores the card it read, with the time it read it', async () => { + const { service } = makeHarness(); + + const view = await service.connect('a', 'b'); + + expect(view.card.name).toBe('Support Bot'); + expect(view.cardReadAt).toEqual(expect.any(String)); + expect(view.peerName).toBe('Support Bot'); + expect(view.peerStatus).toBe('running'); + }); + + it('leaves nothing behind when the card cannot be read', async () => { + const { service, rows } = makeHarness({ + fetchCardThrows: new PeerCardUnreachableError('host is down'), + }); + + await expect(service.connect('a', 'b')).rejects.toMatchObject({ + response: { code: 'PEER_CARD_UNREACHABLE' }, + }); + expect(Object.values(rows)).toHaveLength(0); + }); + + it('refuses to connect an agent to itself', async () => { + const { service } = makeHarness(); + + await expect(service.connect('a', 'a')).rejects.toMatchObject({ + response: { code: 'PEER_SELF' }, + }); + }); + + it('refuses a second connection to a peer it already holds', async () => { + const { service } = makeHarness(); + await service.connect('a', 'b'); + + await expect(service.connect('a', 'b')).rejects.toMatchObject({ + response: { code: 'PEER_EXISTS' }, + }); + }); + + it('refuses a peer that does not exist', async () => { + const { service } = makeHarness(); + + await expect(service.connect('a', 'ghost')).rejects.toMatchObject({ + response: { code: 'PEER_NOT_FOUND' }, + }); + }); + + it('never lets the credential reach the caller', async () => { + const { service } = makeHarness(); + + const view = await service.connect('a', 'b'); + + expect(JSON.stringify(view)).not.toContain('ap_'); + expect(view).not.toHaveProperty('token'); + }); + + it('leaves the opposite direction alone', async () => { + const { service } = makeHarness(); + await service.connect('a', 'b'); + + await expect(service.list('b')).resolves.toEqual([]); + }); +}); + +describe('PeerService.refresh', () => { + it('replaces the snapshot with what the card says now', async () => { + const { service, fetchCard } = makeHarness(); + const connected = await service.connect('a', 'b'); + fetchCard.mockResolvedValue({ + ...card('Support Bot'), + description: 'Now also handles refunds.', + }); + + const refreshed = await service.refresh('a', connected.id); + + expect(refreshed.card.description).toBe('Now also handles refunds.'); + }); + + it('keeps the old snapshot when the read fails, and says what failed', async () => { + const { service, fetchCard } = makeHarness(); + const connected = await service.connect('a', 'b'); + fetchCard.mockRejectedValue(new PeerCardUnreachableError('502 Bad Gateway')); + + await expect(service.refresh('a', connected.id)).rejects.toMatchObject({ + response: { code: 'PEER_CARD_UNREACHABLE' }, + }); + const [still] = await service.list('a'); + expect(still.card.description).toBe('Support Bot answers things'); + }); + + it('refuses to refresh a connection that belongs to another agent', async () => { + const { service } = makeHarness(); + const connected = await service.connect('a', 'b'); + + await expect(service.refresh('c', connected.id)).rejects.toMatchObject({ + response: { code: 'PEER_NOT_FOUND' }, + }); + }); +}); + +describe('PeerService.remove', () => { + it('kills the connection and with it the credential', async () => { + const { service, peers, rows } = makeHarness(); + const connected = await service.connect('a', 'b'); + const token = Object.values(rows)[0].token as string; + + await service.remove('a', connected.id); + + await expect(peers.findByToken(token)).resolves.toBeNull(); + await expect(service.list('a')).resolves.toEqual([]); + }); + + it('refuses to remove a connection that belongs to another agent', async () => { + const { service } = makeHarness(); + const connected = await service.connect('a', 'b'); + + await expect(service.remove('c', connected.id)).rejects.toMatchObject({ + response: { code: 'PEER_NOT_FOUND' }, + }); + }); +}); + +describe('PeerService.candidates', () => { + it('offers every other agent and marks the ones already connected', async () => { + const { service } = makeHarness(); + await service.connect('a', 'b'); + + const candidates = await service.candidates('a'); + + expect(candidates).toEqual([ + { id: 'b', name: 'Support Bot', status: 'running', connected: true }, + { id: 'c', name: 'Third', status: 'stopped', connected: false }, + ]); + }); + + it('never offers the agent itself', async () => { + const { service } = makeHarness(); + + const candidates = await service.candidates('a'); + + expect(candidates.map((c) => c.id)).not.toContain('a'); + }); +}); + +describe('PeerService.list', () => { + it('reports a peer whose agent has been deleted as gone', async () => { + const { service, rows } = makeHarness(); + await service.connect('a', 'b'); + // The FK cascade normally removes the row with the agent; this is the + // remote-peer shape the view has to survive anyway. + Object.values(rows)[0].peerAgentId = 'vanished'; + + const [view] = await service.list('a'); + + expect(view.peerExists).toBe(false); + expect(view.peerStatus).toBe('unknown'); + expect(view.peerName).toBe('Support Bot'); + }); +}); diff --git a/api/src/slices/agent/peer/domain/peer.service.ts b/api/src/slices/agent/peer/domain/peer.service.ts new file mode 100644 index 00000000..a574ffe7 --- /dev/null +++ b/api/src/slices/agent/peer/domain/peer.service.ts @@ -0,0 +1,210 @@ +import { + BadGatewayException, + BadRequestException, + ConflictException, + Injectable, + Logger, + NotFoundException, +} from '@nestjs/common'; +import * as crypto from 'crypto'; +import { IAgentGateway } from '#/agent/agent/domain'; +import { IPeerGateway } from './peer.gateway'; +import { AgentCardService } from './agentCard.service'; +import { A2aClient } from './a2a.client'; +import { + PEER_TOKEN_BYTES, + PEER_TOKEN_PREFIX, + PeerCardUnreachableError, + PeerErrorCodes, + type IAgentPeerCandidate, + type IAgentPeerData, + type IAgentPeerView, +} from './peer.types'; + +/** + * Connecting, refreshing and removing peers (CLEAN-74). + * + * The shape worth knowing: a connection is created BEFORE its card is read, + * because the credential has to exist for the read to be authorised. A failed + * read then deletes the row — so "connected" always means "we proved, with + * this very credential, that the card is readable", and an operator never ends + * up holding a connection that cannot be used. + */ +@Injectable() +export class PeerService { + private readonly logger = new Logger(PeerService.name); + + constructor( + private readonly peers: IPeerGateway, + private readonly agents: IAgentGateway, + private readonly cards: AgentCardService, + private readonly client: A2aClient, + ) {} + + async list(agentId: string): Promise { + const rows = await this.peers.listByAgent(agentId); + return Promise.all(rows.map((row) => this.toView(row))); + } + + /** Every other agent of this installation, marked with what is already connected. */ + async candidates(agentId: string): Promise { + const [all, connected] = await Promise.all([ + this.agents.findAll(), + this.peers.listByAgent(agentId), + ]); + const connectedIds = new Set(connected.map((p) => p.peerAgentId)); + + return all + .filter((agent) => agent.id !== agentId) + .map((agent) => ({ + id: agent.id, + name: agent.name, + status: agent.status, + connected: connectedIds.has(agent.id), + })); + } + + async connect(agentId: string, peerAgentId: string): Promise { + if (agentId === peerAgentId) { + throw new BadRequestException({ + code: PeerErrorCodes.Self, + message: 'An agent cannot be its own peer', + }); + } + + const [caller, peerAgent] = await Promise.all([ + this.agents.findById(agentId), + this.agents.findById(peerAgentId), + ]); + if (!caller || !peerAgent) { + throw new NotFoundException({ + code: PeerErrorCodes.NotFound, + message: 'Agent not found', + }); + } + + const existing = await this.peers.findByPair(agentId, peerAgentId); + if (existing) { + throw new ConflictException({ + code: PeerErrorCodes.Exists, + message: `«${peerAgent.name}» is already a peer of this agent`, + }); + } + + const cardUrl = await this.cards.cardUrlFor(peerAgentId); + const token = this.mintToken(); + + // Created first: the card route requires the credential, so reading the + // card IS the proof that this connection works end to end. + let row = await this.peers.create({ + agentId, + peerAgentId, + token, + cardSnapshot: await this.cards.build(peerAgentId), + cardUrl, + cardReadAt: new Date(), + }); + + try { + const card = await this.client.fetchCard(cardUrl, token); + row = await this.peers.updateSnapshot(row.id, { + cardSnapshot: card, + cardUrl, + cardReadAt: new Date(), + }); + } catch (err) { + await this.peers.delete(row.id); + throw this.cardUnreachable(err, peerAgent.name); + } + + return this.toView(row); + } + + /** + * Re-reads a peer's card. A failed read keeps the old snapshot: a stale + * description is worth more than none, and the operator is told what failed. + */ + async refresh(agentId: string, peerId: string): Promise { + const row = await this.requireOwned(agentId, peerId); + const cardUrl = await this.cards.cardUrlFor(row.peerAgentId); + + try { + const card = await this.client.fetchCard(cardUrl, row.token); + const updated = await this.peers.updateSnapshot(row.id, { + cardSnapshot: card, + cardUrl, + cardReadAt: new Date(), + }); + return this.toView(updated); + } catch (err) { + throw this.cardUnreachable(err, row.cardSnapshot?.name ?? 'the peer'); + } + } + + /** Deleting the row is what revokes the credential — there is no other copy. */ + async remove(agentId: string, peerId: string): Promise { + const row = await this.requireOwned(agentId, peerId); + await this.peers.delete(row.id); + this.logger.log( + `Peer removed: agent=${agentId} peer=${row.peerAgentId} (credential revoked)`, + ); + } + + private async requireOwned( + agentId: string, + peerId: string, + ): Promise { + const row = await this.peers.findById(peerId); + // Same answer for "no such connection" and "not yours": one agent must not + // be able to probe another's peer list by guessing ids. + if (!row || row.agentId !== agentId) { + throw new NotFoundException({ + code: PeerErrorCodes.NotFound, + message: 'Peer connection not found', + }); + } + return row; + } + + private mintToken(): string { + return ( + PEER_TOKEN_PREFIX + + crypto.randomBytes(PEER_TOKEN_BYTES).toString('base64url') + ); + } + + private cardUnreachable(err: unknown, peerName: string): BadGatewayException { + const detail = + err instanceof PeerCardUnreachableError + ? err.message + : err instanceof Error + ? err.message + : String(err); + this.logger.warn(`Card read failed for «${peerName}»: ${detail}`); + return new BadGatewayException({ + code: PeerErrorCodes.CardUnreachable, + message: `Could not read the card of «${peerName}». ${detail}`, + }); + } + + /** + * The public shape of a connection. `token` is dropped here, once, so no + * controller has to remember to: a credential that reaches a browser is a + * credential that has left the building. + */ + private async toView(row: IAgentPeerData): Promise { + const peerAgent = await this.agents.findById(row.peerAgentId); + return { + id: row.id, + agentId: row.agentId, + peerAgentId: row.peerAgentId, + peerName: peerAgent?.name ?? row.cardSnapshot?.name ?? 'Unknown agent', + peerStatus: peerAgent?.status ?? 'unknown', + peerExists: Boolean(peerAgent), + card: row.cardSnapshot, + cardUrl: row.cardUrl, + cardReadAt: row.cardReadAt, + createdAt: row.createdAt, + }; + } +} diff --git a/api/src/slices/agent/peer/dtos/agentCard.dto.ts b/api/src/slices/agent/peer/dtos/agentCard.dto.ts new file mode 100644 index 00000000..63dfcca0 --- /dev/null +++ b/api/src/slices/agent/peer/dtos/agentCard.dto.ts @@ -0,0 +1,100 @@ +import { ApiProperty } from '@nestjs/swagger'; + +/** + * The agent card as the console shows it (CLEAN-74). Response-only — the + * shape mirrors the A2A 1.0 `AgentCard` so the picker preview and the "Agent + * card" panel render exactly what another agent would read, rather than a + * prettier summary that could drift from it. + */ +export class AgentSkillDto { + @ApiProperty({ + description: + 'Stable id of the skill. Prefixed by where it came from: `skill:` for a ' + + 'template skill, `knowledge:` for a bound knowledge base.', + example: 'knowledge:9a1f…', + }) + id: string; + + @ApiProperty({ description: 'Short name.', example: 'Returns policy' }) + name: string; + + @ApiProperty({ + description: + 'What this lets the agent do, written so another agent can decide when ' + + 'to ask.', + example: 'Answers questions about «Returns policy»: 2026 policy PDF.', + }) + description: string; + + @ApiProperty({ + type: [String], + description: 'Origin tags: `skill` or `knowledge`.', + example: ['knowledge'], + }) + tags: string[]; +} + +export class AgentInterfaceDto { + @ApiProperty({ + description: 'Where another agent sends tasks for this one.', + example: 'https://api.ranch.example/a2a/agents/6f1c…', + }) + url: string; + + @ApiProperty({ description: 'Transport binding.', example: 'JSONRPC' }) + protocolBinding: string; + + @ApiProperty({ description: 'A2A protocol version.', example: '1.0' }) + protocolVersion: string; +} + +export class AgentCapabilitiesDto { + @ApiProperty({ + required: false, + description: 'Whether the agent streams partial answers. Ranch: false.', + example: false, + }) + streaming?: boolean; + + @ApiProperty({ + required: false, + description: 'Whether the agent can call back when a task finishes. Ranch: false.', + example: false, + }) + pushNotifications?: boolean; +} + +export class AgentCardDto { + @ApiProperty({ example: 'Support Bot' }) + name: string; + + @ApiProperty({ + description: + "The agent's own description, falling back to its template's.", + example: 'Answers customer questions about orders and returns.', + }) + description: string; + + @ApiProperty({ description: "The template's version.", example: '1' }) + version: string; + + @ApiProperty({ type: [AgentInterfaceDto] }) + supportedInterfaces: AgentInterfaceDto[]; + + @ApiProperty({ type: AgentCapabilitiesDto }) + capabilities: AgentCapabilitiesDto; + + @ApiProperty({ type: [String], example: ['text/plain'] }) + defaultInputModes: string[]; + + @ApiProperty({ type: [String], example: ['text/plain'] }) + defaultOutputModes: string[]; + + @ApiProperty({ + type: [AgentSkillDto], + description: + 'One entry per template skill and per bound knowledge base. May be ' + + 'empty: an agent with nothing to advertise still has a valid card.', + }) + skills: AgentSkillDto[]; +} diff --git a/api/src/slices/agent/peer/dtos/agentDelegation.dto.ts b/api/src/slices/agent/peer/dtos/agentDelegation.dto.ts new file mode 100644 index 00000000..863980d9 --- /dev/null +++ b/api/src/slices/agent/peer/dtos/agentDelegation.dto.ts @@ -0,0 +1,68 @@ +import { ApiProperty } from '@nestjs/swagger'; + +/** + * One recorded delegation (CLEAN-74, FR-016) — the audit trail that lets an + * operator see who asked whom, when and how it went without reading chat text. + */ +export class AgentDelegationDto { + @ApiProperty() + id: string; + + @ApiProperty({ description: 'The peer that was asked.' }) + peerAgentId: string; + + @ApiProperty({ example: 'Support Bot' }) + peerName: string; + + @ApiProperty({ + description: 'The self-contained task the peer received.', + example: 'What is the return window for shoes?', + }) + task: string; + + @ApiProperty({ + description: "The calling model's one-line reason for choosing this peer.", + example: 'Support Bot holds the returns policy base', + }) + reason: string; + + @ApiProperty({ + description: 'waiting | answered | failed | rejected.', + example: 'answered', + }) + status: string; + + @ApiProperty({ + type: String, + nullable: true, + description: + 'Why it did not produce an answer: PEER_NOT_RUNNING, PEER_TIMEOUT, ' + + 'PEER_REJECTED_LOOP, PEER_REJECTED_DEPTH, PEER_UNAUTHORIZED, ' + + 'PEER_UNREACHABLE or PEER_ERROR. Null while waiting and on success.', + example: null, + }) + errorCode: string | null; + + @ApiProperty({ + type: String, + nullable: true, + description: + 'The first part of the reply, or the cause in product wording.', + example: 'Shoes can be returned within 30 days…', + }) + excerpt: string | null; + + @ApiProperty({ format: 'date-time', example: '2026-09-14T10:00:00.000Z' }) + startedAt: string; + + @ApiProperty({ + type: String, + format: 'date-time', + nullable: true, + example: '2026-09-14T10:00:03.120Z', + }) + finishedAt: string | null; + + @ApiProperty({ type: Number, nullable: true, example: 3120 }) + durationMs: number | null; +} diff --git a/api/src/slices/agent/peer/dtos/agentPeer.dto.ts b/api/src/slices/agent/peer/dtos/agentPeer.dto.ts new file mode 100644 index 00000000..db0b8d71 --- /dev/null +++ b/api/src/slices/agent/peer/dtos/agentPeer.dto.ts @@ -0,0 +1,70 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { AgentCardDto } from './agentCard.dto'; + +/** + * One directed peer connection as the console sees it (CLEAN-74). + * + * Response-only, and deliberately without the pair credential: it exists so + * the API can present it to the peer, and a secret that reaches a browser is a + * secret that has left the building. `peer.service.spec` asserts its absence. + */ +export class AgentPeerDto { + @ApiProperty({ description: 'Id of the connection, not of either agent.' }) + id: string; + + @ApiProperty({ description: 'The agent that holds the card (the caller).' }) + agentId: string; + + @ApiProperty({ description: 'The agent whose card is held (the peer).' }) + peerAgentId: string; + + @ApiProperty({ + description: + "The peer's current name. Falls back to the name on the stored card " + + 'when the agent itself is gone.', + example: 'Support Bot', + }) + peerName: string; + + @ApiProperty({ + description: + "The peer's live agent status. 'running' means a delegation can " + + "succeed right now; anything else means it would fail fast.", + example: 'running', + }) + peerStatus: string; + + @ApiProperty({ + description: + 'False when the peer agent no longer exists in this installation.', + example: true, + }) + peerExists: boolean; + + @ApiProperty({ + type: AgentCardDto, + description: + 'The card as read at connect time or at the last refresh — NOT a live ' + + 'read. The delegating agent reasons from this snapshot, so a peer ' + + 'editing its description mid-turn cannot change behaviour until ' + + 'someone presses Refresh.', + }) + card: AgentCardDto; + + @ApiProperty({ + description: 'Where the snapshot was read from.', + example: + 'https://api.ranch.example/a2a/agents/6f1c…/.well-known/agent-card.json', + }) + cardUrl: string; + + @ApiProperty({ + format: 'date-time', + description: 'When the snapshot was taken.', + example: '2026-09-14T10:00:00.000Z', + }) + cardReadAt: string; + + @ApiProperty({ format: 'date-time', example: '2026-09-14T10:00:00.000Z' }) + createdAt: string; +} diff --git a/api/src/slices/agent/peer/dtos/agentPeerCandidate.dto.ts b/api/src/slices/agent/peer/dtos/agentPeerCandidate.dto.ts new file mode 100644 index 00000000..12b35abd --- /dev/null +++ b/api/src/slices/agent/peer/dtos/agentPeerCandidate.dto.ts @@ -0,0 +1,21 @@ +import { ApiProperty } from '@nestjs/swagger'; + +/** An agent offered in the "add peer" picker (CLEAN-74). */ +export class AgentPeerCandidateDto { + @ApiProperty() + id: string; + + @ApiProperty({ example: 'Support Bot' }) + name: string; + + @ApiProperty({ description: 'Live agent status.', example: 'running' }) + status: string; + + @ApiProperty({ + description: + 'True when this agent is already a peer — shown as connected rather ' + + 'than offered again.', + example: false, + }) + connected: boolean; +} diff --git a/api/src/slices/agent/peer/dtos/connectPeer.dto.ts b/api/src/slices/agent/peer/dtos/connectPeer.dto.ts new file mode 100644 index 00000000..2fe813d6 --- /dev/null +++ b/api/src/slices/agent/peer/dtos/connectPeer.dto.ts @@ -0,0 +1,14 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsUUID } from 'class-validator'; + +/** Body of "connect this agent as a peer" (CLEAN-74). */ +export class ConnectPeerDto { + @ApiProperty({ + description: + 'The agent to connect. Must be another agent of this installation: ' + + 'foreign card URLs are not accepted in this feature, and an agent ' + + 'cannot be its own peer.', + }) + @IsUUID() + peerAgentId: string; +} diff --git a/api/src/slices/agent/peer/dtos/index.ts b/api/src/slices/agent/peer/dtos/index.ts new file mode 100644 index 00000000..9d7cb5fb --- /dev/null +++ b/api/src/slices/agent/peer/dtos/index.ts @@ -0,0 +1,6 @@ +export * from './agentCard.dto'; +export * from './agentPeer.dto'; +export * from './agentPeerCandidate.dto'; +export * from './connectPeer.dto'; +export * from './agentDelegation.dto'; +export * from './listDelegations.query.dto'; diff --git a/api/src/slices/agent/peer/dtos/listDelegations.query.dto.ts b/api/src/slices/agent/peer/dtos/listDelegations.query.dto.ts new file mode 100644 index 00000000..a1f4491b --- /dev/null +++ b/api/src/slices/agent/peer/dtos/listDelegations.query.dto.ts @@ -0,0 +1,18 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsInt, IsOptional, Max, Min } from 'class-validator'; + +export class ListDelegationsQueryDto { + @ApiPropertyOptional({ + description: 'How many recent delegations to return.', + default: 20, + minimum: 1, + maximum: 100, + }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + limit?: number = 20; +} diff --git a/api/src/slices/agent/peer/peer.controller.spec.ts b/api/src/slices/agent/peer/peer.controller.spec.ts new file mode 100644 index 00000000..4764a615 --- /dev/null +++ b/api/src/slices/agent/peer/peer.controller.spec.ts @@ -0,0 +1,172 @@ +import { Reflector } from '@nestjs/core'; +import { GUARDS_METADATA, HTTP_CODE_METADATA } from '@nestjs/common/constants'; +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; +import { PeerController } from './peer.controller'; +import { ConnectPeerDto, ListDelegationsQueryDto } from './dtos'; +import { JwtAuthGuard, RolesGuard } from '#/user/auth/guards'; +import { ROLES_METADATA_KEY } from '#/user/auth/guards/roles.decorator'; +import { UserRoleTypes } from '#/user/user/domain'; +import type { PeerService } from './domain/peer.service'; +import type { AgentCardService } from './domain/agentCard.service'; +import type { IDelegationGateway } from './domain/delegation.gateway'; + +/** + * The operator-facing routes. Two things are worth a test here and the rest is + * delegation: that the access rules are actually attached (a peers list is a + * map of what an installation can do, and connecting one grants a credential), + * and that the pair credential cannot escape through a response — the service + * strips it, and this proves the controller does not put it back. + */ +function makeController(options: { peers?: Partial } = {}) { + const poisoned = { + id: 'peer-1', + agentId: 'a', + peerAgentId: 'b', + peerName: 'Support Bot', + peerStatus: 'running', + peerExists: true, + card: { name: 'Support Bot', skills: [] }, + cardUrl: 'https://api.test/a2a/agents/b/.well-known/agent-card.json', + cardReadAt: '2026-09-14T10:00:00.000Z', + createdAt: '2026-09-14T10:00:00.000Z', + // Not part of the DTO. Present here on purpose: if a route ever spreads + // the row instead of the view, this is what leaks. + token: 'ap_' + 'x'.repeat(43), + }; + + const peers = { + list: jest.fn(async () => [poisoned]), + candidates: jest.fn(async () => [ + { id: 'b', name: 'Support Bot', status: 'running', connected: true }, + ]), + connect: jest.fn(async () => poisoned), + refresh: jest.fn(async () => poisoned), + remove: jest.fn(async () => undefined), + ...options.peers, + } as unknown as PeerService; + + const cards = { + build: jest.fn(async () => ({ name: 'Support Bot', skills: [] })), + } as unknown as AgentCardService; + + const delegations = { + listRecent: jest.fn(async () => []), + } as unknown as IDelegationGateway; + + return { + controller: new PeerController(peers, cards, delegations), + peers, + cards, + delegations, + }; +} + +describe('PeerController — access', () => { + const reflector = new Reflector(); + + it('is behind the console guards', () => { + expect(reflector.get(GUARDS_METADATA, PeerController)).toEqual([ + JwtAuthGuard, + RolesGuard, + ]); + }); + + it('admits owners and admins only — never an agent runtime', () => { + const roles = reflector.get(ROLES_METADATA_KEY, PeerController); + + expect(roles).toEqual([UserRoleTypes.Owner, UserRoleTypes.Admin]); + expect(roles).not.toContain(UserRoleTypes.Agent); + expect(roles).not.toContain(UserRoleTypes.User); + }); + + it('answers a disconnect with no content', () => { + expect( + reflector.get(HTTP_CODE_METADATA, PeerController.prototype.remove), + ).toBe(204); + }); +}); + +describe('PeerController — routes', () => { + it('lists peers without ever exposing the pair credential', async () => { + const { controller } = makeController(); + + const listed = await controller.list('a'); + + expect(JSON.stringify(listed)).not.toContain('ap_'); + }); + + it('connects a peer and hands back the connection, credential stripped', async () => { + const { controller, peers } = makeController(); + + const created = await controller.connect('a', { peerAgentId: 'b' }); + + expect(peers.connect).toHaveBeenCalledWith('a', 'b'); + expect(JSON.stringify(created)).not.toContain('ap_'); + }); + + it('refreshes and removes the connection the path names', async () => { + const { controller, peers } = makeController(); + + await controller.refresh('a', 'peer-1'); + await controller.remove('a', 'peer-1'); + + expect(peers.refresh).toHaveBeenCalledWith('a', 'peer-1'); + expect(peers.remove).toHaveBeenCalledWith('a', 'peer-1'); + }); + + it('builds the card fresh on every read', async () => { + const { controller, cards } = makeController(); + + await controller.card('a'); + + expect(cards.build).toHaveBeenCalledWith('a'); + }); + + it('lists the candidates for the agent asked about', async () => { + const { controller, peers } = makeController(); + + await controller.candidates('a'); + + expect(peers.candidates).toHaveBeenCalledWith('a'); + }); + + it('reads recent delegations with the default limit when none is given', async () => { + const { controller, delegations } = makeController(); + + await controller.recentDelegations('a', {}); + + expect(delegations.listRecent).toHaveBeenCalledWith('a', 20); + }); + + it('honours a requested limit', async () => { + const { controller, delegations } = makeController(); + + await controller.recentDelegations('a', { limit: 5 }); + + expect(delegations.listRecent).toHaveBeenCalledWith('a', 5); + }); +}); + +describe('PeerController — request validation', () => { + it('requires a peer id that could be an agent', async () => { + const bad = plainToInstance(ConnectPeerDto, { peerAgentId: 'not-a-uuid' }); + const good = plainToInstance(ConnectPeerDto, { + peerAgentId: '3f2504e0-4f89-11d3-9a0c-0305e82c3301', + }); + + expect(await validate(bad)).toHaveLength(1); + expect(await validate(good)).toHaveLength(0); + }); + + it('bounds the delegation limit', async () => { + const tooMany = plainToInstance(ListDelegationsQueryDto, { limit: 1000 }); + const zero = plainToInstance(ListDelegationsQueryDto, { limit: 0 }); + const ok = plainToInstance(ListDelegationsQueryDto, { limit: '25' }); + + expect(await validate(tooMany)).toHaveLength(1); + expect(await validate(zero)).toHaveLength(1); + expect(await validate(ok)).toHaveLength(0); + expect(ok.limit).toBe(25); + }); +}); diff --git a/api/src/slices/agent/peer/peer.controller.ts b/api/src/slices/agent/peer/peer.controller.ts new file mode 100644 index 00000000..fb52c64a --- /dev/null +++ b/api/src/slices/agent/peer/peer.controller.ts @@ -0,0 +1,195 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + Param, + Post, + Query, + UseGuards, +} from '@nestjs/common'; +import { + ApiBadGatewayResponse, + ApiBearerAuth, + ApiConflictResponse, + ApiNoContentResponse, + ApiNotFoundResponse, + ApiOkResponse, + ApiOperation, + ApiTags, + ApiUnauthorizedResponse, +} from '@nestjs/swagger'; +import { JwtAuthGuard, Roles, RolesGuard } from '#/user/auth/guards'; +import { UserRoleTypes } from '#/user/user/domain'; +import { PeerService } from './domain/peer.service'; +import type { IAgentPeerView } from './domain/peer.types'; +import { AgentCardService } from './domain/agentCard.service'; +import { IDelegationGateway } from './domain/delegation.gateway'; +import { + AgentCardDto, + AgentDelegationDto, + AgentPeerCandidateDto, + AgentPeerDto, + ConnectPeerDto, + ListDelegationsQueryDto, +} from './dtos'; + +/** + * `IAgentPeerView` and `AgentPeerDto` have the same shape, but the copy is + * explicit so nothing the domain grows later leaks onto the wire by accident. + * The field this is really guarding is the pair credential: the service + * already drops it, and naming every field here means a future change to + * either side cannot quietly put it back. + */ +function toPeerDto(view: IAgentPeerView): AgentPeerDto { + return { + id: view.id, + agentId: view.agentId, + peerAgentId: view.peerAgentId, + peerName: view.peerName, + peerStatus: view.peerStatus, + peerExists: view.peerExists, + card: view.card, + cardUrl: view.cardUrl, + cardReadAt: view.cardReadAt, + createdAt: view.createdAt, + }; +} + +/** + * The operator side of agent-to-agent (CLEAN-74): look at an agent's own card, + * see who it can delegate to, connect and disconnect peers, and read the + * delegation history. + * + * Owner/Admin only. `Agent` is deliberately outside this hierarchy: a runtime + * delegates through its `ask_agent` tool and must never be able to grant + * itself a new colleague. + */ +@ApiTags('peers') +@ApiBearerAuth() +@ApiUnauthorizedResponse({ + description: 'Missing, malformed or expired console bearer token.', +}) +@ApiNotFoundResponse({ description: 'No agent or connection with this id.' }) +@Controller('agents/:agentId') +@UseGuards(JwtAuthGuard, RolesGuard) +@Roles(UserRoleTypes.Owner, UserRoleTypes.Admin) +export class PeerController { + constructor( + private readonly peers: PeerService, + private readonly cards: AgentCardService, + private readonly delegations: IDelegationGateway, + ) {} + + @Get('card') + @ApiOperation({ + operationId: 'getAgentCard', + summary: + "This agent's own A2A card, exactly as another agent would read it. " + + 'Derived on every request from the name, description, template skills ' + + 'and bound knowledge bases — there is nothing to regenerate.', + }) + @ApiOkResponse({ type: AgentCardDto }) + async card(@Param('agentId') agentId: string): Promise { + return this.cards.build(agentId); + } + + @Get('peers') + @ApiOperation({ + operationId: 'listAgentPeers', + summary: + 'Agents this one can delegate to, with the card snapshot taken when ' + + 'each was connected or last refreshed. Directed: this never lists the ' + + 'agents that can delegate TO this one.', + }) + @ApiOkResponse({ type: [AgentPeerDto] }) + async list(@Param('agentId') agentId: string): Promise { + return (await this.peers.list(agentId)).map(toPeerDto); + } + + @Get('peers/candidates') + @ApiOperation({ + operationId: 'listAgentPeerCandidates', + summary: + 'Every other agent of this installation, each marked with whether it ' + + 'is already a peer. Excludes the agent itself.', + }) + @ApiOkResponse({ type: [AgentPeerCandidateDto] }) + async candidates( + @Param('agentId') agentId: string, + ): Promise { + return this.peers.candidates(agentId); + } + + @Post('peers') + @ApiOperation({ + operationId: 'connectAgentPeer', + summary: + "Connect another agent as a peer: mints a credential for this pair, " + + 'reads the peer card with it, and stores the snapshot. Nothing is kept ' + + 'if the card cannot be read, so a saved connection always works. The ' + + 'agent picks the tool up on its next restart.', + }) + @ApiOkResponse({ type: AgentPeerDto }) + @ApiConflictResponse({ description: 'Already a peer of this agent.' }) + @ApiBadGatewayResponse({ + description: "The peer's card could not be read; nothing was saved.", + }) + async connect( + @Param('agentId') agentId: string, + @Body() body: ConnectPeerDto, + ): Promise { + return toPeerDto(await this.peers.connect(agentId, body.peerAgentId)); + } + + @Post('peers/:peerId/refresh') + @HttpCode(200) + @ApiOperation({ + operationId: 'refreshAgentPeer', + summary: + "Re-read the peer's card and replace the stored snapshot. A failed " + + 'read keeps the old snapshot: stale is better than nothing.', + }) + @ApiOkResponse({ type: AgentPeerDto }) + @ApiBadGatewayResponse({ + description: "The peer's card could not be read; the old snapshot is kept.", + }) + async refresh( + @Param('agentId') agentId: string, + @Param('peerId') peerId: string, + ): Promise { + return toPeerDto(await this.peers.refresh(agentId, peerId)); + } + + @Delete('peers/:peerId') + @HttpCode(204) + @ApiOperation({ + operationId: 'removeAgentPeer', + summary: + 'Disconnect a peer. This revokes the credential issued for the pair — ' + + 'there is no other copy of it — so the connection cannot be used again.', + }) + @ApiNoContentResponse({ description: 'Disconnected.' }) + async remove( + @Param('agentId') agentId: string, + @Param('peerId') peerId: string, + ): Promise { + await this.peers.remove(agentId, peerId); + } + + @Get('delegations') + @ApiOperation({ + operationId: 'listAgentDelegations', + summary: + 'Recent tasks this agent handed to its peers, newest first: who was ' + + 'asked, why, how long it took and how it ended.', + }) + @ApiOkResponse({ type: [AgentDelegationDto] }) + async recentDelegations( + @Param('agentId') agentId: string, + @Query() query: ListDelegationsQueryDto, + ): Promise { + return this.delegations.listRecent(agentId, query.limit ?? 20); + } +} diff --git a/api/src/slices/agent/peer/peer.module.ts b/api/src/slices/agent/peer/peer.module.ts index bccd10bd..35b48d7b 100644 --- a/api/src/slices/agent/peer/peer.module.ts +++ b/api/src/slices/agent/peer/peer.module.ts @@ -16,7 +16,10 @@ import { DelegationGateway } from './data/delegation.gateway'; import { PeerMapper } from './data/peer.mapper'; import { DelegationMapper } from './data/delegation.mapper'; import { A2aCardGuard, A2aPeerGuard } from './guards/a2a.guards'; +import { A2aClient } from './domain/a2a.client'; +import { PeerService } from './domain/peer.service'; import { A2aController } from './a2a.controller'; +import { PeerController } from './peer.controller'; /** * Agents talking to agents (CLEAN-74): agent cards, peer connections, the A2A @@ -36,13 +39,15 @@ import { A2aController } from './a2a.controller'; forwardRef(() => BridleModule), SettingModule, ], - controllers: [A2aController], + controllers: [A2aController, PeerController], providers: [ PeerMapper, DelegationMapper, { provide: IPeerGateway, useClass: PeerGateway }, { provide: IDelegationGateway, useClass: DelegationGateway }, AgentCardService, + A2aClient, + PeerService, A2aTaskStore, A2aServerService, A2aCardGuard, diff --git a/specs/013-a2a-agent-peers/tasks.md b/specs/013-a2a-agent-peers/tasks.md index e4b1e6b4..3aa30da1 100644 --- a/specs/013-a2a-agent-peers/tasks.md +++ b/specs/013-a2a-agent-peers/tasks.md @@ -96,13 +96,13 @@ - [X] T028 [P] [US2] Implement `PeerMapper` in `api/src/slices/agent/peer/data/peer.mapper.ts` and `DelegationMapper` in `api/src/slices/agent/peer/data/delegation.mapper.ts` — Prisma record → domain data with ISO strings, the only files importing `@prisma/client` types (pattern `shareLink.mapper.ts`) - [X] T029 [US2] Implement `PeerGateway extends IPeerGateway` in `api/src/slices/agent/peer/data/peer.gateway.ts` and `DelegationGateway` in `data/delegation.gateway.ts` over `PrismaService` (`prisma.agentPeer.*`, `prisma.agentDelegation.*`), `listByAgent` ordered by `createdAt asc`, `listRecent` ordered by `startedAt desc` with `take: limit` (depends on T027, T028) - [X] T030 [US2] Write `api/src/slices/agent/peer/data/peer.gateway.spec.ts` with a `makePrismaStub()` (pattern `shareLink.gateway.spec.ts:7-70`) enforcing `@unique token` and `@@unique([agentId, peerAgentId])` (throw `{ code: 'P2002' }`): create/findByPair/findByToken round-trips, `updateSnapshot` changes only the three fields, `delete`; and `data/delegation.gateway.spec.ts`: `finish` sets status/finishedAt/durationMs, `listRecent` newest first and limited (depends on T029) -- [ ] T031 [US2] Implement `A2aClient` (card half) in `api/src/slices/agent/peer/domain/a2a.client.ts`: `fetchCard(cardUrl, token): Promise` using global `fetch` with `Authorization: Bearer `, `Accept: application/json`, a 10 s `AbortSignal.timeout`; non-2xx → `PeerCardUnreachableError(status, bodyExcerpt)`; malformed JSON or missing `name`/`skills`/`supportedInterfaces` → same error with reason; network error → same; define the error class in `domain/peer.types.ts` -- [ ] T032 [US2] Implement `PeerService` in `api/src/slices/agent/peer/domain/peer.service.ts` over `IPeerGateway`, `IAgentGateway`, `AgentCardService`, `A2aClient`: `list(agentId)` → peers joined with the live peer agent (`peerName`, `peerStatus`, `peerExists`); `candidates(agentId)` → every other agent `{ id, name, status, connected }`; `ownCard(agentId)` → `AgentCardService.build`; `connect(agentId, peerAgentId)` → refuse self (`BadRequestException({ code: PEER_SELF })`), unknown peer agent (`NotFoundException({ code: PEER_NOT_FOUND })`), duplicate (`ConflictException({ code: PEER_EXISTS })`); mint `ap_` + `randomBytes(32).toString('base64url')` (pattern `shareLink.service.ts:152`); **create the row first** (so the credential exists), then `fetchCard(cardUrlFor(peerAgentId), token)`; on failure delete the row and throw `BadGatewayException({ code: PEER_CARD_UNREACHABLE, message })`; on success `updateSnapshot`; `refresh(agentId, peerId)` → fetch with the row's token, keep the old snapshot and rethrow `502` on failure; `remove(agentId, peerId)` → 404 if the row's `agentId` differs; the returned data never includes `token` (strip in a `toPublic()` helper) (depends on T017, T027, T031) -- [ ] T033 [US2] Write `api/src/slices/agent/peer/domain/peer.service.spec.ts` with in-memory gateway stubs and a `fetchCard` jest.fn: token format `^ap_[A-Za-z0-9_-]{43}$`; self → `PEER_SELF`; duplicate → `PEER_EXISTS`; connect with a failing card fetch leaves **no** row and throws `PEER_CARD_UNREACHABLE`; connect success stores the snapshot and `cardReadAt`; refresh failure keeps the old snapshot; remove on a foreign row → 404; `list` output has no `token` key; `candidates` excludes the agent itself and marks connected ones (depends on T032); also write `domain/a2a.client.spec.ts` with a mocked `global.fetch`: 200 JSON → card; 401 → unreachable with status; timeout/abort → unreachable -- [ ] T034 [P] [US2] Create DTOs in `api/src/slices/agent/peer/dtos/`: `agentCard.dto.ts` (`AgentCardDto` mirroring `AgentCard` with `@ApiProperty` — nested `AgentSkillDto`, `AgentInterfaceDto`, `AgentCapabilitiesDto`), `agentPeer.dto.ts` (`AgentPeerDto { id, agentId, peerAgentId, peerName, peerStatus, peerExists, card: AgentCardDto, cardUrl, cardReadAt, createdAt }`), `agentPeerCandidate.dto.ts` (`{ id, name, status, connected }`), `connectPeer.dto.ts` (`@IsUUID() peerAgentId`), `agentDelegation.dto.ts` (`{ id, peerAgentId, peerName, task, reason, status, errorCode, excerpt, startedAt, finishedAt, durationMs }`), `listDelegations.query.dto.ts` (`@IsOptional() @IsInt() @Min(1) @Max(100) limit = 20`), `index.ts` -- [ ] T035 [US2] Implement `PeerController` in `api/src/slices/agent/peer/peer.controller.ts` (`@Controller('agents/:agentId')`, `@ApiTags('peers')`, `@ApiBearerAuth()`, class-level `@UseGuards(JwtAuthGuard, RolesGuard)` + `@Roles(Owner, Admin)`): `GET peers` `listAgentPeers`, `GET peers/candidates` `listAgentPeerCandidates`, `GET card` `getAgentCard`, `POST peers` `connectAgentPeer` (201), `POST peers/:peerId/refresh` `refreshAgentPeer`, `DELETE peers/:peerId` `removeAgentPeer` (204), `GET delegations` `listAgentDelegations` (reads `IDelegationGateway.listRecent`; returns `[]` until US5 writes rows) — `@ApiOperation({ operationId })` and `@ApiOkResponse({ type })` on each (pattern `shareLink.controller.ts:76-84`) (depends on T032, T034) -- [ ] T036 [US2] Write `api/src/slices/agent/peer/peer.controller.spec.ts` (`new PeerController(serviceStub)` + `Reflector` metadata: guards, roles, HTTP codes on every route; `ConnectPeerDto` validated via `plainToInstance` + `validate`; a "poisoned" service stub carrying `token` proves no DTO leaks it — pattern `shareLink.controller.spec.ts:243-259`) (depends on T035) -- [ ] T037 [US2] Complete `peer.module.ts` providers (`PeerMapper`, `DelegationMapper`, `{ provide: IPeerGateway, useClass: PeerGateway }`, `{ provide: IDelegationGateway, useClass: DelegationGateway }`, `A2aClient`, `PeerService`) and controllers (`PeerController`); then regenerate: `cd api && bun run build && bun run generate:swagger`, `cd admin && bun run build:api` — confirm the seven `operationId`s appear in `admin/slices/setup/api/data/repositories/api/sdk.gen.ts` (depends on T029, T035) +- [X] T031 [US2] Implement `A2aClient` (card half) in `api/src/slices/agent/peer/domain/a2a.client.ts`: `fetchCard(cardUrl, token): Promise` using global `fetch` with `Authorization: Bearer `, `Accept: application/json`, a 10 s `AbortSignal.timeout`; non-2xx → `PeerCardUnreachableError(status, bodyExcerpt)`; malformed JSON or missing `name`/`skills`/`supportedInterfaces` → same error with reason; network error → same; define the error class in `domain/peer.types.ts` +- [X] T032 [US2] Implement `PeerService` in `api/src/slices/agent/peer/domain/peer.service.ts` over `IPeerGateway`, `IAgentGateway`, `AgentCardService`, `A2aClient`: `list(agentId)` → peers joined with the live peer agent (`peerName`, `peerStatus`, `peerExists`); `candidates(agentId)` → every other agent `{ id, name, status, connected }`; `ownCard(agentId)` → `AgentCardService.build`; `connect(agentId, peerAgentId)` → refuse self (`BadRequestException({ code: PEER_SELF })`), unknown peer agent (`NotFoundException({ code: PEER_NOT_FOUND })`), duplicate (`ConflictException({ code: PEER_EXISTS })`); mint `ap_` + `randomBytes(32).toString('base64url')` (pattern `shareLink.service.ts:152`); **create the row first** (so the credential exists), then `fetchCard(cardUrlFor(peerAgentId), token)`; on failure delete the row and throw `BadGatewayException({ code: PEER_CARD_UNREACHABLE, message })`; on success `updateSnapshot`; `refresh(agentId, peerId)` → fetch with the row's token, keep the old snapshot and rethrow `502` on failure; `remove(agentId, peerId)` → 404 if the row's `agentId` differs; the returned data never includes `token` (strip in a `toPublic()` helper) (depends on T017, T027, T031) +- [X] T033 [US2] Write `api/src/slices/agent/peer/domain/peer.service.spec.ts` with in-memory gateway stubs and a `fetchCard` jest.fn: token format `^ap_[A-Za-z0-9_-]{43}$`; self → `PEER_SELF`; duplicate → `PEER_EXISTS`; connect with a failing card fetch leaves **no** row and throws `PEER_CARD_UNREACHABLE`; connect success stores the snapshot and `cardReadAt`; refresh failure keeps the old snapshot; remove on a foreign row → 404; `list` output has no `token` key; `candidates` excludes the agent itself and marks connected ones (depends on T032); also write `domain/a2a.client.spec.ts` with a mocked `global.fetch`: 200 JSON → card; 401 → unreachable with status; timeout/abort → unreachable +- [X] T034 [P] [US2] Create DTOs in `api/src/slices/agent/peer/dtos/`: `agentCard.dto.ts` (`AgentCardDto` mirroring `AgentCard` with `@ApiProperty` — nested `AgentSkillDto`, `AgentInterfaceDto`, `AgentCapabilitiesDto`), `agentPeer.dto.ts` (`AgentPeerDto { id, agentId, peerAgentId, peerName, peerStatus, peerExists, card: AgentCardDto, cardUrl, cardReadAt, createdAt }`), `agentPeerCandidate.dto.ts` (`{ id, name, status, connected }`), `connectPeer.dto.ts` (`@IsUUID() peerAgentId`), `agentDelegation.dto.ts` (`{ id, peerAgentId, peerName, task, reason, status, errorCode, excerpt, startedAt, finishedAt, durationMs }`), `listDelegations.query.dto.ts` (`@IsOptional() @IsInt() @Min(1) @Max(100) limit = 20`), `index.ts` +- [X] T035 [US2] Implement `PeerController` in `api/src/slices/agent/peer/peer.controller.ts` (`@Controller('agents/:agentId')`, `@ApiTags('peers')`, `@ApiBearerAuth()`, class-level `@UseGuards(JwtAuthGuard, RolesGuard)` + `@Roles(Owner, Admin)`): `GET peers` `listAgentPeers`, `GET peers/candidates` `listAgentPeerCandidates`, `GET card` `getAgentCard`, `POST peers` `connectAgentPeer` (201), `POST peers/:peerId/refresh` `refreshAgentPeer`, `DELETE peers/:peerId` `removeAgentPeer` (204), `GET delegations` `listAgentDelegations` (reads `IDelegationGateway.listRecent`; returns `[]` until US5 writes rows) — `@ApiOperation({ operationId })` and `@ApiOkResponse({ type })` on each (pattern `shareLink.controller.ts:76-84`) (depends on T032, T034) +- [X] T036 [US2] Write `api/src/slices/agent/peer/peer.controller.spec.ts` (`new PeerController(serviceStub)` + `Reflector` metadata: guards, roles, HTTP codes on every route; `ConnectPeerDto` validated via `plainToInstance` + `validate`; a "poisoned" service stub carrying `token` proves no DTO leaks it — pattern `shareLink.controller.spec.ts:243-259`) (depends on T035) +- [X] T037 [US2] Complete `peer.module.ts` providers (`PeerMapper`, `DelegationMapper`, `{ provide: IPeerGateway, useClass: PeerGateway }`, `{ provide: IDelegationGateway, useClass: DelegationGateway }`, `A2aClient`, `PeerService`) and controllers (`PeerController`); then regenerate: `cd api && bun run build && bun run generate:swagger`, `cd admin && bun run build:api` — confirm the seven `operationId`s appear in `admin/slices/setup/api/data/repositories/api/sdk.gen.ts` (depends on T029, T035) ### Admin @@ -126,8 +126,8 @@ **Independent Test**: spec Story 3 — B with a knowledge base, connected to A; ask A about the topic → A answers with the fact and names B; stop B → A says B could not be reached. -- [ ] T047 [US3] Implement the message half of `A2aClient` in `api/src/slices/agent/peer/domain/a2a.client.ts`: `sendMessage(interfaceUrl, token, params: SendMessageParams, timeoutMs): Promise` — POST JSON-RPC `{ jsonrpc: '2.0', id: , method: 'SendMessage', params }` with `Authorization: Bearer`, `A2A-Version: 1.0`, `Content-Type: application/json`, `AbortSignal.timeout(timeoutMs + 5_000)`; HTTP 401/403 → `DelegationError(Unauthorized)`; other non-2xx or network/abort → `DelegationError(Unreachable, detail)`; JSON-RPC `error` → `DelegationError(Error, error.message)`; result without `task` → `DelegationError(Error, 'peer returned a message, not a task')`; define `class DelegationError extends Error { constructor(public code: DelegationErrorCode, message) }` in `peer.types.ts` (depends on T031) -- [ ] T048 [US3] Extend `api/src/slices/agent/peer/domain/a2a.client.spec.ts` with a mocked `global.fetch` for `sendMessage`: sends the three headers and the envelope; 401 → `PEER_UNAUTHORIZED`; 500 → `PEER_UNREACHABLE`; JSON-RPC error → `PEER_ERROR` with the message; `{ result: { task } }` → the task (depends on T047) +- [X] T047 [US3] Implement the message half of `A2aClient` in `api/src/slices/agent/peer/domain/a2a.client.ts`: `sendMessage(interfaceUrl, token, params: SendMessageParams, timeoutMs): Promise` — POST JSON-RPC `{ jsonrpc: '2.0', id: , method: 'SendMessage', params }` with `Authorization: Bearer`, `A2A-Version: 1.0`, `Content-Type: application/json`, `AbortSignal.timeout(timeoutMs + 5_000)`; HTTP 401/403 → `DelegationError(Unauthorized)`; other non-2xx or network/abort → `DelegationError(Unreachable, detail)`; JSON-RPC `error` → `DelegationError(Error, error.message)`; result without `task` → `DelegationError(Error, 'peer returned a message, not a task')`; define `class DelegationError extends Error { constructor(public code: DelegationErrorCode, message) }` in `peer.types.ts` (depends on T031) +- [X] T048 [US3] Extend `api/src/slices/agent/peer/domain/a2a.client.spec.ts` with a mocked `global.fetch` for `sendMessage`: sends the three headers and the envelope; 401 → `PEER_UNAUTHORIZED`; 500 → `PEER_UNREACHABLE`; JSON-RPC error → `PEER_ERROR` with the message; `{ result: { task } }` → the task (depends on T047) - [ ] T049 [US3] Implement `DelegationService` in `api/src/slices/agent/peer/domain/delegation.service.ts` over `IPeerGateway`, `IDelegationGateway`, `A2aClient`, `IBridleGateway`, `ConfigService`: `run(input: { callerAgentId; peer: string; task: string; reason: string; contextId?: string; inboundChain: string[] }): Promise` — resolve the peer among `listByAgent(callerAgentId)` by id or case-insensitive `cardSnapshot.name` (no match → outcome `{ kind: 'no_match', peers }`); compute `matchedSkills` = snapshot skills whose `name` or `description` shares a word (≥ 4 letters, case-insensitive) with `reason`+`task`, capped at 3, falling back to the first skill; `contextId = input.contextId ?? 'ctx-' + uuid`; **create the delegation row (`waiting`)**; call the step emitter hook `onStart(row, matchedSkills)` (no-op until US4); `sendMessage(snapshot.supportedInterfaces[0].url, row.token, { message: { messageId: uuid, role: 'ROLE_USER', parts: [{ text: task }], contextId, metadata: { ranch: { chain: [...inboundChain, callerAgentId], reason } } }, configuration: { acceptedOutputModes: ['text/plain'], returnImmediately: false } }, timeoutMs)`; map the returned task: `TASK_STATE_COMPLETED` → `answered` with `text = textOfParts(artifacts[0].parts)` and `excerpt = text.slice(0, 300)`; `TASK_STATE_REJECTED` → `rejected` with `errorCode` from `metadata.ranch.rejection` (`loop` → `PEER_REJECTED_LOOP`, `depth` → `PEER_REJECTED_DEPTH`) and `excerpt = status.message text`; `TASK_STATE_FAILED` → `failed` with `errorCode` from `metadata.ranch.failure` (`not_running` → `PEER_NOT_RUNNING`, `timeout` → `PEER_TIMEOUT`, else `PEER_ERROR`); a thrown `DelegationError` → `failed` with its code; **finish the row exactly once**; call `onFinish(row)`; return `{ kind: 'done', status, text?, errorCode?, excerpt, peerName, contextId, durationMs }`; `inboundChain` is resolved by the caller (T051) (depends on T027, T047) - [ ] T050 [US3] Write `api/src/slices/agent/peer/domain/delegation.service.spec.ts`: peer matched by id and by name (case-insensitive); no match → `no_match` with the peer names; row created `waiting` **before** `sendMessage` is invoked (assert call order with a shared array); completed → `answered`, excerpt ≤ 300 chars, row finished with `durationMs`; rejected loop/depth → codes; failed not_running/timeout → codes; thrown `DelegationError` → `failed` with its code and the row still finished; `chain` sent = `[...inbound, caller]`; `contextId` reused when given (depends on T049) - [ ] T051 [US3] Implement `AskAgentTool` in `api/src/slices/agent/peer/askAgent.tool.ts` (`@Injectable`, implements `IDynamicallyDescribedTool` and `IConditionallyListedTool`): `extractAgentId(req)` (copy of `knowledge.tool.ts:169-175`); `isListedForRequest` → agent principal with ≥ 1 peer; `describeForRequest` → contracts §4 dynamic text built from `listByAgent` snapshots (name, peer id, description, `Skills: name (description); …`), `null` when no peers; `@Tool({ name: 'ask_agent', description: , parameters: z.object({ peer, task, reason, context_id }) })` method `ask(args, _ctx, req)` — non-agent → `err('ask_agent can only be called by an agent runtime.')`; validate `args` with the zod schema (`safeParse`, first issue message on failure); `inboundChain`: read from the caller's **own** in-flight delegation context — a `Map` on `A2aServerService` set for the duration of `sendAndAwait` (`enterChain(agentId, chain)` / `exitChain`), `[]` when absent; call `DelegationService.run`; render results exactly as contracts §4 (success text with name, `context_id`, seconds; `isError` texts for failed/rejected/no_match, each ending with the "do not guess on its behalf" instruction); register the tool in `peer.module.ts` providers (depends on T008, T022, T049) From a30ccdc06b77c4890f6bbe12f30ef8c0f7bbe8c7 Mon Sep 17 00:00:00 2001 From: "Maksym Hryzodub [DREAM]" Date: Mon, 14 Sep 2026 13:05:40 +0300 Subject: [PATCH 09/31] =?UTF-8?q?feat(admin):=20Peers=20tab=20=E2=80=94=20?= =?UTF-8?q?agent=20card,=20picker=20with=20preview,=20delegations=20(CLEAN?= =?UTF-8?q?-74)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01SZ9dCrzZqd5SwR4erLYe12 --- .../components/agent/workspace/Canvas.vue | 2 + .../components/agent/workspace/sections.ts | 14 +- .../composables/useAgentSectionCounts.ts | 11 + .../agent/peer/components/peer/CardView.vue | 75 +++++++ .../peer/components/peer/Delegations.vue | 123 +++++++++++ .../agent/peer/components/peer/Picker.vue | 147 +++++++++++++ .../slices/agent/peer/components/peer/Row.vue | 99 +++++++++ .../slices/agent/peer/components/peer/Tab.vue | 196 ++++++++++++++++++ admin/slices/agent/peer/data/index.ts | 1 + admin/slices/agent/peer/data/peer.gateway.ts | 124 +++++++++++ admin/slices/agent/peer/domain/index.ts | 2 + .../slices/agent/peer/domain/peer.service.ts | 40 ++++ admin/slices/agent/peer/domain/peer.types.ts | 64 ++++++ admin/slices/agent/peer/i18n/locales/en.json | 1 + admin/slices/agent/peer/index.d.ts | 9 + admin/slices/agent/peer/nuxt.config.ts | 16 ++ admin/slices/agent/peer/plugins/di.ts | 17 ++ admin/slices/agent/peer/stores/peer.ts | 179 ++++++++++++++++ specs/013-a2a-agent-peers/tasks.md | 20 +- 19 files changed, 1129 insertions(+), 11 deletions(-) create mode 100644 admin/slices/agent/peer/components/peer/CardView.vue create mode 100644 admin/slices/agent/peer/components/peer/Delegations.vue create mode 100644 admin/slices/agent/peer/components/peer/Picker.vue create mode 100644 admin/slices/agent/peer/components/peer/Row.vue create mode 100644 admin/slices/agent/peer/components/peer/Tab.vue create mode 100644 admin/slices/agent/peer/data/index.ts create mode 100644 admin/slices/agent/peer/data/peer.gateway.ts create mode 100644 admin/slices/agent/peer/domain/index.ts create mode 100644 admin/slices/agent/peer/domain/peer.service.ts create mode 100644 admin/slices/agent/peer/domain/peer.types.ts create mode 100644 admin/slices/agent/peer/i18n/locales/en.json create mode 100644 admin/slices/agent/peer/index.d.ts create mode 100644 admin/slices/agent/peer/nuxt.config.ts create mode 100644 admin/slices/agent/peer/plugins/di.ts create mode 100644 admin/slices/agent/peer/stores/peer.ts diff --git a/admin/slices/agent/agent/components/agent/workspace/Canvas.vue b/admin/slices/agent/agent/components/agent/workspace/Canvas.vue index 8bb03361..ea5960a2 100644 --- a/admin/slices/agent/agent/components/agent/workspace/Canvas.vue +++ b/admin/slices/agent/agent/components/agent/workspace/Canvas.vue @@ -63,6 +63,8 @@ const restartUnderway = computed( + + Files diff --git a/admin/slices/agent/agent/components/agent/workspace/sections.ts b/admin/slices/agent/agent/components/agent/workspace/sections.ts index 6be98314..5bd1a77d 100644 --- a/admin/slices/agent/agent/components/agent/workspace/sections.ts +++ b/admin/slices/agent/agent/components/agent/workspace/sections.ts @@ -9,7 +9,12 @@ */ /** Tabs that can say how much they hold before you open them. */ -export type SectionCountKey = 'knowledge' | 'files' | 'secrets' | 'channels'; +export type SectionCountKey = + | 'knowledge' + | 'peers' + | 'files' + | 'secrets' + | 'channels'; /** `null` = not known yet, or the source failed. Distinct from `0`. */ export type SectionCounts = Record; @@ -36,6 +41,13 @@ export const AGENT_TABS = [ countKey: 'knowledge', primary: true, }, + { + value: 'peers', + title: 'Peers', + desc: 'Other agents this one can delegate to.', + countKey: 'peers', + primary: true, + }, { value: 'files', title: 'Files', diff --git a/admin/slices/agent/agent/composables/useAgentSectionCounts.ts b/admin/slices/agent/agent/composables/useAgentSectionCounts.ts index 24df0d33..09dbf6e9 100644 --- a/admin/slices/agent/agent/composables/useAgentSectionCounts.ts +++ b/admin/slices/agent/agent/composables/useAgentSectionCounts.ts @@ -29,10 +29,12 @@ export function useAgentSectionCounts( const fileStore = useAgentFileStore(); const secretStore = useAgentSecretStore(); const channelStore = useAgentChannelStore(); + const peerStore = usePeerStore(); const files = ref(null); const secrets = ref(null); const channels = ref(null); + const peers = ref(null); /** Knowledge bases the agent's template binds by default. Only fetched when * the agent has no per-agent override to count instead. */ const templateKnowledge = ref(null); @@ -70,6 +72,14 @@ export function useAgentSectionCounts( .catch(() => { channels.value = null; }), + peerStore + .load(agentId) + .then(() => { + peers.value = peerStore.peers(agentId).length; + }) + .catch(() => { + peers.value = null; + }), loadTemplateKnowledge(), ]); } @@ -107,6 +117,7 @@ export function useAgentSectionCounts( files: files.value, secrets: secrets.value, channels: channels.value, + peers: peers.value, }; }); diff --git a/admin/slices/agent/peer/components/peer/CardView.vue b/admin/slices/agent/peer/components/peer/CardView.vue new file mode 100644 index 00000000..a73ea567 --- /dev/null +++ b/admin/slices/agent/peer/components/peer/CardView.vue @@ -0,0 +1,75 @@ + + + diff --git a/admin/slices/agent/peer/components/peer/Delegations.vue b/admin/slices/agent/peer/components/peer/Delegations.vue new file mode 100644 index 00000000..6cf8d2ec --- /dev/null +++ b/admin/slices/agent/peer/components/peer/Delegations.vue @@ -0,0 +1,123 @@ + + + diff --git a/admin/slices/agent/peer/components/peer/Picker.vue b/admin/slices/agent/peer/components/peer/Picker.vue new file mode 100644 index 00000000..25f691ba --- /dev/null +++ b/admin/slices/agent/peer/components/peer/Picker.vue @@ -0,0 +1,147 @@ + + + diff --git a/admin/slices/agent/peer/components/peer/Row.vue b/admin/slices/agent/peer/components/peer/Row.vue new file mode 100644 index 00000000..490e4a2d --- /dev/null +++ b/admin/slices/agent/peer/components/peer/Row.vue @@ -0,0 +1,99 @@ + + + diff --git a/admin/slices/agent/peer/components/peer/Tab.vue b/admin/slices/agent/peer/components/peer/Tab.vue new file mode 100644 index 00000000..c4b487c6 --- /dev/null +++ b/admin/slices/agent/peer/components/peer/Tab.vue @@ -0,0 +1,196 @@ + + + diff --git a/admin/slices/agent/peer/data/index.ts b/admin/slices/agent/peer/data/index.ts new file mode 100644 index 00000000..6412fb64 --- /dev/null +++ b/admin/slices/agent/peer/data/index.ts @@ -0,0 +1 @@ +export * from './peer.gateway'; diff --git a/admin/slices/agent/peer/data/peer.gateway.ts b/admin/slices/agent/peer/data/peer.gateway.ts new file mode 100644 index 00000000..a359ccce --- /dev/null +++ b/admin/slices/agent/peer/data/peer.gateway.ts @@ -0,0 +1,124 @@ +import { PeersService } from '#api/data'; +import type { + AgentCardDto, + AgentDelegationDto, + AgentPeerCandidateDto, + AgentPeerDto, +} from '#api/data/repositories/api/types.gen'; +import { BaseGateway } from '#common/data/BaseGateway'; +import { unwrapEnvelope } from '#common/data/unwrapEnvelope'; +import type { + IAgentCard, + IAgentDelegation, + IAgentPeer, + IAgentPeerCandidate, +} from '../domain/peer.types'; + +interface HeyApiResult { + data?: unknown; + error?: unknown; + response?: { status?: number }; +} + +/** + * The generated client answers a failed request with `{ error }` rather than + * throwing, so an unchecked `unwrapEnvelope` turns a 409 or a 502 into an + * empty result. That matters more here than almost anywhere else in the + * console: "Could not read the card of «Support Bot». The card answered 401" + * is the entire reason an operator can fix a bad connection, and it arrives + * inside exactly the body this would discard. + */ +function unwrapOrThrow(res: HeyApiResult, action: string): unknown { + const err = res.error as { message?: string; code?: string } | undefined; + if (err !== undefined && err !== null) { + const status = res.response?.status; + if (err.message) throw new Error(err.message); + if (status && status >= 400) throw new Error(`${action} failed: HTTP ${status}`); + throw new Error( + `${action} failed: could not reach the API. Check that the API is ` + + "running and that this app's origin is listed in CORS_ORIGIN.", + ); + } + return unwrapEnvelope(res.data); +} + +/** + * The only place the console talks to the peers API (CLEAN-74). The DTOs and + * the domain types are the same shape, so these are structural casts rather + * than a mapper — there is nothing to convert, and inventing one would only + * hide that fact. + */ +export class PeerGateway extends BaseGateway { + list(agentId: string): Promise { + return this.execute(async () => { + const res = await PeersService.listAgentPeers({ path: { agentId } }); + return ((unwrapOrThrow(res, 'Loading peers') as AgentPeerDto[]) ?? + []) as IAgentPeer[]; + }); + } + + candidates(agentId: string): Promise { + return this.execute(async () => { + const res = await PeersService.listAgentPeerCandidates({ + path: { agentId }, + }); + return ((unwrapOrThrow( + res, + 'Loading agents', + ) as AgentPeerCandidateDto[]) ?? []) as IAgentPeerCandidate[]; + }); + } + + /** An agent's own card. Also used to preview a candidate before connecting. */ + card(agentId: string): Promise { + return this.execute(async () => { + const res = await PeersService.getAgentCard({ path: { agentId } }); + return ((unwrapOrThrow(res, 'Loading the card') as AgentCardDto) ?? + null) as IAgentCard | null; + }); + } + + connect(agentId: string, peerAgentId: string): Promise { + return this.execute(async () => { + const res = await PeersService.connectAgentPeer({ + path: { agentId }, + body: { peerAgentId }, + }); + return unwrapOrThrow(res, 'Connecting the peer') as IAgentPeer; + }); + } + + refresh(agentId: string, peerId: string): Promise { + return this.execute(async () => { + const res = await PeersService.refreshAgentPeer({ + path: { agentId, peerId }, + }); + return unwrapOrThrow(res, 'Refreshing the card') as IAgentPeer; + }); + } + + remove(agentId: string, peerId: string): Promise { + return this.execute(async () => { + const res = await PeersService.removeAgentPeer({ + path: { agentId, peerId }, + }); + // 204: nothing to unwrap, but an error still has to surface. + const err = (res as HeyApiResult).error; + if (err) { + const message = (err as { message?: string }).message; + throw new Error(message ?? 'Disconnecting the peer failed'); + } + }); + } + + delegations(agentId: string, limit = 20): Promise { + return this.execute(async () => { + const res = await PeersService.listAgentDelegations({ + path: { agentId }, + query: { limit }, + }); + return ((unwrapOrThrow(res, 'Loading delegations') as AgentDelegationDto[]) ?? + []) as IAgentDelegation[]; + }); + } +} diff --git a/admin/slices/agent/peer/domain/index.ts b/admin/slices/agent/peer/domain/index.ts new file mode 100644 index 00000000..b83bf7b7 --- /dev/null +++ b/admin/slices/agent/peer/domain/index.ts @@ -0,0 +1,2 @@ +export * from './peer.types'; +export * from './peer.service'; diff --git a/admin/slices/agent/peer/domain/peer.service.ts b/admin/slices/agent/peer/domain/peer.service.ts new file mode 100644 index 00000000..f4332cc9 --- /dev/null +++ b/admin/slices/agent/peer/domain/peer.service.ts @@ -0,0 +1,40 @@ +import type { PeerGateway } from '../data/peer.gateway'; +import type { + IAgentCard, + IAgentDelegation, + IAgentPeer, + IAgentPeerCandidate, +} from './peer.types'; + +/** Pass-through to the gateway, in the slice shape the console uses everywhere. */ +export class PeerService { + constructor(private readonly gateway: PeerGateway) {} + + list(agentId: string): Promise { + return this.gateway.list(agentId); + } + + candidates(agentId: string): Promise { + return this.gateway.candidates(agentId); + } + + card(agentId: string): Promise { + return this.gateway.card(agentId); + } + + connect(agentId: string, peerAgentId: string): Promise { + return this.gateway.connect(agentId, peerAgentId); + } + + refresh(agentId: string, peerId: string): Promise { + return this.gateway.refresh(agentId, peerId); + } + + remove(agentId: string, peerId: string): Promise { + return this.gateway.remove(agentId, peerId); + } + + delegations(agentId: string, limit?: number): Promise { + return this.gateway.delegations(agentId, limit); + } +} diff --git a/admin/slices/agent/peer/domain/peer.types.ts b/admin/slices/agent/peer/domain/peer.types.ts new file mode 100644 index 00000000..2b9b4065 --- /dev/null +++ b/admin/slices/agent/peer/domain/peer.types.ts @@ -0,0 +1,64 @@ +/** + * Console-side view of agent-to-agent (CLEAN-74). Mirrors the API DTOs; the + * card shape is the A2A one on purpose, so what an operator reads in the + * preview is literally what another agent reads. + */ +export interface IAgentSkill { + id: string; + name: string; + description: string; + tags: string[]; +} + +export interface IAgentInterface { + url: string; + protocolBinding: string; + protocolVersion: string; +} + +export interface IAgentCard { + name: string; + description: string; + version: string; + supportedInterfaces: IAgentInterface[]; + capabilities: { streaming?: boolean; pushNotifications?: boolean }; + defaultInputModes: string[]; + defaultOutputModes: string[]; + skills: IAgentSkill[]; +} + +/** One directed connection: this agent may delegate to `peerAgentId`. */ +export interface IAgentPeer { + id: string; + agentId: string; + peerAgentId: string; + peerName: string; + peerStatus: string; + peerExists: boolean; + /** Read at connect time or last refresh — never live. */ + card: IAgentCard; + cardUrl: string; + cardReadAt: string; + createdAt: string; +} + +export interface IAgentPeerCandidate { + id: string; + name: string; + status: string; + connected: boolean; +} + +export interface IAgentDelegation { + id: string; + peerAgentId: string; + peerName: string; + task: string; + reason: string; + status: string; + errorCode: string | null; + excerpt: string | null; + startedAt: string; + finishedAt: string | null; + durationMs: number | null; +} diff --git a/admin/slices/agent/peer/i18n/locales/en.json b/admin/slices/agent/peer/i18n/locales/en.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/admin/slices/agent/peer/i18n/locales/en.json @@ -0,0 +1 @@ +{} diff --git a/admin/slices/agent/peer/index.d.ts b/admin/slices/agent/peer/index.d.ts new file mode 100644 index 00000000..b431fc5d --- /dev/null +++ b/admin/slices/agent/peer/index.d.ts @@ -0,0 +1,9 @@ +import type { PeerService } from './domain/peer.service'; + +declare module '#app' { + interface NuxtApp { + $peerService: PeerService; + } +} + +export {}; diff --git a/admin/slices/agent/peer/nuxt.config.ts b/admin/slices/agent/peer/nuxt.config.ts new file mode 100644 index 00000000..87d601bb --- /dev/null +++ b/admin/slices/agent/peer/nuxt.config.ts @@ -0,0 +1,16 @@ +import { fileURLToPath } from 'url'; +import { dirname } from 'path'; + +const currentDir = dirname(fileURLToPath(import.meta.url)); + +export default defineNuxtConfig({ + alias: { '#peer': currentDir }, + imports: { + dirs: [`${currentDir}/stores`], + }, + modules: ['@nuxtjs/i18n'], + i18n: { + langDir: 'locales', + locales: [{ code: 'en', file: 'en.json' }], + }, +}); diff --git a/admin/slices/agent/peer/plugins/di.ts b/admin/slices/agent/peer/plugins/di.ts new file mode 100644 index 00000000..ba8bb5d0 --- /dev/null +++ b/admin/slices/agent/peer/plugins/di.ts @@ -0,0 +1,17 @@ +import { PeerGateway } from '../data/peer.gateway'; +import { PeerService } from '../domain/peer.service'; + +/** + * Composition root for the peer slice. Provides `$peerService`. + */ +export default defineNuxtPlugin({ + name: 'agent-peer-di', + setup() { + const service = new PeerService(new PeerGateway()); + return { + provide: { + peerService: service, + }, + }; + }, +}); diff --git a/admin/slices/agent/peer/stores/peer.ts b/admin/slices/agent/peer/stores/peer.ts new file mode 100644 index 00000000..9e1306fc --- /dev/null +++ b/admin/slices/agent/peer/stores/peer.ts @@ -0,0 +1,179 @@ +import { createServiceGetter } from '#common/composables/createServiceGetter'; +import { useAgentStore } from '#agent/stores/agent'; +import type { PeerService } from '#peer/domain'; +import type { + IAgentCard, + IAgentDelegation, + IAgentPeer, + IAgentPeerCandidate, +} from '#peer/domain'; + +export type { + IAgentCard, + IAgentDelegation, + IAgentPeer, + IAgentPeerCandidate, + IAgentSkill, +} from '#peer/domain'; + +const getService = createServiceGetter('$peerService'); + +/** + * Peers of one agent, its own card, and its recent delegations (CLEAN-74). + * + * Everything is keyed by agent id because the workspace keeps several agents + * a click apart, and a stale list from the previous one would be worse than + * an empty one. + * + * Connecting or removing a peer marks the agent as needing a restart. That is + * not a formality: a pod reads its tool list once at connect, so a peer added + * now is invisible to the running agent until it comes back up. + */ +export const usePeerStore = defineStore('peer', () => { + const peersByAgent = ref>({}); + const cardByAgent = ref>({}); + const candidatesByAgent = ref>({}); + const delegationsByAgent = ref>({}); + + const loading = ref(false); + const error = ref(null); + + function peers(agentId: string): IAgentPeer[] { + return peersByAgent.value[agentId] ?? []; + } + + function card(agentId: string): IAgentCard | null { + return cardByAgent.value[agentId] ?? null; + } + + function candidates(agentId: string): IAgentPeerCandidate[] { + return candidatesByAgent.value[agentId] ?? []; + } + + function delegations(agentId: string): IAgentDelegation[] { + return delegationsByAgent.value[agentId] ?? []; + } + + function fail(err: unknown, fallback: string): never { + error.value = err instanceof Error ? err.message : fallback; + throw err; + } + + /** The tab's first load: what this agent advertises, and who it can ask. */ + async function load(agentId: string): Promise { + loading.value = true; + error.value = null; + try { + const [ownCard, list] = await Promise.all([ + getService().card(agentId), + getService().list(agentId), + ]); + cardByAgent.value = { ...cardByAgent.value, [agentId]: ownCard }; + peersByAgent.value = { ...peersByAgent.value, [agentId]: list }; + } catch (err) { + error.value = + err instanceof Error ? err.message : 'Could not load peers'; + } finally { + loading.value = false; + } + } + + async function loadCandidates(agentId: string): Promise { + try { + const list = await getService().candidates(agentId); + candidatesByAgent.value = { ...candidatesByAgent.value, [agentId]: list }; + } catch (err) { + fail(err, 'Could not load the agents of this installation'); + } + } + + /** The card of a candidate, shown for review before anything is saved. */ + function previewCard(peerAgentId: string): Promise { + return getService().card(peerAgentId); + } + + async function connect(agentId: string, peerAgentId: string): Promise { + const created = await getService().connect(agentId, peerAgentId); + peersByAgent.value = { + ...peersByAgent.value, + [agentId]: [...peers(agentId), created], + }; + markConnected(agentId, peerAgentId, true); + useAgentStore().markPendingRestart(agentId); + } + + async function refresh(agentId: string, peerId: string): Promise { + const updated = await getService().refresh(agentId, peerId); + peersByAgent.value = { + ...peersByAgent.value, + [agentId]: peers(agentId).map((p) => (p.id === peerId ? updated : p)), + }; + // A refreshed card changes what the agent is told its peer can do, and + // that text is read at boot like the rest of the tool list. + useAgentStore().markPendingRestart(agentId); + } + + async function remove(agentId: string, peerId: string): Promise { + const removed = peers(agentId).find((p) => p.id === peerId); + await getService().remove(agentId, peerId); + peersByAgent.value = { + ...peersByAgent.value, + [agentId]: peers(agentId).filter((p) => p.id !== peerId), + }; + if (removed) markConnected(agentId, removed.peerAgentId, false); + useAgentStore().markPendingRestart(agentId); + } + + async function loadDelegations(agentId: string, limit = 20): Promise { + try { + const list = await getService().delegations(agentId, limit); + delegationsByAgent.value = { + ...delegationsByAgent.value, + [agentId]: list, + }; + } catch (err) { + fail(err, 'Could not load recent delegations'); + } + } + + /** Keeps an already-loaded picker honest without a round trip. */ + function markConnected( + agentId: string, + peerAgentId: string, + connected: boolean, + ): void { + const list = candidatesByAgent.value[agentId]; + if (!list) return; + candidatesByAgent.value = { + ...candidatesByAgent.value, + [agentId]: list.map((c) => + c.id === peerAgentId ? { ...c, connected } : c, + ), + }; + } + + function clearError(): void { + error.value = null; + } + + return { + peersByAgent, + cardByAgent, + candidatesByAgent, + delegationsByAgent, + loading, + error, + peers, + card, + candidates, + delegations, + load, + loadCandidates, + previewCard, + connect, + refresh, + remove, + loadDelegations, + clearError, + }; +}); diff --git a/specs/013-a2a-agent-peers/tasks.md b/specs/013-a2a-agent-peers/tasks.md index 3aa30da1..a31e4d68 100644 --- a/specs/013-a2a-agent-peers/tasks.md +++ b/specs/013-a2a-agent-peers/tasks.md @@ -106,15 +106,15 @@ ### Admin -- [ ] T038 [P] [US2] Create the admin slice skeleton `admin/slices/agent/peer/`: `nuxt.config.ts` (alias `'#peer'`, `imports.dirs: ['/stores']`, i18n module + empty `i18n/locales/en.json` — copy of `admin/slices/agent/agentChannel/nuxt.config.ts`), `index.d.ts` declaring `$peerService: PeerService` on `NuxtApp`, `domain/peer.types.ts` (`IAgentPeer`, `IAgentPeerCandidate`, `IAgentCard`, `IAgentSkill`, `IAgentDelegation` — plain interfaces mirroring the DTOs), `domain/index.ts` -- [ ] T039 [US2] Implement `admin/slices/agent/peer/data/peer.gateway.ts` (`extends BaseGateway`, generated `PeersService` from `#api/data`, every call inside `this.execute` + `unwrapEnvelope` — pattern `admin/slices/agent/agent/data/agent.gateway.ts:50-55`): `list(agentId)`, `candidates(agentId)`, `card(agentId)`, `connect(agentId, peerAgentId)`, `refresh(agentId, peerId)`, `remove(agentId, peerId)`, `delegations(agentId, limit)`; `domain/peer.service.ts` pass-through; `plugins/di.ts` providing `$peerService` (pattern `admin/slices/agent/agent/plugins/di.ts:11-28`) (depends on T037, T038) -- [ ] T040 [US2] Implement Pinia store `admin/slices/agent/peer/stores/peer.ts` (`defineStore('peer', () => …)` setup style, `createServiceGetter('$peerService')`): state keyed by agent id — `peers`, `candidates`, `ownCard`, `delegations`, `loading`, `error: string | null`; actions `load(agentId)` (peers + own card, `Promise.allSettled`), `loadCandidates(agentId)`, `previewCard(peerAgentId)` (calls `card(peerAgentId)`), `connect(agentId, peerAgentId)`, `refresh(agentId, peerId)`, `remove(agentId, peerId)`, `loadDelegations(agentId)`; `connect`/`refresh`/`remove` call `useAgentStore().markPendingRestart()` on success (pattern `agentChannel/components/agentChannel/Provider.vue:132-167`); errors become inline strings from the API `message` (depends on T039) -- [ ] T041 [P] [US2] Create `admin/slices/agent/peer/components/peer/CardView.vue`: props `{ card: IAgentCard; compact?: boolean }` — name, description, a `Badge variant="outline"` per skill with the description in `title`, tags rendered as a muted suffix (`skill` / `knowledge`), the interface URL in a `font-mono text-xs` line; "No skills advertised" line when `skills` is empty (pattern `overview/KnowledgeCard.vue`) -- [ ] T042 [P] [US2] Create `admin/slices/agent/peer/components/peer/Row.vue`: props `{ peer: IAgentPeer; busy?: boolean }`, emits `refresh`, `remove` — name (link to `/agents/`), status pill using `AGENT_STATUS_VARIANT` from `admin/slices/agent/agent/utils/agentFormat.ts`, skill badges (first 4 + "+n"), `cardReadAt` via the existing `TimeAgo` component (`admin/slices/common/components/date/TimeAgo.vue`), a `DropdownMenu` with Refresh card / Remove; when two peers in the list share an identical `description` show an amber "indistinguishable from «X»" note (spec edge case) — the comparison is done by the parent and passed as `warning?: string` -- [ ] T043 [US2] Create `admin/slices/agent/peer/components/peer/Picker.vue`: props `{ agentId: string }`, emits `connected` — loads candidates via the store; a filter `Input` when > 6 candidates; rows with name + status + "connected" badge (disabled when connected or when `id === agentId`); selecting a row calls `previewCard` and shows `CardView` below with **Connect** / **Cancel** buttons; connect error inline; on success emits `connected` (depends on T040, T041) -- [ ] T044 [US2] Create `admin/slices/agent/peer/components/peer/Tab.vue`: props `{ agent: IAgentData }`; sections in a `flex flex-col gap-6`: (1) `Card` "Agent card" with `CardView :card="ownCard"` and a one-line explainer "What other agents read about this agent. Built from its name, description, template skills and knowledge bases."; (2) `Card` "Peers" with an **Add peer** button toggling `Picker`, the `Row` list (Skeleton while loading, dashed empty state "No peers yet. Connect another agent's card to let this agent delegate tasks to it."), a `ConfirmDialog` (`admin/slices/common/components/confirm/Dialog.vue`) on remove, and the note "Changes apply after the agent restarts." when `agentStore.pendingRestart` is set; (3) placeholder `Delegations` section rendered only when the store has rows (component arrives in US5); `onMounted` → `store.load(agent.id)` (depends on T040, T042, T043) -- [ ] T045 [US2] Register the tab: add `{ value: 'peers', title: 'Peers', desc: 'Other agents this one can delegate to', countKey: 'peers', primary: true }` after `knowledge` in `admin/slices/agent/agent/components/agent/workspace/sections.ts` `AGENT_TABS`, extend `SectionCountKey` with `'peers'`; add the `v-else-if="tab === 'peers'"` branch mounting `` in `workspace/Canvas.vue:56-116`; add a `peers` loader (`peerStore.list(agent.id).length`) to `admin/slices/agent/agent/composables/useAgentSectionCounts.ts:46-75` under the same `allSettled`/`catch(() => null)` shape (depends on T044) -- [ ] T046 [US2] `cd admin && bun run build:api && bun run typecheck`; then run quickstart §3 second block and the admin part of the US2 independent test locally (depends on T045) +- [X] T038 [P] [US2] Create the admin slice skeleton `admin/slices/agent/peer/`: `nuxt.config.ts` (alias `'#peer'`, `imports.dirs: ['/stores']`, i18n module + empty `i18n/locales/en.json` — copy of `admin/slices/agent/agentChannel/nuxt.config.ts`), `index.d.ts` declaring `$peerService: PeerService` on `NuxtApp`, `domain/peer.types.ts` (`IAgentPeer`, `IAgentPeerCandidate`, `IAgentCard`, `IAgentSkill`, `IAgentDelegation` — plain interfaces mirroring the DTOs), `domain/index.ts` +- [X] T039 [US2] Implement `admin/slices/agent/peer/data/peer.gateway.ts` (`extends BaseGateway`, generated `PeersService` from `#api/data`, every call inside `this.execute` + `unwrapEnvelope` — pattern `admin/slices/agent/agent/data/agent.gateway.ts:50-55`): `list(agentId)`, `candidates(agentId)`, `card(agentId)`, `connect(agentId, peerAgentId)`, `refresh(agentId, peerId)`, `remove(agentId, peerId)`, `delegations(agentId, limit)`; `domain/peer.service.ts` pass-through; `plugins/di.ts` providing `$peerService` (pattern `admin/slices/agent/agent/plugins/di.ts:11-28`) (depends on T037, T038) +- [X] T040 [US2] Implement Pinia store `admin/slices/agent/peer/stores/peer.ts` (`defineStore('peer', () => …)` setup style, `createServiceGetter('$peerService')`): state keyed by agent id — `peers`, `candidates`, `ownCard`, `delegations`, `loading`, `error: string | null`; actions `load(agentId)` (peers + own card, `Promise.allSettled`), `loadCandidates(agentId)`, `previewCard(peerAgentId)` (calls `card(peerAgentId)`), `connect(agentId, peerAgentId)`, `refresh(agentId, peerId)`, `remove(agentId, peerId)`, `loadDelegations(agentId)`; `connect`/`refresh`/`remove` call `useAgentStore().markPendingRestart()` on success (pattern `agentChannel/components/agentChannel/Provider.vue:132-167`); errors become inline strings from the API `message` (depends on T039) +- [X] T041 [P] [US2] Create `admin/slices/agent/peer/components/peer/CardView.vue`: props `{ card: IAgentCard; compact?: boolean }` — name, description, a `Badge variant="outline"` per skill with the description in `title`, tags rendered as a muted suffix (`skill` / `knowledge`), the interface URL in a `font-mono text-xs` line; "No skills advertised" line when `skills` is empty (pattern `overview/KnowledgeCard.vue`) +- [X] T042 [P] [US2] Create `admin/slices/agent/peer/components/peer/Row.vue`: props `{ peer: IAgentPeer; busy?: boolean }`, emits `refresh`, `remove` — name (link to `/agents/`), status pill using `AGENT_STATUS_VARIANT` from `admin/slices/agent/agent/utils/agentFormat.ts`, skill badges (first 4 + "+n"), `cardReadAt` via the existing `TimeAgo` component (`admin/slices/common/components/date/TimeAgo.vue`), a `DropdownMenu` with Refresh card / Remove; when two peers in the list share an identical `description` show an amber "indistinguishable from «X»" note (spec edge case) — the comparison is done by the parent and passed as `warning?: string` +- [X] T043 [US2] Create `admin/slices/agent/peer/components/peer/Picker.vue`: props `{ agentId: string }`, emits `connected` — loads candidates via the store; a filter `Input` when > 6 candidates; rows with name + status + "connected" badge (disabled when connected or when `id === agentId`); selecting a row calls `previewCard` and shows `CardView` below with **Connect** / **Cancel** buttons; connect error inline; on success emits `connected` (depends on T040, T041) +- [X] T044 [US2] Create `admin/slices/agent/peer/components/peer/Tab.vue`: props `{ agent: IAgentData }`; sections in a `flex flex-col gap-6`: (1) `Card` "Agent card" with `CardView :card="ownCard"` and a one-line explainer "What other agents read about this agent. Built from its name, description, template skills and knowledge bases."; (2) `Card` "Peers" with an **Add peer** button toggling `Picker`, the `Row` list (Skeleton while loading, dashed empty state "No peers yet. Connect another agent's card to let this agent delegate tasks to it."), a `ConfirmDialog` (`admin/slices/common/components/confirm/Dialog.vue`) on remove, and the note "Changes apply after the agent restarts." when `agentStore.pendingRestart` is set; (3) placeholder `Delegations` section rendered only when the store has rows (component arrives in US5); `onMounted` → `store.load(agent.id)` (depends on T040, T042, T043) +- [X] T045 [US2] Register the tab: add `{ value: 'peers', title: 'Peers', desc: 'Other agents this one can delegate to', countKey: 'peers', primary: true }` after `knowledge` in `admin/slices/agent/agent/components/agent/workspace/sections.ts` `AGENT_TABS`, extend `SectionCountKey` with `'peers'`; add the `v-else-if="tab === 'peers'"` branch mounting `` in `workspace/Canvas.vue:56-116`; add a `peers` loader (`peerStore.list(agent.id).length`) to `admin/slices/agent/agent/composables/useAgentSectionCounts.ts:46-75` under the same `allSettled`/`catch(() => null)` shape (depends on T044) +- [X] T046 [US2] `cd admin && bun run build:api && bun run typecheck`; then run quickstart §3 second block and the admin part of the US2 independent test locally (depends on T045) **Checkpoint**: A can hold B's card; B's tab is empty; refresh/remove work; the credential stops working after remove (the well-known card returns 401 for the old `ap_`). @@ -165,7 +165,7 @@ - [X] T062 [US5] Implement `checkChain(chain: string[], agentId: string): { rejection: 'loop' | 'depth'; message: string } | null` in `api/src/slices/agent/peer/domain/a2a.server.service.ts` (replacing the US1 no-op): `chain.includes(agentId)` → `loop`, `would loop: is already in the chain`; `chain.length >= A2A_MAX_CHAIN` (env, default 3) → `depth`, `too deep: chain limit is hops`; on rejection build a task `TASK_STATE_REJECTED` with `status.message = { messageId, role: 'ROLE_AGENT', parts: [{ text }] }`, `metadata.ranch.rejection`, no artifacts, **before** the `isAgentConnected` check and without touching the hub (depends on T022) - [ ] T063 [US5] Extend `api/src/slices/agent/peer/domain/a2a.server.service.spec.ts`: chain containing the agent → rejected `loop`, `sendAndAwait` not called; chain of 3 → rejected `depth`; chain of 2 → proceeds; `A2A_MAX_CHAIN=1` respected; the rejection message names the agent (depends on T062) -- [ ] T064 [P] [US5] Create `admin/slices/agent/peer/components/peer/Delegations.vue`: props `{ agentId: string }`; loads `store.loadDelegations(agentId)` on mount and exposes a **Refresh** button; `Table` with columns Peer (link), Task (truncated with `title`), Outcome (status pill + `errorCode` in product wording via a small map), Started (`TimeAgo`), Duration (`s.s s`); dashed empty state "No delegations yet."; mount it as section (3) of `components/peer/Tab.vue` unconditionally (depends on T040, T044) +- [X] T064 [P] [US5] Create `admin/slices/agent/peer/components/peer/Delegations.vue`: props `{ agentId: string }`; loads `store.loadDelegations(agentId)` on mount and exposes a **Refresh** button; `Table` with columns Peer (link), Task (truncated with `title`), Outcome (status pill + `errorCode` in product wording via a small map), Started (`TimeAgo`), Duration (`s.s s`); dashed empty state "No delegations yet."; mount it as section (3) of `components/peer/Tab.vue` unconditionally (depends on T040, T044) - [ ] T065 [US5] `cd admin && bun run typecheck`; run quickstart §6 (loop, live) and check A's and B's Recent delegations show the `answered` and `rejected` rows (depends on T062, T064) **Checkpoint**: Loops and depth are refused server-side; the audit list is visible. From 9fe6192c28c8352149747d0f55517a8970af7192 Mon Sep 17 00:00:00 2001 From: "Maksym Hryzodub [DREAM]" Date: Mon, 14 Sep 2026 13:10:40 +0300 Subject: [PATCH 10/31] =?UTF-8?q?feat(peer):=20ask=5Fagent=20=E2=80=94=20d?= =?UTF-8?q?elegation=20with=20a=20visible=20step=20(CLEAN-74)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01SZ9dCrzZqd5SwR4erLYe12 --- .../slices/agent/peer/askAgent.tool.spec.ts | 308 +++++++++++ api/src/slices/agent/peer/askAgent.tool.ts | 215 ++++++++ .../peer/domain/delegation.service.spec.ts | 487 ++++++++++++++++++ .../agent/peer/domain/delegation.service.ts | 303 +++++++++++ .../agent/peer/domain/delegationStep.spec.ts | 173 +++++++ .../agent/peer/domain/delegationStep.ts | 116 +++++ api/src/slices/agent/peer/domain/index.ts | 2 + api/src/slices/agent/peer/peer.module.ts | 6 + specs/013-a2a-agent-peers/tasks.md | 20 +- 9 files changed, 1620 insertions(+), 10 deletions(-) create mode 100644 api/src/slices/agent/peer/askAgent.tool.spec.ts create mode 100644 api/src/slices/agent/peer/askAgent.tool.ts create mode 100644 api/src/slices/agent/peer/domain/delegation.service.spec.ts create mode 100644 api/src/slices/agent/peer/domain/delegation.service.ts create mode 100644 api/src/slices/agent/peer/domain/delegationStep.spec.ts create mode 100644 api/src/slices/agent/peer/domain/delegationStep.ts diff --git a/api/src/slices/agent/peer/askAgent.tool.spec.ts b/api/src/slices/agent/peer/askAgent.tool.spec.ts new file mode 100644 index 00000000..7f4f3d83 --- /dev/null +++ b/api/src/slices/agent/peer/askAgent.tool.spec.ts @@ -0,0 +1,308 @@ +import { AskAgentTool } from './askAgent.tool'; +import { DelegationErrorCodes, DelegationStatuses } from './domain/peer.types'; +import type { IAgentPeerData, IA2aAgentCard } from './domain'; +import type { IPeerGateway } from './domain/peer.gateway'; +import type { DelegationService } from './domain/delegation.service'; +import type { A2aServerService } from './domain/a2a.server.service'; +import type { Request } from 'express'; + +/** + * What the model sees and what it is told. These cases are mostly about + * wording, and that is the point: the tool's only defence against an agent + * inventing an answer on a peer's behalf is the sentence it gets back when a + * delegation fails. + */ +const card = (name: string): IA2aAgentCard => ({ + name, + description: `${name} answers order questions.`, + version: '1', + supportedInterfaces: [ + { + url: `https://api.test/a2a/agents/${name}`, + protocolBinding: 'JSONRPC', + protocolVersion: '1.0', + }, + ], + capabilities: {}, + defaultInputModes: ['text/plain'], + defaultOutputModes: ['text/plain'], + skills: [ + { + id: 'knowledge:9a', + name: 'Returns policy', + description: 'Answers questions about returns', + tags: ['knowledge'], + }, + ], +}); + +const connection = (name = 'Support Bot', id = 'peer-1'): IAgentPeerData => ({ + id, + agentId: 'a', + peerAgentId: `agent-${name}`, + token: 'ap_' + 'x'.repeat(43), + cardSnapshot: card(name), + cardUrl: 'https://api.test/card', + cardReadAt: '2026-09-14T10:00:00.000Z', + createdAt: '2026-09-14T10:00:00.000Z', + updatedAt: '2026-09-14T10:00:00.000Z', +}); + +const agentRequest = (sub = 'agent:a') => + ({ user: { sub, email: '', roles: [] } }) as unknown as Request; + +function makeHarness(options: { + connections?: IAgentPeerData[]; + outcome?: unknown; + chain?: string[]; +} = {}) { + const peers = { + listByAgent: jest.fn(async () => options.connections ?? [connection()]), + } as unknown as IPeerGateway; + + const run = jest.fn( + async () => + options.outcome ?? { + kind: 'done', + status: DelegationStatuses.Answered, + peerName: 'Support Bot', + contextId: 'ctx-1', + durationMs: 3120, + text: 'Shoes can be returned within 30 days.', + }, + ); + + const a2aServer = { + currentChain: jest.fn(() => options.chain ?? []), + timeoutMs: 120_000, + } as unknown as A2aServerService; + + const tool = new AskAgentTool( + peers, + { run } as unknown as DelegationService, + a2aServer, + ); + + const args = { + peer: 'Support Bot', + task: 'What is the return window for shoes?', + reason: 'Support Bot holds the returns policy base', + }; + + return { tool, peers, run, args }; +} + +const textOf = (result: { content: { text: string }[] }) => + result.content.map((c) => c.text).join('\n'); + +describe('AskAgentTool — being offered at all', () => { + it('is not listed for an agent with no peers', async () => { + const { tool } = makeHarness({ connections: [] }); + + await expect(tool.isListedForRequest(agentRequest())).resolves.toBe(false); + }); + + it('is listed for an agent that has one', async () => { + const { tool } = makeHarness(); + + await expect(tool.isListedForRequest(agentRequest())).resolves.toBe(true); + }); + + it('is not listed for a caller that is not an agent at all', async () => { + const { tool, peers } = makeHarness(); + + await expect(tool.isListedForRequest(agentRequest('user-1'))).resolves.toBe( + false, + ); + expect(peers.listByAgent).not.toHaveBeenCalled(); + }); +}); + +describe('AskAgentTool — the description the model reads', () => { + it('lists each peer with its name, id and what its card claims', async () => { + const { tool } = makeHarness(); + + const description = await tool.describeForRequest(agentRequest()); + + expect(description).toContain('"Support Bot"'); + expect(description).toContain('peer: agent-Support Bot'); + expect(description).toContain('Support Bot answers order questions.'); + expect(description).toContain( + 'Returns policy (Answers questions about returns)', + ); + }); + + it('says when NOT to call it, not only when to', async () => { + const { tool } = makeHarness(); + + const description = (await tool.describeForRequest(agentRequest())) ?? ''; + + expect(description).toMatch(/not call it for anything you can do yourself/i); + }); + + it('warns that the peer cannot see the conversation', async () => { + const { tool } = makeHarness(); + + const description = (await tool.describeForRequest(agentRequest())) ?? ''; + + expect(description).toMatch(/does not see this conversation/i); + }); + + it('falls back to the static text for an agent with no peers', async () => { + const { tool } = makeHarness({ connections: [] }); + + await expect(tool.describeForRequest(agentRequest())).resolves.toBeNull(); + }); + + it('lists several peers, each on its own line', async () => { + const { tool } = makeHarness({ + connections: [connection('Support Bot'), connection('Billing Bot', 'peer-2')], + }); + + const description = (await tool.describeForRequest(agentRequest())) ?? ''; + + expect(description).toContain('"Support Bot"'); + expect(description).toContain('"Billing Bot"'); + }); +}); + +describe('AskAgentTool — calling it', () => { + it('refuses a caller that is not an agent runtime', async () => { + const { tool, args } = makeHarness(); + + const result = await tool.ask(args, null, agentRequest('user-1')); + + expect(result.isError).toBe(true); + expect(textOf(result)).toContain('agent runtime'); + }); + + it('refuses arguments it cannot act on, naming what is wrong', async () => { + const { tool, run } = makeHarness(); + + const result = await tool.ask( + { peer: 'Support Bot', task: 'do it' }, + null, + agentRequest(), + ); + + expect(result.isError).toBe(true); + expect(textOf(result)).toContain('reason'); + expect(run).not.toHaveBeenCalled(); + }); + + it('returns the reply with the peer, the wait and a context to continue from', async () => { + const { tool, args } = makeHarness(); + + const result = await tool.ask(args, null, agentRequest()); + + expect(result.isError).toBeUndefined(); + const text = textOf(result); + expect(text).toContain('Support Bot'); + expect(text).toContain('ctx-1'); + expect(text).toContain('3.1s'); + expect(text).toContain('Shoes can be returned within 30 days.'); + }); + + it('tells the model not to answer for a peer it could not reach', async () => { + const { tool, args } = makeHarness({ + outcome: { + kind: 'done', + status: DelegationStatuses.Failed, + peerName: 'Support Bot', + contextId: 'ctx-1', + durationMs: 120, + errorCode: DelegationErrorCodes.NotRunning, + }, + }); + + const result = await tool.ask(args, null, agentRequest()); + + expect(result.isError).toBe(true); + const text = textOf(result); + expect(text).toContain('Could not reach «Support Bot»'); + expect(text).toContain('it is not running'); + expect(text).toMatch(/do not answer on its behalf/i); + }); + + it('says the same about a timeout', async () => { + const { tool, args } = makeHarness({ + outcome: { + kind: 'done', + status: DelegationStatuses.Failed, + peerName: 'Support Bot', + contextId: 'ctx-1', + durationMs: 120_000, + errorCode: DelegationErrorCodes.Timeout, + }, + }); + + const result = await tool.ask(args, null, agentRequest()); + + expect(result.isError).toBe(true); + expect(textOf(result)).toContain('did not answer in time'); + }); + + it('reports a refusal as a refusal, with the peer own words', async () => { + const { tool, args } = makeHarness({ + outcome: { + kind: 'done', + status: DelegationStatuses.Rejected, + peerName: 'Support Bot', + contextId: 'ctx-1', + durationMs: 40, + errorCode: DelegationErrorCodes.RejectedLoop, + cause: 'would loop: «Caller» is already in this chain', + }, + }); + + const result = await tool.ask(args, null, agentRequest()); + + expect(result.isError).toBe(true); + const text = textOf(result); + expect(text).toContain('refused the task'); + expect(text).toContain('would loop'); + }); + + it('lists the peers it does have when the name matches none', async () => { + const { tool, args } = makeHarness({ + outcome: { kind: 'no_match', peers: ['Support Bot', 'Billing Bot'] }, + }); + + const result = await tool.ask( + { ...args, peer: 'Nobody' }, + null, + agentRequest(), + ); + + expect(result.isError).toBe(true); + const text = textOf(result); + expect(text).toContain('No peer matches "Nobody"'); + expect(text).toContain('"Support Bot"'); + expect(text).toContain('"Billing Bot"'); + }); + + it('carries the chain this agent is itself part of', async () => { + const { tool, args, run } = makeHarness({ chain: ['origin', 'a'] }); + + await tool.ask(args, null, agentRequest()); + + expect(run).toHaveBeenCalledWith( + expect.objectContaining({ + callerAgentId: 'a', + inboundChain: ['origin', 'a'], + }), + 120_000, + ); + }); + + it('passes a context id through so a follow-up continues the same exchange', async () => { + const { tool, args, run } = makeHarness(); + + await tool.ask({ ...args, context_id: 'ctx-42' }, null, agentRequest()); + + expect(run).toHaveBeenCalledWith( + expect.objectContaining({ contextId: 'ctx-42' }), + 120_000, + ); + }); +}); diff --git a/api/src/slices/agent/peer/askAgent.tool.ts b/api/src/slices/agent/peer/askAgent.tool.ts new file mode 100644 index 00000000..3d93761c --- /dev/null +++ b/api/src/slices/agent/peer/askAgent.tool.ts @@ -0,0 +1,215 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { z } from 'zod'; +import { Tool } from '#mcp'; +import type { Request } from 'express'; +import type { IAuthTokenPayload } from '#/user/auth/domain/auth.types'; +import type { IDynamicallyDescribedTool } from '#/mcp/interfaces/dynamic-description.interface'; +import type { IConditionallyListedTool } from '#/mcp/interfaces/conditional-listing.interface'; +import { IPeerGateway } from './domain/peer.gateway'; +import { DelegationService } from './domain/delegation.service'; +import { A2aServerService } from './domain/a2a.server.service'; +import { causeText } from './domain/delegationStep'; +import { + DelegationStatuses, + type IAgentPeerData, +} from './domain/peer.types'; + +interface ToolResult { + content: { type: 'text'; text: string }[]; + isError?: boolean; +} + +const ok = (text: string): ToolResult => ({ + content: [{ type: 'text', text }], +}); + +const err = (text: string): ToolResult => ({ + content: [{ type: 'text', text }], + isError: true, +}); + +/** + * Read by a model that has never seen this product. Every sentence earns its + * place: when to call it, when NOT to (the expensive mistake is treating a + * colleague as a first resort), that the peer sees none of this conversation, + * and that `reason` is shown to a person — which is what makes the visible + * step worth reading rather than a restatement of the task. + */ +const BASE_DESCRIPTION = + 'Ask one of your connected peer agents to do a task you cannot do yourself, ' + + 'then use their reply in your answer and say it came from them. Call this ' + + 'when the user asks about something a peer\'s skills cover and your own ' + + 'tools do not. Do NOT call it for anything you can do yourself — a peer is ' + + 'a colleague, not a first resort. The peer does not see this conversation, ' + + 'so send it a self-contained task in plain text. If several independent ' + + 'questions go to different peers, call this tool once for each in the same ' + + 'turn. Give a one-line `reason` naming the skill that made you pick that ' + + 'peer: it is shown to the person watching.'; + +const NO_PEERS_DESCRIPTION = + 'Ask one of your connected peer agents to do a task you cannot do yourself. ' + + 'You currently have no peers connected, so do not call this tool.'; + +const DO_NOT_GUESS = + 'Tell the user you could not get this from that agent; do not answer on its behalf.'; + +const schema = z.object({ + peer: z + .string() + .describe('Peer id from the list in this tool description, or its exact name'), + task: z + .string() + .min(1) + .describe( + 'The task, written so it stands alone — the peer cannot see this conversation', + ), + reason: z + .string() + .min(1) + .describe( + 'One line: why this peer. Name the skill that matched. Shown to the user.', + ), + context_id: z + .string() + .optional() + .describe( + 'To continue an earlier exchange with the same peer in this turn, pass the context_id from its previous reply', + ), +}); + +/** + * Delegation, served to the agent runtime through the MCP channel it already + * uses (CLEAN-74). Living in the API rather than in the runtime image is what + * lets peers ship without rebuilding a single agent. + */ +@Injectable() +export class AskAgentTool + implements IDynamicallyDescribedTool, IConditionallyListedTool +{ + private readonly logger = new Logger(AskAgentTool.name); + + constructor( + private readonly peers: IPeerGateway, + private readonly delegations: DelegationService, + private readonly a2aServer: A2aServerService, + ) {} + + /** + * An agent with no peers must not see this tool at all. A description saying + * "you have none" would still be an advertisement for colleagues that do not + * exist, and models act on tool lists, not on caveats inside them. + */ + async isListedForRequest(httpRequest: Request): Promise { + const agentId = extractAgentId(httpRequest); + if (!agentId) return false; + const connections = await this.peers.listByAgent(agentId); + return connections.length > 0; + } + + async describeForRequest(httpRequest: Request): Promise { + const agentId = extractAgentId(httpRequest); + if (!agentId) return null; + + const connections = await this.peers.listByAgent(agentId); + if (connections.length === 0) return null; + + return [ + BASE_DESCRIPTION, + '', + 'Your peers:', + ...connections.map((c) => describePeer(c)), + ].join('\n'); + } + + @Tool({ + name: 'ask_agent', + description: NO_PEERS_DESCRIPTION, + parameters: schema, + }) + async ask( + args: unknown, + _context: unknown, + httpRequest: Request, + ): Promise { + const agentId = extractAgentId(httpRequest); + if (!agentId) { + return err('ask_agent can only be called by an agent runtime.'); + } + + const parsed = schema.safeParse(args); + if (!parsed.success) { + const issue = parsed.error.issues[0]; + return err( + `Invalid arguments: ${issue.path.join('.') || 'input'} — ${issue.message}`, + ); + } + + const outcome = await this.delegations.run( + { + callerAgentId: agentId, + peer: parsed.data.peer, + task: parsed.data.task, + reason: parsed.data.reason, + contextId: parsed.data.context_id, + // This agent may itself be serving a delegation right now. Carrying + // that chain forward is what lets the far end refuse a loop it can see + // and this end cannot. + inboundChain: this.a2aServer.currentChain(agentId), + }, + this.a2aServer.timeoutMs, + ); + + if (outcome.kind === 'no_match') { + const names = outcome.peers.length + ? outcome.peers.map((n) => `"${n}"`).join(', ') + : 'none'; + return err( + `No peer matches "${parsed.data.peer}". Your peers are: ${names}.`, + ); + } + + const took = `${(outcome.durationMs / 1000).toFixed(1)}s`; + + if (outcome.status === DelegationStatuses.Answered) { + return ok( + `Reply from «${outcome.peerName}» (context_id: ${outcome.contextId}, ${took}):\n\n${outcome.text ?? ''}`, + ); + } + + if (outcome.status === DelegationStatuses.Rejected) { + return err( + `«${outcome.peerName}» refused the task: ${outcome.cause ?? causeText(outcome.errorCode ?? null)}. ${DO_NOT_GUESS}`, + ); + } + + return err( + `Could not reach «${outcome.peerName}»: ${causeText(outcome.errorCode ?? null)}. ${DO_NOT_GUESS}`, + ); + } +} + +/** + * One line per peer, carrying exactly what a choice needs: what it is, how to + * name it, and what its card claims it can do. + */ +function describePeer(connection: IAgentPeerData): string { + const card = connection.cardSnapshot; + const name = card?.name ?? connection.peerAgentId; + const description = card?.description ?? ''; + const skills = (card?.skills ?? []) + .map((s) => `${s.name} (${s.description})`) + .join('; '); + + const parts = [`- "${name}" (peer: ${connection.peerAgentId})`]; + if (description) parts.push(` — ${description}`); + if (skills) parts.push(` Skills: ${skills}`); + return parts.join(''); +} + +/** Agent service tokens carry `sub = agent:`; anything else is not an agent. */ +function extractAgentId(httpRequest: Request): string | null { + const user = (httpRequest as Request & { user?: IAuthTokenPayload }).user; + const sub = user?.sub ?? ''; + if (!sub.startsWith('agent:')) return null; + return sub.slice('agent:'.length); +} diff --git a/api/src/slices/agent/peer/domain/delegation.service.spec.ts b/api/src/slices/agent/peer/domain/delegation.service.spec.ts new file mode 100644 index 00000000..b08460bd --- /dev/null +++ b/api/src/slices/agent/peer/domain/delegation.service.spec.ts @@ -0,0 +1,487 @@ +import { DelegationService } from './delegation.service'; +import { + A2aTaskStates, + type IA2aAgentCard, + type IA2aTask, +} from './a2a.types'; +import { + DelegationError, + DelegationErrorCodes, + type IAgentPeerData, +} from './peer.types'; +import type { IPeerGateway } from './peer.gateway'; +import type { IDelegationGateway } from './delegation.gateway'; +import type { A2aClient } from './a2a.client'; +import type { IBridleGateway } from '#/bridle/domain/bridle.gateway'; + +/** + * One delegation end to end. The invariants worth defending are about order + * and honesty: the audit row exists before the outbound call (a crash must + * leave a trace), the step is pushed before the wait (the chat must never look + * idle), the row is finished exactly once, and a failure never comes back + * looking like an answer. + */ +const card = (name: string, skills: IA2aAgentCard['skills'] = []): IA2aAgentCard => ({ + name, + description: `${name} answers things`, + version: '1', + supportedInterfaces: [ + { + url: `https://api.test/a2a/agents/${name}`, + protocolBinding: 'JSONRPC', + protocolVersion: '1.0', + }, + ], + capabilities: {}, + defaultInputModes: ['text/plain'], + defaultOutputModes: ['text/plain'], + skills, +}); + +const connection = ( + overrides: Partial = {}, +): IAgentPeerData => ({ + id: 'peer-1', + agentId: 'a', + peerAgentId: 'b', + token: 'ap_' + 'x'.repeat(43), + cardSnapshot: card('Support Bot', [ + { + id: 'knowledge:9a', + name: 'Returns policy', + description: 'Answers questions about returns and refunds', + tags: ['knowledge'], + }, + { + id: 'skill:1', + name: 'Order lookup', + description: 'Finds an order by number', + tags: ['skill'], + }, + ]), + cardUrl: 'https://api.test/a2a/agents/b/.well-known/agent-card.json', + cardReadAt: '2026-09-14T10:00:00.000Z', + createdAt: '2026-09-14T10:00:00.000Z', + updatedAt: '2026-09-14T10:00:00.000Z', + ...overrides, +}); + +const completed = (text = 'Within 30 days.'): IA2aTask => ({ + id: 't1', + contextId: 'ctx-1', + status: { state: A2aTaskStates.Completed, timestamp: '2026-09-14T10:00:03Z' }, + artifacts: [{ artifactId: 'reply', parts: [{ text }] }], + history: [], + metadata: { ranch: { chain: ['a', 'b'] } }, +}); + +const failedTask = (failure: 'not_running' | 'timeout', text: string): IA2aTask => ({ + id: 't2', + contextId: 'ctx-1', + status: { + state: A2aTaskStates.Failed, + timestamp: '2026-09-14T10:00:00Z', + message: { messageId: 'm', role: 'ROLE_AGENT', parts: [{ text }] }, + }, + artifacts: [], + history: [], + metadata: { ranch: { chain: ['a', 'b'], failure } }, +}); + +const rejectedTask = (rejection: 'loop' | 'depth', text: string): IA2aTask => ({ + id: 't3', + contextId: 'ctx-1', + status: { + state: A2aTaskStates.Rejected, + timestamp: '2026-09-14T10:00:00Z', + message: { messageId: 'm', role: 'ROLE_AGENT', parts: [{ text }] }, + }, + artifacts: [], + history: [], + metadata: { ranch: { chain: ['a', 'b'], rejection } }, +}); + +function makeHarness(options: { + connections?: IAgentPeerData[]; + task?: IA2aTask; + sendThrows?: unknown; + activeTurn?: { clientId: string; turnId: string; ts: number } | null; +} = {}) { + const order: string[] = []; + const rows: Record = {}; + let seq = 0; + + const peers = { + listByAgent: jest.fn(async () => options.connections ?? [connection()]), + } as unknown as IPeerGateway; + + const delegations = { + create: jest.fn(async (input: Record) => { + order.push('create'); + const id = `del-${(seq += 1)}`; + rows[id] = { + id, + status: 'waiting', + errorCode: null, + excerpt: null, + startedAt: '2026-09-14T10:00:00.000Z', + finishedAt: null, + durationMs: null, + ...input, + }; + return rows[id]; + }), + finish: jest.fn(async (id: string, input: Record) => { + order.push('finish'); + Object.assign(rows[id], input, { + finishedAt: input.finishedAt.toISOString(), + }); + return rows[id]; + }), + listRecent: jest.fn(async () => []), + } as unknown as IDelegationGateway; + + const sendMessage = jest.fn(async (..._args: unknown[]) => { + order.push('send'); + if (options.sendThrows) throw options.sendThrows; + return options.task ?? completed(); + }); + const client = { sendMessage } as unknown as A2aClient; + + const sent: Array<{ clientId: string; agentId: string; data: any }> = []; + const hub = { + findActiveTurn: jest.fn(() => + options.activeTurn === undefined + ? { clientId: 'admin', turnId: 'turn-1', ts: 1 } + : options.activeTurn, + ), + sendToClient: jest.fn((clientId: string, agentId: string, data: unknown) => { + order.push('push'); + sent.push({ clientId, agentId, data }); + }), + } as unknown as IBridleGateway; + + const service = new DelegationService(peers, delegations, client, hub); + + const run = (overrides: Record = {}) => + service.run( + { + callerAgentId: 'a', + peer: 'peer-1', + task: 'What is the return window for shoes?', + reason: 'Support Bot holds the returns policy base', + inboundChain: [], + ...overrides, + } as never, + 120_000, + ); + + return { service, run, delegations, sendMessage, hub, sent, order, rows }; +} + +describe('DelegationService.run — choosing the peer', () => { + it('matches a peer by its connection id', async () => { + const { run } = makeHarness(); + + const outcome = await run({ peer: 'peer-1' }); + + expect(outcome).toMatchObject({ kind: 'done', peerName: 'Support Bot' }); + }); + + it('matches a peer by the agent id', async () => { + const { run } = makeHarness(); + + await expect(run({ peer: 'b' })).resolves.toMatchObject({ kind: 'done' }); + }); + + it('matches a peer by the name on its card, whatever the case', async () => { + const { run } = makeHarness(); + + await expect(run({ peer: 'support bot' })).resolves.toMatchObject({ + kind: 'done', + }); + }); + + it('reports the peers it does have when the name matches none', async () => { + const { run, sendMessage } = makeHarness(); + + const outcome = await run({ peer: 'Nobody' }); + + expect(outcome).toEqual({ kind: 'no_match', peers: ['Support Bot'] }); + expect(sendMessage).not.toHaveBeenCalled(); + }); + + it('quotes the card skills that matched the reason', async () => { + const { run, rows } = makeHarness(); + + await run({ reason: 'it holds the returns policy' }); + + const [row] = Object.values(rows) as any[]; + expect(row.matchedSkills).toEqual([ + { id: 'knowledge:9a', name: 'Returns policy' }, + ]); + }); + + it('names the first skill when nothing in the reason overlaps', async () => { + const { run, rows } = makeHarness(); + + await run({ reason: 'xyz', task: 'zzz' }); + + const [row] = Object.values(rows) as any[]; + expect(row.matchedSkills).toEqual([ + { id: 'knowledge:9a', name: 'Returns policy' }, + ]); + }); + + it('records no skills for a peer that advertises none', async () => { + const { run, rows } = makeHarness({ + connections: [connection({ cardSnapshot: card('Bare') })], + }); + + await run({ peer: 'Bare' }); + + const [row] = Object.values(rows) as any[]; + expect(row.matchedSkills).toEqual([]); + }); +}); + +describe('DelegationService.run — the audit row', () => { + it('opens the row before it asks anything, and closes it after', async () => { + const { run, order } = makeHarness(); + + await run(); + + expect(order.indexOf('create')).toBeLessThan(order.indexOf('send')); + expect(order.indexOf('send')).toBeLessThan(order.indexOf('finish')); + }); + + it('records an answer with its duration and an excerpt', async () => { + const { run, delegations } = makeHarness({ + task: completed('Shoes can be returned within 30 days.'), + }); + + await run(); + + expect(delegations.finish).toHaveBeenCalledWith( + 'del-1', + expect.objectContaining({ + status: 'answered', + errorCode: null, + excerpt: 'Shoes can be returned within 30 days.', + }), + ); + }); + + it('truncates a long reply so the record stays readable', async () => { + const { run, rows } = makeHarness({ task: completed('x'.repeat(1000)) }); + + await run(); + + const [row] = Object.values(rows) as any[]; + expect(row.excerpt).toHaveLength(300); + }); + + it('closes the row exactly once, even when the call throws', async () => { + const { run, delegations } = makeHarness({ + sendThrows: new DelegationError( + DelegationErrorCodes.Unreachable, + 'socket hang up', + ), + }); + + await run(); + + expect(delegations.finish).toHaveBeenCalledTimes(1); + }); +}); + +describe('DelegationService.run — outcomes', () => { + it('hands back the reply text on success', async () => { + const { run } = makeHarness({ task: completed('Within 30 days.') }); + + await expect(run()).resolves.toMatchObject({ + status: 'answered', + text: 'Within 30 days.', + }); + }); + + it('turns a peer that is not running into a stated failure', async () => { + const { run } = makeHarness({ + task: failedTask('not_running', 'peer not running'), + }); + + await expect(run()).resolves.toMatchObject({ + status: 'failed', + errorCode: DelegationErrorCodes.NotRunning, + }); + }); + + it('turns a timeout into a stated failure', async () => { + const { run } = makeHarness({ + task: failedTask('timeout', 'timed out after 120s'), + }); + + await expect(run()).resolves.toMatchObject({ + status: 'failed', + errorCode: DelegationErrorCodes.Timeout, + }); + }); + + it('separates a loop refusal from a depth refusal', async () => { + const loop = await makeHarness({ + task: rejectedTask('loop', 'would loop'), + }).run(); + const depth = await makeHarness({ + task: rejectedTask('depth', 'too deep'), + }).run(); + + expect(loop).toMatchObject({ + status: 'rejected', + errorCode: DelegationErrorCodes.RejectedLoop, + }); + expect(depth).toMatchObject({ + status: 'rejected', + errorCode: DelegationErrorCodes.RejectedDepth, + }); + }); + + it('keeps a transport failure as the code the client gave it', async () => { + const { run } = makeHarness({ + sendThrows: new DelegationError( + DelegationErrorCodes.Unauthorized, + 'refused this credential', + ), + }); + + await expect(run()).resolves.toMatchObject({ + status: 'failed', + errorCode: DelegationErrorCodes.Unauthorized, + }); + }); + + it('never returns reply text for anything that was not an answer', async () => { + const { run } = makeHarness({ + task: failedTask('not_running', 'peer not running'), + }); + + const outcome = await run(); + + expect(outcome).not.toHaveProperty('text'); + }); +}); + +describe('DelegationService.run — what goes over the wire', () => { + it('asks at the address on the stored card, with the pair credential', async () => { + const { run, sendMessage } = makeHarness(); + + await run(); + + const [url, token] = sendMessage.mock.calls[0] as [string, string]; + expect(url).toBe('https://api.test/a2a/agents/Support Bot'); + expect(token).toMatch(/^ap_/); + }); + + it('appends itself to the chain it was called with', async () => { + const { run, sendMessage } = makeHarness(); + + await run({ inboundChain: ['origin'] }); + + const params = sendMessage.mock.calls[0][2] as any; + expect(params.message.metadata.ranch.chain).toEqual(['origin', 'a']); + }); + + it('starts a chain when the caller is not itself a peer', async () => { + const { run, sendMessage } = makeHarness(); + + await run(); + + const params = sendMessage.mock.calls[0][2] as any; + expect(params.message.metadata.ranch.chain).toEqual(['a']); + }); + + it('continues an earlier exchange when given its context', async () => { + const { run, sendMessage } = makeHarness(); + + const outcome = await run({ context_id: 'ctx-42', contextId: 'ctx-42' }); + + const params = sendMessage.mock.calls[0][2] as any; + expect(params.message.contextId).toBe('ctx-42'); + expect(outcome).toMatchObject({ contextId: 'ctx-42' }); + }); + + it('mints a context to continue from when none was given', async () => { + const { run } = makeHarness(); + + const outcome = await run(); + + expect((outcome as { contextId: string }).contextId).toMatch(/^ctx-/); + }); +}); + +describe('DelegationService.run — the visible step', () => { + it('pushes the step before the wait and again when it is over', async () => { + const { run, order, sent } = makeHarness(); + + await run(); + + expect(order).toEqual(['create', 'push', 'send', 'finish', 'push']); + expect(sent).toHaveLength(2); + expect(sent[0].data.step.state).toBe('active'); + expect(sent[1].data.step.state).toBe('done'); + }); + + it('uses one step id, so the second push replaces the first', async () => { + const { run, sent } = makeHarness(); + + await run(); + + expect(sent[0].data.step.id).toBe(sent[1].data.step.id); + }); + + it('joins the turn the runtime already opened', async () => { + const { run, sent } = makeHarness(); + + await run(); + + expect(sent[0]).toMatchObject({ clientId: 'admin', agentId: 'a' }); + expect(sent[0].data).toMatchObject({ turnId: 'turn-1', type: 'thinking' }); + }); + + it('never closes the turn it borrowed', async () => { + const { run, sent } = makeHarness(); + + await run(); + + for (const push of sent) expect(push.data.done).toBeUndefined(); + }); + + it('remembers which turn it spoke into', async () => { + const { run, rows } = makeHarness(); + + await run(); + + const [row] = Object.values(rows) as any[]; + expect(row).toMatchObject({ turnId: 'turn-1', clientId: 'admin' }); + }); + + it('runs the delegation anyway when nobody is watching', async () => { + const { run, sent, delegations } = makeHarness({ activeTurn: null }); + + const outcome = await run(); + + expect(sent).toHaveLength(0); + expect(outcome).toMatchObject({ status: 'answered' }); + expect(delegations.finish).toHaveBeenCalledTimes(1); + }); + + it('shows the failure in the step, not only in the tool result', async () => { + const { run, sent } = makeHarness({ + task: failedTask('not_running', 'peer not running'), + }); + + await run(); + + expect(sent[1].data.step.label).toBe('Could not reach «Support Bot»'); + expect(sent[1].data.step.delegation.status).toBe('failed'); + }); +}); diff --git a/api/src/slices/agent/peer/domain/delegation.service.ts b/api/src/slices/agent/peer/domain/delegation.service.ts new file mode 100644 index 00000000..2b94b981 --- /dev/null +++ b/api/src/slices/agent/peer/domain/delegation.service.ts @@ -0,0 +1,303 @@ +import { Injectable, Logger } from '@nestjs/common'; +import * as crypto from 'crypto'; +import { IBridleGateway } from '#/bridle/domain/bridle.gateway'; +import { IPeerGateway } from './peer.gateway'; +import { IDelegationGateway } from './delegation.gateway'; +import { A2aClient } from './a2a.client'; +import { buildDelegationStep, causeText } from './delegationStep'; +import { + A2aRoles, + A2aTaskStates, + textOfParts, + type IA2aTask, +} from './a2a.types'; +import { + DELEGATION_EXCERPT_CHARS, + DelegationError, + DelegationErrorCodes, + DelegationStatuses, + type DelegationErrorCode, + type DelegationStatus, + type IAgentPeerData, + type IMatchedSkill, +} from './peer.types'; + +export interface IRunDelegationInput { + callerAgentId: string; + /** Peer id, or the peer's name as the card spells it. */ + peer: string; + task: string; + reason: string; + contextId?: string; + /** The chain this request is already part of, when the caller is itself a peer. */ + inboundChain: string[]; +} + +export type IDelegationOutcome = + | { kind: 'no_match'; peers: string[] } + | { + kind: 'done'; + status: DelegationStatus; + peerName: string; + contextId: string; + durationMs: number; + text?: string; + errorCode?: DelegationErrorCode; + cause?: string; + }; + +/** Words shorter than this carry no signal when matching a reason to a skill. */ +const MEANINGFUL_WORD_CHARS = 4; +const MAX_MATCHED_SKILLS = 3; + +/** + * One delegation, end to end (CLEAN-74): pick the peer, write the audit row, + * show the person what is happening, ask, and record how it went. + * + * The ordering is deliberate and worth keeping: the row exists 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; and the row is + * finished exactly once, whatever happened. + */ +@Injectable() +export class DelegationService { + private readonly logger = new Logger(DelegationService.name); + + constructor( + private readonly peers: IPeerGateway, + private readonly delegations: IDelegationGateway, + private readonly client: A2aClient, + private readonly hub: IBridleGateway, + ) {} + + async run( + input: IRunDelegationInput, + timeoutMs: number, + ): Promise { + const connections = await this.peers.listByAgent(input.callerAgentId); + const peer = matchPeer(connections, input.peer); + if (!peer) { + return { + kind: 'no_match', + peers: connections.map((c) => c.cardSnapshot?.name ?? c.peerAgentId), + }; + } + + const peerName = peer.cardSnapshot?.name ?? peer.peerAgentId; + const contextId = input.contextId ?? `ctx-${crypto.randomUUID()}`; + const matchedSkills = matchSkills(peer, `${input.reason} ${input.task}`); + + // Whoever is watching this agent right now. Unknown is a normal outcome — + // a chat surface that renders no thinking timeline has no turn to join — + // and the delegation must run exactly the same either way. + const turn = this.hub.findActiveTurn(input.callerAgentId); + + const row = await this.delegations.create({ + agentId: input.callerAgentId, + peerId: peer.id, + peerAgentId: peer.peerAgentId, + peerName, + contextId, + turnId: turn?.turnId ?? null, + clientId: turn?.clientId ?? null, + task: input.task, + reason: input.reason, + matchedSkills, + }); + + this.publish(input.callerAgentId, turn, row); + + const startedAt = Date.now(); + let status: DelegationStatus = DelegationStatuses.Failed; + let errorCode: DelegationErrorCode | undefined; + let text: string | undefined; + let excerpt: string | null = null; + + try { + const task = await this.client.sendMessage( + peer.cardSnapshot.supportedInterfaces[0].url, + peer.token, + { + message: { + messageId: `m-${crypto.randomUUID()}`, + role: A2aRoles.User, + parts: [{ text: input.task }], + contextId, + metadata: { + ranch: { + chain: [...input.inboundChain, input.callerAgentId], + reason: input.reason, + }, + }, + }, + configuration: { + acceptedOutputModes: ['text/plain'], + returnImmediately: false, + }, + }, + timeoutMs, + ); + + ({ status, errorCode, text } = readTask(task)); + excerpt = + status === DelegationStatuses.Answered + ? (text ?? '').slice(0, DELEGATION_EXCERPT_CHARS) + : (statusText(task) ?? causeText(errorCode ?? null)); + } catch (err) { + errorCode = + err instanceof DelegationError ? err.code : DelegationErrorCodes.Error; + status = DelegationStatuses.Failed; + excerpt = err instanceof Error ? err.message : String(err); + this.logger.warn( + `Delegation ${row.id} to «${peerName}» failed: ${excerpt}`, + ); + } + + const durationMs = Date.now() - startedAt; + const finished = await this.delegations.finish(row.id, { + status, + errorCode: errorCode ?? null, + excerpt, + finishedAt: new Date(), + durationMs, + }); + + this.publish(input.callerAgentId, turn, finished); + + return { + kind: 'done', + status, + peerName, + contextId, + durationMs, + ...(text !== undefined ? { text } : {}), + ...(errorCode ? { errorCode } : {}), + ...(excerpt ? { cause: excerpt } : {}), + }; + } + + /** + * Adds the step to the timeline the runtime already opened for this turn. + * Silent when there is none: a delegation with no audience is still a + * delegation, and inventing a turn would close the runtime's own block. + */ + private publish( + agentId: string, + turn: { clientId: string; turnId: string } | null, + row: Parameters[0], + ): void { + if (!turn) { + this.logger.debug( + `No active turn for agent=${agentId}; delegation ${row.id} runs without a visible step`, + ); + return; + } + + this.hub.sendToClient(turn.clientId, agentId, { + type: 'thinking', + clientId: turn.clientId, + turnId: turn.turnId, + ts: Date.now(), + step: buildDelegationStep(row), + }); + } +} + +/** By connection id first, then by the name the card advertises. */ +function matchPeer( + connections: IAgentPeerData[], + wanted: string, +): IAgentPeerData | null { + const needle = wanted.trim().toLowerCase(); + if (!needle) return null; + + return ( + connections.find( + (c) => + c.id.toLowerCase() === needle || c.peerAgentId.toLowerCase() === needle, + ) ?? + connections.find( + (c) => (c.cardSnapshot?.name ?? '').trim().toLowerCase() === needle, + ) ?? + null + ); +} + +/** + * Which of the peer's advertised skills the model appears to have been going + * for. Shown to the person as "what the card promised", so a rough overlap is + * the right level of effort: the alternative is showing every skill, which + * says nothing, or none, which says less. + */ +function matchSkills(peer: IAgentPeerData, context: string): IMatchedSkill[] { + const skills = peer.cardSnapshot?.skills ?? []; + if (skills.length === 0) return []; + + const words = new Set( + context + .toLowerCase() + .split(/[^a-zа-яё0-9]+/i) + .filter((w) => w.length >= MEANINGFUL_WORD_CHARS), + ); + + const scored = skills + .map((skill) => { + const haystack = `${skill.name} ${skill.description}`.toLowerCase(); + const hits = [...words].filter((w) => haystack.includes(w)).length; + return { skill, hits }; + }) + .filter((s) => s.hits > 0) + .sort((a, b) => b.hits - a.hits) + .slice(0, MAX_MATCHED_SKILLS); + + // Nothing overlapped: name the first skill rather than nothing, so the step + // still says what this peer is for. + const chosen = scored.length ? scored.map((s) => s.skill) : [skills[0]]; + return chosen.map((s) => ({ id: s.id, name: s.name })); +} + +function readTask(task: IA2aTask): { + status: DelegationStatus; + errorCode?: DelegationErrorCode; + text?: string; +} { + switch (task.status.state) { + case A2aTaskStates.Completed: + return { + status: DelegationStatuses.Answered, + text: textOfParts(task.artifacts?.[0]?.parts), + }; + case A2aTaskStates.Rejected: + return { + status: DelegationStatuses.Rejected, + errorCode: + task.metadata?.ranch?.rejection === 'depth' + ? DelegationErrorCodes.RejectedDepth + : DelegationErrorCodes.RejectedLoop, + }; + case A2aTaskStates.Failed: + return { + status: DelegationStatuses.Failed, + errorCode: + task.metadata?.ranch?.failure === 'timeout' + ? DelegationErrorCodes.Timeout + : task.metadata?.ranch?.failure === 'not_running' + ? DelegationErrorCodes.NotRunning + : DelegationErrorCodes.Error, + }; + default: + // Any other state means the peer is still working on something this + // client has no way to poll — it cannot be reported as an answer. + return { + status: DelegationStatuses.Failed, + errorCode: DelegationErrorCodes.Error, + }; + } +} + +/** The peer's own words about what went wrong, when it offered any. */ +function statusText(task: IA2aTask): string | null { + const parts = task.status.message?.parts; + const text = textOfParts(parts); + return text || null; +} diff --git a/api/src/slices/agent/peer/domain/delegationStep.spec.ts b/api/src/slices/agent/peer/domain/delegationStep.spec.ts new file mode 100644 index 00000000..f92487ad --- /dev/null +++ b/api/src/slices/agent/peer/domain/delegationStep.spec.ts @@ -0,0 +1,173 @@ +import { buildDelegationStep, causeText, delegationStepId } from './delegationStep'; +import { + DelegationErrorCodes, + DelegationStatuses, + type IAgentDelegationData, +} from './peer.types'; + +/** + * The visible half of a delegation. Two things must hold at once: the admin + * chat gets structured fields to draw, and every other surface — which renders + * a plain label and some markdown — still gets the whole story in words. The + * cases below check both readings of the same object. + */ +const row = ( + overrides: Partial = {}, +): IAgentDelegationData => ({ + id: 'del-1', + agentId: 'a', + peerId: 'peer-1', + peerAgentId: 'b', + peerName: 'Support Bot', + contextId: 'ctx-1', + turnId: 'turn-1', + clientId: 'admin', + task: 'What is the return window for shoes?', + reason: 'Support Bot holds the returns policy base', + matchedSkills: [{ id: 'knowledge:9a', name: 'Returns policy' }], + status: DelegationStatuses.Waiting, + errorCode: null, + excerpt: null, + startedAt: '2026-09-14T10:00:00.000Z', + finishedAt: null, + durationMs: null, + ...overrides, +}); + +describe('buildDelegationStep', () => { + it('keeps one id across both pushes, so the step updates in place', () => { + const started = buildDelegationStep(row()); + const finished = buildDelegationStep( + row({ status: DelegationStatuses.Answered, durationMs: 3120 }), + ); + + expect(started.id).toBe(delegationStepId('del-1')); + expect(finished.id).toBe(started.id); + }); + + it('shimmers while waiting and settles when the answer lands', () => { + expect(buildDelegationStep(row()).state).toBe('active'); + expect( + buildDelegationStep(row({ status: DelegationStatuses.Answered })).state, + ).toBe('done'); + }); + + it('names the peer in the label, with what happened to it', () => { + expect(buildDelegationStep(row()).label).toBe('Asking «Support Bot»'); + expect( + buildDelegationStep(row({ status: DelegationStatuses.Answered })).label, + ).toBe('Answered by «Support Bot»'); + expect( + buildDelegationStep(row({ status: DelegationStatuses.Failed })).label, + ).toBe('Could not reach «Support Bot»'); + expect( + buildDelegationStep(row({ status: DelegationStatuses.Rejected })).label, + ).toBe('«Support Bot» refused the task'); + }); + + it('carries peer, reason, task and matched skills for the admin layout', () => { + const step = buildDelegationStep(row()); + + expect(step.kind).toBe('delegation'); + expect(step.delegation).toMatchObject({ + delegationId: 'del-1', + peerAgentId: 'b', + peerName: 'Support Bot', + reason: 'Support Bot holds the returns policy base', + task: 'What is the return window for shoes?', + matchedSkills: [{ id: 'knowledge:9a', name: 'Returns policy' }], + status: 'waiting', + startedAt: Date.parse('2026-09-14T10:00:00.000Z'), + }); + }); + + it('tells the same story in the detail, for surfaces that read only words', () => { + const detail = buildDelegationStep(row()).detail ?? ''; + + expect(detail).toContain('Support Bot'); + expect(detail).toContain('Returns policy'); + expect(detail).toContain('Support Bot holds the returns policy base'); + expect(detail).toContain('What is the return window for shoes?'); + expect(detail).toContain('waiting'); + }); + + it('reports how long an answer took, and quotes it', () => { + const step = buildDelegationStep( + row({ + status: DelegationStatuses.Answered, + excerpt: 'Shoes can be returned within 30 days.', + durationMs: 3120, + }), + ); + + expect(step.detail).toContain('3.1 s'); + expect(step.detail).toContain('Shoes can be returned within 30 days.'); + expect(step.delegation).toMatchObject({ + durationMs: 3120, + excerpt: 'Shoes can be returned within 30 days.', + }); + }); + + it('explains a failure in product wording, never in error text', () => { + const step = buildDelegationStep( + row({ + status: DelegationStatuses.Failed, + errorCode: DelegationErrorCodes.NotRunning, + excerpt: 'peer not running', + durationMs: 180, + }), + ); + + expect(step.detail).toContain('it is not running'); + expect(step.delegation?.excerpt).toBe('it is not running'); + }); + + it('says what a refusal means rather than quoting a rule name', () => { + const loop = buildDelegationStep( + row({ + status: DelegationStatuses.Rejected, + errorCode: DelegationErrorCodes.RejectedLoop, + durationMs: 40, + }), + ); + const depth = buildDelegationStep( + row({ + status: DelegationStatuses.Rejected, + errorCode: DelegationErrorCodes.RejectedDepth, + durationMs: 40, + }), + ); + + expect(loop.detail).toContain('would loop back to an agent already involved'); + expect(depth.detail).toContain('as long as it may get'); + }); + + it('shows sub-second waits in milliseconds', () => { + const step = buildDelegationStep( + row({ status: DelegationStatuses.Answered, durationMs: 180 }), + ); + + expect(step.detail).toContain('180 ms'); + }); + + it('still names the peer when it advertised no matching skill', () => { + const step = buildDelegationStep(row({ matchedSkills: [] })); + + expect(step.detail).toContain('**Peer:** Support Bot'); + expect(step.delegation?.matchedSkills).toEqual([]); + }); +}); + +describe('causeText', () => { + it('has words for every failure this feature can produce', () => { + for (const code of Object.values(DelegationErrorCodes)) { + const text = causeText(code); + expect(text).toBeTruthy(); + expect(text).not.toMatch(/PEER_/); + } + }); + + it('falls back to something sayable when there is no code', () => { + expect(causeText(null)).toBe('it did not answer'); + }); +}); diff --git a/api/src/slices/agent/peer/domain/delegationStep.ts b/api/src/slices/agent/peer/domain/delegationStep.ts new file mode 100644 index 00000000..6f9af865 --- /dev/null +++ b/api/src/slices/agent/peer/domain/delegationStep.ts @@ -0,0 +1,116 @@ +import type { IBridleThinkingStep } from '#/bridle/domain/bridle.types'; +import { + DELEGATION_STEP_PREFIX, + DelegationErrorCodes, + DelegationStatuses, + type DelegationErrorCode, + type IAgentDelegationData, +} from './peer.types'; + +/** + * The delegation as the person watching the chat sees it (CLEAN-74, FR-013). + * + * Two audiences, one object. The admin chat reads `kind` and `delegation` and + * draws the peer, the matched skills, the reason and a running clock. Every + * other surface — the user console, the embed, anything that renders a plain + * thinking step — reads `label` and `detail`, so the same story has to survive + * in words alone. That is why `detail` repeats what the structured fields say + * instead of pointing at them. + */ + +/** Product wording for every way a delegation can fail. Never raw error text: + * this string is read by someone who did not ask for a peer to exist. */ +const CAUSES: Record = { + [DelegationErrorCodes.NotRunning]: 'it is not running', + [DelegationErrorCodes.Timeout]: 'it did not answer in time', + [DelegationErrorCodes.RejectedLoop]: + 'it refused: the request would loop back to an agent already involved', + [DelegationErrorCodes.RejectedDepth]: + 'it refused: the chain of agents is already as long as it may get', + [DelegationErrorCodes.Unauthorized]: + 'it refused the credential — the connection may have been removed', + [DelegationErrorCodes.Unreachable]: 'it could not be reached', + [DelegationErrorCodes.Error]: 'it answered with an error', +}; + +export function causeText(code: DelegationErrorCode | null): string { + return code ? CAUSES[code] : 'it did not answer'; +} + +export function delegationStepId(delegationId: string): string { + return `${DELEGATION_STEP_PREFIX}${delegationId}`; +} + +function seconds(ms: number | null): string { + if (ms === null) return ''; + return ms < 1000 ? `${ms} ms` : `${(ms / 1000).toFixed(1)} s`; +} + +function labelFor(row: IAgentDelegationData): string { + switch (row.status) { + case DelegationStatuses.Answered: + return `Answered by «${row.peerName}»`; + case DelegationStatuses.Rejected: + return `«${row.peerName}» refused the task`; + case DelegationStatuses.Failed: + return `Could not reach «${row.peerName}»`; + default: + return `Asking «${row.peerName}»`; + } +} + +/** Markdown, because that is what a thinking step's detail is rendered as. */ +function detailFor(row: IAgentDelegationData): string { + const skills = row.matchedSkills.map((s) => s.name).join(', '); + const lines = [ + `**Peer:** ${row.peerName}${skills ? ` — ${skills}` : ''}`, + `**Why:** ${row.reason}`, + `**Task:** ${row.task}`, + ]; + + if (row.status === DelegationStatuses.Waiting) { + lines.push('**Status:** waiting for an answer…'); + return lines.join('\n\n'); + } + + const took = seconds(row.durationMs); + if (row.status === DelegationStatuses.Answered) { + lines.push(`**Answered** in ${took}`); + if (row.excerpt) lines.push(row.excerpt); + return lines.join('\n\n'); + } + + lines.push(`**Failed** after ${took}: ${causeText(row.errorCode)}`); + return lines.join('\n\n'); +} + +export function buildDelegationStep( + row: IAgentDelegationData, +): IBridleThinkingStep { + const waiting = row.status === DelegationStatuses.Waiting; + return { + id: delegationStepId(row.id), + label: labelFor(row), + detail: detailFor(row), + // The same id twice: the second push replaces the first in place, which is + // what turns "Asking…" into "Answered by…" rather than stacking two steps. + state: waiting ? 'active' : 'done', + kind: 'delegation', + delegation: { + delegationId: row.id, + peerAgentId: row.peerAgentId, + peerName: row.peerName, + matchedSkills: row.matchedSkills, + reason: row.reason, + task: row.task, + status: row.status, + startedAt: new Date(row.startedAt).getTime(), + ...(row.durationMs !== null ? { durationMs: row.durationMs } : {}), + ...(row.status === DelegationStatuses.Answered + ? row.excerpt + ? { excerpt: row.excerpt } + : {} + : { excerpt: causeText(row.errorCode) }), + }, + }; +} diff --git a/api/src/slices/agent/peer/domain/index.ts b/api/src/slices/agent/peer/domain/index.ts index 299c02d2..1b9101fe 100644 --- a/api/src/slices/agent/peer/domain/index.ts +++ b/api/src/slices/agent/peer/domain/index.ts @@ -7,3 +7,5 @@ export * from './a2aTask.store'; export * from './a2a.server.service'; export * from './a2a.client'; export * from './peer.service'; +export * from './delegationStep'; +export * from './delegation.service'; diff --git a/api/src/slices/agent/peer/peer.module.ts b/api/src/slices/agent/peer/peer.module.ts index 35b48d7b..8913b88c 100644 --- a/api/src/slices/agent/peer/peer.module.ts +++ b/api/src/slices/agent/peer/peer.module.ts @@ -18,6 +18,8 @@ import { DelegationMapper } from './data/delegation.mapper'; import { A2aCardGuard, A2aPeerGuard } from './guards/a2a.guards'; import { A2aClient } from './domain/a2a.client'; import { PeerService } from './domain/peer.service'; +import { DelegationService } from './domain/delegation.service'; +import { AskAgentTool } from './askAgent.tool'; import { A2aController } from './a2a.controller'; import { PeerController } from './peer.controller'; @@ -48,6 +50,10 @@ import { PeerController } from './peer.controller'; AgentCardService, A2aClient, PeerService, + DelegationService, + // MCP tool: ask_agent. Discovered by the #mcp registry like every + // @Tool provider, and listed only for agents that have peers. + AskAgentTool, A2aTaskStore, A2aServerService, A2aCardGuard, diff --git a/specs/013-a2a-agent-peers/tasks.md b/specs/013-a2a-agent-peers/tasks.md index a31e4d68..73244c02 100644 --- a/specs/013-a2a-agent-peers/tasks.md +++ b/specs/013-a2a-agent-peers/tasks.md @@ -128,11 +128,11 @@ - [X] T047 [US3] Implement the message half of `A2aClient` in `api/src/slices/agent/peer/domain/a2a.client.ts`: `sendMessage(interfaceUrl, token, params: SendMessageParams, timeoutMs): Promise` — POST JSON-RPC `{ jsonrpc: '2.0', id: , method: 'SendMessage', params }` with `Authorization: Bearer`, `A2A-Version: 1.0`, `Content-Type: application/json`, `AbortSignal.timeout(timeoutMs + 5_000)`; HTTP 401/403 → `DelegationError(Unauthorized)`; other non-2xx or network/abort → `DelegationError(Unreachable, detail)`; JSON-RPC `error` → `DelegationError(Error, error.message)`; result without `task` → `DelegationError(Error, 'peer returned a message, not a task')`; define `class DelegationError extends Error { constructor(public code: DelegationErrorCode, message) }` in `peer.types.ts` (depends on T031) - [X] T048 [US3] Extend `api/src/slices/agent/peer/domain/a2a.client.spec.ts` with a mocked `global.fetch` for `sendMessage`: sends the three headers and the envelope; 401 → `PEER_UNAUTHORIZED`; 500 → `PEER_UNREACHABLE`; JSON-RPC error → `PEER_ERROR` with the message; `{ result: { task } }` → the task (depends on T047) -- [ ] T049 [US3] Implement `DelegationService` in `api/src/slices/agent/peer/domain/delegation.service.ts` over `IPeerGateway`, `IDelegationGateway`, `A2aClient`, `IBridleGateway`, `ConfigService`: `run(input: { callerAgentId; peer: string; task: string; reason: string; contextId?: string; inboundChain: string[] }): Promise` — resolve the peer among `listByAgent(callerAgentId)` by id or case-insensitive `cardSnapshot.name` (no match → outcome `{ kind: 'no_match', peers }`); compute `matchedSkills` = snapshot skills whose `name` or `description` shares a word (≥ 4 letters, case-insensitive) with `reason`+`task`, capped at 3, falling back to the first skill; `contextId = input.contextId ?? 'ctx-' + uuid`; **create the delegation row (`waiting`)**; call the step emitter hook `onStart(row, matchedSkills)` (no-op until US4); `sendMessage(snapshot.supportedInterfaces[0].url, row.token, { message: { messageId: uuid, role: 'ROLE_USER', parts: [{ text: task }], contextId, metadata: { ranch: { chain: [...inboundChain, callerAgentId], reason } } }, configuration: { acceptedOutputModes: ['text/plain'], returnImmediately: false } }, timeoutMs)`; map the returned task: `TASK_STATE_COMPLETED` → `answered` with `text = textOfParts(artifacts[0].parts)` and `excerpt = text.slice(0, 300)`; `TASK_STATE_REJECTED` → `rejected` with `errorCode` from `metadata.ranch.rejection` (`loop` → `PEER_REJECTED_LOOP`, `depth` → `PEER_REJECTED_DEPTH`) and `excerpt = status.message text`; `TASK_STATE_FAILED` → `failed` with `errorCode` from `metadata.ranch.failure` (`not_running` → `PEER_NOT_RUNNING`, `timeout` → `PEER_TIMEOUT`, else `PEER_ERROR`); a thrown `DelegationError` → `failed` with its code; **finish the row exactly once**; call `onFinish(row)`; return `{ kind: 'done', status, text?, errorCode?, excerpt, peerName, contextId, durationMs }`; `inboundChain` is resolved by the caller (T051) (depends on T027, T047) -- [ ] T050 [US3] Write `api/src/slices/agent/peer/domain/delegation.service.spec.ts`: peer matched by id and by name (case-insensitive); no match → `no_match` with the peer names; row created `waiting` **before** `sendMessage` is invoked (assert call order with a shared array); completed → `answered`, excerpt ≤ 300 chars, row finished with `durationMs`; rejected loop/depth → codes; failed not_running/timeout → codes; thrown `DelegationError` → `failed` with its code and the row still finished; `chain` sent = `[...inbound, caller]`; `contextId` reused when given (depends on T049) -- [ ] T051 [US3] Implement `AskAgentTool` in `api/src/slices/agent/peer/askAgent.tool.ts` (`@Injectable`, implements `IDynamicallyDescribedTool` and `IConditionallyListedTool`): `extractAgentId(req)` (copy of `knowledge.tool.ts:169-175`); `isListedForRequest` → agent principal with ≥ 1 peer; `describeForRequest` → contracts §4 dynamic text built from `listByAgent` snapshots (name, peer id, description, `Skills: name (description); …`), `null` when no peers; `@Tool({ name: 'ask_agent', description: , parameters: z.object({ peer, task, reason, context_id }) })` method `ask(args, _ctx, req)` — non-agent → `err('ask_agent can only be called by an agent runtime.')`; validate `args` with the zod schema (`safeParse`, first issue message on failure); `inboundChain`: read from the caller's **own** in-flight delegation context — a `Map` on `A2aServerService` set for the duration of `sendAndAwait` (`enterChain(agentId, chain)` / `exitChain`), `[]` when absent; call `DelegationService.run`; render results exactly as contracts §4 (success text with name, `context_id`, seconds; `isError` texts for failed/rejected/no_match, each ending with the "do not guess on its behalf" instruction); register the tool in `peer.module.ts` providers (depends on T008, T022, T049) -- [ ] T052 [US3] Write `api/src/slices/agent/peer/askAgent.tool.spec.ts` (harness pattern `knowledge.tool.spec.ts:31-92`, request `{ user: { sub: 'agent:a' } }`): not listed / listed by peer count; `describeForRequest` lists two peers with skills and returns `null` for none; a user principal → `isError`; missing `reason` → `isError` with the zod message; `answered` → text contains reply, name, `context_id`; `failed` `PEER_NOT_RUNNING` → `isError` text contains "peer not running" and "do not guess"; `rejected` → "refused the task"; `no_match` → lists the peers; `inboundChain` forwarded when the caller is mid-delegation (depends on T051) -- [ ] T053 [US3] Add `enterChain/exitChain` bookkeeping to `A2aServerService.sendMessage` around `sendAndAwait` (try/finally) and cover it in `a2a.server.service.spec.ts`; then run `cd api && bun run test -- peer agentCard a2a askAgent` and the spec Story 3 independent test locally with two agents (restart A after connecting B — research R7) (depends on T022, T051) +- [X] T049 [US3] Implement `DelegationService` in `api/src/slices/agent/peer/domain/delegation.service.ts` over `IPeerGateway`, `IDelegationGateway`, `A2aClient`, `IBridleGateway`, `ConfigService`: `run(input: { callerAgentId; peer: string; task: string; reason: string; contextId?: string; inboundChain: string[] }): Promise` — resolve the peer among `listByAgent(callerAgentId)` by id or case-insensitive `cardSnapshot.name` (no match → outcome `{ kind: 'no_match', peers }`); compute `matchedSkills` = snapshot skills whose `name` or `description` shares a word (≥ 4 letters, case-insensitive) with `reason`+`task`, capped at 3, falling back to the first skill; `contextId = input.contextId ?? 'ctx-' + uuid`; **create the delegation row (`waiting`)**; call the step emitter hook `onStart(row, matchedSkills)` (no-op until US4); `sendMessage(snapshot.supportedInterfaces[0].url, row.token, { message: { messageId: uuid, role: 'ROLE_USER', parts: [{ text: task }], contextId, metadata: { ranch: { chain: [...inboundChain, callerAgentId], reason } } }, configuration: { acceptedOutputModes: ['text/plain'], returnImmediately: false } }, timeoutMs)`; map the returned task: `TASK_STATE_COMPLETED` → `answered` with `text = textOfParts(artifacts[0].parts)` and `excerpt = text.slice(0, 300)`; `TASK_STATE_REJECTED` → `rejected` with `errorCode` from `metadata.ranch.rejection` (`loop` → `PEER_REJECTED_LOOP`, `depth` → `PEER_REJECTED_DEPTH`) and `excerpt = status.message text`; `TASK_STATE_FAILED` → `failed` with `errorCode` from `metadata.ranch.failure` (`not_running` → `PEER_NOT_RUNNING`, `timeout` → `PEER_TIMEOUT`, else `PEER_ERROR`); a thrown `DelegationError` → `failed` with its code; **finish the row exactly once**; call `onFinish(row)`; return `{ kind: 'done', status, text?, errorCode?, excerpt, peerName, contextId, durationMs }`; `inboundChain` is resolved by the caller (T051) (depends on T027, T047) +- [X] T050 [US3] Write `api/src/slices/agent/peer/domain/delegation.service.spec.ts`: peer matched by id and by name (case-insensitive); no match → `no_match` with the peer names; row created `waiting` **before** `sendMessage` is invoked (assert call order with a shared array); completed → `answered`, excerpt ≤ 300 chars, row finished with `durationMs`; rejected loop/depth → codes; failed not_running/timeout → codes; thrown `DelegationError` → `failed` with its code and the row still finished; `chain` sent = `[...inbound, caller]`; `contextId` reused when given (depends on T049) +- [X] T051 [US3] Implement `AskAgentTool` in `api/src/slices/agent/peer/askAgent.tool.ts` (`@Injectable`, implements `IDynamicallyDescribedTool` and `IConditionallyListedTool`): `extractAgentId(req)` (copy of `knowledge.tool.ts:169-175`); `isListedForRequest` → agent principal with ≥ 1 peer; `describeForRequest` → contracts §4 dynamic text built from `listByAgent` snapshots (name, peer id, description, `Skills: name (description); …`), `null` when no peers; `@Tool({ name: 'ask_agent', description: , parameters: z.object({ peer, task, reason, context_id }) })` method `ask(args, _ctx, req)` — non-agent → `err('ask_agent can only be called by an agent runtime.')`; validate `args` with the zod schema (`safeParse`, first issue message on failure); `inboundChain`: read from the caller's **own** in-flight delegation context — a `Map` on `A2aServerService` set for the duration of `sendAndAwait` (`enterChain(agentId, chain)` / `exitChain`), `[]` when absent; call `DelegationService.run`; render results exactly as contracts §4 (success text with name, `context_id`, seconds; `isError` texts for failed/rejected/no_match, each ending with the "do not guess on its behalf" instruction); register the tool in `peer.module.ts` providers (depends on T008, T022, T049) +- [X] T052 [US3] Write `api/src/slices/agent/peer/askAgent.tool.spec.ts` (harness pattern `knowledge.tool.spec.ts:31-92`, request `{ user: { sub: 'agent:a' } }`): not listed / listed by peer count; `describeForRequest` lists two peers with skills and returns `null` for none; a user principal → `isError`; missing `reason` → `isError` with the zod message; `answered` → text contains reply, name, `context_id`; `failed` `PEER_NOT_RUNNING` → `isError` text contains "peer not running" and "do not guess"; `rejected` → "refused the task"; `no_match` → lists the peers; `inboundChain` forwarded when the caller is mid-delegation (depends on T051) +- [X] T053 [US3] Add `enterChain/exitChain` bookkeeping to `A2aServerService.sendMessage` around `sendAndAwait` (try/finally) and cover it in `a2a.server.service.spec.ts`; then run `cd api && bun run test -- peer agentCard a2a askAgent` and the spec Story 3 independent test locally with two agents (restart A after connecting B — research R7) (depends on T022, T051) **Checkpoint**: A answers a topic-X question with B's fact and names B; with B stopped, A says it could not reach B. No visible step yet — that is US4. @@ -144,10 +144,10 @@ **Independent Test**: spec Story 4 — trigger a delegation while watching A's admin chat: "Asking «B»" with skill badges, reason, task and a running timer appears before the answer, then turns into "Answered by «B»" with duration and excerpt; a failed delegation ends as "Could not reach «B»" with the cause; the collapsed block re-expands with the step intact. -- [ ] T054 [P] [US4] Implement `api/src/slices/agent/peer/domain/delegationStep.ts`: `buildDelegationStep(row: IAgentDelegationData, matchedSkills, phase: 'start' | 'finish'): IBridleThinkingStep` — `id: 'delegation:' + row.id`; `label` `Asking «name»` / `Answered by «name»` / `Could not reach «name»` / `«name» refused the task`; `state` `active`/`done`; `kind: 'delegation'`; `delegation` per data-model §5 (`startedAt` epoch ms, `durationMs`, `excerpt`); `detail` markdown per contracts §5 (`**Peer:** … — skills`, `**Why:**`, `**Task:**`, `**Status:** waiting…` or `answered in 3.1s` + excerpt or `failed: cause`); a `causeText(errorCode)` map to product wording (`peer not running`, `timed out after Ns`, `would loop`, `too deep`, `not authorised`, `could not be reached`, `error`) -- [ ] T055 [P] [US4] Write `api/src/slices/agent/peer/domain/delegationStep.spec.ts`: ids stable across phases; labels per status; `state` active on start and done on finish; `detail` contains peer, why, task and, on finish, the excerpt or cause; `kind`/`delegation` present (depends on T054) -- [ ] T056 [US4] Wire the emitter into `DelegationService` in `api/src/slices/agent/peer/domain/delegation.service.ts`: on `run`, `const turn = hub.findActiveTurn(callerAgentId)`; store `turnId`/`clientId` on the row when known; `onStart` → `hub.sendToClient(turn.clientId, callerAgentId, { type: 'thinking', clientId: turn.clientId, turnId: turn.turnId, ts: Date.now(), step: buildDelegationStep(row, skills, 'start') })`; `onFinish` → same with `'finish'`; when `turn` is null push nothing and log at debug `no active turn for , delegation step not shown`; never send `done: true` (depends on T049, T054) -- [ ] T057 [US4] Extend `api/src/slices/agent/peer/domain/delegation.service.spec.ts`: with an active turn two `sendToClient` calls with the same `step.id`, first `active`/`waiting`, second `done`/final status, `turnId` and `clientId` from `findActiveTurn` and persisted on the row; with no active turn zero calls and the delegation still completes; no call carries `done: true` (depends on T056) +- [X] T054 [P] [US4] Implement `api/src/slices/agent/peer/domain/delegationStep.ts`: `buildDelegationStep(row: IAgentDelegationData, matchedSkills, phase: 'start' | 'finish'): IBridleThinkingStep` — `id: 'delegation:' + row.id`; `label` `Asking «name»` / `Answered by «name»` / `Could not reach «name»` / `«name» refused the task`; `state` `active`/`done`; `kind: 'delegation'`; `delegation` per data-model §5 (`startedAt` epoch ms, `durationMs`, `excerpt`); `detail` markdown per contracts §5 (`**Peer:** … — skills`, `**Why:**`, `**Task:**`, `**Status:** waiting…` or `answered in 3.1s` + excerpt or `failed: cause`); a `causeText(errorCode)` map to product wording (`peer not running`, `timed out after Ns`, `would loop`, `too deep`, `not authorised`, `could not be reached`, `error`) +- [X] T055 [P] [US4] Write `api/src/slices/agent/peer/domain/delegationStep.spec.ts`: ids stable across phases; labels per status; `state` active on start and done on finish; `detail` contains peer, why, task and, on finish, the excerpt or cause; `kind`/`delegation` present (depends on T054) +- [X] T056 [US4] Wire the emitter into `DelegationService` in `api/src/slices/agent/peer/domain/delegation.service.ts`: on `run`, `const turn = hub.findActiveTurn(callerAgentId)`; store `turnId`/`clientId` on the row when known; `onStart` → `hub.sendToClient(turn.clientId, callerAgentId, { type: 'thinking', clientId: turn.clientId, turnId: turn.turnId, ts: Date.now(), step: buildDelegationStep(row, skills, 'start') })`; `onFinish` → same with `'finish'`; when `turn` is null push nothing and log at debug `no active turn for , delegation step not shown`; never send `done: true` (depends on T049, T054) +- [X] T057 [US4] Extend `api/src/slices/agent/peer/domain/delegation.service.spec.ts`: with an active turn two `sendToClient` calls with the same `step.id`, first `active`/`waiting`, second `done`/final status, `turnId` and `clientId` from `findActiveTurn` and persisted on the row; with no active turn zero calls and the delegation still completes; no call carries `done: true` (depends on T056) - [ ] T058 [P] [US4] Mirror the type in `admin/slices/bridle/stores/bridle.ts:64-69`: add `kind?: 'delegation'` and `delegation?: IBridleDelegationStep` (same fields as T010) to `IBridleThinkingStep`; no store logic change (steps already replace by id at `:580-585`) - [ ] T059 [US4] Create `admin/slices/bridle/components/bridle/DelegationStep.vue`: props `{ step: IBridleThinkingStep }` (with `step.delegation` required by a guard in the parent); layout — a peer icon (`IconUsers` from `@tabler/icons-vue`) + `label`; a row of `Badge variant="outline"` for `matchedSkills`; "Why: " in `text-muted-foreground`; the task in a `border-l-2 pl-3 italic` quote; a status pill (`waiting` amber + shimmer, `answered` green, `failed`/`rejected` red) with elapsed time — while `waiting`, a `useIntervalFn(…, 250)` (`@vueuse/core`) ticks `Date.now() - startedAt` formatted `s.s s`; after, `durationMs`; the `excerpt` (answered) or cause (failed/rejected) as a final line; respects `prefers-reduced-motion` like the existing shimmer (depends on T058) - [ ] T060 [US4] In `admin/slices/bridle/components/bridle/Provider.vue:574-604` render `` in place of the label/detail row for that step, keeping the existing row for every other step; ensure the block-level "is thinking…" shimmer and collapse behaviour are untouched; `cd admin && bun run typecheck` (depends on T059) @@ -164,7 +164,7 @@ **Independent Test**: spec Story 5 — A→B and B→A connected; B's attempt to call A back is refused with "would loop" and B answers alone; a chain beyond 3 hops is refused with "too deep"; A's Peers tab lists the delegations with peer, time, duration and outcome. - [X] T062 [US5] Implement `checkChain(chain: string[], agentId: string): { rejection: 'loop' | 'depth'; message: string } | null` in `api/src/slices/agent/peer/domain/a2a.server.service.ts` (replacing the US1 no-op): `chain.includes(agentId)` → `loop`, `would loop: is already in the chain`; `chain.length >= A2A_MAX_CHAIN` (env, default 3) → `depth`, `too deep: chain limit is hops`; on rejection build a task `TASK_STATE_REJECTED` with `status.message = { messageId, role: 'ROLE_AGENT', parts: [{ text }] }`, `metadata.ranch.rejection`, no artifacts, **before** the `isAgentConnected` check and without touching the hub (depends on T022) -- [ ] T063 [US5] Extend `api/src/slices/agent/peer/domain/a2a.server.service.spec.ts`: chain containing the agent → rejected `loop`, `sendAndAwait` not called; chain of 3 → rejected `depth`; chain of 2 → proceeds; `A2A_MAX_CHAIN=1` respected; the rejection message names the agent (depends on T062) +- [X] T063 [US5] Extend `api/src/slices/agent/peer/domain/a2a.server.service.spec.ts`: chain containing the agent → rejected `loop`, `sendAndAwait` not called; chain of 3 → rejected `depth`; chain of 2 → proceeds; `A2A_MAX_CHAIN=1` respected; the rejection message names the agent (depends on T062) - [X] T064 [P] [US5] Create `admin/slices/agent/peer/components/peer/Delegations.vue`: props `{ agentId: string }`; loads `store.loadDelegations(agentId)` on mount and exposes a **Refresh** button; `Table` with columns Peer (link), Task (truncated with `title`), Outcome (status pill + `errorCode` in product wording via a small map), Started (`TimeAgo`), Duration (`s.s s`); dashed empty state "No delegations yet."; mount it as section (3) of `components/peer/Tab.vue` unconditionally (depends on T040, T044) - [ ] T065 [US5] `cd admin && bun run typecheck`; run quickstart §6 (loop, live) and check A's and B's Recent delegations show the `answered` and `rejected` rows (depends on T062, T064) From 5c2461cbb0a895d275206667231d268b6364e119 Mon Sep 17 00:00:00 2001 From: "Maksym Hryzodub [DREAM]" Date: Mon, 14 Sep 2026 13:11:56 +0300 Subject: [PATCH 11/31] feat(admin): draw the delegation inside the thinking timeline (CLEAN-74) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01SZ9dCrzZqd5SwR4erLYe12 --- .../components/bridle/DelegationStep.vue | 113 ++++++++++++++++++ .../bridle/components/bridle/Provider.vue | 15 ++- admin/slices/bridle/stores/bridle.ts | 21 ++++ specs/013-a2a-agent-peers/tasks.md | 8 +- 4 files changed, 151 insertions(+), 6 deletions(-) create mode 100644 admin/slices/bridle/components/bridle/DelegationStep.vue diff --git a/admin/slices/bridle/components/bridle/DelegationStep.vue b/admin/slices/bridle/components/bridle/DelegationStep.vue new file mode 100644 index 00000000..0ba4fa01 --- /dev/null +++ b/admin/slices/bridle/components/bridle/DelegationStep.vue @@ -0,0 +1,113 @@ + + + diff --git a/admin/slices/bridle/components/bridle/Provider.vue b/admin/slices/bridle/components/bridle/Provider.vue index f5f3055e..fa5eb930 100644 --- a/admin/slices/bridle/components/bridle/Provider.vue +++ b/admin/slices/bridle/components/bridle/Provider.vue @@ -589,8 +589,16 @@ async function onConfirmReset() { :key="s.id" class="flex flex-col items-start" > + +