# dsh-a2a

[English](README.md) | [简体中文](README.zh.md)

> A2A v1.0 for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) — expose the harness as an A2A agent, and let harness agents delegate to remote A2A agents.

[![npm version](https://img.shields.io/npm/v/dsh-a2a)](https://www.npmjs.com/package/dsh-a2a)
[![license](https://img.shields.io/npm/l/dsh-a2a)](LICENSE)
[![node](https://img.shields.io/node/v/dsh-a2a)](https://nodejs.org)

One plugin, two halves, built on the official [`@a2a-js/sdk`](https://github.com/a2aproject/a2a-js) (A2A Protocol v1.0):

- **Server** — a self-contained HTTP endpoint exposing the harness as an A2A agent: Agent Card discovery, JSON-RPC (the mandatory transport, SSE streaming included), and the HTTP+JSON REST surface. Every A2A `contextId` maps to a **persistent harness session**, so follow-up messages continue the same conversation.
- **Client** — the `a2a_call` / `a2a_list` model tools, so harness agents can delegate work to remote A2A agents from a config-driven registry with header auth.

## ✨ Features

- 🤖 **Multi-agent server** — one endpoint serves several agents, each at its own `/agents/<id>` with its own preset, model route, workspace group, Agent Card identity, and `skills`; one `contextId` maps to one persistent harness session per agent (deterministic `sha256(contextId)` ids), preset-mounted, persistent across turns.
- 📇 **Per-agent Agent Card skills** — each agent advertises its abilities as A2A `AgentSkill[]` (`id` / `name` / `description`) on its own card, so an A2A client can route by what an agent is for.
- 🎛️ **Live served-agent management** — the settings tab's inbound editor adds / removes / re-identifies a served agent; a save reconciles the running server without a restart.
- 🔑 **Token view + rotation** — the settings tab shows the Bearer key (masked by default, reveal + copy) and rotates it live, persisting the new key to the settings document.
- 🔌 **Full A2A v1.0 wire** — `/.well-known/agent-card.json`, JSON-RPC `SendMessage` / `SendStreamingMessage` / `GetTask` / `CancelTask` / `ListTasks` (SSE for streaming), REST `message:send` / `tasks/*` — all through the official SDK's request handler.
- 🔒 **Header auth on the client side** — per-agent headers with `${ENV_VAR}` placeholders resolved at call time; credentials never sit in the config.
- 🔑 **Bearer auth on the server side** — with `server.apiKey` set, every request except the Agent Card must present `Authorization: Bearer <key>`; the card can also advertise a `publicUrl` behind a reverse proxy.
- 🎛️ **Per-request preset and model** — a caller can name the preset and the model route on the A2A `metadata` map instead of using the deployment's route (see [Per-request overrides](#-per-request-overrides)).
- 🧭 **Model route and workspace** — A2A conversations can pin a provider/model pair and live in their own workspace group (`A2A`, `~/.a2a-sessions` by default).
- ⏱️ **Turn deadlines** — a slow turn is cancelled (`turnTimeoutMs`, default 5 min) so the next message is never stuck.
- 🛡️ **Fail-loud config** — bad URLs, empty names, or out-of-range ports throw at plugin load.
- 🧪 **Real-wire tests** — the server half is exercised end to end with the official A2A client over a live HTTP port.

## 🚀 Quick Start

```sh
# This revision targets exactly dsh 0.1.7-rc.2.
npm install -g @deepseek-ai/dsh@0.1.7-rc.2
dsh plugin --profile web add dsh-a2a@0.7.2

export A2A_HOST=127.0.0.1
export A2A_PORT=8899

dsh web   # restart, then GET http://127.0.0.1:8899/.well-known/agent-card.json
```

Call the agent (JSON-RPC, blocking):

```sh
curl -s http://127.0.0.1:8899/ -H 'Content-Type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"SendMessage","params":{"message":{"role":"user","messageId":"m1","parts":[{"kind":"text","text":"hi"}],"contextId":"demo"}}}'
```

`A2A_ENABLED=0` runs the client tools only (no listener).

## Compatibility and installation

Version 0.7.2 targets **dsh 0.1.7-rc.2**. Its Harness peers and
build dependencies are pinned to that release; Cordis is pinned to `4.0.4`,
Cosmokit to `1.8.5`, and Schemastery to `3.18.4`. Other Harness releases are
not covered by this compatibility claim. Node.js must be at least `22.19`.
The installation smoke test runs on Linux with Node.js 26 and pnpm 11.

The npm package includes prebuilt bundles; installing it does not require a
Git dependency build approval. Upgrade with the same pinned install command
shown above, then restart the target profile. See the [changelog](CHANGELOG.md)
for release details.

To install from Git instead, pin a reviewed full commit SHA:
`dsh plugin --profile web add github:TtTRz/dsh-a2a#<commit>`.

With pnpm 11, the first Git install may stop with
`ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED`. Copy the **exact `allowBuilds` entry
printed by pnpm** into that profile's `pnpm-workspace.yaml`, preserving its
existing settings, then run the same install command again. The key includes
the Git source and resolved commit; allowing just `dsh-a2a` is insufficient.
Review the commit before allowing its `prepare` build script. Do not enable
all dependency build scripts globally.

**Do not install missing Harness peers individually into the profile.** dsh
provides them through its runtime module resolver. Its generated
`pnpm-workspace.yaml` deliberately sets `autoInstallPeers: false` and
`nodeLinker: hoisted`. Keep those settings. `pnpm peers check` and a standalone
`node import(...)` do not use the dsh resolver, so neither is a plugin-load
test. Unversioned `pnpm add @deepseek-ai/dsh-*` can select stale `latest` tags
and shadow the host runtime with incompatible packages (see [#6](https://github.com/TtTRz/dsh-a2a/issues/6)).

If an existing profile already contains manually installed Harness packages,
first verify the plugin in a fresh profile without changing the original:

```sh
dsh --profile a2a-test --from-default-profile web --dump-config > /dev/null
dsh plugin --profile a2a-test add dsh-a2a@0.7.2
A2A_PORT=8900 dsh --profile a2a-test --port 3100 --no-open
```

Use free ports if these are occupied. A successful install should serve
`http://127.0.0.1:8900/.well-known/agent-card.json`. Existing profile dependencies
should be backed up and reviewed before removing any manually added packages.

## ⚙️ Configuration

The mounted row lives in `~/.dsh/profiles/web/cordis.patch.yml`:

```yaml
- id: a2a
  name: dsh-a2a
  config:
    server:
      enabled: true            # serve the A2A endpoint at all
      host: 127.0.0.1          # A2A_HOST
      port: 8899               # A2A_PORT
      turnTimeoutMs: 300000    # per-turn deadline
      allowOverrides: true     # let callers pick preset/model per request
      agents:                  # LOCAL agents served at /agents/<id>
        - id: support
          name: Support Agent
          description: Answers internal support questions.
          version: '0.1.0'
          preset: standard
          workspaceTitle: A2A
          skills:
            - id: query-orders
              name: Query orders
              description: Look up an order's status and timeline.
    agents: []                 # remote agents reachable from a2a_call
```

Each local agent lives at `/agents/<id>/.well-known/agent-card.json` and gets a
session namespace (`a2a-<id>-<hash>`), its own workspace group, and its own
Agent Card. The legacy single-agent form (`server.preset` / `server.agentCard`)
still works: when `server.agents` is empty, one agent is derived from it.

| Field | Default | Meaning |
| --- | --- | --- |
| `server.enabled` | `true` | Serve the A2A endpoint; client tools work regardless |
| `server.host` / `server.port` | `127.0.0.1` / `8899` | Listen address (env `A2A_HOST` / `A2A_PORT`) |
| `server.preset` | `standard` | Preset mounted into each A2A conversation agent |
| `server.turnTimeoutMs` | `300000` | Per-turn deadline; a slow turn is cancelled |
| `server.allowOverrides` | `true` | Whether callers may pick preset/model per request via `metadata`; when `false` their overrides are dropped (and logged) |
| `server.publicUrl` | — | Public URL advertised on the Agent Card (required behind a reverse proxy); env `A2A_PUBLIC_URL` |
| `server.apiKey` | — | When set, every request except the Agent Card must present `Authorization: Bearer <key>`; env `A2A_API_KEY` |
| `server.provider` / `server.model` | — | Model route for A2A conversations, must be set as a pair; falls back to the harness default model; env `A2A_PROVIDER` / `A2A_MODEL` |
| `server.cwd` | `~/.a2a-sessions` | Working directory for A2A conversations (doubles as the sidebar workspace path); env `DSH_A2A_CWD` |
| `server.workspaceTitle` | `A2A` | Sidebar group title for A2A conversations |
| `server.agents[].id` | — | URL slug under `/agents/` (must be URL-safe), e.g. `support` |
| `server.agents[].name` / `description` / `version` | — | This agent's Agent Card identity |
| `server.agents[].preset` | — | Preset mounted into this agent's conversations |
| `server.agents[].provider` / `model` | — | This agent's model route (must be a pair); falls back to the harness default |
| `server.agents[].cwd` / `workspaceTitle` | `server.cwd` / `A2A` | This agent's workspace directory / group title |
| `server.agents[].skills` | `[]` | A2A `AgentSkill[]` (`id` / `name` / `description`) advertised on this agent's card |
| `server.agentCard.*` | — | Legacy single-agent identity (used when `server.agents` is empty) |
| `agents[].name` / `url` | — | **Remote** registry name for `a2a_call` + Agent Card URL |
| `agents[].headers` | `{}` | Request headers; `${ENV_VAR}` placeholders resolved at call time |
| `agents[].description` | `''` | Shown by `a2a_list` |

## 🎛️ Per-request overrides

By default every conversation uses the deployment's route: the configured `server.preset` and provider/model pair. A caller can pick its own instead by naming them on the A2A `metadata` map:

```sh
curl -s http://127.0.0.1:8899/ -H 'Content-Type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"SendMessage","params":{
        "message":{"role":"user","messageId":"m1","parts":[{"kind":"text","text":"hi"}],"contextId":"demo"},
        "metadata":{"agentPreset":"general","model":"model-a"}
      }}'
```

| Key | Aliases | Meaning | Applies |
| --- | --- | --- | --- |
| `agentPreset` | `preset` | Preset id to compose the agent from, overriding `server.preset` | on the request that **creates** the session |
| `model` | — | Model id, overriding `server.model` | on every request, and it **sticks** for the rest of the session |
| `provider` | — | Provider route, paired with the configured (or default) model | on every request |

The two ride different lifetimes because the harness gives them different ones:

- **A preset composes an agent** — it mounts tools and skills at session creation. Once a session exists its composition is fixed, so a `preset` on a later turn is ignored and logged: swapping tools mid-conversation would leave tool calls the new composition cannot make. Send the preset on the first message (or use a fresh `contextId`).
- **The model is a per-step route**, so a live session is switched onto the requested model without losing the conversation — or the KV-cache prefix its history already earned.

Precedence and edge cases:

- `params.message.metadata` wins over `params.metadata`; writing the same value to both — as the REST `message:send` shape invites — is unambiguous, and callers that only know one of them still work.
- A bare `model` is paired with `server.provider`, falling back to the harness default model's provider: callers know the model they want, not the route serving it. Without any provider to pair with, the task fails rather than answering on a model the caller did not ask for.
- A malformed value (a non-string, or one that could escape the preset root) fails the task with a message naming the key.
- An unknown preset fails the task and lists the ids this deployment does supply.
- Set `server.allowOverrides: false` to lock the deployment's route: overrides are then dropped and logged, and the request is still answered.

## 🏷️ Deployment customization (recommended)

dsh assemblies are layered: **the bundle's own patch (npm-distributed) → the profile's `cordis.patch.yml` → environment variables**. When giving a deployment its own identity, put each piece on the right layer:

- **Keep the shipped `cordis.patch.yml` generic** — it reaches every npm consumer, so never bake deployment-specific identity (a bespoke preset, a dedicated Agent Card name/description) into it.
- **Deployment identity goes in the profile override layer** — override the `a2a` row by id in `~/.dsh/profiles/web/cordis.patch.yml` (no `- insert:` prefix, and spell out the full `server` block):

```yaml
# ~/.dsh/profiles/web/cordis.patch.yml
- id: a2a
  name: dsh-a2a
  config:
    server:
      enabled: true
      host: 127.0.0.1
      port: 8899
      preset: my-support-preset      # deployment-specific preset
      turnTimeoutMs: 300000
      agentCard:
        name: My Support Agent       # deployment-specific identity
        description: Answers internal support questions.
        version: '0.1.0'
    agents: []
```

- **Secrets go in environment variables** — the real values of `publicUrl` / `apiKey` / `provider` / `model` never sit in any YAML; keep the `!!js process.env.XXX` expressions and inject the values at launch (systemd users can use `EnvironmentFile`):

```ini
# /etc/dsh-web.env (systemd EnvironmentFile example)
A2A_PUBLIC_URL=https://gateway.example.com/
A2A_API_KEY=<secret>
A2A_PROVIDER=example-provider
A2A_MODEL=model-b
```

Patch-layer changes are read at assembly time — restart `dsh web` to apply them. The GUI registry (`agents`) is the exception: saves hot-reload.

## 💬 Model tools

| Tool | What it does |
| --- | --- |
| `a2a_list` | Lists the registered remote A2A agents (name + description). |
| `a2a_call` | Sends a prompt to one registered agent and returns its completed text reply. |

## 🏗️ How it works

- The server is a **standalone Node HTTP listener** (not a route on the web GUI): it serves several local agents, each at `/agents/<id>/...` with its own Agent Card, JSON-RPC + REST surface, preset-mounted executor, and session namespace. The root `/.well-known/agent-card.json` returns the first agent (there is no default agent at `/`); `server.agents[]` is the multi-agent form, and the legacy single-agent config derives one agent. Lifecycle is tied to the Cordis fiber — the listener starts on plugin load and closes (sockets included) on plugin disposal.
- Each agent's executor implements the SDK's `AgentExecutor` contract: publish a `task` event, run one harness turn (`followup` → `whenIdle` with deadline → cancel on timeout), publish the reply as a `message` event, then a terminal `statusUpdate`. `CancelTask` reaches the right agent through the running-task table. The executor per agent is keyed by `contextId` (session namespace `a2a-<agentId>-<hash>`), so follow-up turns continue the same conversation.
- The client resolves the registry through the SDK's `ClientFactory` (JSON-RPC + REST transports) with an authenticating fetch that injects the per-agent headers.

## ⚠️ Limitations

- Inbound authentication: since 0.3.0 `server.apiKey` enforces a Bearer token on every request except the Agent Card (which must stay publicly readable); for larger deployments, still put the port behind an authenticating gateway or a reverse proxy. The Agent Card advertises no security schemes.
- One executor instance serves every context **of one agent** (each `/agents/<id>` gets its own); sessions resume across turns but a dsh restart creates fresh in-memory state. The root `/.well-known/agent-card.json` returns the first agent only — address a specific agent at `/agents/<id>/...`.
- A `model` override switches sessions this executor instance created. One adopted from outside it — opened in the web UI, or resumed from disk — has no route handle to switch, so the override is logged and ignored; the reply keeps the model it was created with. Sending the model again after the executor creates the session works as expected.
- ~~Outbound registry edits require a profile patch + restart~~ **0.2.0: GUI-configurable.** The Plugins → Plugin configuration section ships an "A2A remote agents" card over the `a2a` settings namespace (schema defaults → row-config base → user overrides); a save hot-reloads the running tools, and a reset re-inherits the deployment registry.

## 🧪 Development

```sh
pnpm install --frozen-lockfile
npm run check          # biome + typecheck + vitest + build
npm run test:install   # fresh temporary profile; requires dsh 0.1.7-rc.2 and pnpm
```

The installation test packs the plugin, installs it through `dsh plugin`, and verifies
the Agent Card and browser bundle without manually installing Harness peers. It
cleans up its temporary profile and process and makes no model or remote-agent calls.
An explicit package or Git spec can be tested with `npm run test:install -- <spec>`.

The suite covers config validation, the Agent Card + JSON-RPC round trip through the official A2A client against a real HTTP listener, per-context session continuity, task cancellation, header auth, per-request overrides, and the model-facing tools.

## 📄 License

[MIT](LICENSE)
