@amaster.ai/dsh-a2a 0.1.5 → 0.1.7

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/README.md CHANGED
@@ -26,6 +26,7 @@ Disabled by default. Configure via the profile's `cordis.patch.yml`:
26
26
  port: 41241
27
27
  basePath: /a2a
28
28
  cwd: /srv/agent-workspaces
29
+ uploadsDir: '' # file-part upload root; empty = <OS temp>/dsh-a2a-uploads/<date>
29
30
  agent:
30
31
  provider: '' # dsh provider/model for A2A sessions; empty = profile default
31
32
  model: ''
@@ -49,17 +50,19 @@ Disabled by default. Configure via the profile's `cordis.patch.yml`:
49
50
  ## Endpoints
50
51
 
51
52
  - `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
+ - `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. Legacy `tasks/get` also accepts `contextId` without `id` on this path or root `/`, returning the latest stored task or `result: null` when absent.
53
54
 
54
55
  ## Behavior notes
55
56
 
56
57
  - **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
58
  - **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
59
  - **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
+ - **Message parts beyond text.** File parts (inline bytes or a `url` — the plugin downloads those over http/https, bounded to 64 MiB and 30 s) use the composed attachment store (`ctx.attachments`, e.g. `@deepseek-ai/dsh-attachment-local`) unless a non-image materializer is configured: images reach vision-capable models natively, and stored files project to deterministic handle text with a read-only path when dsh can map one into the execution world. Without a store or materializer, files persist under the configured `uploadsDir` (default `<OS temp>/dsh-a2a-uploads/<date>/`, names sanitized cross-platform, collisions suffixed) and the prompt references them by absolute path inside a `<document>` tag. Ensure that directory is readable by the agent's tools. `data` parts become `<data>` JSON text. A turn never fails because a part kind is unsupported.
61
+ - **Remote execution files.** A deployment can register `ctx.provide('a2aFileMaterializer', { materializeFile })`, using the exported `A2aFileMaterializer` type. The callback receives `{ contextId, bytes, filename, mediaType }` (with a sanitized basename) and returns `{ readablePath }`, a path readable by that context's file/shell tools. When configured, non-image FileParts use the materializer instead of `ctx.attachments.saveFile()`; the model receives the returned `<document path="...">`. Images keep the existing attachment handling. A failed callback yields a delivery note, never a host-path fallback. The deployment owns sandbox selection, upload, and lifetime; no E2B SDK is required by this plugin.
60
62
  - **No approval bridge**: dsh ships the mid-turn approval seam only as the optional `dsh-user-approval` package, which headless profiles do not compose, 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
63
  - **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).
64
+ - **Restart**: persisted task shells survive in Redis/GCS. With `sessionPersistence` composed, a `contextId` already present on disk resumes its dsh session after restart; a new id creates a new session.
65
+ - **Clear**: the same-process gateway can call `ctx.get('a2aTasks').clearContext(contextId)` before reporting `messages/clear` success. It cancels and drains the live turn, removes the binding and every TaskStore shell for that context, and returns the removed task IDs. The gateway owns the separate session-surface replacement and client-visible history-ID update.
63
66
 
64
67
  ## Task stores
65
68
 
@@ -71,11 +74,11 @@ A2A **task state** (status + metadata) is separate from conversation history —
71
74
 
72
75
  ## Security
73
76
 
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.
77
+ 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. File parts carrying a `url` are fetched server-side (http/https only, bounded) — another reason to keep the endpoint off untrusted networks.
75
78
 
76
79
  ## Compatibility
77
80
 
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.6-alpha.2`) — check the `TODO(verify)` markers in `src/` before upgrading dsh.
81
+ 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,attachment}@0.1.6-alpha.2` — the attachment store is an optional peer) — check the `TODO(verify)` markers in `src/` before upgrading dsh.
79
82
 
80
83
  ## License
81
84
 
package/lib/bridge.js CHANGED
@@ -23,12 +23,16 @@ import { installModelSelection } from '@deepseek-ai/dsh-agent';
23
23
  // modules import packages that are not on npm yet (dsh-timeout, dsh-scope).
24
24
  import { createUserMessage } from '@deepseek-ai/dsh-llm/message';
25
25
  import { SessionId } from '@deepseek-ai/dsh-session/types';
26
+ import { buildMessageContent, uploadsDir } from './content.js';
26
27
  import { SessionTranslator, terminalStatusUpdate } from './translator.js';
