@amaster.ai/dsh-a2a 0.1.27 → 0.1.28

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
@@ -18,9 +18,37 @@ dsh plugin --profile my-agent add @amaster.ai/dsh-a2a
18
18
 
19
19
  Standard `SendMessage` / `SendStreamingMessage`, task queries, cancellation and subscriptions remain the wire interface. The negotiated [inbox extension](../../docs/protocol/inbox-v1.md) forwards `target` and `wakeup` to DSH. `wakeup: false` leaves an idle driver parked; it does not prevent an active driver from consuming pending input. A later real waking message releases pending input.
20
20
 
21
- The same-process `a2aInbox` service exposes `resolve(contextId)`, `content(message, contextId)`, `enqueue(taskId, message, { target?, wakeup? })`, and `cancel(taskId)`. There is no separate pause, wake-only, resume, or retained-task restart operation. Cancellation is terminal and removes that task's pending input. Other tasks' input is retained according to official DSH cancellation semantics, and may wait for the next waking message. Clearing a context discards all its pending work.
21
+ The same-process `a2aInbox` service exposes `resolve(contextId)`, `content(message, contextId)`, `enqueue(taskId, message, { target?, wakeup? })`, `promote(contextId, nativeMessageId, activeTaskId)`, and `cancel(taskId)`. There is no separate pause, wake-only, resume, or retained-task restart operation. Cancellation is terminal and removes that task's pending input. Other tasks' input is retained according to official DSH cancellation semantics, and may wait for the next waking message. Clearing a context discards all its pending work.
22
22
 
23
- `a2a/before-followup` validates admission; `a2a/before-step` runs at the official pre-step boundary, after system-prompt assembly and before model execution. It cannot change the already assembled system prompt. Original request headers remain process-local. DSH persistence restores its own pending projection; the plugin does not replay splice logs or recover consumed claims. Graceful disposal follows the official runtime's cancellation behavior. Durable A2A task snapshots still require a Redis or GCS TaskStore; an in-memory store is sufficient without restart requirements.
23
+ `a2a/before-followup` validates admission; `a2a/before-step` runs at the official pre-step boundary, after system-prompt assembly and before model execution. It cannot change the already assembled system prompt. Original request headers remain process-local. DSH persistence restores its own pending projection; the plugin does not replay splice logs or recover consumed claims. Graceful disposal follows the official runtime's cancellation behavior. Durable A2A task snapshots require a filesystem, Redis or GCS TaskStore; an in-memory store is sufficient without restart requirements.
24
+
25
+ ### Goal rounds
26
+
27
+ When the optional official `@deepseek-ai/dsh-goal` and `dsh-goal-round-driver` plugins are mounted, a Goal created or resumed during an A2A task keeps that task open while the Goal is active and armed. Automatic rounds retain the driver's original native message ID, content and `goal` source. The official driver still validates its reservation, revision and round; the plugin selects the owning task for streaming and `a2a/before-step` billing attribution. Editing the same Goal from another task preserves its original owner. Completing, pausing, blocking or disarming the Goal releases that ownership. A cold restart does not inherit automatic Goal authority: the host must explicitly resume it through a live attributed task.
28
+
29
+ ### Promote a queued input
30
+
31
+ Queue an ordinary future turn as a new task (omit `taskId`). To steer an active task with that already admitted input, pass its **native DSH message ID**, available from `agent.inbox.nextTurn` or `a2a/inbox` observations:
32
+
33
+ ```ts
34
+ await ctx.a2aInbox.promote(contextId, nativeMessageId, activeTaskId);
35
+ ```
36
+
37
+ Only an unclaimed, single-message independent task in the same context can be promoted. The native ID and A2A `messageId` stay unchanged; the target task owns the next-step input, and `referenceTaskIds` records the original task, which settles canceled after the durable transfer. Claimed input, a finished target, concurrent consumption or task settlement reject a stale request. A failed session checkpoint restores independent ownership; a later storage error can report failure after the transfer committed: inspect the queue and task states before sending new input. Recovery repairs interrupted transfers without replaying an ordinary DSH claim or committed model input.
38
+
39
+ ### Local restart recovery
40
+
41
+ Select `taskStore: filesystem` with `filesystem.root` pointing at a retained directory, and separately enable durable DSH session persistence. Keep queued file materialization on retained storage as well. Each task snapshot is scoped by tenant and user, written to a private temporary file and atomically renamed; no additional service is needed. Use one host writer per directory. Files have no automatic expiry; context clearing deletes task snapshots.
42
+
43
+ After a crash, the host discovers sessions, calls `a2aInbox.resolve(contextId)` and supplies a real attributed waking input through the normal API. Restored unclaimed inputs keep their original task ownership. Startup scanning and scheduling remain host responsibilities. Explicit cancellation stays terminal and discards only the canceled task's input. This does not promise exactly-once external side effects, replay of consumed input, or graceful-unload retention beyond the official DSH lifecycle.
44
+
45
+ The dedicated `E2E (dsh-a2a native ownership / issue-75)` integration job runs these regressions with unmodified npm DSH and scripted model responses. Its Langfuse scenario verifies Goal rounds and promoted input under the original task trace, canceled source ownership, and execution restored after SIGKILL. Same-repository CI also sends the scenario to the configured real Langfuse project and polls its Observations API; fork PRs use the local collector without credentials.
46
+
47
+ ```sh
48
+ node .github/scripts/check-inbox.mjs -t issue-75
49
+ # With LANGFUSE_BASE_URL, LANGFUSE_PUBLIC_KEY and LANGFUSE_SECRET_KEY configured:
50
+ DSH_ISSUE75_LANGFUSE_LIVE=true node .github/scripts/check-inbox.mjs -t 'issue-75.*reports Goal continuation'
51
+ ```
24
52
 
