DSH HUB
HomePlugin StorePlugin PacksCommunityRankingsResourcesPublish Guide
Plugin source
Back to catalog

huangjuhua-aigc /

dsh-a2a

Verified

This repository has no description yet.

★ 0 Stars0 Forks0 IssuesN/A Community rating0 Confirmed installs
View on GitHub
READMESource: main@b526d7b2

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 pack verifies the tarball already carries lib/ and cordis.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.workspaceRoot is required
  • peer names must match [A-Za-z0-9][A-Za-z0-9_-]* (they become directory names)
  • tokenEnv must be a POSIX identifier, so a pasted token is rejected
  • trustedPeers and peerWorkspaces may only name declared peers
  • basePath must 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 peers table answers every request 401.
  • No TLS. ctx.webServer provides 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 never on 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:

  1. params.configuration.blocking — the client's stated preference
  2. sendMode — the deployment default for a client that states none
  3. blockTimeoutMs — 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 in Task.metadata.dsh.stopReason.
  • Any config change restarts the plugin and cancels in-flight tasks.
  • streamGranularity is 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.enabled only 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-peer is a poor default for collaborating peers — they must set shared explicitly 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

—/ 5

No ratings yet

Verified DSH bundle

Commit b526d7b2b0b3

Community comments

No comments yet. Be the first to write one.

DSH HUB

A community index for DSH plugins. Not an official GitHub or DeepSeek AI product.

CommunityResourcesAPIAbout