27
28
  export class A2aBridge {
28
29
  ctx;
29
30
  options;
30
31
  tasks = new Map(); // taskId → entry
31
32
  bySession = new Map(); // sessionId (== contextId) → entry
33
+ clearing = new Set();
34
+ creating = new Map();
35
+ executing = new Map();
32
36
  constructor(ctx, options) {
33
37
  this.ctx = ctx;
34
38
  this.options = options;
@@ -44,6 +48,25 @@ export class A2aBridge {
44
48
  * a new task id for `message/send` calls that omit `taskId`.
45
49
  */
46
50
  async ensureTask(taskId, contextId) {
51
+ const creation = this.ensureTaskInner(taskId, contextId);
52
+ let pending = this.creating.get(contextId);
53
+ if (!pending) {
54
+ pending = new Set();
55
+ this.creating.set(contextId, pending);
56
+ }
57
+ pending.add(creation);
58
+ try {
59
+ return await creation;
60
+ }
61
+ finally {
62
+ pending.delete(creation);
63
+ if (pending.size === 0)
64
+ this.creating.delete(contextId);
65
+ }
66
+ }
67
+ async ensureTaskInner(taskId, contextId) {
68
+ if (this.clearing.has(contextId))
69
+ throw new Error('Context is being cleared.');
47
70
  const existing = this.tasks.get(taskId);
48
71
  if (existing)
49
72
  return { entry: existing, freshTask: false };
@@ -52,12 +75,12 @@ export class A2aBridge {
52
75
  this.tasks.delete(forContext.taskId);
53
76
  forContext.taskId = taskId;
54
77
  forContext.translator = new SessionTranslator(taskId, contextId, this.options.agentOptions?.model);
78
+ // A turn still open from the previous binding (e.g. a cancel whose abort
79
+ // is still propagating) must not emit events under the new task id.
80
+ forContext.staleTurn = forContext.turnActive;
55
81
  this.tasks.set(taskId, forContext);
56
82
  return { entry: forContext, freshTask: true };
57
83
  }
58
- // TODO(verify): resuming a persisted session (agents.resume) after a
59
- // restart — needs dsh's sessionPersistence service composed. For now a
60
- // client-supplied contextId simply names the fresh session.
61
84
  const sessionId = SessionId(contextId);
62
85
  const selection = this.resolveSelection();
63
86
  // The web profile disables the host-plane tool plugins — agents get tools
@@ -70,32 +93,40 @@ export class A2aBridge {
70
93
  if (presets) {
71
94
  agentPreset = (await presets.resolve(this.options.preset || undefined)).id;
72
95
  }
73
- const handle = await this.ctx.agents.create({
74
- sessionId,
75
- meta: { cwd: this.options.cwd, ...(agentPreset ? { agentPreset } : {}) },
76
- agentOptions: selection
77
- ? { provider: selection.provider, model: selection.model }
78
- : undefined,
79
- // The loop's `{{model}}` prompt variable and request routing resolve from
80
- // the agent's installed model selection (dsh-agent-loop's variables read
81
- // agent.options; the scoped waterfalls wire provider/model into prompt
82
- // assembly and the request config). Verified against
83
- // @deepseek-ai/dsh-headless@0.1.6-alpha.2's run() — entry points are
84
- // expected to resolve the deployment default themselves.
85
- setup: selection || agentPreset
86
- ? async (agentCtx) => {
87
- if (selection) {
88
- // Statement, deliberately not returned: a returned disposer would
89
- // be invoked as the setup commit and immediately unwired. Likewise
90
- // the mount result must not escape — setup's return value is
91
- // commit()-shaped to the factory.
92
- installModelSelection(agentCtx, { current: selection, assembled: undefined });
93
- }
94
- if (agentPreset)
95
- await presets.mount(agentCtx, agentPreset);
96
+ const agentOptions = selection
97
+ ? { provider: selection.provider, model: selection.model }
98
+ : undefined;
99
+ // The loop's `{{model}}` prompt variable and request routing resolve from
100
+ // the agent's installed model selection (dsh-agent-loop's variables read
101
+ // agent.options; the scoped waterfalls wire provider/model into prompt
102
+ // assembly and the request config). Verified against
103
+ // @deepseek-ai/dsh-headless@0.1.6-alpha.2's run() — entry points are
104
+ // expected to resolve the deployment default themselves.
105
+ const setup = selection || agentPreset
106
+ ? async (agentCtx) => {
107
+ if (selection) {
108
+ // Statement, deliberately not returned: a returned disposer would
109
+ // be invoked as the setup commit and immediately unwired. Likewise
110
+ // the mount result must not escape — setup's return value is
111
+ // commit()-shaped to the factory.
112
+ installModelSelection(agentCtx, { current: selection, assembled: undefined });
96
113
  }
97
- : undefined,
98
- });
114
+ if (agentPreset)
115
+ await presets.mount(agentCtx, agentPreset);
116
+ }
117
+ : undefined;
118
+ // Persistence is optional in minimal compositions. A stat failure is not
119
+ // absence: let it fail instead of trying create() against an existing id.
120
+ const persistence = this.ctx.get('sessionPersistence');
121
+ const persisted = await persistence?.stat(sessionId);
122
+ const handle = persisted
123
+ ? await this.ctx.agents.resume({ resumeSessionId: sessionId, agentOptions, setup })
124
+ : await this.ctx.agents.create({
125
+ sessionId,
126
+ meta: { cwd: this.options.cwd, ...(agentPreset ? { agentPreset } : {}) },
127
+ agentOptions,
128
+ setup,
129
+ });
99
130
  const entry = {
100
131
  taskId,
101
132
  sessionId,
@@ -103,14 +134,55 @@ export class A2aBridge {
103
134
  translator: new SessionTranslator(taskId, contextId, this.options.agentOptions?.model),
104
135
  bus: null,
105
136
  turnActive: false,
137
+ staleTurn: false,
106
138
  settled: [],
139
+ running: new Set(),
107
140
  };
108
141
  this.tasks.set(taskId, entry);
109
142
  this.bySession.set(contextId, entry);
110
143
  return { entry, freshTask: true };
111
144
  }
145
+ /** A2A parts → dsh content blocks; throws only when no part is usable. */
146
+ async buildContent(message, contextId) {
147
+ const attachments = this.ctx.get('attachments');
148
+ const materializer = this.ctx.get('a2aFileMaterializer');
149
+ return buildMessageContent(message.parts, attachments, uploadsDir(this.options.uploadsDir), materializer ? { contextId, materializer } : undefined);
150
+ }
151
+ isClearing(contextId) {
152
+ return this.clearing.has(contextId);
153
+ }
154
+ /** Include content preparation in the work a context clear must drain. */
155
+ async trackExecution(contextId, run) {
156
+ const execution = run();
157
+ let pending = this.executing.get(contextId);
158
+ if (!pending) {
159
+ pending = new Set();
160
+ this.executing.set(contextId, pending);
161
+ }
162
+ pending.add(execution);
163
+ try {
164
+ await execution;
165
+ }
166
+ finally {
167
+ pending.delete(execution);
168
+ if (pending.size === 0)
169
+ this.executing.delete(contextId);
170
+ }
171
+ }
112
172
  /** Queue one user-message turn on the task's agent and await its `turn/end`. */
113
- async runTurn(entry, text, bus) {
173
+ async runTurn(entry, content, bus) {
174
+ if (this.clearing.has(entry.sessionId))
175
+ throw new Error('Context is being cleared.');
176
+ const running = this.runTurnInner(entry, content, bus);
177
+ entry.running.add(running);
178
+ try {
179
+ await running;
180
+ }
181
+ finally {
182
+ entry.running.delete(running);
183
+ }
184
+ }
185
+ async runTurnInner(entry, content, bus) {
114
186
  let resolveSettled;
115
187
  const settled = new Promise((resolve) => {
116
188
  resolveSettled = resolve;
@@ -119,7 +191,7 @@ export class A2aBridge {
119
191
  entry.bus = bus;
120
192
  try {
121
193
  const result = entry.handle.agent.followup(createUserMessage({
122
- content: [{ type: 'text', text }],
194
+ content,
123
195
  source: { kind: 'user' },
124
196
  }));
125
197
  // followup() is typed void; guard a mistyped async impl anyway — an
@@ -150,6 +222,32 @@ export class A2aBridge {
150
222
  entry.bus = null;
151
223
  }
152
224
  }
225
+ /** Cancel and release the live binding before the caller deletes stored shells. */
226
+ async clearContext(contextId, clearStored = async (ids) => ids) {
227
+ if (this.clearing.has(contextId))
228
+ throw new Error('Context is already being cleared.');
229
+ this.clearing.add(contextId);
230
+ try {
231
+ await Promise.allSettled([...(this.creating.get(contextId) ?? [])]);
232
+ const entry = this.bySession.get(contextId);
233
+ if (entry) {
234
+ if (entry.turnActive || entry.running.size > 0) {
235
+ await entry.handle.agent.cancel({ kind: 'user' });
236
+ }
237
+ await entry.handle.agent.whenIdle();
238
+ this.settleEntry(entry, 'Context cleared.');
239
+ await Promise.allSettled([...entry.running]);
240
+ this.tasks.delete(entry.taskId);
241
+ this.bySession.delete(contextId);
242
+ await entry.handle.dispose();
243
+ }
244
+ await Promise.allSettled([...(this.executing.get(contextId) ?? [])]);
245
+ return await clearStored(entry ? [entry.taskId] : []);
246
+ }
247
+ finally {
248
+ this.clearing.delete(contextId);
249
+ }
250
+ }
153
251
  /** Abort the task's active turn; returns the contextId when the task is live. */
154
252
  cancel(taskId) {
155
253
  const entry = this.tasks.get(taskId);
@@ -181,12 +279,20 @@ export class A2aBridge {
181
279
  return;
182
280
  if (event.type === 'turn/start')
183
281
  entry.turnActive = true;
282
+ // A turn carried over from a previous task binding (e.g. a canceled turn
283
+ // whose abort was still in flight when the context rebound): its turn/end
284
+ // belongs to the old task — settle waiters, but publish nothing under the
285
+ // new task id.
286
+ const stale = event.type === 'turn/end' && entry.staleTurn;
287
+ if (stale)
288
+ entry.staleTurn = false;
184
289
  // No-throw seam: translation errors must never reach the agent loop. The
185
290
  // turn/end waiter still resolves below, so a poisoned event cannot hang
186
291
  // an in-flight execute() either.
187
292
  try {
188
- for (const out of entry.translator.handle(event))
189
- entry.bus?.publish(out);
293
+ if (!stale)
294
+ for (const out of entry.translator.handle(event))
295
+ entry.bus?.publish(out);
190
296
  }
191
297
  catch (error) {
192
298
  console.error('[dsh-a2a] failed to translate session event:', error);
package/lib/content.js ADDED
@@ -0,0 +1,244 @@
1
+ /**
2
+ * Maps A2A message parts onto dsh content blocks. A turn never fails over
3
+ * part kinds:
4
+ *
5
+ * - `text` parts stay text;
6
+ * - file parts (inline `raw` bytes or a `url`) use the composed attachment
7
+ * store unless a non-image materializer is configured; url parts are
8
+ * downloaded by the plugin (http/https only, size- and time-bounded);
9
+ * - an optional deployment materializer puts non-image files into the
10
+ * active execution world instead of saving a host attachment;
11
+ * - without a materializer, files with no store (or refused by a store)
12
+ * persist under the configured upload root (default `<OS temp>/
13
+ * dsh-a2a-uploads/<date>/`) and the prompt references them by path;
14
+ * - `data` parts become JSON text.
15
+ *
16
+ * The SDK's admission helpers (admitEncodedFile/admitPromptContent) are not
17
+ * used: they take base64 wire uploads and throw on refusal — this path holds
18
+ * raw bytes and must degrade, never fail.
19
+ */
20
+ import { mkdir, writeFile } from 'node:fs/promises';
21
+ import os from 'node:os';
22
+ import path from 'node:path';
23
+ /** Cap on one url part download. */
24
+ const MAX_DOWNLOAD_BYTES = 64 * 1024 * 1024;
25
+ const DOWNLOAD_TIMEOUT_MS = 30_000;
26
+ // TODO(verify): the confirmation data-part protocol ({callId, outcome}) maps
27
+ // onto the optional dsh-user-approval service (0.1.2+, asks carry the exact
28
+ // tool call) — bridge it once a deployment composes that service.
29
+ export async function buildMessageContent(parts, attachments, dir, materialization) {
30
+ const content = [];
31
+ for (const part of parts) {
32
+ const c = part.content;
33
+ if (!c)
34
+ continue;
35
+ switch (c.$case) {
36
+ case 'text':
37
+ if (c.value.trim())
38
+ content.push({ type: 'text', text: c.value });
39
+ break;
40
+ case 'data':
41
+ content.push({ type: 'text', text: dataText(c.value) });
42
+ break;
43
+ case 'raw':
44
+ case 'url':
45
+ content.push(await fileContent(c, part, attachments, dir, materialization));
46
+ break;
47
+ default:
48
+ // A part kind this SDK version predates: keep it visible, never drop.
49
+ content.push(note(part, part.mediaType, undefined, `unsupported part kind "${c.$case}"`));
50
+ }
51
+ }
52
+ if (content.length === 0) {
53
+ throw new Error('dsh-a2a: message must contain at least one usable part');
54
+ }
55
+ return content;
56
+ }
57
+ /** The directory file parts persist into when they are not attachments. */
58
+ export function uploadsDir(root) {
59
+ // 'sv-SE' renders YYYY-MM-DD; the bucket is local time.
60
+ const segment = new Date().toLocaleDateString('sv-SE').replaceAll('-', '');
61
+ return path.join(root || path.join(os.tmpdir(), 'dsh-a2a-uploads'), segment);
62
+ }
63
+ /** Log and degrade one file-part failure to a metadata note. */
64
+ function failNote(part, mediaType, uri, label, error) {
65
+ console.error(`[dsh-a2a] ${label}:`, error);
66
+ return note(part, mediaType, uri, error instanceof Error ? error.message : String(error));
67
+ }
68
+ /** Resolve one file part to an attachment block or a workspace file. */
69
+ async function fileContent(c, part, attachments, dir, materialization) {
70
+ let bytes;
71
+ let mediaType = part.mediaType;
72
+ let uri;
73
+ try {
74
+ if (c.$case === 'url') {
75
+ uri = c.value;
76
+ const got = await download(uri);
77
+ bytes = got.bytes;
78
+ mediaType ||= got.mediaType ?? '';
79
+ }
80
+ else {
81
+ bytes = c.value;
82
+ }
83
+ }
84
+ catch (error) {
85
+ return failNote(part, mediaType, uri, 'failed to read a file part', error);
86
+ }
87
+ const image = attachments?.imageLimits.mediaTypes.includes(mediaType.toLowerCase());
88
+ if (attachments && (!materialization || image)) {
89
+ const block = await saveAttachment(attachments, bytes, mediaType, part.filename);
90
+ if (block)
91
+ return block;
92
+ }
93
+ if (materialization && !image) {
94
+ try {
95
+ const { readablePath } = await materialization.materializer.materializeFile({
96
+ contextId: materialization.contextId,
97
+ bytes,
98
+ filename: sanitizeUploadFileName(part.filename),
99
+ mediaType,
100
+ });
101
+ if (!readablePath)
102
+ throw new Error('materializer returned no readable path');
103
+ return documentBlock(uri, mediaType, bytes.byteLength, readablePath);
104
+ }
105
+ catch (error) {
106
+ console.error('[dsh-a2a] failed to materialize a file part:', error);
107
+ return note(part, mediaType, uri, 'file materialization failed');
108
+ }
109
+ }
110
+ try {
111
+ const name = sanitizeUploadFileName(part.filename);
112
+ const filePath = await persistUpload(dir, name, bytes);
113
+ return documentBlock(uri, mediaType, bytes.byteLength, filePath);
114
+ }
115
+ catch (error) {
116
+ return failNote(part, mediaType, uri, 'failed to persist a file part', error);
117
+ }
118
+ }
119
+ /** Commit part bytes to the store: image path first, verbatim file on refusal. */
120
+ async function saveAttachment(attachments, bytes, mediaType, filename) {
121
+ const name = filename || undefined;
122
+ if (attachments.imageLimits.mediaTypes.includes(mediaType.toLowerCase())) {
123
+ try {
124
+ const attachment = await attachments.saveImage({
125
+ data: bytes,
126
+ mediaType: mediaType,
127
+ name,
128
+ });
129
+ return { type: 'image', attachment };
130
+ }
131
+ catch (error) {
132
+ console.error('[dsh-a2a] image admission failed; storing the bytes as a file:', error);
133
+ }
134
+ }
135
+ try {
136
+ const attachment = await attachments.saveFile({ data: bytes, name });
137
+ return { type: 'file', attachment };
138
+ }
139
+ catch (error) {
140
+ console.error('[dsh-a2a] failed to store an attachment:', error);
141
+ return undefined;
142
+ }
143
+ }
144
+ /** Save one upload under the upload dir; returns the absolute path. */
145
+ async function persistUpload(dir, name, bytes) {
146
+ await mkdir(dir, { recursive: true });
147
+ const { name: stem, ext } = path.parse(name);
148
+ for (let attempt = 0;; attempt++) {
149
+ const candidate = path.join(dir, attempt === 0 ? name : `${stem}-${attempt}${ext}`);
150
+ try {
151
+ await writeFile(candidate, bytes, { flag: 'wx' });
152
+ return candidate;
153
+ }
154
+ catch (error) {
155
+ if (error.code !== 'EEXIST')
156
+ throw error;
157
+ }
158
+ }
159
+ }
160
+ // Windows refuses these device stems regardless of extension.
161
+ const WINDOWS_RESERVED_STEMS = new Set('CON PRN AUX NUL COM1 COM2 COM3 COM4 COM5 COM6 COM7 COM8 COM9 LPT1 LPT2 LPT3 LPT4 LPT5 LPT6 LPT7 LPT8 LPT9'.split(' '));
162
+ /**
163
+ * Cross-platform basename with control and Windows-illegal characters
164
+ * stripped; the trailing-dot/space strip also collapses '.' and '..'.
165
+ */
166
+ function sanitizeUploadFileName(name) {
167
+ const cleaned = path.win32
168
+ .basename(name)
169
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: stripping control chars is the point
170
+ .replace(/[<>:"|?*\x00-\x1f\x7f]/g, '')
171
+ .trim()
172
+ .replace(/[. ]+$/, '');
173
+ const stem = cleaned.split('.')[0].toUpperCase();
174
+ return !cleaned || WINDOWS_RESERVED_STEMS.has(stem) ? 'unnamed-file' : cleaned;
175
+ }
176
+ /** Tool-readable path stand-in for a file part. */
177
+ function documentBlock(uri, mediaType, bytes, filePath) {
178
+ const attrs = [
179
+ // The basename of the final path, so name= always matches the file on
180
+ // disk — including collision suffixes.
181
+ `name="${attr(path.win32.basename(filePath))}"`,
182
+ uri && `uri="${attr(uri)}"`,
183
+ mediaType && `type="${attr(mediaType)}"`,
184
+ `size="${bytes}"`,
185
+ `path="${attr(filePath)}"`,
186
+ ]
187
+ .filter(Boolean)
188
+ .join(' ');
189
+ return {
190
+ type: 'text',
191
+ text: `<document ${attrs}>\nThe file is saved at the path above; read or process it with your tools.\n</document>`,
192
+ };
193
+ }
194
+ function note(part, mediaType, uri, reason) {
195
+ const desc = [
196
+ part.filename && `"${part.filename}"`,
197
+ mediaType && `(${mediaType})`,
198
+ uri && `at ${uri}`,
199
+ ]
200
+ .filter(Boolean)
201
+ .join(' ');
202
+ return { type: 'text', text: `[attachment ${desc || 'file'} not delivered: ${reason}]` };
203
+ }
204
+ /** JSON text stand-in for a structured data part. */
205
+ function dataText(value) {
206
+ try {
207
+ return `<data>\n${JSON.stringify(value, null, 2) ?? String(value)}\n</data>`;
208
+ }
209
+ catch {
210
+ return `<data>\n${String(value)}\n</data>`;
211
+ }
212
+ }
213
+ /** Strip characters that would break attribute position. */
214
+ function attr(value) {
215
+ return value.replace(/["\r\n<>]/g, ' ');
216
+ }
217
+ /** Fetch one url part's bytes over http(s), bounded in size and time. */
218
+ async function download(uri) {
219
+ const scheme = new URL(uri).protocol;
220
+ if (scheme !== 'http:' && scheme !== 'https:') {
221
+ throw new Error(`unsupported scheme "${scheme}"`);
222
+ }
223
+ const response = await fetch(uri, { signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS) });
224
+ if (!response.ok)
225
+ throw new Error(`HTTP ${response.status}`);
226
+ const declared = Number(response.headers.get('content-length'));
227
+ if (declared > MAX_DOWNLOAD_BYTES) {
228
+ throw new Error(`exceeds the ${MAX_DOWNLOAD_BYTES}-byte download limit`);
229
+ }
230
+ const chunks = [];
231
+ let total = 0;
232
+ if (response.body) {
233
+ // Throwing out of the loop cancels the stream via the iterator's return.
234
+ for await (const chunk of response.body) {
235
+ total += chunk.byteLength;
236
+ if (total > MAX_DOWNLOAD_BYTES) {
237
+ throw new Error(`exceeds the ${MAX_DOWNLOAD_BYTES}-byte download limit`);
238
+ }
239
+ chunks.push(chunk);
240
+ }
241
+ }
242
+ const mediaType = response.headers.get('content-type')?.split(';')[0].trim() || undefined;
243
+ return { bytes: Buffer.concat(chunks, total), mediaType };
244
+ }
package/lib/executor.js CHANGED
@@ -2,7 +2,8 @@
2
2
  * The @a2a-js/sdk {@link AgentExecutor} that drives dsh agents through the
3
3
  * bridge. One `execute()` = one user-message turn:
4
4
  *
5
- * 1. extract the text prompt (v1 is text-only)
5
+ * 1. map the message's parts onto dsh content blocks (file parts become
6
+ * execution-world paths with a materializer, attachments or local files otherwise)
6
7
  * 2. ensure the task's session/agent exists (creating it on first contact)
7
8
  * 3. publish a `task` event first — A2A 1.0 stream ordering REQUIRES the
8
9
  * first event of every execute to be a task or message, including
@@ -21,14 +22,22 @@ import { AgentEvent } from '@a2a-js/sdk/server';
21
22
  import { agentTextMessage, terminalStatusUpdate } from './translator.js';
22
23
  export class DshAgentExecutor {
23
24
  bridge;
24
- constructor(bridge) {
25
+ suppressTask;
26
+ constructor(bridge, suppressTask) {
25
27
  this.bridge = bridge;
28
+ this.suppressTask = suppressTask;
26
29
  }
27
30
  async execute(requestContext, eventBus) {
31
+ if (this.bridge.isClearing(requestContext.contextId)) {
32
+ this.suppressTask?.(requestContext.taskId);
33
+ }
34
+ return this.bridge.trackExecution(requestContext.contextId, () => this.executeInner(requestContext, eventBus));
35
+ }
36
+ async executeInner(requestContext, eventBus) {
28
37
  const { userMessage, taskId, contextId } = requestContext;
29
38
  let anchored = false;
30
39
  try {
31
- const text = extractText(userMessage);
40
+ const content = await this.bridge.buildContent(userMessage, contextId);
32
41
  const { entry, freshTask } = await this.bridge.ensureTask(taskId, contextId);
33
42
  eventBus.publish(freshTask
34
43
  ? // First contact: the submitted anchor carries the user message
@@ -39,9 +48,11 @@ export class DshAgentExecutor {
39
48
  // stored task (history/artifacts preserved).
40
49
  taskEvent(taskId, contextId, TaskState.TASK_STATE_WORKING));
41
50
  anchored = true;
42
- await this.bridge.runTurn(entry, text, eventBus);
51
+ await this.bridge.runTurn(entry, content, eventBus);
43
52
  }
44
53
  catch (error) {
54
+ if (this.bridge.isClearing(contextId))
55
+ this.suppressTask?.(taskId);
45
56
  const message = error instanceof Error ? error.message : String(error);
46
57
  if (!anchored) {
47
58
  eventBus.publish(taskEvent(taskId, contextId, TaskState.TASK_STATE_FAILED, {
@@ -80,20 +91,3 @@ function taskEvent(taskId, contextId, state, extra = {}) {
80
91
  };
81
92
  return AgentEvent.task(task);
82
93
  }
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}) maps onto the optional dsh-user-approval
86
- // service (0.1.2+, asks carry the exact tool call) — bridge it once a
87
- // deployment composes that service.
88
- function extractText(message) {
89
- const nonText = message.parts.filter((part) => part.content?.$case !== 'text');
90
- if (nonText.length > 0) {
91
- throw new Error(`dsh-a2a: unsupported part kind(s): ${nonText.map((part) => part.content?.$case ?? 'unknown').join(', ')} (text-only)`);
92
- }
93
- const text = message.parts
94
- .map((part) => (part.content?.$case === 'text' ? part.content.value : ''))
95
- .join('');
96
- if (!text.trim())
97
- throw new Error('dsh-a2a: message must contain at least one text part');
98
- return text;
99
- }
package/lib/index.js CHANGED
@@ -11,14 +11,15 @@
11
11
  *
12
12
  * @module dsh-a2a
13
13
  */
14
- import { InMemoryTaskStore } from '@a2a-js/sdk/server';
14
+ import { TaskState } from '@a2a-js/sdk';
15
+ import { ServerCallContext } from '@a2a-js/sdk/server';
15
16
  import Schema from '@deepseek-ai/schemastery';
16
17
  import { A2aBridge } from './bridge.js';
17
18
  import { DshAgentExecutor } from './executor.js';
18
19
  import { startA2aServer } from './server.js';
19
20
  import { GcsTaskStore } from './stores/gcs.js';
20
21
  import { RedisTaskStore } from './stores/redis.js';
21
- import { SanitizedTaskStore } from './task-store.js';
22
+ import { MemoryTaskStore, SanitizedTaskStore } from './task-store.js';
22
23
  export const name = 'dsh-a2a';
23
24
  /** The bridge creates and owns agents through the registry service. */
24
25
  export const inject = ['agents'];
@@ -29,6 +30,11 @@ export const Config = Schema.object({
29
30
  basePath: Schema.string().default('/a2a'),
30
31
  /** Working directory for agents spawned by A2A tasks. Must be absolute. */
31
32
  cwd: Schema.string().default(process.cwd()),
33
+ /**
34
+ * Root directory file parts persist into when no attachment store is
35
+ * composed (date-layered below it). Empty = <OS temp>/dsh-a2a-uploads.
36
+ */
37
+ uploadsDir: Schema.string().default(''),
32
38
  agent: Schema.object({
33
39
  provider: Schema.string().default(''),
34
40
  model: Schema.string().default(''),
@@ -83,6 +89,7 @@ export function apply(ctx, config) {
83
89
  async function start() {
84
90
  const bridge = new A2aBridge(ctx, {
85
91
  cwd: config.cwd,
92
+ uploadsDir: config.uploadsDir || undefined,
86
93
  agentOptions: {
87
94
  provider: config.agent.provider || undefined,
88
95
  model: config.agent.model || undefined,
@@ -91,7 +98,32 @@ export function apply(ctx, config) {
91
98
  });
92
99
  const store = createTaskStore(config);
93
100
  await store.init?.();
94
- const executor = new DshAgentExecutor(bridge);
101
+ ctx.provide('a2aTasks', {
102
+ async clearContext(contextId, tenant = '') {
103
+ const call = new ServerCallContext({ tenant });
104
+ return bridge.clearContext(contextId, async (liveIds) => {
105
+ const ids = new Set(liveIds);
106
+ let pageToken = '';
107
+ do {
108
+ const page = await store.list({
109
+ tenant: '',
110
+ contextId,
111
+ pageSize: 100,
112
+ pageToken,
113
+ status: TaskState.TASK_STATE_UNSPECIFIED,
114
+ statusTimestampAfter: undefined,
115
+ }, call);
116
+ for (const task of page.tasks)
117
+ ids.add(task.id);
118
+ pageToken = page.nextPageToken;
119
+ } while (pageToken);
120
+ for (const id of ids)
121
+ await store.delete(id, call);
122
+ return [...ids];
123
+ });
124
+ },
125
+ });
126
+ const executor = new DshAgentExecutor(bridge, (taskId) => store.suppress(taskId));
95
127
  const server = await startA2aServer({
96
128
  host: config.host,
97
129
  port: config.port,
@@ -125,6 +157,6 @@ function createTaskStore(config) {
125
157
  }
126
158
  return new SanitizedTaskStore(new GcsTaskStore(config.gcs));
127
159
  default:
128
- return new SanitizedTaskStore(new InMemoryTaskStore());
160
+ return new SanitizedTaskStore(new MemoryTaskStore());
129
161
  }
130
162
  }
package/lib/server.js CHANGED
@@ -12,9 +12,9 @@
12
12
  * the server and builds the card.
13
13
  */
14
14
  import { createServer } from 'node:http';
15
- import { A2A_PROTOCOL_VERSION, AGENT_CARD_PATH, } from '@a2a-js/sdk';
15
+ import { A2A_PROTOCOL_VERSION, AGENT_CARD_PATH, TaskState, } from '@a2a-js/sdk';
16
16
  import { duplicateInterfacesForLegacy } from '@a2a-js/sdk/compat/v0_3';
17
- import { DefaultRequestHandler } from '@a2a-js/sdk/server';
17
+ import { DefaultRequestHandler, ServerCallContext, } from '@a2a-js/sdk/server';
18
18
  import { agentCardHandler, jsonRpcHandler, UserBuilder } from '@a2a-js/sdk/server/express';
19
19
  import express from 'express';
20
20
  export async function startA2aServer(options) {
@@ -61,12 +61,61 @@ export async function startA2aServer(options) {
61
61
  app.use(`/${AGENT_CARD_PATH}`, cardHandler);
62
62
  // Pre-1.0 discovery path, kept as a convenience alias.
63
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({
64
+ const contextLookup = async (req, res, next) => {
65
+ const body = req.body;
66
+ const params = body?.params;
67
+ if (body?.method !== 'tasks/get' ||
68
+ typeof params?.contextId !== 'string' ||
69
+ !params.contextId ||
70
+ params.id) {
71
+ next();
72
+ return;
73
+ }
74
+ try {
75
+ const contextId = params.contextId;
76
+ const tenant = typeof params.tenant === 'string' ? params.tenant : '';
77
+ const page = await options.taskStore.list({
78
+ tenant,
79
+ contextId,
80
+ pageSize: 1,
81
+ pageToken: '',
82
+ status: TaskState.TASK_STATE_UNSPECIFIED,
83
+ statusTimestampAfter: undefined,
84
+ }, new ServerCallContext({ tenant }));
85
+ const task = page.tasks[0];
86
+ if (!task) {
87
+ res.json({ jsonrpc: '2.0', id: body.id ?? null, result: null });
88
+ return;
89
+ }
90
+ const { contextId: _contextId, ...rest } = params;
91
+ req.body = { ...body, params: { ...rest, id: task.id } };
92
+ next(); // Let the SDK load and serialize the task on the v0.3 wire.
93
+ }
94
+ catch (error) {
95
+ console.error('[dsh-a2a] context task lookup failed:', error);
96
+ res.status(500).json({
97
+ jsonrpc: '2.0',
98
+ id: body.id ?? null,
99
+ error: { code: -32603, message: 'Task lookup failed' },
100
+ });
101
+ }
102
+ };
103
+ const rpcHandler = jsonRpcHandler({
66
104
  requestHandler,
67
105
  userBuilder: UserBuilder.noAuthentication,
68
106
  legacyCompat: { enabled: true },
69
- }));
107
+ });
108
+ // dsh ships no authn/authz — the loopback default binding is the boundary.
109
+ if (base) {
110
+ app.post('/', (req, res, next) => {
111
+ const body = req.body;
112
+ if (body?.method === 'tasks/get' && body.params?.contextId && !body.params.id)
113
+ next();
114
+ else
115
+ res.sendStatus(404);
116
+ }, contextLookup, rpcHandler);
117
+ }
118
+ app.use(base, contextLookup, rpcHandler);
70
119
  const server = createServer(app);
71
120
  await new Promise((resolve, reject) => {
72
121
  server.once('error', reject);
package/lib/stores/gcs.js CHANGED
@@ -49,6 +49,18 @@ export class GcsTaskStore {
49
49
  const [buf] = await file.download();
50
50
  return JSON.parse(gunzipSync(buf).toString('utf8'));
51
51
  }
52
+ async delete(taskId) {
53
+ if (!this.bucket)
54
+ return;
55
+ await Promise.all([
56
+ this.bucket
57
+ .file(`${this.prefix}/${taskId}/metadata.json.gz`)
58
+ .delete({ ignoreNotFound: true }),
59
+ this.bucket
60
+ .file(`${this.prefix}/${taskId}/workspace.tar.gz`)
61
+ .delete({ ignoreNotFound: true }),
62
+ ]);
63
+ }
52
64
  async list(params) {
53
65
  if (!this.bucket)
54
66
  return { tasks: [], nextPageToken: '', pageSize: 0, totalSize: 0 };
@@ -35,6 +35,9 @@ export class RedisTaskStore {
35
35
  const raw = await this.redis.get(this.taskKey(taskId));
36
36
  return raw ? JSON.parse(raw) : undefined;
37
37
  }
38
+ async delete(taskId) {
39
+ await this.redis?.del(this.taskKey(taskId));
40
+ }
38
41
  async list(params) {
39
42
  if (!this.redis)
40
43
  return { tasks: [], nextPageToken: '', pageSize: 0, totalSize: 0 };
package/lib/task-store.js CHANGED
@@ -16,6 +16,7 @@
16
16
  * A2A 1.0 added `list()` to the TaskStore contract; {@link listShells} holds
17
17
  * the shared filter/sort/paginate logic so the backends only enumerate shells.
18
18
  */
19
+ import { resolveUserScope } from '@a2a-js/sdk/server';
19
20
  /** The persistent shape: a metadata shell with empty history/artifacts. */
20
21
  export function sanitizeTask(task) {
21
22
  return {
@@ -27,10 +28,39 @@ export function sanitizeTask(task) {
27
28
  artifacts: [],
28
29
  };
29
30
  }
31
+ /** The SDK memory store has no delete operation; keep the same shell contract. */
32
+ export class MemoryTaskStore {
33
+ scopes = new Map();
34
+ bucket(context) {
35
+ const scope = JSON.stringify([context.tenant ?? '', resolveUserScope(context)]);
36
+ let tasks = this.scopes.get(scope);
37
+ if (!tasks) {
38
+ tasks = new Map();
39
+ this.scopes.set(scope, tasks);
40
+ }
41
+ return tasks;
42
+ }
43
+ async save(task, context) {
44
+ this.bucket(context).set(task.id, structuredClone(task));
45
+ }
46
+ async load(taskId, context) {
47
+ const task = this.bucket(context).get(taskId);
48
+ return task && structuredClone(task);
49
+ }
50
+ async list(params, context) {
51
+ return listShells([...this.bucket(context).values()], params);
52
+ }
53
+ async delete(taskId, context) {
54
+ this.bucket(context).delete(taskId);
55
+ }
56
+ }
30
57
  /** Strips history/artifacts and saves only on task-state transitions. */
31
58
  export class SanitizedTaskStore {
32
59
  inner;
33
60
  lastState = new Map();
61
+ writes = new Map();
62
+ // ponytail: deleted ids stay tombstoned until plugin unload; use expiring ids if clear volume grows.
63
+ deleted = new Set();
34
64
  constructor(inner) {
35
65
  this.inner = inner;
36
66
  }
@@ -40,13 +70,28 @@ export class SanitizedTaskStore {
40
70
  async close() {
41
71
  await this.inner.close?.();
42
72
  }
73
+ /** Drop SDK saves that may arrive after a clear rejects an in-flight request. */
74
+ suppress(taskId) {
75
+ this.deleted.add(taskId);
76
+ }
77
+ async delete(taskId, context) {
78
+ this.suppress(taskId);
79
+ await this.writes.get(taskId);
80
+ await this.inner.delete(taskId, context);
81
+ this.lastState.delete(taskId);
82
+ this.writes.delete(taskId);
83
+ }
43
84
  async save(task, context) {
85
+ if (this.deleted.has(task.id))
86
+ return;
44
87
  const state = task.status?.state;
45
88
  if (state !== undefined && this.lastState.get(task.id) === state)
46
89
  return;
47
90
  if (state !== undefined)
48
91
  this.lastState.set(task.id, state);
49
- await this.inner.save(sanitizeTask(task), context);
92
+ const write = (this.writes.get(task.id) ?? Promise.resolve()).then(() => this.inner.save(sanitizeTask(task), context));
93
+ this.writes.set(task.id, write.catch(() => { }));
94
+ await write;
50
95
  }
51
96
  load(taskId, context) {
52
97
  return this.inner.load(taskId, context);
@@ -15,14 +15,18 @@
15
15
  * turn order). The final status-update is published before the waiter
16
16
  * resolves, so an execute() that returns always landed its events first.
17
17
  */
18
+ import type { Message } from '@a2a-js/sdk';
18
19
  import type { ExecutionEventBus } from '@a2a-js/sdk/server';
19
20
  import type { Context } from '@deepseek-ai/cordis';
20
21
  import type { AgentHandle } from '@deepseek-ai/dsh-agent';
22
+ import type { ContentBlock } from '@deepseek-ai/dsh-llm/types';
21
23
  import { SessionId } from '@deepseek-ai/dsh-session/types';
22
24
  import { SessionTranslator } from './translator.js';
23
25
  export interface BridgeOptions {
24
26
  /** Absolute working directory for spawned agents. */
25
27
  cwd: string;
28
+ /** Upload root for persisted file parts; absent = the OS temp dir. */
29
+ uploadsDir?: string;
26
30
  /** Optional provider/model overrides for created agents. */
27
31
  agentOptions?: {
28
32
  provider?: string;
@@ -40,14 +44,21 @@ export interface TaskEntry {
40
44
  bus: ExecutionEventBus | null;
41
45
  /** Between turn/start and turn/end. */
42
46
  turnActive: boolean;
47
+ /** A turn still open from a previous task binding; its turn/end is stale. */
48
+ staleTurn: boolean;
43
49
  /** FIFO turn-end waiters, one per queued/running execute(). */
44
50
  settled: Array<() => void>;
51
+ /** Active execute() calls that must finish before task shells are deleted. */
52
+ running: Set<Promise<void>>;
45
53
  }
46
54
  export declare class A2aBridge {
47
55
  private readonly ctx;
48
56
  private readonly options;
49
57
  private readonly tasks;
50
58
  private readonly bySession;
59
+ private readonly clearing;
60
+ private readonly creating;
61
+ private readonly executing;
51
62
  constructor(ctx: Context, options: BridgeOptions);
52
63
  /**
53
64
  * Resolve the live task entry, creating the session/agent when needed.
@@ -60,8 +71,17 @@ export declare class A2aBridge {
60
71
  entry: TaskEntry;
61
72
  freshTask: boolean;
62
73
  }>;
74
+ private ensureTaskInner;
75
+ /** A2A parts → dsh content blocks; throws only when no part is usable. */
76
+ buildContent(message: Message, contextId: string): Promise<ContentBlock[]>;
77
+ isClearing(contextId: string): boolean;
78
+ /** Include content preparation in the work a context clear must drain. */
79
+ trackExecution(contextId: string, run: () => Promise<void>): Promise<void>;
63
80
  /** Queue one user-message turn on the task's agent and await its `turn/end`. */
64
- runTurn(entry: TaskEntry, text: string, bus: ExecutionEventBus): Promise<void>;
81
+ runTurn(entry: TaskEntry, content: ContentBlock[], bus: ExecutionEventBus): Promise<void>;
82
+ private runTurnInner;
83
+ /** Cancel and release the live binding before the caller deletes stored shells. */
84
+ clearContext(contextId: string, clearStored?: (liveIds: string[]) => Promise<string[]>): Promise<string[]>;
65
85
  /** Abort the task's active turn; returns the contextId when the task is live. */
66
86
  cancel(taskId: string): string | undefined;
67
87
  /** Dispose every owned agent (plugin unload / server shutdown). */
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Maps A2A message parts onto dsh content blocks. A turn never fails over
3
+ * part kinds:
4
+ *
5
+ * - `text` parts stay text;
6
+ * - file parts (inline `raw` bytes or a `url`) use the composed attachment
7
+ * store unless a non-image materializer is configured; url parts are
8
+ * downloaded by the plugin (http/https only, size- and time-bounded);
9
+ * - an optional deployment materializer puts non-image files into the
10
+ * active execution world instead of saving a host attachment;
11
+ * - without a materializer, files with no store (or refused by a store)
12
+ * persist under the configured upload root (default `<OS temp>/
13
+ * dsh-a2a-uploads/<date>/`) and the prompt references them by path;
14
+ * - `data` parts become JSON text.
15
+ *
16
+ * The SDK's admission helpers (admitEncodedFile/admitPromptContent) are not
17
+ * used: they take base64 wire uploads and throw on refusal — this path holds
18
+ * raw bytes and must degrade, never fail.
19
+ */
20
+ import type { Part } from '@a2a-js/sdk';
21
+ import type { AttachmentStore } from '@deepseek-ai/dsh-attachment';
22
+ import type { ContentBlock } from '@deepseek-ai/dsh-llm/types';
23
+ /** Deployment-owned writer for files read by an agent's active tools. */
24
+ export interface A2aFileMaterializer {
25
+ materializeFile(input: {
26
+ contextId: string;
27
+ bytes: Uint8Array;
28
+ filename: string;
29
+ mediaType: string;
30
+ }): Promise<{
31
+ readablePath: string;
32
+ }>;
33
+ }
34
+ interface Materialization {
35
+ contextId: string;
36
+ materializer: A2aFileMaterializer;
37
+ }
38
+ export declare function buildMessageContent(parts: readonly Part[], attachments: AttachmentStore | undefined, dir: string, materialization?: Materialization): Promise<ContentBlock[]>;
39
+ /** The directory file parts persist into when they are not attachments. */
40
+ export declare function uploadsDir(root?: string): string;
41
+ export {};
@@ -2,7 +2,8 @@
2
2
  * The @a2a-js/sdk {@link AgentExecutor} that drives dsh agents through the
3
3
  * bridge. One `execute()` = one user-message turn:
4
4
  *
5
- * 1. extract the text prompt (v1 is text-only)
5
+ * 1. map the message's parts onto dsh content blocks (file parts become
6
+ * execution-world paths with a materializer, attachments or local files otherwise)
6
7
  * 2. ensure the task's session/agent exists (creating it on first contact)
7
8
  * 3. publish a `task` event first — A2A 1.0 stream ordering REQUIRES the
8
9
  * first event of every execute to be a task or message, including
@@ -20,7 +21,9 @@ import type { AgentExecutor, ExecutionEventBus, RequestContext } from '@a2a-js/s
20
21
  import type { A2aBridge } from './bridge.js';
21
22
  export declare class DshAgentExecutor implements AgentExecutor {
22
23
  private readonly bridge;
23
- constructor(bridge: A2aBridge);
24
+ private readonly suppressTask?;
25
+ constructor(bridge: A2aBridge, suppressTask?: ((taskId: string) => void) | undefined);
24
26
  execute(requestContext: RequestContext, eventBus: ExecutionEventBus): Promise<void>;
27
+ private executeInner;
25
28
  cancelTask: (taskId: string, eventBus: ExecutionEventBus) => Promise<void>;
26
29
  }
@@ -13,6 +13,7 @@
13
13
  */
14
14
  import type { Context } from '@deepseek-ai/cordis';
15
15
  import Schema from '@deepseek-ai/schemastery';
16
+ export type { A2aFileMaterializer } from './content.js';
16
17
  export declare const name = "dsh-a2a";
17
18
  /** The bridge creates and owns agents through the registry service. */
18
19
  export declare const inject: string[];
@@ -23,6 +24,11 @@ export declare const Config: Schema<Schemastery.ObjectS<{
23
24
  basePath: Schema<string, string>;
24
25
  /** Working directory for agents spawned by A2A tasks. Must be absolute. */
25
26
  cwd: Schema<string, string>;
27
+ /**
28
+ * Root directory file parts persist into when no attachment store is
29
+ * composed (date-layered below it). Empty = <OS temp>/dsh-a2a-uploads.
30
+ */
31
+ uploadsDir: Schema<string, string>;
26
32
  agent: Schema<Schemastery.ObjectS<{
27
33
  provider: Schema<string, string>;
28
34
  model: Schema<string, string>;
@@ -83,6 +89,11 @@ export declare const Config: Schema<Schemastery.ObjectS<{
83
89
  basePath: Schema<string, string>;
84
90
  /** Working directory for agents spawned by A2A tasks. Must be absolute. */
85
91
  cwd: Schema<string, string>;
92
+ /**
93
+ * Root directory file parts persist into when no attachment store is
94
+ * composed (date-layered below it). Empty = <OS temp>/dsh-a2a-uploads.
95
+ */
96
+ uploadsDir: Schema<string, string>;
86
97
  agent: Schema<Schemastery.ObjectS<{
87
98
  provider: Schema<string, string>;
88
99
  model: Schema<string, string>;
@@ -143,6 +154,7 @@ export interface A2aPluginConfig {
143
154
  port: number;
144
155
  basePath: string;
145
156
  cwd: string;
157
+ uploadsDir: string;
146
158
  agent: {
147
159
  provider: string;
148
160
  model: string;
@@ -166,6 +178,10 @@ export interface A2aPluginConfig {
166
178
  keyFilename: string;
167
179
  };
168
180
  }
181
+ /** Same-process gateway contract for messages/clear. */
182
+ export interface A2aTasks {
183
+ clearContext(contextId: string, tenant?: string): Promise<string[]>;
184
+ }
169
185
  export declare function apply(ctx: Context, config: A2aPluginConfig): Promise<{
170
186
  port: number;
171
187
  }> | undefined;
@@ -11,7 +11,7 @@
11
11
  * per-task snapshot needs a per-task workspace story first).
12
12
  */
13
13
  import type { ListTasksRequest, ListTasksResponse, Task } from '@a2a-js/sdk';
14
- import type { TaskStore } from '@a2a-js/sdk/server';
14
+ import { type ManagedTaskStore } from '../task-store.js';
15
15
  export interface GcsTaskStoreConfig {
16
16
  bucket: string;
17
17
  /** Object key prefix, default 'tasks' (matches the source layout). */
@@ -19,7 +19,7 @@ export interface GcsTaskStoreConfig {
19
19
  /** Path to a service-account key file; omit to use ADC. */
20
20
  keyFilename?: string;
21
21
  }
22
- export declare class GcsTaskStore implements TaskStore {
22
+ export declare class GcsTaskStore implements ManagedTaskStore {
23
23
  private config;
24
24
  private bucket;
25
25
  private prefix;
@@ -27,6 +27,7 @@ export declare class GcsTaskStore implements TaskStore {
27
27
  init(): Promise<void>;
28
28
  save(task: Task): Promise<void>;
29
29
  load(taskId: string): Promise<Task | undefined>;
30
+ delete(taskId: string): Promise<void>;
30
31
  list(params: ListTasksRequest): Promise<ListTasksResponse>;
31
32
  /**
32
33
  * Tar + gzip the workspace directory to `tasks/{taskId}/workspace.tar.gz`.
@@ -7,13 +7,13 @@
7
7
  * conversation history is dsh-storage's job (ai_messages), not this store's.
8
8
  */
9
9
  import type { ListTasksRequest, ListTasksResponse, Task } from '@a2a-js/sdk';
10
- import type { TaskStore } from '@a2a-js/sdk/server';
10
+ import { type ManagedTaskStore } from '../task-store.js';
11
11
  export interface RedisTaskStoreConfig {
12
12
  url: string;
13
13
  keyPrefix?: string;
14
14
  ttlSeconds?: number;
15
15
  }
16
- export declare class RedisTaskStore implements TaskStore {
16
+ export declare class RedisTaskStore implements ManagedTaskStore {
17
17
  private readonly config;
18
18
  private redis;
19
19
  private readonly prefix;
@@ -23,6 +23,7 @@ export declare class RedisTaskStore implements TaskStore {
23
23
  private taskKey;
24
24
  save(task: Task): Promise<void>;
25
25
  load(taskId: string): Promise<Task | undefined>;
26
+ delete(taskId: string): Promise<void>;
26
27
  list(params: ListTasksRequest): Promise<ListTasksResponse>;
27
28
  close(): Promise<void>;
28
29
  }
@@ -17,21 +17,36 @@
17
17
  * the shared filter/sort/paginate logic so the backends only enumerate shells.
18
18
  */
19
19
  import type { ListTasksRequest, ListTasksResponse, Task } from '@a2a-js/sdk';
20
- import type { ServerCallContext, TaskStore } from '@a2a-js/sdk/server';
20
+ import { type ServerCallContext, type TaskStore } from '@a2a-js/sdk/server';
21
21
  /** The persistent shape: a metadata shell with empty history/artifacts. */
22
22
  export declare function sanitizeTask(task: Task): Task;
23
23
  /** TaskStore with the optional lifecycle our backends add. */
24
24
  export interface ManagedTaskStore extends TaskStore {
25
25
  init?(): Promise<void>;
26
+ delete(taskId: string, context: ServerCallContext): Promise<void>;
26
27
  close?(): Promise<void>;
27
28
  }
29
+ /** The SDK memory store has no delete operation; keep the same shell contract. */
30
+ export declare class MemoryTaskStore implements ManagedTaskStore {
31
+ private readonly scopes;
32
+ private bucket;
33
+ save(task: Task, context: ServerCallContext): Promise<void>;
34
+ load(taskId: string, context: ServerCallContext): Promise<Task | undefined>;
35
+ list(params: ListTasksRequest, context: ServerCallContext): Promise<ListTasksResponse>;
36
+ delete(taskId: string, context: ServerCallContext): Promise<void>;
37
+ }
28
38
  /** Strips history/artifacts and saves only on task-state transitions. */
29
39
  export declare class SanitizedTaskStore implements ManagedTaskStore {
30
40
  private readonly inner;
31
41
  private readonly lastState;
42
+ private readonly writes;
43
+ private readonly deleted;
32
44
  constructor(inner: ManagedTaskStore);
33
45
  init(): Promise<void>;
34
46
  close(): Promise<void>;
47
+ /** Drop SDK saves that may arrive after a clear rejects an in-flight request. */
48
+ suppress(taskId: string): void;
49
+ delete(taskId: string, context: ServerCallContext): Promise<void>;
35
50
  save(task: Task, context: ServerCallContext): Promise<void>;
36
51
  load(taskId: string, context: ServerCallContext): Promise<Task | undefined>;
37
52
  list(params: ListTasksRequest, context: ServerCallContext): Promise<ListTasksResponse>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amaster.ai/dsh-a2a",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "A2A protocol (JSON-RPC + SSE) server plugin for DeepSeek Harness: expose dsh agents as A2A agents.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -39,12 +39,16 @@
39
39
  "peerDependencies": {
40
40
  "@deepseek-ai/cordis": "^4.0.1",
41
41
  "@deepseek-ai/dsh-agent": ">=0.1.5-rc.1 || >=0.1.6-alpha.1",
42
+ "@deepseek-ai/dsh-attachment": ">=0.1.5-rc.1 || >=0.1.6-alpha.1",
42
43
  "@deepseek-ai/dsh-llm": ">=0.1.5-rc.1 || >=0.1.6-alpha.1",
43
44
  "@deepseek-ai/dsh-session": ">=0.1.5-rc.1 || >=0.1.6-alpha.1",
44
45
  "@google-cloud/storage": "^7.0.0",
45
46
  "ioredis": "^5.0.0"
46
47
  },
47
48
  "peerDependenciesMeta": {
49
+ "@deepseek-ai/dsh-attachment": {
50
+ "optional": true
51
+ },
48
52
  "@google-cloud/storage": {
49
53
  "optional": true
50
54
  },
@@ -55,6 +59,7 @@
55
59
  "devDependencies": {
56
60
  "@deepseek-ai/cordis": "^4.0.1",
57
61
  "@deepseek-ai/dsh-agent": "0.1.6-alpha.2",
62
+ "@deepseek-ai/dsh-attachment": "0.1.6-alpha.2",
58
63
  "@deepseek-ai/dsh-llm": "0.1.6-alpha.2",
59
64
  "@deepseek-ai/dsh-session": "0.1.6-alpha.2",
60
65
  "@google-cloud/storage": "^7.0.0",