25
53
  ## Configuration
26
54
 
@@ -51,7 +79,9 @@ Disabled by default. Configure via the profile's `cordis.patch.yml`:
51
79
  description: My dsh agent over A2A
52
80
  version: 0.1.0
53
81
  publicUrl: https://agent.example.com
54
- taskStore: redis # memory | redis | gcs
82
+ taskStore: redis # memory | filesystem | redis | gcs
83
+ filesystem:
84
+ root: /srv/dsh/a2a-tasks # required when taskStore: filesystem
55
85
  redis:
56
86
  url: redis://127.0.0.1:6379
57
87
  keyPrefix: a2a
@@ -128,6 +158,7 @@ Forwarding follows the owning task's active request bus. Background child events
128
158
  A2A task snapshots retain protocol history and artifacts; [dsh-storage](../dsh-storage) separately records the full conversation. Backends save on task-state transitions, including the complete reply at `input-required`, so token-rate stream events never reach Redis or GCS. While a task is working, in-process history accumulates text deltas for `GetTask` and resubscription; a restart before the next state change can lose those transient deltas.
129
159
 
130
160
  - `memory` (default) — in-process, lost on restart
161
+ - `filesystem` — private, atomically replaced JSON snapshots under `filesystem.root`; no additional peer dependency
131
162
  - `redis` — task JSON under `<keyPrefix>:tasks:<scope>:<taskId>` with a TTL; requires the `ioredis` peer
132
163
  - `gcs` — gzipped task JSON at `<prefix>/<scope>/<taskId>/metadata.json.gz`; requires the `@google-cloud/storage` peer. `archiveWorkspace()` (tar of the workspace) exists but is not wired to the lifecycle yet.
133
164
 
@@ -139,7 +170,9 @@ dsh ships **no authentication or authorization**. The server binds `127.0.0.1` b
139
170
 
140
171
  ## Compatibility
141
172
 
142
- 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,tools}@0.2.0-rc.2` — the attachment store is an optional peer) — the session peer floor is `>=0.2.0-rc.1` because child attribution uses `firstLifecycleSeq` to reject restored sessions; check the `TODO(verify)` markers in `src/` before upgrading dsh.
173
+ Optional Goal integration requires `@deepseek-ai/dsh-goal >=0.2.0-rc.2 || >=0.2.1-alpha.1` and the official Goal round driver (both tested at `0.2.1-rc.2`).
174
+
175
+ 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,tools}@0.2.1-rc.2` — the attachment store is an optional peer) — the session peer floor is `>=0.2.0-rc.1` because child attribution uses `firstLifecycleSeq` to reject restored sessions; check the `TODO(verify)` markers in `src/` before upgrading dsh.
143
176
 
144
177
  ## License
145
178
 
package/lib/inbox.js CHANGED
@@ -24,11 +24,43 @@ export class NativeInbox {
24
24
  await this.prepare(detail);
25
25
  return next();
26
26
  });
