@ryubyte/dsh-a2a 0.1.2 → 0.1.4

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/src/server.ts CHANGED
@@ -42,6 +42,14 @@ export interface ServerOptions {
42
42
  skills?: ServerSkill[];
43
43
  /** Override the executor that runs an incoming task. */
44
44
  execute?: TaskExecutor;
45
+ /**
46
+ * Optional shared bearer token. When set, the AgentCard declares an
47
+ * `http`/`bearer` security scheme and every inbound JSON-RPC request must
48
+ * carry `Authorization: Bearer <token>` or it is rejected with 401. When
49
+ * unset, no scheme is advertised and requests are not token-gated (but the
50
+ * default executor still refuses to act unless one is injected).
51
+ */
52
+ authToken?: string;
45
53
  /** Extra custom headers on the AgentCard (rarely needed). */
46
54
  iconUrl?: string;
47
55
  defaultInputModes?: string[];
@@ -151,11 +159,17 @@ export class A2AServer {
151
159
  private execute: TaskExecutor;
152
160
  private listeners = new Set<(ev: StreamResponse) => void>();
153
161
  private baseUrl: string;
162
+ /** Abort controllers for in-flight tasks, so CancelTask can abort the executor. */
163
+ private running = new Map<string, AbortController>();
154
164
 
155
165
  constructor(options: ServerOptions, store = new TaskStore()) {
156
166
  this.options = options;
157
167
  this.store = store;
158
- this.execute = options.execute ?? defaultExecutor;
168
+ // Safe default: refuse to act when no executor is configured. A
169
+ // shell-based executor is available but must be opted in explicitly
170
+ // (see `shellExecutor`), so the path of least resistance is NOT
171
+ // arbitrary shell execution.
172
+ this.execute = options.execute ?? notConfiguredExecutor;
159
173
  this.endpointPath = options.endpointPath ?? '/a2a';
160
174
  this.baseUrl = options.baseUrl;
161
175
  this.card = this.buildCard();
@@ -173,6 +187,30 @@ export class A2AServer {
173
187
  }));
174
188
  }
175
189
 
