@amaster.ai/dsh-a2a 0.1.34 → 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 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.4`; 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.
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 remain the wire interface. 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.
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(contextId)`, `content(message, contextId)`, `enqueue(taskId, message, { target?, wakeup? })`, `promote(contextId, nativeMessageId, activeTaskId)`, and `cancel(taskId)`. There is no separate pause, wake-only, resume, or retained-task restart operation. Cancellation is terminal and removes that task's pending input. Other tasks' input is retained according to official DSH cancellation semantics, and may wait for the next waking message. Clearing a context discards all its pending work.
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.4`).
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
- 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.4` — the attachment store is an optional peer) — the session peer floor is `>=0.2.0-rc.1` because child attribution uses `firstLifecycleSeq` to reject restored sessions; check the `TODO(verify)` markers in `src/` before upgrading dsh.
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
 
@@ -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 creation = this.ensureTaskInner(taskId, contextId);
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
- const handle = persisted
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
- if (entry.running.size === 0)
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
  }