@amaster.ai/dsh-a2a 0.1.36 → 0.1.38
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 +11 -8
- package/lib/inbox-api.js +3 -6
- package/lib/inbox.js +109 -61
- package/lib/types/inbox.d.ts +0 -7
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -16,11 +16,13 @@ dsh plugin --profile my-agent add @amaster.ai/dsh-a2a
|
|
|
16
16
|
|
|
17
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 task subscriptions keep their official meanings.
|
|
19
|
+
Standard `SendMessage` / `SendStreamingMessage`, task queries, cancellation and task subscriptions keep their official meanings. Optional inbox metadata 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
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
22
|
|
|
23
|
-
|
|
23
|
+
Pause is process-local and writes no custom required session event. After restart, restored input stays parked until a waking admission or explicit execution. Retention recovery accepts snapshots under the historical extension URI as well as the current URI, writes only the current metadata key, and clears both keys after consumption. Explicit resume durably transfers input to a fresh task before waking it; retry after an interrupted cleanup reuses the existing replacement instead of replaying its input.
|
|
24
|
+
|
|
25
|
+
For external clients, the [native inbox extension specification](#native-inbox-extension-v1) defines `inbox/get`, `update`, `move`, `remove`, `clear`, `execute`, `steer`, and `subscribe` at the advertised JSONRPC endpoint. No `A2A-Extensions` header or extension activation is required. 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
26
|
|
|
25
27
|
`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
28
|
|
|
@@ -79,14 +81,15 @@ DSH_NATIVE_OWNERSHIP_LANGFUSE_LIVE=true node .github/scripts/check-inbox.mjs -t
|
|
|
79
81
|
|
|
80
82
|
Protocol identifier: `https://github.com/TGYD-helige/dsh-plugins/blob/main/packages/dsh-a2a/README.md`
|
|
81
83
|
|
|
82
|
-
This README contains the extension specification. The URI
|
|
83
|
-
|
|
84
|
+
This README contains the extension specification. The URI remains a metadata
|
|
85
|
+
namespace and discovery identifier for compatibility; callers do not need to send it.
|
|
84
86
|
|
|
85
87
|
This is a project extension, not an official A2A extension. It follows the
|
|
86
88
|
[A2A extension mechanism](https://a2a-protocol.org/v1.0.1/topics/extensions/).
|
|
87
|
-
The Agent Card advertises it when `inbox.enabled` is true.
|
|
88
|
-
`A2A-Extensions`
|
|
89
|
-
states, subscriptions and Push
|
|
89
|
+
The Agent Card advertises it when `inbox.enabled` is true. Inbox methods and
|
|
90
|
+
optional admission controls do not require an `A2A-Extensions` header or an
|
|
91
|
+
activation handshake. Standard task methods, states, subscriptions and Push
|
|
92
|
+
configurations keep their official meanings.
|
|
90
93
|
|
|
91
94
|
### Methods
|
|
92
95
|
|
|
@@ -175,7 +178,7 @@ Closing the plugin unregisters observers/subscriptions and suppresses late sends
|
|
|
175
178
|
|
|
176
179
|
Standard SendMessage/SendStreamingMessage continues to accept
|
|
177
180
|
`metadata[URI] = {target: 'next-turn' | 'next-step', wakeup: boolean}` on its
|
|
178
|
-
Message,
|
|
181
|
+
Message, without extension negotiation. Prefer those standard methods for new input.
|
|
179
182
|
The operations above control already queued input; they do not add another
|
|
180
183
|
queue, consumer, task-state machine or webhook configuration store.
|
|
181
184
|
|
package/lib/inbox-api.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/** JSON-RPC method extension; standard task methods stay with the official SDK. */
|
|
2
|
-
import { A2A_PROTOCOL_VERSION, A2A_VERSION_HEADER,
|
|
3
|
-
import {
|
|
2
|
+
import { A2A_PROTOCOL_VERSION, A2A_VERSION_HEADER, Message } from '@a2a-js/sdk';
|
|
3
|
+
import { RequestMalformedError, toJsonRpcError } from '@a2a-js/sdk/errors';
|
|
4
4
|
import { validateVersion } from '@a2a-js/sdk/server';
|
|
5
|
-
import {
|
|
5
|
+
import { validateInboxPage } from './inbox.js';
|
|
6
6
|
export function inboxRpcHandler(inbox, card) {
|
|
7
7
|
return async (req, res, next) => {
|
|
8
8
|
const body = req.body;
|
|
@@ -12,9 +12,6 @@ export function inboxRpcHandler(inbox, card) {
|
|
|
12
12
|
if (body.jsonrpc !== '2.0' || !['string', 'number'].includes(typeof body.id))
|
|
13
13
|
throw new RequestMalformedError('A JSON-RPC 2.0 request id is required.');
|
|
14
14
|
validateVersion(req.get(A2A_VERSION_HEADER) || A2A_PROTOCOL_VERSION, card, 'JSONRPC');
|
|
15
|
-
if (!Extensions.parseServiceParameter(req.get(HTTP_EXTENSION_HEADER)).includes(INBOX_EXTENSION))
|
|
16
|
-
throw new ExtensionSupportRequiredError('Activate the native inbox extension.');
|
|
17
|
-
res.setHeader(HTTP_EXTENSION_HEADER, INBOX_EXTENSION);
|
|
18
15
|
const params = body.params;
|
|
19
16
|
if (!params ||
|
|
20
17
|
typeof params !== 'object' ||
|
package/lib/inbox.js
CHANGED
|
@@ -9,6 +9,16 @@ import { commitInboxInput, markInboxInputPending } from './task-store.js';
|
|
|
9
9
|
import { agentTextMessage, SessionTranslator, terminalStatusUpdate } from './translator.js';
|
|
10
10
|
export const INBOX_EXTENSION = 'https://github.com/TGYD-helige/dsh-plugins/blob/main/packages/dsh-a2a/README.md';
|
|
11
11
|
const RETAINED_INPUTS = `${INBOX_EXTENSION}#retained-inputs`;
|
|
12
|
+
const LEGACY_RETAINED_INPUTS = 'https://github.com/TGYD-helige/dsh-plugins/blob/main/docs/protocol/inbox-v1.md#retained-inputs';
|
|
13
|
+
function retainedSnapshot(task) {
|
|
14
|
+
return task.metadata?.[RETAINED_INPUTS] ?? task.metadata?.[LEGACY_RETAINED_INPUTS];
|
|
15
|
+
}
|
|
16
|
+
function clearRetainedSnapshot(task) {
|
|
17
|
+
if (!task.metadata)
|
|
18
|
+
return;
|
|
19
|
+
delete task.metadata[RETAINED_INPUTS];
|
|
20
|
+
delete task.metadata[LEGACY_RETAINED_INPUTS];
|
|
21
|
+
}
|
|
12
22
|
export function validateInboxPage(page) {
|
|
13
23
|
if (page === undefined)
|
|
14
24
|
return;
|
|
@@ -508,56 +518,103 @@ export class NativeInbox {
|
|
|
508
518
|
}
|
|
509
519
|
async resume(contextId, taskId) {
|
|
510
520
|
const context = new ServerCallContext();
|
|
511
|
-
const previous = await this.store.load(taskId, context);
|
|
512
|
-
if (!previous || previous.contextId !== contextId || !this.terminal(previous))
|
|
513
|
-
throw new Error('Resume requires a terminal retained task.');
|
|
514
|
-
const retained = previous.metadata?.[RETAINED_INPUTS];
|
|
515
|
-
if (typeof retained !== 'string')
|
|
516
|
-
throw new Error('Task has no retained input to resume.');
|
|
517
|
-
const inputs = JSON.parse(retained);
|
|
518
|
-
if (!inputs.length)
|
|
519
|
-
throw new Error('Task has no retained input to resume.');
|
|
520
521
|
const run = await this.context(contextId, context);
|
|
521
|
-
const
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
message
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
522
|
+
const operation = run.admissionTail.then(async () => {
|
|
523
|
+
if (this.stopping || this.bridge.isClearing(contextId))
|
|
524
|
+
throw new Error('Context is unavailable.');
|
|
525
|
+
const previous = await this.store.load(taskId, context);
|
|
526
|
+
if (!previous || previous.contextId !== contextId || !this.terminal(previous))
|
|
527
|
+
throw new Error('Resume requires a terminal retained task.');
|
|
528
|
+
const retained = retainedSnapshot(previous);
|
|
529
|
+
if (typeof retained !== 'string')
|
|
530
|
+
throw new Error('Task has no retained input to resume.');
|
|
531
|
+
const inputs = JSON.parse(retained);
|
|
532
|
+
if (!inputs.length)
|
|
533
|
+
throw new Error('Task has no retained input to resume.');
|
|
534
|
+
const nativeIds = new Set(inputs.map((input) => input.message.id));
|
|
535
|
+
const resumed = new Map();
|
|
536
|
+
const messages = [
|
|
537
|
+
...run.entry.handle.agent.session
|
|
538
|
+
.snapshotEvents()
|
|
539
|
+
.flatMap((event) => (event.type === 'user/message' ? [event.data] : [])),
|
|
540
|
+
...this.pending(run),
|
|
541
|
+
...[...run.tasks.values()].flatMap((task) => task.claimedInputs ?? []),
|
|
542
|
+
];
|
|
543
|
+
for (const message of messages) {
|
|
544
|
+
if (!nativeIds.has(message.id) || message.source.kind !== 'a2a')
|
|
545
|
+
continue;
|
|
546
|
+
const original = Message.fromJSON(JSON.parse(message.source.message));
|
|
547
|
+
if (message.source.taskId !== taskId && original.referenceTaskIds.includes(taskId))
|
|
548
|
+
resumed.set(message.id, message.source.taskId);
|
|
549
|
+
}
|
|
550
|
+
if (resumed.size) {
|
|
551
|
+
const replacements = new Set(resumed.values());
|
|
552
|
+
if (resumed.size !== nativeIds.size || replacements.size !== 1)
|
|
553
|
+
throw new Error('Retained input has already been partially resumed.');
|
|
554
|
+
const id = resumed.values().next().value;
|
|
555
|
+
await this.flush(run.entry.handle.agent.session);
|
|
556
|
+
const replacement = run.tasks.get(id)?.task ?? (await this.store.load(id, context));
|
|
557
|
+
if (!replacement)
|
|
558
|
+
throw new Error('Resumed task is unavailable.');
|
|
559
|
+
clearRetainedSnapshot(replacement);
|
|
560
|
+
await this.checkpoint(replacement, context);
|
|
561
|
+
clearRetainedSnapshot(previous);
|
|
562
|
+
await this.checkpoint(previous, context);
|
|
563
|
+
if (this.pending(run).some((message) => message.source.kind === 'a2a' && message.source.taskId === id))
|
|
564
|
+
await this.wake(contextId);
|
|
565
|
+
return id;
|
|
566
|
+
}
|
|
567
|
+
const id = randomUUID();
|
|
568
|
+
const replacement = {
|
|
569
|
+
...previous,
|
|
570
|
+
id,
|
|
571
|
+
status: {
|
|
572
|
+
state: TaskState.TASK_STATE_SUBMITTED,
|
|
573
|
+
timestamp: new Date().toISOString(),
|
|
574
|
+
message: undefined,
|
|
550
575
|
},
|
|
576
|
+
history: [],
|
|
577
|
+
metadata: { ...previous.metadata },
|
|
551
578
|
};
|
|
579
|
+
clearRetainedSnapshot(replacement);
|
|
580
|
+
const retainedInputs = inputs.map((input) => {
|
|
581
|
+
if (input.message.source.kind !== 'a2a')
|
|
582
|
+
throw new Error('Retained input has invalid task ownership.');
|
|
583
|
+
const original = Message.fromJSON(JSON.parse(input.message.source.message));
|
|
584
|
+
original.taskId = id;
|
|
585
|
+
original.referenceTaskIds = [...new Set([...original.referenceTaskIds, taskId])];
|
|
586
|
+
replacement.history.push(markInboxInputPending(original));
|
|
587
|
+
return {
|
|
588
|
+
...input,
|
|
589
|
+
message: {
|
|
590
|
+
...input.message,
|
|
591
|
+
source: {
|
|
592
|
+
...input.message.source,
|
|
593
|
+
taskId: id,
|
|
594
|
+
message: JSON.stringify(Message.toJSON(original)),
|
|
595
|
+
},
|
|
596
|
+
},
|
|
597
|
+
};
|
|
598
|
+
});
|
|
599
|
+
replacement.metadata[RETAINED_INPUTS] = JSON.stringify(retainedInputs);
|
|
600
|
+
await this.checkpoint(replacement, context);
|
|
601
|
+
for (const input of retainedInputs) {
|
|
602
|
+
if (this.pending(run).some((pending) => pending.id === input.message.id))
|
|
603
|
+
run.entry.handle.agent.inbox.remove(input.message.id);
|
|
604
|
+
run.entry.handle.agent.send(input.message, input.target, false);
|
|
605
|
+
}
|
|
606
|
+
this.restoreTask(run, replacement, context);
|
|
607
|
+
// Consume the old snapshot only after its replacement input is durable.
|
|
608
|
+
await this.flush(run.entry.handle.agent.session);
|
|
609
|
+
clearRetainedSnapshot(replacement);
|
|
610
|
+
await this.checkpoint(replacement, context);
|
|
611
|
+
clearRetainedSnapshot(previous);
|
|
612
|
+
await this.checkpoint(previous, context);
|
|
613
|
+
await this.wake(contextId);
|
|
614
|
+
return id;
|
|
552
615
|
});
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
if (this.pending(run).some((pending) => pending.id === input.message.id))
|
|
556
|
-
run.entry.handle.agent.inbox.remove(input.message.id);
|
|
557
|
-
run.entry.handle.agent.send(input.message, input.target, index === retainedInputs.length - 1);
|
|
558
|
-
}
|
|
559
|
-
this.restoreTask(run, replacement, context);
|
|
560
|
-
return id;
|
|
616
|
+
run.admissionTail = operation.then(() => { }, () => { });
|
|
617
|
+
return operation;
|
|
561
618
|
}
|
|
562
619
|
/** Repair only an incomplete ownership move, never an ordinary DSH claim. */
|
|
563
620
|
async recoverPromotions(run, context) {
|
|
@@ -632,7 +689,7 @@ export class NativeInbox {
|
|
|
632
689
|
statusTimestampAfter: undefined,
|
|
633
690
|
}, context);
|
|
634
691
|
for (const task of page.tasks) {
|
|
635
|
-
const retained = task
|
|
692
|
+
const retained = retainedSnapshot(task);
|
|
636
693
|
if (typeof retained !== 'string')
|
|
637
694
|
continue;
|
|
638
695
|
// Terminal snapshots belong to explicit resume(); do not consume their
|
|
@@ -736,7 +793,7 @@ export class NativeInbox {
|
|
|
736
793
|
return;
|
|
737
794
|
await this.flush(agent.session);
|
|
738
795
|
for (const task of snapshots) {
|
|
739
|
-
|
|
796
|
+
clearRetainedSnapshot(task);
|
|
740
797
|
await this.checkpoint(task, context);
|
|
741
798
|
}
|
|
742
799
|
}
|
|
@@ -762,11 +819,6 @@ export class NativeInbox {
|
|
|
762
819
|
rolledBack: new Set(),
|
|
763
820
|
retained: { 'next-step': [], 'next-turn': [] },
|
|
764
821
|
};
|
|
765
|
-
const pause = [...run.entry.handle.agent.session.snapshotEvents()]
|
|
766
|
-
.reverse()
|
|
767
|
-
.find((event) => event.type === 'agent/inbox/paused');
|
|
768
|
-
if (pause)
|
|
769
|
-
run.paused = pause.data.paused;
|
|
770
822
|
await this.recoverRetained(run, context);
|
|
771
823
|
await this.recoverPromotions(run, context);
|
|
772
824
|
const snapshots = new Map();
|
|
@@ -899,12 +951,10 @@ export class NativeInbox {
|
|
|
899
951
|
context: new ServerCallContext({ tenant: context.tenant, user: context.user }),
|
|
900
952
|
};
|
|
901
953
|
}
|
|
902
|
-
controls(message
|
|
954
|
+
controls(message) {
|
|
903
955
|
const value = message.metadata?.[INBOX_EXTENSION];
|
|
904
956
|
if (value === undefined)
|
|
905
957
|
return { target: 'next-turn', wakeup: true };
|
|
906
|
-
if (!context.requestedExtensions?.includes(INBOX_EXTENSION))
|
|
907
|
-
throw new Error('Inbox controls require A2A-Extensions negotiation.');
|
|
908
958
|
if (!value ||
|
|
909
959
|
typeof value !== 'object' ||
|
|
910
960
|
Array.isArray(value) ||
|
|
@@ -914,7 +964,6 @@ export class NativeInbox {
|
|
|
914
964
|
throw new Error('Invalid inbox target.');
|
|
915
965
|
if (value.wakeup !== undefined && typeof value.wakeup !== 'boolean')
|
|
916
966
|
throw new Error('Invalid inbox wakeup.');
|
|
917
|
-
context.addActivatedExtension(INBOX_EXTENSION);
|
|
918
967
|
return {
|
|
919
968
|
target: value.target === 'next-step' ? 'next-step' : 'next-turn',
|
|
920
969
|
wakeup: value.wakeup !== false,
|
|
@@ -924,7 +973,7 @@ export class NativeInbox {
|
|
|
924
973
|
async beforeMessage(message, context) {
|
|
925
974
|
if (!message.messageId)
|
|
926
975
|
throw new Error('message.messageId is required.');
|
|
927
|
-
const controls = this.controls(message
|
|
976
|
+
const controls = this.controls(message);
|
|
928
977
|
if (!message.taskId) {
|
|
929
978
|
if (controls.target === 'next-step')
|
|
930
979
|
throw new Error('Steering must address an active task.');
|
|
@@ -1097,7 +1146,7 @@ export class NativeInbox {
|
|
|
1097
1146
|
let task;
|
|
1098
1147
|
let ownsTask = false;
|
|
1099
1148
|
try {
|
|
1100
|
-
const controls = this.controls(userMessage
|
|
1149
|
+
const controls = this.controls(userMessage);
|
|
1101
1150
|
const run = await this.context(contextId, context);
|
|
1102
1151
|
task = run.tasks.get(taskId);
|
|
1103
1152
|
if (!task) {
|
|
@@ -1548,7 +1597,6 @@ export class NativeInbox {
|
|
|
1548
1597
|
if (run.paused)
|
|
1549
1598
|
return;
|
|
1550
1599
|
run.paused = true;
|
|
1551
|
-
run.entry.handle.agent.session.append('agent/inbox/paused', { paused: true });
|
|
1552
1600
|
for (const admission of run.admissions)
|
|
1553
1601
|
admission.abort();
|
|
1554
1602
|
run.entry.handle.agent.cancel({ kind: 'user' }, { keepInbox: true });
|
|
@@ -1560,7 +1608,6 @@ export class NativeInbox {
|
|
|
1560
1608
|
const agent = run.entry.handle.agent;
|
|
1561
1609
|
const pending = this.pending(run);
|
|
1562
1610
|
run.paused = false;
|
|
1563
|
-
agent.session.append('agent/inbox/paused', { paused: false });
|
|
1564
1611
|
const target = agent.inbox.nextTurn.length ? 'next-turn' : 'next-step';
|
|
1565
1612
|
const items = target === 'next-turn' ? agent.inbox.nextTurn : agent.inbox.nextStep;
|
|
1566
1613
|
const input = items.at(-1);
|
|
@@ -1732,6 +1779,7 @@ export class NativeInbox {
|
|
|
1732
1779
|
if (this.retainPending) {
|
|
1733
1780
|
const pending = retainedByTask.get(task.task.id) ?? [];
|
|
1734
1781
|
if (pending.length) {
|
|
1782
|
+
clearRetainedSnapshot(task.task);
|
|
1735
1783
|
task.task.metadata = {
|
|
1736
1784
|
...task.task.metadata,
|
|
1737
1785
|
[RETAINED_INPUTS]: JSON.stringify(pending),
|
package/lib/types/inbox.d.ts
CHANGED
|
@@ -46,13 +46,6 @@ declare module '@deepseek-ai/dsh-llm/types' {
|
|
|
46
46
|
goal: GoalMessageSource;
|
|
47
47
|
}
|
|
48
48
|
}
|
|
49
|
-
declare module '@deepseek-ai/dsh-session/types' {
|
|
50
|
-
interface SessionEventMap {
|
|
51
|
-
'agent/inbox/paused': {
|
|
52
|
-
paused: boolean;
|
|
53
|
-
};
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
49
|
declare module '@deepseek-ai/cordis' {
|
|
57
50
|
interface Events {
|
|
58
51
|
/** Native inbox lifecycle; content consumers must honor their capture settings. */
|
package/package.json
CHANGED