@amaster.ai/dsh-a2a 0.1.20 → 0.1.22

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
@@ -30,6 +30,8 @@ Disabled by default. Configure via the profile's `cordis.patch.yml`:
30
30
  intervalMs: 15000 # positive integer, at most 2147483647
31
31
  cwd: /srv/agent-workspaces
32
32
  uploadsDir: '' # file-part upload root; empty = <OS temp>/dsh-a2a-uploads/<date>
33
+ subagentTools:
34
+ enabled: false # opt in to attributed local descendant tool calls/results
33
35
  agent:
34
36
  provider: '' # dsh provider/model for A2A sessions; empty = profile default
35
37
  model: ''
@@ -103,6 +105,14 @@ queue or catch-up burst. Timers stop on completion, disconnect, response errors,
103
105
  and server disposal. Heartbeats require a responsive event loop; they cannot
104
106
  repair event-loop starvation or guarantee delivery during backpressure.
105
107
 
108
+ ## Subagent tools
109
+
110
+ `subagentTools.enabled` defaults to `false`. When enabled, tool calls/results from local descendant sessions created inside `tools/execute` are forwarded to the owning A2A task. Each child has a separate translator: its text, reasoning, usage and turn state never enter the parent's translator, and child completion or disposal never settles the parent task. Main-agent streaming keeps its existing format.
111
+
112
+ Child events retain the parent's A2A `taskId` and `contextId`. `metadata.dshAgent.source` carries `{ sessionId, agentId, parentSessionId, parentCallId }` (exported as `SubagentToolSource`): the source child, its immediate parent, and the exact dispatch call that created it. DSH agent IDs are session IDs. Use `(source.sessionId, data.callId)` to identify a child tool call; use `(source.parentSessionId, source.parentCallId)` to attach it to its delegation, including nested descendants. The link is captured with async dispatch context, never inferred from event ordering or descriptions. Verified against DSH 0.2.0-rc.1 and 0.2.0-rc.2's in-process child creation path.
113
+
114
+ Forwarding follows the owning task's active request bus. Background child events between streams are dropped, with no buffering, replay, extra stream or extension of parent settlement; if the same task opens another stream, live events can appear there. Rebinding the parent context to a new task permanently excludes children created for the old task. Providers running outside this process expose no local child events, so only their parent delegation call/result remain visible. Sessions without a captured dispatch link (including children loaded after a process restart, or created outside tool dispatch) are not forwarded; durable attribution for those cases requires an upstream DSH lineage field.
115
+
106
116
  ## Task stores
107
117
 
108
118
  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.
@@ -119,7 +129,7 @@ dsh ships **no authentication or authorization**. The server binds `127.0.0.1` b
119
129
 
120
130
  ## Compatibility
121
131
 
