@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/README.en.md +267 -0
- package/README.md +101 -112
- package/lib/client.js +110 -12
- package/lib/index.js +454 -26
- package/lib/types/a2a-config.d.ts +2 -0
- package/lib/types/a2a-config.d.ts.map +1 -1
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/dashboard.d.ts +8 -2
- package/lib/types/dashboard.d.ts.map +1 -1
- package/lib/types/index.d.ts +41 -2
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/outbound.d.ts.map +1 -1
- package/lib/types/server.d.ts +225 -1
- package/lib/types/server.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/a2a-config.ts +3 -0
- package/src/client/index.ts +111 -12
- package/src/dashboard.ts +16 -4
- package/src/index.ts +221 -16
- package/src/outbound.ts +31 -4
- package/src/server.ts +474 -9
- package/README.zh-CN.md +0 -196
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
|
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
|
+
}
|