dsh-a2a
English | 简体中文
A2A v1.0 for DeepSeek Harness — expose the harness as an A2A agent, and let harness agents delegate to remote A2A agents.
One plugin, two halves, built on the official @a2a-js/sdk (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).
- 🧭 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
# 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):
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
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).
If an existing profile already contains manually installed Harness packages,
first verify the plugin in a fresh profile without changing the original:
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:
- 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 |
🎛️ 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:
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):
# ~/.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):
# /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
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