122
- Pinned dsh/cordis versions live in the [root compat matrix](../../README.md#compatibility). Event payloads ride pre-release dsh APIs (`@deepseek-ai/dsh-{agent,session,llm,attachment}@0.2.0-rc.2` — the attachment store is an optional peer) — check the `TODO(verify)` markers in `src/` before upgrading dsh.
132
+ 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.0-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.
123
133
 
124
134
  ## License
125
135
 
package/lib/bridge.js CHANGED
@@ -13,6 +13,7 @@
13
13
  * settles at the next `turn/end`; a host may keep the same task open for
14
14
  * autonomous turns through `a2a/task-settlement`.
15
15
  */
16
+ import { AsyncLocalStorage } from 'node:async_hooks';
16
17
  import { TaskState } from '@a2a-js/sdk';
17
18
  import { AgentEvent } from '@a2a-js/sdk/server';
18
19
  // installModelSelection is a root-module runtime import: the dsh-agent index
@@ -37,6 +38,8 @@ export class A2aBridge {
37
38
  asked = new Map();
38
39
  approvals = new Map();
39
40
  codec;
41
+ delegation = new AsyncLocalStorage();
42
+ children = new WeakMap();
40
43
  constructor(ctx, options) {
41
44
  this.ctx = ctx;
42
45
  this.options = options;
@@ -46,6 +49,49 @@ export class A2aBridge {
46
49
  ctx.on('session/disposed', (session) => this.onSessionDisposed(session));
47
50
  ctx.on('agent/assistant-stream', ({ agent, frame }) => this.onAssistantStream(agent, frame));
48
51
  ctx.on('approval/request', (req, next) => this.onApprovalRequest(req, next));
52
+ if (options.subagentTools?.enabled) {
53
+ // Async context distinguishes concurrent dispatches without relying on event order.
54
+ ctx.on('tools/execute', (exec, next) => {
55
+ const session = exec.agent?.session;
56
+ if (!session)
57
+ return next();
58
+ const child = this.children.get(session);
59
+ const entry = this.bySession.get(session.id) ?? child?.entry;
60
+ if (!entry || entry.staleTurn || (child && child.taskId !== entry.taskId))
61
+ return next();
62
+ return this.delegation.run({
63
+ entry,
64
+ taskId: entry.taskId,
65
+ parentSessionId: session.id,
66
+ parentCallId: exec.callId,
67
+ }, next);
68
+ });
69
+ // Verified against dsh-subagent/session 0.2.0-rc.2: local child creation runs inside dispatch.
70
+ ctx.on('session/created', (session) => {
71
+ try {
72
+ const delegation = this.delegation.getStore();
73
+ if (!delegation ||
74
+ session.header.origin !== 'subagent' ||
75
+ session.header.parentSession !== delegation.parentSessionId ||
76
+ // A wake/resume dispatch is not the original creating delegation.
77
+ session.firstLifecycleSeq !== session.inheritedEventCount ||
78
+ delegation.taskId !== delegation.entry.taskId)
79
+ return;
80
+ this.children.set(session, {
81
+ ...delegation,
82
+ translator: new SessionTranslator(delegation.taskId, delegation.entry.sessionId, undefined, {
83
+ sessionId: session.id,
84
+ agentId: session.id,
85
+ parentSessionId: delegation.parentSessionId,
86
+ parentCallId: delegation.parentCallId,
87
+ }),
88
+ });
89
+ }
90
+ catch (error) {
91
+ console.error('[dsh-a2a] failed to attribute child session:', error);
92
+ }
93
+ });
94
+ }
49
95
  }
50
96
  /**
51
97
  * Resolve the live task entry, creating the session/agent when needed.
@@ -500,8 +546,23 @@ export class A2aBridge {
500
546
  }
501
547
  onSessionEvent(session, event) {
502
548
  const entry = this.bySession.get(session.id);
503
- if (!entry)
549
+ if (!entry) {
550
+ const child = this.children.get(session);
551
+ if (!child ||
552
+ child.taskId !== child.entry.taskId ||
553
+ !child.entry.bus ||
554
+ child.entry.staleTurn ||
555
+ (event.type !== 'tool/call' && event.type !== 'tool/result'))
556
+ return;
557
+ try {
558
+ for (const out of child.translator.handle(event))
559
+ child.entry.bus.publish(out);
560
+ }
561
+ catch (error) {
562
+ console.error('[dsh-a2a] failed to translate child tool event:', error);
563
+ }
504
564
  return;
565
+ }
505
566
  if (entry.staleTurn) {
506
567
  if (event.type === 'turn/end')
507
568
  entry.staleTurn = false;
@@ -618,6 +679,7 @@ export class A2aBridge {
618
679
  }
619
680
  }
620
681
  onSessionDisposed(session) {
682
+ this.children.delete(session);
621
683
  const entry = this.bySession.get(session.id);
622
684
  if (!entry)
623
685
  return;
package/lib/index.js CHANGED
@@ -42,6 +42,9 @@ export const Config = Schema.object({
42
42
  * below it). Empty = <OS temp>/dsh-a2a-uploads.
43
43
  */
44
44
  uploadsDir: Schema.string().default(''),
45
+ subagentTools: Schema.object({
46
+ enabled: Schema.boolean().default(false),
47
+ }),
45
48
  agent: Schema.object({
46
49
  provider: Schema.string().default(''),
47
50
  model: Schema.string().default(''),
@@ -102,6 +105,7 @@ export function apply(ctx, config) {
102
105
  model: config.agent.model || undefined,
103
106
  },
104
107
  preset: config.agent.preset || undefined,
108
+ subagentTools: config.subagentTools,
105
109
  });
106
110
  const store = createTaskStore(config);
107
111
  await store.init?.();
package/lib/translator.js CHANGED
@@ -87,6 +87,7 @@ export class SessionTranslator {
87
87
  taskId;
88
88
  contextId;
89
89
  model;
90
+ source;
90
91
  /** messageId shared by this turn's streamed text deltas (client-side aggregation key). */
91
92
  textMessageId = randomUUID();
92
93
  thoughtMessageId = randomUUID();
@@ -98,10 +99,11 @@ export class SessionTranslator {
98
99
  usage;
99
100
  /** callId → tool name, for naming `tool/result` payloads. */
100
101
  toolNames = new Map();
101
- constructor(taskId, contextId, model) {
102
+ constructor(taskId, contextId, model, source) {
102
103
  this.taskId = taskId;
103
104
  this.contextId = contextId;
104
105
  this.model = model;
106
+ this.source = source;
105
107
  }
106
108
  /** Translate one session event into zero or more A2A events. Never throws on unknown types. */
107
109
  handle(event) {
@@ -199,7 +201,9 @@ export class SessionTranslator {
199
201
  }
200
202
  }
201
203
  status(state, dshAgent, message, error) {
202
- const metadata = { dshAgent };
204
+ const metadata = {
205
+ dshAgent: this.source ? { ...dshAgent, source: this.source } : dshAgent,
206
+ };
203
207
  if (this.model)
204
208
  metadata.model = this.model;
205
209
  const terminal = state !== TaskState.TASK_STATE_WORKING;
@@ -32,6 +32,10 @@ export interface BridgeOptions {
32
32
  };
33
33
  /** Agent preset id; empty/absent = the deployment default (when a roster exists). */
34
34
  preset?: string;
35
+ /** Forward attributed descendant tool events; disabled by default. */
36
+ subagentTools?: {
37
+ enabled: boolean;
38
+ };
35
39
  }
36
40
  export interface TaskEntry {
37
41
  taskId: string;
@@ -67,6 +71,8 @@ export declare class A2aBridge {
67
71
  private readonly asked;
68
72
  private readonly approvals;
69
73
  private readonly codec;
74
+ private readonly delegation;
75
+ private readonly children;
70
76
  constructor(ctx: Context, options: BridgeOptions);
71
77
  /**
72
78
  * Resolve the live task entry, creating the session/agent when needed.
@@ -19,6 +19,7 @@ import Schema from '@deepseek-ai/schemastery';
19
19
  export type { A2aApproval, A2aApprovalCodec, A2aApprovalReply } from './approval.js';
20
20
  export { defaultApprovalCodec } from './approval.js';
21
21
  export type { A2aFileMaterializer } from './content.js';
22
+ export type { SubagentToolSource } from './translator.js';
22
23
  /** Event emitted when one A2A user message enters the dsh inbox. */
23
24
  export declare const A2A_MESSAGE_ADMITTED_EVENT: "a2a/message-admitted";
24
25
  export interface A2aTurnContext {
@@ -84,6 +85,11 @@ export declare const Config: Schema<Schemastery.ObjectS<{
84
85
  * below it). Empty = <OS temp>/dsh-a2a-uploads.
85
86
  */
86
87
  uploadsDir: Schema<string, string>;
88
+ subagentTools: Schema<Schemastery.ObjectS<{
89
+ enabled: Schema<boolean, boolean>;
90
+ }>, Schemastery.ObjectT<{
91
+ enabled: Schema<boolean, boolean>;
92
+ }>>;
87
93
  agent: Schema<Schemastery.ObjectS<{
88
94
  provider: Schema<string, string>;
89
95
  model: Schema<string, string>;
@@ -156,6 +162,11 @@ export declare const Config: Schema<Schemastery.ObjectS<{
156
162
  * below it). Empty = <OS temp>/dsh-a2a-uploads.
157
163
  */
158
164
  uploadsDir: Schema<string, string>;
165
+ subagentTools: Schema<Schemastery.ObjectS<{
166
+ enabled: Schema<boolean, boolean>;
167
+ }>, Schemastery.ObjectT<{
168
+ enabled: Schema<boolean, boolean>;
169
+ }>>;
159
170
  agent: Schema<Schemastery.ObjectS<{
160
171
  provider: Schema<string, string>;
161
172
  model: Schema<string, string>;
@@ -221,6 +232,9 @@ export interface A2aPluginConfig {
221
232
  };
222
233
  cwd: string;
223
234
  uploadsDir: string;
235
+ subagentTools: {
236
+ enabled: boolean;
237
+ };
224
238
  agent: {
225
239
  provider: string;
226
240
  model: string;
@@ -40,6 +40,13 @@ import { type Message } from '@a2a-js/sdk';
40
40
  import { type AgentExecutionEvent } from '@a2a-js/sdk/server';
41
41
  import type { AssistantStreamFrame } from '@deepseek-ai/dsh-agent';
42
42
  import type { SessionEvent } from '@deepseek-ai/dsh-session';
43
+ /** Immediate delegation edge; tool call IDs are scoped by their source session. */
44
+ export interface SubagentToolSource {
45
+ sessionId: string;
46
+ agentId: string;
47
+ parentSessionId: string;
48
+ parentCallId: string;
49
+ }
43
50
  /** One agent-role text message — the single construction site for the plugin's message payloads. */
44
51
  export declare function agentTextMessage(taskId: string, contextId: string, text: string, messageId?: string): Message;
45
52
  /** A terminal (stream-closing) statusUpdate — shared by the bridge's settle path and the executor's failure/cancel paths. */
@@ -48,6 +55,7 @@ export declare class SessionTranslator {
48
55
  private readonly taskId;
49
56
  private readonly contextId;
50
57
  private readonly model?;
58
+ private readonly source?;
51
59
  /** messageId shared by this turn's streamed text deltas (client-side aggregation key). */
52
60
  private textMessageId;
53
61
  private thoughtMessageId;
@@ -59,7 +67,7 @@ export declare class SessionTranslator {
59
67
  private usage;
60
68
  /** callId → tool name, for naming `tool/result` payloads. */
61
69
  private readonly toolNames;
62
- constructor(taskId: string, contextId: string, model?: string | undefined);
70
+ constructor(taskId: string, contextId: string, model?: string | undefined, source?: SubagentToolSource | undefined);
63
71
  /** Translate one session event into zero or more A2A events. Never throws on unknown types. */
64
72
  handle(event: SessionEvent): AgentExecutionEvent[];
65
73
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amaster.ai/dsh-a2a",
3
- "version": "0.1.20",
3
+ "version": "0.1.22",
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",
@@ -41,7 +41,8 @@
41
41
  "@deepseek-ai/dsh-agent": ">=0.1.5-rc.1 || >=0.1.6-alpha.1 || >=0.1.7-alpha.1 || >=0.2.0-rc.1",
42
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
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.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",
45
46
  "@deepseek-ai/dsh-user-approval": ">=0.1.6-alpha.2 || >=0.1.7-alpha.1 || >=0.2.0-rc.1",
46
47
  "@google-cloud/storage": "^7.0.0",
47
48
  "ioredis": "^5.0.0"
@@ -68,6 +69,7 @@
68
69
  "@deepseek-ai/dsh-llm": "0.2.0-rc.2",
69
70
  "@deepseek-ai/dsh-session": "0.2.0-rc.2",
70
71
  "@deepseek-ai/dsh-session-projection": "0.2.0-rc.2",
72
+ "@deepseek-ai/dsh-tools": "0.2.0-rc.2",
71
73
  "@deepseek-ai/dsh-user-approval": "0.2.0-rc.2",
72
74
  "@google-cloud/storage": "^7.0.0",
73
75
  "@types/express": "^4.17.21",