@amaster.ai/dsh-a2a 0.1.14 → 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 +6 -4
- package/lib/index.js +2 -2
- package/lib/server.js +146 -2
- package/lib/stores/gcs.js +18 -16
- package/lib/stores/redis.js +14 -14
- package/lib/task-store.js +76 -19
- package/lib/translator.js +2 -2
- package/lib/types/index.d.ts +4 -4
- package/lib/types/stores/gcs.d.ts +11 -10
- package/lib/types/stores/redis.d.ts +8 -7
- package/lib/types/task-store.d.ts +9 -7
- package/lib/types/translator.d.ts +2 -2
- package/package.json +12 -12
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
|
|
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
|
|
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.
|
|
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
|
|
59
|
-
*
|
|
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
|
-
|
|
74
|
-
|
|
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
|
|
6
|
-
*
|
|
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({
|
|
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 {
|
package/lib/stores/redis.js
CHANGED
|
@@ -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.
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
|
9
|
-
*
|
|
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
|
-
*
|
|
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
|
-
/**
|
|
21
|
+
/** Omit an unfinished current message from a WORKING snapshot. */
|
|
21
22
|
export function sanitizeTask(task) {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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/lib/types/index.d.ts
CHANGED
|
@@ -108,8 +108,8 @@ export declare const Config: Schema<Schemastery.ObjectS<{
|
|
|
108
108
|
publicUrl: Schema<string, string>;
|
|
109
109
|
}>>;
|
|
110
110
|
/**
|
|
111
|
-
* A2A task
|
|
112
|
-
*
|
|
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
|
|
177
|
-
*
|
|
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
|
|
6
|
-
*
|
|
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'
|
|
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.
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
|
9
|
-
*
|
|
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
|
-
*
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
66
|
-
"@deepseek-ai/dsh-attachment": "0.
|
|
67
|
-
"@deepseek-ai/dsh-llm": "0.
|
|
68
|
-
"@deepseek-ai/dsh-session": "0.
|
|
69
|
-
"@deepseek-ai/dsh-session-projection": "0.
|
|
70
|
-
"@deepseek-ai/dsh-user-approval": "0.
|
|
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",
|