@amaster.ai/dsh-a2a 0.1.4 → 0.1.6

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,18 @@ 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) become durable image/file content blocks when the deployment composes an attachment store (`ctx.attachments`, e.g. `@deepseek-ai/dsh-attachment-local`): images reach vision-capable models natively, and every file projects to deterministic handle text naming the file and — on host-file-backed stores — its read-only saved path. Without a store, 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, so the agent reads or converts them with its own tools. `data` parts become `<data>` JSON text. A turn never fails because a part kind is unsupported.
60
61
  - **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
62
  - **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
+ - **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.
64
+ - **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
65
 
64
66
  ## Task stores
65
67
 
@@ -71,11 +73,11 @@ A2A **task state** (status + metadata) is separate from conversation history —
71
73
 
72
74
  ## Security
73
75
 
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.
76
+ 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
77
 
76
78
  ## Compatibility
77
79
 
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.
80
+ 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
81
 
80
82
  ## License
81
83
 
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,54 @@ 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) {
147
+ const attachments = this.ctx.get('attachments');
148
+ return buildMessageContent(message.parts, attachments, uploadsDir(this.options.uploadsDir));
149
+ }
150
+ isClearing(contextId) {
151
+ return this.clearing.has(contextId);
152
+ }
153
+ /** Include content preparation in the work a context clear must drain. */
154
+ async trackExecution(contextId, run) {
155
+ const execution = run();
156
+ let pending = this.executing.get(contextId);
157
+ if (!pending) {
158
+ pending = new Set();
159
+ this.executing.set(contextId, pending);
160
+ }
161
+ pending.add(execution);
162
+ try {
163
+ await execution;
164
+ }
165
+ finally {
166
+ pending.delete(execution);
167
+ if (pending.size === 0)
168
+ this.executing.delete(contextId);
169
+ }
170
+ }
112
171
  /** Queue one user-message turn on the task's agent and await its `turn/end`. */
