@amaster.ai/dsh-a2a 0.1.7 → 0.1.9

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
@@ -57,8 +57,9 @@ Disabled by default. Configure via the profile's `cordis.patch.yml`:
57
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).
58
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.
59
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.
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.
60
+ - **Message parts beyond text.** File parts can carry inline bytes or a `url`; the plugin downloads URLs over http/https, bounded to 64 MiB and 30 s. Supported images use the composed attachment store (`ctx.attachments`, e.g. `@deepseek-ai/dsh-attachment-local`) to reach vision-capable models. Non-image files use a configured materializer or persist under `uploadsDir` (default `<OS temp>/dsh-a2a-uploads/<date>/`, names sanitized cross-platform, collisions suffixed), even when an attachment store exists. The prompt references the resulting readable path inside a `<document>` tag. Ensure the local 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
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.
62
+ - **Local execution files.** Without a remote materializer, inline and downloaded non-image FileParts persist under `uploadsDir`; the `<document>` envelope carries the path, source URL, media type, size, and original filename when sanitization or a collision changes it. Images still use native attachment handling.
62
63
  - **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`.
63
64
  - **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.
64
65
  - **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.
package/lib/bridge.js CHANGED
@@ -170,10 +170,10 @@ export class A2aBridge {
170
170
  }
171
171
  }
172
172
  /** Queue one user-message turn on the task's agent and await its `turn/end`. */
173
- async runTurn(entry, content, bus) {
173
+ async runTurn(entry, content, bus, a2aMessageId, requestHeaders) {
174
174
  if (this.clearing.has(entry.sessionId))
175
175
  throw new Error('Context is being cleared.');
176
- const running = this.runTurnInner(entry, content, bus);
176
+ const running = this.runTurnInner(entry, content, bus, a2aMessageId, requestHeaders);
177
177
  entry.running.add(running);
178
178
  try {
179
179
  await running;
@@ -182,7 +182,7 @@ export class A2aBridge {
182
182
  entry.running.delete(running);
183
183
  }
184
184
  }
185
- async runTurnInner(entry, content, bus) {
185
+ async runTurnInner(entry, content, bus, a2aMessageId, requestHeaders) {
186
186
  let resolveSettled;
187
187
  const settled = new Promise((resolve) => {
188
188
  resolveSettled = resolve;
@@ -190,26 +190,45 @@ export class A2aBridge {
190
190
  entry.settled.push(resolveSettled);
191
191
  entry.bus = bus;
192
192
  try {
193
- const result = entry.handle.agent.followup(createUserMessage({
194
- content,
195
- source: { kind: 'user' },
196
- }));
197
- // followup() is typed void; guard a mistyped async impl anyway — an
198
- // unhandled rejection would crash the host process, and no turn/end
199
- // would ever settle this waiter.
200
- void Promise.resolve(result).catch((error) => {
201
- const message = error instanceof Error ? error.message : String(error);
202
- console.error('[dsh-a2a] followup failed:', error);
203
- if (entry.bus === bus) {
204
- entry.bus.publish(terminalStatusUpdate(entry.taskId, entry.sessionId, 'failed', message));
205
- }
206
- resolveSettled();
193
+ const message = createUserMessage({ content, source: { kind: 'user' } });
194
+ // followup() inserts synchronously, then may claim the message before
195
+ // returning. Observe that insertion so the mapping is both real and early.
196
+ const stopListening = this.ctx.on('agent/inbox/inserted', ({ agent, message: inserted }) => {
197
+ if (agent !== entry.handle.agent || inserted.id !== message.id)
198
+ return;
199
+ stopListening();
200
+ void this.ctx
201
+ .parallel('a2a/message-admitted', {
202
+ contextId: entry.sessionId,
203
+ taskId: entry.taskId,
204
+ a2aMessageId,
205
+ dshMessageId: message.id,
206
+ requestHeaders,
207
+ })
208
+ .catch((error) => {
209
+ console.error('[dsh-a2a] message-admitted listener failed:', error);
210
+ });
207
211
  });
208
- // Never rejects: waiters only resolve. A synchronous followup() throw is
209
- // the one failure path — it means no turn ever started, so pull this
210
- // waiter back out of the FIFO before it can be settled by the NEXT
211
- // turn's turn/end (which would resolve one execute() too early).
212
- await settled;
212
+ try {
213
+ const result = entry.handle.agent.followup(message);
214
+ // followup() is typed void; guard a mistyped async impl anyway — an
215
+ // unhandled rejection would crash the host process, and no turn/end
216
+ // would ever settle this waiter.
217
+ void Promise.resolve(result).catch((error) => {
218
+ const message = error instanceof Error ? error.message : String(error);
219
+ console.error('[dsh-a2a] followup failed:', error);
220
+ if (entry.bus === bus) {
221
+ entry.bus.publish(terminalStatusUpdate(entry.taskId, entry.sessionId, 'failed', message));
222
+ }
223
+ resolveSettled();
224
+ });
225
+ // Never rejects: waiters only resolve. A synchronous followup() throw
226
+ // means no turn started, so pull its waiter from the FIFO below.
227
+ await settled;
228
+ }
229
+ finally {
230
+ stopListening();
231
+ }
213
232
  }
214
233
  catch (error) {
215
234
  const index = entry.settled.indexOf(resolveSettled);
package/lib/content.js CHANGED
@@ -3,13 +3,12 @@
3
3
  * part kinds:
4
4
  *
5
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);
6
+ * - file parts (inline `raw` bytes or a `url`) are downloaded by the plugin
7
+ * (http/https only, size- and time-bounded) when they carry a URL;
9
8
  * - an optional deployment materializer puts non-image files into the
10
9
  * 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>/
10
+ * - without a materializer, non-image files persist under the configured
11
+ * upload root even when an attachment store exists (default `<OS temp>/
13
12
  * dsh-a2a-uploads/<date>/`) and the prompt references them by path;
14
13
  * - `data` parts become JSON text.
15
14
  *
@@ -54,11 +53,11 @@ export async function buildMessageContent(parts, attachments, dir, materializati
54
53
  }
55
54
  return content;
56
55
  }
57
- /** The directory file parts persist into when they are not attachments. */
56
+ /** The directory non-image file parts persist into without a materializer. */
58
57
  export function uploadsDir(root) {
59
58
  // 'sv-SE' renders YYYY-MM-DD; the bucket is local time.
60
59
  const segment = new Date().toLocaleDateString('sv-SE').replaceAll('-', '');
61
- return path.join(root || path.join(os.tmpdir(), 'dsh-a2a-uploads'), segment);
60
+ return path.resolve(root || path.join(os.tmpdir(), 'dsh-a2a-uploads'), segment);
62
61
  }
63
62
  /** Log and degrade one file-part failure to a metadata note. */
64
63
  function failNote(part, mediaType, uri, label, error) {
@@ -85,7 +84,7 @@ async function fileContent(c, part, attachments, dir, materialization) {
85
84
  return failNote(part, mediaType, uri, 'failed to read a file part', error);
86
85
  }
87
86
  const image = attachments?.imageLimits.mediaTypes.includes(mediaType.toLowerCase());
88
- if (attachments && (!materialization || image)) {
87
+ if (attachments && image) {
89
88
  const block = await saveAttachment(attachments, bytes, mediaType, part.filename);
90
89
  if (block)
91
90
  return block;
@@ -100,7 +99,7 @@ async function fileContent(c, part, attachments, dir, materialization) {
100
99
  });
101
100
  if (!readablePath)
102
101
  throw new Error('materializer returned no readable path');
103
- return documentBlock(uri, mediaType, bytes.byteLength, readablePath);
102
+ return documentBlock(uri, mediaType, bytes.byteLength, readablePath, part.filename);
104
103
  }
105
104
  catch (error) {
106
105
  console.error('[dsh-a2a] failed to materialize a file part:', error);
@@ -110,7 +109,7 @@ async function fileContent(c, part, attachments, dir, materialization) {
110
109
  try {
111
110
  const name = sanitizeUploadFileName(part.filename);
112
111
  const filePath = await persistUpload(dir, name, bytes);
113
- return documentBlock(uri, mediaType, bytes.byteLength, filePath);
112
+ return documentBlock(uri, mediaType, bytes.byteLength, filePath, part.filename);
114
113
  }
115
114
  catch (error) {
116
115
  return failNote(part, mediaType, uri, 'failed to persist a file part', error);
@@ -174,11 +173,14 @@ function sanitizeUploadFileName(name) {
174
173
  return !cleaned || WINDOWS_RESERVED_STEMS.has(stem) ? 'unnamed-file' : cleaned;
175
174
  }
176
175
  /** Tool-readable path stand-in for a file part. */
177
- function documentBlock(uri, mediaType, bytes, filePath) {
176
+ function documentBlock(uri, mediaType, bytes, filePath, originalName) {
178
177
  const attrs = [
179
178
  // The basename of the final path, so name= always matches the file on
180
179
  // disk — including collision suffixes.
181
180
  `name="${attr(path.win32.basename(filePath))}"`,
181
+ originalName &&
182
+ originalName !== path.win32.basename(filePath) &&
183
+ `originalName="${attr(originalName)}"`,
182
184
  uri && `uri="${attr(uri)}"`,
183
185
  mediaType && `type="${attr(mediaType)}"`,
184
186
  `size="${bytes}"`,
package/lib/executor.js CHANGED
@@ -3,7 +3,7 @@
3
3
  * bridge. One `execute()` = one user-message turn:
4
4
  *
5
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
+ * execution-world paths with a materializer, local paths otherwise)
7
7
  * 2. ensure the task's session/agent exists (creating it on first contact)
8
8
  * 3. publish a `task` event first — A2A 1.0 stream ordering REQUIRES the
9
9
  * first event of every execute to be a task or message, including
@@ -18,7 +18,7 @@
18
18
  * `cancelTask` is an arrow property because the SDK destructures it.
19
19
  */
20
20
  import { TaskState } from '@a2a-js/sdk';
21
- import { AgentEvent } from '@a2a-js/sdk/server';
21
+ import { AgentEvent, STATE_HEADERS_KEY } from '@a2a-js/sdk/server';
22
22
  import { agentTextMessage, terminalStatusUpdate } from './translator.js';
23
23
  export class DshAgentExecutor {
24
24
  bridge;
@@ -48,7 +48,8 @@ export class DshAgentExecutor {
48
48
  // stored task (history/artifacts preserved).
49
49
  taskEvent(taskId, contextId, TaskState.TASK_STATE_WORKING));
50
50
  anchored = true;
51
- await this.bridge.runTurn(entry, content, eventBus);
51
+ const requestHeaders = requestContext.context.state.get(STATE_HEADERS_KEY);
52
+ await this.bridge.runTurn(entry, content, eventBus, userMessage.messageId, requestHeaders);
52
53
  }
53
54
  catch (error) {
54
55
  if (this.bridge.isClearing(contextId))
package/lib/index.js CHANGED
@@ -20,6 +20,8 @@ import { startA2aServer } from './server.js';
20
20
  import { GcsTaskStore } from './stores/gcs.js';
21
21
  import { RedisTaskStore } from './stores/redis.js';
22
22
  import { MemoryTaskStore, SanitizedTaskStore } from './task-store.js';
23
+ /** Event emitted when one A2A user message enters the dsh inbox. */
24
+ export const A2A_MESSAGE_ADMITTED_EVENT = 'a2a/message-admitted';
23
25
  export const name = 'dsh-a2a';
24
26
  /** The bridge creates and owns agents through the registry service. */
25
27
  export const inject = ['agents'];
@@ -31,8 +33,8 @@ export const Config = Schema.object({
31
33
  /** Working directory for agents spawned by A2A tasks. Must be absolute. */
32
34
  cwd: Schema.string().default(process.cwd()),
33
35
  /**
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
+ * Root directory for fallback or opted-in local file parts (date-layered
37
+ * below it). Empty = <OS temp>/dsh-a2a-uploads.
36
38
  */
37
39
  uploadsDir: Schema.string().default(''),
38
40
  agent: Schema.object({
@@ -78,7 +78,7 @@ export declare class A2aBridge {
78
78
  /** Include content preparation in the work a context clear must drain. */
79
79
  trackExecution(contextId: string, run: () => Promise<void>): Promise<void>;
80
80
  /** Queue one user-message turn on the task's agent and await its `turn/end`. */
81
- runTurn(entry: TaskEntry, content: ContentBlock[], bus: ExecutionEventBus): Promise<void>;
81
+ runTurn(entry: TaskEntry, content: ContentBlock[], bus: ExecutionEventBus, a2aMessageId: string, requestHeaders: unknown): Promise<void>;
82
82
  private runTurnInner;
83
83
  /** Cancel and release the live binding before the caller deletes stored shells. */
84
84
  clearContext(contextId: string, clearStored?: (liveIds: string[]) => Promise<string[]>): Promise<string[]>;
@@ -3,13 +3,12 @@
3
3
  * part kinds:
4
4
  *
5
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);
6
+ * - file parts (inline `raw` bytes or a `url`) are downloaded by the plugin
7
+ * (http/https only, size- and time-bounded) when they carry a URL;
9
8
  * - an optional deployment materializer puts non-image files into the
10
9
  * 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>/
10
+ * - without a materializer, non-image files persist under the configured
11
+ * upload root even when an attachment store exists (default `<OS temp>/
13
12
  * dsh-a2a-uploads/<date>/`) and the prompt references them by path;
14
13
  * - `data` parts become JSON text.
15
14
  *
@@ -36,6 +35,6 @@ interface Materialization {
36
35
  materializer: A2aFileMaterializer;
37
36
  }
38
37
  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. */
38
+ /** The directory non-image file parts persist into without a materializer. */
40
39
  export declare function uploadsDir(root?: string): string;
41
40
  export {};
@@ -3,7 +3,7 @@
3
3
  * bridge. One `execute()` = one user-message turn:
4
4
  *
5
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
+ * execution-world paths with a materializer, local paths otherwise)
7
7
  * 2. ensure the task's session/agent exists (creating it on first contact)
8
8
  * 3. publish a `task` event first — A2A 1.0 stream ordering REQUIRES the
9
9
  * first event of every execute to be a task or message, including
@@ -14,6 +14,20 @@
14
14
  import type { Context } from '@deepseek-ai/cordis';
15
15
  import Schema from '@deepseek-ai/schemastery';
16
16
  export type { A2aFileMaterializer } from './content.js';
17
+ /** Event emitted when one A2A user message enters the dsh inbox. */
18
+ export declare const A2A_MESSAGE_ADMITTED_EVENT: "a2a/message-admitted";
19
+ declare module '@deepseek-ai/cordis' {
20
+ interface Events {
21
+ /** Link a protocol message to the dsh inbox message created for its turn. */
22
+ 'a2a/message-admitted'(detail: {
23
+ contextId: string;
24
+ taskId: string;
25
+ a2aMessageId: string;
26
+ dshMessageId: string;
27
+ requestHeaders: unknown;
28
+ }): void;
29
+ }
30
+ }
17
31
  export declare const name = "dsh-a2a";
18
32
  /** The bridge creates and owns agents through the registry service. */
19
33
  export declare const inject: string[];
@@ -25,8 +39,8 @@ export declare const Config: Schema<Schemastery.ObjectS<{
25
39
  /** Working directory for agents spawned by A2A tasks. Must be absolute. */
26
40
  cwd: Schema<string, string>;
27
41
  /**
28
- * Root directory file parts persist into when no attachment store is
29
- * composed (date-layered below it). Empty = <OS temp>/dsh-a2a-uploads.
42
+ * Root directory for fallback or opted-in local file parts (date-layered
43
+ * below it). Empty = <OS temp>/dsh-a2a-uploads.
30
44
  */
31
45
  uploadsDir: Schema<string, string>;
32
46
  agent: Schema<Schemastery.ObjectS<{
@@ -90,8 +104,8 @@ export declare const Config: Schema<Schemastery.ObjectS<{
90
104
  /** Working directory for agents spawned by A2A tasks. Must be absolute. */
91
105
  cwd: Schema<string, string>;
92
106
  /**
93
- * Root directory file parts persist into when no attachment store is
94
- * composed (date-layered below it). Empty = <OS temp>/dsh-a2a-uploads.
107
+ * Root directory for fallback or opted-in local file parts (date-layered
108
+ * below it). Empty = <OS temp>/dsh-a2a-uploads.
95
109
  */
96
110
  uploadsDir: Schema<string, string>;
97
111
  agent: Schema<Schemastery.ObjectS<{
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amaster.ai/dsh-a2a",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
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",