@amaster.ai/dsh-a2a 0.1.5 → 0.1.7
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 +8 -5
- package/lib/bridge.js +138 -32
- package/lib/content.js +244 -0
- package/lib/executor.js +15 -21
- package/lib/index.js +36 -4
- package/lib/server.js +54 -5
- package/lib/stores/gcs.js +12 -0
- package/lib/stores/redis.js +3 -0
- package/lib/task-store.js +46 -1
- package/lib/types/bridge.d.ts +21 -1
- package/lib/types/content.d.ts +41 -0
- package/lib/types/executor.d.ts +5 -2
- package/lib/types/index.d.ts +16 -0
- package/lib/types/stores/gcs.d.ts +3 -2
- package/lib/types/stores/redis.d.ts +3 -2
- package/lib/types/task-store.d.ts +16 -1
- package/package.json +6 -1
package/README.md
CHANGED
|
@@ -26,6 +26,7 @@ Disabled by default. Configure via the profile's `cordis.patch.yml`:
|
|
|
26
26
|
port: 41241
|
|
27
27
|
basePath: /a2a
|
|
28
28
|
cwd: /srv/agent-workspaces
|
|
29
|
+
uploadsDir: '' # file-part upload root; empty = <OS temp>/dsh-a2a-uploads/<date>
|
|
29
30
|
agent:
|
|
30
31
|
provider: '' # dsh provider/model for A2A sessions; empty = profile default
|
|
31
32
|
model: ''
|
|
@@ -49,17 +50,19 @@ Disabled by default. Configure via the profile's `cordis.patch.yml`:
|
|
|
49
50
|
## Endpoints
|
|
50
51
|
|
|
51
52
|
- `GET /.well-known/agent-card.json` — agent card: A2A 1.0 shape for `A2A-Version: 1.0` clients, the 0.3 shape for headerless clients (the legacy `/.well-known/agent.json` path is served as an alias)
|
|
52
|
-
- `POST <basePath>/` — JSON-RPC. v1 methods: `SendMessage` (blocking by default, `configuration.returnImmediately: true` returns after the first event), `SendStreamingMessage` (SSE), `GetTask`, `ListTasks` (filter + cursor pagination), `CancelTask`, `SubscribeToTask` (the current task is the first event; the bus stays alive while the task is interrupted, so interrupted tasks can be re-followed live). v0.3 spellings (`message/send`, `message/stream`, `tasks/get`, `tasks/cancel`, `tasks/resubscribe`) keep working through the compat layer.
|
|
53
|
+
- `POST <basePath>/` — JSON-RPC. v1 methods: `SendMessage` (blocking by default, `configuration.returnImmediately: true` returns after the first event), `SendStreamingMessage` (SSE), `GetTask`, `ListTasks` (filter + cursor pagination), `CancelTask`, `SubscribeToTask` (the current task is the first event; the bus stays alive while the task is interrupted, so interrupted tasks can be re-followed live). v0.3 spellings (`message/send`, `message/stream`, `tasks/get`, `tasks/cancel`, `tasks/resubscribe`) keep working through the compat layer. Legacy `tasks/get` also accepts `contextId` without `id` on this path or root `/`, returning the latest stored task or `result: null` when absent.
|
|
53
54
|
|
|
54
55
|
## Behavior notes
|
|
55
56
|
|
|
56
57
|
- **One task = one dsh session.** The A2A `contextId` IS the dsh session id. A completed turn ends `input-required`, not `completed` — the task is a conversation and stays continuable; `CancelTask` and turn errors are terminal (`canceled` / `failed`), and the SDK rejects follow-ups addressed at a terminal `taskId` (send with only the `contextId` to continue the session under a fresh task id).
|
|
57
58
|
- **Agents are full preset citizens.** Each task's agent is created with the deployment's default model selection (`agentDefaultModel`) and mounts its agent preset (the web profile keeps all tools inside presets — without one the agent would see an empty tool catalog). `agent.preset` pins a specific preset.
|
|
58
59
|
- **Streaming aggregation.** Text deltas of a turn share one `messageId`, so clients accumulate them into a single message; reasoning deltas ride a separate `messageId` and are marked `metadata.dshAgent.kind: 'thought'`. The turn-final event's message carries the full assembled text, so blocking `SendMessage` clients read the answer from `result.task.status.message`. Tool calls/results are data parts marked `tool-call` / `tool-result`; token usage lands in `metadata.usage` of the final event. A2A 1.0 has no `final` flag — terminal and interrupted states close the stream.
|
|
59
|
-
- **
|
|
60
|
+
- **Message parts beyond text.** File parts (inline bytes or a `url` — the plugin downloads those over http/https, bounded to 64 MiB and 30 s) use the composed attachment store (`ctx.attachments`, e.g. `@deepseek-ai/dsh-attachment-local`) unless a non-image materializer is configured: images reach vision-capable models natively, and stored files project to deterministic handle text with a read-only path when dsh can map one into the execution world. Without a store or materializer, files persist under the configured `uploadsDir` (default `<OS temp>/dsh-a2a-uploads/<date>/`, names sanitized cross-platform, collisions suffixed) and the prompt references them by absolute path inside a `<document>` tag. Ensure that directory is readable by the agent's tools. `data` parts become `<data>` JSON text. A turn never fails because a part kind is unsupported.
|
|
61
|
+
- **Remote execution files.** A deployment can register `ctx.provide('a2aFileMaterializer', { materializeFile })`, using the exported `A2aFileMaterializer` type. The callback receives `{ contextId, bytes, filename, mediaType }` (with a sanitized basename) and returns `{ readablePath }`, a path readable by that context's file/shell tools. When configured, non-image FileParts use the materializer instead of `ctx.attachments.saveFile()`; the model receives the returned `<document path="...">`. Images keep the existing attachment handling. A failed callback yields a delivery note, never a host-path fallback. The deployment owns sandbox selection, upload, and lifetime; no E2B SDK is required by this plugin.
|
|
60
62
|
- **No approval bridge**: dsh ships the mid-turn approval seam only as the optional `dsh-user-approval` package, which headless profiles do not compose, so tools that would ask are governed by the profile's own approval setup; the A2A side never enters a mid-turn `input-required`.
|
|
61
63
|
- **One in-flight message per task** is the supported flow (send the next message after the turn-final event). dsh serializes queued follow-ups into successive turns, but concurrent requests share one event bus — a second in-flight request may resolve with the first turn's final event.
|
|
62
|
-
- **Restart**: persisted task shells survive in Redis/GCS,
|
|
64
|
+
- **Restart**: persisted task shells survive in Redis/GCS. With `sessionPersistence` composed, a `contextId` already present on disk resumes its dsh session after restart; a new id creates a new session.
|
|
65
|
+
- **Clear**: the same-process gateway can call `ctx.get('a2aTasks').clearContext(contextId)` before reporting `messages/clear` success. It cancels and drains the live turn, removes the binding and every TaskStore shell for that context, and returns the removed task IDs. The gateway owns the separate session-surface replacement and client-visible history-ID update.
|
|
63
66
|
|
|
64
67
|
## Task stores
|
|
65
68
|
|
|
@@ -71,11 +74,11 @@ A2A **task state** (status + metadata) is separate from conversation history —
|
|
|
71
74
|
|
|
72
75
|
## Security
|
|
73
76
|
|
|
74
|
-
dsh ships **no authentication or authorization**. The server binds `127.0.0.1` by default; if you expose it, put an authenticated reverse proxy in front and treat every agent as running with the host process's OS privileges.
|
|
77
|
+
dsh ships **no authentication or authorization**. The server binds `127.0.0.1` by default; if you expose it, put an authenticated reverse proxy in front and treat every agent as running with the host process's OS privileges. File parts carrying a `url` are fetched server-side (http/https only, bounded) — another reason to keep the endpoint off untrusted networks.
|
|
75
78
|
|
|
76
79
|
## Compatibility
|
|
77
80
|
|
|
78
|
-
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}@0.1.6-alpha.2`) — check the `TODO(verify)` markers in `src/` before upgrading dsh.
|
|
81
|
+
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.6-alpha.2` — the attachment store is an optional peer) — check the `TODO(verify)` markers in `src/` before upgrading dsh.
|
|
79
82
|
|
|
80
83
|
## License
|
|
81
84
|
|
package/lib/bridge.js
CHANGED
|
@@ -23,12 +23,16 @@ import { installModelSelection } from '@deepseek-ai/dsh-agent';
|
|
|
23
23
|
// modules import packages that are not on npm yet (dsh-timeout, dsh-scope).
|
|
24
24
|
import { createUserMessage } from '@deepseek-ai/dsh-llm/message';
|
|
25
25
|
import { SessionId } from '@deepseek-ai/dsh-session/types';
|
|
26
|
+
import { buildMessageContent, uploadsDir } from './content.js';
|
|
26
27
|
import { SessionTranslator, terminalStatusUpdate } from './translator.js';
|
|
27
28
|
export class A2aBridge {
|
|
28
29
|
ctx;
|
|
29
30
|
options;
|
|
30
31
|
tasks = new Map(); // taskId → entry
|
|
31
32
|
bySession = new Map(); // sessionId (== contextId) → entry
|
|
33
|
+
clearing = new Set();
|
|
34
|
+
creating = new Map();
|
|
35
|
+
executing = new Map();
|
|
32
36
|
constructor(ctx, options) {
|
|
33
37
|
this.ctx = ctx;
|
|
34
38
|
this.options = options;
|
|
@@ -44,6 +48,25 @@ export class A2aBridge {
|
|
|
44
48
|
* a new task id for `message/send` calls that omit `taskId`.
|
|
45
49
|
*/
|
|
46
50
|
async ensureTask(taskId, contextId) {
|
|
51
|
+
const creation = this.ensureTaskInner(taskId, contextId);
|
|
52
|
+
let pending = this.creating.get(contextId);
|
|
53
|
+
if (!pending) {
|
|
54
|
+
pending = new Set();
|
|
55
|
+
this.creating.set(contextId, pending);
|
|
56
|
+
}
|
|
57
|
+
pending.add(creation);
|
|
58
|
+
try {
|
|
59
|
+
return await creation;
|
|
60
|
+
}
|
|
61
|
+
finally {
|
|
62
|
+
pending.delete(creation);
|
|
63
|
+
if (pending.size === 0)
|
|
64
|
+
this.creating.delete(contextId);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
async ensureTaskInner(taskId, contextId) {
|
|
68
|
+
if (this.clearing.has(contextId))
|
|
69
|
+
throw new Error('Context is being cleared.');
|
|
47
70
|
const existing = this.tasks.get(taskId);
|
|
48
71
|
if (existing)
|
|
49
72
|
return { entry: existing, freshTask: false };
|
|
@@ -52,12 +75,12 @@ export class A2aBridge {
|
|
|
52
75
|
this.tasks.delete(forContext.taskId);
|
|
53
76
|
forContext.taskId = taskId;
|
|
54
77
|
forContext.translator = new SessionTranslator(taskId, contextId, this.options.agentOptions?.model);
|
|
78
|
+
// A turn still open from the previous binding (e.g. a cancel whose abort
|
|
79
|
+
// is still propagating) must not emit events under the new task id.
|
|
80
|
+
forContext.staleTurn = forContext.turnActive;
|
|
55
81
|
this.tasks.set(taskId, forContext);
|
|
56
82
|
return { entry: forContext, freshTask: true };
|
|
57
83
|
}
|
|
58
|
-
// TODO(verify): resuming a persisted session (agents.resume) after a
|
|
59
|
-
// restart — needs dsh's sessionPersistence service composed. For now a
|
|
60
|
-
// client-supplied contextId simply names the fresh session.
|
|
61
84
|
const sessionId = SessionId(contextId);
|
|
62
85
|
const selection = this.resolveSelection();
|
|
63
86
|
// The web profile disables the host-plane tool plugins — agents get tools
|
|
@@ -70,32 +93,40 @@ export class A2aBridge {
|
|
|
70
93
|
if (presets) {
|
|
71
94
|
agentPreset = (await presets.resolve(this.options.preset || undefined)).id;
|
|
72
95
|
}
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
// the mount result must not escape — setup's return value is
|
|
91
|
-
// commit()-shaped to the factory.
|
|
92
|
-
installModelSelection(agentCtx, { current: selection, assembled: undefined });
|
|
93
|
-
}
|
|
94
|
-
if (agentPreset)
|
|
95
|
-
await presets.mount(agentCtx, agentPreset);
|
|
96
|
+
const agentOptions = selection
|
|
97
|
+
? { provider: selection.provider, model: selection.model }
|
|
98
|
+
: undefined;
|
|
99
|
+
// The loop's `{{model}}` prompt variable and request routing resolve from
|
|
100
|
+
// the agent's installed model selection (dsh-agent-loop's variables read
|
|
101
|
+
// agent.options; the scoped waterfalls wire provider/model into prompt
|
|
102
|
+
// assembly and the request config). Verified against
|
|
103
|
+
// @deepseek-ai/dsh-headless@0.1.6-alpha.2's run() — entry points are
|
|
104
|
+
// expected to resolve the deployment default themselves.
|
|
105
|
+
const setup = selection || agentPreset
|
|
106
|
+
? async (agentCtx) => {
|
|
107
|
+
if (selection) {
|
|
108
|
+
// Statement, deliberately not returned: a returned disposer would
|
|
109
|
+
// be invoked as the setup commit and immediately unwired. Likewise
|
|
110
|
+
// the mount result must not escape — setup's return value is
|
|
111
|
+
// commit()-shaped to the factory.
|
|
112
|
+
installModelSelection(agentCtx, { current: selection, assembled: undefined });
|
|
96
113
|
}
|
|
97
|
-
|
|
98
|
-
|
|
114
|
+
if (agentPreset)
|
|
115
|
+
await presets.mount(agentCtx, agentPreset);
|
|
116
|
+
}
|
|
117
|
+
: undefined;
|
|
118
|
+
// Persistence is optional in minimal compositions. A stat failure is not
|
|
119
|
+
// absence: let it fail instead of trying create() against an existing id.
|
|
120
|
+
const persistence = this.ctx.get('sessionPersistence');
|
|
121
|
+
const persisted = await persistence?.stat(sessionId);
|
|
122
|
+
const handle = persisted
|
|
123
|
+
? await this.ctx.agents.resume({ resumeSessionId: sessionId, agentOptions, setup })
|
|
124
|
+
: await this.ctx.agents.create({
|
|
125
|
+
sessionId,
|
|
126
|
+
meta: { cwd: this.options.cwd, ...(agentPreset ? { agentPreset } : {}) },
|
|
127
|
+
agentOptions,
|
|
128
|
+
setup,
|
|
129
|
+
});
|
|
99
130
|
const entry = {
|
|
100
131
|
taskId,
|
|
101
132
|
sessionId,
|
|
@@ -103,14 +134,55 @@ export class A2aBridge {
|
|
|
103
134
|
translator: new SessionTranslator(taskId, contextId, this.options.agentOptions?.model),
|
|
104
135
|
bus: null,
|
|
105
136
|
turnActive: false,
|
|
137
|
+
staleTurn: false,
|
|
106
138
|
settled: [],
|
|
139
|
+
running: new Set(),
|
|
107
140
|
};
|
|
108
141
|
this.tasks.set(taskId, entry);
|
|
109
142
|
this.bySession.set(contextId, entry);
|
|
110
143
|
return { entry, freshTask: true };
|
|
111
144
|
}
|
|
145
|
+
/** A2A parts → dsh content blocks; throws only when no part is usable. */
|
|
146
|
+
async buildContent(message, contextId) {
|
|
147
|
+
const attachments = this.ctx.get('attachments');
|
|
148
|
+
const materializer = this.ctx.get('a2aFileMaterializer');
|
|
149
|
+
return buildMessageContent(message.parts, attachments, uploadsDir(this.options.uploadsDir), materializer ? { contextId, materializer } : undefined);
|
|
150
|
+
}
|
|
151
|
+
isClearing(contextId) {
|
|
152
|
+
return this.clearing.has(contextId);
|
|
153
|
+
}
|
|
154
|
+
/** Include content preparation in the work a context clear must drain. */
|
|
155
|
+
async trackExecution(contextId, run) {
|
|
156
|
+
const execution = run();
|
|
157
|
+
let pending = this.executing.get(contextId);
|
|
158
|
+
if (!pending) {
|
|
159
|
+
pending = new Set();
|
|
160
|
+
this.executing.set(contextId, pending);
|
|
161
|
+
}
|
|
162
|
+
pending.add(execution);
|
|
163
|
+
try {
|
|
164
|
+
await execution;
|
|
165
|
+
}
|
|
166
|
+
finally {
|
|
167
|
+
pending.delete(execution);
|
|
168
|
+
if (pending.size === 0)
|
|
169
|
+
this.executing.delete(contextId);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
112
172
|
/** Queue one user-message turn on the task's agent and await its `turn/end`. */
|
|
113
|
-
async runTurn(entry,
|
|
173
|
+
async runTurn(entry, content, bus) {
|
|
174
|
+
if (this.clearing.has(entry.sessionId))
|
|
175
|
+
throw new Error('Context is being cleared.');
|
|
176
|
+
const running = this.runTurnInner(entry, content, bus);
|
|
177
|
+
entry.running.add(running);
|
|
178
|
+
try {
|
|
179
|
+
await running;
|
|
180
|
+
}
|
|
181
|
+
finally {
|
|
182
|
+
entry.running.delete(running);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
async runTurnInner(entry, content, bus) {
|
|
114
186
|
let resolveSettled;
|
|
115
187
|
const settled = new Promise((resolve) => {
|
|
116
188
|
resolveSettled = resolve;
|
|
@@ -119,7 +191,7 @@ export class A2aBridge {
|
|
|
119
191
|
entry.bus = bus;
|
|
120
192
|
try {
|
|
121
193
|
const result = entry.handle.agent.followup(createUserMessage({
|
|
122
|
-
content
|
|
194
|
+
content,
|
|
123
195
|
source: { kind: 'user' },
|
|
124
196
|
}));
|
|
125
197
|
// followup() is typed void; guard a mistyped async impl anyway — an
|
|
@@ -150,6 +222,32 @@ export class A2aBridge {
|
|
|
150
222
|
entry.bus = null;
|
|
151
223
|
}
|
|
152
224
|
}
|
|
225
|
+
/** Cancel and release the live binding before the caller deletes stored shells. */
|
|
226
|
+
async clearContext(contextId, clearStored = async (ids) => ids) {
|
|
227
|
+
if (this.clearing.has(contextId))
|
|
228
|
+
throw new Error('Context is already being cleared.');
|
|
229
|
+
this.clearing.add(contextId);
|
|
230
|
+
try {
|
|
231
|
+
await Promise.allSettled([...(this.creating.get(contextId) ?? [])]);
|
|
232
|
+
const entry = this.bySession.get(contextId);
|
|
233
|
+
if (entry) {
|
|
234
|
+
if (entry.turnActive || entry.running.size > 0) {
|
|
235
|
+
await entry.handle.agent.cancel({ kind: 'user' });
|
|
236
|
+
}
|
|
237
|
+
await entry.handle.agent.whenIdle();
|
|
238
|
+
this.settleEntry(entry, 'Context cleared.');
|
|
239
|
+
await Promise.allSettled([...entry.running]);
|
|
240
|
+
this.tasks.delete(entry.taskId);
|
|
241
|
+
this.bySession.delete(contextId);
|
|
242
|
+
await entry.handle.dispose();
|
|
243
|
+
}
|
|
244
|
+
await Promise.allSettled([...(this.executing.get(contextId) ?? [])]);
|
|
245
|
+
return await clearStored(entry ? [entry.taskId] : []);
|
|
246
|
+
}
|
|
247
|
+
finally {
|
|
248
|
+
this.clearing.delete(contextId);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
153
251
|
/** Abort the task's active turn; returns the contextId when the task is live. */
|
|
154
252
|
cancel(taskId) {
|
|
155
253
|
const entry = this.tasks.get(taskId);
|
|
@@ -181,12 +279,20 @@ export class A2aBridge {
|
|
|
181
279
|
return;
|
|
182
280
|
if (event.type === 'turn/start')
|
|
183
281
|
entry.turnActive = true;
|
|
282
|
+
// A turn carried over from a previous task binding (e.g. a canceled turn
|
|
283
|
+
// whose abort was still in flight when the context rebound): its turn/end
|
|
284
|
+
// belongs to the old task — settle waiters, but publish nothing under the
|
|
285
|
+
// new task id.
|
|
286
|
+
const stale = event.type === 'turn/end' && entry.staleTurn;
|
|
287
|
+
if (stale)
|
|
288
|
+
entry.staleTurn = false;
|
|
184
289
|
// No-throw seam: translation errors must never reach the agent loop. The
|
|
185
290
|
// turn/end waiter still resolves below, so a poisoned event cannot hang
|
|
186
291
|
// an in-flight execute() either.
|
|
187
292
|
try {
|
|
188
|
-
|
|
189
|
-
entry.
|
|
293
|
+
if (!stale)
|
|
294
|
+
for (const out of entry.translator.handle(event))
|
|
295
|
+
entry.bus?.publish(out);
|
|
190
296
|
}
|
|
191
297
|
catch (error) {
|
|
192
298
|
console.error('[dsh-a2a] failed to translate session event:', error);
|
package/lib/content.js
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Maps A2A message parts onto dsh content blocks. A turn never fails over
|
|
3
|
+
* part kinds:
|
|
4
|
+
*
|
|
5
|
+
* - `text` parts stay text;
|
|
6
|
+
* - file parts (inline `raw` bytes or a `url`) use the composed attachment
|
|
7
|
+
* store unless a non-image materializer is configured; url parts are
|
|
8
|
+
* downloaded by the plugin (http/https only, size- and time-bounded);
|
|
9
|
+
* - an optional deployment materializer puts non-image files into the
|
|
10
|
+
* active execution world instead of saving a host attachment;
|
|
11
|
+
* - without a materializer, files with no store (or refused by a store)
|
|
12
|
+
* persist under the configured upload root (default `<OS temp>/
|
|
13
|
+
* dsh-a2a-uploads/<date>/`) and the prompt references them by path;
|
|
14
|
+
* - `data` parts become JSON text.
|
|
15
|
+
*
|
|
16
|
+
* The SDK's admission helpers (admitEncodedFile/admitPromptContent) are not
|
|
17
|
+
* used: they take base64 wire uploads and throw on refusal — this path holds
|
|
18
|
+
* raw bytes and must degrade, never fail.
|
|
19
|
+
*/
|
|
20
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
21
|
+
import os from 'node:os';
|
|
22
|
+
import path from 'node:path';
|
|
23
|
+
/** Cap on one url part download. */
|
|
24
|
+
const MAX_DOWNLOAD_BYTES = 64 * 1024 * 1024;
|
|
25
|
+
const DOWNLOAD_TIMEOUT_MS = 30_000;
|
|
26
|
+
// TODO(verify): the confirmation data-part protocol ({callId, outcome}) maps
|
|
27
|
+
// onto the optional dsh-user-approval service (0.1.2+, asks carry the exact
|
|
28
|
+
// tool call) — bridge it once a deployment composes that service.
|
|
29
|
+
export async function buildMessageContent(parts, attachments, dir, materialization) {
|
|
30
|
+
const content = [];
|
|
31
|
+
for (const part of parts) {
|
|
32
|
+
const c = part.content;
|
|
33
|
+
if (!c)
|
|
34
|
+
continue;
|
|
35
|
+
switch (c.$case) {
|
|
36
|
+
case 'text':
|
|
37
|
+
if (c.value.trim())
|
|
38
|
+
content.push({ type: 'text', text: c.value });
|
|
39
|
+
break;
|
|
40
|
+
case 'data':
|
|
41
|
+
content.push({ type: 'text', text: dataText(c.value) });
|
|
42
|
+
break;
|
|
43
|
+
case 'raw':
|
|
44
|
+
case 'url':
|
|
45
|
+
content.push(await fileContent(c, part, attachments, dir, materialization));
|
|
46
|
+
break;
|
|
47
|
+
default:
|
|
48
|
+
// A part kind this SDK version predates: keep it visible, never drop.
|
|
49
|
+
content.push(note(part, part.mediaType, undefined, `unsupported part kind "${c.$case}"`));
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (content.length === 0) {
|
|
53
|
+
throw new Error('dsh-a2a: message must contain at least one usable part');
|
|
54
|
+
}
|
|
55
|
+
return content;
|
|
56
|
+
}
|
|
57
|
+
/** The directory file parts persist into when they are not attachments. */
|
|
58
|
+
export function uploadsDir(root) {
|
|
59
|
+
// 'sv-SE' renders YYYY-MM-DD; the bucket is local time.
|
|
60
|
+
const segment = new Date().toLocaleDateString('sv-SE').replaceAll('-', '');
|
|
61
|
+
return path.join(root || path.join(os.tmpdir(), 'dsh-a2a-uploads'), segment);
|
|
62
|
+
}
|
|
63
|
+
/** Log and degrade one file-part failure to a metadata note. */
|
|
64
|
+
function failNote(part, mediaType, uri, label, error) {
|
|
65
|
+
console.error(`[dsh-a2a] ${label}:`, error);
|
|
66
|
+
return note(part, mediaType, uri, error instanceof Error ? error.message : String(error));
|
|
67
|
+
}
|
|
68
|
+
/** Resolve one file part to an attachment block or a workspace file. */
|
|
69
|
+
async function fileContent(c, part, attachments, dir, materialization) {
|
|
70
|
+
let bytes;
|
|
71
|
+
let mediaType = part.mediaType;
|
|
72
|
+
let uri;
|
|
73
|
+
try {
|
|
74
|
+
if (c.$case === 'url') {
|
|
75
|
+
uri = c.value;
|
|
76
|
+
const got = await download(uri);
|
|
77
|
+
bytes = got.bytes;
|
|
78
|
+
mediaType ||= got.mediaType ?? '';
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
bytes = c.value;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
return failNote(part, mediaType, uri, 'failed to read a file part', error);
|
|
86
|
+
}
|
|
87
|
+
const image = attachments?.imageLimits.mediaTypes.includes(mediaType.toLowerCase());
|
|
88
|
+
if (attachments && (!materialization || image)) {
|
|
89
|
+
const block = await saveAttachment(attachments, bytes, mediaType, part.filename);
|
|
90
|
+
if (block)
|
|
91
|
+
return block;
|
|
92
|
+
}
|
|
93
|
+
if (materialization && !image) {
|
|
94
|
+
try {
|
|
95
|
+
const { readablePath } = await materialization.materializer.materializeFile({
|
|
96
|
+
contextId: materialization.contextId,
|
|
97
|
+
bytes,
|
|
98
|
+
filename: sanitizeUploadFileName(part.filename),
|
|
99
|
+
mediaType,
|
|
100
|
+
});
|
|
101
|
+
if (!readablePath)
|
|
102
|
+
throw new Error('materializer returned no readable path');
|
|
103
|
+
return documentBlock(uri, mediaType, bytes.byteLength, readablePath);
|
|
104
|
+
}
|
|
105
|
+
catch (error) {
|
|
106
|
+
console.error('[dsh-a2a] failed to materialize a file part:', error);
|
|
107
|
+
return note(part, mediaType, uri, 'file materialization failed');
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
try {
|
|
111
|
+
const name = sanitizeUploadFileName(part.filename);
|
|
112
|
+
const filePath = await persistUpload(dir, name, bytes);
|
|
113
|
+
return documentBlock(uri, mediaType, bytes.byteLength, filePath);
|
|
114
|
+
}
|
|
115
|
+
catch (error) {
|
|
116
|
+
return failNote(part, mediaType, uri, 'failed to persist a file part', error);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
/** Commit part bytes to the store: image path first, verbatim file on refusal. */
|
|
120
|
+
async function saveAttachment(attachments, bytes, mediaType, filename) {
|
|
121
|
+
const name = filename || undefined;
|
|
122
|
+
if (attachments.imageLimits.mediaTypes.includes(mediaType.toLowerCase())) {
|
|
123
|
+
try {
|
|
124
|
+
const attachment = await attachments.saveImage({
|
|
125
|
+
data: bytes,
|
|
126
|
+
mediaType: mediaType,
|
|
127
|
+
name,
|
|
128
|
+
});
|
|
129
|
+
return { type: 'image', attachment };
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
console.error('[dsh-a2a] image admission failed; storing the bytes as a file:', error);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
try {
|
|
136
|
+
const attachment = await attachments.saveFile({ data: bytes, name });
|
|
137
|
+
return { type: 'file', attachment };
|
|
138
|
+
}
|
|
139
|
+
catch (error) {
|
|
140
|
+
console.error('[dsh-a2a] failed to store an attachment:', error);
|
|
141
|
+
return undefined;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
/** Save one upload under the upload dir; returns the absolute path. */
|
|
145
|
+
async function persistUpload(dir, name, bytes) {
|
|
146
|
+
await mkdir(dir, { recursive: true });
|
|
147
|
+
const { name: stem, ext } = path.parse(name);
|
|
148
|
+
for (let attempt = 0;; attempt++) {
|
|
149
|
+
const candidate = path.join(dir, attempt === 0 ? name : `${stem}-${attempt}${ext}`);
|
|
150
|
+
try {
|
|
151
|
+
await writeFile(candidate, bytes, { flag: 'wx' });
|
|
152
|
+
return candidate;
|
|
153
|
+
}
|
|
154
|
+
catch (error) {
|
|
155
|
+
if (error.code !== 'EEXIST')
|
|
156
|
+
throw error;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
// Windows refuses these device stems regardless of extension.
|
|
161
|
+
const WINDOWS_RESERVED_STEMS = new Set('CON PRN AUX NUL COM1 COM2 COM3 COM4 COM5 COM6 COM7 COM8 COM9 LPT1 LPT2 LPT3 LPT4 LPT5 LPT6 LPT7 LPT8 LPT9'.split(' '));
|
|
162
|
+
/**
|
|
163
|
+
* Cross-platform basename with control and Windows-illegal characters
|
|
164
|
+
* stripped; the trailing-dot/space strip also collapses '.' and '..'.
|
|
165
|
+
*/
|
|
166
|
+
function sanitizeUploadFileName(name) {
|
|
167
|
+
const cleaned = path.win32
|
|
168
|
+
.basename(name)
|
|
169
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: stripping control chars is the point
|
|
170
|
+
.replace(/[<>:"|?*\x00-\x1f\x7f]/g, '')
|
|
171
|
+
.trim()
|
|
172
|
+
.replace(/[. ]+$/, '');
|
|
173
|
+
const stem = cleaned.split('.')[0].toUpperCase();
|
|
174
|
+
return !cleaned || WINDOWS_RESERVED_STEMS.has(stem) ? 'unnamed-file' : cleaned;
|
|
175
|
+
}
|
|
176
|
+
/** Tool-readable path stand-in for a file part. */
|
|
177
|
+
function documentBlock(uri, mediaType, bytes, filePath) {
|
|
178
|
+
const attrs = [
|
|
179
|
+
// The basename of the final path, so name= always matches the file on
|
|
180
|
+
// disk — including collision suffixes.
|
|
181
|
+
`name="${attr(path.win32.basename(filePath))}"`,
|
|
182
|
+
uri && `uri="${attr(uri)}"`,
|
|
183
|
+
mediaType && `type="${attr(mediaType)}"`,
|
|
184
|
+
`size="${bytes}"`,
|
|
185
|
+
`path="${attr(filePath)}"`,
|
|
186
|
+
]
|
|
187
|
+
.filter(Boolean)
|
|
188
|
+
.join(' ');
|
|
189
|
+
return {
|
|
190
|
+
type: 'text',
|
|
191
|
+
text: `<document ${attrs}>\nThe file is saved at the path above; read or process it with your tools.\n</document>`,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
function note(part, mediaType, uri, reason) {
|
|
195
|
+
const desc = [
|
|
196
|
+
part.filename && `"${part.filename}"`,
|
|
197
|
+
mediaType && `(${mediaType})`,
|
|
198
|
+
uri && `at ${uri}`,
|
|
199
|
+
]
|
|
200
|
+
.filter(Boolean)
|
|
201
|
+
.join(' ');
|
|
202
|
+
return { type: 'text', text: `[attachment ${desc || 'file'} not delivered: ${reason}]` };
|
|
203
|
+
}
|
|
204
|
+
/** JSON text stand-in for a structured data part. */
|
|
205
|
+
function dataText(value) {
|
|
206
|
+
try {
|
|
207
|
+
return `<data>\n${JSON.stringify(value, null, 2) ?? String(value)}\n</data>`;
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
return `<data>\n${String(value)}\n</data>`;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
/** Strip characters that would break attribute position. */
|
|
214
|
+
function attr(value) {
|
|
215
|
+
return value.replace(/["\r\n<>]/g, ' ');
|
|
216
|
+
}
|
|
217
|
+
/** Fetch one url part's bytes over http(s), bounded in size and time. */
|
|
218
|
+
async function download(uri) {
|
|
219
|
+
const scheme = new URL(uri).protocol;
|
|
220
|
+
if (scheme !== 'http:' && scheme !== 'https:') {
|
|
221
|
+
throw new Error(`unsupported scheme "${scheme}"`);
|
|
222
|
+
}
|
|
223
|
+
const response = await fetch(uri, { signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS) });
|
|
224
|
+
if (!response.ok)
|
|
225
|
+
throw new Error(`HTTP ${response.status}`);
|
|
226
|
+
const declared = Number(response.headers.get('content-length'));
|
|
227
|
+
if (declared > MAX_DOWNLOAD_BYTES) {
|
|
228
|
+
throw new Error(`exceeds the ${MAX_DOWNLOAD_BYTES}-byte download limit`);
|
|
229
|
+
}
|
|
230
|
+
const chunks = [];
|
|
231
|
+
let total = 0;
|
|
232
|
+
if (response.body) {
|
|
233
|
+
// Throwing out of the loop cancels the stream via the iterator's return.
|
|
234
|
+
for await (const chunk of response.body) {
|
|
235
|
+
total += chunk.byteLength;
|
|
236
|
+
if (total > MAX_DOWNLOAD_BYTES) {
|
|
237
|
+
throw new Error(`exceeds the ${MAX_DOWNLOAD_BYTES}-byte download limit`);
|
|
238
|
+
}
|
|
239
|
+
chunks.push(chunk);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
const mediaType = response.headers.get('content-type')?.split(';')[0].trim() || undefined;
|
|
243
|
+
return { bytes: Buffer.concat(chunks, total), mediaType };
|
|
244
|
+
}
|
package/lib/executor.js
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
* The @a2a-js/sdk {@link AgentExecutor} that drives dsh agents through the
|
|
3
3
|
* bridge. One `execute()` = one user-message turn:
|
|
4
4
|
*
|
|
5
|
-
* 1.
|
|
5
|
+
* 1. map the message's parts onto dsh content blocks (file parts become
|
|
6
|
+
* execution-world paths with a materializer, attachments or local files otherwise)
|
|
6
7
|
* 2. ensure the task's session/agent exists (creating it on first contact)
|
|
7
8
|
* 3. publish a `task` event first — A2A 1.0 stream ordering REQUIRES the
|
|
8
9
|
* first event of every execute to be a task or message, including
|
|
@@ -21,14 +22,22 @@ import { AgentEvent } from '@a2a-js/sdk/server';
|
|
|
21
22
|
import { agentTextMessage, terminalStatusUpdate } from './translator.js';
|
|
22
23
|
export class DshAgentExecutor {
|
|
23
24
|
bridge;
|
|
24
|
-
|
|
25
|
+
suppressTask;
|
|
26
|
+
constructor(bridge, suppressTask) {
|
|
25
27
|
this.bridge = bridge;
|
|
28
|
+
this.suppressTask = suppressTask;
|
|
26
29
|
}
|
|
27
30
|
async execute(requestContext, eventBus) {
|
|
31
|
+
if (this.bridge.isClearing(requestContext.contextId)) {
|
|
32
|
+
this.suppressTask?.(requestContext.taskId);
|
|
33
|
+
}
|
|
34
|
+
return this.bridge.trackExecution(requestContext.contextId, () => this.executeInner(requestContext, eventBus));
|
|
35
|
+
}
|
|
36
|
+
async executeInner(requestContext, eventBus) {
|
|
28
37
|
const { userMessage, taskId, contextId } = requestContext;
|
|
29
38
|
let anchored = false;
|
|
30
39
|
try {
|
|
31
|
-
const
|
|
40
|
+
const content = await this.bridge.buildContent(userMessage, contextId);
|
|
32
41
|
const { entry, freshTask } = await this.bridge.ensureTask(taskId, contextId);
|
|
33
42
|
eventBus.publish(freshTask
|
|
34
43
|
? // First contact: the submitted anchor carries the user message
|
|
@@ -39,9 +48,11 @@ export class DshAgentExecutor {
|
|
|
39
48
|
// stored task (history/artifacts preserved).
|
|
40
49
|
taskEvent(taskId, contextId, TaskState.TASK_STATE_WORKING));
|
|
41
50
|
anchored = true;
|
|
42
|
-
await this.bridge.runTurn(entry,
|
|
51
|
+
await this.bridge.runTurn(entry, content, eventBus);
|
|
43
52
|
}
|
|
44
53
|
catch (error) {
|
|
54
|
+
if (this.bridge.isClearing(contextId))
|
|
55
|
+
this.suppressTask?.(taskId);
|
|
45
56
|
const message = error instanceof Error ? error.message : String(error);
|
|
46
57
|
if (!anchored) {
|
|
47
58
|
eventBus.publish(taskEvent(taskId, contextId, TaskState.TASK_STATE_FAILED, {
|
|
@@ -80,20 +91,3 @@ function taskEvent(taskId, contextId, state, extra = {}) {
|
|
|
80
91
|
};
|
|
81
92
|
return AgentEvent.task(task);
|
|
82
93
|
}
|
|
83
|
-
// TODO(verify): non-text parts (file/raw/url/data) — dsh supports image
|
|
84
|
-
// content blocks; map them when a client needs it. The confirmation data-part
|
|
85
|
-
// protocol ({callId, outcome}) maps onto the optional dsh-user-approval
|
|
86
|
-
// service (0.1.2+, asks carry the exact tool call) — bridge it once a
|
|
87
|
-
// deployment composes that service.
|
|
88
|
-
function extractText(message) {
|
|
89
|
-
const nonText = message.parts.filter((part) => part.content?.$case !== 'text');
|
|
90
|
-
if (nonText.length > 0) {
|
|
91
|
-
throw new Error(`dsh-a2a: unsupported part kind(s): ${nonText.map((part) => part.content?.$case ?? 'unknown').join(', ')} (text-only)`);
|
|
92
|
-
}
|
|
93
|
-
const text = message.parts
|
|
94
|
-
.map((part) => (part.content?.$case === 'text' ? part.content.value : ''))
|
|
95
|
-
.join('');
|
|
96
|
-
if (!text.trim())
|
|
97
|
-
throw new Error('dsh-a2a: message must contain at least one text part');
|
|
98
|
-
return text;
|
|
99
|
-
}
|
package/lib/index.js
CHANGED
|
@@ -11,14 +11,15 @@
|
|
|
11
11
|
*
|
|
12
12
|
* @module dsh-a2a
|
|
13
13
|
*/
|
|
14
|
-
import {
|
|
14
|
+
import { TaskState } from '@a2a-js/sdk';
|
|
15
|
+
import { ServerCallContext } from '@a2a-js/sdk/server';
|
|
15
16
|
import Schema from '@deepseek-ai/schemastery';
|
|
16
17
|
import { A2aBridge } from './bridge.js';
|
|
17
18
|
import { DshAgentExecutor } from './executor.js';
|
|
18
19
|
import { startA2aServer } from './server.js';
|
|
19
20
|
import { GcsTaskStore } from './stores/gcs.js';
|
|
20
21
|
import { RedisTaskStore } from './stores/redis.js';
|
|
21
|
-
import { SanitizedTaskStore } from './task-store.js';
|
|
22
|
+
import { MemoryTaskStore, SanitizedTaskStore } from './task-store.js';
|
|
22
23
|
export const name = 'dsh-a2a';
|
|
23
24
|
/** The bridge creates and owns agents through the registry service. */
|
|
24
25
|
export const inject = ['agents'];
|
|
@@ -29,6 +30,11 @@ export const Config = Schema.object({
|
|
|
29
30
|
basePath: Schema.string().default('/a2a'),
|
|
30
31
|
/** Working directory for agents spawned by A2A tasks. Must be absolute. */
|
|
31
32
|
cwd: Schema.string().default(process.cwd()),
|
|
33
|
+
/**
|
|
34
|
+
* Root directory file parts persist into when no attachment store is
|
|
35
|
+
* composed (date-layered below it). Empty = <OS temp>/dsh-a2a-uploads.
|
|
36
|
+
*/
|
|
37
|
+
uploadsDir: Schema.string().default(''),
|
|
32
38
|
agent: Schema.object({
|
|
33
39
|
provider: Schema.string().default(''),
|
|
34
40
|
model: Schema.string().default(''),
|
|
@@ -83,6 +89,7 @@ export function apply(ctx, config) {
|
|
|
83
89
|
async function start() {
|
|
84
90
|
const bridge = new A2aBridge(ctx, {
|
|
85
91
|
cwd: config.cwd,
|
|
92
|
+
uploadsDir: config.uploadsDir || undefined,
|
|
86
93
|
agentOptions: {
|
|
87
94
|
provider: config.agent.provider || undefined,
|
|
88
95
|
model: config.agent.model || undefined,
|
|
@@ -91,7 +98,32 @@ export function apply(ctx, config) {
|
|
|
91
98
|
});
|
|
92
99
|
const store = createTaskStore(config);
|
|
93
100
|
await store.init?.();
|
|
94
|
-
|
|
101
|
+
ctx.provide('a2aTasks', {
|
|
102
|
+
async clearContext(contextId, tenant = '') {
|
|
103
|
+
const call = new ServerCallContext({ tenant });
|
|
104
|
+
return bridge.clearContext(contextId, async (liveIds) => {
|
|
105
|
+
const ids = new Set(liveIds);
|
|
106
|
+
let pageToken = '';
|
|
107
|
+
do {
|
|
108
|
+
const page = await store.list({
|
|
109
|
+
tenant: '',
|
|
110
|
+
contextId,
|
|
111
|
+
pageSize: 100,
|
|
112
|
+
pageToken,
|
|
113
|
+
status: TaskState.TASK_STATE_UNSPECIFIED,
|
|
114
|
+
statusTimestampAfter: undefined,
|
|
115
|
+
}, call);
|
|
116
|
+
for (const task of page.tasks)
|
|
117
|
+
ids.add(task.id);
|
|
118
|
+
pageToken = page.nextPageToken;
|
|
119
|
+
} while (pageToken);
|
|
120
|
+
for (const id of ids)
|
|
121
|
+
await store.delete(id, call);
|
|
122
|
+
return [...ids];
|
|
123
|
+
});
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
const executor = new DshAgentExecutor(bridge, (taskId) => store.suppress(taskId));
|
|
95
127
|
const server = await startA2aServer({
|
|
96
128
|
host: config.host,
|
|
97
129
|
port: config.port,
|
|
@@ -125,6 +157,6 @@ function createTaskStore(config) {
|
|
|
125
157
|
}
|
|
126
158
|
return new SanitizedTaskStore(new GcsTaskStore(config.gcs));
|
|
127
159
|
default:
|
|
128
|
-
return new SanitizedTaskStore(new
|
|
160
|
+
return new SanitizedTaskStore(new MemoryTaskStore());
|
|
129
161
|
}
|
|
130
162
|
}
|
package/lib/server.js
CHANGED
|
@@ -12,9 +12,9 @@
|
|
|
12
12
|
* the server and builds the card.
|
|
13
13
|
*/
|
|
14
14
|
import { createServer } from 'node:http';
|
|
15
|
-
import { A2A_PROTOCOL_VERSION, AGENT_CARD_PATH, } from '@a2a-js/sdk';
|
|
15
|
+
import { A2A_PROTOCOL_VERSION, AGENT_CARD_PATH, TaskState, } from '@a2a-js/sdk';
|
|
16
16
|
import { duplicateInterfacesForLegacy } from '@a2a-js/sdk/compat/v0_3';
|
|
17
|
-
import { DefaultRequestHandler } from '@a2a-js/sdk/server';
|
|
17
|
+
import { DefaultRequestHandler, ServerCallContext, } from '@a2a-js/sdk/server';
|
|
18
18
|
import { agentCardHandler, jsonRpcHandler, UserBuilder } from '@a2a-js/sdk/server/express';
|
|
19
19
|
import express from 'express';
|
|
20
20
|
export async function startA2aServer(options) {
|
|
@@ -61,12 +61,61 @@ export async function startA2aServer(options) {
|
|
|
61
61
|
app.use(`/${AGENT_CARD_PATH}`, cardHandler);
|
|
62
62
|
// Pre-1.0 discovery path, kept as a convenience alias.
|
|
63
63
|
app.use('/.well-known/agent.json', cardHandler);
|
|
64
|
-
|
|
65
|
-
|
|
64
|
+
const contextLookup = async (req, res, next) => {
|
|
65
|
+
const body = req.body;
|
|
66
|
+
const params = body?.params;
|
|
67
|
+
if (body?.method !== 'tasks/get' ||
|
|
68
|
+
typeof params?.contextId !== 'string' ||
|
|
69
|
+
!params.contextId ||
|
|
70
|
+
params.id) {
|
|
71
|
+
next();
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
try {
|
|
75
|
+
const contextId = params.contextId;
|
|
76
|
+
const tenant = typeof params.tenant === 'string' ? params.tenant : '';
|
|
77
|
+
const page = await options.taskStore.list({
|
|
78
|
+
tenant,
|
|
79
|
+
contextId,
|
|
80
|
+
pageSize: 1,
|
|
81
|
+
pageToken: '',
|
|
82
|
+
status: TaskState.TASK_STATE_UNSPECIFIED,
|
|
83
|
+
statusTimestampAfter: undefined,
|
|
84
|
+
}, new ServerCallContext({ tenant }));
|
|
85
|
+
const task = page.tasks[0];
|
|
86
|
+
if (!task) {
|
|
87
|
+
res.json({ jsonrpc: '2.0', id: body.id ?? null, result: null });
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
const { contextId: _contextId, ...rest } = params;
|
|
91
|
+
req.body = { ...body, params: { ...rest, id: task.id } };
|
|
92
|
+
next(); // Let the SDK load and serialize the task on the v0.3 wire.
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
console.error('[dsh-a2a] context task lookup failed:', error);
|
|
96
|
+
res.status(500).json({
|
|
97
|
+
jsonrpc: '2.0',
|
|
98
|
+
id: body.id ?? null,
|
|
99
|
+
error: { code: -32603, message: 'Task lookup failed' },
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
const rpcHandler = jsonRpcHandler({
|
|
66
104
|
requestHandler,
|
|
67
105
|
userBuilder: UserBuilder.noAuthentication,
|
|
68
106
|
legacyCompat: { enabled: true },
|
|
69
|
-
})
|
|
107
|
+
});
|
|
108
|
+
// dsh ships no authn/authz — the loopback default binding is the boundary.
|
|
109
|
+
if (base) {
|
|
110
|
+
app.post('/', (req, res, next) => {
|
|
111
|
+
const body = req.body;
|
|
112
|
+
if (body?.method === 'tasks/get' && body.params?.contextId && !body.params.id)
|
|
113
|
+
next();
|
|
114
|
+
else
|
|
115
|
+
res.sendStatus(404);
|
|
116
|
+
}, contextLookup, rpcHandler);
|
|
117
|
+
}
|
|
118
|
+
app.use(base, contextLookup, rpcHandler);
|
|
70
119
|
const server = createServer(app);
|
|
71
120
|
await new Promise((resolve, reject) => {
|
|
72
121
|
server.once('error', reject);
|
package/lib/stores/gcs.js
CHANGED
|
@@ -49,6 +49,18 @@ export class GcsTaskStore {
|
|
|
49
49
|
const [buf] = await file.download();
|
|
50
50
|
return JSON.parse(gunzipSync(buf).toString('utf8'));
|
|
51
51
|
}
|
|
52
|
+
async delete(taskId) {
|
|
53
|
+
if (!this.bucket)
|
|
54
|
+
return;
|
|
55
|
+
await Promise.all([
|
|
56
|
+
this.bucket
|
|
57
|
+
.file(`${this.prefix}/${taskId}/metadata.json.gz`)
|
|
58
|
+
.delete({ ignoreNotFound: true }),
|
|
59
|
+
this.bucket
|
|
60
|
+
.file(`${this.prefix}/${taskId}/workspace.tar.gz`)
|
|
61
|
+
.delete({ ignoreNotFound: true }),
|
|
62
|
+
]);
|
|
63
|
+
}
|
|
52
64
|
async list(params) {
|
|
53
65
|
if (!this.bucket)
|
|
54
66
|
return { tasks: [], nextPageToken: '', pageSize: 0, totalSize: 0 };
|
package/lib/stores/redis.js
CHANGED
|
@@ -35,6 +35,9 @@ export class RedisTaskStore {
|
|
|
35
35
|
const raw = await this.redis.get(this.taskKey(taskId));
|
|
36
36
|
return raw ? JSON.parse(raw) : undefined;
|
|
37
37
|
}
|
|
38
|
+
async delete(taskId) {
|
|
39
|
+
await this.redis?.del(this.taskKey(taskId));
|
|
40
|
+
}
|
|
38
41
|
async list(params) {
|
|
39
42
|
if (!this.redis)
|
|
40
43
|
return { tasks: [], nextPageToken: '', pageSize: 0, totalSize: 0 };
|
package/lib/task-store.js
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
* A2A 1.0 added `list()` to the TaskStore contract; {@link listShells} holds
|
|
17
17
|
* the shared filter/sort/paginate logic so the backends only enumerate shells.
|
|
18
18
|
*/
|
|
19
|
+
import { resolveUserScope } from '@a2a-js/sdk/server';
|
|
19
20
|
/** The persistent shape: a metadata shell with empty history/artifacts. */
|
|
20
21
|
export function sanitizeTask(task) {
|
|
21
22
|
return {
|
|
@@ -27,10 +28,39 @@ export function sanitizeTask(task) {
|
|
|
27
28
|
artifacts: [],
|
|
28
29
|
};
|
|
29
30
|
}
|
|
31
|
+
/** The SDK memory store has no delete operation; keep the same shell contract. */
|
|
32
|
+
export class MemoryTaskStore {
|
|
33
|
+
scopes = new Map();
|
|
34
|
+
bucket(context) {
|
|
35
|
+
const scope = JSON.stringify([context.tenant ?? '', resolveUserScope(context)]);
|
|
36
|
+
let tasks = this.scopes.get(scope);
|
|
37
|
+
if (!tasks) {
|
|
38
|
+
tasks = new Map();
|
|
39
|
+
this.scopes.set(scope, tasks);
|
|
40
|
+
}
|
|
41
|
+
return tasks;
|
|
42
|
+
}
|
|
43
|
+
async save(task, context) {
|
|
44
|
+
this.bucket(context).set(task.id, structuredClone(task));
|
|
45
|
+
}
|
|
46
|
+
async load(taskId, context) {
|
|
47
|
+
const task = this.bucket(context).get(taskId);
|
|
48
|
+
return task && structuredClone(task);
|
|
49
|
+
}
|
|
50
|
+
async list(params, context) {
|
|
51
|
+
return listShells([...this.bucket(context).values()], params);
|
|
52
|
+
}
|
|
53
|
+
async delete(taskId, context) {
|
|
54
|
+
this.bucket(context).delete(taskId);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
30
57
|
/** Strips history/artifacts and saves only on task-state transitions. */
|
|
31
58
|
export class SanitizedTaskStore {
|
|
32
59
|
inner;
|
|
33
60
|
lastState = new Map();
|
|
61
|
+
writes = new Map();
|
|
62
|
+
// ponytail: deleted ids stay tombstoned until plugin unload; use expiring ids if clear volume grows.
|
|
63
|
+
deleted = new Set();
|
|
34
64
|
constructor(inner) {
|
|
35
65
|
this.inner = inner;
|
|
36
66
|
}
|
|
@@ -40,13 +70,28 @@ export class SanitizedTaskStore {
|
|
|
40
70
|
async close() {
|
|
41
71
|
await this.inner.close?.();
|
|
42
72
|
}
|
|
73
|
+
/** Drop SDK saves that may arrive after a clear rejects an in-flight request. */
|
|
74
|
+
suppress(taskId) {
|
|
75
|
+
this.deleted.add(taskId);
|
|
76
|
+
}
|
|
77
|
+
async delete(taskId, context) {
|
|
78
|
+
this.suppress(taskId);
|
|
79
|
+
await this.writes.get(taskId);
|
|
80
|
+
await this.inner.delete(taskId, context);
|
|
81
|
+
this.lastState.delete(taskId);
|
|
82
|
+
this.writes.delete(taskId);
|
|
83
|
+
}
|
|
43
84
|
async save(task, context) {
|
|
85
|
+
if (this.deleted.has(task.id))
|
|
86
|
+
return;
|
|
44
87
|
const state = task.status?.state;
|
|
45
88
|
if (state !== undefined && this.lastState.get(task.id) === state)
|
|
46
89
|
return;
|
|
47
90
|
if (state !== undefined)
|
|
48
91
|
this.lastState.set(task.id, state);
|
|
49
|
-
|
|
92
|
+
const write = (this.writes.get(task.id) ?? Promise.resolve()).then(() => this.inner.save(sanitizeTask(task), context));
|
|
93
|
+
this.writes.set(task.id, write.catch(() => { }));
|
|
94
|
+
await write;
|
|
50
95
|
}
|
|
51
96
|
load(taskId, context) {
|
|
52
97
|
return this.inner.load(taskId, context);
|
package/lib/types/bridge.d.ts
CHANGED
|
@@ -15,14 +15,18 @@
|
|
|
15
15
|
* turn order). The final status-update is published before the waiter
|
|
16
16
|
* resolves, so an execute() that returns always landed its events first.
|
|
17
17
|
*/
|
|
18
|
+
import type { Message } from '@a2a-js/sdk';
|
|
18
19
|
import type { ExecutionEventBus } from '@a2a-js/sdk/server';
|
|
19
20
|
import type { Context } from '@deepseek-ai/cordis';
|
|
20
21
|
import type { AgentHandle } from '@deepseek-ai/dsh-agent';
|
|
22
|
+
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types';
|
|
21
23
|
import { SessionId } from '@deepseek-ai/dsh-session/types';
|
|
22
24
|
import { SessionTranslator } from './translator.js';
|
|
23
25
|
export interface BridgeOptions {
|
|
24
26
|
/** Absolute working directory for spawned agents. */
|
|
25
27
|
cwd: string;
|
|
28
|
+
/** Upload root for persisted file parts; absent = the OS temp dir. */
|
|
29
|
+
uploadsDir?: string;
|
|
26
30
|
/** Optional provider/model overrides for created agents. */
|
|
27
31
|
agentOptions?: {
|
|
28
32
|
provider?: string;
|
|
@@ -40,14 +44,21 @@ export interface TaskEntry {
|
|
|
40
44
|
bus: ExecutionEventBus | null;
|
|
41
45
|
/** Between turn/start and turn/end. */
|
|
42
46
|
turnActive: boolean;
|
|
47
|
+
/** A turn still open from a previous task binding; its turn/end is stale. */
|
|
48
|
+
staleTurn: boolean;
|
|
43
49
|
/** FIFO turn-end waiters, one per queued/running execute(). */
|
|
44
50
|
settled: Array<() => void>;
|
|
51
|
+
/** Active execute() calls that must finish before task shells are deleted. */
|
|
52
|
+
running: Set<Promise<void>>;
|
|
45
53
|
}
|
|
46
54
|
export declare class A2aBridge {
|
|
47
55
|
private readonly ctx;
|
|
48
56
|
private readonly options;
|
|
49
57
|
private readonly tasks;
|
|
50
58
|
private readonly bySession;
|
|
59
|
+
private readonly clearing;
|
|
60
|
+
private readonly creating;
|
|
61
|
+
private readonly executing;
|
|
51
62
|
constructor(ctx: Context, options: BridgeOptions);
|
|
52
63
|
/**
|
|
53
64
|
* Resolve the live task entry, creating the session/agent when needed.
|
|
@@ -60,8 +71,17 @@ export declare class A2aBridge {
|
|
|
60
71
|
entry: TaskEntry;
|
|
61
72
|
freshTask: boolean;
|
|
62
73
|
}>;
|
|
74
|
+
private ensureTaskInner;
|
|
75
|
+
/** A2A parts → dsh content blocks; throws only when no part is usable. */
|
|
76
|
+
buildContent(message: Message, contextId: string): Promise<ContentBlock[]>;
|
|
77
|
+
isClearing(contextId: string): boolean;
|
|
78
|
+
/** Include content preparation in the work a context clear must drain. */
|
|
79
|
+
trackExecution(contextId: string, run: () => Promise<void>): Promise<void>;
|
|
63
80
|
/** Queue one user-message turn on the task's agent and await its `turn/end`. */
|
|
64
|
-
runTurn(entry: TaskEntry,
|
|
81
|
+
runTurn(entry: TaskEntry, content: ContentBlock[], bus: ExecutionEventBus): Promise<void>;
|
|
82
|
+
private runTurnInner;
|
|
83
|
+
/** Cancel and release the live binding before the caller deletes stored shells. */
|
|
84
|
+
clearContext(contextId: string, clearStored?: (liveIds: string[]) => Promise<string[]>): Promise<string[]>;
|
|
65
85
|
/** Abort the task's active turn; returns the contextId when the task is live. */
|
|
66
86
|
cancel(taskId: string): string | undefined;
|
|
67
87
|
/** Dispose every owned agent (plugin unload / server shutdown). */
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Maps A2A message parts onto dsh content blocks. A turn never fails over
|
|
3
|
+
* part kinds:
|
|
4
|
+
*
|
|
5
|
+
* - `text` parts stay text;
|
|
6
|
+
* - file parts (inline `raw` bytes or a `url`) use the composed attachment
|
|
7
|
+
* store unless a non-image materializer is configured; url parts are
|
|
8
|
+
* downloaded by the plugin (http/https only, size- and time-bounded);
|
|
9
|
+
* - an optional deployment materializer puts non-image files into the
|
|
10
|
+
* active execution world instead of saving a host attachment;
|
|
11
|
+
* - without a materializer, files with no store (or refused by a store)
|
|
12
|
+
* persist under the configured upload root (default `<OS temp>/
|
|
13
|
+
* dsh-a2a-uploads/<date>/`) and the prompt references them by path;
|
|
14
|
+
* - `data` parts become JSON text.
|
|
15
|
+
*
|
|
16
|
+
* The SDK's admission helpers (admitEncodedFile/admitPromptContent) are not
|
|
17
|
+
* used: they take base64 wire uploads and throw on refusal — this path holds
|
|
18
|
+
* raw bytes and must degrade, never fail.
|
|
19
|
+
*/
|
|
20
|
+
import type { Part } from '@a2a-js/sdk';
|
|
21
|
+
import type { AttachmentStore } from '@deepseek-ai/dsh-attachment';
|
|
22
|
+
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types';
|
|
23
|
+
/** Deployment-owned writer for files read by an agent's active tools. */
|
|
24
|
+
export interface A2aFileMaterializer {
|
|
25
|
+
materializeFile(input: {
|
|
26
|
+
contextId: string;
|
|
27
|
+
bytes: Uint8Array;
|
|
28
|
+
filename: string;
|
|
29
|
+
mediaType: string;
|
|
30
|
+
}): Promise<{
|
|
31
|
+
readablePath: string;
|
|
32
|
+
}>;
|
|
33
|
+
}
|
|
34
|
+
interface Materialization {
|
|
35
|
+
contextId: string;
|
|
36
|
+
materializer: A2aFileMaterializer;
|
|
37
|
+
}
|
|
38
|
+
export declare function buildMessageContent(parts: readonly Part[], attachments: AttachmentStore | undefined, dir: string, materialization?: Materialization): Promise<ContentBlock[]>;
|
|
39
|
+
/** The directory file parts persist into when they are not attachments. */
|
|
40
|
+
export declare function uploadsDir(root?: string): string;
|
|
41
|
+
export {};
|
package/lib/types/executor.d.ts
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
* The @a2a-js/sdk {@link AgentExecutor} that drives dsh agents through the
|
|
3
3
|
* bridge. One `execute()` = one user-message turn:
|
|
4
4
|
*
|
|
5
|
-
* 1.
|
|
5
|
+
* 1. map the message's parts onto dsh content blocks (file parts become
|
|
6
|
+
* execution-world paths with a materializer, attachments or local files otherwise)
|
|
6
7
|
* 2. ensure the task's session/agent exists (creating it on first contact)
|
|
7
8
|
* 3. publish a `task` event first — A2A 1.0 stream ordering REQUIRES the
|
|
8
9
|
* first event of every execute to be a task or message, including
|
|
@@ -20,7 +21,9 @@ import type { AgentExecutor, ExecutionEventBus, RequestContext } from '@a2a-js/s
|
|
|
20
21
|
import type { A2aBridge } from './bridge.js';
|
|
21
22
|
export declare class DshAgentExecutor implements AgentExecutor {
|
|
22
23
|
private readonly bridge;
|
|
23
|
-
|
|
24
|
+
private readonly suppressTask?;
|
|
25
|
+
constructor(bridge: A2aBridge, suppressTask?: ((taskId: string) => void) | undefined);
|
|
24
26
|
execute(requestContext: RequestContext, eventBus: ExecutionEventBus): Promise<void>;
|
|
27
|
+
private executeInner;
|
|
25
28
|
cancelTask: (taskId: string, eventBus: ExecutionEventBus) => Promise<void>;
|
|
26
29
|
}
|
package/lib/types/index.d.ts
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import type { Context } from '@deepseek-ai/cordis';
|
|
15
15
|
import Schema from '@deepseek-ai/schemastery';
|
|
16
|
+
export type { A2aFileMaterializer } from './content.js';
|
|
16
17
|
export declare const name = "dsh-a2a";
|
|
17
18
|
/** The bridge creates and owns agents through the registry service. */
|
|
18
19
|
export declare const inject: string[];
|
|
@@ -23,6 +24,11 @@ export declare const Config: Schema<Schemastery.ObjectS<{
|
|
|
23
24
|
basePath: Schema<string, string>;
|
|
24
25
|
/** Working directory for agents spawned by A2A tasks. Must be absolute. */
|
|
25
26
|
cwd: Schema<string, string>;
|
|
27
|
+
/**
|
|
28
|
+
* Root directory file parts persist into when no attachment store is
|
|
29
|
+
* composed (date-layered below it). Empty = <OS temp>/dsh-a2a-uploads.
|
|
30
|
+
*/
|
|
31
|
+
uploadsDir: Schema<string, string>;
|
|
26
32
|
agent: Schema<Schemastery.ObjectS<{
|
|
27
33
|
provider: Schema<string, string>;
|
|
28
34
|
model: Schema<string, string>;
|
|
@@ -83,6 +89,11 @@ export declare const Config: Schema<Schemastery.ObjectS<{
|
|
|
83
89
|
basePath: Schema<string, string>;
|
|
84
90
|
/** Working directory for agents spawned by A2A tasks. Must be absolute. */
|
|
85
91
|
cwd: Schema<string, string>;
|
|
92
|
+
/**
|
|
93
|
+
* Root directory file parts persist into when no attachment store is
|
|
94
|
+
* composed (date-layered below it). Empty = <OS temp>/dsh-a2a-uploads.
|
|
95
|
+
*/
|
|
96
|
+
uploadsDir: Schema<string, string>;
|
|
86
97
|
agent: Schema<Schemastery.ObjectS<{
|
|
87
98
|
provider: Schema<string, string>;
|
|
88
99
|
model: Schema<string, string>;
|
|
@@ -143,6 +154,7 @@ export interface A2aPluginConfig {
|
|
|
143
154
|
port: number;
|
|
144
155
|
basePath: string;
|
|
145
156
|
cwd: string;
|
|
157
|
+
uploadsDir: string;
|
|
146
158
|
agent: {
|
|
147
159
|
provider: string;
|
|
148
160
|
model: string;
|
|
@@ -166,6 +178,10 @@ export interface A2aPluginConfig {
|
|
|
166
178
|
keyFilename: string;
|
|
167
179
|
};
|
|
168
180
|
}
|
|
181
|
+
/** Same-process gateway contract for messages/clear. */
|
|
182
|
+
export interface A2aTasks {
|
|
183
|
+
clearContext(contextId: string, tenant?: string): Promise<string[]>;
|
|
184
|
+
}
|
|
169
185
|
export declare function apply(ctx: Context, config: A2aPluginConfig): Promise<{
|
|
170
186
|
port: number;
|
|
171
187
|
}> | undefined;
|
|
@@ -11,7 +11,7 @@
|
|
|
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
|
|
14
|
+
import { type ManagedTaskStore } from '../task-store.js';
|
|
15
15
|
export interface GcsTaskStoreConfig {
|
|
16
16
|
bucket: string;
|
|
17
17
|
/** Object key prefix, default 'tasks' (matches the source layout). */
|
|
@@ -19,7 +19,7 @@ export interface GcsTaskStoreConfig {
|
|
|
19
19
|
/** Path to a service-account key file; omit to use ADC. */
|
|
20
20
|
keyFilename?: string;
|
|
21
21
|
}
|
|
22
|
-
export declare class GcsTaskStore implements
|
|
22
|
+
export declare class GcsTaskStore implements ManagedTaskStore {
|
|
23
23
|
private config;
|
|
24
24
|
private bucket;
|
|
25
25
|
private prefix;
|
|
@@ -27,6 +27,7 @@ export declare class GcsTaskStore implements TaskStore {
|
|
|
27
27
|
init(): Promise<void>;
|
|
28
28
|
save(task: Task): Promise<void>;
|
|
29
29
|
load(taskId: string): Promise<Task | undefined>;
|
|
30
|
+
delete(taskId: string): Promise<void>;
|
|
30
31
|
list(params: ListTasksRequest): Promise<ListTasksResponse>;
|
|
31
32
|
/**
|
|
32
33
|
* Tar + gzip the workspace directory to `tasks/{taskId}/workspace.tar.gz`.
|
|
@@ -7,13 +7,13 @@
|
|
|
7
7
|
* conversation history is dsh-storage's job (ai_messages), not this store's.
|
|
8
8
|
*/
|
|
9
9
|
import type { ListTasksRequest, ListTasksResponse, Task } from '@a2a-js/sdk';
|
|
10
|
-
import type
|
|
10
|
+
import { type ManagedTaskStore } from '../task-store.js';
|
|
11
11
|
export interface RedisTaskStoreConfig {
|
|
12
12
|
url: string;
|
|
13
13
|
keyPrefix?: string;
|
|
14
14
|
ttlSeconds?: number;
|
|
15
15
|
}
|
|
16
|
-
export declare class RedisTaskStore implements
|
|
16
|
+
export declare class RedisTaskStore implements ManagedTaskStore {
|
|
17
17
|
private readonly config;
|
|
18
18
|
private redis;
|
|
19
19
|
private readonly prefix;
|
|
@@ -23,6 +23,7 @@ export declare class RedisTaskStore implements TaskStore {
|
|
|
23
23
|
private taskKey;
|
|
24
24
|
save(task: Task): Promise<void>;
|
|
25
25
|
load(taskId: string): Promise<Task | undefined>;
|
|
26
|
+
delete(taskId: string): Promise<void>;
|
|
26
27
|
list(params: ListTasksRequest): Promise<ListTasksResponse>;
|
|
27
28
|
close(): Promise<void>;
|
|
28
29
|
}
|
|
@@ -17,21 +17,36 @@
|
|
|
17
17
|
* the shared filter/sort/paginate logic so the backends only enumerate shells.
|
|
18
18
|
*/
|
|
19
19
|
import type { ListTasksRequest, ListTasksResponse, Task } from '@a2a-js/sdk';
|
|
20
|
-
import type
|
|
20
|
+
import { type ServerCallContext, type TaskStore } from '@a2a-js/sdk/server';
|
|
21
21
|
/** The persistent shape: a metadata shell with empty history/artifacts. */
|
|
22
22
|
export declare function sanitizeTask(task: Task): Task;
|
|
23
23
|
/** TaskStore with the optional lifecycle our backends add. */
|
|
24
24
|
export interface ManagedTaskStore extends TaskStore {
|
|
25
25
|
init?(): Promise<void>;
|
|
26
|
+
delete(taskId: string, context: ServerCallContext): Promise<void>;
|
|
26
27
|
close?(): Promise<void>;
|
|
27
28
|
}
|
|
29
|
+
/** The SDK memory store has no delete operation; keep the same shell contract. */
|
|
30
|
+
export declare class MemoryTaskStore implements ManagedTaskStore {
|
|
31
|
+
private readonly scopes;
|
|
32
|
+
private bucket;
|
|
33
|
+
save(task: Task, context: ServerCallContext): Promise<void>;
|
|
34
|
+
load(taskId: string, context: ServerCallContext): Promise<Task | undefined>;
|
|
35
|
+
list(params: ListTasksRequest, context: ServerCallContext): Promise<ListTasksResponse>;
|
|
36
|
+
delete(taskId: string, context: ServerCallContext): Promise<void>;
|
|
37
|
+
}
|
|
28
38
|
/** Strips history/artifacts and saves only on task-state transitions. */
|
|
29
39
|
export declare class SanitizedTaskStore implements ManagedTaskStore {
|
|
30
40
|
private readonly inner;
|
|
31
41
|
private readonly lastState;
|
|
42
|
+
private readonly writes;
|
|
43
|
+
private readonly deleted;
|
|
32
44
|
constructor(inner: ManagedTaskStore);
|
|
33
45
|
init(): Promise<void>;
|
|
34
46
|
close(): Promise<void>;
|
|
47
|
+
/** Drop SDK saves that may arrive after a clear rejects an in-flight request. */
|
|
48
|
+
suppress(taskId: string): void;
|
|
49
|
+
delete(taskId: string, context: ServerCallContext): Promise<void>;
|
|
35
50
|
save(task: Task, context: ServerCallContext): Promise<void>;
|
|
36
51
|
load(taskId: string, context: ServerCallContext): Promise<Task | undefined>;
|
|
37
52
|
list(params: ListTasksRequest, context: ServerCallContext): Promise<ListTasksResponse>;
|
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.7",
|
|
4
4
|
"description": "A2A protocol (JSON-RPC + SSE) server plugin for DeepSeek Harness: expose dsh agents as A2A agents.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -39,12 +39,16 @@
|
|
|
39
39
|
"peerDependencies": {
|
|
40
40
|
"@deepseek-ai/cordis": "^4.0.1",
|
|
41
41
|
"@deepseek-ai/dsh-agent": ">=0.1.5-rc.1 || >=0.1.6-alpha.1",
|
|
42
|
+
"@deepseek-ai/dsh-attachment": ">=0.1.5-rc.1 || >=0.1.6-alpha.1",
|
|
42
43
|
"@deepseek-ai/dsh-llm": ">=0.1.5-rc.1 || >=0.1.6-alpha.1",
|
|
43
44
|
"@deepseek-ai/dsh-session": ">=0.1.5-rc.1 || >=0.1.6-alpha.1",
|
|
44
45
|
"@google-cloud/storage": "^7.0.0",
|
|
45
46
|
"ioredis": "^5.0.0"
|
|
46
47
|
},
|
|
47
48
|
"peerDependenciesMeta": {
|
|
49
|
+
"@deepseek-ai/dsh-attachment": {
|
|
50
|
+
"optional": true
|
|
51
|
+
},
|
|
48
52
|
"@google-cloud/storage": {
|
|
49
53
|
"optional": true
|
|
50
54
|
},
|
|
@@ -55,6 +59,7 @@
|
|
|
55
59
|
"devDependencies": {
|
|
56
60
|
"@deepseek-ai/cordis": "^4.0.1",
|
|
57
61
|
"@deepseek-ai/dsh-agent": "0.1.6-alpha.2",
|
|
62
|
+
"@deepseek-ai/dsh-attachment": "0.1.6-alpha.2",
|
|
58
63
|
"@deepseek-ai/dsh-llm": "0.1.6-alpha.2",
|
|
59
64
|
"@deepseek-ai/dsh-session": "0.1.6-alpha.2",
|
|
60
65
|
"@google-cloud/storage": "^7.0.0",
|