@amaster.ai/dsh-a2a 0.1.0
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/LICENSE +21 -0
- package/README.md +82 -0
- package/cordis.patch.yml +3 -0
- package/lib/bridge.js +229 -0
- package/lib/executor.js +97 -0
- package/lib/index.js +130 -0
- package/lib/server.js +82 -0
- package/lib/stores/gcs.js +84 -0
- package/lib/stores/redis.js +58 -0
- package/lib/task-store.js +111 -0
- package/lib/translator.js +244 -0
- package/lib/types/bridge.d.ts +78 -0
- package/lib/types/executor.d.ts +26 -0
- package/lib/types/index.d.ts +171 -0
- package/lib/types/server.d.ts +34 -0
- package/lib/types/stores/gcs.d.ts +37 -0
- package/lib/types/stores/redis.d.ts +28 -0
- package/lib/types/task-store.d.ts +45 -0
- package/lib/types/translator.d.ts +66 -0
- package/package.json +75 -0
- package/preview.png +0 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GCS TaskStore for @a2a-js/sdk. `@google-cloud/storage` is an optional peer.
|
|
3
|
+
*
|
|
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
|
|
8
|
+
*
|
|
9
|
+
* The workspace archive is written explicitly via `archiveWorkspace()` (not
|
|
10
|
+
* wired to any lifecycle event yet — A2A tasks share one configured cwd, so a
|
|
11
|
+
* per-task snapshot needs a per-task workspace story first).
|
|
12
|
+
*/
|
|
13
|
+
import { execFile } from 'node:child_process';
|
|
14
|
+
import { createReadStream } from 'node:fs';
|
|
15
|
+
import { mkdtemp, rm } from 'node:fs/promises';
|
|
16
|
+
import { tmpdir } from 'node:os';
|
|
17
|
+
import { join } from 'node:path';
|
|
18
|
+
import { pipeline } from 'node:stream/promises';
|
|
19
|
+
import { promisify } from 'node:util';
|
|
20
|
+
import { gunzipSync, gzipSync } from 'node:zlib';
|
|
21
|
+
import { listShells } from '../task-store.js';
|
|
22
|
+
export class GcsTaskStore {
|
|
23
|
+
config;
|
|
24
|
+
bucket = null;
|
|
25
|
+
prefix;
|
|
26
|
+
constructor(config) {
|
|
27
|
+
this.config = config;
|
|
28
|
+
this.prefix = config.prefix ?? 'tasks';
|
|
29
|
+
}
|
|
30
|
+
async init() {
|
|
31
|
+
const mod = await import('@google-cloud/storage');
|
|
32
|
+
const storage = new mod.Storage(this.config.keyFilename ? { keyFilename: this.config.keyFilename } : undefined);
|
|
33
|
+
this.bucket = storage.bucket(this.config.bucket);
|
|
34
|
+
}
|
|
35
|
+
async save(task) {
|
|
36
|
+
if (!this.bucket)
|
|
37
|
+
return;
|
|
38
|
+
await this.bucket
|
|
39
|
+
.file(`${this.prefix}/${task.id}/metadata.json.gz`)
|
|
40
|
+
.save(gzipSync(JSON.stringify(task)), { contentType: 'application/gzip', resumable: false });
|
|
41
|
+
}
|
|
42
|
+
async load(taskId) {
|
|
43
|
+
if (!this.bucket)
|
|
44
|
+
return undefined;
|
|
45
|
+
const file = this.bucket.file(`${this.prefix}/${taskId}/metadata.json.gz`);
|
|
46
|
+
const [exists] = await file.exists();
|
|
47
|
+
if (!exists)
|
|
48
|
+
return undefined;
|
|
49
|
+
const [buf] = await file.download();
|
|
50
|
+
return JSON.parse(gunzipSync(buf).toString('utf8'));
|
|
51
|
+
}
|
|
52
|
+
async list(params) {
|
|
53
|
+
if (!this.bucket)
|
|
54
|
+
return { tasks: [], nextPageToken: '', pageSize: 0, totalSize: 0 };
|
|
55
|
+
const [files] = await this.bucket.getFiles({ prefix: `${this.prefix}/` });
|
|
56
|
+
const shells = [];
|
|
57
|
+
for (const file of files) {
|
|
58
|
+
if (!file.name.endsWith('/metadata.json.gz'))
|
|
59
|
+
continue;
|
|
60
|
+
const [buf] = await file.download();
|
|
61
|
+
shells.push(JSON.parse(gunzipSync(buf).toString('utf8')));
|
|
62
|
+
}
|
|
63
|
+
return listShells(shells, params);
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Tar + gzip the workspace directory to `tasks/{taskId}/workspace.tar.gz`.
|
|
67
|
+
* TODO: replace the tar subprocess with a streaming tar library (e.g. `tar`)
|
|
68
|
+
* to control inclusion and avoid shelling out.
|
|
69
|
+
*/
|
|
70
|
+
async archiveWorkspace(taskId, cwd) {
|
|
71
|
+
if (!this.bucket)
|
|
72
|
+
return;
|
|
73
|
+
const dir = await mkdtemp(join(tmpdir(), 'dsh-a2a-archive-'));
|
|
74
|
+
const tarball = join(dir, 'workspace.tar.gz');
|
|
75
|
+
try {
|
|
76
|
+
await promisify(execFile)('tar', ['-czf', tarball, '-C', cwd, '.']);
|
|
77
|
+
const dest = this.bucket.file(`${this.prefix}/${taskId}/workspace.tar.gz`);
|
|
78
|
+
await pipeline(createReadStream(tarball), dest.createWriteStream());
|
|
79
|
+
}
|
|
80
|
+
finally {
|
|
81
|
+
await rm(dir, { recursive: true, force: true });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Redis TaskStore for @a2a-js/sdk. `ioredis` is an optional peer dependency.
|
|
3
|
+
*
|
|
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.
|
|
8
|
+
*/
|
|
9
|
+
import { listShells } from '../task-store.js';
|
|
10
|
+
export class RedisTaskStore {
|
|
11
|
+
config;
|
|
12
|
+
redis = null;
|
|
13
|
+
prefix;
|
|
14
|
+
ttl;
|
|
15
|
+
constructor(config) {
|
|
16
|
+
this.config = config;
|
|
17
|
+
this.prefix = config.keyPrefix ?? 'a2a';
|
|
18
|
+
this.ttl = config.ttlSeconds ?? 86_400;
|
|
19
|
+
}
|
|
20
|
+
async init() {
|
|
21
|
+
const mod = await import('ioredis');
|
|
22
|
+
this.redis = new mod.Redis(this.config.url);
|
|
23
|
+
}
|
|
24
|
+
taskKey(taskId) {
|
|
25
|
+
return `${this.prefix}:tasks:${taskId}`;
|
|
26
|
+
}
|
|
27
|
+
async save(task) {
|
|
28
|
+
if (!this.redis)
|
|
29
|
+
return;
|
|
30
|
+
await this.redis.set(this.taskKey(task.id), JSON.stringify(task), 'EX', this.ttl);
|
|
31
|
+
}
|
|
32
|
+
async load(taskId) {
|
|
33
|
+
if (!this.redis)
|
|
34
|
+
return undefined;
|
|
35
|
+
const raw = await this.redis.get(this.taskKey(taskId));
|
|
36
|
+
return raw ? JSON.parse(raw) : undefined;
|
|
37
|
+
}
|
|
38
|
+
async list(params) {
|
|
39
|
+
if (!this.redis)
|
|
40
|
+
return { tasks: [], nextPageToken: '', pageSize: 0, totalSize: 0 };
|
|
41
|
+
// Task shells are few and TTL-bound — a paged SCAN over the prefix is ample.
|
|
42
|
+
const shells = [];
|
|
43
|
+
let cursor = '0';
|
|
44
|
+
do {
|
|
45
|
+
const [next, keys] = await this.redis.scan(cursor, 'MATCH', `${this.prefix}:tasks:*`, 'COUNT', 100);
|
|
46
|
+
cursor = next;
|
|
47
|
+
for (const key of keys) {
|
|
48
|
+
const raw = await this.redis.get(key);
|
|
49
|
+
if (raw)
|
|
50
|
+
shells.push(JSON.parse(raw));
|
|
51
|
+
}
|
|
52
|
+
} while (cursor !== '0');
|
|
53
|
+
return listShells(shells, params);
|
|
54
|
+
}
|
|
55
|
+
async close() {
|
|
56
|
+
await this.redis?.quit?.();
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TaskStore boundary for the SDK's DefaultRequestHandler.
|
|
3
|
+
*
|
|
4
|
+
* The SDK's ResultManager calls `taskStore.save()` on EVERY task/statusUpdate
|
|
5
|
+
* event — including each streamed text delta — and folds status messages into
|
|
6
|
+
* `task.history`. Two constraints follow from this plugin's design:
|
|
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.
|
|
11
|
+
* 2. Token-rate saves would hammer Redis and put a GCS upload in the hot path
|
|
12
|
+
* of the SSE stream (ResultManager awaits every save). {@link
|
|
13
|
+
* SanitizedTaskStore} collapses saves to task-state CHANGES, which are the
|
|
14
|
+
* only transitions a `tasks/get` reader can act on anyway.
|
|
15
|
+
*
|
|
16
|
+
* A2A 1.0 added `list()` to the TaskStore contract; {@link listShells} holds
|
|
17
|
+
* the shared filter/sort/paginate logic so the backends only enumerate shells.
|
|
18
|
+
*/
|
|
19
|
+
/** The persistent shape: a metadata shell with empty history/artifacts. */
|
|
20
|
+
export function sanitizeTask(task) {
|
|
21
|
+
return {
|
|
22
|
+
id: task.id,
|
|
23
|
+
contextId: task.contextId,
|
|
24
|
+
status: task.status,
|
|
25
|
+
metadata: task.metadata,
|
|
26
|
+
history: [],
|
|
27
|
+
artifacts: [],
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
/** Strips history/artifacts and saves only on task-state transitions. */
|
|
31
|
+
export class SanitizedTaskStore {
|
|
32
|
+
inner;
|
|
33
|
+
lastState = new Map();
|
|
34
|
+
constructor(inner) {
|
|
35
|
+
this.inner = inner;
|
|
36
|
+
}
|
|
37
|
+
async init() {
|
|
38
|
+
await this.inner.init?.();
|
|
39
|
+
}
|
|
40
|
+
async close() {
|
|
41
|
+
await this.inner.close?.();
|
|
42
|
+
}
|
|
43
|
+
async save(task, context) {
|
|
44
|
+
const state = task.status?.state;
|
|
45
|
+
if (state !== undefined && this.lastState.get(task.id) === state)
|
|
46
|
+
return;
|
|
47
|
+
if (state !== undefined)
|
|
48
|
+
this.lastState.set(task.id, state);
|
|
49
|
+
await this.inner.save(sanitizeTask(task), context);
|
|
50
|
+
}
|
|
51
|
+
load(taskId, context) {
|
|
52
|
+
return this.inner.load(taskId, context);
|
|
53
|
+
}
|
|
54
|
+
list(params, context) {
|
|
55
|
+
return this.inner.list(params, context);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Shared `listTasks` semantics for the backends: filter by contextId / status /
|
|
60
|
+
* statusTimestampAfter (strictly greater), newest status first, cursor-paginated
|
|
61
|
+
* (base64 "timestamp|id"; a well-formed but unknown cursor yields an empty page,
|
|
62
|
+
* a malformed token throws). Follows the SDK's InMemoryTaskStore.
|
|
63
|
+
*/
|
|
64
|
+
export function listShells(shells, params) {
|
|
65
|
+
const filtered = shells.filter((task) => {
|
|
66
|
+
if (params.contextId && task.contextId !== params.contextId)
|
|
67
|
+
return false;
|
|
68
|
+
if (params.status && task.status?.state !== params.status)
|
|
69
|
+
return false;
|
|
70
|
+
if (params.statusTimestampAfter) {
|
|
71
|
+
const timestamp = task.status?.timestamp;
|
|
72
|
+
// Strictly-greater mirrors the SDK's InMemoryTaskStore.
|
|
73
|
+
if (!timestamp || Date.parse(timestamp) <= Date.parse(params.statusTimestampAfter)) {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return true;
|
|
78
|
+
});
|
|
79
|
+
filtered.sort((a, b) => {
|
|
80
|
+
const at = a.status?.timestamp ?? '';
|
|
81
|
+
const bt = b.status?.timestamp ?? '';
|
|
82
|
+
return bt.localeCompare(at) || b.id.localeCompare(a.id);
|
|
83
|
+
});
|
|
84
|
+
const pageSize = Math.min(Math.max(params.pageSize ?? 50, 1), 100);
|
|
85
|
+
let start = 0;
|
|
86
|
+
if (params.pageToken) {
|
|
87
|
+
const decoded = Buffer.from(params.pageToken, 'base64').toString('utf8');
|
|
88
|
+
const sep = decoded.indexOf('|');
|
|
89
|
+
if (sep < 0)
|
|
90
|
+
throw new Error('invalid page token');
|
|
91
|
+
const timestamp = decoded.slice(0, sep);
|
|
92
|
+
const id = decoded.slice(sep + 1);
|
|
93
|
+
// A well-formed cursor pointing at nothing (expired/rotated) is an empty page.
|
|
94
|
+
const index = filtered.findIndex((task) => (task.status?.timestamp ?? '') === timestamp && task.id === id);
|
|
95
|
+
if (index >= 0)
|
|
96
|
+
start = index + 1;
|
|
97
|
+
else
|
|
98
|
+
return { tasks: [], nextPageToken: '', pageSize, totalSize: filtered.length };
|
|
99
|
+
}
|
|
100
|
+
const page = filtered.slice(start, start + pageSize);
|
|
101
|
+
const last = page.at(-1);
|
|
102
|
+
const hasMore = start + pageSize < filtered.length;
|
|
103
|
+
return {
|
|
104
|
+
tasks: page.map((task) => (params.includeArtifacts ? task : { ...task, artifacts: [] })),
|
|
105
|
+
nextPageToken: hasMore && last
|
|
106
|
+
? Buffer.from(`${last.status?.timestamp ?? ''}|${last.id}`).toString('base64')
|
|
107
|
+
: '',
|
|
108
|
+
pageSize,
|
|
109
|
+
totalSize: filtered.length,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure translation layer: dsh session events → A2A protocol events.
|
|
3
|
+
*
|
|
4
|
+
* One {@link SessionTranslator} serves one A2A task (one dsh session). It is
|
|
5
|
+
* deliberately free of cordis/HTTP concerns so the mapping is unit-testable
|
|
6
|
+
* without a harness. The mapping ports the source project's
|
|
7
|
+
* `packages/a2a-server/src/agent/task.ts` event switch onto dsh's
|
|
8
|
+
* `SessionEventMap` (verified against @deepseek-ai/dsh-session@0.1.0-rc.7),
|
|
9
|
+
* emitting the A2A 1.0 data model (@a2a-js/sdk 1.1.0):
|
|
10
|
+
*
|
|
11
|
+
* turn/start → statusUpdate(WORKING), ids rotate
|
|
12
|
+
* assistant/chunk (text-delta) → statusUpdate(WORKING, text part),
|
|
13
|
+
* reusing the turn's messageId so
|
|
14
|
+
* clients aggregate deltas into one message
|
|
15
|
+
* assistant/chunk (reasoning-delta) → statusUpdate(WORKING, thought metadata)
|
|
16
|
+
* assistant/message → no event; text + usage captured for the
|
|
17
|
+
* turn-final message (blocking `SendMessage`
|
|
18
|
+
* clients read the answer from the final
|
|
19
|
+
* task status, not from streamed deltas)
|
|
20
|
+
* tool/call → statusUpdate(WORKING, data part)
|
|
21
|
+
* tool/result → statusUpdate(WORKING, data part)
|
|
22
|
+
* turn/end completed | max-tokens → statusUpdate(INPUT_REQUIRED) — the task
|
|
23
|
+
* is a conversation and stays continuable
|
|
24
|
+
* (COMPLETED is terminal in A2A and the SDK
|
|
25
|
+
* refuses follow-ups to terminal tasks)
|
|
26
|
+
* turn/end aborted → statusUpdate(CANCELED)
|
|
27
|
+
* turn/end blocked | error → statusUpdate(FAILED)
|
|
28
|
+
* everything else → no event
|
|
29
|
+
*
|
|
30
|
+
* A2A 1.0 removed the `final` flag: the SDK's event queue stops on terminal
|
|
31
|
+
* and interrupted (input-required) states, which is exactly the set above.
|
|
32
|
+
*
|
|
33
|
+
* dsh rc.7 has no approval/`input-required` mid-turn seam (the unpublished
|
|
34
|
+
* dsh-user-approval package is not installed anywhere; `tools/pre-execute`'s
|
|
35
|
+
* `ask` degrades to deny without an approval service), so no approval bridge
|
|
36
|
+
* exists here. TODO(verify) when dsh ships one.
|
|
37
|
+
*/
|
|
38
|
+
import { randomUUID } from 'node:crypto';
|
|
39
|
+
import { Role, TaskState } from '@a2a-js/sdk';
|
|
40
|
+
import { AgentEvent } from '@a2a-js/sdk/server';
|
|
41
|
+
/** One text part in the 1.0 wire model. */
|
|
42
|
+
function textPart(text) {
|
|
43
|
+
return {
|
|
44
|
+
content: { $case: 'text', value: text },
|
|
45
|
+
metadata: undefined,
|
|
46
|
+
filename: '',
|
|
47
|
+
mediaType: 'text/plain',
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
/** One structured-data part in the 1.0 wire model. */
|
|
51
|
+
function dataPart(data) {
|
|
52
|
+
return {
|
|
53
|
+
content: { $case: 'data', value: data },
|
|
54
|
+
metadata: undefined,
|
|
55
|
+
filename: '',
|
|
56
|
+
mediaType: 'application/json',
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
/** One agent-role text message — the single construction site for the plugin's message payloads. */
|
|
60
|
+
export function agentTextMessage(taskId, contextId, text, messageId = randomUUID()) {
|
|
61
|
+
return {
|
|
62
|
+
messageId,
|
|
63
|
+
contextId,
|
|
64
|
+
taskId,
|
|
65
|
+
role: Role.ROLE_AGENT,
|
|
66
|
+
parts: [textPart(text)],
|
|
67
|
+
metadata: undefined,
|
|
68
|
+
extensions: [],
|
|
69
|
+
referenceTaskIds: [],
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
/** A terminal (stream-closing) statusUpdate — shared by the bridge's settle path and the executor's failure/cancel paths. */
|
|
73
|
+
export function terminalStatusUpdate(taskId, contextId, state, text) {
|
|
74
|
+
return AgentEvent.statusUpdate({
|
|
75
|
+
taskId,
|
|
76
|
+
contextId,
|
|
77
|
+
status: {
|
|
78
|
+
state: state === 'canceled' ? TaskState.TASK_STATE_CANCELED : TaskState.TASK_STATE_FAILED,
|
|
79
|
+
message: agentTextMessage(taskId, contextId, text),
|
|
80
|
+
timestamp: new Date().toISOString(),
|
|
81
|
+
},
|
|
82
|
+
metadata: { dshAgent: { kind: 'state-change', reason: state } },
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
export class SessionTranslator {
|
|
86
|
+
taskId;
|
|
87
|
+
contextId;
|
|
88
|
+
model;
|
|
89
|
+
/** messageId shared by this turn's streamed text deltas (client-side aggregation key). */
|
|
90
|
+
textMessageId = randomUUID();
|
|
91
|
+
thoughtMessageId = randomUUID();
|
|
92
|
+
/** Authoritative turn text, assembled from `assistant/message` events. */
|
|
93
|
+
turnText = '';
|
|
94
|
+
/** Fallback turn text, accumulated from `text-delta` chunks. */
|
|
95
|
+
deltaText = '';
|
|
96
|
+
sawAssistantMessage = false;
|
|
97
|
+
usage;
|
|
98
|
+
/** callId → tool name, for naming `tool/result` payloads. */
|
|
99
|
+
toolNames = new Map();
|
|
100
|
+
constructor(taskId, contextId, model) {
|
|
101
|
+
this.taskId = taskId;
|
|
102
|
+
this.contextId = contextId;
|
|
103
|
+
this.model = model;
|
|
104
|
+
}
|
|
105
|
+
/** Translate one session event into zero or more A2A events. Never throws on unknown types. */
|
|
106
|
+
handle(event) {
|
|
107
|
+
switch (event.type) {
|
|
108
|
+
case 'turn/start': {
|
|
109
|
+
this.textMessageId = randomUUID();
|
|
110
|
+
this.thoughtMessageId = randomUUID();
|
|
111
|
+
this.turnText = '';
|
|
112
|
+
this.deltaText = '';
|
|
113
|
+
this.sawAssistantMessage = false;
|
|
114
|
+
this.usage = undefined;
|
|
115
|
+
return [this.status(TaskState.TASK_STATE_WORKING, { kind: 'state-change' })];
|
|
116
|
+
}
|
|
117
|
+
case 'assistant/chunk': {
|
|
118
|
+
const chunk = event.data.chunk;
|
|
119
|
+
if (chunk.type === 'text-delta') {
|
|
120
|
+
this.deltaText += chunk.text;
|
|
121
|
+
return [
|
|
122
|
+
this.status(TaskState.TASK_STATE_WORKING, { kind: 'text-content' }, agentTextMessage(this.taskId, this.contextId, chunk.text, this.textMessageId)),
|
|
123
|
+
];
|
|
124
|
+
}
|
|
125
|
+
if (chunk.type === 'reasoning-delta') {
|
|
126
|
+
return [
|
|
127
|
+
this.status(TaskState.TASK_STATE_WORKING, { kind: 'thought' }, agentTextMessage(this.taskId, this.contextId, chunk.text, this.thoughtMessageId)),
|
|
128
|
+
];
|
|
129
|
+
}
|
|
130
|
+
return [];
|
|
131
|
+
}
|
|
132
|
+
case 'assistant/message': {
|
|
133
|
+
this.sawAssistantMessage = true;
|
|
134
|
+
for (const block of event.data.message.content) {
|
|
135
|
+
if (block.type === 'text')
|
|
136
|
+
this.turnText += block.text;
|
|
137
|
+
}
|
|
138
|
+
const usage = event.data.usage;
|
|
139
|
+
if (usage)
|
|
140
|
+
this.usage = this.usage ? addUsage(this.usage, usage) : { ...usage };
|
|
141
|
+
return [];
|
|
142
|
+
}
|
|
143
|
+
case 'tool/call': {
|
|
144
|
+
const { callId, name, arguments: args } = event.data;
|
|
145
|
+
this.toolNames.set(callId, name);
|
|
146
|
+
return [
|
|
147
|
+
this.status(TaskState.TASK_STATE_WORKING, { kind: 'tool-call' }, this.dataMessage({ callId, name, arguments: args })),
|
|
148
|
+
];
|
|
149
|
+
}
|
|
150
|
+
case 'tool/result': {
|
|
151
|
+
const { message, error, meta } = event.data;
|
|
152
|
+
// ToolResultMessage.content is a single tool-result block.
|
|
153
|
+
const block = message.content[0];
|
|
154
|
+
const callId = block?.type === 'tool-result' ? block.toolCallId : undefined;
|
|
155
|
+
return [
|
|
156
|
+
this.status(TaskState.TASK_STATE_WORKING, { kind: 'tool-result' }, this.dataMessage({
|
|
157
|
+
callId,
|
|
158
|
+
name: callId ? this.toolNames.get(callId) : undefined,
|
|
159
|
+
result: block?.type === 'tool-result'
|
|
160
|
+
? flattenContent(block.content)
|
|
161
|
+
: flattenContent(message.content),
|
|
162
|
+
isError: (block?.type === 'tool-result' && block.isError === true) || error !== undefined,
|
|
163
|
+
...(error ? { error: `${error.name}: ${error.code}` } : {}),
|
|
164
|
+
...(meta !== undefined ? { meta } : {}),
|
|
165
|
+
})),
|
|
166
|
+
];
|
|
167
|
+
}
|
|
168
|
+
case 'turn/end': {
|
|
169
|
+
const final = this.turnEnd(event.data.reason);
|
|
170
|
+
return final ? [final] : [];
|
|
171
|
+
}
|
|
172
|
+
default:
|
|
173
|
+
return [];
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
turnEnd(reason) {
|
|
177
|
+
const text = this.sawAssistantMessage ? this.turnText : this.deltaText;
|
|
178
|
+
const finalMessage = text
|
|
179
|
+
? agentTextMessage(this.taskId, this.contextId, text, this.textMessageId)
|
|
180
|
+
: undefined;
|
|
181
|
+
switch (reason.kind) {
|
|
182
|
+
case 'completed':
|
|
183
|
+
case 'max-tokens':
|
|
184
|
+
// The task stays continuable; the reason records which ceiling hit.
|
|
185
|
+
return this.status(TaskState.TASK_STATE_INPUT_REQUIRED, { kind: 'state-change', reason: reason.kind }, finalMessage);
|
|
186
|
+
case 'aborted':
|
|
187
|
+
return this.status(TaskState.TASK_STATE_CANCELED, { kind: 'state-change', reason: 'aborted' }, undefined);
|
|
188
|
+
case 'blocked':
|
|
189
|
+
return this.status(TaskState.TASK_STATE_FAILED, { kind: 'state-change', reason: 'blocked' }, undefined);
|
|
190
|
+
case 'error': {
|
|
191
|
+
const message = reason.error.message || 'Agent turn failed.';
|
|
192
|
+
return this.status(TaskState.TASK_STATE_FAILED, { kind: 'state-change', reason: 'error' }, agentTextMessage(this.taskId, this.contextId, message), message);
|
|
193
|
+
}
|
|
194
|
+
default:
|
|
195
|
+
// 'interrupted' closes crash-orphaned turns on reload; the live loop
|
|
196
|
+
// never emits it, and unknown merge extensions are ignored too.
|
|
197
|
+
return undefined;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
status(state, dshAgent, message, error) {
|
|
201
|
+
const metadata = { dshAgent };
|
|
202
|
+
if (this.model)
|
|
203
|
+
metadata.model = this.model;
|
|
204
|
+
const terminal = state !== TaskState.TASK_STATE_WORKING;
|
|
205
|
+
if (terminal && this.usage)
|
|
206
|
+
metadata.usage = this.usage;
|
|
207
|
+
if (error)
|
|
208
|
+
metadata.error = error;
|
|
209
|
+
return AgentEvent.statusUpdate({
|
|
210
|
+
taskId: this.taskId,
|
|
211
|
+
contextId: this.contextId,
|
|
212
|
+
status: { state, message, timestamp: new Date().toISOString() },
|
|
213
|
+
metadata,
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
dataMessage(data) {
|
|
217
|
+
return {
|
|
218
|
+
messageId: randomUUID(),
|
|
219
|
+
contextId: this.contextId,
|
|
220
|
+
taskId: this.taskId,
|
|
221
|
+
role: Role.ROLE_AGENT,
|
|
222
|
+
parts: [dataPart(data)],
|
|
223
|
+
metadata: undefined,
|
|
224
|
+
extensions: [],
|
|
225
|
+
referenceTaskIds: [],
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
function addUsage(a, b) {
|
|
230
|
+
const sum = (x, y) => x === undefined && y === undefined ? undefined : (x ?? 0) + (y ?? 0);
|
|
231
|
+
return {
|
|
232
|
+
inputTokens: a.inputTokens + b.inputTokens,
|
|
233
|
+
outputTokens: a.outputTokens + b.outputTokens,
|
|
234
|
+
cacheReadTokens: sum(a.cacheReadTokens, b.cacheReadTokens),
|
|
235
|
+
cacheWriteTokens: sum(a.cacheWriteTokens, b.cacheWriteTokens),
|
|
236
|
+
reasoningTokens: sum(a.reasoningTokens, b.reasoningTokens),
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
/** Flatten tool-result content blocks to display text; non-text blocks serialize as JSON. */
|
|
240
|
+
function flattenContent(content) {
|
|
241
|
+
return content
|
|
242
|
+
.map((block) => (block.type === 'text' ? block.text : JSON.stringify(block)))
|
|
243
|
+
.join('');
|
|
244
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bridges A2A tasks to dsh agents.
|
|
3
|
+
*
|
|
4
|
+
* One A2A task maps to one dsh session/agent; the A2A `contextId` IS the dsh
|
|
5
|
+
* sessionId. The bridge owns every agent it creates (the consumer-handle
|
|
6
|
+
* pattern dsh's own ACP bridge uses) and routes the durable `session/event`
|
|
7
|
+
* stream through a per-task {@link SessionTranslator} onto the executing
|
|
8
|
+
* request's {@link ExecutionEventBus}.
|
|
9
|
+
*
|
|
10
|
+
* Turn settlement: `agent.followup()` is fire-and-forget, so callers await a
|
|
11
|
+
* FIFO waiter resolved by the session's next `turn/end` event (dsh's inbox
|
|
12
|
+
* serializes queued follow-ups into successive turns, so waiter order matches
|
|
13
|
+
* turn order). The final status-update is published before the waiter
|
|
14
|
+
* resolves, so an execute() that returns always landed its events first.
|
|
15
|
+
*/
|
|
16
|
+
import type { ExecutionEventBus } from '@a2a-js/sdk/server';
|
|
17
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
18
|
+
import type { AgentHandle } from '@deepseek-ai/dsh-agent';
|
|
19
|
+
import { SessionId } from '@deepseek-ai/dsh-session/types';
|
|
20
|
+
import { SessionTranslator } from './translator.js';
|
|
21
|
+
export interface BridgeOptions {
|
|
22
|
+
/** Absolute working directory for spawned agents. */
|
|
23
|
+
cwd: string;
|
|
24
|
+
/** Optional provider/model overrides for created agents. */
|
|
25
|
+
agentOptions?: {
|
|
26
|
+
provider?: string;
|
|
27
|
+
model?: string;
|
|
28
|
+
};
|
|
29
|
+
/** Agent preset id; empty/absent = the deployment default (when a roster exists). */
|
|
30
|
+
preset?: string;
|
|
31
|
+
}
|
|
32
|
+
export interface TaskEntry {
|
|
33
|
+
taskId: string;
|
|
34
|
+
sessionId: SessionId;
|
|
35
|
+
handle: AgentHandle;
|
|
36
|
+
translator: SessionTranslator;
|
|
37
|
+
/** The in-flight execute()'s bus; null between turns. */
|
|
38
|
+
bus: ExecutionEventBus | null;
|
|
39
|
+
/** Between turn/start and turn/end. */
|
|
40
|
+
turnActive: boolean;
|
|
41
|
+
/** FIFO turn-end waiters, one per queued/running execute(). */
|
|
42
|
+
settled: Array<() => void>;
|
|
43
|
+
}
|
|
44
|
+
export declare class A2aBridge {
|
|
45
|
+
private readonly ctx;
|
|
46
|
+
private readonly options;
|
|
47
|
+
private readonly tasks;
|
|
48
|
+
private readonly bySession;
|
|
49
|
+
constructor(ctx: Context, options: BridgeOptions);
|
|
50
|
+
/**
|
|
51
|
+
* Resolve the live task entry, creating the session/agent when needed.
|
|
52
|
+
* `freshTask` tells the executor to anchor the SDK's ResultManager with an
|
|
53
|
+
* initial Task event (status-updates for a task id the store never saw are
|
|
54
|
+
* dropped by it). Rebinding by bare contextId counts as fresh: the SDK mints
|
|
55
|
+
* a new task id for `message/send` calls that omit `taskId`.
|
|
56
|
+
*/
|
|
57
|
+
ensureTask(taskId: string, contextId: string): Promise<{
|
|
58
|
+
entry: TaskEntry;
|
|
59
|
+
freshTask: boolean;
|
|
60
|
+
}>;
|
|
61
|
+
/** Queue one user-message turn on the task's agent and await its `turn/end`. */
|
|
62
|
+
runTurn(entry: TaskEntry, text: string, bus: ExecutionEventBus): Promise<void>;
|
|
63
|
+
/** Abort the task's active turn; returns the contextId when the task is live. */
|
|
64
|
+
cancel(taskId: string): string | undefined;
|
|
65
|
+
/** Dispose every owned agent (plugin unload / server shutdown). */
|
|
66
|
+
dispose(): Promise<void>;
|
|
67
|
+
private onSessionEvent;
|
|
68
|
+
private onSessionDisposed;
|
|
69
|
+
/** Close an in-flight stream honestly and release every turn waiter. */
|
|
70
|
+
private settleEntry;
|
|
71
|
+
/**
|
|
72
|
+
* Config override wins per field; otherwise the deployment default read from
|
|
73
|
+
* the `agentDefaultModel` service (the entry-point contract — the loop
|
|
74
|
+
* itself does not apply it). Undefined when neither source has a pair
|
|
75
|
+
* (persona-less minimal compositions stay valid).
|
|
76
|
+
*/
|
|
77
|
+
private resolveSelection;
|
|
78
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The @a2a-js/sdk {@link AgentExecutor} that drives dsh agents through the
|
|
3
|
+
* bridge. One `execute()` = one user-message turn:
|
|
4
|
+
*
|
|
5
|
+
* 1. extract the text prompt (v1 is text-only)
|
|
6
|
+
* 2. ensure the task's session/agent exists (creating it on first contact)
|
|
7
|
+
* 3. publish a `task` event first — A2A 1.0 stream ordering REQUIRES the
|
|
8
|
+
* first event of every execute to be a task or message, including
|
|
9
|
+
* follow-up turns on a known task (the ResultManager merges it with the
|
|
10
|
+
* stored task; fresh tasks get the submitted anchor carrying the user
|
|
11
|
+
* message, continuing tasks get a working snapshot)
|
|
12
|
+
* 4. run the turn; session events stream onto the bus via the bridge
|
|
13
|
+
* 5. on failure publish a failed statusUpdate (plus a failed task anchor if
|
|
14
|
+
* none went out yet) instead of rethrowing — clients get the terminal
|
|
15
|
+
* state in band on both blocking and streaming paths
|
|
16
|
+
*
|
|
17
|
+
* `cancelTask` is an arrow property because the SDK destructures it.
|
|
18
|
+
*/
|
|
19
|
+
import type { AgentExecutor, ExecutionEventBus, RequestContext } from '@a2a-js/sdk/server';
|
|
20
|
+
import type { A2aBridge } from './bridge.js';
|
|
21
|
+
export declare class DshAgentExecutor implements AgentExecutor {
|
|
22
|
+
private readonly bridge;
|
|
23
|
+
constructor(bridge: A2aBridge);
|
|
24
|
+
execute(requestContext: RequestContext, eventBus: ExecutionEventBus): Promise<void>;
|
|
25
|
+
cancelTask: (taskId: string, eventBus: ExecutionEventBus) => Promise<void>;
|
|
26
|
+
}
|