@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 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
+ ![dsh-a2a preview](preview.png)
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
@@ -0,0 +1,3 @@
1
+ - insert:
2
+ - id: a2a
3
+ name: '@amaster.ai/dsh-a2a'
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
+ }
@@ -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
+ }