@amaster.ai/dsh-a2a 0.1.27 → 0.1.29
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 +52 -4
- package/lib/inbox.js +422 -19
- package/lib/index.js +12 -2
- package/lib/stores/filesystem.js +86 -0
- package/lib/types/inbox.d.ts +18 -1
- package/lib/types/index.d.ts +21 -3
- package/lib/types/stores/filesystem.d.ts +15 -0
- package/package.json +18 -8
package/README.md
CHANGED
|
@@ -18,9 +18,52 @@ dsh plugin --profile my-agent add @amaster.ai/dsh-a2a
|
|
|
18
18
|
|
|
19
19
|
Standard `SendMessage` / `SendStreamingMessage`, task queries, cancellation and subscriptions remain the wire interface. The negotiated [inbox extension](../../docs/protocol/inbox-v1.md) 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? })`, 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(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.
|
|
22
22
|
|
|
23
|
-
`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
|
|
23
|
+
`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
|
+
|
|
25
|
+
### Goal rounds
|
|
26
|
+
|
|
27
|
+
When the optional official `@deepseek-ai/dsh-goal` and `dsh-goal-round-driver` plugins are mounted, a Goal created or resumed during an A2A task keeps that task open while the Goal is active and armed. Automatic rounds retain the driver's original native message ID, content and `goal` source. The official driver still validates its reservation, revision and round; the plugin selects the owning task for streaming and `a2a/before-step` billing attribution. Editing the same Goal from another task preserves its original owner. Completing, pausing, blocking or disarming the Goal releases that ownership. A cold restart does not inherit automatic Goal authority: the host must explicitly resume it through a live attributed task.
|
|
28
|
+
|
|
29
|
+
### Promote a queued input
|
|
30
|
+
|
|
31
|
+
Queue an ordinary future turn as a new task (omit `taskId`). To steer an active task with that already admitted input, pass its **native DSH message ID**, available from `agent.inbox.nextTurn` or `a2a/inbox` observations:
|
|
32
|
+
|
|
33
|
+
```ts
|
|
34
|
+
await ctx.a2aInbox.promote(contextId, nativeMessageId, activeTaskId);
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Only an unclaimed, single-message independent task in the same context can be promoted. The native ID and A2A `messageId` stay unchanged; the target task owns the next-step input, and `referenceTaskIds` records the original task, which settles canceled after the durable transfer. Claimed input, a finished target, concurrent consumption or task settlement reject a stale request. A failed session checkpoint restores independent ownership; a later storage error can report failure after the transfer committed: inspect the queue and task states before sending new input. Recovery repairs interrupted transfers without replaying an ordinary DSH claim or committed model input.
|
|
38
|
+
|
|
39
|
+
### Local restart recovery
|
|
40
|
+
|
|
41
|
+
Select `taskStore: filesystem` with `filesystem.root` pointing at a retained directory, and separately enable durable DSH session persistence. Keep queued file materialization on retained storage as well. Each task snapshot is scoped by tenant and user, written to a private temporary file and atomically renamed; no additional service is needed. Use one host writer per directory. Files have no automatic expiry; context clearing deletes task snapshots.
|
|
42
|
+
|
|
43
|
+
After a crash, the host discovers sessions, calls `a2aInbox.resolve(contextId)` and supplies a real attributed waking input through the normal API. Restored unclaimed inputs keep their original task ownership. Startup scanning and scheduling remain host responsibilities. Explicit cancellation stays terminal and discards only the canceled task's input. This does not promise exactly-once external side effects, replay of consumed input, or replay of the interrupted active model call.
|
|
44
|
+
|
|
45
|
+
### Graceful host shutdown
|
|
46
|
+
|
|
47
|
+
Set `inbox: { enabled: true, shutdown: retain-pending }` on both sides of a restart to retain unclaimed A2A `next-step` and `next-turn` input. Keep durable session persistence, the TaskStore and materialized files on retained storage. This is a host configuration policy, not wire metadata. The default `shutdown: cancel` keeps the existing terminal-on-unload behavior.
|
|
48
|
+
|
|
49
|
+
Retention tracks normalized native inbox splices, including public edits, reordering, removals and queue moves, then freezes the final pending state when host/plugin unloading begins. It stops the current execution, checkpoints pending input in task snapshot metadata with its order and original native/A2A identities, then closes live request/subscription streams. The extra checkpoint survives the core's later inbox-clearing disposal. A task with pending input stays nonterminal for recovery; an interrupted task with no pending input becomes failed. Reconnect subscriptions after restart: streams are not replayed or carried across processes.
|
|
50
|
+
|
|
51
|
+
On `a2aInbox.resolve(contextId)`, the plugin restores retained ownership before allowing new input, excludes IDs already committed as `user/message`, and drains the retention checkpoint only after the restored session is durable. Explicit `CancelTask`, `a2aInbox.cancel()` and context clearing remain terminal. Claims already handed to the active execution are not retained, and external tool effects are not exactly-once. The host still owns startup discovery and an attributed waking input; merely resolving a context does not wake it. Persistence/checkpoint errors are reported through the existing shutdown error path; the host must not treat a failed checkpoint as a durable restart barrier.
|
|
52
|
+
|
|
53
|
+
The dedicated `E2E (dsh-a2a graceful retention)` job covers public plugin disposal, a real SIGTERM child process, repeated restarts, promotion/checkpoint races and terminal cancellation. It also reports recovered tasks to Langfuse and verifies their task/turn/generation ancestry through the real Observations API on same-repository CI.
|
|
54
|
+
|
|
55
|
+
```sh
|
|
56
|
+
node .github/scripts/check-inbox.mjs -t 'graceful retention'
|
|
57
|
+
DSH_GRACEFUL_RETENTION_LANGFUSE_LIVE=true node .github/scripts/check-inbox.mjs -t 'graceful retention.*reports gracefully recovered'
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
The dedicated `E2E (dsh-a2a native ownership)` integration job runs these regressions with unmodified npm DSH and scripted model responses. Its Langfuse scenario verifies Goal rounds and promoted input under the original task trace, canceled source ownership, and execution restored after SIGKILL. Same-repository CI also sends the scenario to the configured real Langfuse project and polls its Observations API; fork PRs use the local collector without credentials.
|
|
61
|
+
|
|
62
|
+
```sh
|
|
63
|
+
node .github/scripts/check-inbox.mjs -t 'native ownership'
|
|
64
|
+
# With LANGFUSE_BASE_URL, LANGFUSE_PUBLIC_KEY and LANGFUSE_SECRET_KEY configured:
|
|
65
|
+
DSH_NATIVE_OWNERSHIP_LANGFUSE_LIVE=true node .github/scripts/check-inbox.mjs -t 'native ownership.*reports Goal continuation'
|
|
66
|
+
```
|
|
24
67
|
|
|
25
68
|
## Configuration
|
|
26
69
|
|
|
@@ -51,7 +94,9 @@ Disabled by default. Configure via the profile's `cordis.patch.yml`:
|
|
|
51
94
|
description: My dsh agent over A2A
|
|
52
95
|
version: 0.1.0
|
|
53
96
|
publicUrl: https://agent.example.com
|
|
54
|
-
taskStore: redis # memory | redis | gcs
|
|
97
|
+
taskStore: redis # memory | filesystem | redis | gcs
|
|
98
|
+
filesystem:
|
|
99
|
+
root: /srv/dsh/a2a-tasks # required when taskStore: filesystem
|
|
55
100
|
redis:
|
|
56
101
|
url: redis://127.0.0.1:6379
|
|
57
102
|
keyPrefix: a2a
|
|
@@ -128,6 +173,7 @@ Forwarding follows the owning task's active request bus. Background child events
|
|
|
128
173
|
A2A task snapshots retain protocol history and artifacts; [dsh-storage](../dsh-storage) separately records the full conversation. Backends save on task-state transitions, including the complete reply at `input-required`, so token-rate stream events never reach Redis or GCS. While a task is working, in-process history accumulates text deltas for `GetTask` and resubscription; a restart before the next state change can lose those transient deltas.
|
|
129
174
|
|
|
130
175
|
- `memory` (default) — in-process, lost on restart
|
|
176
|
+
- `filesystem` — private, atomically replaced JSON snapshots under `filesystem.root`; no additional peer dependency
|
|
131
177
|
- `redis` — task JSON under `<keyPrefix>:tasks:<scope>:<taskId>` with a TTL; requires the `ioredis` peer
|
|
132
178
|
- `gcs` — gzipped task JSON at `<prefix>/<scope>/<taskId>/metadata.json.gz`; requires the `@google-cloud/storage` peer. `archiveWorkspace()` (tar of the workspace) exists but is not wired to the lifecycle yet.
|
|
133
179
|
|
|
@@ -139,7 +185,9 @@ dsh ships **no authentication or authorization**. The server binds `127.0.0.1` b
|
|
|
139
185
|
|
|
140
186
|
## Compatibility
|
|
141
187
|
|
|
142
|
-
|
|
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.2`).
|
|
189
|
+
|
|
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.2` — 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.
|
|
143
191
|
|
|
144
192
|
## License
|
|
145
193
|
|
package/lib/inbox.js
CHANGED
|
@@ -5,30 +5,80 @@ import { AgentEvent, ServerCallContext, STATE_HEADERS_KEY, } from '@a2a-js/sdk/s
|
|
|
5
5
|
import { createUserMessage } from '@deepseek-ai/dsh-llm/message';
|
|
6
6
|
import { agentTextMessage, SessionTranslator, terminalStatusUpdate } from './translator.js';
|
|
7
7
|
export const INBOX_EXTENSION = 'https://github.com/TGYD-helige/dsh-plugins/blob/main/docs/protocol/inbox-v1.md';
|
|
8
|
+
const RETAINED_INPUTS = `${INBOX_EXTENSION}#retained-inputs`;
|
|
8
9
|
export class NativeInbox {
|
|
9
10
|
ctx;
|
|
10
11
|
bridge;
|
|
11
12
|
store;
|
|
13
|
+
retainPending;
|
|
12
14
|
contexts = new Map();
|
|
13
15
|
resolving = new Map();
|
|
14
16
|
stopping = false;
|
|
15
17
|
shutdownPromise;
|
|
16
18
|
preadmitted = new WeakMap();
|
|
17
19
|
server;
|
|
18
|
-
|
|
20
|
+
persistence;
|
|
21
|
+
constructor(ctx, bridge, store, retainPending = false) {
|
|
19
22
|
this.ctx = ctx;
|
|
20
23
|
this.bridge = bridge;
|
|
21
24
|
this.store = store;
|
|
25
|
+
this.retainPending = retainPending;
|
|
22
26
|
bridge.inbox = this;
|
|
27
|
+
this.persistence = ctx.get('sessionPersistence');
|
|
28
|
+
if (retainPending)
|
|
29
|
+
ctx.on('session/event', (session, event) => {
|
|
30
|
+
const run = this.contexts.get(session.id);
|
|
31
|
+
if (!run || event.type !== 'agent/inbox/spliced' || this.stopping || this.unloading())
|
|
32
|
+
return;
|
|
33
|
+
try {
|
|
34
|
+
const splice = event.data;
|
|
35
|
+
run.retained[splice.target].splice(splice.start, splice.removedCount ?? 0, ...structuredClone(splice.inserted));
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
console.error('[dsh-a2a] failed to track pending inbox:', error);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
23
41
|
ctx.on('agent/pre-step', async (detail, next) => {
|
|
24
42
|
await this.prepare(detail);
|
|
25
43
|
return next();
|
|
26
44
|
});
|
|
45
|
+
ctx.on('goal/changed', ({ agent, change }) => {
|
|
46
|
+
const run = this.contexts.get(agent.session.id);
|
|
47
|
+
if (!run)
|
|
48
|
+
return;
|
|
49
|
+
const previous = run.goalOwner;
|
|
50
|
+
if (change.goal?.phase === 'active' && change.goal.activation === 'armed') {
|
|
51
|
+
const task = previous?.goalId === change.ref.id ? previous.task : run.active;
|
|
52
|
+
if (task && !task.settled)
|
|
53
|
+
run.goalOwner = { goalId: change.ref.id, task };
|
|
54
|
+
}
|
|
55
|
+
else
|
|
56
|
+
run.goalOwner = undefined;
|
|
57
|
+
if (previous)
|
|
58
|
+
this.settle(run, previous.task);
|
|
59
|
+
});
|
|
60
|
+
ctx.on('agent/status', ({ agent, status }) => {
|
|
61
|
+
const run = this.contexts.get(agent.session.id);
|
|
62
|
+
if (status !== 'idle' || !run?.goalOwner)
|
|
63
|
+
return;
|
|
64
|
+
const { task } = run.goalOwner;
|
|
65
|
+
try {
|
|
66
|
+
const goal = ctx.get('goals')?.get(agent);
|
|
67
|
+
if (goal?.phase === 'active' && goal.activation === 'armed')
|
|
68
|
+
return;
|
|
69
|
+
run.goalOwner = undefined;
|
|
70
|
+
this.settle(run, task);
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
run.goalOwner = undefined;
|
|
74
|
+
this.fail(task, agent.session.id, error);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
27
77
|
ctx.on('agent/inbox/claimed', ({ agent, message, turn }) => {
|
|
28
78
|
const run = this.contexts.get(agent.session.id);
|
|
29
|
-
if (!run
|
|
79
|
+
if (!run)
|
|
30
80
|
return;
|
|
31
|
-
const task =
|
|
81
|
+
const task = this.owner(run, message);
|
|
32
82
|
if (task && !task.settled) {
|
|
33
83
|
run.claimed.add(task);
|
|
34
84
|
if (!run.active)
|
|
@@ -41,6 +91,18 @@ export class NativeInbox {
|
|
|
41
91
|
}
|
|
42
92
|
});
|
|
43
93
|
}
|
|
94
|
+
unloading() {
|
|
95
|
+
// Cordis publishes FiberState as a const enum, without a runtime export.
|
|
96
|
+
const unloading = 5;
|
|
97
|
+
for (let fiber = this.ctx.fiber;;) {
|
|
98
|
+
if (fiber.uid === null || fiber.state === unloading)
|
|
99
|
+
return true;
|
|
100
|
+
const parent = fiber.parent.fiber;
|
|
101
|
+
if (parent === fiber)
|
|
102
|
+
return false;
|
|
103
|
+
fiber = parent;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
44
106
|
content(message, contextId) {
|
|
45
107
|
return this.bridge.buildContent(message, contextId);
|
|
46
108
|
}
|
|
@@ -69,6 +131,243 @@ export class NativeInbox {
|
|
|
69
131
|
}
|
|
70
132
|
throw new Error('Host input must address a live A2A task.');
|
|
71
133
|
}
|
|
134
|
+
// A non-canceled removal keeps an interrupted move distinguishable from explicit cancellation.
|
|
135
|
+
moveInput(agent, message, target) {
|
|
136
|
+
const from = target === 'next-step' ? 'next-turn' : 'next-step';
|
|
137
|
+
const items = from === 'next-turn' ? agent.inbox.nextTurn : agent.inbox.nextStep;
|
|
138
|
+
const index = items.findIndex((item) => item.id === message.id);
|
|
139
|
+
if (index < 0)
|
|
140
|
+
throw new Error('Queued input has already been claimed.');
|
|
141
|
+
agent.session.append('agent/inbox/spliced', {
|
|
142
|
+
target: from,
|
|
143
|
+
start: index,
|
|
144
|
+
removedCount: 1,
|
|
145
|
+
inserted: [],
|
|
146
|
+
});
|
|
147
|
+
agent.inbox.append(target, message);
|
|
148
|
+
}
|
|
149
|
+
async promote(contextId, messageId, taskId) {
|
|
150
|
+
const run = await this.context(contextId);
|
|
151
|
+
const operation = run.admissionTail.then(() => this.promoteInput(run, messageId, taskId));
|
|
152
|
+
run.admissionTail = operation.catch(() => { });
|
|
153
|
+
run.promotions.set(messageId, operation);
|
|
154
|
+
const clear = () => {
|
|
155
|
+
if (run.promotions.get(messageId) === operation)
|
|
156
|
+
run.promotions.delete(messageId);
|
|
157
|
+
};
|
|
158
|
+
void operation.then(clear, clear);
|
|
159
|
+
return operation;
|
|
160
|
+
}
|
|
161
|
+
async promoteInput(run, messageId, taskId) {
|
|
162
|
+
const agent = run.entry.handle.agent;
|
|
163
|
+
const target = run.tasks.get(taskId);
|
|
164
|
+
const message = agent.inbox.nextTurn.find((item) => item.id === messageId);
|
|
165
|
+
if (message?.source.kind !== 'a2a')
|
|
166
|
+
throw new Error('Queued input has already been claimed.');
|
|
167
|
+
const source = run.tasks.get(message.source.taskId);
|
|
168
|
+
if (!target || !source)
|
|
169
|
+
throw new Error('Promotion requires live source and target tasks.');
|
|
170
|
+
const valid = () => !this.stopping &&
|
|
171
|
+
!this.bridge.isClearing(run.entry.sessionId) &&
|
|
172
|
+
!target.settled &&
|
|
173
|
+
!target.controller.signal.aborted &&
|
|
174
|
+
run.active === target &&
|
|
175
|
+
source !== target &&
|
|
176
|
+
!source.settled &&
|
|
177
|
+
!source.controller.signal.aborted &&
|
|
178
|
+
source.task.history.length === 1 &&
|
|
179
|
+
!source.pendingRequests &&
|
|
180
|
+
!run.claimed.has(source) &&
|
|
181
|
+
agent.inbox.nextTurn.some((item) => item === message);
|
|
182
|
+
if (!valid())
|
|
183
|
+
throw new Error('Promotion requires an unclaimed independent input and an active target.');
|
|
184
|
+
const original = Message.fromJSON(JSON.parse(message.source.message));
|
|
185
|
+
original.taskId = taskId;
|
|
186
|
+
original.referenceTaskIds = [...new Set([...original.referenceTaskIds, source.task.id])];
|
|
187
|
+
const promotionId = randomUUID();
|
|
188
|
+
const promoted = {
|
|
189
|
+
...message,
|
|
190
|
+
source: {
|
|
191
|
+
...message.source,
|
|
192
|
+
taskId,
|
|
193
|
+
target: 'next-step',
|
|
194
|
+
promotedFrom: source.task.id,
|
|
195
|
+
promotionId,
|
|
196
|
+
message: JSON.stringify(Message.toJSON(original)),
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
target.pendingRequests++;
|
|
200
|
+
try {
|
|
201
|
+
await this.checkpoint(target.task, target.context);
|
|
202
|
+
if (!valid())
|
|
203
|
+
throw new Error('Promotion raced input consumption or task settlement.');
|
|
204
|
+
target.task.history = [...target.task.history, original];
|
|
205
|
+
target.headers.set(message.id, source.headers.get(message.id));
|
|
206
|
+
if (!agent.inbox.replace(message.id, promoted))
|
|
207
|
+
throw new Error('Queued input has already been claimed.');
|
|
208
|
+
this.moveInput(agent, promoted, 'next-step');
|
|
209
|
+
try {
|
|
210
|
+
await this.flush(agent.session);
|
|
211
|
+
}
|
|
212
|
+
catch (error) {
|
|
213
|
+
// Until the session checkpoint succeeds, the independent task still owns this input.
|
|
214
|
+
const rollback = {
|
|
215
|
+
...message,
|
|
216
|
+
source: { ...message.source, promotedFrom: source.task.id },
|
|
217
|
+
};
|
|
218
|
+
run.rolledBack.add(promotionId);
|
|
219
|
+
const live = this.contexts.get(run.entry.sessionId) === run &&
|
|
220
|
+
!this.stopping &&
|
|
221
|
+
!this.bridge.isClearing(run.entry.sessionId);
|
|
222
|
+
if (live && !source.settled && !source.controller.signal.aborted) {
|
|
223
|
+
if (this.pending(run).some((item) => item.id === message.id)) {
|
|
224
|
+
agent.inbox.replace(message.id, rollback);
|
|
225
|
+
this.moveInput(agent, rollback, 'next-turn');
|
|
226
|
+
}
|
|
227
|
+
else
|
|
228
|
+
agent.inbox.append('next-turn', rollback);
|
|
229
|
+
}
|
|
230
|
+
else if (live)
|
|
231
|
+
agent.inbox.remove(message.id);
|
|
232
|
+
target.task.history = target.task.history.filter((item) => item.messageId !== original.messageId);
|
|
233
|
+
target.headers.delete(message.id);
|
|
234
|
+
try {
|
|
235
|
+
if (live)
|
|
236
|
+
await this.flush(agent.session);
|
|
237
|
+
}
|
|
238
|
+
catch (rollbackError) {
|
|
239
|
+
console.error('[dsh-a2a] promotion rollback checkpoint failed:', rollbackError);
|
|
240
|
+
}
|
|
241
|
+
throw error;
|
|
242
|
+
}
|
|
243
|
+
if (this.stopping ||
|
|
244
|
+
this.contexts.get(run.entry.sessionId) !== run ||
|
|
245
|
+
this.bridge.isClearing(run.entry.sessionId))
|
|
246
|
+
throw new Error('Promotion was interrupted by context disposal.');
|
|
247
|
+
source.controller.abort();
|
|
248
|
+
try {
|
|
249
|
+
await this.checkpoint(target.task, target.context);
|
|
250
|
+
}
|
|
251
|
+
finally {
|
|
252
|
+
await this.cancel(source.task.id);
|
|
253
|
+
}
|
|
254
|
+
this.observeInput('promoted', run, promoted);
|
|
255
|
+
}
|
|
256
|
+
finally {
|
|
257
|
+
target.pendingRequests--;
|
|
258
|
+
this.settle(run, target);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
/** Repair only an incomplete ownership move, never an ordinary DSH claim. */
|
|
262
|
+
async recoverPromotions(run, context) {
|
|
263
|
+
const agent = run.entry.handle.agent;
|
|
264
|
+
const transfers = new Map();
|
|
265
|
+
for (const event of agent.session.snapshotEvents()) {
|
|
266
|
+
if (event.type !== 'agent/inbox/spliced')
|
|
267
|
+
continue;
|
|
268
|
+
for (const message of event.data.inserted) {
|
|
269
|
+
if (message.source.kind !== 'a2a' || !message.source.promotedFrom)
|
|
270
|
+
continue;
|
|
271
|
+
const previous = transfers.get(message.id);
|
|
272
|
+
transfers.set(message.id, {
|
|
273
|
+
message: message,
|
|
274
|
+
// A completed transfer stays complete after later public queue edits.
|
|
275
|
+
placed: event.data.target === message.source.target ||
|
|
276
|
+
!!(previous?.placed &&
|
|
277
|
+
previous.message.source.taskId === message.source.taskId &&
|
|
278
|
+
previous.message.source.promotionId === message.source.promotionId),
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
for (const { message, placed } of transfers.values()) {
|
|
283
|
+
if (!placed) {
|
|
284
|
+
const queued = this.pending(run).find((item) => item.id === message.id);
|
|
285
|
+
if (queued)
|
|
286
|
+
this.moveInput(agent, message, message.source.target);
|
|
287
|
+
else
|
|
288
|
+
agent.inbox.append(message.source.target, message);
|
|
289
|
+
}
|
|
290
|
+
if (this.pending(run).some((item) => item.id === message.id)) {
|
|
291
|
+
const recipient = await this.store.load(message.source.taskId, context);
|
|
292
|
+
if (!recipient || recipient.contextId !== agent.session.id)
|
|
293
|
+
throw new Error('Promoted A2A task is not accessible.');
|
|
294
|
+
const original = Message.fromJSON(JSON.parse(message.source.message));
|
|
295
|
+
if (!recipient.history.some((item) => item.messageId === original.messageId)) {
|
|
296
|
+
recipient.history.push(original);
|
|
297
|
+
await this.checkpoint(recipient, context);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
if (message.source.promotedFrom === message.source.taskId)
|
|
301
|
+
continue;
|
|
302
|
+
const former = await this.store.load(message.source.promotedFrom, context);
|
|
303
|
+
if (!former || this.terminal(former))
|
|
304
|
+
continue;
|
|
305
|
+
former.status = {
|
|
306
|
+
state: TaskState.TASK_STATE_CANCELED,
|
|
307
|
+
message: undefined,
|
|
308
|
+
timestamp: new Date().toISOString(),
|
|
309
|
+
};
|
|
310
|
+
await this.flush(agent.session);
|
|
311
|
+
await this.checkpoint(former, context);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
async recoverRetained(run, context) {
|
|
315
|
+
if (!this.retainPending)
|
|
316
|
+
return;
|
|
317
|
+
const agent = run.entry.handle.agent;
|
|
318
|
+
const committed = new Set(agent.session
|
|
319
|
+
.snapshotEvents()
|
|
320
|
+
.flatMap((event) => (event.type === 'user/message' ? [event.data.id] : [])));
|
|
321
|
+
const snapshots = [];
|
|
322
|
+
const inputs = [];
|
|
323
|
+
let pageToken = '';
|
|
324
|
+
do {
|
|
325
|
+
const page = await this.store.list({
|
|
326
|
+
tenant: '',
|
|
327
|
+
contextId: agent.session.id,
|
|
328
|
+
pageSize: 100,
|
|
329
|
+
pageToken,
|
|
330
|
+
status: TaskState.TASK_STATE_UNSPECIFIED,
|
|
331
|
+
statusTimestampAfter: undefined,
|
|
332
|
+
}, context);
|
|
333
|
+
for (const task of page.tasks) {
|
|
334
|
+
const retained = task.metadata?.[RETAINED_INPUTS];
|
|
335
|
+
if (typeof retained !== 'string')
|
|
336
|
+
continue;
|
|
337
|
+
snapshots.push(task);
|
|
338
|
+
if (this.terminal(task))
|
|
339
|
+
continue;
|
|
340
|
+
for (const input of JSON.parse(retained)) {
|
|
341
|
+
const { message } = input;
|
|
342
|
+
if (message.source.kind !== 'a2a' || message.source.taskId !== task.id)
|
|
343
|
+
throw new Error('Retained input has invalid task ownership.');
|
|
344
|
+
if (!committed.has(message.id))
|
|
345
|
+
inputs.push(input);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
pageToken = page.nextPageToken;
|
|
349
|
+
} while (pageToken);
|
|
350
|
+
for (const { message, target } of inputs.sort((a, b) => a.position - b.position)) {
|
|
351
|
+
if (message.source.kind !== 'a2a')
|
|
352
|
+
continue;
|
|
353
|
+
const existing = this.pending(run).find((pending) => pending.id === message.id);
|
|
354
|
+
if (existing) {
|
|
355
|
+
agent.inbox.replace(message.id, message);
|
|
356
|
+
const destination = target === 'next-step' ? agent.inbox.nextStep : agent.inbox.nextTurn;
|
|
357
|
+
if (!destination.some((pending) => pending.id === message.id))
|
|
358
|
+
this.moveInput(agent, message, target);
|
|
359
|
+
}
|
|
360
|
+
else
|
|
361
|
+
agent.inbox.append(target, message);
|
|
362
|
+
}
|
|
363
|
+
if (!snapshots.length)
|
|
364
|
+
return;
|
|
365
|
+
await this.flush(agent.session);
|
|
366
|
+
for (const task of snapshots) {
|
|
367
|
+
delete task.metadata[RETAINED_INPUTS];
|
|
368
|
+
await this.checkpoint(task, context);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
72
371
|
context(contextId, context = new ServerCallContext()) {
|
|
73
372
|
if (this.stopping)
|
|
74
373
|
return Promise.reject(new Error('Native inbox is unloading.'));
|
|
@@ -85,7 +384,12 @@ export class NativeInbox {
|
|
|
85
384
|
claimed: new Set(),
|
|
86
385
|
admissions: new Set(),
|
|
87
386
|
admissionTail: Promise.resolve(),
|
|
387
|
+
promotions: new Map(),
|
|
388
|
+
rolledBack: new Set(),
|
|
389
|
+
retained: { 'next-step': [], 'next-turn': [] },
|
|
88
390
|
};
|
|
391
|
+
await this.recoverRetained(run, context);
|
|
392
|
+
await this.recoverPromotions(run, context);
|
|
89
393
|
const snapshots = new Map();
|
|
90
394
|
for (const message of this.pending(run)) {
|
|
91
395
|
if (message.source.kind !== 'a2a' || snapshots.has(message.source.taskId))
|
|
@@ -101,6 +405,10 @@ export class NativeInbox {
|
|
|
101
405
|
await this.flush(entry.handle.agent.session);
|
|
102
406
|
for (const snapshot of snapshots.values())
|
|
103
407
|
this.restoreTask(run, snapshot, context);
|
|
408
|
+
run.retained = structuredClone({
|
|
409
|
+
'next-step': [...entry.handle.agent.inbox.nextStep],
|
|
410
|
+
'next-turn': [...entry.handle.agent.inbox.nextTurn],
|
|
411
|
+
});
|
|
104
412
|
this.contexts.set(contextId, run);
|
|
105
413
|
this.observe('resolved', run);
|
|
106
414
|
for (const message of this.pending(run))
|
|
@@ -113,7 +421,9 @@ export class NativeInbox {
|
|
|
113
421
|
}
|
|
114
422
|
observe(phase, run, fields = {}) {
|
|
115
423
|
try {
|
|
116
|
-
const pending = this.
|
|
424
|
+
const pending = this.stopping && this.retainPending
|
|
425
|
+
? [...run.retained['next-step'], ...run.retained['next-turn']]
|
|
426
|
+
: this.pending(run);
|
|
117
427
|
void this.ctx
|
|
118
428
|
.parallel('a2a/inbox', {
|
|
119
429
|
phase,
|
|
@@ -137,21 +447,22 @@ export class NativeInbox {
|
|
|
137
447
|
}
|
|
138
448
|
observeInput(phase, run, message, fields = {}) {
|
|
139
449
|
try {
|
|
140
|
-
|
|
450
|
+
const task = this.owner(run, message);
|
|
451
|
+
if (!task)
|
|
141
452
|
return;
|
|
142
|
-
const original =
|
|
453
|
+
const original = this.protocolMessage(run, task, message);
|
|
143
454
|
const position = this.pending(run).findIndex((input) => input.id === message.id);
|
|
144
455
|
this.observe(phase, run, {
|
|
145
|
-
target: message.source.target,
|
|
456
|
+
target: message.source.kind === 'a2a' ? message.source.target : 'next-turn',
|
|
146
457
|
input: original.parts
|
|
147
458
|
.flatMap((part) => (part.content?.$case === 'text' ? [part.content.value] : []))
|
|
148
459
|
.join('\n'),
|
|
149
460
|
position: position < 0 ? undefined : position + 1,
|
|
150
|
-
taskId:
|
|
461
|
+
taskId: task.task.id,
|
|
151
462
|
messageId: original.messageId,
|
|
152
463
|
referenceTaskIds: original.referenceTaskIds,
|
|
153
464
|
nativeMessageId: message.id,
|
|
154
|
-
queuedAt: message.source.queuedAt,
|
|
465
|
+
queuedAt: message.source.kind === 'a2a' ? message.source.queuedAt : undefined,
|
|
155
466
|
...fields,
|
|
156
467
|
});
|
|
157
468
|
}
|
|
@@ -416,7 +727,7 @@ export class NativeInbox {
|
|
|
416
727
|
bus.publish(task.final);
|
|
417
728
|
}
|
|
418
729
|
catch (error) {
|
|
419
|
-
if (task?.settled)
|
|
730
|
+
if (task?.settled || (this.stopping && this.retainPending))
|
|
420
731
|
return;
|
|
421
732
|
if (task && !ownsTask) {
|
|
422
733
|
bus.publish(AgentEvent.task(structuredClone(task.task)));
|
|
@@ -468,13 +779,67 @@ export class NativeInbox {
|
|
|
468
779
|
run.turnStart = undefined;
|
|
469
780
|
}
|
|
470
781
|
}
|
|
782
|
+
protocolMessage(run, task, message) {
|
|
783
|
+
if (message.source.kind === 'a2a')
|
|
784
|
+
return Message.fromJSON(JSON.parse(message.source.message));
|
|
785
|
+
return Message.fromJSON({
|
|
786
|
+
messageId: message.id,
|
|
787
|
+
taskId: task.task.id,
|
|
788
|
+
contextId: run.entry.sessionId,
|
|
789
|
+
role: 'ROLE_USER',
|
|
790
|
+
parts: message.content
|
|
791
|
+
.filter((block) => block.type === 'text')
|
|
792
|
+
.map((block) => ({ text: block.text })),
|
|
793
|
+
});
|
|
794
|
+
}
|
|
795
|
+
owner(run, message) {
|
|
796
|
+
if (message.source.kind === 'a2a')
|
|
797
|
+
return run.tasks.get(message.source.taskId);
|
|
798
|
+
if (message.source.kind !== 'goal' || message.source.round <= 0)
|
|
799
|
+
return;
|
|
800
|
+
const goal = this.ctx.get('goals')?.get(run.entry.handle.agent);
|
|
801
|
+
if (run.goalOwner &&
|
|
802
|
+
goal &&
|
|
803
|
+
run.goalOwner.goalId === message.source.goalId &&
|
|
804
|
+
goal.id === message.source.goalId &&
|
|
805
|
+
goal.revision === message.source.revision &&
|
|
806
|
+
goal.phase === 'active' &&
|
|
807
|
+
goal.activation === 'armed')
|
|
808
|
+
return run.goalOwner.task;
|
|
809
|
+
}
|
|
471
810
|
async prepare(detail) {
|
|
472
811
|
const run = this.contexts.get(detail.agent.session.id);
|
|
473
812
|
if (!run)
|
|
474
813
|
return;
|
|
475
814
|
if (this.stopping)
|
|
476
815
|
throw new Error('Inbox is unloading.');
|
|
477
|
-
|
|
816
|
+
for (let index = detail.messages.length - 1; index >= 0; index--) {
|
|
817
|
+
const message = detail.messages[index];
|
|
818
|
+
if (message.source.kind !== 'a2a' || !message.source.promotionId)
|
|
819
|
+
continue;
|
|
820
|
+
const promotion = run.promotions.get(message.id);
|
|
821
|
+
if (promotion) {
|
|
822
|
+
let onAbort;
|
|
823
|
+
try {
|
|
824
|
+
await Promise.race([
|
|
825
|
+
promotion.catch(() => { }),
|
|
826
|
+
new Promise((_resolve, reject) => {
|
|
827
|
+
onAbort = () => reject(detail.signal.reason);
|
|
828
|
+
detail.signal.addEventListener('abort', onAbort, { once: true });
|
|
829
|
+
if (detail.signal.aborted)
|
|
830
|
+
onAbort();
|
|
831
|
+
}),
|
|
832
|
+
]);
|
|
833
|
+
detail.signal.throwIfAborted();
|
|
834
|
+
}
|
|
835
|
+
finally {
|
|
836
|
+
detail.signal.removeEventListener('abort', onAbort);
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
if (run.rolledBack.delete(message.source.promotionId))
|
|
840
|
+
detail.messages.splice(index, 1);
|
|
841
|
+
}
|
|
842
|
+
if (!run.active && detail.messages.some((message) => !this.owner(run, message)))
|
|
478
843
|
throw new Error('Native host input requires task-attributed a2aInbox.enqueue().');
|
|
479
844
|
// Restarted steering and an ordinary queued turn may be claimed together by the core.
|
|
480
845
|
// Requeue other owners before committing any user message, keeping task lifecycles separate.
|
|
@@ -495,9 +860,9 @@ export class NativeInbox {
|
|
|
495
860
|
detail.messages.splice(index, 1);
|
|
496
861
|
}
|
|
497
862
|
for (const message of detail.messages) {
|
|
498
|
-
|
|
863
|
+
const task = this.owner(run, message);
|
|
864
|
+
if (!task && message.source.kind !== 'a2a')
|
|
499
865
|
continue;
|
|
500
|
-
const task = run.tasks.get(message.source.taskId);
|
|
501
866
|
if (!task || task.settled)
|
|
502
867
|
throw new Error('Queued input has no live A2A task; restore it before waking.');
|
|
503
868
|
if (run.active && run.active !== task)
|
|
@@ -511,7 +876,7 @@ export class NativeInbox {
|
|
|
511
876
|
agent: detail.agent,
|
|
512
877
|
contextId: detail.agent.session.id,
|
|
513
878
|
taskId: task.task.id,
|
|
514
|
-
a2aMessage:
|
|
879
|
+
a2aMessage: this.protocolMessage(run, task, message),
|
|
515
880
|
dshMessageId: message.id,
|
|
516
881
|
requestHeaders: task.headers.get(message.id),
|
|
517
882
|
signal: detail.signal,
|
|
@@ -607,6 +972,7 @@ export class NativeInbox {
|
|
|
607
972
|
}
|
|
608
973
|
else if (!task.settlementReady ||
|
|
609
974
|
task.pendingRequests ||
|
|
975
|
+
run.goalOwner?.task === task ||
|
|
610
976
|
run.active === task ||
|
|
611
977
|
this.pending(run).some((message) => message.source.kind === 'a2a' && message.source.taskId === task.task.id))
|
|
612
978
|
return;
|
|
@@ -738,6 +1104,7 @@ export class NativeInbox {
|
|
|
738
1104
|
];
|
|
739
1105
|
for (const admission of run.admissions)
|
|
740
1106
|
admission.abort();
|
|
1107
|
+
run.retained = { 'next-step': [], 'next-turn': [] };
|
|
741
1108
|
run.entry.handle.agent.inbox.clear();
|
|
742
1109
|
run.entry.handle.agent.cancel({ kind: 'user' });
|
|
743
1110
|
await run.entry.handle.agent.whenIdle();
|
|
@@ -754,7 +1121,7 @@ export class NativeInbox {
|
|
|
754
1121
|
}
|
|
755
1122
|
onDisposed(session) {
|
|
756
1123
|
const run = this.contexts.get(session.id);
|
|
757
|
-
if (!run)
|
|
1124
|
+
if (!run || (this.retainPending && this.stopping))
|
|
758
1125
|
return;
|
|
759
1126
|
for (const admission of run.admissions)
|
|
760
1127
|
admission.abort();
|
|
@@ -787,7 +1154,7 @@ export class NativeInbox {
|
|
|
787
1154
|
for (const admission of run.admissions)
|
|
788
1155
|
admission.abort();
|
|
789
1156
|
try {
|
|
790
|
-
run.entry.handle.agent.cancel({ kind: 'disposed' });
|
|
1157
|
+
run.entry.handle.agent.cancel({ kind: 'disposed' }, { keepInbox: this.retainPending });
|
|
791
1158
|
}
|
|
792
1159
|
catch (error) {
|
|
793
1160
|
failures.push(error);
|
|
@@ -799,7 +1166,11 @@ export class NativeInbox {
|
|
|
799
1166
|
failures.push(error);
|
|
800
1167
|
}
|
|
801
1168
|
try {
|
|
802
|
-
|
|
1169
|
+
// The persistence barrier tolerates writers already closed by parallel core teardown.
|
|
1170
|
+
if (this.retainPending && this.persistence)
|
|
1171
|
+
await this.persistence.flush();
|
|
1172
|
+
else
|
|
1173
|
+
await this.flush(run.entry.handle.agent.session);
|
|
803
1174
|
}
|
|
804
1175
|
catch (error) {
|
|
805
1176
|
failures.push(error);
|
|
@@ -807,8 +1178,40 @@ export class NativeInbox {
|
|
|
807
1178
|
finally {
|
|
808
1179
|
for (const task of run.tasks.values()) {
|
|
809
1180
|
try {
|
|
810
|
-
if (!task.settled)
|
|
811
|
-
|
|
1181
|
+
if (!task.settled) {
|
|
1182
|
+
if (this.retainPending) {
|
|
1183
|
+
const committed = new Set(run.entry.handle.agent.session
|
|
1184
|
+
.snapshotEvents()
|
|
1185
|
+
.flatMap((event) => (event.type === 'user/message' ? [event.data.id] : [])));
|
|
1186
|
+
let position = 0;
|
|
1187
|
+
const pending = ['next-step', 'next-turn'].flatMap((target) => run.retained[target].flatMap((message) => {
|
|
1188
|
+
const input = { message, target, position: position++ };
|
|
1189
|
+
return message.source.kind === 'a2a' &&
|
|
1190
|
+
message.source.taskId === task.task.id &&
|
|
1191
|
+
!committed.has(message.id)
|
|
1192
|
+
? [input]
|
|
1193
|
+
: [];
|
|
1194
|
+
}));
|
|
1195
|
+
if (pending.length) {
|
|
1196
|
+
task.task.metadata = {
|
|
1197
|
+
...task.task.metadata,
|
|
1198
|
+
[RETAINED_INPUTS]: JSON.stringify(pending),
|
|
1199
|
+
};
|
|
1200
|
+
await this.checkpoint(structuredClone(task.task), task.context);
|
|
1201
|
+
}
|
|
1202
|
+
else {
|
|
1203
|
+
task.final = terminalStatusUpdate(task.task.id, run.entry.sessionId, 'failed', 'Host stopped; active input will not be replayed.');
|
|
1204
|
+
if (task.final.kind === 'statusUpdate')
|
|
1205
|
+
task.task.status = task.final.data.status;
|
|
1206
|
+
await this.checkpoint(structuredClone(task.task), task.context);
|
|
1207
|
+
task.bus.publish(task.final);
|
|
1208
|
+
}
|
|
1209
|
+
task.settled = true;
|
|
1210
|
+
task.controller.abort();
|
|
1211
|
+
}
|
|
1212
|
+
else
|
|
1213
|
+
await this.cancelTask(run, task);
|
|
1214
|
+
}
|
|
812
1215
|
}
|
|
813
1216
|
catch (error) {
|
|
814
1217
|
failures.push(error);
|
package/lib/index.js
CHANGED
|
@@ -18,6 +18,7 @@ import { A2aBridge } from './bridge.js';
|
|
|
18
18
|
import { DshAgentExecutor } from './executor.js';
|
|
19
19
|
import { INBOX_EXTENSION, NativeInbox } from './inbox.js';
|
|
20
20
|
import { startA2aServer } from './server.js';
|
|
21
|
+
import { FilesystemTaskStore } from './stores/filesystem.js';
|
|
21
22
|
import { GcsTaskStore } from './stores/gcs.js';
|
|
22
23
|
import { RedisTaskStore } from './stores/redis.js';
|
|
23
24
|
import { MemoryTaskStore, SanitizedTaskStore } from './task-store.js';
|
|
@@ -29,7 +30,10 @@ export const name = 'dsh-a2a';
|
|
|
29
30
|
/** The bridge creates and owns agents through the registry service. */
|
|
30
31
|
export const inject = ['agents'];
|
|
31
32
|
export const Config = Schema.object({
|
|
32
|
-
inbox: Schema.object({
|
|
33
|
+
inbox: Schema.object({
|
|
34
|
+
enabled: Schema.boolean().default(false),
|
|
35
|
+
shutdown: Schema.union([Schema.const('cancel'), Schema.const('retain-pending')]).default('cancel'),
|
|
36
|
+
}).description('Use the official DSH inbox for queued turns and steering.'),
|
|
33
37
|
enabled: Schema.boolean().default(false),
|
|
34
38
|
host: Schema.string().default('127.0.0.1'),
|
|
35
39
|
port: Schema.natural().default(41241),
|
|
@@ -70,9 +74,11 @@ export const Config = Schema.object({
|
|
|
70
74
|
*/
|
|
71
75
|
taskStore: Schema.union([
|
|
72
76
|
Schema.const('memory'),
|
|
77
|
+
Schema.const('filesystem'),
|
|
73
78
|
Schema.const('redis'),
|
|
74
79
|
Schema.const('gcs'),
|
|
75
80
|
]).default('memory'),
|
|
81
|
+
filesystem: Schema.object({ root: Schema.string().default('') }),
|
|
76
82
|
redis: Schema.object({
|
|
77
83
|
url: Schema.string().role('secret').default('redis://127.0.0.1:6379'),
|
|
78
84
|
keyPrefix: Schema.string().default('a2a'),
|
|
@@ -111,7 +117,9 @@ export function apply(ctx, config) {
|
|
|
111
117
|
subagentTools: config.subagentTools,
|
|
112
118
|
});
|
|
113
119
|
const store = createTaskStore(config);
|
|
114
|
-
const inbox = config.inbox?.enabled
|
|
120
|
+
const inbox = config.inbox?.enabled
|
|
121
|
+
? new NativeInbox(ctx, bridge, store, config.inbox.shutdown === 'retain-pending')
|
|
122
|
+
: undefined;
|
|
115
123
|
await store.init?.();
|
|
116
124
|
if (inbox)
|
|
117
125
|
ctx.provide('a2aInbox', inbox);
|
|
@@ -198,6 +206,8 @@ export function apply(ctx, config) {
|
|
|
198
206
|
}
|
|
199
207
|
function createTaskStore(config) {
|
|
200
208
|
switch (config.taskStore) {
|
|
209
|
+
case 'filesystem':
|
|
210
|
+
return new SanitizedTaskStore(new FilesystemTaskStore({ root: config.filesystem?.root ?? '' }));
|
|
201
211
|
case 'redis':
|
|
202
212
|
return new SanitizedTaskStore(new RedisTaskStore(config.redis));
|
|
203
213
|
case 'gcs':
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/** Local task snapshots, retained alongside the DSH session log. */
|
|
2
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
3
|
+
import { mkdir, open, readdir, readFile, rename, rm } from 'node:fs/promises';
|
|
4
|
+
import { join, resolve } from 'node:path';
|
|
5
|
+
import { Task } from '@a2a-js/sdk';
|
|
6
|
+
import { listShells, taskScopeKey } from '../task-store.js';
|
|
7
|
+
export class FilesystemTaskStore {
|
|
8
|
+
root;
|
|
9
|
+
constructor(config) {
|
|
10
|
+
if (!config.root)
|
|
11
|
+
throw new Error('[dsh-a2a] filesystem.root is required.');
|
|
12
|
+
this.root = resolve(config.root);
|
|
13
|
+
}
|
|
14
|
+
directory(context) {
|
|
15
|
+
return join(this.root, taskScopeKey(context));
|
|
16
|
+
}
|
|
17
|
+
path(id, context) {
|
|
18
|
+
return join(this.directory(context), `${createHash('sha256').update(id).digest('hex')}.json`);
|
|
19
|
+
}
|
|
20
|
+
async save(task, context) {
|
|
21
|
+
const directory = this.directory(context);
|
|
22
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
23
|
+
const temporary = join(directory, `${randomUUID()}.tmp`);
|
|
24
|
+
try {
|
|
25
|
+
const file = await open(temporary, 'wx', 0o600);
|
|
26
|
+
try {
|
|
27
|
+
await file.writeFile(JSON.stringify(Task.toJSON(task)));
|
|
28
|
+
await file.sync();
|
|
29
|
+
}
|
|
30
|
+
finally {
|
|
31
|
+
await file.close();
|
|
32
|
+
}
|
|
33
|
+
await rename(temporary, this.path(task.id, context));
|
|
34
|
+
// Windows cannot open directory handles through fs.open.
|
|
35
|
+
if (process.platform !== 'win32') {
|
|
36
|
+
const dir = await open(directory, 'r');
|
|
37
|
+
try {
|
|
38
|
+
await dir.sync();
|
|
39
|
+
}
|
|
40
|
+
finally {
|
|
41
|
+
await dir.close();
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
finally {
|
|
46
|
+
await rm(temporary, { force: true });
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
async load(id, context) {
|
|
50
|
+
try {
|
|
51
|
+
return Task.fromJSON(JSON.parse(await readFile(this.path(id, context), 'utf8')));
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
if (error.code === 'ENOENT')
|
|
55
|
+
return;
|
|
56
|
+
throw error;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
async delete(id, context) {
|
|
60
|
+
await rm(this.path(id, context), { force: true });
|
|
61
|
+
}
|
|
62
|
+
async list(params, context) {
|
|
63
|
+
let files;
|
|
64
|
+
try {
|
|
65
|
+
files = await readdir(this.directory(context));
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
if (error.code !== 'ENOENT')
|
|
69
|
+
throw error;
|
|
70
|
+
files = [];
|
|
71
|
+
}
|
|
72
|
+
const tasks = [];
|
|
73
|
+
for (const file of files) {
|
|
74
|
+
if (!/^[a-f0-9]{64}\.json$/.test(file))
|
|
75
|
+
continue;
|
|
76
|
+
try {
|
|
77
|
+
tasks.push(Task.fromJSON(JSON.parse(await readFile(join(this.directory(context), file), 'utf8'))));
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
if (error.code !== 'ENOENT')
|
|
81
|
+
throw error;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return listShells(tasks, params);
|
|
85
|
+
}
|
|
86
|
+
}
|
package/lib/types/inbox.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { Message } from '@a2a-js/sdk';
|
|
|
2
2
|
import { type ExecutionEventBus, type RequestContext, ServerCallContext } from '@a2a-js/sdk/server';
|
|
3
3
|
import type { Context } from '@deepseek-ai/cordis';
|
|
4
4
|
import type { Agent, AssistantStreamFrame, InboxTarget } from '@deepseek-ai/dsh-agent';
|
|
5
|
+
import type { GoalMessageSource } from '@deepseek-ai/dsh-goal';
|
|
5
6
|
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types';
|
|
6
7
|
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session';
|
|
7
8
|
import type { A2aBridge } from './bridge.js';
|
|
@@ -35,10 +36,13 @@ interface InboxSource {
|
|
|
35
36
|
message: string;
|
|
36
37
|
target: InboxTarget;
|
|
37
38
|
queuedAt?: number;
|
|
39
|
+
promotedFrom?: string;
|
|
40
|
+
promotionId?: string;
|
|
38
41
|
}
|
|
39
42
|
declare module '@deepseek-ai/dsh-llm/types' {
|
|
40
43
|
interface MessageSourceMap {
|
|
41
44
|
a2a: InboxSource;
|
|
45
|
+
goal: GoalMessageSource;
|
|
42
46
|
}
|
|
43
47
|
}
|
|
44
48
|
declare module '@deepseek-ai/cordis' {
|
|
@@ -58,6 +62,8 @@ export interface A2aInbox {
|
|
|
58
62
|
target?: InboxTarget;
|
|
59
63
|
wakeup?: boolean;
|
|
60
64
|
}): Promise<void>;
|
|
65
|
+
/** Transfer one unclaimed independent input into the active task. */
|
|
66
|
+
promote(contextId: string, messageId: string, taskId: string): Promise<void>;
|
|
61
67
|
/** Standard cancellation is terminal and removes only this task's pending input. */
|
|
62
68
|
cancel(taskId: string): Promise<string | undefined>;
|
|
63
69
|
}
|
|
@@ -65,19 +71,28 @@ export declare class NativeInbox implements A2aInbox {
|
|
|
65
71
|
private readonly ctx;
|
|
66
72
|
private readonly bridge;
|
|
67
73
|
private readonly store;
|
|
74
|
+
private readonly retainPending;
|
|
68
75
|
private readonly contexts;
|
|
69
76
|
private readonly resolving;
|
|
70
77
|
private stopping;
|
|
71
78
|
private shutdownPromise?;
|
|
72
79
|
private readonly preadmitted;
|
|
73
80
|
server?: A2aServer;
|
|
74
|
-
|
|
81
|
+
private readonly persistence?;
|
|
82
|
+
constructor(ctx: Context, bridge: A2aBridge, store: ManagedTaskStore, retainPending?: boolean);
|
|
83
|
+
private unloading;
|
|
75
84
|
content(message: Message, contextId: string): Promise<ContentBlock[]>;
|
|
76
85
|
resolve(contextId: string): Promise<Agent>;
|
|
77
86
|
enqueue(taskId: string, message: Message, options?: {
|
|
78
87
|
target?: InboxTarget;
|
|
79
88
|
wakeup?: boolean;
|
|
80
89
|
}): Promise<void>;
|
|
90
|
+
private moveInput;
|
|
91
|
+
promote(contextId: string, messageId: string, taskId: string): Promise<void>;
|
|
92
|
+
private promoteInput;
|
|
93
|
+
/** Repair only an incomplete ownership move, never an ordinary DSH claim. */
|
|
94
|
+
private recoverPromotions;
|
|
95
|
+
private recoverRetained;
|
|
81
96
|
private context;
|
|
82
97
|
private observe;
|
|
83
98
|
private observeInput;
|
|
@@ -96,6 +111,8 @@ export declare class NativeInbox implements A2aInbox {
|
|
|
96
111
|
execute(request: RequestContext, bus: ExecutionEventBus): Promise<void>;
|
|
97
112
|
private restoreTask;
|
|
98
113
|
private select;
|
|
114
|
+
private protocolMessage;
|
|
115
|
+
private owner;
|
|
99
116
|
private prepare;
|
|
100
117
|
private publish;
|
|
101
118
|
onSessionEvent(session: Session, event: SessionEvent): boolean;
|
package/lib/types/index.d.ts
CHANGED
|
@@ -71,8 +71,10 @@ export declare const inject: string[];
|
|
|
71
71
|
export declare const Config: Schema<Schemastery.ObjectS<{
|
|
72
72
|
inbox: Schema<Schemastery.ObjectS<{
|
|
73
73
|
enabled: Schema<boolean, boolean>;
|
|
74
|
+
shutdown: Schema<"cancel" | "retain-pending", "cancel" | "retain-pending">;
|
|
74
75
|
}>, Schemastery.ObjectT<{
|
|
75
76
|
enabled: Schema<boolean, boolean>;
|
|
77
|
+
shutdown: Schema<"cancel" | "retain-pending", "cancel" | "retain-pending">;
|
|
76
78
|
}>>;
|
|
77
79
|
enabled: Schema<boolean, boolean>;
|
|
78
80
|
host: Schema<string, string>;
|
|
@@ -131,7 +133,12 @@ export declare const Config: Schema<Schemastery.ObjectS<{
|
|
|
131
133
|
* A2A task snapshots, including protocol history. dsh-storage separately
|
|
132
134
|
* owns the full conversation log. 'memory' loses tasks on restart.
|
|
133
135
|
*/
|
|
134
|
-
taskStore: Schema<"memory" | "redis" | "gcs", "memory" | "redis" | "gcs">;
|
|
136
|
+
taskStore: Schema<"memory" | "filesystem" | "redis" | "gcs", "memory" | "filesystem" | "redis" | "gcs">;
|
|
137
|
+
filesystem: Schema<Schemastery.ObjectS<{
|
|
138
|
+
root: Schema<string, string>;
|
|
139
|
+
}>, Schemastery.ObjectT<{
|
|
140
|
+
root: Schema<string, string>;
|
|
141
|
+
}>>;
|
|
135
142
|
redis: Schema<Schemastery.ObjectS<{
|
|
136
143
|
url: Schema<string, string>;
|
|
137
144
|
keyPrefix: Schema<string, string>;
|
|
@@ -153,8 +160,10 @@ export declare const Config: Schema<Schemastery.ObjectS<{
|
|
|
153
160
|
}>, Schemastery.ObjectT<{
|
|
154
161
|
inbox: Schema<Schemastery.ObjectS<{
|
|
155
162
|
enabled: Schema<boolean, boolean>;
|
|
163
|
+
shutdown: Schema<"cancel" | "retain-pending", "cancel" | "retain-pending">;
|
|
156
164
|
}>, Schemastery.ObjectT<{
|
|
157
165
|
enabled: Schema<boolean, boolean>;
|
|
166
|
+
shutdown: Schema<"cancel" | "retain-pending", "cancel" | "retain-pending">;
|
|
158
167
|
}>>;
|
|
159
168
|
enabled: Schema<boolean, boolean>;
|
|
160
169
|
host: Schema<string, string>;
|
|
@@ -213,7 +222,12 @@ export declare const Config: Schema<Schemastery.ObjectS<{
|
|
|
213
222
|
* A2A task snapshots, including protocol history. dsh-storage separately
|
|
214
223
|
* owns the full conversation log. 'memory' loses tasks on restart.
|
|
215
224
|
*/
|
|
216
|
-
taskStore: Schema<"memory" | "redis" | "gcs", "memory" | "redis" | "gcs">;
|
|
225
|
+
taskStore: Schema<"memory" | "filesystem" | "redis" | "gcs", "memory" | "filesystem" | "redis" | "gcs">;
|
|
226
|
+
filesystem: Schema<Schemastery.ObjectS<{
|
|
227
|
+
root: Schema<string, string>;
|
|
228
|
+
}>, Schemastery.ObjectT<{
|
|
229
|
+
root: Schema<string, string>;
|
|
230
|
+
}>>;
|
|
217
231
|
redis: Schema<Schemastery.ObjectS<{
|
|
218
232
|
url: Schema<string, string>;
|
|
219
233
|
keyPrefix: Schema<string, string>;
|
|
@@ -236,6 +250,7 @@ export declare const Config: Schema<Schemastery.ObjectS<{
|
|
|
236
250
|
export interface A2aPluginConfig {
|
|
237
251
|
inbox?: {
|
|
238
252
|
enabled: boolean;
|
|
253
|
+
shutdown?: 'cancel' | 'retain-pending';
|
|
239
254
|
};
|
|
240
255
|
enabled: boolean;
|
|
241
256
|
host: string;
|
|
@@ -261,7 +276,10 @@ export interface A2aPluginConfig {
|
|
|
261
276
|
version: string;
|
|
262
277
|
publicUrl: string;
|
|
263
278
|
};
|
|
264
|
-
taskStore: 'memory' | 'redis' | 'gcs';
|
|
279
|
+
taskStore: 'memory' | 'filesystem' | 'redis' | 'gcs';
|
|
280
|
+
filesystem?: {
|
|
281
|
+
root: string;
|
|
282
|
+
};
|
|
265
283
|
redis: {
|
|
266
284
|
url: string;
|
|
267
285
|
keyPrefix: string;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { type ListTasksRequest, type ListTasksResponse, Task } from '@a2a-js/sdk';
|
|
2
|
+
import type { ServerCallContext } from '@a2a-js/sdk/server';
|
|
3
|
+
import { type ManagedTaskStore } from '../task-store.js';
|
|
4
|
+
export declare class FilesystemTaskStore implements ManagedTaskStore {
|
|
5
|
+
private readonly root;
|
|
6
|
+
constructor(config: {
|
|
7
|
+
root: string;
|
|
8
|
+
});
|
|
9
|
+
private directory;
|
|
10
|
+
private path;
|
|
11
|
+
save(task: Task, context: ServerCallContext): Promise<void>;
|
|
12
|
+
load(id: string, context: ServerCallContext): Promise<Task | undefined>;
|
|
13
|
+
delete(id: string, context: ServerCallContext): Promise<void>;
|
|
14
|
+
list(params: ListTasksRequest, context: ServerCallContext): Promise<ListTasksResponse>;
|
|
15
|
+
}
|
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.29",
|
|
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,13 +39,15 @@
|
|
|
39
39
|
"peerDependencies": {
|
|
40
40
|
"@deepseek-ai/cordis": "^4.0.1",
|
|
41
41
|
"@deepseek-ai/dsh-agent": ">=0.2.0-rc.2 || >=0.2.1-alpha.1",
|
|
42
|
-
"@deepseek-ai/dsh-attachment": ">=0.1.5-rc.1 || >=0.1.6-alpha.1 || >=0.1.7-alpha.1 || >=0.2.0-rc.1",
|
|
43
|
-
"@deepseek-ai/dsh-llm": ">=0.1.5-rc.1 || >=0.1.6-alpha.1 || >=0.1.7-alpha.1 || >=0.2.0-rc.1",
|
|
44
|
-
"@deepseek-ai/dsh-session": ">=0.2.0-rc.1",
|
|
45
|
-
"@deepseek-ai/dsh-tools": ">=0.1.5-rc.1 || >=0.1.6-alpha.1 || >=0.1.7-alpha.1 || >=0.2.0-rc.1",
|
|
46
|
-
"@deepseek-ai/dsh-user-approval": ">=0.1.6-alpha.2 || >=0.1.7-alpha.1 || >=0.2.0-rc.1",
|
|
42
|
+
"@deepseek-ai/dsh-attachment": ">=0.1.5-rc.1 || >=0.1.6-alpha.1 || >=0.1.7-alpha.1 || >=0.2.0-rc.1 || >=0.2.1-rc.1",
|
|
43
|
+
"@deepseek-ai/dsh-llm": ">=0.1.5-rc.1 || >=0.1.6-alpha.1 || >=0.1.7-alpha.1 || >=0.2.0-rc.1 || >=0.2.1-rc.1",
|
|
44
|
+
"@deepseek-ai/dsh-session": ">=0.2.0-rc.1 || >=0.2.1-rc.1",
|
|
45
|
+
"@deepseek-ai/dsh-tools": ">=0.1.5-rc.1 || >=0.1.6-alpha.1 || >=0.1.7-alpha.1 || >=0.2.0-rc.1 || >=0.2.1-rc.1",
|
|
46
|
+
"@deepseek-ai/dsh-user-approval": ">=0.1.6-alpha.2 || >=0.1.7-alpha.1 || >=0.2.0-rc.1 || >=0.2.1-rc.1",
|
|
47
47
|
"@google-cloud/storage": "^7.0.0",
|
|
48
|
-
"ioredis": "^5.0.0"
|
|
48
|
+
"ioredis": "^5.0.0",
|
|
49
|
+
"@deepseek-ai/dsh-goal": ">=0.2.0-rc.2 || >=0.2.1-alpha.1",
|
|
50
|
+
"@deepseek-ai/dsh-session-persistence": ">=0.2.0-rc.2 || >=0.2.1-alpha.1"
|
|
49
51
|
},
|
|
50
52
|
"peerDependenciesMeta": {
|
|
51
53
|
"@deepseek-ai/dsh-attachment": {
|
|
@@ -59,6 +61,12 @@
|
|
|
59
61
|
},
|
|
60
62
|
"ioredis": {
|
|
61
63
|
"optional": true
|
|
64
|
+
},
|
|
65
|
+
"@deepseek-ai/dsh-goal": {
|
|
66
|
+
"optional": true
|
|
67
|
+
},
|
|
68
|
+
"@deepseek-ai/dsh-session-persistence": {
|
|
69
|
+
"optional": true
|
|
62
70
|
}
|
|
63
71
|
},
|
|
64
72
|
"devDependencies": {
|
|
@@ -76,7 +84,9 @@
|
|
|
76
84
|
"@types/node": "^22.0.0",
|
|
77
85
|
"ioredis": "^5.0.0",
|
|
78
86
|
"typescript": "^5.6.3",
|
|
79
|
-
"vitest": "^4.1.11"
|
|
87
|
+
"vitest": "^4.1.11",
|
|
88
|
+
"@deepseek-ai/dsh-goal": "0.2.1-rc.2",
|
|
89
|
+
"@deepseek-ai/dsh-session-persistence": "0.2.1-rc.2"
|
|
80
90
|
},
|
|
81
91
|
"engines": {
|
|
82
92
|
"node": "^20.19 || ^22.12 || >=24.0"
|