dsh-a2a
English · 简体中文
Inbound A2A (Agent2Agent) protocol server for DeepSeek Harness. Publishes an Agent Card at a well-known URI and serves the v0.3.0 JSON-RPC binding, so any compliant peer that knows this deployment's URL can discover it and submit tasks to a harness agent.
Inbound only. This plugin never connects to another agent: there is no client, no peer directory, and no A2A subagent provider. It is a transport adapter, not a capability seam.
Install
dsh plugin --profile web add ./path/to/dsh-a2a # local checkout
dsh plugin --profile web add dsh-a2a # once published
Not on npm yet. Use the local-checkout form;
npm packverifies the tarball already carrieslib/andcordis.patch.yml, so publishing is the only remaining step.
dsh plugin forwards to pnpm inside the profile directory and appends this
bundle to dsh.profile.bundles, because the package declares dsh.bundle.
Then configure the layer (see Configuration).
Pick a profile that has an HTTP carrier
The plugin injects ctx.agents, ctx.webServer, and ctx.credentials, and
stays PENDING until all three exist. ctx.webServer ships in dsh-web-app,
not in dsh-base — so on a headless profile this bundle loads and then sits
there: nothing serves, and nothing errors, because a PENDING fiber is a normal
Cordis state rather than a failure.
| Profile | Result |
|---|---|
web |
works |
headless |
PENDING — mount @deepseek-ai/dsh-host-webserver first |
dsh --profile <name> --dump-config prints the composed rows, which is the
quickest way to confirm the carrier is there.
Try it locally
The demo always runs a real model — deepseek-official/deepseek-v4-flash, or
whatever DEEPSEEK_MODEL names. A missing credential is an error, not a silent
fall back to a stub that would answer nothing useful.
The credential is resolved through ctx.credentials, so it may live in the
process environment, $DSH_HOME/.credentials.yaml, or either .env layer —
whichever a harness install already uses works here unchanged. The test suite
forces the stub instead: a real model would make assertions about exact reply
text meaningless and would spend tokens on every run.
A2A_SEND_MODE=immediate is what exercises the polling path: the peer gets a
non-terminal task and must come back with tasks/get for the result.
bash / zsh
pnpm install
A2A_PEER_ALICE=demo123 A2A_PORT=9922 A2A_SEND_MODE=immediate pnpm serve
curl -s http://127.0.0.1:9922/.well-known/agent-card.json
curl -s http://127.0.0.1:9922/a2a -H "authorization: Bearer demo123" -H 'content-type: application/json' -d '{"jsonrpc":"2.0","id":1,"method":"message/send","params":{
"message":{"kind":"message","messageId":"m1","role":"user",
"parts":[{"kind":"text","text":"hello"}]}}}'
PowerShell
PowerShell has no VAR=value cmd prefix — set the variables first. And curl
is an alias for Invoke-WebRequest, so call curl.exe explicitly or use
Invoke-RestMethod.
pnpm install
$env:A2A_PEER_ALICE = "demo123"
$env:A2A_PORT = "9922"
$env:A2A_SEND_MODE = "immediate"
pnpm serve
In a second terminal:
curl.exe -s http://127.0.0.1:9922/.well-known/agent-card.json
$h = @{ authorization = "Bearer demo123" }
$body = '{"jsonrpc":"2.0","id":1,"method":"message/send","params":{"message":{"kind":"message","messageId":"m1","role":"user","parts":[{"kind":"text","text":"hello"}]}}}'
$sent = Invoke-RestMethod -Uri http://127.0.0.1:9922/a2a -Method Post -Headers $h -ContentType 'application/json' -Body $body
$sent.result | ConvertTo-Json -Depth 5
# The task is settled by now and its slot is gone; this answer comes from the
# projection folded over the session log.
$taskId = $sent.result.id
$poll = Invoke-RestMethod -Uri http://127.0.0.1:9922/a2a -Method Post -Headers $h -ContentType 'application/json' `
-Body "{`"jsonrpc`":`"2.0`",`"id`":2,`"method`":`"tasks/get`",`"params`":{`"taskId`":`"$taskId`"}}"
$poll.result | ConvertTo-Json -Depth 5
One-shot probe
With a server running, sweep every documented behavior and get a pass/fail checklist. Plain Node, so the JSON payloads dodge both shells' quoting rules:
pnpm probe # defaults to :9922 / demo123
node example/probe.mjs http://127.0.0.1:9922 demo123
It exits non-zero on any mismatch, so it also works as a smoke check against a real deployment. The 48 checks run against whichever model the server is on: where a check needs to prove a non-text part reached the request, it asks the model a question only that part can answer rather than matching reply text, which would only ever describe one particular model.
What it serves
| Route | Purpose |
|---|---|
GET /.well-known/agent-card.json |
Agent Card (v0.3 canonical path) |
GET /.well-known/agent.json |
Same card, pre-0.3 clients |
POST /a2a |
JSON-RPC endpoint; SSE methods answer on the same route |
| Method | Status |
|---|---|
message/send |
✅ blocking negotiated per request |
message/stream |
✅ SSE, both dialects |
tasks/get |
✅ idempotent, answers after settlement |
tasks/cancel |
✅ real cancellation, not just a dropped reply |
tasks/resubscribe |
✅ live task, or one terminal frame for a settled one |
tasks/pushNotificationConfig/* |
⛔ -32003; card advertises pushNotifications: false |
tasks/list · ListTasks |
⛔ -32601; v1.0-only, not served |
agent/getAuthenticatedExtendedCard |
⛔ -32601; no extended card |
Both dialects are accepted: v0.3 (message/send, "working", kind-tagged
parts) is the mainline, and the v1.0 spellings (SendMessage,
TASK_STATE_WORKING, member-presence parts) are normalized on the way in and
rendered back in whichever dialect the request used.
How it works
src/
├── protocol/ dependency-free library: no Cordis, no HTTP, no harness
│ ├── wire.ts the A2A vocabulary, normalized to v0.3 spelling
│ ├── normalize.ts v0.3 <-> v1.0 dialect translation, both directions
│ ├── jsonrpc.ts framing and the A2A error codes
│ ├── card.ts Agent Card construction
│ └── sse.ts SSE frame encoding
├── index.ts the Cordis plugin: wiring, agent ownership, teardown
├── router.ts HTTP + JSON-RPC dispatch, free of Cordis so it unit-tests
├── contexts.ts contextId -> Activation registry and residency policy
├── tasks.ts task slots and the three-stage turn correlation
├── projection.ts the a2aTask fold over the session log
├── security.ts authentication, rate limiting, defanging, redaction
├── config.ts schema plus the cross-field checks that fail at load
└── types.ts declaration merges into SessionEventMap / MessageSourceMap
Three decisions shape everything else:
A task is an interval, not a turn. One submitted message may span several
turns if tools queue more work, so settlement uses three hooks rather than one:
agent/inbox/claimed binds the message to a turn, turn/end records that
turn's ending, and agent.whenIdle() settles once the whole agent is quiet. A
model error fails immediately; a token ceiling settles as completed with the
real ending in Task.metadata.dsh.stopReason, because A2A's state enum cannot
express it.
Task state lives in the session log. Each lifecycle edge is an a2a/task
event, and a projection unit folds them into the read model tasks/get serves.
The terminal edge carries the agent's committed output too — the projection
contract's whole-value rule — so a polling peer receives the answer and not just
the fact that work finished.
HTTP has no connection lifetime, so residency is explicit. Each contextId
maps to an Activation that is evicted when idle, leaving the durable Session
behind. This is the one place the design departs from dsh-acp, whose stdio
connection owns its sessions.
Configuration
- id: a2a-server
name: dsh-a2a
config:
basePath: /a2a
publicUrl: https://agents.example.com/a2a # behind a reverse proxy
protocolVersion: 0.3.0 # advertised on the card
provider: deepseek-official
model: deepseek-v4-flash
card:
name: dsh-harness
description: Reads code, runs commands, reports findings.
public: true # discovery expects an anonymous read
skills: # DECLARED, never projected from ctx.tools
- id: general
name: general
description: General-purpose task execution.
tags: [coding, research]
provider: # optional publisher attribution on the card
organization: Example Inc.
url: https://example.com
# tokenEnv is a credential REFERENCE name, not a token. Values live in
# ~/.dsh/.credentials.yaml or the process environment.
peers:
alice: { tokenEnv: A2A_PEER_ALICE }
bob: { tokenEnv: A2A_PEER_BOB }
trustedPeers: [alice] # omit to allow every authenticated peer
rateLimitPerMinute: 60
maxContextTurns: 5
sendMode: block # default when the client states no preference
blockTimeoutMs: 60000 # after which a blocking request is declined
contextIdleTtlMs: 1800000
maxResidentContexts: 64
isolation:
workspaceMode: per-peer # per-peer (default) | shared
workspaceRoot: /srv/dsh/a2a # required, no default
peerWorkspaces: # optional per-peer override
alice: /srv/project
push:
enabled: false # reserved; see Known limitations
Every field above is read by the code. Nothing is accepted that is not enforced — the deny-list, stream granularity, task-timeout and SSRF knobs from the design are absent until their implementations land, so a deployment cannot set a security option and believe something honors it.
Refused at load
isolation.workspaceRootis required- peer names must match
[A-Za-z0-9][A-Za-z0-9_-]*(they become directory names) tokenEnvmust be a POSIX identifier, so a pasted token is rejectedtrustedPeersandpeerWorkspacesmay only name declared peersbasePathmust start with/
Credentials
Configuration carries references; values live with the credential provider:
# ~/.dsh/.credentials.yaml
A2A_PEER_ALICE: <32-byte-hex-from-openssl-rand>
The reference must be a POSIX identifier, so pasting a real token into
tokenEnv fails at load rather than silently becoming a lookup that never
resolves. Rotation needs no restart: credentials resolve per request.
There is deliberately no shared bearer token. Peer isolation is built on authenticated identity, so two peers sharing one credential would share one identity and could read each other's contexts.
Isolation
Three layers, the first two structural:
| Layer | Guarantee | Mechanism |
|---|---|---|
| Model context | Peer A's conversation cannot enter peer B's model request | Separate contextId → separate Session → separate log |
| Protocol access | Peer B cannot read, continue, or cancel peer A's context or task | Ownership by authenticated identity; a foreign id answers exactly like an absent one |
| Tooling | Peer A's agent cannot use tools to read peer B's session | workspaceMode: per-peer (default) |
per-peer gives each identity its own cwd. Cross-session tooling authorizes
by exact cwd equality, so distinct workspaces isolate peers through the
mechanism that already exists — and cut the filesystem side channel too.
shared is the collaborative posture (several machines maintaining one
repository). It must be chosen deliberately: under it, peer A's files are
readable by peer B.
Security posture
- No credential ⇒ no service. An empty
peerstable answers every request 401. - No TLS.
ctx.webServerprovides none; put a reverse proxy in front for any non-loopback exposure. - Approvals are deterministically rejected. Nobody watches an A2A-driven
agent, so the policy is pinned to
neveron the agent's own log rather than waiting out a prompt no human will answer. - Injection defanging is noise reduction, not a boundary. The boundary is the sandbox scope and the rejected approval.
- Replies are scrubbed of credential-shaped strings before leaving.
- Tool results never reach a peer — A2A's opaque-execution principle.
Blocking is negotiated, not fixed
A2A is async-first: message/send may answer with a non-terminal task. Whether
it waits is settled per request, in this order:
params.configuration.blocking— the client's stated preferencesendMode— the deployment default for a client that states noneblockTimeoutMs— after which the server declines to keep waiting
Declining means answering with a non-terminal task, not a failure: the task
is still running and tasks/get will have the result. The spec allows exactly
this — "The server may reject this if the task is long-running."
{ "method": "message/send", "params": {
"message": { "kind": "message", "messageId": "m1", "role": "user",
"parts": [{ "kind": "text", "text": "…" }] },
"configuration": { "blocking": true } } }
Task durability
Task state is folded out of the session log by an a2aTask projection unit, so
tasks/get keeps answering after a task settles — which is what makes the
polling path usable at all. message/stream and push notifications are optional
A2A capabilities; tasks/get is the baseline every peer can rely on.
The terminal edge carries the agent's committed output, not just the state — the
projection contract's whole-value rule. Without it a polling peer would receive
completed with an empty artifact list, which reads as "it worked and produced
nothing" rather than prompting a retry.
The projection registry (ctx.sessionProjections) is an optional dependency. A
composition without it still serves, but logs a warning and cannot answer for a
task once it settles.
Surviving a process restart additionally needs session persistence composed; that path is not wired yet.
Known limitations
- Push notifications are not implemented; the card advertises them as absent.
- Only the JSONRPC binding is served (no gRPC, no HTTP+JSON).
- No extended Agent Card,
stateTransitionHistory, extensions, or card signatures. - A token ceiling settles a task as
completed, not a distinct state; the real harness turn ending rides inTask.metadata.dsh.stopReason. - Any config change restarts the plugin and cancels in-flight tasks.
streamGranularityis not configurable: streaming emits committed assistant messages only. Per-chunk streaming is designed but unbuilt.- No orphan-task watchdog: a task wedged non-terminal stays that way.
push.enabledonly selects which error the push methods return; there is no sender, and therefore no SSRF fence to configure.- Cross-session tool denial is not implemented. Peer isolation rests on
workspaceMode: per-peer, which is enforced. - Task state survives settlement but not a restart: session persistence is not composed yet, so the projection has no log to cold-fold after a reboot.
workspaceMode: per-peeris a poor default for collaborating peers — they must setsharedexplicitly or each will see only its own empty directory.
Development
pnpm install
pnpm typecheck
pnpm test # 129 tests across 9 files: protocol, security, tasks, contexts,
# projection, end-to-end, SSE, polling, blocking
pnpm serve # a real server on localhost
The end-to-end suite boots a real Cordis composition with a real agent loop and
drives it over real HTTP — the same composition pnpm serve runs, so what the
tests prove is what you run.
Compatibility
Built against the 0.1.0-rc.6 line of the harness packages. The harness is
pre-release and explicitly does not promise compatibility across renames or
repackaging, so peer dependencies are pinned exactly: a breaking upstream change
should fail at install rather than at runtime.
License
MIT
No comments yet. Be the first to write one.