113
- async runTurn(entry, text, bus) {
172
+ async runTurn(entry, content, bus) {
173
+ if (this.clearing.has(entry.sessionId))
174
+ throw new Error('Context is being cleared.');
175
+ const running = this.runTurnInner(entry, content, bus);
176
+ entry.running.add(running);
177
+ try {
178
+ await running;
179
+ }
180
+ finally {
181
+ entry.running.delete(running);
182
+ }
183
+ }
184
+ async runTurnInner(entry, content, bus) {
114
185
  let resolveSettled;
115
186
  const settled = new Promise((resolve) => {
116
187
  resolveSettled = resolve;
@@ -119,7 +190,7 @@ export class A2aBridge {
119
190
  entry.bus = bus;
120
191
  try {
121
192
  const result = entry.handle.agent.followup(createUserMessage({
122
- content: [{ type: 'text', text }],
193
+ content,
123
194
  source: { kind: 'user' },
124
195
  }));
125
196
  // followup() is typed void; guard a mistyped async impl anyway — an
@@ -150,6 +221,32 @@ export class A2aBridge {
150
221
  entry.bus = null;
151
222
  }
152
223
  }
224
+ /** Cancel and release the live binding before the caller deletes stored shells. */
225
+ async clearContext(contextId, clearStored = async (ids) => ids) {
226
+ if (this.clearing.has(contextId))
227
+ throw new Error('Context is already being cleared.');
228
+ this.clearing.add(contextId);
229
+ try {
230
+ await Promise.allSettled([...(this.creating.get(contextId) ?? [])]);
231
+ const entry = this.bySession.get(contextId);
232
+ if (entry) {
233
+ if (entry.turnActive || entry.running.size > 0) {
234
+ await entry.handle.agent.cancel({ kind: 'user' });
235
+ }
236
+ await entry.handle.agent.whenIdle();
237
+ this.settleEntry(entry, 'Context cleared.');
238
+ await Promise.allSettled([...entry.running]);
239
+ this.tasks.delete(entry.taskId);
240
+ this.bySession.delete(contextId);
241
+ await entry.handle.dispose();
242
+ }
243
+ await Promise.allSettled([...(this.executing.get(contextId) ?? [])]);
244
+ return await clearStored(entry ? [entry.taskId] : []);
245
+ }
246
+ finally {
247
+ this.clearing.delete(contextId);
248
+ }
249
+ }
153
250
  /** Abort the task's active turn; returns the contextId when the task is live. */
154
251
  cancel(taskId) {
155
252
  const entry = this.tasks.get(taskId);
@@ -181,12 +278,20 @@ export class A2aBridge {
181
278
  return;
182
279
  if (event.type === 'turn/start')
183
280
  entry.turnActive = true;
281
+ // A turn carried over from a previous task binding (e.g. a canceled turn
282
+ // whose abort was still in flight when the context rebound): its turn/end
283
+ // belongs to the old task — settle waiters, but publish nothing under the
284
+ // new task id.
285
+ const stale = event.type === 'turn/end' && entry.staleTurn;
286
+ if (stale)
287
+ entry.staleTurn = false;
184
288
  // No-throw seam: translation errors must never reach the agent loop. The
185
289
  // turn/end waiter still resolves below, so a poisoned event cannot hang
186
290
  // an in-flight execute() either.
187
291
  try {
188
- for (const out of entry.translator.handle(event))
189
- entry.bus?.publish(out);
292
+ if (!stale)
293
+ for (const out of entry.translator.handle(event))
294
+ entry.bus?.publish(out);
190
295
  }
191
296
  catch (error) {
192
297
  console.error('[dsh-a2a] failed to translate session event:', error);
package/lib/content.js ADDED
@@ -0,0 +1,227 @@
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`) become durable image/file
7
+ * content blocks when the deployment composes an attachment store
8
+ * (`ctx.attachments`, e.g. @deepseek-ai/dsh-attachment-local); url parts
9
+ * are downloaded by the plugin (http/https only, size- and time-bounded);
10
+ * - without a store — or when a store refuses the bytes — file parts
11
+ * persist under the configured upload root (default `<OS temp>/
12
+ * dsh-a2a-uploads/<date>/`) and the prompt references them by path, so
13
+ * the agent reads or converts them with its own tools (the same
14
+ * path-based model dsh-llm's file-block projection uses);
15
+ * - `data` parts become JSON text.
16
+ *
17
+ * The SDK's admission helpers (admitEncodedFile/admitPromptContent) are not
18
+ * used: they take base64 wire uploads and throw on refusal — this path holds
19
+ * raw bytes and must degrade, never fail.
20
+ */
21
+ import { mkdir, writeFile } from 'node:fs/promises';
22
+ import os from 'node:os';
23
+ import path from 'node:path';
24
+ /** Cap on one url part download. */
25
+ const MAX_DOWNLOAD_BYTES = 64 * 1024 * 1024;
26
+ const DOWNLOAD_TIMEOUT_MS = 30_000;
27
+ // TODO(verify): the confirmation data-part protocol ({callId, outcome}) maps
28
+ // onto the optional dsh-user-approval service (0.1.2+, asks carry the exact
29
+ // tool call) — bridge it once a deployment composes that service.
30
+ export async function buildMessageContent(parts, attachments, dir) {
31
+ const content = [];
32
+ for (const part of parts) {
33
+ const c = part.content;
34
+ if (!c)
35
+ continue;
36
+ switch (c.$case) {
37
+ case 'text':
38
+ if (c.value.trim())
39
+ content.push({ type: 'text', text: c.value });
40
+ break;
41
+ case 'data':
42
+ content.push({ type: 'text', text: dataText(c.value) });
43
+ break;
44
+ case 'raw':
45
+ case 'url':
46
+ content.push(await fileContent(c, part, attachments, dir));
47
+ break;
48
+ default:
49
+ // A part kind this SDK version predates: keep it visible, never drop.
50
+ content.push(note(part, part.mediaType, undefined, `unsupported part kind "${c.$case}"`));
51
+ }
52
+ }
53
+ if (content.length === 0) {
54
+ throw new Error('dsh-a2a: message must contain at least one usable part');
55
+ }
56
+ return content;
57
+ }
58
+ /** The directory file parts persist into when they are not attachments. */
59
+ export function uploadsDir(root) {
60
+ // 'sv-SE' renders YYYY-MM-DD; the bucket is local time.
61
+ const segment = new Date().toLocaleDateString('sv-SE').replaceAll('-', '');
62
+ return path.join(root || path.join(os.tmpdir(), 'dsh-a2a-uploads'), segment);
63
+ }
64
+ /** Log and degrade one file-part failure to a metadata note. */
65
+ function failNote(part, mediaType, uri, label, error) {
66
+ console.error(`[dsh-a2a] ${label}:`, error);
67
+ return note(part, mediaType, uri, error instanceof Error ? error.message : String(error));
68
+ }
69
+ /** Resolve one file part to an attachment block or a workspace file. */
70
+ async function fileContent(c, part, attachments, dir) {
71
+ let bytes;
72
+ let mediaType = part.mediaType;
73
+ let uri;
74
+ try {
75
+ if (c.$case === 'url') {
76
+ uri = c.value;
77
+ const got = await download(uri);
78
+ bytes = got.bytes;
79
+ mediaType ||= got.mediaType ?? '';
80
+ }
81
+ else {
82
+ bytes = c.value;
83
+ }
84
+ }
85
+ catch (error) {
86
+ return failNote(part, mediaType, uri, 'failed to read a file part', error);
87
+ }
88
+ if (attachments) {
89
+ const block = await saveAttachment(attachments, bytes, mediaType, part.filename);
90
+ if (block)
91
+ return block;
92
+ }
93
+ try {
94
+ const name = sanitizeUploadFileName(part.filename);
95
+ const filePath = await persistUpload(dir, name, bytes);
96
+ return documentBlock(uri, mediaType, bytes.byteLength, filePath);
97
+ }
98
+ catch (error) {
99
+ return failNote(part, mediaType, uri, 'failed to persist a file part', error);
100
+ }
101
+ }
102
+ /** Commit part bytes to the store: image path first, verbatim file on refusal. */
103
+ async function saveAttachment(attachments, bytes, mediaType, filename) {
104
+ const name = filename || undefined;
105
+ if (attachments.imageLimits.mediaTypes.includes(mediaType.toLowerCase())) {
106
+ try {
107
+ const attachment = await attachments.saveImage({
108
+ data: bytes,
109
+ mediaType: mediaType,
110
+ name,
111
+ });
112
+ return { type: 'image', attachment };
113
+ }
114
+ catch (error) {
115
+ console.error('[dsh-a2a] image admission failed; storing the bytes as a file:', error);
116
+ }
117
+ }
118
+ try {
119
+ const attachment = await attachments.saveFile({ data: bytes, name });
120
+ return { type: 'file', attachment };
121
+ }
122
+ catch (error) {
123
+ console.error('[dsh-a2a] failed to store an attachment:', error);
124
+ return undefined;
125
+ }
126
+ }
127
+ /** Save one upload under the upload dir; returns the absolute path. */
128
+ async function persistUpload(dir, name, bytes) {
129
+ await mkdir(dir, { recursive: true });
130
+ const { name: stem, ext } = path.parse(name);
131
+ for (let attempt = 0;; attempt++) {
132
+ const candidate = path.join(dir, attempt === 0 ? name : `${stem}-${attempt}${ext}`);
133
+ try {
134
+ await writeFile(candidate, bytes, { flag: 'wx' });
135
+ return candidate;
136
+ }
137
+ catch (error) {
138
+ if (error.code !== 'EEXIST')
139
+ throw error;
140
+ }
141
+ }
142
+ }
143
+ // Windows refuses these device stems regardless of extension.
144
+ 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(' '));
145
+ /**
146
+ * Cross-platform basename with control and Windows-illegal characters
147
+ * stripped; the trailing-dot/space strip also collapses '.' and '..'.
148
+ */
149
+ function sanitizeUploadFileName(name) {
150
+ const cleaned = path.win32
151
+ .basename(name)
152
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: stripping control chars is the point
153
+ .replace(/[<>:"|?*\x00-\x1f\x7f]/g, '')
154
+ .trim()
155
+ .replace(/[. ]+$/, '');
156
+ const stem = cleaned.split('.')[0].toUpperCase();
157
+ return !cleaned || WINDOWS_RESERVED_STEMS.has(stem) ? 'unnamed-file' : cleaned;
158
+ }
159
+ /** Workspace-file stand-in for a file part that did not become an attachment. */
160
+ function documentBlock(uri, mediaType, bytes, filePath) {
161
+ const attrs = [
162
+ // The basename of the final path, so name= always matches the file on
163
+ // disk — including collision suffixes.
164
+ `name="${attr(path.basename(filePath))}"`,
165
+ uri && `uri="${attr(uri)}"`,
166
+ mediaType && `type="${attr(mediaType)}"`,
167
+ `size="${bytes}"`,
168
+ `path="${attr(filePath)}"`,
169
+ ]
170
+ .filter(Boolean)
171
+ .join(' ');
172
+ return {
173
+ type: 'text',
174
+ text: `<document ${attrs}>\nThe file is saved at the path above; read or process it with your tools.\n</document>`,
175
+ };
176
+ }
177
+ function note(part, mediaType, uri, reason) {
178
+ const desc = [
179
+ part.filename && `"${part.filename}"`,
180
+ mediaType && `(${mediaType})`,
181
+ uri && `at ${uri}`,
182
+ ]
183
+ .filter(Boolean)
184
+ .join(' ');
185
+ return { type: 'text', text: `[attachment ${desc || 'file'} not delivered: ${reason}]` };
186
+ }
187
+ /** JSON text stand-in for a structured data part. */
188
+ function dataText(value) {
189
+ try {
190
+ return `<data>\n${JSON.stringify(value, null, 2) ?? String(value)}\n</data>`;
191
+ }
192
+ catch {
193
+ return `<data>\n${String(value)}\n</data>`;
194
+ }
195
+ }
196
+ /** Strip characters that would break attribute position. */
197
+ function attr(value) {
198
+ return value.replace(/["\r\n<>]/g, ' ');
199
+ }
200
+ /** Fetch one url part's bytes over http(s), bounded in size and time. */
201
+ async function download(uri) {
202
+ const scheme = new URL(uri).protocol;
203
+ if (scheme !== 'http:' && scheme !== 'https:') {
204
+ throw new Error(`unsupported scheme "${scheme}"`);
205
+ }
206
+ const response = await fetch(uri, { signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS) });
207
+ if (!response.ok)
208
+ throw new Error(`HTTP ${response.status}`);
209
+ const declared = Number(response.headers.get('content-length'));
210
+ if (declared > MAX_DOWNLOAD_BYTES) {
211
+ throw new Error(`exceeds the ${MAX_DOWNLOAD_BYTES}-byte download limit`);
212
+ }
213
+ const chunks = [];
214
+ let total = 0;
215
+ if (response.body) {
216
+ // Throwing out of the loop cancels the stream via the iterator's return.
217
+ for await (const chunk of response.body) {
218
+ total += chunk.byteLength;
219
+ if (total > MAX_DOWNLOAD_BYTES) {
220
+ throw new Error(`exceeds the ${MAX_DOWNLOAD_BYTES}-byte download limit`);
221
+ }
222
+ chunks.push(chunk);
223
+ }
224
+ }
225
+ const mediaType = response.headers.get('content-type')?.split(';')[0].trim() || undefined;
226
+ return { bytes: Buffer.concat(chunks, total), mediaType };
227
+ }
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
+ * attachments with a store composed, workspace 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);
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): 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,26 @@
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`) become durable image/file
7
+ * content blocks when the deployment composes an attachment store
8
+ * (`ctx.attachments`, e.g. @deepseek-ai/dsh-attachment-local); url parts
9
+ * are downloaded by the plugin (http/https only, size- and time-bounded);
10
+ * - without a store — or when a store refuses the bytes — file parts
11
+ * persist under the configured upload root (default `<OS temp>/
12
+ * dsh-a2a-uploads/<date>/`) and the prompt references them by path, so
13
+ * the agent reads or converts them with its own tools (the same
14
+ * path-based model dsh-llm's file-block projection uses);
15
+ * - `data` parts become JSON text.
16
+ *
17
+ * The SDK's admission helpers (admitEncodedFile/admitPromptContent) are not
18
+ * used: they take base64 wire uploads and throw on refusal — this path holds
19
+ * raw bytes and must degrade, never fail.
20
+ */
21
+ import type { Part } from '@a2a-js/sdk';
22
+ import type { AttachmentStore } from '@deepseek-ai/dsh-attachment';
23
+ import type { ContentBlock } from '@deepseek-ai/dsh-llm/types';
24
+ export declare function buildMessageContent(parts: readonly Part[], attachments: AttachmentStore | undefined, dir: string): Promise<ContentBlock[]>;
25
+ /** The directory file parts persist into when they are not attachments. */
26
+ export declare function uploadsDir(root?: string): string;
@@ -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
+ * attachments with a store composed, workspace 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
  }
@@ -23,6 +23,11 @@ export declare const Config: Schema<Schemastery.ObjectS<{
23
23
  basePath: Schema<string, string>;
24
24
  /** Working directory for agents spawned by A2A tasks. Must be absolute. */
25
25
  cwd: Schema<string, string>;
26
+ /**
27
+ * Root directory file parts persist into when no attachment store is
28
+ * composed (date-layered below it). Empty = <OS temp>/dsh-a2a-uploads.
29
+ */
30
+ uploadsDir: Schema<string, string>;
26
31
  agent: Schema<Schemastery.ObjectS<{
27
32
  provider: Schema<string, string>;
28
33
  model: Schema<string, string>;
@@ -83,6 +88,11 @@ export declare const Config: Schema<Schemastery.ObjectS<{
83
88
  basePath: Schema<string, string>;
84
89
  /** Working directory for agents spawned by A2A tasks. Must be absolute. */
85
90
  cwd: Schema<string, string>;
91
+ /**
92
+ * Root directory file parts persist into when no attachment store is
93
+ * composed (date-layered below it). Empty = <OS temp>/dsh-a2a-uploads.
94
+ */
95
+ uploadsDir: Schema<string, string>;
86
96
  agent: Schema<Schemastery.ObjectS<{
87
97
  provider: Schema<string, string>;
88
98
  model: Schema<string, string>;
@@ -143,6 +153,7 @@ export interface A2aPluginConfig {
143
153
  port: number;
144
154
  basePath: string;
145
155
  cwd: string;
156
+ uploadsDir: string;
146
157
  agent: {
147
158
  provider: string;
148
159
  model: string;
@@ -166,6 +177,10 @@ export interface A2aPluginConfig {
166
177
  keyFilename: string;
167
178
  };
168
179
  }
180
+ /** Same-process gateway contract for messages/clear. */
181
+ export interface A2aTasks {
182
+ clearContext(contextId: string, tenant?: string): Promise<string[]>;
183
+ }
169
184
  export declare function apply(ctx: Context, config: A2aPluginConfig): Promise<{
170
185
  port: number;
171
186
  }> | 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.4",
3
+ "version": "0.1.6",
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",
@@ -38,13 +38,17 @@
38
38
  },
39
39
  "peerDependencies": {
40
40
  "@deepseek-ai/cordis": "^4.0.1",
41
- "@deepseek-ai/dsh-agent": ">=0.1.5-rc.1",
42
- "@deepseek-ai/dsh-llm": ">=0.1.5-rc.1",
43
- "@deepseek-ai/dsh-session": ">=0.1.5-rc.1",
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",
43
+ "@deepseek-ai/dsh-llm": ">=0.1.5-rc.1 || >=0.1.6-alpha.1",
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",