@amaster.ai/dsh-a2a 0.1.33 → 0.1.35
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 +278 -7
- package/lib/admission.js +25 -0
- package/lib/bridge.js +45 -23
- package/lib/content.js +163 -30
- package/lib/executor.js +38 -3
- package/lib/inbox-api.js +117 -0
- package/lib/inbox-changes.js +116 -0
- package/lib/inbox.js +557 -43
- package/lib/index.js +33 -4
- package/lib/legacy-stream.js +20 -5
- package/lib/server.js +159 -26
- package/lib/stores/gcs.js +6 -5
- package/lib/stores/redis.js +5 -4
- package/lib/stream-connection.js +75 -0
- package/lib/task-store.js +22 -4
- package/lib/types/admission.d.ts +10 -0
- package/lib/types/bridge.d.ts +8 -0
- package/lib/types/content.d.ts +7 -1
- package/lib/types/executor.d.ts +2 -0
- package/lib/types/inbox-api.d.ts +5 -0
- package/lib/types/inbox-changes.d.ts +25 -0
- package/lib/types/inbox.d.ts +57 -1
- package/lib/types/index.d.ts +197 -142
- package/lib/types/server.d.ts +13 -1
- package/lib/types/stream-connection.d.ts +8 -0
- package/package.json +25 -15
package/README.md
CHANGED
|
@@ -14,11 +14,21 @@ dsh plugin --profile my-agent add @amaster.ai/dsh-a2a
|
|
|
14
14
|
|
|
15
15
|
## Native inbox execution
|
|
16
16
|
|
|
17
|
-
`inbox.enabled: true` uses the official DSH inbox (`Agent.send`) for ordinary `next-turn` input and `next-step` steering. Tested with the unmodified npm runtime `0.2.1-rc.
|
|
17
|
+
`inbox.enabled: true` uses the official DSH inbox (`Agent.send`) for ordinary `next-turn` input and `next-step` steering. Tested with the unmodified npm runtime `0.2.1-rc.5`; no core patch is required. The queue and its durable projection belong to DSH. The plugin only materializes messages, attaches A2A task identities, and translates execution events.
|
|
18
18
|
|
|
19
|
-
Standard `SendMessage` / `SendStreamingMessage`, task queries, cancellation and subscriptions
|
|
19
|
+
Standard `SendMessage` / `SendStreamingMessage`, task queries, cancellation and task subscriptions keep their official meanings. The negotiated inbox extension forwards `target` and `wakeup` to DSH. `wakeup: false` leaves an idle driver parked; it does not prevent an active driver from consuming pending input. A later real waking message releases pending input.
|
|
20
20
|
|
|
21
|
-
The same-process `a2aInbox` service exposes `resolve
|
|
21
|
+
The same-process `a2aInbox` service exposes `resolve`, `content`, `enqueue`, `promote`, `cancel`, `get`, `update`, `move`, `remove`, `clearPending`, and `executePending`, plus the existing pause/wake/resume controls. Pending-only clearing preserves active work; context clearing discards all work.
|
|
22
|
+
|
|
23
|
+
For external clients, the [native inbox extension specification](#native-inbox-extension-v1) defines `dsh/inbox/get`, `update`, `move`, `remove`, `clear`, `execute`, `steer`, and `subscribe` at the advertised JSONRPC endpoint. Activate it with the `A2A-Extensions` header set to the extension URI. Updates use standard A2A 1.0 user Messages; operations address native inbox IDs. REST/gRPC clients use the advertised JSONRPC endpoint for these extension methods. No extra queue or consumer is created.
|
|
24
|
+
|
|
25
|
+
`dsh/inbox/subscribe` streams checkpointed invalidations `{contextId, version}` over SSE, including native consumption and edits. It registers before its initial checkpoint and sends only after owner-scoped `ctx.sessions.flush()` returns true. A false/rejected checkpoint sends no success notification. Re-read the live inbox on invalidation and reconnect; there is no replay or durable outbox. Slow clients disconnect instead of accumulating notifications. To publish the same invalidations to host listeners on `a2a/inbox-checkpointed` without an SSE subscriber, enable `inbox.notifications: true` (default false). Hosts retain authentication, authorization and product-specific fan-out.
|
|
26
|
+
|
|
27
|
+
Existing native agents keep their setup and lifecycle owner. Reading/subscribing does not block their producers. Plugin shutdown detaches its own queued A2A inputs from a borrowed agent, retaining recovery data when configured; native work and the caller's agent remain available.
|
|
28
|
+
|
|
29
|
+
Wire snapshots are paginated: 50 entries by default, `pageSize: 1..100`, with `totalSize` and `nextPageToken`. Read `dsh/inbox/get` until the token is empty, traversing next-step before next-turn. Any session-version change invalidates the cursor; restart from the first page. Mutations accept `pageSize` and return the first page. Same-process calls without page options still return the full snapshot. Adjacent reorders log only their affected interval, and slow notification checkpoints keep one pending version/error observer per batch.
|
|
30
|
+
|
|
31
|
+
The `E2E (dsh-a2a.inbox-api)` matrix leg installs the packed plugin into the pinned CLI and uses the real model gateway. It checks queue editing/reordering/removal/clearing, execution and steering, two checkpointed SSE subscribers, native consumption, and model recall in a subsequent context turn. A local loopback forwarder only gates the first model request to make steering deterministic; it never generates model responses.
|
|
22
32
|
|
|
23
33
|
`a2a/before-followup` validates admission; `a2a/before-step` runs at the official pre-step boundary, after system-prompt assembly and before model execution. It cannot change the already assembled system prompt. Original request headers remain process-local. DSH persistence restores its own pending projection; the plugin does not replay splice logs or recover consumed claims. Graceful disposal cancels tasks by default; `inbox.shutdown: retain-pending` opts into the retention policy below. Durable A2A task snapshots require a filesystem, Redis or GCS TaskStore; an in-memory store is sufficient without restart requirements.
|
|
24
34
|
|
|
@@ -65,6 +75,109 @@ node .github/scripts/check-inbox.mjs -t 'native ownership'
|
|
|
65
75
|
DSH_NATIVE_OWNERSHIP_LANGFUSE_LIVE=true node .github/scripts/check-inbox.mjs -t 'native ownership.*reports Goal continuation'
|
|
66
76
|
```
|
|
67
77
|
|
|
78
|
+
## Native inbox extension v1
|
|
79
|
+
|
|
80
|
+
Stable protocol identifier: `https://github.com/TGYD-helige/dsh-plugins/blob/main/docs/protocol/inbox-v1.md`
|
|
81
|
+
|
|
82
|
+
The identifier stays unchanged for existing messages and retained task metadata;
|
|
83
|
+
this README is the canonical specification. Agent Card extension params advertise
|
|
84
|
+
`documentationUrl` pointing to this section.
|
|
85
|
+
|
|
86
|
+
This is a project extension, not an official A2A extension. It follows the
|
|
87
|
+
[A2A extension mechanism](https://a2a-protocol.org/v1.0.1/topics/extensions/).
|
|
88
|
+
The Agent Card advertises it when `inbox.enabled` is true. Activate it with the
|
|
89
|
+
`A2A-Extensions` HTTP header. Responses echo that header. Standard task methods,
|
|
90
|
+
states, subscriptions and Push configurations keep their official meanings.
|
|
91
|
+
|
|
92
|
+
### Methods
|
|
93
|
+
|
|
94
|
+
Send JSON-RPC 2.0 requests to the advertised JSONRPC endpoint. All methods take
|
|
95
|
+
`contextId`. IDs named `id` below are **native inbox message IDs**, not task IDs.
|
|
96
|
+
The extension adds no REST or gRPC service; those clients can discover and use
|
|
97
|
+
the advertised JSONRPC endpoint for these operations.
|
|
98
|
+
|
|
99
|
+
| Method | Additional parameters | Result |
|
|
100
|
+
| --- | --- | --- |
|
|
101
|
+
| `dsh/inbox/get` | Optional `pageSize`, `pageToken` | Live inbox snapshot page |
|
|
102
|
+
| `dsh/inbox/update` | `id`, `message` (A2A 1.0 user Message) | Updated snapshot |
|
|
103
|
+
| `dsh/inbox/move` | `id`, `index` (zero-based destination in the same list) | Updated snapshot |
|
|
104
|
+
| `dsh/inbox/remove` | `id` | Updated snapshot |
|
|
105
|
+
| `dsh/inbox/clear` | — | Snapshot after clearing pending input |
|
|
106
|
+
| `dsh/inbox/execute` | Optional `id` to move first in the next-turn list | Snapshot after waking pending work |
|
|
107
|
+
| `dsh/inbox/steer` | `id`, `taskId` (active target) | Snapshot after promotion |
|
|
108
|
+
| `dsh/inbox/subscribe` | — | SSE JSON-RPC result stream of `{contextId, version}` |
|
|
109
|
+
|
|
110
|
+
A snapshot is `{contextId, version, nextTurn, nextStep, totalSize, nextPageToken}`. Each entry is
|
|
111
|
+
`{id, message, editable}`: `message` uses the standard A2A 1.0 Message JSON shape.
|
|
112
|
+
Native inputs without A2A ownership expose text and have `editable: false`;
|
|
113
|
+
editing them requires the originating producer. Queue removal/reordering still
|
|
114
|
+
uses native IDs. Updates preserve native/protocol IDs, context and task ownership;
|
|
115
|
+
they cannot replace message identity or edit already claimed input. Index is
|
|
116
|
+
evaluated after removing the selected item. Execute preserves other pending
|
|
117
|
+
items and their order; steer reuses the existing task-ownership promotion rules.
|
|
118
|
+
Explicit execution also drives native-producer input without inventing an A2A
|
|
119
|
+
Task for it; its conversation events remain in the native session history.
|
|
120
|
+
Reading or subscribing does not gate input from native producers. An already
|
|
121
|
+
live native agent is reused with its existing setup and lifecycle owner. Plugin
|
|
122
|
+
shutdown leaves native work running; it detaches plugin-owned queued A2A input,
|
|
123
|
+
retaining it in task metadata only under the configured retention policy.
|
|
124
|
+
Clearing pending input does not cancel an active turn; use standard CancelTask
|
|
125
|
+
for task cancellation. Removed independent queued tasks become canceled.
|
|
126
|
+
|
|
127
|
+
HTTP snapshots default to 50 entries, with `pageSize` from 1 to 100. Pages also
|
|
128
|
+
limit serialized entries to about 1 MiB; one oversized Message is returned intact
|
|
129
|
+
to ensure progress, so a page may contain fewer items than requested. All mutation
|
|
130
|
+
methods accept `pageSize` for their returned first page; invalid values reject
|
|
131
|
+
before changing the queue. Pages traverse `nextStep` before `nextTurn`, keeping
|
|
132
|
+
native order within each list. Continue via `dsh/inbox/get` and `nextPageToken`
|
|
133
|
+
until the token is empty. Tokens bind the context and session version; a changed
|
|
134
|
+
version rejects stale tokens instead of silently skipping or duplicating input.
|
|
135
|
+
Re-read from the first page after invalidation. Same-process `get(contextId)`
|
|
136
|
+
and mutation calls without page options retain full-snapshot behavior; passing
|
|
137
|
+
`{pageSize}` opts into bounded materialization there too.
|
|
138
|
+
|
|
139
|
+
Reordering uses one atomic native splice over only the affected interval;
|
|
140
|
+
executing an input already at the head skips the reorder. Notifications retain
|
|
141
|
+
one pending version and one error observer per context checkpoint batch,
|
|
142
|
+
including when a slow checkpoint sees many changes.
|
|
143
|
+
|
|
144
|
+
Methods use SDK JSON-RPC error serialization. Invalid parameters and stale
|
|
145
|
+
claims are RequestMalformedError; missing extension activation is
|
|
146
|
+
ExtensionSupportRequiredError. Unknown extension methods return method-not-found.
|
|
147
|
+
No tenant namespace or authorization model is added: sessions are native DSH
|
|
148
|
+
contexts. The host must authenticate/authorize access before exposing this
|
|
149
|
+
loopback-default server. A nonempty tenant is rejected for these methods.
|
|
150
|
+
|
|
151
|
+
### Checkpointed invalidation
|
|
152
|
+
|
|
153
|
+
`version` is a session event sequence (last included event, initially -1), not a
|
|
154
|
+
task revision. Snapshots describe the live queue and may include newer changes
|
|
155
|
+
than a previously notified checkpoint. Do not treat a snapshot as a durability
|
|
156
|
+
acknowledgement. Mutation failures may occur after a native mutation; re-read
|
|
157
|
+
the authoritative queue before retrying. Same-process host observers opt in with
|
|
158
|
+
`inbox.notifications: true` (default false); an SSE subscription activates its
|
|
159
|
+
own session feed without enabling unused contexts.
|
|
160
|
+
|
|
161
|
+
Subscribe registers before checkpointing, then sends an initial invalidation
|
|
162
|
+
only after `ctx.sessions.flush(session)` returns true. Subsequent invalidations
|
|
163
|
+
observe native `agent/inbox/spliced` events, including edits and consumption,
|
|
164
|
+
coalesce changes per context, and checkpoint before publishing. A rejected or
|
|
165
|
+
false checkpoint produces no success notification. Later changes or reconnects
|
|
166
|
+
retry the current state; there is no durable outbox, replay or exactly-once
|
|
167
|
+
delivery. A host can consume the same feed through `a2a/inbox-checkpointed`.
|
|
168
|
+
Notification data contains no message bodies. Re-read `dsh/inbox/get` after an
|
|
169
|
+
invalidation and on reconnect. Each context checkpoints independently; slow SSE
|
|
170
|
+
clients disconnect instead of creating an unbounded notification backlog.
|
|
171
|
+
Closing the plugin unregisters observers/subscriptions and suppresses late sends.
|
|
172
|
+
|
|
173
|
+
### Existing admission metadata
|
|
174
|
+
|
|
175
|
+
Standard SendMessage/SendStreamingMessage continues to accept
|
|
176
|
+
`metadata[URI] = {target: 'next-turn' | 'next-step', wakeup: boolean}` on its
|
|
177
|
+
Message, negotiated as before. Prefer those standard methods for new input.
|
|
178
|
+
The operations above control already queued input; they do not add another
|
|
179
|
+
queue, consumer, task-state machine or webhook configuration store.
|
|
180
|
+
|
|
68
181
|
## Configuration
|
|
69
182
|
|
|
70
183
|
Disabled by default. Configure via the profile's `cordis.patch.yml`:
|
|
@@ -83,6 +196,9 @@ Disabled by default. Configure via the profile's `cordis.patch.yml`:
|
|
|
83
196
|
intervalMs: 15000 # positive integer, at most 2147483647
|
|
84
197
|
cwd: /srv/agent-workspaces
|
|
85
198
|
uploadsDir: '' # file-part upload root; empty = <OS temp>/dsh-a2a-uploads/<date>
|
|
199
|
+
urlDownloads:
|
|
200
|
+
allowPrivate: false # true = allow all private/loopback hosts in a trusted intranet
|
|
201
|
+
allowedHosts: [] # or allow only these private/loopback hosts; public hosts need no entry
|
|
86
202
|
subagentTools:
|
|
87
203
|
enabled: false # opt in to attributed local descendant tool calls/results
|
|
88
204
|
agent:
|
|
@@ -109,6 +225,75 @@ Disabled by default. Configure via the profile's `cordis.patch.yml`:
|
|
|
109
225
|
|
|
110
226
|
## Endpoints
|
|
111
227
|
|
|
228
|
+
All eleven A2A 1.0 operations use the official `@a2a-js/sdk` handlers. JSON-RPC
|
|
229
|
+
is the default binding; HTTP+JSON/REST and gRPC are opt-in and share the same
|
|
230
|
+
TaskStore, executor, subscriptions, and push configuration store.
|
|
231
|
+
|
|
232
|
+
| Operation | JSON-RPC | HTTP+JSON/REST, relative to `<basePath>/rest` | gRPC |
|
|
233
|
+
| --- | --- | --- | --- |
|
|
234
|
+
| Send message | `SendMessage` | `POST /message:send` | `SendMessage` |
|
|
235
|
+
| Stream message | `SendStreamingMessage` | `POST /message:stream` | `SendStreamingMessage` |
|
|
236
|
+
| Get / list tasks | `GetTask`, `ListTasks` | `GET /tasks/{id}`, `GET /tasks` | `GetTask`, `ListTasks` |
|
|
237
|
+
| Cancel task | `CancelTask` | `POST /tasks/{id}:cancel` | `CancelTask` |
|
|
238
|
+
| Subscribe | `SubscribeToTask` | `GET` or `POST /tasks/{id}:subscribe` | `SubscribeToTask` |
|
|
239
|
+
| Create / list push configs | `CreateTaskPushNotificationConfig`, `ListTaskPushNotificationConfigs` | `POST` / `GET /tasks/{id}/pushNotificationConfigs` | Same operation names |
|
|
240
|
+
| Get / delete push config | `GetTaskPushNotificationConfig`, `DeleteTaskPushNotificationConfig` | `GET` / `DELETE /tasks/{id}/pushNotificationConfigs/{configId}` | Same operation names |
|
|
241
|
+
| Extended card | `GetExtendedAgentCard` | `GET /extendedAgentCard` | `GetExtendedAgentCard` |
|
|
242
|
+
|
|
243
|
+
Push operations require `pushNotifications: true`. The extended card operation
|
|
244
|
+
requires a host provider; otherwise the SDK returns its standard unsupported
|
|
245
|
+
error. Requests use `A2A-Version: 1.0` and JSON bodies accept
|
|
246
|
+
`application/a2a+json` or `application/json`. REST SSE carries event objects
|
|
247
|
+
directly, while JSON-RPC SSE carries JSON-RPC response envelopes. REST tenant
|
|
248
|
+
routes prepend `/{tenant}`; gRPC requests carry the standard `tenant` field.
|
|
249
|
+
|
|
250
|
+
```yaml
|
|
251
|
+
rest: true # default: false; uses the HTTP listener
|
|
252
|
+
grpc:
|
|
253
|
+
enabled: true # default: false; separate plaintext HTTP/2 listener
|
|
254
|
+
port: 41242 # same bind host as HTTP, default 127.0.0.1
|
|
255
|
+
publicUrl: '' # advertised gRPC host:port; empty uses bound address
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
Install `@grpc/grpc-js` and `@bufbuild/protobuf` in the host when enabling gRPC.
|
|
259
|
+
They are optional peer dependencies and are loaded only when gRPC is enabled.
|
|
260
|
+
Both gRPC versions use the SDK service descriptors and coexist on this listener;
|
|
261
|
+
the Agent Card advertises only enabled bindings, including their v0.3 mirrors.
|
|
262
|
+
REST v0.3 uses the SDK's `/v1/...` routes and protobuf JSON shapes (for example,
|
|
263
|
+
`ROLE_USER`); it is distinct from v0.3 JSON-RPC `kind`-tagged messages. Use the
|
|
264
|
+
matching SDK client rather than copying payloads between bindings.
|
|
265
|
+
|
|
266
|
+
A small connection adapter releases an individual subscription's SDK queue when
|
|
267
|
+
its client disconnects; it never cancels the task or other subscribers. Disconnected
|
|
268
|
+
sends continue draining task events for persistence, independently of client read
|
|
269
|
+
speed. After a stream writer first reports backpressure, it may queue at most
|
|
270
|
+
1 MiB of additional HTTP output or 16 additional gRPC messages (plus the frame
|
|
271
|
+
that crosses the limit). A writer that exceeds this allowance is disconnected;
|
|
272
|
+
clients can retrieve the persisted task or resubscribe. A single large frame is
|
|
273
|
+
allowed, and a `drain` resets the allowance. This bounds the plugin's Node output
|
|
274
|
+
backlog, not individual frame size or the SDK's internal event/push queues.
|
|
275
|
+
|
|
276
|
+
The host can register an SDK `ExtendedAgentCardProvider` with
|
|
277
|
+
`ctx.provide('a2aExtendedCard', provider)` **before loading this plugin**. The
|
|
278
|
+
type is also exported by this package. The provider receives the SDK
|
|
279
|
+
`ServerCallContext` and must authenticate and authorize the caller before
|
|
280
|
+
returning private card data. The SDK stores HTTP headers or gRPC metadata at
|
|
281
|
+
`context.state.get(STATE_HEADERS_KEY)` (`STATE_HEADERS_KEY` is exported by
|
|
282
|
+
`@a2a-js/sdk/server`); tenant and requested version are also available. A rejected
|
|
283
|
+
provider call returns an error, and the public discovery card stays public.
|
|
284
|
+
Enabling the provider advertises `capabilities.extendedAgentCard: true`.
|
|
285
|
+
|
|
286
|
+
The plugin does not supply an identity provider or TLS certificates. Keep the
|
|
287
|
+
loopback default or use a host gateway for authentication, authorization and TLS
|
|
288
|
+
on **every enabled listener**. Application session listing, queue editing and
|
|
289
|
+
optional card signing are separate from these eleven operations. This support
|
|
290
|
+
matrix describes the wired SDK surface, not independent protocol certification.
|
|
291
|
+
|
|
292
|
+
The secret-free `E2E (dsh-a2a protocol bindings)` job runs HTTP/gRPC contract tests
|
|
293
|
+
and real DSH runtime turns with scripted model responses. It covers cross-binding
|
|
294
|
+
task state, authenticated push callbacks, extended cards, reconnect cleanup and
|
|
295
|
+
slow readers independently of the LLM/Langfuse integration gate.
|
|
296
|
+
|
|
112
297
|
- `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)
|
|
113
298
|
- `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 SSE status updates that end an interaction carry `final: true`, including `input-required` after a completed turn or an approval wait; this ends the stream without making the task terminal. Resubscribe snapshots keep waiting for future events while a live task bus exists. Legacy `tasks/get` also accepts `contextId` without `id` on this path or root `/`, returning the latest stored task or `result: null` when absent.
|
|
114
299
|
|
|
@@ -119,13 +304,13 @@ Disabled by default. Configure via the profile's `cordis.patch.yml`:
|
|
|
119
304
|
- **Host admission and settlement.** `ctx.on('a2a/before-followup', async (detail) => { ... })` runs before every user message enters the agent inbox. Its detail contains `agent`, `contextId`, `taskId`, the original `a2aMessage`, `dshMessageId`, `requestHeaders`, and an abort `signal`; rejection fails the A2A task without enqueueing the message. A host may inspect its own A2A message metadata here; the plugin does not interpret it. The existing `a2a/message-admitted` event remains an observational tap after insertion. A host can register `ctx.on('a2a/task-settlement', async ({ agent, contextId, taskId, reason, signal }, next) => { await hostWork; return next(); })`. The hook starts after the first completed turn and may wait for any number of autonomous follow-ups, including none. The plugin streams their events under the same task ID and publishes the latest turn's final status when the hook resolves. Cancel, clear, and disposal abort the signal and release the waiter. Without this hook, the first completed turn settles as before. The host must end its wait when its work is done; the plugin does not infer that from product metadata.
|
|
120
305
|
- **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.
|
|
121
306
|
- **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.
|
|
122
|
-
- **Message parts beyond text.** File parts can carry inline bytes or a `url`; the plugin downloads URLs over http/https, bounded to 64 MiB and 30 s. Supported images use the composed attachment store (`ctx.attachments`, e.g. `@deepseek-ai/dsh-attachment-local`) to reach vision-capable models. Non-image files use a configured materializer or persist under `uploadsDir` (default `<OS temp>/dsh-a2a-uploads/<date>/`, names sanitized cross-platform, collisions suffixed), even when an attachment store exists. The prompt references the resulting readable path inside a `<document>` tag. Ensure the local directory is readable by the agent's tools. `data` parts become `<data>` JSON text. A turn never fails because a part kind is unsupported.
|
|
307
|
+
- **Message parts beyond text.** File parts can carry inline bytes or a `url`; the plugin downloads URLs over http/https, bounded to 64 MiB and 30 s. Downloads default to public IP addresses and revalidate every redirect. In a trusted intranet, set `urlDownloads.allowPrivate: true` to allow every private or loopback host without an allowlist; `urlDownloads.allowedHosts` remains available to allow only named hosts. Supported images use the composed attachment store (`ctx.attachments`, e.g. `@deepseek-ai/dsh-attachment-local`) to reach vision-capable models. Non-image files use a configured materializer or persist under `uploadsDir` (default `<OS temp>/dsh-a2a-uploads/<date>/`, names sanitized cross-platform, collisions suffixed), even when an attachment store exists. The prompt references the resulting readable path inside a `<document>` tag. Ensure the local directory is readable by the agent's tools. `data` parts become `<data>` JSON text. A turn never fails because a part kind is unsupported.
|
|
123
308
|
- **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.
|
|
124
309
|
- **Local execution files.** Without a remote materializer, inline and downloaded non-image FileParts persist under `uploadsDir`; the `<document>` envelope carries the path, source URL, media type, size, and original filename when sanitization or a collision changes it. Images still use native attachment handling.
|
|
125
310
|
- **Tool approvals.** When the profile composes `@deepseek-ai/dsh-user-approval` with policy `ask`, A2A-owned agents answer `approval/request` by publishing `input-required` with a DataPart `{ requestId, toolName, reason?, callId? }`. Reply on the same `taskId`/`contextId` with one DataPart `{ requestId, callId?, outcome: "allowed-once" | "rejected" }`. The reply settles the waiting tool call inside the original dsh turn; it does not create another user message or billing turn. Invalid, duplicate, and withdrawn replies are rejected. `never` policy rejects inside dsh before the A2A answerer is called. The approval service is optional; without it, `ask` fails closed as usual.
|
|
126
311
|
- **Custom approval payloads.** A deployment may register `ctx.provide('a2aApprovalCodec', codec)` before this plugin starts. The exported `A2aApprovalCodec` has `encode(request)` and `decode(message)` methods; `decode` returns `{ requestId?, callId?, outcome }` only for a supported one-shot decision. A unique `callId` can replace `requestId` for legacy clients. Reject unsupported outcomes in the decoder; never translate `proceed_always` into `allowed-once`. Other message types, including question, plan, and UI markup, remain outside this plugin.
|
|
127
312
|
- **Requests for one task execute in order.** Autonomous turns covered by a settlement hook finish before the next user request starts. Approval confirmations settle a pending decision inside its current turn.
|
|
128
|
-
- **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.
|
|
313
|
+
- **Restart and workers**: 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. Shared stores do not provide multi-active execution: route a working task/context to its owning worker. A non-owner cancellation fails fast instead of reporting a cancel while the original agent continues.
|
|
129
314
|
- **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.
|
|
130
315
|
|
|
131
316
|
## SSE heartbeats
|
|
@@ -185,9 +370,95 @@ dsh ships **no authentication or authorization**. The server binds `127.0.0.1` b
|
|
|
185
370
|
|
|
186
371
|
## Compatibility
|
|
187
372
|
|
|
188
|
-
Optional Goal integration requires `@deepseek-ai/dsh-goal >=0.2.0-rc.2 || >=0.2.1-alpha.1` and the official Goal round driver (both tested at `0.2.1-rc.
|
|
373
|
+
Optional Goal integration requires `@deepseek-ai/dsh-goal >=0.2.0-rc.2 || >=0.2.1-alpha.1` and the official Goal round driver (both tested at `0.2.1-rc.5`).
|
|
374
|
+
|
|
375
|
+
Pinned dsh/cordis versions live in the [root compat matrix](../../README.md#compatibility). Event payloads ride pre-release dsh APIs (`@deepseek-ai/dsh-{agent,session,llm,attachment,tools}@0.2.1-rc.5` — the attachment store is an optional peer) — the session peer floor is `>=0.2.1-rc.5` for the owner-scoped `SessionStore.flush` checkpoint barrier; check the `TODO(verify)` markers in `src/` before upgrading dsh.
|
|
376
|
+
|
|
377
|
+
## Standard A2A notifications
|
|
378
|
+
|
|
379
|
+
Use `SendStreamingMessage` / `SubscribeToTask` for live task updates, `GetTask`
|
|
380
|
+
for task snapshots/history, and `ListTasks` scoped by `contextId` for discovery.
|
|
381
|
+
Multiple clients can subscribe independently. Use streaming for interactive UI;
|
|
382
|
+
use official task Push Notifications for a receiver that cannot keep a stream open.
|
|
383
|
+
See the [official streaming and push guide](https://a2a-protocol.org/v1.0.1/topics/streaming-and-async/).
|
|
384
|
+
|
|
385
|
+
Enable the SDK's standard push capability explicitly (default: `false`):
|
|
386
|
+
|
|
387
|
+
```yaml
|
|
388
|
+
config:
|
|
389
|
+
enabled: true
|
|
390
|
+
pushNotifications: true
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
The Agent Card then advertises `capabilities.pushNotifications: true`. This plugin
|
|
394
|
+
passes the official SDK's `InMemoryPushNotificationStore` and
|
|
395
|
+
`createLegacyAwarePushNotificationSender` into its `DefaultRequestHandler`.
|
|
396
|
+
Configuration CRUD, dispatch, authentication headers, serialization and timeouts
|
|
397
|
+
are SDK behavior. Restored native task events use that same sender after their
|
|
398
|
+
existing SDK ResultManager persistence drain; there is no separate notification protocol, sender adapter,
|
|
399
|
+
queue-change channel, message correlation cache or plugin retry scheduler.
|
|
400
|
+
|
|
401
|
+
A v1.0 client can register its webhook while sending a message:
|
|
402
|
+
|
|
403
|
+
```json
|
|
404
|
+
{
|
|
405
|
+
"jsonrpc": "2.0",
|
|
406
|
+
"id": 1,
|
|
407
|
+
"method": "SendMessage",
|
|
408
|
+
"params": {
|
|
409
|
+
"message": {
|
|
410
|
+
"messageId": "fixture-message-1",
|
|
411
|
+
"role": "user",
|
|
412
|
+
"parts": [{ "text": "Run this task" }]
|
|
413
|
+
},
|
|
414
|
+
"configuration": {
|
|
415
|
+
"taskPushNotificationConfig": {
|
|
416
|
+
"url": "https://receiver.example.invalid/a2a-notifications",
|
|
417
|
+
"authentication": { "scheme": "Bearer", "credentials": "placeholder-token" }
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
```
|
|
189
423
|
|
|
190
|
-
|
|
424
|
+
Send `A2A-Version: 1.0`. For an existing task use
|
|
425
|
+
`CreateTaskPushNotificationConfig` with `{ taskId, url, authentication }`.
|
|
426
|
+
The SDK assigns the returned config ID. Manage it with
|
|
427
|
+
`GetTaskPushNotificationConfig`, `ListTaskPushNotificationConfigs` and
|
|
428
|
+
`DeleteTaskPushNotificationConfig` (Get and Delete use
|
|
429
|
+
`{ taskId, id }`). Configuration access uses the SDK's tenant/owner scope.
|
|
430
|
+
The existing legacy methods and `configuration.pushNotificationConfig` are
|
|
431
|
+
handled by the SDK's v0.3 compatibility layer.
|
|
432
|
+
|
|
433
|
+
The receiver gets HTTP POSTs with `Content-Type: application/a2a+json` and a
|
|
434
|
+
standard `StreamResponse`: `{ task }`, `{ message }`, `{ statusUpdate }` or
|
|
435
|
+
`{ artifactUpdate }`. The configured Bearer credentials become `Authorization`;
|
|
436
|
+
incoming application identity headers are not automatically forwarded. A webhook
|
|
437
|
+
registered over v0.3 receives the SDK's v0.3 task snapshots and `application/json`,
|
|
438
|
+
even if a later triggering request uses a different wire version.
|
|
439
|
+
|
|
440
|
+
The host verifies webhook authentication and resolves application/context
|
|
441
|
+
ownership before bridging the update to its existing WebSocket/UI refresh
|
|
442
|
+
logic. Product topics, permission rules, and snapshot selection stay in the
|
|
443
|
+
host. Native queue editing/reordering still uses the existing inbox API and
|
|
444
|
+
snapshots; this plugin does not manufacture new A2A task states for queue edits.
|
|
445
|
+
A task update does not promise that a separate application database mirror has
|
|
446
|
+
flushed. `GetTask` returns the authoritative A2A task snapshot instead.
|
|
447
|
+
|
|
448
|
+
The official store is in memory. Configs and their credentials are lost on
|
|
449
|
+
restart, independently of the chosen durable TaskStore backend; clients must
|
|
450
|
+
register again. Delete configurations that are no longer needed. The SDK uses
|
|
451
|
+
its default five-second webhook request timeout and logs delivery failures
|
|
452
|
+
without failing task execution. No plugin-level retries, shutdown drain or
|
|
453
|
+
persistent delivery guarantee are added. Read task snapshots on initial
|
|
454
|
+
connection/reconnection and tolerate missing or duplicate notifications.
|
|
455
|
+
|
|
456
|
+
Keep the loopback default. Push registration lets callers choose outbound
|
|
457
|
+
webhook destinations; enable it only behind host authentication/authorization
|
|
458
|
+
and an appropriate outbound-network policy. The SDK wiring does not add an
|
|
459
|
+
application identity provider or a webhook URL allowlist. Use placeholder
|
|
460
|
+
credentials and temporary session directories such as `/tmp/dsh-a2a-example`
|
|
461
|
+
when testing locally.
|
|
191
462
|
|
|
192
463
|
## License
|
|
193
464
|
|
package/lib/admission.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** Small keyed FIFO gate. Cleanup runs only when the releasing tail is still current. */
|
|
2
|
+
export class AdmissionGate {
|
|
3
|
+
tails = new Map();
|
|
4
|
+
acquire(key) {
|
|
5
|
+
const previous = this.tails.get(key) ?? Promise.resolve();
|
|
6
|
+
let open;
|
|
7
|
+
const gate = new Promise((resolve) => (open = resolve));
|
|
8
|
+
const tail = previous.then(() => gate);
|
|
9
|
+
this.tails.set(key, tail);
|
|
10
|
+
let released = false;
|
|
11
|
+
return {
|
|
12
|
+
ready: previous,
|
|
13
|
+
release: (onCurrent) => {
|
|
14
|
+
if (released)
|
|
15
|
+
return;
|
|
16
|
+
released = true;
|
|
17
|
+
open();
|
|
18
|
+
if (this.tails.get(key) !== tail)
|
|
19
|
+
return;
|
|
20
|
+
this.tails.delete(key);
|
|
21
|
+
onCurrent?.();
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
}
|
package/lib/bridge.js
CHANGED
|
@@ -25,6 +25,7 @@ import { installModelSelection } from '@deepseek-ai/dsh-agent';
|
|
|
25
25
|
// modules import packages that are not on npm yet (dsh-timeout, dsh-scope).
|
|
26
26
|
import { createUserMessage } from '@deepseek-ai/dsh-llm/message';
|
|
27
27
|
import { SessionId } from '@deepseek-ai/dsh-session/types';
|
|
28
|
+
import { AdmissionGate } from './admission.js';
|
|
28
29
|
import { ApprovalReplyError, defaultApprovalCodec, } from './approval.js';
|
|
29
30
|
import { buildMessageContent, uploadsDir } from './content.js';
|
|
30
31
|
import { agentTextMessage, SessionTranslator, terminalStatusUpdate } from './translator.js';
|
|
@@ -36,6 +37,7 @@ export class A2aBridge {
|
|
|
36
37
|
bySession = new Map(); // sessionId (== contextId) → entry
|
|
37
38
|
clearing = new Set();
|
|
38
39
|
creating = new Map();
|
|
40
|
+
admissions = new AdmissionGate();
|
|
39
41
|
executing = new Map();
|
|
40
42
|
asked = new Map();
|
|
41
43
|
approvals = new Map();
|
|
@@ -103,7 +105,8 @@ export class A2aBridge {
|
|
|
103
105
|
* a new task id for `message/send` calls that omit `taskId`.
|
|
104
106
|
*/
|
|
105
107
|
async ensureTask(taskId, contextId) {
|
|
106
|
-
const
|
|
108
|
+
const admission = this.admissions.acquire(contextId);
|
|
109
|
+
const creation = admission.ready.then(() => this.ensureTaskInner(taskId, contextId));
|
|
107
110
|
let pending = this.creating.get(contextId);
|
|
108
111
|
if (!pending) {
|
|
109
112
|
pending = new Set();
|
|
@@ -117,6 +120,7 @@ export class A2aBridge {
|
|
|
117
120
|
pending.delete(creation);
|
|
118
121
|
if (pending.size === 0)
|
|
119
122
|
this.creating.delete(contextId);
|
|
123
|
+
admission.release();
|
|
120
124
|
}
|
|
121
125
|
}
|
|
122
126
|
async ensureTaskInner(taskId, contextId, clearing = false) {
|
|
@@ -149,6 +153,31 @@ export class A2aBridge {
|
|
|
149
153
|
return { entry: forContext, freshTask: true };
|
|
150
154
|
}
|
|
151
155
|
const sessionId = SessionId(contextId);
|
|
156
|
+
const borrowed = this.inbox ? this.ctx.agents.get?.(sessionId) : undefined;
|
|
157
|
+
const handle = borrowed
|
|
158
|
+
? { agent: borrowed, dispose: async () => { } }
|
|
159
|
+
: await this.createAgent(sessionId);
|
|
160
|
+
const entry = {
|
|
161
|
+
taskId,
|
|
162
|
+
sessionId,
|
|
163
|
+
handle,
|
|
164
|
+
borrowed: !!borrowed,
|
|
165
|
+
translator: new SessionTranslator(taskId, contextId, this.options.agentOptions?.model),
|
|
166
|
+
bus: null,
|
|
167
|
+
turnActive: false,
|
|
168
|
+
staleTurn: false,
|
|
169
|
+
settled: [],
|
|
170
|
+
running: new Set(),
|
|
171
|
+
pendingMessageAborts: new Set(),
|
|
172
|
+
settlementAbort: new AbortController(),
|
|
173
|
+
settling: false,
|
|
174
|
+
interrupted: false,
|
|
175
|
+
};
|
|
176
|
+
this.tasks.set(taskId, entry);
|
|
177
|
+
this.bySession.set(contextId, entry);
|
|
178
|
+
return { entry, freshTask: true };
|
|
179
|
+
}
|
|
180
|
+
async createAgent(sessionId) {
|
|
152
181
|
const selection = this.resolveSelection();
|
|
153
182
|
// The web profile disables the host-plane tool plugins — agents get tools
|
|
154
183
|
// only through a mounted agent preset (dsh-host-apiproxy's composeAgent is
|
|
@@ -186,42 +215,29 @@ export class A2aBridge {
|
|
|
186
215
|
// absence: let it fail instead of trying create() against an existing id.
|
|
187
216
|
const persistence = this.ctx.get('sessionPersistence');
|
|
188
217
|
const persisted = await persistence?.stat(sessionId);
|
|
189
|
-
|
|
218
|
+
return persisted
|
|
190
219
|
? await this.ctx.agents.resume({
|
|
191
220
|
resumeSessionId: sessionId,
|
|
192
221
|
agentOptions,
|
|
193
222
|
setup,
|
|
223
|
+
...(this.inbox ? { keepInboxOnDispose: true } : {}),
|
|
194
224
|
})
|
|
195
225
|
: await this.ctx.agents.create({
|
|
196
226
|
sessionId,
|
|
197
227
|
meta: { cwd: this.options.cwd, ...(agentPreset ? { agentPreset } : {}) },
|
|
198
228
|
agentOptions,
|
|
199
229
|
setup,
|
|
230
|
+
...(this.inbox ? { keepInboxOnDispose: true } : {}),
|
|
200
231
|
});
|
|
201
|
-
const entry = {
|
|
202
|
-
taskId,
|
|
203
|
-
sessionId,
|
|
204
|
-
handle,
|
|
205
|
-
translator: new SessionTranslator(taskId, contextId, this.options.agentOptions?.model),
|
|
206
|
-
bus: null,
|
|
207
|
-
turnActive: false,
|
|
208
|
-
staleTurn: false,
|
|
209
|
-
settled: [],
|
|
210
|
-
running: new Set(),
|
|
211
|
-
pendingMessageAborts: new Set(),
|
|
212
|
-
settlementAbort: new AbortController(),
|
|
213
|
-
settling: false,
|
|
214
|
-
interrupted: false,
|
|
215
|
-
};
|
|
216
|
-
this.tasks.set(taskId, entry);
|
|
217
|
-
this.bySession.set(contextId, entry);
|
|
218
|
-
return { entry, freshTask: true };
|
|
219
232
|
}
|
|
220
233
|
/** A2A parts → dsh content blocks; throws only when no part is usable. */
|
|
221
234
|
async buildContent(message, contextId) {
|
|
222
235
|
const attachments = this.ctx.get('attachments');
|
|
223
236
|
const materializer = this.ctx.get('a2aFileMaterializer');
|
|
224
|
-
return buildMessageContent(message.parts, attachments, uploadsDir(this.options.uploadsDir), materializer ? { contextId, materializer } : undefined
|
|
237
|
+
return buildMessageContent(message.parts, attachments, uploadsDir(this.options.uploadsDir), materializer ? { contextId, materializer } : undefined, {
|
|
238
|
+
allowPrivate: this.options.downloadAllowPrivate,
|
|
239
|
+
allowedHosts: this.options.downloadAllowedHosts,
|
|
240
|
+
});
|
|
225
241
|
}
|
|
226
242
|
isClearing(contextId) {
|
|
227
243
|
return this.clearing.has(contextId);
|
|
@@ -356,8 +372,12 @@ export class A2aBridge {
|
|
|
356
372
|
}
|
|
357
373
|
finally {
|
|
358
374
|
pending.delete(execution);
|
|
359
|
-
if (pending.size === 0)
|
|
375
|
+
if (pending.size === 0) {
|
|
360
376
|
this.executing.delete(contextId);
|
|
377
|
+
const entry = this.bySession.get(contextId);
|
|
378
|
+
if (entry && entry.running.size === 0)
|
|
379
|
+
entry.terminalState = undefined;
|
|
380
|
+
}
|
|
361
381
|
}
|
|
362
382
|
}
|
|
363
383
|
/** Queue one user-message turn on the task's agent and await its `turn/end`. */
|
|
@@ -391,7 +411,9 @@ export class A2aBridge {
|
|
|
391
411
|
}
|
|
392
412
|
finally {
|
|
393
413
|
entry.running.delete(running);
|
|
394
|
-
|
|
414
|
+
// Keep a failure visible to messages already admitted into this execution
|
|
415
|
+
// batch; trackExecution clears it once a genuinely new batch can arrive.
|
|
416
|
+
if (entry.running.size === 0 && !this.executing.get(entry.sessionId)?.size)
|
|
395
417
|
entry.terminalState = undefined;
|
|
396
418
|
}
|
|
397
419
|
}
|