27
+ ctx.on('goal/changed', ({ agent, change }) => {
28
+ const run = this.contexts.get(agent.session.id);
29
+ if (!run)
30
+ return;
31
+ const previous = run.goalOwner;
32
+ if (change.goal?.phase === 'active' && change.goal.activation === 'armed') {
33
+ const task = previous?.goalId === change.ref.id ? previous.task : run.active;
34
+ if (task && !task.settled)
35
+ run.goalOwner = { goalId: change.ref.id, task };
36
+ }
37
+ else
38
+ run.goalOwner = undefined;
39
+ if (previous)
40
+ this.settle(run, previous.task);
41
+ });
42
+ ctx.on('agent/status', ({ agent, status }) => {
43
+ const run = this.contexts.get(agent.session.id);
44
+ if (status !== 'idle' || !run?.goalOwner)
45
+ return;
46
+ const { task } = run.goalOwner;
47
+ try {
48
+ const goal = ctx.get('goals')?.get(agent);
49
+ if (goal?.phase === 'active' && goal.activation === 'armed')
50
+ return;
51
+ run.goalOwner = undefined;
52
+ this.settle(run, task);
53
+ }
54
+ catch (error) {
55
+ run.goalOwner = undefined;
56
+ this.fail(task, agent.session.id, error);
57
+ }
58
+ });
27
59
  ctx.on('agent/inbox/claimed', ({ agent, message, turn }) => {
28
60
  const run = this.contexts.get(agent.session.id);
29
- if (!run || message.source.kind !== 'a2a')
61
+ if (!run)
30
62
  return;
31
- const task = run.tasks.get(message.source.taskId);
63
+ const task = this.owner(run, message);
32
64
  if (task && !task.settled) {
33
65
  run.claimed.add(task);
34
66
  if (!run.active)
@@ -69,6 +101,181 @@ export class NativeInbox {
69
101
  }
70
102
  throw new Error('Host input must address a live A2A task.');
71
103
  }
104
+ // A non-canceled removal keeps an interrupted move distinguishable from explicit cancellation.
105
+ moveInput(agent, message, target) {
106
+ const from = target === 'next-step' ? 'next-turn' : 'next-step';
107
+ const items = from === 'next-turn' ? agent.inbox.nextTurn : agent.inbox.nextStep;
108
+ const index = items.findIndex((item) => item.id === message.id);
109
+ if (index < 0)
110
+ throw new Error('Queued input has already been claimed.');
111
+ agent.session.append('agent/inbox/spliced', {
112
+ target: from,
113
+ start: index,
114
+ removedCount: 1,
115
+ inserted: [],
116
+ });
117
+ agent.inbox.append(target, message);
118
+ }
119
+ async promote(contextId, messageId, taskId) {
120
+ const run = await this.context(contextId);
121
+ const operation = run.admissionTail.then(() => this.promoteInput(run, messageId, taskId));
122
+ run.admissionTail = operation.catch(() => { });
123
+ run.promotions.set(messageId, operation);
124
+ const clear = () => {
125
+ if (run.promotions.get(messageId) === operation)
126
+ run.promotions.delete(messageId);
127
+ };
128
+ void operation.then(clear, clear);
129
+ return operation;
130
+ }
131
+ async promoteInput(run, messageId, taskId) {
132
+ const agent = run.entry.handle.agent;
133
+ const target = run.tasks.get(taskId);
134
+ const message = agent.inbox.nextTurn.find((item) => item.id === messageId);
135
+ if (message?.source.kind !== 'a2a')
136
+ throw new Error('Queued input has already been claimed.');
137
+ const source = run.tasks.get(message.source.taskId);
138
+ if (!target || !source)
139
+ throw new Error('Promotion requires live source and target tasks.');
140
+ const valid = () => !this.stopping &&
141
+ !this.bridge.isClearing(run.entry.sessionId) &&
142
+ !target.settled &&
143
+ !target.controller.signal.aborted &&
144
+ run.active === target &&
145
+ source !== target &&
146
+ !source.settled &&
147
+ !source.controller.signal.aborted &&
148
+ source.task.history.length === 1 &&
149
+ !source.pendingRequests &&
150
+ !run.claimed.has(source) &&
151
+ agent.inbox.nextTurn.some((item) => item === message);
152
+ if (!valid())
153
+ throw new Error('Promotion requires an unclaimed independent input and an active target.');
154
+ const original = Message.fromJSON(JSON.parse(message.source.message));
155
+ original.taskId = taskId;
156
+ original.referenceTaskIds = [...new Set([...original.referenceTaskIds, source.task.id])];
157
+ const promotionId = randomUUID();
158
+ const promoted = {
159
+ ...message,
160
+ source: {
161
+ ...message.source,
162
+ taskId,
163
+ target: 'next-step',
164
+ promotedFrom: source.task.id,
165
+ promotionId,
166
+ message: JSON.stringify(Message.toJSON(original)),
167
+ },
168
+ };
169
+ target.pendingRequests++;
170
+ try {
171
+ await this.checkpoint(target.task, target.context);
172
+ if (!valid())
173
+ throw new Error('Promotion raced input consumption or task settlement.');
174
+ target.task.history = [...target.task.history, original];
175
+ target.headers.set(message.id, source.headers.get(message.id));
176
+ if (!agent.inbox.replace(message.id, promoted))
177
+ throw new Error('Queued input has already been claimed.');
178
+ this.moveInput(agent, promoted, 'next-step');
179
+ try {
180
+ await this.flush(agent.session);
181
+ }
182
+ catch (error) {
183
+ // Until the session checkpoint succeeds, the independent task still owns this input.
184
+ const rollback = {
185
+ ...message,
186
+ source: { ...message.source, promotedFrom: source.task.id },
187
+ };
188
+ run.rolledBack.add(promotionId);
189
+ const live = this.contexts.get(run.entry.sessionId) === run &&
190
+ !this.stopping &&
191
+ !this.bridge.isClearing(run.entry.sessionId);
192
+ if (live && !source.settled && !source.controller.signal.aborted) {
193
+ if (this.pending(run).some((item) => item.id === message.id)) {
194
+ agent.inbox.replace(message.id, rollback);
195
+ this.moveInput(agent, rollback, 'next-turn');
196
+ }
197
+ else
198
+ agent.inbox.append('next-turn', rollback);
199
+ }
200
+ else if (live)
201
+ agent.inbox.remove(message.id);
202
+ target.task.history = target.task.history.filter((item) => item.messageId !== original.messageId);
203
+ target.headers.delete(message.id);
204
+ try {
205
+ if (live)
206
+ await this.flush(agent.session);
207
+ }
208
+ catch (rollbackError) {
209
+ console.error('[dsh-a2a] promotion rollback checkpoint failed:', rollbackError);
210
+ }
211
+ throw error;
212
+ }
213
+ if (this.stopping ||
214
+ this.contexts.get(run.entry.sessionId) !== run ||
215
+ this.bridge.isClearing(run.entry.sessionId))
216
+ throw new Error('Promotion was interrupted by context disposal.');
217
+ source.controller.abort();
218
+ try {
219
+ await this.checkpoint(target.task, target.context);
220
+ }
221
+ finally {
222
+ await this.cancel(source.task.id);
223
+ }
224
+ this.observeInput('promoted', run, promoted);
225
+ }
226
+ finally {
227
+ target.pendingRequests--;
228
+ this.settle(run, target);
229
+ }
230
+ }
231
+ /** Repair only an incomplete ownership move, never an ordinary DSH claim. */
232
+ async recoverPromotions(run, context) {
233
+ const agent = run.entry.handle.agent;
234
+ const transfers = new Map();
235
+ for (const event of agent.session.snapshotEvents()) {
236
+ if (event.type !== 'agent/inbox/spliced')
237
+ continue;
238
+ for (const message of event.data.inserted) {
239
+ if (message.source.kind !== 'a2a' || !message.source.promotedFrom)
240
+ continue;
241
+ transfers.set(message.id, {
242
+ message: message,
243
+ placed: event.data.target === message.source.target,
244
+ });
245
+ }
246
+ }
247
+ for (const { message, placed } of transfers.values()) {
248
+ if (!placed) {
249
+ const queued = this.pending(run).find((item) => item.id === message.id);
250
+ if (queued)
251
+ this.moveInput(agent, message, message.source.target);
252
+ else
253
+ agent.inbox.append(message.source.target, message);
254
+ }
255
+ if (this.pending(run).some((item) => item.id === message.id)) {
256
+ const recipient = await this.store.load(message.source.taskId, context);
257
+ if (!recipient || recipient.contextId !== agent.session.id)
258
+ throw new Error('Promoted A2A task is not accessible.');
259
+ const original = Message.fromJSON(JSON.parse(message.source.message));
260
+ if (!recipient.history.some((item) => item.messageId === original.messageId)) {
261
+ recipient.history.push(original);
262
+ await this.checkpoint(recipient, context);
263
+ }
264
+ }
265
+ if (message.source.promotedFrom === message.source.taskId)
266
+ continue;
267
+ const former = await this.store.load(message.source.promotedFrom, context);
268
+ if (!former || this.terminal(former))
269
+ continue;
270
+ former.status = {
271
+ state: TaskState.TASK_STATE_CANCELED,
272
+ message: undefined,
273
+ timestamp: new Date().toISOString(),
274
+ };
275
+ await this.flush(agent.session);
276
+ await this.checkpoint(former, context);
277
+ }
278
+ }
72
279
  context(contextId, context = new ServerCallContext()) {
73
280
  if (this.stopping)
74
281
  return Promise.reject(new Error('Native inbox is unloading.'));
@@ -85,7 +292,10 @@ export class NativeInbox {
85
292
  claimed: new Set(),
86
293
  admissions: new Set(),
87
294
  admissionTail: Promise.resolve(),
295
+ promotions: new Map(),
296
+ rolledBack: new Set(),
88
297
  };
298
+ await this.recoverPromotions(run, context);
89
299
  const snapshots = new Map();
90
300
  for (const message of this.pending(run)) {
91
301
  if (message.source.kind !== 'a2a' || snapshots.has(message.source.taskId))
@@ -137,21 +347,22 @@ export class NativeInbox {
137
347
  }
138
348
  observeInput(phase, run, message, fields = {}) {
139
349
  try {
140
- if (message.source.kind !== 'a2a')
350
+ const task = this.owner(run, message);
351
+ if (!task)
141
352
  return;
142
- const original = Message.fromJSON(JSON.parse(message.source.message));
353
+ const original = this.protocolMessage(run, task, message);
143
354
  const position = this.pending(run).findIndex((input) => input.id === message.id);
144
355
  this.observe(phase, run, {
145
- target: message.source.target,
356
+ target: message.source.kind === 'a2a' ? message.source.target : 'next-turn',
146
357
  input: original.parts
147
358
  .flatMap((part) => (part.content?.$case === 'text' ? [part.content.value] : []))
148
359
  .join('\n'),
149
360
  position: position < 0 ? undefined : position + 1,
150
- taskId: message.source.taskId,
361
+ taskId: task.task.id,
151
362
  messageId: original.messageId,
152
363
  referenceTaskIds: original.referenceTaskIds,
153
364
  nativeMessageId: message.id,
154
- queuedAt: message.source.queuedAt,
365
+ queuedAt: message.source.kind === 'a2a' ? message.source.queuedAt : undefined,
155
366
  ...fields,
156
367
  });
157
368
  }
@@ -468,13 +679,67 @@ export class NativeInbox {
468
679
  run.turnStart = undefined;
469
680
  }
470
681
  }
682
+ protocolMessage(run, task, message) {
683
+ if (message.source.kind === 'a2a')
684
+ return Message.fromJSON(JSON.parse(message.source.message));
685
+ return Message.fromJSON({
686
+ messageId: message.id,
687
+ taskId: task.task.id,
688
+ contextId: run.entry.sessionId,
689
+ role: 'ROLE_USER',
690
+ parts: message.content
691
+ .filter((block) => block.type === 'text')
692
+ .map((block) => ({ text: block.text })),
693
+ });
694
+ }
695
+ owner(run, message) {
696
+ if (message.source.kind === 'a2a')
697
+ return run.tasks.get(message.source.taskId);
698
+ if (message.source.kind !== 'goal' || message.source.round <= 0)
699
+ return;
700
+ const goal = this.ctx.get('goals')?.get(run.entry.handle.agent);
701
+ if (run.goalOwner &&
702
+ goal &&
703
+ run.goalOwner.goalId === message.source.goalId &&
704
+ goal.id === message.source.goalId &&
705
+ goal.revision === message.source.revision &&
706
+ goal.phase === 'active' &&
707
+ goal.activation === 'armed')
708
+ return run.goalOwner.task;
709
+ }
471
710
  async prepare(detail) {
472
711
  const run = this.contexts.get(detail.agent.session.id);
473
712
  if (!run)
474
713
  return;
475
714
  if (this.stopping)
476
715
  throw new Error('Inbox is unloading.');
477
- if (!run.active && detail.messages.some((message) => message.source.kind !== 'a2a'))
716
+ for (let index = detail.messages.length - 1; index >= 0; index--) {
717
+ const message = detail.messages[index];
718
+ if (message.source.kind !== 'a2a' || !message.source.promotionId)
719
+ continue;
720
+ const promotion = run.promotions.get(message.id);
721
+ if (promotion) {
722
+ let onAbort;
723
+ try {
724
+ await Promise.race([
725
+ promotion.catch(() => { }),
726
+ new Promise((_resolve, reject) => {
727
+ onAbort = () => reject(detail.signal.reason);
728
+ detail.signal.addEventListener('abort', onAbort, { once: true });
729
+ if (detail.signal.aborted)
730
+ onAbort();
731
+ }),
732
+ ]);
733
+ detail.signal.throwIfAborted();
734
+ }
735
+ finally {
736
+ detail.signal.removeEventListener('abort', onAbort);
737
+ }
738
+ }
739
+ if (run.rolledBack.delete(message.source.promotionId))
740
+ detail.messages.splice(index, 1);
741
+ }
742
+ if (!run.active && detail.messages.some((message) => !this.owner(run, message)))
478
743
  throw new Error('Native host input requires task-attributed a2aInbox.enqueue().');
479
744
  // Restarted steering and an ordinary queued turn may be claimed together by the core.
480
745
  // Requeue other owners before committing any user message, keeping task lifecycles separate.
@@ -495,9 +760,9 @@ export class NativeInbox {
495
760
  detail.messages.splice(index, 1);
496
761
  }
497
762
  for (const message of detail.messages) {
498
- if (message.source.kind !== 'a2a')
763
+ const task = this.owner(run, message);
764
+ if (!task && message.source.kind !== 'a2a')
499
765
  continue;
500
- const task = run.tasks.get(message.source.taskId);
501
766
  if (!task || task.settled)
502
767
  throw new Error('Queued input has no live A2A task; restore it before waking.');
503
768
  if (run.active && run.active !== task)
@@ -511,7 +776,7 @@ export class NativeInbox {
511
776
  agent: detail.agent,
512
777
  contextId: detail.agent.session.id,
513
778
  taskId: task.task.id,
514
- a2aMessage: Message.fromJSON(JSON.parse(message.source.message)),
779
+ a2aMessage: this.protocolMessage(run, task, message),
515
780
  dshMessageId: message.id,
516
781
  requestHeaders: task.headers.get(message.id),
517
782
  signal: detail.signal,
@@ -607,6 +872,7 @@ export class NativeInbox {
607
872
  }
608
873
  else if (!task.settlementReady ||
609
874
  task.pendingRequests ||
875
+ run.goalOwner?.task === task ||
610
876
  run.active === task ||
611
877
  this.pending(run).some((message) => message.source.kind === 'a2a' && message.source.taskId === task.task.id))
612
878
  return;
package/lib/index.js CHANGED
@@ -18,6 +18,7 @@ import { A2aBridge } from './bridge.js';
18
18
  import { DshAgentExecutor } from './executor.js';
19
19
  import { INBOX_EXTENSION, NativeInbox } from './inbox.js';
20
20
  import { startA2aServer } from './server.js';
21
+ import { FilesystemTaskStore } from './stores/filesystem.js';
21
22
  import { GcsTaskStore } from './stores/gcs.js';
22
23
  import { RedisTaskStore } from './stores/redis.js';
23
24
  import { MemoryTaskStore, SanitizedTaskStore } from './task-store.js';
@@ -70,9 +71,11 @@ export const Config = Schema.object({
70
71
  */
71
72
  taskStore: Schema.union([
72
73
  Schema.const('memory'),
74
+ Schema.const('filesystem'),
73
75
  Schema.const('redis'),
74
76
  Schema.const('gcs'),
75
77
  ]).default('memory'),
78
+ filesystem: Schema.object({ root: Schema.string().default('') }),
76
79
  redis: Schema.object({
77
80
  url: Schema.string().role('secret').default('redis://127.0.0.1:6379'),
78
81
  keyPrefix: Schema.string().default('a2a'),
@@ -198,6 +201,8 @@ export function apply(ctx, config) {
198
201
  }
199
202
  function createTaskStore(config) {
200
203
  switch (config.taskStore) {
204
+ case 'filesystem':
205
+ return new SanitizedTaskStore(new FilesystemTaskStore({ root: config.filesystem?.root ?? '' }));
201
206
  case 'redis':
202
207
  return new SanitizedTaskStore(new RedisTaskStore(config.redis));
203
208
  case 'gcs':
@@ -0,0 +1,86 @@
1
+ /** Local task snapshots, retained alongside the DSH session log. */
2
+ import { createHash, randomUUID } from 'node:crypto';
3
+ import { mkdir, open, readdir, readFile, rename, rm } from 'node:fs/promises';
4
+ import { join, resolve } from 'node:path';
5
+ import { Task } from '@a2a-js/sdk';
6
+ import { listShells, taskScopeKey } from '../task-store.js';
7
+ export class FilesystemTaskStore {
8
+ root;
9
+ constructor(config) {
10
+ if (!config.root)
11
+ throw new Error('[dsh-a2a] filesystem.root is required.');
12
+ this.root = resolve(config.root);
13
+ }
14
+ directory(context) {
15
+ return join(this.root, taskScopeKey(context));
16
+ }
17
+ path(id, context) {
18
+ return join(this.directory(context), `${createHash('sha256').update(id).digest('hex')}.json`);
19
+ }
20
+ async save(task, context) {
21
+ const directory = this.directory(context);
22
+ await mkdir(directory, { recursive: true, mode: 0o700 });
23
+ const temporary = join(directory, `${randomUUID()}.tmp`);
24
+ try {
25
+ const file = await open(temporary, 'wx', 0o600);
26
+ try {
27
+ await file.writeFile(JSON.stringify(Task.toJSON(task)));
28
+ await file.sync();
29
+ }
30
+ finally {
31
+ await file.close();
32
+ }
33
+ await rename(temporary, this.path(task.id, context));
34
+ // Windows cannot open directory handles through fs.open.
35
+ if (process.platform !== 'win32') {
36
+ const dir = await open(directory, 'r');
37
+ try {
38
+ await dir.sync();
39
+ }
40
+ finally {
41
+ await dir.close();
42
+ }
43
+ }
44
+ }
45
+ finally {
46
+ await rm(temporary, { force: true });
47
+ }
48
+ }
49
+ async load(id, context) {
50
+ try {
51
+ return Task.fromJSON(JSON.parse(await readFile(this.path(id, context), 'utf8')));
52
+ }
53
+ catch (error) {
54
+ if (error.code === 'ENOENT')
55
+ return;
56
+ throw error;
57
+ }
58
+ }
59
+ async delete(id, context) {
60
+ await rm(this.path(id, context), { force: true });
61
+ }
62
+ async list(params, context) {
63
+ let files;
64
+ try {
65
+ files = await readdir(this.directory(context));
66
+ }
67
+ catch (error) {
68
+ if (error.code !== 'ENOENT')
69
+ throw error;
70
+ files = [];
71
+ }
72
+ const tasks = [];
73
+ for (const file of files) {
74
+ if (!/^[a-f0-9]{64}\.json$/.test(file))
75
+ continue;
76
+ try {
77
+ tasks.push(Task.fromJSON(JSON.parse(await readFile(join(this.directory(context), file), 'utf8'))));
78
+ }
79
+ catch (error) {
80
+ if (error.code !== 'ENOENT')
81
+ throw error;
82
+ }
83
+ }
84
+ return listShells(tasks, params);
85
+ }
86
+ }
@@ -2,6 +2,7 @@ import { Message } from '@a2a-js/sdk';
2
2
  import { type ExecutionEventBus, type RequestContext, ServerCallContext } from '@a2a-js/sdk/server';
3
3
  import type { Context } from '@deepseek-ai/cordis';
4
4
  import type { Agent, AssistantStreamFrame, InboxTarget } from '@deepseek-ai/dsh-agent';
5
+ import type { GoalMessageSource } from '@deepseek-ai/dsh-goal';
5
6
  import type { ContentBlock } from '@deepseek-ai/dsh-llm/types';
6
7
  import type { Session, SessionEvent } from '@deepseek-ai/dsh-session';
7
8
  import type { A2aBridge } from './bridge.js';
@@ -35,10 +36,13 @@ interface InboxSource {
35
36
  message: string;
36
37
  target: InboxTarget;
37
38
  queuedAt?: number;
39
+ promotedFrom?: string;
40
+ promotionId?: string;
38
41
  }
39
42
  declare module '@deepseek-ai/dsh-llm/types' {
40
43
  interface MessageSourceMap {
41
44
  a2a: InboxSource;
45
+ goal: GoalMessageSource;
42
46
  }
43
47
  }
44
48
  declare module '@deepseek-ai/cordis' {
@@ -58,6 +62,8 @@ export interface A2aInbox {
58
62
  target?: InboxTarget;
59
63
  wakeup?: boolean;
60
64
  }): Promise<void>;
65
+ /** Transfer one unclaimed independent input into the active task. */
66
+ promote(contextId: string, messageId: string, taskId: string): Promise<void>;
61
67
  /** Standard cancellation is terminal and removes only this task's pending input. */
62
68
  cancel(taskId: string): Promise<string | undefined>;
63
69
  }
@@ -78,6 +84,11 @@ export declare class NativeInbox implements A2aInbox {
78
84
  target?: InboxTarget;
79
85
  wakeup?: boolean;
80
86
  }): Promise<void>;
87
+ private moveInput;
88
+ promote(contextId: string, messageId: string, taskId: string): Promise<void>;
89
+ private promoteInput;
90
+ /** Repair only an incomplete ownership move, never an ordinary DSH claim. */
91
+ private recoverPromotions;
81
92
  private context;
82
93
  private observe;
83
94
  private observeInput;
@@ -96,6 +107,8 @@ export declare class NativeInbox implements A2aInbox {
96
107
  execute(request: RequestContext, bus: ExecutionEventBus): Promise<void>;
97
108
  private restoreTask;
98
109
  private select;
110
+ private protocolMessage;
111
+ private owner;
99
112
  private prepare;
100
113
  private publish;
101
114
  onSessionEvent(session: Session, event: SessionEvent): boolean;
@@ -131,7 +131,12 @@ export declare const Config: Schema<Schemastery.ObjectS<{
131
131
  * A2A task snapshots, including protocol history. dsh-storage separately
132
132
  * owns the full conversation log. 'memory' loses tasks on restart.
133
133
  */
134
- taskStore: Schema<"memory" | "redis" | "gcs", "memory" | "redis" | "gcs">;
134
+ taskStore: Schema<"memory" | "filesystem" | "redis" | "gcs", "memory" | "filesystem" | "redis" | "gcs">;
135
+ filesystem: Schema<Schemastery.ObjectS<{
136
+ root: Schema<string, string>;
137
+ }>, Schemastery.ObjectT<{
138
+ root: Schema<string, string>;
139
+ }>>;
135
140
  redis: Schema<Schemastery.ObjectS<{
136
141
  url: Schema<string, string>;
137
142
  keyPrefix: Schema<string, string>;
@@ -213,7 +218,12 @@ export declare const Config: Schema<Schemastery.ObjectS<{
213
218
  * A2A task snapshots, including protocol history. dsh-storage separately
214
219
  * owns the full conversation log. 'memory' loses tasks on restart.
215
220
  */
216
- taskStore: Schema<"memory" | "redis" | "gcs", "memory" | "redis" | "gcs">;
221
+ taskStore: Schema<"memory" | "filesystem" | "redis" | "gcs", "memory" | "filesystem" | "redis" | "gcs">;
222
+ filesystem: Schema<Schemastery.ObjectS<{
223
+ root: Schema<string, string>;
224
+ }>, Schemastery.ObjectT<{
225
+ root: Schema<string, string>;
226
+ }>>;
217
227
  redis: Schema<Schemastery.ObjectS<{
218
228
  url: Schema<string, string>;
219
229
  keyPrefix: Schema<string, string>;
@@ -261,7 +271,10 @@ export interface A2aPluginConfig {
261
271
  version: string;
262
272
  publicUrl: string;
263
273
  };
264
- taskStore: 'memory' | 'redis' | 'gcs';
274
+ taskStore: 'memory' | 'filesystem' | 'redis' | 'gcs';
275
+ filesystem?: {
276
+ root: string;
277
+ };
265
278
  redis: {
266
279
  url: string;
267
280
  keyPrefix: string;
@@ -0,0 +1,15 @@
1
+ import { type ListTasksRequest, type ListTasksResponse, Task } from '@a2a-js/sdk';
2
+ import type { ServerCallContext } from '@a2a-js/sdk/server';
3
+ import { type ManagedTaskStore } from '../task-store.js';
4
+ export declare class FilesystemTaskStore implements ManagedTaskStore {
5
+ private readonly root;
6
+ constructor(config: {
7
+ root: string;
8
+ });
9
+ private directory;
10
+ private path;
11
+ save(task: Task, context: ServerCallContext): Promise<void>;
12
+ load(id: string, context: ServerCallContext): Promise<Task | undefined>;
13
+ delete(id: string, context: ServerCallContext): Promise<void>;
14
+ list(params: ListTasksRequest, context: ServerCallContext): Promise<ListTasksResponse>;
15
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amaster.ai/dsh-a2a",
3
- "version": "0.1.27",
3
+ "version": "0.1.28",
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,13 +39,14 @@
39
39
  "peerDependencies": {
40
40
  "@deepseek-ai/cordis": "^4.0.1",
41
41
  "@deepseek-ai/dsh-agent": ">=0.2.0-rc.2 || >=0.2.1-alpha.1",
42
- "@deepseek-ai/dsh-attachment": ">=0.1.5-rc.1 || >=0.1.6-alpha.1 || >=0.1.7-alpha.1 || >=0.2.0-rc.1",
43
- "@deepseek-ai/dsh-llm": ">=0.1.5-rc.1 || >=0.1.6-alpha.1 || >=0.1.7-alpha.1 || >=0.2.0-rc.1",
44
- "@deepseek-ai/dsh-session": ">=0.2.0-rc.1",
45
- "@deepseek-ai/dsh-tools": ">=0.1.5-rc.1 || >=0.1.6-alpha.1 || >=0.1.7-alpha.1 || >=0.2.0-rc.1",
46
- "@deepseek-ai/dsh-user-approval": ">=0.1.6-alpha.2 || >=0.1.7-alpha.1 || >=0.2.0-rc.1",
42
+ "@deepseek-ai/dsh-attachment": ">=0.1.5-rc.1 || >=0.1.6-alpha.1 || >=0.1.7-alpha.1 || >=0.2.0-rc.1 || >=0.2.1-rc.1",
43
+ "@deepseek-ai/dsh-llm": ">=0.1.5-rc.1 || >=0.1.6-alpha.1 || >=0.1.7-alpha.1 || >=0.2.0-rc.1 || >=0.2.1-rc.1",
44
+ "@deepseek-ai/dsh-session": ">=0.2.0-rc.1 || >=0.2.1-rc.1",
45
+ "@deepseek-ai/dsh-tools": ">=0.1.5-rc.1 || >=0.1.6-alpha.1 || >=0.1.7-alpha.1 || >=0.2.0-rc.1 || >=0.2.1-rc.1",
46
+ "@deepseek-ai/dsh-user-approval": ">=0.1.6-alpha.2 || >=0.1.7-alpha.1 || >=0.2.0-rc.1 || >=0.2.1-rc.1",
47
47
  "@google-cloud/storage": "^7.0.0",
48
- "ioredis": "^5.0.0"
48
+ "ioredis": "^5.0.0",
49
+ "@deepseek-ai/dsh-goal": ">=0.2.0-rc.2 || >=0.2.1-alpha.1"
49
50
  },
50
51
  "peerDependenciesMeta": {
51
52
  "@deepseek-ai/dsh-attachment": {
@@ -59,6 +60,9 @@
59
60
  },
60
61
  "ioredis": {
61
62
  "optional": true
63
+ },
64
+ "@deepseek-ai/dsh-goal": {
65
+ "optional": true
62
66
  }
63
67
  },
64
68
  "devDependencies": {
@@ -76,7 +80,8 @@
76
80
  "@types/node": "^22.0.0",
77
81
  "ioredis": "^5.0.0",
78
82
  "typescript": "^5.6.3",
79
- "vitest": "^4.1.11"
83
+ "vitest": "^4.1.11",
84
+ "@deepseek-ai/dsh-goal": "0.2.1-rc.2"
80
85
  },
81
86
  "engines": {
82
87
  "node": "^20.19 || ^22.12 || >=24.0"