@ryubyte/dsh-a2a 0.1.0

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 ADDED
@@ -0,0 +1,467 @@
1
+ /**
2
+ * dsh-a2a — inbound half: serve DSH as an A2A agent.
3
+ *
4
+ * Implements the server side of A2A v1.0 JSONRPC binding:
5
+ * - `/.well-known/agent-card.json` agent manifest
6
+ * - POST JSON-RPC endpoint handling SendMessage / GetTask / ListTasks /
7
+ * CancelTask / SendStreamingMessage (SSE) / SubscribeToTask
8
+ *
9
+ * Task execution is delegated to an injectable executor so compositions can
10
+ * wire DSH's real agent loop; plugin ships a default executor that runs
11
+ * shell commands in the workspace.
12
+ */
13
+
14
+ import type {
15
+ AgentCard,
16
+ AgentInterface,
17
+ AgentSkill,
18
+ JsonRpcError,
19
+ JsonRpcRequest,
20
+ Message,
21
+ Part,
22
+ StreamResponse,
23
+ Task,
24
+ } from './protocol.js';
25
+ import {
26
+ TaskState,
27
+ Role,
28
+ A2A_METHODS,
29
+ A2A_ERROR_CODES,
30
+ isTerminal,
31
+ } from './protocol.js';
32
+
33
+ export interface ServerSkill extends AgentSkill {}
34
+
35
+ export interface ServerOptions {
36
+ /** Public base URL (scheme + host + optional prefix) where this DSH is exposed. */
37
+ baseUrl: string;
38
+ agentName: string;
39
+ agentDescription: string;
40
+ agentVersion: string;
41
+ agentProvider?: { url: string; organization: string };
42
+ skills?: ServerSkill[];
43
+ /** Override the executor that runs an incoming task. */
44
+ execute?: TaskExecutor;
45
+ /** Extra custom headers on the AgentCard (rarely needed). */
46
+ iconUrl?: string;
47
+ defaultInputModes?: string[];
48
+ defaultOutputModes?: string[];
49
+ /** Where tasks are accepted — the JSON-RPC POST route path. */
50
+ endpointPath?: string;
51
+ /**
52
+ * Optional inbound observation hook: called for every JSON-RPC request with
53
+ * the request facts and the task ids it touched. Lets the connection
54
+ * dashboard track "who is talking to us".
55
+ */
56
+ onInbound?: (facts: InboundFacts) => void;
57
+ /** Called when a task reaches a terminal state (completed/failed/canceled). */
58
+ onTaskSettled?: (taskId: string) => void;
59
+ }
60
+
61
+ /** Observational facts about one inbound JSON-RPC request. */
62
+ export interface InboundFacts {
63
+ method: string;
64
+ headers?: Record<string, string>;
65
+ source?: string;
66
+ taskIds: string[];
67
+ streaming: boolean;
68
+ }
69
+
70
+ export interface TaskExecutor {
71
+ (input: { message: Message; taskId: string; contextId: string; signal: AbortSignal }): Promise<Message>;
72
+ }
73
+
74
+ export interface InboundHandle {
75
+ card: AgentCard;
76
+ endpointPath: string;
77
+ taskStore: TaskStore;
78
+ dispose: () => void;
79
+ }
80
+
81
+ /** In-memory task store with the full A2A Task lifecycle. */
82
+ export class TaskStore {
83
+ private tasks = new Map<string, Task>();
84
+ private messages = new Map<string, Message>();
85
+
86
+ create(message: Message): Task {
87
+ const taskId = crypto.randomUUID();
88
+ const contextId = message.contextId ?? crypto.randomUUID();
89
+ const task: Task = {
90
+ id: taskId,
91
+ contextId,
92
+ status: { state: TaskState.SUBMITTED, timestamp: new Date().toISOString() },
93
+ artifacts: [],
94
+ history: [message],
95
+ metadata: {},
96
+ };
97
+ this.tasks.set(taskId, task);
98
+ this.messages.set(message.messageId, message);
99
+ return task;
100
+ }
101
+
102
+ get(id: string): Task | undefined {
103
+ return this.tasks.get(id);
104
+ }
105
+
106
+ list(): Task[] {
107
+ return [...this.tasks.values()];
108
+ }
109
+
110
+ setStatus(taskId: string, state: TaskState, message?: Message): Task {
111
+ const task = this.tasks.get(taskId);
112
+ if (!task) throw new Error(`task ${taskId} not found`);
113
+ task.status = { state, message, timestamp: new Date().toISOString() };
114
+ if (message) {
115
+ this.messages.set(message.messageId, message);
116
+ task.history = [...(task.history ?? []), message];
117
+ }
118
+ return task;
119
+ }
120
+
121
+ addArtifact(taskId: string, artifact: { artifactId: string; parts: Part[]; name?: string; append?: boolean; lastChunk?: boolean }): Task {
122
+ const task = this.tasks.get(taskId);
123
+ if (!task) throw new Error(`task ${taskId} not found`);
124
+ const existing = task.artifacts?.find((a) => a.artifactId === artifact.artifactId);
125
+ if (existing) {
126
+ existing.parts.push(...artifact.parts);
127
+ } else {
128
+ task.artifacts = [...(task.artifacts ?? []), { artifactId: artifact.artifactId, name: artifact.name, parts: artifact.parts }];
129
+ }
130
+ return task;
131
+ }
132
+
133
+ getMessage(id: string): Message | undefined {
134
+ return this.messages.get(id);
135
+ }
136
+ }
137
+
138
+ /** Extract a human-readable source address from a request (when visible). */
139
+ function sourceOf(req: { socket?: { remoteAddress?: string; remotePort?: number } }): string | undefined {
140
+ const s = req.socket;
141
+ if (!s?.remoteAddress) return undefined;
142
+ return `${s.remoteAddress}:${s.remotePort ?? ''}`;
143
+ }
144
+
145
+ /** A2A JSON-RPC server handler over a raw HTTP request/response pair. */
146
+ export class A2AServer {
147
+ readonly options: ServerOptions;
148
+ readonly store: TaskStore;
149
+ readonly card: AgentCard;
150
+ readonly endpointPath: string;
151
+ private execute: TaskExecutor;
152
+ private listeners = new Set<(ev: StreamResponse) => void>();
153
+ private baseUrl: string;
154
+
155
+ constructor(options: ServerOptions, store = new TaskStore()) {
156
+ this.options = options;
157
+ this.store = store;
158
+ this.execute = options.execute ?? defaultExecutor;
159
+ this.endpointPath = options.endpointPath ?? '/a2a';
160
+ this.baseUrl = options.baseUrl;
161
+ this.card = this.buildCard();
162
+ }
163
+
164
+ /**
165
+ * Update the base URL advertised in the AgentCard after the real listen
166
+ * address is known (e.g. ephemeral port). Mutates `card` in place.
167
+ */
168
+ setBaseUrl(baseUrl: string): void {
169
+ this.baseUrl = baseUrl.replace(/\/$/, '');
170
+ this.card.supportedInterfaces = this.card.supportedInterfaces.map((iface) => ({
171
+ ...iface,
172
+ url: `${this.baseUrl}${this.endpointPath}`,
173
+ }));
174
+ }
175
+
176
+ private buildCard(): AgentCard {
177
+ const iface: AgentInterface = {
178
+ url: `${this.baseUrl.replace(/\/$/, '')}${this.endpointPath}`,
179
+ protocolBinding: 'JSONRPC',
180
+ protocolVersion: '1.0',
181
+ };
182
+ // No implicit skills: only what the deployer configured. Publishing
183
+ // nothing is safer than inventing a default capability.
184
+ const skills: AgentSkill[] = this.options.skills ?? [];
185
+ return {
186
+ name: this.options.agentName,
187
+ description: this.options.agentDescription,
188
+ version: this.options.agentVersion,
189
+ supportedInterfaces: [iface],
190
+ provider: this.options.agentProvider ?? { url: 'https://deepseek.com', organization: 'DeepSeek' },
191
+ capabilities: { streaming: true, pushNotifications: false, extensions: [] },
192
+ defaultInputModes: this.options.defaultInputModes ?? ['text/plain'],
193
+ defaultOutputModes: this.options.defaultOutputModes ?? ['text/plain'],
194
+ skills,
195
+ ...(this.options.iconUrl ? { iconUrl: this.options.iconUrl } : {}),
196
+ };
197
+ }
198
+
199
+ private emit(ev: StreamResponse) {
200
+ for (const l of this.listeners) l(ev);
201
+ }
202
+
203
+ /** 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 }> {
205
+ const path = (req.url ?? '').split('?')[0];
206
+ // AgentCard discovery
207
+ if (req.method === 'GET' && (path === '/.well-known/agent-card.json' || path === this.endpointPath + '/card')) {
208
+ return {
209
+ status: 200,
210
+ contentType: 'application/json',
211
+ body: JSON.stringify(this.card),
212
+ };
213
+ }
214
+ // JSON-RPC
215
+ if (req.method === 'POST' && path === this.endpointPath) {
216
+ let payload: unknown;
217
+ try {
218
+ payload = JSON.parse(body);
219
+ } catch {
220
+ return this.jsonRpcError(null, A2A_ERROR_CODES.INVALID_REQUEST, 'Invalid JSON', 'Parse error');
221
+ }
222
+ const rpc = payload as JsonRpcRequest;
223
+ if (!rpc || rpc.jsonrpc !== '2.0' || typeof rpc.method !== 'string') {
224
+ return this.jsonRpcError(rpc?.id ?? null, A2A_ERROR_CODES.INVALID_REQUEST, 'Invalid JSON-RPC request');
225
+ }
226
+ try {
227
+ const result = await this.dispatch(rpc);
228
+ this.options.onInbound?.({
229
+ method: rpc.method,
230
+ headers: req.headers as Record<string, string> | undefined,
231
+ source: sourceOf(req),
232
+ taskIds: extractTaskIds(result),
233
+ streaming: false,
234
+ });
235
+ return {
236
+ status: 200,
237
+ contentType: 'application/json',
238
+ body: JSON.stringify({ jsonrpc: '2.0', id: rpc.id, result }),
239
+ };
240
+ } catch (err) {
241
+ const e = err as { code?: number; message?: string; data?: unknown };
242
+ return this.jsonRpcError(
243
+ rpc.id ?? null,
244
+ typeof e.code === 'number' ? e.code : A2A_ERROR_CODES.INTERNAL_ERROR,
245
+ e.message ?? String(err),
246
+ e.data,
247
+ );
248
+ }
249
+ }
250
+ return { status: 404, contentType: 'text/plain', body: 'Not Found' };
251
+ }
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 }> {
253
+ const path = (req.url ?? '').split('?')[0];
254
+ if (req.method !== 'POST' || path !== this.endpointPath) {
255
+ onEvent(`event: error\ndata: ${JSON.stringify({ code: 404, message: 'Not Found' })}\n\n`);
256
+ return { status: 200 };
257
+ }
258
+ let payload: unknown;
259
+ try {
260
+ payload = JSON.parse(body);
261
+ } catch {
262
+ onEvent(`event: error\ndata: ${JSON.stringify({ code: A2A_ERROR_CODES.INVALID_REQUEST, message: 'Parse error' })}\n\n`);
263
+ return { status: 200 };
264
+ }
265
+ const rpc = payload as JsonRpcRequest;
266
+ const method = rpc?.method;
267
+ if (method === A2A_METHODS.sendStreamingMessage || method === A2A_METHODS.subscribeToTask) {
268
+ const taskId =
269
+ method === A2A_METHODS.subscribeToTask
270
+ ? (rpc.params as { id?: string })?.id
271
+ : await this.ensureTask(rpc.params as { message?: Message });
272
+ if (!taskId) {
273
+ onEvent(`event: error\ndata: ${JSON.stringify({ code: A2A_ERROR_CODES.INVALID_PARAMS, message: 'Missing message' })}\n\n`);
274
+ return { status: 200 };
275
+ }
276
+ const task = this.store.get(taskId);
277
+ if (!task) {
278
+ onEvent(`event: error\ndata: ${JSON.stringify({ code: A2A_ERROR_CODES.TASK_NOT_FOUND, message: `Task ${taskId} not found` })}\n\n`);
279
+ return { status: 200 };
280
+ }
281
+ const sub = (ev: StreamResponse) => onEvent(`data: ${JSON.stringify(ev)}\n\n`);
282
+ this.listeners.add(sub);
283
+ this.options.onInbound?.({
284
+ method,
285
+ headers: req.headers as Record<string, string> | undefined,
286
+ source: sourceOf(req),
287
+ taskIds: taskId ? [taskId] : [],
288
+ streaming: true,
289
+ });
290
+ try {
291
+ // If this was a fresh SendStreamingMessage, kick off execution now.
292
+ if (method === A2A_METHODS.sendStreamingMessage) {
293
+ const msg = (rpc.params as { message?: Message }).message!;
294
+ this.runTask(task.id, task.contextId!, msg);
295
+ }
296
+ // Keep the stream open until the task reaches a terminal state.
297
+ await this.waitTerminal(task.id);
298
+ onEvent(`data: ${JSON.stringify({ task: this.store.get(taskId) })}\n\n`);
299
+ } finally {
300
+ this.listeners.delete(sub);
301
+ }
302
+ return { status: 200 };
303
+ }
304
+ onEvent(`event: error\ndata: ${JSON.stringify({ code: A2A_ERROR_CODES.METHOD_NOT_FOUND, message: `Unknown method ${method}` })}\n\n`);
305
+ return { status: 200 };
306
+ }
307
+
308
+ private async ensureTask(params: { message?: Message } | undefined): Promise<string | undefined> {
309
+ if (!params?.message) return undefined;
310
+ const msg = params.message;
311
+ // If message references an existing task, continue it; else create.
312
+ if (msg.taskId && this.store.get(msg.taskId)) return msg.taskId;
313
+ const task = this.store.create(msg);
314
+ return task.id;
315
+ }
316
+
317
+ private async waitTerminal(taskId: string): Promise<void> {
318
+ for (;;) {
319
+ const t = this.store.get(taskId);
320
+ if (t && isTerminal(t.status.state as TaskState)) return;
321
+ await new Promise((r) => setTimeout(r, 200));
322
+ }
323
+ }
324
+
325
+ /** Execute a task in the background, streaming status/artifact updates. */
326
+ private async runTask(taskId: string, contextId: string, msg: Message) {
327
+ this.store.setStatus(taskId, TaskState.WORKING);
328
+ this.emit({ statusUpdate: { taskId, contextId, status: this.store.get(taskId)!.status } });
329
+ const signal = new AbortController().signal;
330
+ try {
331
+ const reply = await this.execute({ message: msg, taskId, contextId, signal });
332
+ this.store.addArtifact(taskId, { artifactId: 'result', parts: reply.parts, lastChunk: true });
333
+ this.store.setStatus(taskId, TaskState.COMPLETED, reply);
334
+ this.options.onTaskSettled?.(taskId);
335
+ const task = this.store.get(taskId)!;
336
+ this.emit({ task });
337
+ this.emit({ artifactUpdate: { taskId, contextId, artifact: task.artifacts![task.artifacts!.length - 1], lastChunk: true } });
338
+ } catch (err) {
339
+ const message: Message = {
340
+ messageId: crypto.randomUUID(),
341
+ role: Role.AGENT,
342
+ parts: [{ text: `Task failed: ${(err as Error).message ?? String(err)}` }],
343
+ };
344
+ this.store.setStatus(taskId, TaskState.FAILED, message);
345
+ this.options.onTaskSettled?.(taskId);
346
+ this.emit({ statusUpdate: { taskId, contextId, status: this.store.get(taskId)!.status } });
347
+ }
348
+ }
349
+
350
+ private async dispatch(rpc: JsonRpcRequest): Promise<unknown> {
351
+ const method = rpc.method;
352
+ const params = (rpc.params ?? {}) as Record<string, unknown>;
353
+ switch (method) {
354
+ case A2A_METHODS.sendMessage: {
355
+ const msg = params.message as Message | undefined;
356
+ if (!msg?.parts?.length) throw { code: A2A_ERROR_CODES.INVALID_PARAMS, message: 'Missing message' };
357
+ const taskId = await this.ensureTask(params as { message?: Message });
358
+ const task = this.store.get(taskId!)!;
359
+ // Run synchronously (returnImmediately=false default) — execute inline,
360
+ // but don't block forever: the executor contract returns a Message.
361
+ this.runTask(task.id, task.contextId!, msg);
362
+ // For a stateless-style SendMessage, wait for completion.
363
+ await this.waitTerminal(task.id);
364
+ return { task: this.store.get(task.id) };
365
+ }
366
+ case A2A_METHODS.getTask: {
367
+ const id = params.id as string;
368
+ const task = this.store.get(id);
369
+ if (!task) throw { code: A2A_ERROR_CODES.TASK_NOT_FOUND, message: `Task ${id} not found` };
370
+ return task;
371
+ }
372
+ case A2A_METHODS.listTasks: {
373
+ const tasks = this.store.list();
374
+ return {
375
+ tasks,
376
+ nextPageToken: '',
377
+ pageSize: tasks.length,
378
+ totalSize: tasks.length,
379
+ };
380
+ }
381
+ case A2A_METHODS.cancelTask: {
382
+ const id = params.id as string;
383
+ const task = this.store.get(id);
384
+ if (!task) throw { code: A2A_ERROR_CODES.TASK_NOT_FOUND, message: `Task ${id} not found` };
385
+ if (isTerminal(task.status.state as TaskState)) {
386
+ throw { code: A2A_ERROR_CODES.TASK_CANCEL_NOT_ALLOWED, message: `Task ${id} already in terminal state ${task.status.state}` };
387
+ }
388
+ this.store.setStatus(id, TaskState.CANCELED);
389
+ this.options.onTaskSettled?.(id);
390
+ return this.store.get(id);
391
+ }
392
+ case A2A_METHODS.getExtendedAgentCard:
393
+ return this.card;
394
+ default:
395
+ throw { code: A2A_ERROR_CODES.METHOD_NOT_FOUND, message: `Method ${method} not supported` };
396
+ }
397
+ }
398
+
399
+ private jsonRpcError(id: string | number | null, code: number, message: string, data?: unknown): { status: number; contentType: string; body: string } {
400
+ const err: JsonRpcError = { jsonrpc: '2.0', id, error: { code, message, ...(data !== undefined ? { data } : {}) } };
401
+ return { status: 200, contentType: 'application/json', body: JSON.stringify(err) };
402
+ }
403
+ }
404
+
405
+ /** Pull task ids out of a dispatch result for inbound peer tracking. */
406
+ function extractTaskIds(result: unknown): string[] {
407
+ if (!result || typeof result !== 'object') return [];
408
+ const r = result as { task?: { id?: string }; tasks?: Array<{ id?: string }> };
409
+ if (typeof r.task?.id === 'string') return [r.task.id];
410
+ if (Array.isArray(r.tasks)) {
411
+ return r.tasks.map((t) => t.id).filter((id): id is string => typeof id === 'string');
412
+ }
413
+ return [];
414
+ }
415
+
416
+ export const defaultSkills: ServerSkill[] = [
417
+ {
418
+ id: 'coding',
419
+ name: 'Coding',
420
+ description: 'Execute coding and shell tasks inside the DSH workspace.',
421
+ tags: ['coding', 'shell'],
422
+ inputModes: ['text/plain'],
423
+ outputModes: ['text/plain'],
424
+ },
425
+ ];
426
+
427
+ /**
428
+ * Default executor: run the incoming prompt through the system shell and
429
+ * return the captured stdout/stderr as the task artifact. Honors the
430
+ * AbortSignal (task cancellation / shutdown) by killing the child process.
431
+ */
432
+ export const defaultExecutor: TaskExecutor = async ({ message, signal }) => {
433
+ const text = message.parts
434
+ .map((p) => ('text' in p ? p.text : ''))
435
+ .filter(Boolean)
436
+ .join('\n');
437
+ if (!text.trim()) {
438
+ return { messageId: crypto.randomUUID(), role: Role.AGENT, parts: [{ text: 'No prompt provided.' }] };
439
+ }
440
+ try {
441
+ const { execFile } = await import('node:child_process');
442
+ const out = await new Promise<string>((resolve, _reject) => {
443
+ const child = execFile(
444
+ '/bin/sh',
445
+ ['-c', text],
446
+ { timeout: 30000, maxBuffer: 1024 * 1024, signal },
447
+ (err, stdout, stderr) => {
448
+ if (err) {
449
+ if ((err as NodeJS.ErrnoException & { killed?: boolean }).killed || signal?.aborted) {
450
+ resolve(`(aborted) ${stdout}${stderr}`.trim() || '(aborted)');
451
+ return;
452
+ }
453
+ resolve(`(exit ${(err as { code?: number | string }).code ?? '?'}) ${stdout}\n${stderr}`);
454
+ return;
455
+ }
456
+ resolve(`${stdout}\n${stderr}`.trim());
457
+ },
458
+ );
459
+ // Redundant safety: if the signal fires between spawn and execFile's
460
+ // internal wiring, make sure the child is killed.
461
+ signal?.addEventListener('abort', () => child.kill(), { once: true });
462
+ });
463
+ return { messageId: crypto.randomUUID(), role: Role.AGENT, parts: [{ text: out || '(no output)' }] };
464
+ } catch (err) {
465
+ return { messageId: crypto.randomUUID(), role: Role.AGENT, parts: [{ text: `Executor error: ${(err as Error).message}` }] };
466
+ }
467
+ };