@amaster.ai/dsh-a2a 0.1.15 → 0.1.16

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
@@ -70,11 +70,13 @@ Disabled by default. Configure via the profile's `cordis.patch.yml`:
70
70
 
71
71
  ## Task stores
72
72
 
73
- A2A **task state** (status + metadata) is separate from conversation history — use [dsh-storage](../dsh-storage) for the latter. Every backend persists a sanitized metadata shell (history/artifacts stripped) and saves only on task-state transitions, so token-rate stream events never reach the backend.
73
+ 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.
74
74
 
75
75
  - `memory` (default) — in-process, lost on restart
76
- - `redis` — task JSON under `<keyPrefix>:tasks:<taskId>` with a TTL; requires the `ioredis` peer
77
- - `gcs` — gzipped task JSON at `<prefix>/<taskId>/metadata.json.gz` (same layout as the source project's `GCSTaskStore`); requires the `@google-cloud/storage` peer. `archiveWorkspace()` (tar of the workspace) exists but is not wired to the lifecycle yet.
76
+ - `redis` — task JSON under `<keyPrefix>:tasks:<scope>:<taskId>` with a TTL; requires the `ioredis` peer
77
+ - `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.
78
+
79
+ `<scope>` is a hash of the A2A tenant and user. Older unscoped Redis/GCS task records are not read after this upgrade; migrate known records into their scope or let them expire before rollout.
78
80
 
79
81
  ## Security
80
82
 
@@ -82,7 +84,7 @@ dsh ships **no authentication or authorization**. The server binds `127.0.0.1` b
82
84
 
83
85
  ## Compatibility
84
86
 
85
- 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.7-rc.2` — the attachment store is an optional peer) — check the `TODO(verify)` markers in `src/` before upgrading dsh.
87
+ 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.2.0-rc.1` — the attachment store is an optional peer) — check the `TODO(verify)` markers in `src/` before upgrading dsh.
86
88
 
87
89
  ## License
88
90
 
package/lib/index.js CHANGED
@@ -55,8 +55,8 @@ export const Config = Schema.object({
55
55
  publicUrl: Schema.string().default(''),
56
56
  }),
57
57
  /**
58
- * A2A task state store (task metadata only — conversation history is
59
- * dsh-storage's ai_messages, not this). 'memory' loses tasks on restart.
58
+ * A2A task snapshots, including protocol history. dsh-storage separately
59
+ * owns the full conversation log. 'memory' loses tasks on restart.
60
60
  */
61
61
  taskStore: Schema.union([
62
62
  Schema.const('memory'),
package/lib/server.js CHANGED
@@ -70,10 +70,61 @@ export async function startA2aServer(options) {
70
70
  let task = taskBuses.get(taskId);
71
71
  if (!task) {
72
72
  const broadcast = new DefaultExecutionEventBus();
73
- task = { broadcast, requests: new Set() };
74
- taskBuses.set(taskId, task);
73
+ const created = {
74
+ broadcast,
75
+ requests: new Set(),
76
+ replay: [],
77
+ };
78
+ task = created;
79
+ taskBuses.set(taskId, created);
75
80
  broadcast.on('event', (event) => {
81
+ if (event.kind === 'task') {
82
+ created.replay = [event];
83
+ }
84
+ else if (event.kind === 'statusUpdate') {
85
+ const previous = created.replay.at(-1);
86
+ const priorMessage = previous?.kind === 'statusUpdate' && previous.data.status?.message;
87
+ const message = event.data.status?.message;
88
+ const before = priorMessage ? priorMessage.parts[0]?.content : undefined;
89
+ const delta = message?.parts[0]?.content;
90
+ if (previous?.kind === 'statusUpdate' &&
91
+ previous.data.status?.state === TaskState.TASK_STATE_WORKING &&
92
+ event.data.status?.state === TaskState.TASK_STATE_WORKING &&
93
+ priorMessage &&
94
+ message &&
95
+ priorMessage.messageId === message.messageId &&
96
+ before?.$case === 'text' &&
97
+ delta?.$case === 'text') {
98
+ created.replay[created.replay.length - 1] = {
99
+ ...event,
100
+ data: {
101
+ ...event.data,
102
+ status: {
103
+ ...event.data.status,
104
+ message: {
105
+ ...message,
106
+ parts: [
107
+ {
108
+ ...message.parts[0],
109
+ content: { $case: 'text', value: before.value + delta.value },
110
+ },
111
+ ],
112
+ },
113
+ },
114
+ },
115
+ };
116
+ }
117
+ else
118
+ created.replay.push(event);
119
+ }
120
+ else
121
+ created.replay.push(event);
76
122
  const state = event.kind === 'statusUpdate' ? event.data.status?.state : undefined;
123
+ // Keep the settling event until the persisted snapshot catches up.
124
+ if (state !== undefined &&
125
+ state !== TaskState.TASK_STATE_WORKING &&
126
+ state !== TaskState.TASK_STATE_SUBMITTED)
127
+ created.replay = [event];
77
128
  if (state !== undefined &&
78
129
  [
79
130
  TaskState.TASK_STATE_COMPLETED,
@@ -154,6 +205,99 @@ export async function startA2aServer(options) {
154
205
  release?.();
155
206
  }
156
207
  }
208
+ async *resubscribe(...args) {
209
+ const task = taskBuses.get(args[0].id);
210
+ if (!task) {
211
+ yield* super.resubscribe(...args);
212
+ return;
213
+ }
214
+ const stream = super.resubscribe(...args);
215
+ try {
216
+ const replay = task.replay.slice();
217
+ const last = replay.at(-1);
218
+ const settledAtAttach = replay.length === 1 &&
219
+ last?.kind === 'statusUpdate' &&
220
+ last.data.status?.state !== TaskState.TASK_STATE_WORKING &&
221
+ last.data.status?.state !== TaskState.TASK_STATE_SUBMITTED;
222
+ const during = [];
223
+ const record = (event) => during.push(event);
224
+ task.broadcast.on('event', record);
225
+ let first;
226
+ try {
227
+ // The SDK attaches its live queue before awaiting the TaskStore load.
228
+ first = await stream.next();
229
+ }
230
+ finally {
231
+ task.broadcast.off('event', record);
232
+ }
233
+ if (first.done)
234
+ return;
235
+ const payload = first.value.payload;
236
+ const queuedAnchor = during.find((event) => event.kind === 'task');
237
+ const anchor = queuedAnchor ?? replay.find((event) => event.kind === 'task');
238
+ const replayNeeded = (!settledAtAttach && !during.some((event) => event.kind === 'task')) ||
239
+ (payload?.$case === 'task' &&
240
+ payload.value.status?.state === TaskState.TASK_STATE_WORKING &&
241
+ !during.some((event) => event.kind === 'task'));
242
+ const replayedIds = new Set((replayNeeded ? [...replay, ...during] : during).flatMap((event) => event.kind === 'statusUpdate' && event.data.status?.message
243
+ ? [event.data.status.message.messageId]
244
+ : []));
245
+ if (payload?.$case === 'task') {
246
+ const initial = payload.value;
247
+ const useAnchor = anchor?.kind === 'task' &&
248
+ (initial.status?.state === TaskState.TASK_STATE_INPUT_REQUIRED ||
249
+ (anchor.data.status?.timestamp &&
250
+ initial.status?.timestamp &&
251
+ Date.parse(anchor.data.status.timestamp) > Date.parse(initial.status.timestamp)));
252
+ const history = initial.history?.filter((message) => !replayedIds.has(message.messageId)) ?? [];
253
+ if (useAnchor && anchor.kind === 'task') {
254
+ const ids = new Set(history.map((message) => message.messageId));
255
+ for (const message of anchor.data.history ?? [])
256
+ if (!ids.has(message.messageId))
257
+ history.push(message);
258
+ }
259
+ yield {
260
+ payload: {
261
+ $case: 'task',
262
+ value: {
263
+ ...initial,
264
+ history,
265
+ status: useAnchor
266
+ ? anchor.data.status
267
+ : initial.status?.message && replayedIds.has(initial.status.message.messageId)
268
+ ? { ...initial.status, message: undefined }
269
+ : initial.status,
270
+ },
271
+ },
272
+ };
273
+ }
274
+ else {
275
+ yield first.value;
276
+ }
277
+ for (const event of replayNeeded ? replay : []) {
278
+ if (event.kind === 'task')
279
+ continue;
280
+ if (event.kind === 'statusUpdate')
281
+ yield { payload: { $case: 'statusUpdate', value: event.data } };
282
+ else if (event.kind === 'artifactUpdate')
283
+ yield { payload: { $case: 'artifactUpdate', value: event.data } };
284
+ }
285
+ let skipQueuedAnchor = Boolean(queuedAnchor);
286
+ for await (const value of stream) {
287
+ if (skipQueuedAnchor &&
288
+ queuedAnchor?.kind === 'task' &&
289
+ value.payload?.$case === 'task' &&
290
+ value.payload.value.status?.timestamp === queuedAnchor.data.status?.timestamp) {
291
+ skipQueuedAnchor = false;
292
+ continue;
293
+ }
294
+ yield value;
295
+ }
296
+ }
297
+ finally {
298
+ await stream.return(undefined);
299
+ }
300
+ }
157
301
  })(card, options.taskStore, options.executor, busManager);
158
302
  const app = express();
159
303
  // The SDK's jsonRpcHandler parses bodies with express.json()'s 100kb default;
package/lib/stores/gcs.js CHANGED
@@ -2,9 +2,9 @@
2
2
  * GCS TaskStore for @a2a-js/sdk. `@google-cloud/storage` is an optional peer.
3
3
  *
4
4
  * Ported from the source project's packages/a2a-server/src/persistence/gcs.ts:
5
- * tasks/{taskId}/metadata.json.gz — gzipped task state (pre-sanitized by
6
- * SanitizedTaskStore: no history)
7
- * tasks/{taskId}/workspace.tar.gz — tar of the task's workspace directory
5
+ * tasks/{scope}/{taskId}/metadata.json.gz — gzipped task snapshot (written on
6
+ * state transitions)
7
+ * tasks/{scope}/{taskId}/workspace.tar.gz — tar of the task's workspace directory
8
8
  *
9
9
  * The workspace archive is written explicitly via `archiveWorkspace()` (not
10
10
  * wired to any lifecycle event yet — A2A tasks share one configured cwd, so a
@@ -18,7 +18,7 @@ import { join } from 'node:path';
18
18
  import { pipeline } from 'node:stream/promises';
19
19
  import { promisify } from 'node:util';
20
20
  import { gunzipSync, gzipSync } from 'node:zlib';
21
- import { listShells } from '../task-store.js';
21
+ import { listShells, taskScopeKey } from '../task-store.js';
22
22
  export class GcsTaskStore {
23
23
  config;
24
24
  bucket = null;
@@ -32,39 +32,41 @@ export class GcsTaskStore {
32
32
  const storage = new mod.Storage(this.config.keyFilename ? { keyFilename: this.config.keyFilename } : undefined);
33
33
  this.bucket = storage.bucket(this.config.bucket);
34
34
  }
35
- async save(task) {
35
+ async save(task, context) {
36
36
  if (!this.bucket)
37
37
  return;
38
38
  await this.bucket
39
- .file(`${this.prefix}/${task.id}/metadata.json.gz`)
39
+ .file(`${this.prefix}/${taskScopeKey(context)}/${task.id}/metadata.json.gz`)
40
40
  .save(gzipSync(JSON.stringify(task)), { contentType: 'application/gzip', resumable: false });
41
41
  }
42
- async load(taskId) {
42
+ async load(taskId, context) {
43
43
  if (!this.bucket)
44
44
  return undefined;
45
- const file = this.bucket.file(`${this.prefix}/${taskId}/metadata.json.gz`);
45
+ const file = this.bucket.file(`${this.prefix}/${taskScopeKey(context)}/${taskId}/metadata.json.gz`);
46
46
  const [exists] = await file.exists();
47
47
  if (!exists)
48
48
  return undefined;
49
49
  const [buf] = await file.download();
50
50
  return JSON.parse(gunzipSync(buf).toString('utf8'));
51
51
  }
52
- async delete(taskId) {
52
+ async delete(taskId, context) {
53
53
  if (!this.bucket)
54
54
  return;
55
55
  await Promise.all([
56
56
  this.bucket
57
- .file(`${this.prefix}/${taskId}/metadata.json.gz`)
57
+ .file(`${this.prefix}/${taskScopeKey(context)}/${taskId}/metadata.json.gz`)
58
58
  .delete({ ignoreNotFound: true }),
59
59
  this.bucket
60
- .file(`${this.prefix}/${taskId}/workspace.tar.gz`)
60
+ .file(`${this.prefix}/${taskScopeKey(context)}/${taskId}/workspace.tar.gz`)
61
61
  .delete({ ignoreNotFound: true }),
62
62
  ]);
63
63
  }
64
- async list(params) {
64
+ async list(params, context) {
65
65
  if (!this.bucket)
66
66
  return { tasks: [], nextPageToken: '', pageSize: 0, totalSize: 0 };
67
- const [files] = await this.bucket.getFiles({ prefix: `${this.prefix}/` });
67
+ const [files] = await this.bucket.getFiles({
68
+ prefix: `${this.prefix}/${taskScopeKey(context)}/`,
69
+ });
68
70
  const shells = [];
69
71
  for (const file of files) {
70
72
  if (!file.name.endsWith('/metadata.json.gz'))
@@ -75,18 +77,18 @@ export class GcsTaskStore {
75
77
  return listShells(shells, params);
76
78
  }
77
79
  /**
78
- * Tar + gzip the workspace directory to `tasks/{taskId}/workspace.tar.gz`.
80
+ * Tar + gzip the workspace directory to `tasks/{scope}/{taskId}/workspace.tar.gz`.
79
81
  * TODO: replace the tar subprocess with a streaming tar library (e.g. `tar`)
80
82
  * to control inclusion and avoid shelling out.
81
83
  */
82
- async archiveWorkspace(taskId, cwd) {
84
+ async archiveWorkspace(taskId, cwd, context) {
83
85
  if (!this.bucket)
84
86
  return;
85
87
  const dir = await mkdtemp(join(tmpdir(), 'dsh-a2a-archive-'));
86
88
  const tarball = join(dir, 'workspace.tar.gz');
87
89
  try {
88
90
  await promisify(execFile)('tar', ['-czf', tarball, '-C', cwd, '.']);
89
- const dest = this.bucket.file(`${this.prefix}/${taskId}/workspace.tar.gz`);
91
+ const dest = this.bucket.file(`${this.prefix}/${taskScopeKey(context)}/${taskId}/workspace.tar.gz`);
90
92
  await pipeline(createReadStream(tarball), dest.createWriteStream());
91
93
  }
92
94
  finally {
@@ -2,11 +2,11 @@
2
2
  * Redis TaskStore for @a2a-js/sdk. `ioredis` is an optional peer dependency.
3
3
  *
4
4
  * Ported from the source project's packages/a2a-server/src/persistence/redis.ts:
5
- * task state JSON under `a2a:tasks:{taskId}` with a TTL. Tasks arrive
6
- * pre-sanitized (metadata shell, no history) from SanitizedTaskStore —
7
- * conversation history is dsh-storage's job (ai_messages), not this store's.
5
+ * task state JSON under `a2a:tasks:{scope}:{taskId}` with a TTL. Task
6
+ * snapshots arrive from SanitizedTaskStore at state transitions;
7
+ * dsh-storage separately owns the full conversation log (ai_messages).
8
8
  */
9
- import { listShells } from '../task-store.js';
9
+ import { listShells, taskScopeKey } from '../task-store.js';
10
10
  export class RedisTaskStore {
11
11
  config;
12
12
  redis = null;
@@ -21,31 +21,31 @@ export class RedisTaskStore {
21
21
  const mod = await import('ioredis');
22
22
  this.redis = new mod.Redis(this.config.url);
23
23
  }
24
- taskKey(taskId) {
25
- return `${this.prefix}:tasks:${taskId}`;
24
+ taskKey(taskId, context) {
25
+ return `${this.prefix}:tasks:${taskScopeKey(context)}:${taskId}`;
26
26
  }
27
- async save(task) {
27
+ async save(task, context) {
28
28
  if (!this.redis)
29
29
  return;
30
- await this.redis.set(this.taskKey(task.id), JSON.stringify(task), 'EX', this.ttl);
30
+ await this.redis.set(this.taskKey(task.id, context), JSON.stringify(task), 'EX', this.ttl);
31
31
  }
32
- async load(taskId) {
32
+ async load(taskId, context) {
33
33
  if (!this.redis)
34
34
  return undefined;
35
- const raw = await this.redis.get(this.taskKey(taskId));
35
+ const raw = await this.redis.get(this.taskKey(taskId, context));
36
36
  return raw ? JSON.parse(raw) : undefined;
37
37
  }
38
- async delete(taskId) {
39
- await this.redis?.del(this.taskKey(taskId));
38
+ async delete(taskId, context) {
39
+ await this.redis?.del(this.taskKey(taskId, context));
40
40
  }
41
- async list(params) {
41
+ async list(params, context) {
42
42
  if (!this.redis)
43
43
  return { tasks: [], nextPageToken: '', pageSize: 0, totalSize: 0 };
44
44
  // Task shells are few and TTL-bound — a paged SCAN over the prefix is ample.
45
45
  const shells = [];
46
46
  let cursor = '0';
47
47
  do {
48
- const [next, keys] = await this.redis.scan(cursor, 'MATCH', `${this.prefix}:tasks:*`, 'COUNT', 100);
48
+ const [next, keys] = await this.redis.scan(cursor, 'MATCH', `${this.prefix}:tasks:${taskScopeKey(context)}:*`, 'COUNT', 100);
49
49
  cursor = next;
50
50
  for (const key of keys) {
51
51
  const raw = await this.redis.get(key);
package/lib/task-store.js CHANGED
@@ -5,28 +5,32 @@
5
5
  * event — including each streamed text delta — and folds status messages into
6
6
  * `task.history`. Two constraints follow from this plugin's design:
7
7
  *
8
- * 1. A2A stores carry task STATE only (task metadata ≠ conversation history —
9
- * history is dsh-storage's `ai_messages`). {@link sanitizeTask} strips
10
- * `history`/`artifacts` before anything reaches a backend.
8
+ * 1. A2A task history is a protocol snapshot, separate from dsh-storage's
9
+ * conversation log. Persist it at state changes so tasks survive restart.
11
10
  * 2. Token-rate saves would hammer Redis and put a GCS upload in the hot path
12
11
  * of the SSE stream (ResultManager awaits every save). {@link
13
12
  * SanitizedTaskStore} collapses saves to task-state CHANGES, which are the
14
- * only transitions a `tasks/get` reader can act on anyway.
13
+ * durable checkpoints; active deltas remain available from this process.
15
14
  *
16
15
  * A2A 1.0 added `list()` to the TaskStore contract; {@link listShells} holds
17
16
  * the shared filter/sort/paginate logic so the backends only enumerate shells.
18
17
  */
18
+ import { createHash } from 'node:crypto';
19
+ import { TaskState } from '@a2a-js/sdk';
19
20
  import { resolveUserScope } from '@a2a-js/sdk/server';
20
- /** The persistent shape: a metadata shell with empty history/artifacts. */
21
+ /** Omit an unfinished current message from a WORKING snapshot. */
21
22
  export function sanitizeTask(task) {
22
- return {
23
- id: task.id,
24
- contextId: task.contextId,
25
- status: task.status,
26
- metadata: task.metadata,
27
- history: [],
28
- artifacts: [],
29
- };
23
+ const current = task.status?.state === TaskState.TASK_STATE_WORKING && task.status.message?.messageId;
24
+ return current
25
+ ? { ...task, history: task.history.filter((message) => message.messageId !== current) }
26
+ : task;
27
+ }
28
+ function scopeOf(context) {
29
+ return JSON.stringify([context?.tenant ?? '', context ? resolveUserScope(context) : 'unknown']);
30
+ }
31
+ /** A safe path/key segment for the SDK's tenant and owner scope. */
32
+ export function taskScopeKey(context) {
33
+ return createHash('sha256').update(scopeOf(context)).digest('hex');
30
34
  }
31
35
  /** The SDK memory store has no delete operation; keep the same shell contract. */
32
36
  export class MemoryTaskStore {
@@ -54,11 +58,12 @@ export class MemoryTaskStore {
54
58
  this.bucket(context).delete(taskId);
55
59
  }
56
60
  }
57
- /** Strips history/artifacts and saves only on task-state transitions. */
61
+ /** Saves task snapshots on state transitions while keeping active deltas in process. */
58
62
  export class SanitizedTaskStore {
59
63
  inner;
60
64
  lastState = new Map();
61
65
  writes = new Map();
66
+ liveHistory = new Map();
62
67
  // ponytail: deleted ids stay tombstoned until plugin unload; use expiring ids if clear volume grows.
63
68
  deleted = new Set();
64
69
  constructor(inner) {
@@ -68,11 +73,13 @@ export class SanitizedTaskStore {
68
73
  await this.inner.init?.();
69
74
  }
70
75
  async close() {
76
+ this.liveHistory.clear();
71
77
  await this.inner.close?.();
72
78
  }
73
79
  /** Drop SDK saves that may arrive after a clear rejects an in-flight request. */
74
80
  suppress(taskId) {
75
81
  this.deleted.add(taskId);
82
+ this.liveHistory.delete(taskId);
76
83
  }
77
84
  async delete(taskId, context) {
78
85
  this.suppress(taskId);
@@ -84,17 +91,67 @@ export class SanitizedTaskStore {
84
91
  async save(task, context) {
85
92
  if (this.deleted.has(task.id))
86
93
  return;
94
+ const history = structuredClone(task.history ?? []);
95
+ const current = task.status?.message;
96
+ if (task.status?.state === TaskState.TASK_STATE_WORKING && current) {
97
+ const previous = this.liveHistory
98
+ .get(task.id)
99
+ ?.history.find((message) => message.messageId === current.messageId);
100
+ const index = history.findIndex((message) => message.messageId === current.messageId);
101
+ const before = previous?.parts[0]?.content;
102
+ const delta = current.parts[0]?.content;
103
+ if (index >= 0 && before?.$case === 'text' && delta?.$case === 'text') {
104
+ history[index] = {
105
+ ...history[index],
106
+ parts: [
107
+ {
108
+ ...history[index].parts[0],
109
+ content: { $case: 'text', value: before.value + delta.value },
110
+ },
111
+ ],
112
+ };
113
+ }
114
+ }
115
+ if (task.status?.state === TaskState.TASK_STATE_INPUT_REQUIRED && task.status.message) {
116
+ const final = structuredClone(task.status.message);
117
+ const index = history.findIndex((message) => message.messageId === final.messageId);
118
+ if (index >= 0)
119
+ history[index] = final;
120
+ else
121
+ history.push(final);
122
+ }
123
+ this.liveHistory.set(task.id, {
124
+ scope: scopeOf(context),
125
+ history,
126
+ });
87
127
  const state = task.status?.state;
88
128
  if (state !== undefined && this.lastState.get(task.id) === state)
89
129
  return;
90
- if (state !== undefined)
91
- this.lastState.set(task.id, state);
92
- const write = (this.writes.get(task.id) ?? Promise.resolve()).then(() => this.inner.save(sanitizeTask(task), context));
130
+ const write = (this.writes.get(task.id) ?? Promise.resolve()).then(() => this.inner.save(sanitizeTask({ ...task, history }), context));
93
131
  this.writes.set(task.id, write.catch(() => { }));
94
132
  await write;
133
+ if (state === TaskState.TASK_STATE_WORKING || state === TaskState.TASK_STATE_SUBMITTED) {
134
+ this.lastState.set(task.id, state);
135
+ }
136
+ else {
137
+ this.lastState.delete(task.id);
138
+ this.writes.delete(task.id);
139
+ this.liveHistory.delete(task.id);
140
+ }
95
141
  }
96
- load(taskId, context) {
97
- return this.inner.load(taskId, context);
142
+ async load(taskId, context) {
143
+ const task = await this.inner.load(taskId, context);
144
+ const live = this.liveHistory.get(taskId);
145
+ if (!task || live?.scope !== scopeOf(context))
146
+ return task;
147
+ const history = structuredClone(live.history);
148
+ const current = task.status?.message;
149
+ const message = current && history.find((entry) => entry.messageId === current.messageId);
150
+ return {
151
+ ...task,
152
+ history,
153
+ status: message && task.status ? { ...task.status, message } : task.status,
154
+ };
98
155
  }
99
156
  list(params, context) {
100
157
  return this.inner.list(params, context);
package/lib/translator.js CHANGED
@@ -5,11 +5,11 @@
5
5
  * deliberately free of cordis/HTTP concerns so the mapping is unit-testable
6
6
  * without a harness. The mapping ports the source project's
7
7
  * `packages/a2a-server/src/agent/task.ts` event switch onto dsh's
8
- * `SessionEventMap` (verified against @deepseek-ai/dsh-session@0.1.7-rc.2),
8
+ * `SessionEventMap` (verified against @deepseek-ai/dsh-session@0.2.0-rc.1),
9
9
  * emitting the A2A 1.0 data model (@a2a-js/sdk 1.1.0). As of the V3 session
10
10
  * format the durable log carries no incremental chunks — live text/reasoning
11
11
  * deltas arrive as `agent/assistant-stream` chunk frames
12
- * (@deepseek-ai/dsh-agent@0.1.7-rc.2) through {@link handleStreamFrame}:
12
+ * (@deepseek-ai/dsh-agent@0.2.0-rc.1) through {@link handleStreamFrame}:
13
13
  *
14
14
  * turn/start → statusUpdate(WORKING), ids rotate
15
15
  * stream chunk (text-delta) → statusUpdate(WORKING, text part),
@@ -108,8 +108,8 @@ export declare const Config: Schema<Schemastery.ObjectS<{
108
108
  publicUrl: Schema<string, string>;
109
109
  }>>;
110
110
  /**
111
- * A2A task state store (task metadata only — conversation history is
112
- * dsh-storage's ai_messages, not this). 'memory' loses tasks on restart.
111
+ * A2A task snapshots, including protocol history. dsh-storage separately
112
+ * owns the full conversation log. 'memory' loses tasks on restart.
113
113
  */
114
114
  taskStore: Schema<"memory" | "redis" | "gcs", "memory" | "redis" | "gcs">;
115
115
  redis: Schema<Schemastery.ObjectS<{
@@ -173,8 +173,8 @@ export declare const Config: Schema<Schemastery.ObjectS<{
173
173
  publicUrl: Schema<string, string>;
174
174
  }>>;
175
175
  /**
176
- * A2A task state store (task metadata only — conversation history is
177
- * dsh-storage's ai_messages, not this). 'memory' loses tasks on restart.
176
+ * A2A task snapshots, including protocol history. dsh-storage separately
177
+ * owns the full conversation log. 'memory' loses tasks on restart.
178
178
  */
179
179
  taskStore: Schema<"memory" | "redis" | "gcs", "memory" | "redis" | "gcs">;
180
180
  redis: Schema<Schemastery.ObjectS<{
@@ -2,19 +2,20 @@
2
2
  * GCS TaskStore for @a2a-js/sdk. `@google-cloud/storage` is an optional peer.
3
3
  *
4
4
  * Ported from the source project's packages/a2a-server/src/persistence/gcs.ts:
5
- * tasks/{taskId}/metadata.json.gz — gzipped task state (pre-sanitized by
6
- * SanitizedTaskStore: no history)
7
- * tasks/{taskId}/workspace.tar.gz — tar of the task's workspace directory
5
+ * tasks/{scope}/{taskId}/metadata.json.gz — gzipped task snapshot (written on
6
+ * state transitions)
7
+ * tasks/{scope}/{taskId}/workspace.tar.gz — tar of the task's workspace directory
8
8
  *
9
9
  * The workspace archive is written explicitly via `archiveWorkspace()` (not
10
10
  * wired to any lifecycle event yet — A2A tasks share one configured cwd, so a
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 { ServerCallContext } from '@a2a-js/sdk/server';
14
15
  import { type ManagedTaskStore } from '../task-store.js';
15
16
  export interface GcsTaskStoreConfig {
16
17
  bucket: string;
17
- /** Object key prefix, default 'tasks' (matches the source layout). */
18
+ /** Object key prefix, default 'tasks'. */
18
19
  prefix?: string;
19
20
  /** Path to a service-account key file; omit to use ADC. */
20
21
  keyFilename?: string;
@@ -25,14 +26,14 @@ export declare class GcsTaskStore implements ManagedTaskStore {
25
26
  private prefix;
26
27
  constructor(config: GcsTaskStoreConfig);
27
28
  init(): Promise<void>;
28
- save(task: Task): Promise<void>;
29
- load(taskId: string): Promise<Task | undefined>;
30
- delete(taskId: string): Promise<void>;
31
- list(params: ListTasksRequest): Promise<ListTasksResponse>;
29
+ save(task: Task, context: ServerCallContext): Promise<void>;
30
+ load(taskId: string, context: ServerCallContext): Promise<Task | undefined>;
31
+ delete(taskId: string, context: ServerCallContext): Promise<void>;
32
+ list(params: ListTasksRequest, context: ServerCallContext): Promise<ListTasksResponse>;
32
33
  /**
33
- * Tar + gzip the workspace directory to `tasks/{taskId}/workspace.tar.gz`.
34
+ * Tar + gzip the workspace directory to `tasks/{scope}/{taskId}/workspace.tar.gz`.
34
35
  * TODO: replace the tar subprocess with a streaming tar library (e.g. `tar`)
35
36
  * to control inclusion and avoid shelling out.
36
37
  */
37
- archiveWorkspace(taskId: string, cwd: string): Promise<void>;
38
+ archiveWorkspace(taskId: string, cwd: string, context: ServerCallContext): Promise<void>;
38
39
  }
@@ -2,11 +2,12 @@
2
2
  * Redis TaskStore for @a2a-js/sdk. `ioredis` is an optional peer dependency.
3
3
  *
4
4
  * Ported from the source project's packages/a2a-server/src/persistence/redis.ts:
5
- * task state JSON under `a2a:tasks:{taskId}` with a TTL. Tasks arrive
6
- * pre-sanitized (metadata shell, no history) from SanitizedTaskStore —
7
- * conversation history is dsh-storage's job (ai_messages), not this store's.
5
+ * task state JSON under `a2a:tasks:{scope}:{taskId}` with a TTL. Task
6
+ * snapshots arrive from SanitizedTaskStore at state transitions;
7
+ * dsh-storage separately owns the full conversation log (ai_messages).
8
8
  */
9
9
  import type { ListTasksRequest, ListTasksResponse, Task } from '@a2a-js/sdk';
10
+ import type { ServerCallContext } from '@a2a-js/sdk/server';
10
11
  import { type ManagedTaskStore } from '../task-store.js';
11
12
  export interface RedisTaskStoreConfig {
12
13
  url: string;
@@ -21,9 +22,9 @@ export declare class RedisTaskStore implements ManagedTaskStore {
21
22
  constructor(config: RedisTaskStoreConfig);
22
23
  init(): Promise<void>;
23
24
  private taskKey;
24
- save(task: Task): Promise<void>;
25
- load(taskId: string): Promise<Task | undefined>;
26
- delete(taskId: string): Promise<void>;
27
- list(params: ListTasksRequest): Promise<ListTasksResponse>;
25
+ save(task: Task, context: ServerCallContext): Promise<void>;
26
+ load(taskId: string, context: ServerCallContext): Promise<Task | undefined>;
27
+ delete(taskId: string, context: ServerCallContext): Promise<void>;
28
+ list(params: ListTasksRequest, context: ServerCallContext): Promise<ListTasksResponse>;
28
29
  close(): Promise<void>;
29
30
  }
@@ -5,20 +5,19 @@
5
5
  * event — including each streamed text delta — and folds status messages into
6
6
  * `task.history`. Two constraints follow from this plugin's design:
7
7
  *
8
- * 1. A2A stores carry task STATE only (task metadata ≠ conversation history —
9
- * history is dsh-storage's `ai_messages`). {@link sanitizeTask} strips
10
- * `history`/`artifacts` before anything reaches a backend.
8
+ * 1. A2A task history is a protocol snapshot, separate from dsh-storage's
9
+ * conversation log. Persist it at state changes so tasks survive restart.
11
10
  * 2. Token-rate saves would hammer Redis and put a GCS upload in the hot path
12
11
  * of the SSE stream (ResultManager awaits every save). {@link
13
12
  * SanitizedTaskStore} collapses saves to task-state CHANGES, which are the
14
- * only transitions a `tasks/get` reader can act on anyway.
13
+ * durable checkpoints; active deltas remain available from this process.
15
14
  *
16
15
  * A2A 1.0 added `list()` to the TaskStore contract; {@link listShells} holds
17
16
  * the shared filter/sort/paginate logic so the backends only enumerate shells.
18
17
  */
19
- import type { ListTasksRequest, ListTasksResponse, Task } from '@a2a-js/sdk';
18
+ import { type ListTasksRequest, type ListTasksResponse, type Task } from '@a2a-js/sdk';
20
19
  import { type ServerCallContext, type TaskStore } from '@a2a-js/sdk/server';
21
- /** The persistent shape: a metadata shell with empty history/artifacts. */
20
+ /** Omit an unfinished current message from a WORKING snapshot. */
22
21
  export declare function sanitizeTask(task: Task): Task;
23
22
  /** TaskStore with the optional lifecycle our backends add. */
24
23
  export interface ManagedTaskStore extends TaskStore {
@@ -26,6 +25,8 @@ export interface ManagedTaskStore extends TaskStore {
26
25
  delete(taskId: string, context: ServerCallContext): Promise<void>;
27
26
  close?(): Promise<void>;
28
27
  }
28
+ /** A safe path/key segment for the SDK's tenant and owner scope. */
29
+ export declare function taskScopeKey(context: ServerCallContext): string;
29
30
  /** The SDK memory store has no delete operation; keep the same shell contract. */
30
31
  export declare class MemoryTaskStore implements ManagedTaskStore {
31
32
  private readonly scopes;
@@ -35,11 +36,12 @@ export declare class MemoryTaskStore implements ManagedTaskStore {
35
36
  list(params: ListTasksRequest, context: ServerCallContext): Promise<ListTasksResponse>;
36
37
  delete(taskId: string, context: ServerCallContext): Promise<void>;
37
38
  }
38
- /** Strips history/artifacts and saves only on task-state transitions. */
39
+ /** Saves task snapshots on state transitions while keeping active deltas in process. */
39
40
  export declare class SanitizedTaskStore implements ManagedTaskStore {
40
41
  private readonly inner;
41
42
  private readonly lastState;
42
43
  private readonly writes;
44
+ private readonly liveHistory;
43
45
  private readonly deleted;
44
46
  constructor(inner: ManagedTaskStore);
45
47
  init(): Promise<void>;
@@ -5,11 +5,11 @@
5
5
  * deliberately free of cordis/HTTP concerns so the mapping is unit-testable
6
6
  * without a harness. The mapping ports the source project's
7
7
  * `packages/a2a-server/src/agent/task.ts` event switch onto dsh's
8
- * `SessionEventMap` (verified against @deepseek-ai/dsh-session@0.1.7-rc.2),
8
+ * `SessionEventMap` (verified against @deepseek-ai/dsh-session@0.2.0-rc.1),
9
9
  * emitting the A2A 1.0 data model (@a2a-js/sdk 1.1.0). As of the V3 session
10
10
  * format the durable log carries no incremental chunks — live text/reasoning
11
11
  * deltas arrive as `agent/assistant-stream` chunk frames
12
- * (@deepseek-ai/dsh-agent@0.1.7-rc.2) through {@link handleStreamFrame}:
12
+ * (@deepseek-ai/dsh-agent@0.2.0-rc.1) through {@link handleStreamFrame}:
13
13
  *
14
14
  * turn/start → statusUpdate(WORKING), ids rotate
15
15
  * stream chunk (text-delta) → statusUpdate(WORKING, text part),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amaster.ai/dsh-a2a",
3
- "version": "0.1.15",
3
+ "version": "0.1.16",
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,11 +38,11 @@
38
38
  },
39
39
  "peerDependencies": {
40
40
  "@deepseek-ai/cordis": "^4.0.1",
41
- "@deepseek-ai/dsh-agent": ">=0.1.5-rc.1 || >=0.1.6-alpha.1 || >=0.1.7-alpha.1",
42
- "@deepseek-ai/dsh-attachment": ">=0.1.5-rc.1 || >=0.1.6-alpha.1 || >=0.1.7-alpha.1",
43
- "@deepseek-ai/dsh-llm": ">=0.1.5-rc.1 || >=0.1.6-alpha.1 || >=0.1.7-alpha.1",
44
- "@deepseek-ai/dsh-session": ">=0.1.5-rc.1 || >=0.1.6-alpha.1 || >=0.1.7-alpha.1",
45
- "@deepseek-ai/dsh-user-approval": ">=0.1.6-alpha.2 || >=0.1.7-alpha.1",
41
+ "@deepseek-ai/dsh-agent": ">=0.1.5-rc.1 || >=0.1.6-alpha.1 || >=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",
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.1.5-rc.1 || >=0.1.6-alpha.1 || >=0.1.7-alpha.1 || >=0.2.0-rc.1",
45
+ "@deepseek-ai/dsh-user-approval": ">=0.1.6-alpha.2 || >=0.1.7-alpha.1 || >=0.2.0-rc.1",
46
46
  "@google-cloud/storage": "^7.0.0",
47
47
  "ioredis": "^5.0.0"
48
48
  },
@@ -62,12 +62,12 @@
62
62
  },
63
63
  "devDependencies": {
64
64
  "@deepseek-ai/cordis": "^4.0.4",
65
- "@deepseek-ai/dsh-agent": "0.1.7-rc.2",
66
- "@deepseek-ai/dsh-attachment": "0.1.7-rc.2",
67
- "@deepseek-ai/dsh-llm": "0.1.7-rc.2",
68
- "@deepseek-ai/dsh-session": "0.1.7-rc.2",
69
- "@deepseek-ai/dsh-session-projection": "0.1.7-rc.2",
70
- "@deepseek-ai/dsh-user-approval": "0.1.7-rc.2",
65
+ "@deepseek-ai/dsh-agent": "0.2.0-rc.1",
66
+ "@deepseek-ai/dsh-attachment": "0.2.0-rc.1",
67
+ "@deepseek-ai/dsh-llm": "0.2.0-rc.1",
68
+ "@deepseek-ai/dsh-session": "0.2.0-rc.1",
69
+ "@deepseek-ai/dsh-session-projection": "0.2.0-rc.1",
70
+ "@deepseek-ai/dsh-user-approval": "0.2.0-rc.1",
71
71
  "@google-cloud/storage": "^7.0.0",
72
72
  "@types/express": "^4.17.21",
73
73
  "@types/node": "^22.0.0",