@amaster.ai/dsh-a2a 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +82 -0
- package/cordis.patch.yml +3 -0
- package/lib/bridge.js +229 -0
- package/lib/executor.js +97 -0
- package/lib/index.js +130 -0
- package/lib/server.js +82 -0
- package/lib/stores/gcs.js +84 -0
- package/lib/stores/redis.js +58 -0
- package/lib/task-store.js +111 -0
- package/lib/translator.js +244 -0
- package/lib/types/bridge.d.ts +78 -0
- package/lib/types/executor.d.ts +26 -0
- package/lib/types/index.d.ts +171 -0
- package/lib/types/server.d.ts +34 -0
- package/lib/types/stores/gcs.d.ts +37 -0
- package/lib/types/stores/redis.d.ts +28 -0
- package/lib/types/task-store.d.ts +45 -0
- package/lib/types/translator.d.ts +66 -0
- package/package.json +75 -0
- package/preview.png +0 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 dsh-plugins contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# @amaster.ai/dsh-a2a
|
|
2
|
+
|
|
3
|
+

|
|
4
|
+
|
|
5
|
+
A2A protocol server plugin for [DeepSeek Harness (dsh)](https://github.com/deepseek-ai/deepseek-harness): expose dsh agents as [A2A](https://github.com/a2aproject) agents — streaming turns, task cancel, agent card, and pluggable task-state stores. Speaks **A2A 1.0** (JSON-RPC + SSE) on [`@a2a-js/sdk`](https://github.com/a2aproject/a2a-js) 1.x, with the SDK's opt-in **v0.3 compatibility layer** kept on so pre-1.0 clients keep working. Ported from the source project's `packages/a2a-server`.
|
|
6
|
+
|
|
7
|
+
`dsh-a2a` is the network boundary for a dsh agent. It gives A2A clients a stable task-oriented interface while the agent continues to use the profile's models, presets, tools, and workspace rules.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
dsh plugin --profile my-agent add @amaster.ai/dsh-a2a
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Configuration
|
|
16
|
+
|
|
17
|
+
Disabled by default. Configure via the profile's `cordis.patch.yml`:
|
|
18
|
+
|
|
19
|
+
```yaml
|
|
20
|
+
- insert:
|
|
21
|
+
- id: a2a
|
|
22
|
+
name: '@amaster.ai/dsh-a2a'
|
|
23
|
+
config:
|
|
24
|
+
enabled: true
|
|
25
|
+
host: 127.0.0.1 # no auth built in — keep loopback or front with a proxy
|
|
26
|
+
port: 41241
|
|
27
|
+
basePath: /a2a
|
|
28
|
+
cwd: /srv/agent-workspaces
|
|
29
|
+
agent:
|
|
30
|
+
provider: '' # dsh provider/model for A2A sessions; empty = profile default
|
|
31
|
+
model: ''
|
|
32
|
+
preset: '' # agent preset mounted per task; empty = deployment default
|
|
33
|
+
card:
|
|
34
|
+
name: my-agent
|
|
35
|
+
description: My dsh agent over A2A
|
|
36
|
+
version: 0.1.0
|
|
37
|
+
publicUrl: https://agent.example.com
|
|
38
|
+
taskStore: redis # memory | redis | gcs
|
|
39
|
+
redis:
|
|
40
|
+
url: redis://127.0.0.1:6379
|
|
41
|
+
keyPrefix: a2a
|
|
42
|
+
ttlSeconds: 86400
|
|
43
|
+
gcs:
|
|
44
|
+
bucket: my-agent-archives
|
|
45
|
+
prefix: tasks
|
|
46
|
+
keyFilename: '' # GOOGLE_APPLICATION_CREDENTIALS path; empty = ADC
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Endpoints
|
|
50
|
+
|
|
51
|
+
- `GET /.well-known/agent-card.json` — agent card: A2A 1.0 shape for `A2A-Version: 1.0` clients, the 0.3 shape for headerless clients (the legacy `/.well-known/agent.json` path is served as an alias)
|
|
52
|
+
- `POST <basePath>/` — JSON-RPC. v1 methods: `SendMessage` (blocking by default, `configuration.returnImmediately: true` returns after the first event), `SendStreamingMessage` (SSE), `GetTask`, `ListTasks` (filter + cursor pagination), `CancelTask`, `SubscribeToTask` (the current task is the first event; the bus stays alive while the task is interrupted, so interrupted tasks can be re-followed live). v0.3 spellings (`message/send`, `message/stream`, `tasks/get`, `tasks/cancel`, `tasks/resubscribe`) keep working through the compat layer.
|
|
53
|
+
|
|
54
|
+
## Behavior notes
|
|
55
|
+
|
|
56
|
+
- **One task = one dsh session.** The A2A `contextId` IS the dsh session id. A completed turn ends `input-required`, not `completed` — the task is a conversation and stays continuable; `CancelTask` and turn errors are terminal (`canceled` / `failed`), and the SDK rejects follow-ups addressed at a terminal `taskId` (send with only the `contextId` to continue the session under a fresh task id).
|
|
57
|
+
- **Agents are full preset citizens.** Each task's agent is created with the deployment's default model selection (`agentDefaultModel`) and mounts its agent preset (the web profile keeps all tools inside presets — without one the agent would see an empty tool catalog). `agent.preset` pins a specific preset.
|
|
58
|
+
- **Streaming aggregation.** Text deltas of a turn share one `messageId`, so clients accumulate them into a single message; reasoning deltas ride a separate `messageId` and are marked `metadata.dshAgent.kind: 'thought'`. The turn-final event's message carries the full assembled text, so blocking `SendMessage` clients read the answer from `result.task.status.message`. Tool calls/results are data parts marked `tool-call` / `tool-result`; token usage lands in `metadata.usage` of the final event. A2A 1.0 has no `final` flag — terminal and interrupted states close the stream.
|
|
59
|
+
- **Text-only boundary** for now: `SendMessage` rejects messages with non-text parts (file/url/data) with a failed task.
|
|
60
|
+
- **No approval bridge**: dsh rc.7 has no mid-turn approval seam, so tools that would ask are governed by the profile's own approval setup; the A2A side never enters a mid-turn `input-required`.
|
|
61
|
+
- **One in-flight message per task** is the supported flow (send the next message after the turn-final event). dsh serializes queued follow-ups into successive turns, but concurrent requests share one event bus — a second in-flight request may resolve with the first turn's final event.
|
|
62
|
+
- **Restart**: persisted task shells survive in Redis/GCS, but live agents do not — continuing after a restart starts a fresh session (a client-supplied `contextId` names it).
|
|
63
|
+
|
|
64
|
+
## Task stores
|
|
65
|
+
|
|
66
|
+
A2A **task state** (status + metadata) is separate from conversation history — use [dsh-storage](../dsh-storage) for the latter. Every backend persists a sanitized metadata shell (history/artifacts stripped) and saves only on task-state transitions, so token-rate stream events never reach the backend.
|
|
67
|
+
|
|
68
|
+
- `memory` (default) — in-process, lost on restart
|
|
69
|
+
- `redis` — task JSON under `<keyPrefix>:tasks:<taskId>` with a TTL; requires the `ioredis` peer
|
|
70
|
+
- `gcs` — gzipped task JSON at `<prefix>/<taskId>/metadata.json.gz` (same layout as the source project's `GCSTaskStore`); requires the `@google-cloud/storage` peer. `archiveWorkspace()` (tar of the workspace) exists but is not wired to the lifecycle yet.
|
|
71
|
+
|
|
72
|
+
## Security
|
|
73
|
+
|
|
74
|
+
dsh ships **no authentication or authorization**. The server binds `127.0.0.1` by default; if you expose it, put an authenticated reverse proxy in front and treat every agent as running with the host process's OS privileges.
|
|
75
|
+
|
|
76
|
+
## Compatibility
|
|
77
|
+
|
|
78
|
+
Pinned dsh/cordis versions live in the [root compat matrix](../../README.md#compatibility). Event payloads ride pre-release dsh APIs (`@deepseek-ai/dsh-{agent,session,llm}@0.1.0-rc.7`) — check the `TODO(verify)` markers in `src/` before upgrading dsh.
|
|
79
|
+
|
|
80
|
+
## License
|
|
81
|
+
|
|
82
|
+
MIT
|
package/cordis.patch.yml
ADDED
package/lib/bridge.js
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bridges A2A tasks to dsh agents.
|
|
3
|
+
*
|
|
4
|
+
* One A2A task maps to one dsh session/agent; the A2A `contextId` IS the dsh
|
|
5
|
+
* sessionId. The bridge owns every agent it creates (the consumer-handle
|
|
6
|
+
* pattern dsh's own ACP bridge uses) and routes the durable `session/event`
|
|
7
|
+
* stream through a per-task {@link SessionTranslator} onto the executing
|
|
8
|
+
* request's {@link ExecutionEventBus}.
|
|
9
|
+
*
|
|
10
|
+
* Turn settlement: `agent.followup()` is fire-and-forget, so callers await a
|
|
11
|
+
* FIFO waiter resolved by the session's next `turn/end` event (dsh's inbox
|
|
12
|
+
* serializes queued follow-ups into successive turns, so waiter order matches
|
|
13
|
+
* turn order). The final status-update is published before the waiter
|
|
14
|
+
* resolves, so an execute() that returns always landed its events first.
|
|
15
|
+
*/
|
|
16
|
+
// installModelSelection is a root-module runtime import: the dsh-agent index
|
|
17
|
+
// pulls workspace-internal packages (dsh-scope) that are absent from the
|
|
18
|
+
// plugin's dev install, so unit tests vi.mock this module.
|
|
19
|
+
import { installModelSelection } from '@deepseek-ai/dsh-agent';
|
|
20
|
+
// Runtime helpers ride the clean subpaths: the dsh-llm/dsh-session index
|
|
21
|
+
// modules import packages that are not on npm yet (dsh-timeout, dsh-scope).
|
|
22
|
+
import { createUserMessage } from '@deepseek-ai/dsh-llm/message';
|
|
23
|
+
import { SessionId } from '@deepseek-ai/dsh-session/types';
|
|
24
|
+
import { SessionTranslator, terminalStatusUpdate } from './translator.js';
|
|
25
|
+
export class A2aBridge {
|
|
26
|
+
ctx;
|
|
27
|
+
options;
|
|
28
|
+
tasks = new Map(); // taskId → entry
|
|
29
|
+
bySession = new Map(); // sessionId (== contextId) → entry
|
|
30
|
+
constructor(ctx, options) {
|
|
31
|
+
this.ctx = ctx;
|
|
32
|
+
this.options = options;
|
|
33
|
+
ctx.on('session/event', (session, event) => this.onSessionEvent(session, event));
|
|
34
|
+
ctx.on('session/disposed', (session) => this.onSessionDisposed(session));
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Resolve the live task entry, creating the session/agent when needed.
|
|
38
|
+
* `freshTask` tells the executor to anchor the SDK's ResultManager with an
|
|
39
|
+
* initial Task event (status-updates for a task id the store never saw are
|
|
40
|
+
* dropped by it). Rebinding by bare contextId counts as fresh: the SDK mints
|
|
41
|
+
* a new task id for `message/send` calls that omit `taskId`.
|
|
42
|
+
*/
|
|
43
|
+
async ensureTask(taskId, contextId) {
|
|
44
|
+
const existing = this.tasks.get(taskId);
|
|
45
|
+
if (existing)
|
|
46
|
+
return { entry: existing, freshTask: false };
|
|
47
|
+
const forContext = this.bySession.get(contextId);
|
|
48
|
+
if (forContext) {
|
|
49
|
+
this.tasks.delete(forContext.taskId);
|
|
50
|
+
forContext.taskId = taskId;
|
|
51
|
+
forContext.translator = new SessionTranslator(taskId, contextId, this.options.agentOptions?.model);
|
|
52
|
+
this.tasks.set(taskId, forContext);
|
|
53
|
+
return { entry: forContext, freshTask: true };
|
|
54
|
+
}
|
|
55
|
+
// TODO(verify): resuming a persisted session (agents.resume) after a
|
|
56
|
+
// restart — needs dsh's sessionPersistence service composed. For now a
|
|
57
|
+
// client-supplied contextId simply names the fresh session.
|
|
58
|
+
const sessionId = SessionId(contextId);
|
|
59
|
+
const selection = this.resolveSelection();
|
|
60
|
+
// The web profile disables the host-plane tool plugins — agents get tools
|
|
61
|
+
// only through a mounted agent preset (dsh-host-apiproxy's composeAgent is
|
|
62
|
+
// the reference: resolve the id up front so it lands on the session header,
|
|
63
|
+
// mount inside setup). Profiles without a preset roster (headless) keep
|
|
64
|
+
// host-plane tools and skip this entirely.
|
|
65
|
+
const presets = this.ctx.get('agentPresets');
|
|
66
|
+
let agentPreset;
|
|
67
|
+
if (presets) {
|
|
68
|
+
agentPreset = (await presets.resolve(this.options.preset || undefined)).id;
|
|
69
|
+
}
|
|
70
|
+
const handle = await this.ctx.agents.create({
|
|
71
|
+
sessionId,
|
|
72
|
+
meta: { cwd: this.options.cwd, ...(agentPreset ? { agentPreset } : {}) },
|
|
73
|
+
agentOptions: selection
|
|
74
|
+
? { provider: selection.provider, model: selection.model }
|
|
75
|
+
: undefined,
|
|
76
|
+
// The loop's `{{model}}` prompt variable and request routing resolve from
|
|
77
|
+
// the agent's installed model selection (dsh-agent-loop's variables read
|
|
78
|
+
// agent.options; the scoped waterfalls wire provider/model into prompt
|
|
79
|
+
// assembly and the request config). Verified against
|
|
80
|
+
// @deepseek-ai/dsh-headless@0.1.0-rc.7's run() — entry points are
|
|
81
|
+
// expected to resolve the deployment default themselves.
|
|
82
|
+
setup: selection || agentPreset
|
|
83
|
+
? async (agentCtx) => {
|
|
84
|
+
if (selection) {
|
|
85
|
+
// Statement, deliberately not returned: a returned disposer would
|
|
86
|
+
// be invoked as the setup commit and immediately unwired. Likewise
|
|
87
|
+
// the mount result must not escape — setup's return value is
|
|
88
|
+
// commit()-shaped to the factory.
|
|
89
|
+
installModelSelection(agentCtx, { current: selection, assembled: undefined });
|
|
90
|
+
}
|
|
91
|
+
if (agentPreset)
|
|
92
|
+
await presets.mount(agentCtx, agentPreset);
|
|
93
|
+
}
|
|
94
|
+
: undefined,
|
|
95
|
+
});
|
|
96
|
+
const entry = {
|
|
97
|
+
taskId,
|
|
98
|
+
sessionId,
|
|
99
|
+
handle,
|
|
100
|
+
translator: new SessionTranslator(taskId, contextId, this.options.agentOptions?.model),
|
|
101
|
+
bus: null,
|
|
102
|
+
turnActive: false,
|
|
103
|
+
settled: [],
|
|
104
|
+
};
|
|
105
|
+
this.tasks.set(taskId, entry);
|
|
106
|
+
this.bySession.set(contextId, entry);
|
|
107
|
+
return { entry, freshTask: true };
|
|
108
|
+
}
|
|
109
|
+
/** Queue one user-message turn on the task's agent and await its `turn/end`. */
|
|
110
|
+
async runTurn(entry, text, bus) {
|
|
111
|
+
let resolveSettled;
|
|
112
|
+
const settled = new Promise((resolve) => {
|
|
113
|
+
resolveSettled = resolve;
|
|
114
|
+
});
|
|
115
|
+
entry.settled.push(resolveSettled);
|
|
116
|
+
entry.bus = bus;
|
|
117
|
+
try {
|
|
118
|
+
const result = entry.handle.agent.followup(createUserMessage({
|
|
119
|
+
content: [{ type: 'text', text }],
|
|
120
|
+
source: { kind: 'user' },
|
|
121
|
+
}));
|
|
122
|
+
// followup() is typed void; guard a mistyped async impl anyway — an
|
|
123
|
+
// unhandled rejection would crash the host process, and no turn/end
|
|
124
|
+
// would ever settle this waiter.
|
|
125
|
+
void Promise.resolve(result).catch((error) => {
|
|
126
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
127
|
+
console.error('[dsh-a2a] followup failed:', error);
|
|
128
|
+
if (entry.bus === bus) {
|
|
129
|
+
entry.bus.publish(terminalStatusUpdate(entry.taskId, entry.sessionId, 'failed', message));
|
|
130
|
+
}
|
|
131
|
+
resolveSettled();
|
|
132
|
+
});
|
|
133
|
+
// Never rejects: waiters only resolve. A synchronous followup() throw is
|
|
134
|
+
// the one failure path — it means no turn ever started, so pull this
|
|
135
|
+
// waiter back out of the FIFO before it can be settled by the NEXT
|
|
136
|
+
// turn's turn/end (which would resolve one execute() too early).
|
|
137
|
+
await settled;
|
|
138
|
+
}
|
|
139
|
+
catch (error) {
|
|
140
|
+
const index = entry.settled.indexOf(resolveSettled);
|
|
141
|
+
if (index >= 0)
|
|
142
|
+
entry.settled.splice(index, 1);
|
|
143
|
+
throw error;
|
|
144
|
+
}
|
|
145
|
+
finally {
|
|
146
|
+
if (entry.bus === bus)
|
|
147
|
+
entry.bus = null;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
/** Abort the task's active turn; returns the contextId when the task is live. */
|
|
151
|
+
cancel(taskId) {
|
|
152
|
+
const entry = this.tasks.get(taskId);
|
|
153
|
+
if (!entry)
|
|
154
|
+
return undefined;
|
|
155
|
+
// cancel() is typed void; the guard keeps a mistyped async impl from
|
|
156
|
+
// crashing the host (the caller already published its canceled final).
|
|
157
|
+
void Promise.resolve(entry.handle.agent.cancel({ kind: 'user' })).catch((error) => console.error('[dsh-a2a] cancel failed:', error));
|
|
158
|
+
return entry.sessionId;
|
|
159
|
+
}
|
|
160
|
+
/** Dispose every owned agent (plugin unload / server shutdown). */
|
|
161
|
+
async dispose() {
|
|
162
|
+
const entries = [...this.tasks.values()];
|
|
163
|
+
this.tasks.clear();
|
|
164
|
+
this.bySession.clear();
|
|
165
|
+
for (const entry of entries) {
|
|
166
|
+
this.settleEntry(entry, 'Plugin unloaded.');
|
|
167
|
+
try {
|
|
168
|
+
await entry.handle.dispose();
|
|
169
|
+
}
|
|
170
|
+
catch (error) {
|
|
171
|
+
console.error('[dsh-a2a] failed to dispose agent:', error);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
onSessionEvent(session, event) {
|
|
176
|
+
const entry = this.bySession.get(session.id);
|
|
177
|
+
if (!entry)
|
|
178
|
+
return;
|
|
179
|
+
if (event.type === 'turn/start')
|
|
180
|
+
entry.turnActive = true;
|
|
181
|
+
// No-throw seam: translation errors must never reach the agent loop. The
|
|
182
|
+
// turn/end waiter still resolves below, so a poisoned event cannot hang
|
|
183
|
+
// an in-flight execute() either.
|
|
184
|
+
try {
|
|
185
|
+
for (const out of entry.translator.handle(event))
|
|
186
|
+
entry.bus?.publish(out);
|
|
187
|
+
}
|
|
188
|
+
catch (error) {
|
|
189
|
+
console.error('[dsh-a2a] failed to translate session event:', error);
|
|
190
|
+
}
|
|
191
|
+
if (event.type === 'turn/end') {
|
|
192
|
+
entry.turnActive = false;
|
|
193
|
+
entry.settled.shift()?.();
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
onSessionDisposed(session) {
|
|
197
|
+
const entry = this.bySession.get(session.id);
|
|
198
|
+
if (!entry)
|
|
199
|
+
return;
|
|
200
|
+
this.tasks.delete(entry.taskId);
|
|
201
|
+
this.bySession.delete(session.id);
|
|
202
|
+
// A mid-turn disposal never emits turn/end — settle instead of hanging
|
|
203
|
+
// the in-flight execute() calls.
|
|
204
|
+
this.settleEntry(entry, 'Session disposed.');
|
|
205
|
+
}
|
|
206
|
+
/** Close an in-flight stream honestly and release every turn waiter. */
|
|
207
|
+
settleEntry(entry, message) {
|
|
208
|
+
if (entry.turnActive && entry.bus) {
|
|
209
|
+
entry.bus.publish(terminalStatusUpdate(entry.taskId, entry.sessionId, 'canceled', message));
|
|
210
|
+
}
|
|
211
|
+
entry.turnActive = false;
|
|
212
|
+
for (const resolve of entry.settled.splice(0))
|
|
213
|
+
resolve();
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Config override wins per field; otherwise the deployment default read from
|
|
217
|
+
* the `agentDefaultModel` service (the entry-point contract — the loop
|
|
218
|
+
* itself does not apply it). Undefined when neither source has a pair
|
|
219
|
+
* (persona-less minimal compositions stay valid).
|
|
220
|
+
*/
|
|
221
|
+
resolveSelection() {
|
|
222
|
+
const defaults = this.ctx.get('agentDefaultModel')?.currentSelection?.();
|
|
223
|
+
const provider = this.options.agentOptions?.provider || defaults?.provider;
|
|
224
|
+
const model = this.options.agentOptions?.model || defaults?.model;
|
|
225
|
+
if (!provider || !model)
|
|
226
|
+
return undefined;
|
|
227
|
+
return { provider, model, reasoningEffort: defaults?.reasoningEffort };
|
|
228
|
+
}
|
|
229
|
+
}
|
package/lib/executor.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The @a2a-js/sdk {@link AgentExecutor} that drives dsh agents through the
|
|
3
|
+
* bridge. One `execute()` = one user-message turn:
|
|
4
|
+
*
|
|
5
|
+
* 1. extract the text prompt (v1 is text-only)
|
|
6
|
+
* 2. ensure the task's session/agent exists (creating it on first contact)
|
|
7
|
+
* 3. publish a `task` event first — A2A 1.0 stream ordering REQUIRES the
|
|
8
|
+
* first event of every execute to be a task or message, including
|
|
9
|
+
* follow-up turns on a known task (the ResultManager merges it with the
|
|
10
|
+
* stored task; fresh tasks get the submitted anchor carrying the user
|
|
11
|
+
* message, continuing tasks get a working snapshot)
|
|
12
|
+
* 4. run the turn; session events stream onto the bus via the bridge
|
|
13
|
+
* 5. on failure publish a failed statusUpdate (plus a failed task anchor if
|
|
14
|
+
* none went out yet) instead of rethrowing — clients get the terminal
|
|
15
|
+
* state in band on both blocking and streaming paths
|
|
16
|
+
*
|
|
17
|
+
* `cancelTask` is an arrow property because the SDK destructures it.
|
|
18
|
+
*/
|
|
19
|
+
import { TaskState } from '@a2a-js/sdk';
|
|
20
|
+
import { AgentEvent } from '@a2a-js/sdk/server';
|
|
21
|
+
import { agentTextMessage, terminalStatusUpdate } from './translator.js';
|
|
22
|
+
export class DshAgentExecutor {
|
|
23
|
+
bridge;
|
|
24
|
+
constructor(bridge) {
|
|
25
|
+
this.bridge = bridge;
|
|
26
|
+
}
|
|
27
|
+
async execute(requestContext, eventBus) {
|
|
28
|
+
const { userMessage, taskId, contextId } = requestContext;
|
|
29
|
+
let anchored = false;
|
|
30
|
+
try {
|
|
31
|
+
const text = extractText(userMessage);
|
|
32
|
+
const { entry, freshTask } = await this.bridge.ensureTask(taskId, contextId);
|
|
33
|
+
eventBus.publish(freshTask
|
|
34
|
+
? // First contact: the submitted anchor carries the user message
|
|
35
|
+
// into the task history the ResultManager persists.
|
|
36
|
+
taskEvent(taskId, contextId, TaskState.TASK_STATE_SUBMITTED, { history: [userMessage] })
|
|
37
|
+
: // Follow-up turn: a working snapshot satisfies the 1.0
|
|
38
|
+
// stream-ordering rule; the ResultManager merges it with the
|
|
39
|
+
// stored task (history/artifacts preserved).
|
|
40
|
+
taskEvent(taskId, contextId, TaskState.TASK_STATE_WORKING));
|
|
41
|
+
anchored = true;
|
|
42
|
+
await this.bridge.runTurn(entry, text, eventBus);
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
46
|
+
if (!anchored) {
|
|
47
|
+
eventBus.publish(taskEvent(taskId, contextId, TaskState.TASK_STATE_FAILED, {
|
|
48
|
+
message: agentTextMessage(taskId, contextId, message),
|
|
49
|
+
}));
|
|
50
|
+
}
|
|
51
|
+
eventBus.publish(terminalStatusUpdate(taskId, contextId, 'failed', message));
|
|
52
|
+
}
|
|
53
|
+
finally {
|
|
54
|
+
eventBus.finished();
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
cancelTask = async (taskId, eventBus) => {
|
|
58
|
+
// The SDK only calls cancelTask while a bus for the task is live. The live
|
|
59
|
+
// path aborts the dsh turn (its turn/end also lands on the bus as a
|
|
60
|
+
// canceled statusUpdate via the translator — a harmless duplicate); the
|
|
61
|
+
// published event moves the persisted shell to canceled through the
|
|
62
|
+
// handler's ResultManager either way.
|
|
63
|
+
const contextId = this.bridge.cancel(taskId) ?? '';
|
|
64
|
+
eventBus.publish(terminalStatusUpdate(taskId, contextId, 'canceled', 'Task canceled by request.'));
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
/** One task event in the 1.0 model: the proto-required fields live here exactly once. */
|
|
68
|
+
function taskEvent(taskId, contextId, state, extra = {}) {
|
|
69
|
+
const task = {
|
|
70
|
+
id: taskId,
|
|
71
|
+
contextId,
|
|
72
|
+
status: {
|
|
73
|
+
state,
|
|
74
|
+
message: extra.message,
|
|
75
|
+
timestamp: new Date().toISOString(),
|
|
76
|
+
},
|
|
77
|
+
history: extra.history ?? [],
|
|
78
|
+
artifacts: [],
|
|
79
|
+
metadata: undefined,
|
|
80
|
+
};
|
|
81
|
+
return AgentEvent.task(task);
|
|
82
|
+
}
|
|
83
|
+
// TODO(verify): non-text parts (file/raw/url/data) — dsh supports image
|
|
84
|
+
// content blocks; map them when a client needs it. The confirmation data-part
|
|
85
|
+
// protocol ({callId, outcome}) has no dsh rc.7 counterpart to bridge to.
|
|
86
|
+
function extractText(message) {
|
|
87
|
+
const nonText = message.parts.filter((part) => part.content?.$case !== 'text');
|
|
88
|
+
if (nonText.length > 0) {
|
|
89
|
+
throw new Error(`dsh-a2a: unsupported part kind(s): ${nonText.map((part) => part.content?.$case ?? 'unknown').join(', ')} (text-only)`);
|
|
90
|
+
}
|
|
91
|
+
const text = message.parts
|
|
92
|
+
.map((part) => (part.content?.$case === 'text' ? part.content.value : ''))
|
|
93
|
+
.join('');
|
|
94
|
+
if (!text.trim())
|
|
95
|
+
throw new Error('dsh-a2a: message must contain at least one text part');
|
|
96
|
+
return text;
|
|
97
|
+
}
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-a2a — exposes DeepSeek Harness agents over the A2A protocol.
|
|
3
|
+
*
|
|
4
|
+
* Plugin shape mirrors dsh's own ACP bridge (packages/acp/acp in the dsh
|
|
5
|
+
* repo): the plugin owns the agents it creates, serves a protocol endpoint
|
|
6
|
+
* for the process lifetime, and disposes everything on unload.
|
|
7
|
+
*
|
|
8
|
+
* Security note: dsh ships no authn/authz. This plugin binds 127.0.0.1 by
|
|
9
|
+
* default; put a real authentication layer in front before exposing it
|
|
10
|
+
* beyond loopback.
|
|
11
|
+
*
|
|
12
|
+
* @module dsh-a2a
|
|
13
|
+
*/
|
|
14
|
+
import { InMemoryTaskStore } from '@a2a-js/sdk/server';
|
|
15
|
+
import Schema from '@deepseek-ai/schemastery';
|
|
16
|
+
import { A2aBridge } from './bridge.js';
|
|
17
|
+
import { DshAgentExecutor } from './executor.js';
|
|
18
|
+
import { startA2aServer } from './server.js';
|
|
19
|
+
import { GcsTaskStore } from './stores/gcs.js';
|
|
20
|
+
import { RedisTaskStore } from './stores/redis.js';
|
|
21
|
+
import { SanitizedTaskStore } from './task-store.js';
|
|
22
|
+
export const name = 'dsh-a2a';
|
|
23
|
+
/** The bridge creates and owns agents through the registry service. */
|
|
24
|
+
export const inject = ['agents'];
|
|
25
|
+
export const Config = Schema.object({
|
|
26
|
+
enabled: Schema.boolean().default(false),
|
|
27
|
+
host: Schema.string().default('127.0.0.1'),
|
|
28
|
+
port: Schema.natural().default(41241),
|
|
29
|
+
basePath: Schema.string().default('/a2a'),
|
|
30
|
+
/** Working directory for agents spawned by A2A tasks. Must be absolute. */
|
|
31
|
+
cwd: Schema.string().default(process.cwd()),
|
|
32
|
+
agent: Schema.object({
|
|
33
|
+
provider: Schema.string().default(''),
|
|
34
|
+
model: Schema.string().default(''),
|
|
35
|
+
/**
|
|
36
|
+
* Agent preset to mount per task (empty = the deployment default when the
|
|
37
|
+
* profile composes a preset roster). The web profile keeps tools inside
|
|
38
|
+
* presets — without one the agent sees an empty tool catalog.
|
|
39
|
+
*/
|
|
40
|
+
preset: Schema.string().default(''),
|
|
41
|
+
}),
|
|
42
|
+
card: Schema.object({
|
|
43
|
+
name: Schema.string().default('dsh-a2a-agent'),
|
|
44
|
+
description: Schema.string().default('DeepSeek Harness agent over A2A'),
|
|
45
|
+
version: Schema.string().default('0.1.0'),
|
|
46
|
+
publicUrl: Schema.string().default(''),
|
|
47
|
+
}),
|
|
48
|
+
/**
|
|
49
|
+
* A2A task state store (task metadata only — conversation history is
|
|
50
|
+
* dsh-storage's ai_messages, not this). 'memory' loses tasks on restart.
|
|
51
|
+
*/
|
|
52
|
+
taskStore: Schema.union([
|
|
53
|
+
Schema.const('memory'),
|
|
54
|
+
Schema.const('redis'),
|
|
55
|
+
Schema.const('gcs'),
|
|
56
|
+
]).default('memory'),
|
|
57
|
+
redis: Schema.object({
|
|
58
|
+
url: Schema.string().role('secret').default('redis://127.0.0.1:6379'),
|
|
59
|
+
keyPrefix: Schema.string().default('a2a'),
|
|
60
|
+
ttlSeconds: Schema.natural().default(86400),
|
|
61
|
+
}),
|
|
62
|
+
gcs: Schema.object({
|
|
63
|
+
bucket: Schema.string().default(''),
|
|
64
|
+
prefix: Schema.string().default('tasks'),
|
|
65
|
+
keyFilename: Schema.string().default(''),
|
|
66
|
+
}),
|
|
67
|
+
});
|
|
68
|
+
export function apply(ctx, config) {
|
|
69
|
+
if (!config.enabled)
|
|
70
|
+
return;
|
|
71
|
+
// This cordis fork has no ready/dispose events: startup runs inside
|
|
72
|
+
// ctx.effect() (executed at fiber load) and its returned disposer runs on
|
|
73
|
+
// fiber unload. apply() returns the startup promise so fiber readiness
|
|
74
|
+
// covers the bind.
|
|
75
|
+
let ready;
|
|
76
|
+
let serverPort = 0;
|
|
77
|
+
ctx.effect(() => {
|
|
78
|
+
const startup = start();
|
|
79
|
+
ready = startup.then(() => ({ port: serverPort }));
|
|
80
|
+
return startup;
|
|
81
|
+
});
|
|
82
|
+
return ready;
|
|
83
|
+
async function start() {
|
|
84
|
+
const bridge = new A2aBridge(ctx, {
|
|
85
|
+
cwd: config.cwd,
|
|
86
|
+
agentOptions: {
|
|
87
|
+
provider: config.agent.provider || undefined,
|
|
88
|
+
model: config.agent.model || undefined,
|
|
89
|
+
},
|
|
90
|
+
preset: config.agent.preset || undefined,
|
|
91
|
+
});
|
|
92
|
+
const store = createTaskStore(config);
|
|
93
|
+
await store.init?.();
|
|
94
|
+
const executor = new DshAgentExecutor(bridge);
|
|
95
|
+
const server = await startA2aServer({
|
|
96
|
+
host: config.host,
|
|
97
|
+
port: config.port,
|
|
98
|
+
basePath: config.basePath,
|
|
99
|
+
card: {
|
|
100
|
+
name: config.card.name,
|
|
101
|
+
description: config.card.description,
|
|
102
|
+
version: config.card.version,
|
|
103
|
+
publicUrl: config.card.publicUrl || undefined,
|
|
104
|
+
},
|
|
105
|
+
executor,
|
|
106
|
+
taskStore: store,
|
|
107
|
+
});
|
|
108
|
+
serverPort = server.port;
|
|
109
|
+
console.log(`[dsh-a2a] A2A endpoint: http://${config.host}:${server.port}${config.basePath}/ ` +
|
|
110
|
+
`(agent card: /.well-known/agent-card.json)`);
|
|
111
|
+
return async () => {
|
|
112
|
+
await server.close();
|
|
113
|
+
await bridge.dispose();
|
|
114
|
+
await store.close?.();
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
function createTaskStore(config) {
|
|
119
|
+
switch (config.taskStore) {
|
|
120
|
+
case 'redis':
|
|
121
|
+
return new SanitizedTaskStore(new RedisTaskStore(config.redis));
|
|
122
|
+
case 'gcs':
|
|
123
|
+
if (!config.gcs.bucket) {
|
|
124
|
+
throw new Error("[dsh-a2a] taskStore 'gcs' requires gcs.bucket");
|
|
125
|
+
}
|
|
126
|
+
return new SanitizedTaskStore(new GcsTaskStore(config.gcs));
|
|
127
|
+
default:
|
|
128
|
+
return new SanitizedTaskStore(new InMemoryTaskStore());
|
|
129
|
+
}
|
|
130
|
+
}
|
package/lib/server.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP layer: the @a2a-js/sdk Express middlewares on a plain Express app.
|
|
3
|
+
*
|
|
4
|
+
* - `GET /.well-known/agent-card.json` (and the legacy `agent.json` alias) —
|
|
5
|
+
* agent card, via the SDK's `agentCardHandler`
|
|
6
|
+
* - `POST <basePath>/` — JSON-RPC: A2A 1.0 methods (`SendMessage`,
|
|
7
|
+
* `SendStreamingMessage`, `GetTask`, `ListTasks`, `CancelTask`,
|
|
8
|
+
* `SubscribeToTask`, ...) plus the v0.3 spellings (`message/send`, ...)
|
|
9
|
+
* through the SDK's opt-in legacyCompat layer
|
|
10
|
+
*
|
|
11
|
+
* The SSE framing and error envelopes are the SDK's; this layer only binds
|
|
12
|
+
* the server and builds the card.
|
|
13
|
+
*/
|
|
14
|
+
import { createServer } from 'node:http';
|
|
15
|
+
import { A2A_PROTOCOL_VERSION, AGENT_CARD_PATH, } from '@a2a-js/sdk';
|
|
16
|
+
import { duplicateInterfacesForLegacy } from '@a2a-js/sdk/compat/v0_3';
|
|
17
|
+
import { DefaultRequestHandler } from '@a2a-js/sdk/server';
|
|
18
|
+
import { agentCardHandler, jsonRpcHandler, UserBuilder } from '@a2a-js/sdk/server/express';
|
|
19
|
+
import express from 'express';
|
|
20
|
+
export async function startA2aServer(options) {
|
|
21
|
+
const base = options.basePath.replace(/\/$/, '');
|
|
22
|
+
const publicUrl = (options.card.publicUrl ?? `http://${options.host}:${options.port}`).replace(/\/$/, '');
|
|
23
|
+
const interfaces = [
|
|
24
|
+
{
|
|
25
|
+
url: `${publicUrl}${base}/`,
|
|
26
|
+
protocolBinding: 'JSONRPC',
|
|
27
|
+
tenant: '',
|
|
28
|
+
protocolVersion: A2A_PROTOCOL_VERSION,
|
|
29
|
+
},
|
|
30
|
+
];
|
|
31
|
+
const card = {
|
|
32
|
+
name: options.card.name,
|
|
33
|
+
description: options.card.description,
|
|
34
|
+
version: options.card.version,
|
|
35
|
+
// The v0.3 mirror entries let pre-1.0 clients keep working through the
|
|
36
|
+
// legacyCompat layer (they discover the card without an A2A-Version header).
|
|
37
|
+
supportedInterfaces: duplicateInterfacesForLegacy(interfaces, ['JSONRPC']),
|
|
38
|
+
provider: undefined,
|
|
39
|
+
capabilities: {
|
|
40
|
+
streaming: true,
|
|
41
|
+
pushNotifications: false,
|
|
42
|
+
extensions: [],
|
|
43
|
+
extendedAgentCard: false,
|
|
44
|
+
},
|
|
45
|
+
securitySchemes: {},
|
|
46
|
+
securityRequirements: [],
|
|
47
|
+
defaultInputModes: ['text'],
|
|
48
|
+
defaultOutputModes: ['text'],
|
|
49
|
+
skills: [],
|
|
50
|
+
signatures: [],
|
|
51
|
+
};
|
|
52
|
+
const requestHandler = new DefaultRequestHandler(card, options.taskStore, options.executor);
|
|
53
|
+
const app = express();
|
|
54
|
+
// The SDK's jsonRpcHandler parses bodies with express.json()'s 100kb default;
|
|
55
|
+
// raise the ceiling here — body-parser skips re-parsing an already-read body.
|
|
56
|
+
app.use(express.json({ limit: '16mb' }));
|
|
57
|
+
const cardHandler = agentCardHandler({
|
|
58
|
+
agentCardProvider: requestHandler,
|
|
59
|
+
legacyCompat: { enabled: true },
|
|
60
|
+
});
|
|
61
|
+
app.use(`/${AGENT_CARD_PATH}`, cardHandler);
|
|
62
|
+
// Pre-1.0 discovery path, kept as a convenience alias.
|
|
63
|
+
app.use('/.well-known/agent.json', cardHandler);
|
|
64
|
+
// dsh ships no authn/authz — the loopback default binding is the boundary.
|
|
65
|
+
app.use(base, jsonRpcHandler({
|
|
66
|
+
requestHandler,
|
|
67
|
+
userBuilder: UserBuilder.noAuthentication,
|
|
68
|
+
legacyCompat: { enabled: true },
|
|
69
|
+
}));
|
|
70
|
+
const server = createServer(app);
|
|
71
|
+
await new Promise((resolve, reject) => {
|
|
72
|
+
server.once('error', reject);
|
|
73
|
+
server.listen(options.port, options.host, () => resolve());
|
|
74
|
+
});
|
|
75
|
+
return {
|
|
76
|
+
port: server.address().port,
|
|
77
|
+
close: () => new Promise((resolve) => {
|
|
78
|
+
server.closeAllConnections?.();
|
|
79
|
+
server.close(() => resolve());
|
|
80
|
+
}),
|
|
81
|
+
};
|
|
82
|
+
}
|