190
+ /**
191
+ * Set or clear the shared bearer token that gates the inbound endpoint at
192
+ * runtime. Rebuilds the AgentCard so the `bearerAuth` security scheme is
193
+ * advertised (token set) or withdrawn (token cleared); `authorized()` reads
194
+ * `options.authToken` on each request, so the gate flips immediately. Pass
195
+ * `undefined` or an empty string to disable token gating.
196
+ */
197
+ setAuthToken(token?: string): void {
198
+ this.options.authToken = token && token.length > 0 ? token : undefined;
199
+ // `card` is readonly (mutated in place, like setBaseUrl): add or withdraw
200
+ // the bearer security scheme to match the new token state.
201
+ if (this.options.authToken) {
202
+ applyAuthScheme(this.card, this.options.authToken);
203
+ } else {
204
+ delete this.card.securitySchemes;
205
+ delete this.card.securityRequirements;
206
+ }
207
+ }
208
+
209
+ /** Whether the inbound endpoint is currently token-gated. */
210
+ get authConfigured(): boolean {
211
+ return Boolean(this.options.authToken);
212
+ }
213
+
176
214
  private buildCard(): AgentCard {
177
215
  const iface: AgentInterface = {
178
216
  url: `${this.baseUrl.replace(/\/$/, '')}${this.endpointPath}`,
@@ -182,7 +220,8 @@ export class A2AServer {
182
220
  // No implicit skills: only what the deployer configured. Publishing
183
221
  // nothing is safer than inventing a default capability.
184
222
  const skills: AgentSkill[] = this.options.skills ?? [];
185
- return {
223
+ const token = this.options.authToken;
224
+ const card: AgentCard = {
186
225
  name: this.options.agentName,
187
226
  description: this.options.agentDescription,
188
227
  version: this.options.agentVersion,
@@ -194,6 +233,19 @@ export class A2AServer {
194
233
  skills,
195
234
  ...(this.options.iconUrl ? { iconUrl: this.options.iconUrl } : {}),
196
235
  };
236
+ applyAuthScheme(card, token);
237
+ return card;
238
+ }
239
+
240
+ /** True when the request carries the configured bearer token. */
241
+ private authorized(req: { headers?: Record<string, string> }): boolean {
242
+ const token = this.options.authToken;
243
+ if (!token) return true; // no scheme configured → not token-gated
244
+ const h = req.headers ?? {};
245
+ const auth = h['authorization'] ?? h['Authorization'];
246
+ if (!auth) return false;
247
+ const m = /^Bearer\s+(.+)$/i.exec(auth.trim());
248
+ return !!m && m[1] === token;
197
249
  }
198
250
 
199
251
  private emit(ev: StreamResponse) {
@@ -201,7 +253,7 @@ export class A2AServer {
201
253
  }
202
254
 
203
255
  /** Route an inbound HTTP request; returns true when handled. */
204
- async handle(req: { method?: string; url?: string; headers?: { 'content-type'?: string }; socket?: { remoteAddress?: string; remotePort?: number } }, body: string): Promise<{ status: number; contentType: string; body: string }> {
256
+ async handle(req: { method?: string; url?: string; headers?: { 'content-type'?: string }; socket?: { remoteAddress?: string; remotePort?: number } }, body: string): Promise<{ status: number; contentType: string; body: string; headers?: Record<string, string> }> {
205
257
  const path = (req.url ?? '').split('?')[0];
206
258
  // AgentCard discovery
207
259
  if (req.method === 'GET' && (path === '/.well-known/agent-card.json' || path === this.endpointPath + '/card')) {
@@ -213,6 +265,18 @@ export class A2AServer {
213
265
  }
214
266
  // JSON-RPC
215
267
  if (req.method === 'POST' && path === this.endpointPath) {
268
+ if (!this.authorized(req)) {
269
+ return {
270
+ status: 401,
271
+ contentType: 'application/json',
272
+ body: JSON.stringify({
273
+ jsonrpc: '2.0',
274
+ id: null,
275
+ error: { code: -32001, message: 'Unauthorized' },
276
+ }),
277
+ headers: { 'WWW-Authenticate': 'Bearer' },
278
+ };
279
+ }
216
280
  let payload: unknown;
217
281
  try {
218
282
  payload = JSON.parse(body);
@@ -249,12 +313,17 @@ export class A2AServer {
249
313
  }
250
314
  return { status: 404, contentType: 'text/plain', body: 'Not Found' };
251
315
  }
252
- async handleStream(req: { method?: string; url?: string; headers?: Record<string, string>; socket?: { remoteAddress?: string; remotePort?: number } }, body: string, onEvent: (frame: string) => void): Promise<{ status: number }> {
316
+ async handleStream(req: { method?: string; url?: string; headers?: Record<string, string>; socket?: { remoteAddress?: string; remotePort?: number } }, body: string, onEvent: (frame: string) => void): Promise<{ status: number; headers?: Record<string, string> }> {
253
317
  const path = (req.url ?? '').split('?')[0];
254
318
  if (req.method !== 'POST' || path !== this.endpointPath) {
255
319
  onEvent(`event: error\ndata: ${JSON.stringify({ code: 404, message: 'Not Found' })}\n\n`);
256
320
  return { status: 200 };
257
321
  }
322
+ if (!this.authorized(req)) {
323
+ // Do not emit on the SSE stream — the caller flips the HTTP status to
324
+ // 401 + WWW-Authenticate. (Spec §7.4: fail closed on missing tokens.)
325
+ return { status: 401, headers: { 'WWW-Authenticate': 'Bearer' } };
326
+ }
258
327
  let payload: unknown;
259
328
  try {
260
329
  payload = JSON.parse(body);
@@ -326,9 +395,15 @@ export class A2AServer {
326
395
  private async runTask(taskId: string, contextId: string, msg: Message) {
327
396
  this.store.setStatus(taskId, TaskState.WORKING);
328
397
  this.emit({ statusUpdate: { taskId, contextId, status: this.store.get(taskId)!.status } });
329
- const signal = new AbortController().signal;
398
+ // Track a live controller so CancelTask can abort the running executor.
399
+ const controller = new AbortController();
400
+ this.running.set(taskId, controller);
330
401
  try {
331
- const reply = await this.execute({ message: msg, taskId, contextId, signal });
402
+ const reply = await this.execute({ message: msg, taskId, contextId, signal: controller.signal });
403
+ // CancelTask aborts the signal AND sets the task terminal (CANCELED); the
404
+ // executor still returns normally, so guard against clobbering that
405
+ // terminal state with COMPLETED (and firing onTaskSettled a second time).
406
+ if (controller.signal.aborted || isTerminal(this.store.get(taskId)?.status.state as TaskState)) return;
332
407
  this.store.addArtifact(taskId, { artifactId: 'result', parts: reply.parts, lastChunk: true });
333
408
  this.store.setStatus(taskId, TaskState.COMPLETED, reply);
334
409
  this.options.onTaskSettled?.(taskId);
@@ -336,6 +411,8 @@ export class A2AServer {
336
411
  this.emit({ task });
337
412
  this.emit({ artifactUpdate: { taskId, contextId, artifact: task.artifacts![task.artifacts!.length - 1], lastChunk: true } });
338
413
  } catch (err) {
414
+ // Don't overwrite an already-terminal task (e.g. CANCELED) with FAILED.
415
+ if (controller.signal.aborted || isTerminal(this.store.get(taskId)?.status.state as TaskState)) return;
339
416
  const message: Message = {
340
417
  messageId: crypto.randomUUID(),
341
418
  role: Role.AGENT,
@@ -344,6 +421,8 @@ export class A2AServer {
344
421
  this.store.setStatus(taskId, TaskState.FAILED, message);
345
422
  this.options.onTaskSettled?.(taskId);
346
423
  this.emit({ statusUpdate: { taskId, contextId, status: this.store.get(taskId)!.status } });
424
+ } finally {
425
+ this.running.delete(taskId);
347
426
  }
348
427
  }
349
428
 
@@ -385,6 +464,9 @@ export class A2AServer {
385
464
  if (isTerminal(task.status.state as TaskState)) {
386
465
  throw { code: A2A_ERROR_CODES.TASK_CANCEL_NOT_ALLOWED, message: `Task ${id} already in terminal state ${task.status.state}` };
387
466
  }
467
+ // Abort the running executor (if any) so the work actually stops, not
468
+ // just the store status. The executor observes this via its signal.
469
+ this.running.get(id)?.abort();
388
470
  this.store.setStatus(id, TaskState.CANCELED);
389
471
  this.options.onTaskSettled?.(id);
390
472
  return this.store.get(id);
@@ -402,6 +484,17 @@ export class A2AServer {
402
484
  }
403
485
  }
404
486
 
487
+ /**
488
+ * Advertise the `bearerAuth` HTTP security scheme on an AgentCard when a token
489
+ * is configured. Mutates in place; a no-op when `token` is falsy. Shared by
490
+ * {@link A2AServer.buildCard} and {@link A2AServer.setAuthToken}.
491
+ */
492
+ function applyAuthScheme(card: AgentCard, token?: string): void {
493
+ if (!token) return;
494
+ card.securitySchemes = { bearerAuth: { type: 'http', scheme: 'bearer', bearerFormat: 'opaque' } };
495
+ card.securityRequirements = [{ schemes: { bearerAuth: [] } }];
496
+ }
497
+
405
498
  /** Pull task ids out of a dispatch result for inbound peer tracking. */
406
499
  function extractTaskIds(result: unknown): string[] {
407
500
  if (!result || typeof result !== 'object') return [];
@@ -425,11 +518,39 @@ export const defaultSkills: ServerSkill[] = [
425
518
  ];
426
519
 
427
520
  /**
428
- * Default executor: run the incoming prompt through the system shell and
521
+ * Safe default executor: refuses to act, telling the caller to inject a real
522
+ * executor. Used when an `A2AServer` is constructed without an `execute`
523
+ * option, so an inbound message never triggers arbitrary action (in particular
524
+ * never a shell command) unless the operator explicitly configured one.
525
+ */
526
+ export const notConfiguredExecutor: TaskExecutor = async ({ message }) => {
527
+ const text = message.parts
528
+ .map((p) => ('text' in p ? p.text : ''))
529
+ .filter(Boolean)
530
+ .join('\n')
531
+ .slice(0, 120);
532
+ const detail = text ? ` (prompt: ${text}…)` : '';
533
+ return {
534
+ messageId: crypto.randomUUID(),
535
+ role: Role.AGENT,
536
+ parts: [{ text: `no executor configured — inject one via the \`execute\` option${detail}` }],
537
+ };
538
+ };
539
+
540
+ /**
541
+ * Shell executor: run the incoming prompt through the system shell and
429
542
  * return the captured stdout/stderr as the task artifact. Honors the
430
543
  * AbortSignal (task cancellation / shutdown) by killing the child process.
544
+ *
545
+ * This is local-testing-only: arbitrary text from an inbound message becomes
546
+ * a `/bin/sh -c` command, so only use it against a trusted client. It is
547
+ * deliberately NOT the default — pass it explicitly to opt in:
548
+ *
549
+ * ```ts
550
+ * const server = new A2AServer({ ..., execute: shellExecutor }, store);
551
+ * ```
431
552
  */
432
- export const defaultExecutor: TaskExecutor = async ({ message, signal }) => {
553
+ export const shellExecutor: TaskExecutor = async ({ message, signal }) => {
433
554
  const text = message.parts
434
555
  .map((p) => ('text' in p ? p.text : ''))
435
556
  .filter(Boolean)
@@ -464,4 +585,348 @@ export const defaultExecutor: TaskExecutor = async ({ message, signal }) => {
464
585
  } catch (err) {
465
586
  return { messageId: crypto.randomUUID(), role: Role.AGENT, parts: [{ text: `Executor error: ${(err as Error).message}` }] };
466
587
  }
467
- };
588
+ };
589
+ /**
590
+ * Backwards-compatible alias for {@link shellExecutor}. Kept for code that
591
+ * explicitly opted into the shell executor before it was renamed; the safe
592
+ * default for `A2AServer` is now {@link notConfiguredExecutor}, so using
593
+ * this alias still requires an explicit opt-in.
594
+ */
595
+ export const defaultExecutor: TaskExecutor = shellExecutor;
596
+
597
+ // ─────────────────────────────────────────────────────────────────────────────
598
+ // DSH agent executor — run an inbound A2A task through this harness's own agent
599
+ // loop, instead of a shell.
600
+ //
601
+ // The shapes below are declared STRUCTURALLY (same philosophy as the
602
+ // `WebServerService` interface in `index.ts`): this package builds against
603
+ // `@deepseek-ai/cordis` alone and never hard-depends on `@deepseek-ai/dsh-agent`
604
+ // or `@deepseek-ai/dsh-agent-loop`. We only describe the slice of the live
605
+ // `ctx.agents` service surface we actually call. The concrete runtime shapes
606
+ // are owned by `@deepseek-ai/dsh-agent`; the agent-creation FACTORY that makes
607
+ // `create()` succeed is owned by `@deepseek-ai/dsh-agent-loop`. A composition
608
+ // that lacks the loop still has `ctx.agents` (the registry), but `create()`
609
+ // rejects — which is exactly why enabling this executor is gated on a
610
+ // factory-readiness probe in `index.ts`, and why it degrades to a readable
611
+ // error rather than throwing raw.
612
+ // ─────────────────────────────────────────────────────────────────────────────
613
+
614
+ /** The one ordered pending-message list we route inbound turns to. */
615
+ type InboxTarget = 'next-turn' | 'next-step';
616
+
617
+ /** A model-facing text block (mirrors `@deepseek-ai/dsh-llm` `TextBlock`). */
618
+ interface AgentTextBlock {
619
+ type: 'text';
620
+ text: string;
621
+ }
622
+
623
+ /** The subset of the derived message history we read back (mirrors `Message`). */
624
+ interface AgentMessage {
625
+ readonly role: 'system' | 'user' | 'assistant';
626
+ readonly content: ReadonlyArray<{ type: string; text?: string }>;
627
+ }
628
+
629
+ /** A user-role message accepted by `agent.send` (mirrors `UserMessage`). */
630
+ interface AgentUserMessage {
631
+ readonly id: string;
632
+ readonly role: 'user';
633
+ readonly content: AgentTextBlock[];
634
+ readonly source: { kind: 'plugin'; plugin: string };
635
+ }
636
+
637
+ /** The slice of a live `Agent` we drive (mirrors `@deepseek-ai/dsh-agent` `Agent`). */
638
+ interface LiveAgent {
639
+ readonly session: { deriveMessages(): AgentMessage[] };
640
+ send(message: AgentUserMessage, target: InboxTarget, wakeup: boolean): void;
641
+ whenIdle(): Promise<void>;
642
+ cancel(cause: string): void;
643
+ }
644
+
645
+ /** Owned agent + capability disposer (mirrors `AgentHandle`). */
646
+ interface AgentHandle {
647
+ agent: LiveAgent;
648
+ dispose(): Promise<void>;
649
+ }
650
+
651
+ /**
652
+ * The slice of `ctx.agents` (mirrors `AgentRegistry`) this executor calls.
653
+ * `create()` mints a brand-new session on a caller-supplied id and REJECTS when
654
+ * no agent-loop factory is registered, or when a persisted log already owns the
655
+ * id. `resume()` loads a persisted session by id.
656
+ *
657
+ * `setup` mirrors `CreateAgentOptions.setup`: it runs inside the DSH agent
658
+ * factory's unpublished creation window. That is the ONLY supported place to
659
+ * join an agent preset (`agentPresets.mount()`), because the join must exist
660
+ * before `session/created`/`agent/created` — otherwise the agent publishes
661
+ * with an empty tool catalog (its tools, prompt sections, and skills resolve
662
+ * against the "empty global layer", which is exactly the failure this executor
663
+ * used to produce for every inbound task).
664
+ */
665
+ export interface AgentRegistryLike {
666
+ create(options: {
667
+ readonly sessionId: string;
668
+ readonly meta?: { readonly cwd?: string; readonly agentPreset?: string };
669
+ readonly agentOptions?: { provider?: string; model?: string; maxTokens?: number };
670
+ readonly setup?: (agentCtx: unknown) => void | Promise<void>;
671
+ }): Promise<AgentHandle>;
672
+ resume(options: {
673
+ readonly resumeSessionId: string;
674
+ readonly agentOptions?: { provider?: string; model?: string; maxTokens?: number };
675
+ readonly setup?: (agentCtx: unknown) => void | Promise<void>;
676
+ }): Promise<AgentHandle>;
677
+ }
678
+
679
+ /**
680
+ * Structural slice of `ctx.agentPresets` (owned by `@deepseek-ai/dsh-agent-presets`),
681
+ * the preset roster service. Mirrors exactly the two methods the DSH host's own
682
+ * session entry point (`dsh-host-apiproxy` `composeAgent`) uses to compose a
683
+ * fresh agent:
684
+ * - `resolve(id)` resolves the requested preset (or the deployment default
685
+ * when `undefined`) and returns `{ id }`;
686
+ * - `mount(agentCtx, id)` joins the agent's scope to the preset's standing
687
+ * composition — the ONLY supported call site is the agent factory's
688
+ * `setup` hook, where a failure rolls the whole creation back.
689
+ */
690
+ export interface AgentPresetsLike {
691
+ resolve(id?: string): Promise<{ id: string }>;
692
+ mount(agentCtx: unknown, id: string): Promise<unknown>;
693
+ }
694
+
695
+ /** The executor function returned by {@link createDshAgentExecutor}, plus teardown. */
696
+ interface DshAgentExecutor extends TaskExecutor {
697
+ /** Dispose every live per-context session this executor created. */
698
+ disposeAll(): Promise<void>;
699
+ }
700
+
701
+ /** Options for {@link createDshAgentExecutor}. */
702
+ export interface DshAgentExecutorOptions {
703
+ /**
704
+ * Absolute workspace path bound to each spawned session's durable header.
705
+ * DSH validates this is absolute and freezes it at creation, so it MUST be
706
+ * absolute — pass `process.cwd()` (the profile's workspace) at wire time.
707
+ */
708
+ cwd: string;
709
+ /** `source.plugin` tag stamped on inbound messages (defaults to `'a2a'`). */
710
+ plugin?: string;
711
+ /** Optional static per-agent model routing forwarded to `create()`. */
712
+ agentOptions?: { provider?: string; model?: string; maxTokens?: number };
713
+ /**
714
+ * Optional resolver for the per-agent model selection, evaluated ONCE PER
715
+ * TASK just before `create()`. This is how a workspace-bound session gets a
716
+ * model: DSH's persona system-prompt section interpolates a `{{model}}`
717
+ * variable that is only filled when the agent has a selected model, and
718
+ * seeding that selection is the creating entry point's responsibility (the
719
+ * deployment default lives on `ctx.agentDefaultModel`, not on the agent).
720
+ * Resolving per task (rather than fixing it at wire time) means a later model
721
+ * switch in Settings is picked up by the next inbound task. Takes precedence
722
+ * over the static {@link agentOptions} when it returns a value.
723
+ */
724
+ resolveAgentOptions?: () => { provider?: string; model?: string; maxTokens?: number } | undefined;
725
+ /**
726
+ * Called ONCE per A2A context, right after its session is first opened
727
+ * (create or resume) and its first prompt sent. Lets the composition name the
728
+ * session and group it (e.g. `ctx.sessionTitle.rename` + a workspace attach)
729
+ * without this module depending on those services. Failures are swallowed by
730
+ * the caller — naming/grouping is cosmetic and must never fail a task.
731
+ */
732
+ onSessionOpened?: (info: { sessionId: string; contextId: string; session: LiveAgent['session']; firstPrompt: string }) => void | Promise<void>;
733
+ /**
734
+ * The DSH agent-preset roster (`ctx.agentPresets`), probed by the caller via
735
+ * the reflection layer — see `probeAgentPresets` in `index.ts`. When present,
736
+ * every spawned session is composed from the deployment's default preset so
737
+ * it actually gets tools (shell, fs, skills …). Without a roster, sessions
738
+ * keep the legacy behavior: they run against the host composition only.
739
+ */
740
+ agentPresets?: AgentPresetsLike;
741
+ }
742
+
743
+ /** Flatten A2A message parts into a single prompt string (text parts only). */
744
+ function partsToPrompt(parts: Part[]): string {
745
+ return parts
746
+ .map((p) => ('text' in p && typeof p.text === 'string' ? p.text : ''))
747
+ .filter(Boolean)
748
+ .join('\n')
749
+ .trim();
750
+ }
751
+
752
+ /** Read the last assistant message's text off an agent's derived history. */
753
+ function lastAssistantText(agent: LiveAgent): string {
754
+ const history = agent.session.deriveMessages();
755
+ for (let i = history.length - 1; i >= 0; i--) {
756
+ if (history[i].role === 'assistant') {
757
+ return history[i].content
758
+ .map((b) => (b.type === 'text' && typeof b.text === 'string' ? b.text : ''))
759
+ .filter(Boolean)
760
+ .join('\n')
761
+ .trim();
762
+ }
763
+ }
764
+ return '';
765
+ }
766
+
767
+ /** Derive a stable session id for an A2A context id. */
768
+ function sessionIdForContext(contextId: string): string {
769
+ return `a2a-${contextId}`;
770
+ }
771
+
772
+ /**
773
+ * DSH agent executor: forward each inbound A2A task to this harness's own agent
774
+ * loop and return the assistant reply as the task artifact.
775
+ *
776
+ * ONE A2A CONTEXT ⇒ ONE SESSION. A2A already scopes a conversation by
777
+ * `contextId`, so this executor keeps one workspace-bound agent session per
778
+ * context: the first task for a context `create()`s it (session id derived from
779
+ * the context, `meta.cwd` from `opts.cwd`), and later tasks for the same
780
+ * context reuse the SAME live agent so history accumulates. Distinct contexts
781
+ * get distinct sessions. A fresh process that sees a known context first tries
782
+ * `resume()` (load persisted history), falling back to `create()`.
783
+ *
784
+ * Sessions are NOT disposed per task — a disposed session couldn't accumulate
785
+ * context. The returned executor exposes {@link DshAgentExecutor.disposeAll} so
786
+ * the plugin disposes every live per-context session on unload/teardown.
787
+ *
788
+ * Per-context work is serialized on a promise chain so concurrent tasks for the
789
+ * same context (and the shared `whenIdle()` — a whole-agent quiescence signal,
790
+ * not a per-message one) can't interleave. Different contexts run in parallel.
791
+ *
792
+ * Enable this via the plugin's `execute` precedence in `index.ts`; it is not
793
+ * `A2AServer`'s default (the default stays {@link notConfiguredExecutor}).
794
+ */
795
+ export function createDshAgentExecutor(
796
+ agents: AgentRegistryLike,
797
+ opts: DshAgentExecutorOptions,
798
+ ): DshAgentExecutor {
799
+ const plugin = opts.plugin ?? 'a2a';
800
+ // Live per-context handles and the serialization tail for each context.
801
+ const handles = new Map<string, AgentHandle>();
802
+ const tails = new Map<string, Promise<unknown>>();
803
+
804
+ /**
805
+ * Get or open the live agent for a context. Returns the agent and whether it
806
+ * was JUST opened (so the caller fires the one-time onSessionOpened hook after
807
+ * the first prompt lands, keeping "open" and "first message" atomic per tail).
808
+ * `justOpened` is true only on the create/resume path — a cached handle returns
809
+ * false — so it alone gates the once-per-context hook; serialization on the
810
+ * per-context tail guarantees no two openings race.
811
+ */
812
+ const agentFor = async (contextId: string): Promise<{ agent: LiveAgent; sessionId: string; justOpened: boolean }> => {
813
+ const sessionId = sessionIdForContext(contextId);
814
+ const existing = handles.get(contextId);
815
+ if (existing) return { agent: existing.agent, sessionId, justOpened: false };
816
+ const agentOptions = opts.resolveAgentOptions?.() ?? opts.agentOptions;
817
+ const withModel = agentOptions ? { agentOptions } : {};
818
+ // Join an agent preset so the spawned session actually has tools: in DSH,
819
+ // an agent's tool catalog, prompt sections, and skills all come from its
820
+ // preset composition. Without the join, the agent publishes against the
821
+ // "empty global layer" and every inbound task degenerates to pure text
822
+ // (the model can only write `<invoke …>` markup as if it were text — the
823
+ // exact failure the a2a-test report described). Resolution mirrors
824
+ // dsh-host-apiproxy's composeAgent(): resolve the id BEFORE create (the
825
+ // session boundary snapshots meta before async setup) and mount inside
826
+ // setup (failure rolls creation back). A deployment without a preset
827
+ // roster (no `agentPresets` service) keeps the pre-preset behavior.
828
+ const presets = opts.agentPresets;
829
+ let presetId: string | undefined;
830
+ let setup: ((agentCtx: unknown) => Promise<void>) | undefined;
831
+ if (presets) {
832
+ try {
833
+ presetId = (await presets.resolve()).id;
834
+ if (!presetId) throw new Error('agent preset roster resolved to no id');
835
+ setup = async (agentCtx: unknown): Promise<void> => {
836
+ await presets.mount(agentCtx, presetId!);
837
+ };
838
+ } catch (err) {
839
+ // A broken/unknown roster must fail creation loudly, not silently
840
+ // spawn a tool-less agent — that is exactly the defect being fixed.
841
+ throw new Error(`a2a: failed to resolve agent preset: ${(err as Error).message}`);
842
+ }
843
+ }
844
+ const withPreset = presetId ? { meta: { cwd: opts.cwd, agentPreset: presetId } } : { meta: { cwd: opts.cwd } };
845
+ const createOptions = { sessionId, ...withPreset, ...withModel, ...(setup ? { setup } : {}) };
846
+ // Create fresh; if a persisted log already owns the id, resume it so history
847
+ // carries over. Any other create() failure is re-thrown.
848
+ let handle: AgentHandle;
849
+ try {
850
+ handle = await agents.create(createOptions);
851
+ } catch (err) {
852
+ if (/already (has|owns)|persisted/i.test((err as Error).message)) {
853
+ handle = await agents.resume({ resumeSessionId: sessionId, ...withModel, ...(setup ? { setup } : {}) });
854
+ } else {
855
+ throw err;
856
+ }
857
+ }
858
+ handles.set(contextId, handle);
859
+ return { agent: handle.agent, sessionId, justOpened: true };
860
+ };
861
+
862
+ /** Run one turn on a context's session, serialized behind its tail. */
863
+ const runTurn = async (contextId: string, prompt: string, signal?: AbortSignal): Promise<string> => {
864
+ const { agent, sessionId, justOpened } = await agentFor(contextId);
865
+ const onAbort = (): void => agent.cancel('a2a-canceled');
866
+ if (signal?.aborted) onAbort();
867
+ else signal?.addEventListener('abort', onAbort, { once: true });
868
+ const userMessage: AgentUserMessage = Object.freeze<AgentUserMessage>({
869
+ id: crypto.randomUUID(),
870
+ role: 'user',
871
+ content: [{ type: 'text', text: prompt }],
872
+ source: { kind: 'plugin', plugin },
873
+ });
874
+ agent.send(userMessage, 'next-turn', true);
875
+ // Fire the one-time naming/grouping hook after the first prompt is queued
876
+ // (so the title provider has an eligible message).
877
+ if (justOpened) {
878
+ try {
879
+ await opts.onSessionOpened?.({ sessionId, contextId, session: agent.session, firstPrompt: prompt });
880
+ } catch {
881
+ // Naming/grouping is cosmetic — never fail the task over it.
882
+ }
883
+ }
884
+ await agent.whenIdle();
885
+ return lastAssistantText(agent);
886
+ };
887
+
888
+ const executor = (async ({ message, contextId, signal }) => {
889
+ const prompt = partsToPrompt(message.parts);
890
+ if (!prompt) {
891
+ return { messageId: crypto.randomUUID(), role: Role.AGENT, parts: [{ text: 'No prompt provided.' }] };
892
+ }
893
+ // A2A guarantees a stable contextId per conversation (TaskStore assigns one
894
+ // when a message omits it), so it's the session key directly.
895
+ const ctxKey = contextId;
896
+ // Serialize per context: chain this task behind the context's tail so
897
+ // concurrent tasks for the same conversation never interleave turns.
898
+ const prior = tails.get(ctxKey) ?? Promise.resolve();
899
+ const run = prior.catch(() => {}).then(() => runTurn(ctxKey, prompt, signal));
900
+ tails.set(ctxKey, run);
901
+ try {
902
+ const replyText = await run;
903
+ const text = signal?.aborted ? `(canceled) ${replyText}`.trim() : replyText || '(no reply)';
904
+ return { messageId: crypto.randomUUID(), role: Role.AGENT, parts: [{ text }] };
905
+ } catch (err) {
906
+ // Most common cause: no agent-loop factory (bare dsh-base) — create()
907
+ // rejects. Surface it as a readable artifact, not a raw throw.
908
+ return {
909
+ messageId: crypto.randomUUID(),
910
+ role: Role.AGENT,
911
+ parts: [{ text: `DSH agent executor error: ${(err as Error).message}` }],
912
+ };
913
+ } finally {
914
+ // Clear the tail only if it's still ours (no later task chained on).
915
+ if (tails.get(ctxKey) === run) tails.delete(ctxKey);
916
+ }
917
+ }) as DshAgentExecutor;
918
+
919
+ executor.disposeAll = async (): Promise<void> => {
920
+ const live = [...handles.values()];
921
+ const pending = [...tails.values()];
922
+ handles.clear();
923
+ tails.clear();
924
+ // Let in-flight turns settle before tearing their sessions down, so a
925
+ // running turn isn't disposed out from under itself.
926
+ await Promise.allSettled(pending);
927
+ // dispose() is a capability: not calling it leaks the agent + session.
928
+ await Promise.allSettled(live.map((h) => h.dispose()));
929
+ };
930
+
931
+ return executor;
932
+ }