@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/LICENSE +21 -0
- package/README.md +224 -0
- package/README.zh-CN.md +196 -0
- package/cordis.patch.yml +12 -0
- package/lib/client.js +574 -0
- package/lib/index.js +1896 -0
- package/lib/types/a2a-config.d.ts +107 -0
- package/lib/types/a2a-config.d.ts.map +1 -0
- package/lib/types/card.d.ts +40 -0
- package/lib/types/card.d.ts.map +1 -0
- package/lib/types/client/index.d.ts +22 -0
- package/lib/types/client/index.d.ts.map +1 -0
- package/lib/types/client.d.ts +52 -0
- package/lib/types/client.d.ts.map +1 -0
- package/lib/types/dashboard.d.ts +144 -0
- package/lib/types/dashboard.d.ts.map +1 -0
- package/lib/types/errors.d.ts +24 -0
- package/lib/types/errors.d.ts.map +1 -0
- package/lib/types/index.d.ts +89 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/jsonrpc.d.ts +40 -0
- package/lib/types/jsonrpc.d.ts.map +1 -0
- package/lib/types/outbound.d.ts +45 -0
- package/lib/types/outbound.d.ts.map +1 -0
- package/lib/types/protocol.d.ts +330 -0
- package/lib/types/protocol.d.ts.map +1 -0
- package/lib/types/server.d.ts +142 -0
- package/lib/types/server.d.ts.map +1 -0
- package/package.json +89 -0
- package/src/a2a-config.ts +201 -0
- package/src/card.ts +143 -0
- package/src/client/index.ts +613 -0
- package/src/client.ts +141 -0
- package/src/dashboard.ts +350 -0
- package/src/errors.ts +39 -0
- package/src/index.ts +659 -0
- package/src/jsonrpc.ts +185 -0
- package/src/outbound.ts +186 -0
- package/src/protocol.ts +308 -0
- package/src/server.ts +467 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,659 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-a2a — Cordis plugin entry point for DeepSeek Harness.
|
|
3
|
+
*
|
|
4
|
+
* Mounts the A2A plugin inside a DSH composition. The plugin is
|
|
5
|
+
* configuration-driven and opportunistically consumes DSH services:
|
|
6
|
+
*
|
|
7
|
+
* mode: 'client' — fetch a remote AgentCard, register each skill as a
|
|
8
|
+
* model tool on `ctx.tools` (name `a2a__<name>__<skill>`).
|
|
9
|
+
* mode: 'server' — expose this DSH as an A2A agent: AgentCard +
|
|
10
|
+
* JSON-RPC endpoint on `ctx.webServer` (when mounted).
|
|
11
|
+
*
|
|
12
|
+
* Both modes feed a connection dashboard (see `dashboard.ts`) exposed at
|
|
13
|
+
* `/a2a/api` (state snapshot + reconnect/close controls), so the settings
|
|
14
|
+
* panel can show "who is connected to us" (inbound) and "who we are
|
|
15
|
+
* connected to" (outbound).
|
|
16
|
+
*
|
|
17
|
+
* Service access follows the native DSH pattern: services are declared via
|
|
18
|
+
* module augmentation and read as context properties inside `ctx.effect(...)`,
|
|
19
|
+
* so Cordis waits for availability and disposes registrations with the fiber.
|
|
20
|
+
* The plugin stays mounted in compositions that lack a service (it logs and
|
|
21
|
+
* idles); it never hard-depends on one.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
25
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
26
|
+
import { registerAgentTools } from './outbound.js';
|
|
27
|
+
import { fetchAgentCard, pickInterface } from './card.js';
|
|
28
|
+
import { A2AServer, type ServerOptions } from './server.js';
|
|
29
|
+
import { TaskStore } from './server.js';
|
|
30
|
+
import { getSharedRegistry } from './dashboard.js';
|
|
31
|
+
import {
|
|
32
|
+
loadConfig as loadPersistedA2A,
|
|
33
|
+
saveConfig as savePersistedA2A,
|
|
34
|
+
mergePersisted,
|
|
35
|
+
type PersistedA2AConfig,
|
|
36
|
+
resolveConfigPath,
|
|
37
|
+
} from './a2a-config.js';
|
|
38
|
+
|
|
39
|
+
export interface A2APluginConfig {
|
|
40
|
+
mode?: 'client' | 'server' | 'both';
|
|
41
|
+
// client
|
|
42
|
+
name?: string;
|
|
43
|
+
agentCardUrl?: string;
|
|
44
|
+
bearerToken?: string;
|
|
45
|
+
timeoutMs?: number;
|
|
46
|
+
mapSkills?: boolean;
|
|
47
|
+
// server
|
|
48
|
+
/** Explicitly enable the inbound A2A server (default off). */
|
|
49
|
+
serverEnabled?: boolean;
|
|
50
|
+
baseUrl?: string;
|
|
51
|
+
agentName?: string;
|
|
52
|
+
agentDescription?: string;
|
|
53
|
+
agentVersion?: string;
|
|
54
|
+
skills?: ServerOptions['skills'];
|
|
55
|
+
endpointPath?: string;
|
|
56
|
+
execute?: ServerOptions['execute'];
|
|
57
|
+
// dashboard
|
|
58
|
+
/** Serve the dashboard API on the webserver (default true). */
|
|
59
|
+
dashboard?: boolean;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export { A2AClient } from './client.js';
|
|
63
|
+
export * from './protocol.js';
|
|
64
|
+
export * from './errors.js';
|
|
65
|
+
export { fetchAgentCard } from './card.js';
|
|
66
|
+
export { A2AServer, TaskStore, defaultExecutor, defaultSkills } from './server.js';
|
|
67
|
+
export { registerAgentTools } from './outbound.js';
|
|
68
|
+
export { DashboardRegistry, getSharedRegistry } from './dashboard.js';
|
|
69
|
+
export type {
|
|
70
|
+
DashboardSnapshot,
|
|
71
|
+
InboundPeer,
|
|
72
|
+
OutboundAgent,
|
|
73
|
+
ControlResult,
|
|
74
|
+
} from './dashboard.js';
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Service shapes consumed from the DSH composition. Declared structurally so
|
|
78
|
+
* this package builds against `@deepseek-ai/cordis` alone (peer dependency);
|
|
79
|
+
* the runtime shapes are owned by `@deepseek-ai/dsh-host-webserver` and
|
|
80
|
+
* `@deepseek-ai/dsh-tools` respectively.
|
|
81
|
+
*/
|
|
82
|
+
export interface WebServerService {
|
|
83
|
+
/** The OS-assigned (or configured) listen port. */
|
|
84
|
+
readonly port: number;
|
|
85
|
+
/** The configured bind host literal ('127.0.0.1' | '0.0.0.0'). */
|
|
86
|
+
readonly host: string;
|
|
87
|
+
register(route: {
|
|
88
|
+
kind: 'exact' | 'prefix';
|
|
89
|
+
path: string;
|
|
90
|
+
handler: (req: IncomingMessage, res: ServerResponse) => void | Promise<void>;
|
|
91
|
+
}): () => void;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
declare module '@deepseek-ai/cordis' {
|
|
95
|
+
interface Context {
|
|
96
|
+
/** Provided by @deepseek-ai/dsh-host-webserver. */
|
|
97
|
+
webServer: WebServerService;
|
|
98
|
+
/** Provided by @deepseek-ai/dsh-tools. */
|
|
99
|
+
tools: { register(def: unknown): () => void };
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const DEFAULT_ENDPOINT = '/a2a';
|
|
104
|
+
const DEFAULT_API_PATH = '/a2a/api';
|
|
105
|
+
|
|
106
|
+
/** Whether the shared dashboard API route is currently registered (once per process). */
|
|
107
|
+
let dashboardApiRegistered = false;
|
|
108
|
+
|
|
109
|
+
export const name = 'a2a';
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Services this plugin may read as context properties. Declared so property
|
|
113
|
+
* reads inside `apply` never trip Cordis's "without inject" guard. The
|
|
114
|
+
* mode-specific wiring runs inside `ctx.inject(...)` for the single service it
|
|
115
|
+
* needs, so a composition lacking the other one simply idles that half.
|
|
116
|
+
*/
|
|
117
|
+
export const inject = ['webServer', 'tools'] as const;
|
|
118
|
+
|
|
119
|
+
export function apply(ctx: Context, config: A2APluginConfig = {}) {
|
|
120
|
+
// Persisted `a2a.json` (per-profile, UI-editable) wins over static config.
|
|
121
|
+
const persisted: PersistedA2AConfig = loadPersistedA2A();
|
|
122
|
+
const mergedConfig: A2APluginConfig = mergePersisted(config, persisted);
|
|
123
|
+
const configPath = resolveConfigPath();
|
|
124
|
+
const mode = mergedConfig.mode ?? 'client';
|
|
125
|
+
const logger = ctx.logger;
|
|
126
|
+
const dashboard = getSharedRegistry();
|
|
127
|
+
const wantsClient = mode === 'client' || mode === 'both';
|
|
128
|
+
// `mode: 'server' | 'both'` enables the inbound server; an explicit
|
|
129
|
+
// `server.enabled` in a2a.json also turns it on. Default is OFF — support
|
|
130
|
+
// (both) is not the same as exposure.
|
|
131
|
+
const serverEnabled = mergedConfig.serverEnabled === true;
|
|
132
|
+
const wantsServer = mode === 'server' || mode === 'both' || serverEnabled;
|
|
133
|
+
|
|
134
|
+
// ── dashboard API (GET/POST /a2a/api) — registered for BOTH modes, once per
|
|
135
|
+
// process, as soon as webServer is available ────────────────────────────────
|
|
136
|
+
if (mergedConfig.dashboard !== false) {
|
|
137
|
+
ctx.inject(['webServer'], (ctx) => {
|
|
138
|
+
if (dashboardApiRegistered) return;
|
|
139
|
+
dashboardApiRegistered = true;
|
|
140
|
+
let apiRoute: (() => void) | undefined;
|
|
141
|
+
apiRoute = ctx.webServer.register({
|
|
142
|
+
kind: 'prefix',
|
|
143
|
+
path: DEFAULT_API_PATH,
|
|
144
|
+
handler: async (req, res) => {
|
|
145
|
+
if (!trustedLoopback(req)) {
|
|
146
|
+
res.writeHead(403, { 'content-type': 'text/plain' });
|
|
147
|
+
res.end('forbidden');
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
if (req.method === 'GET') {
|
|
151
|
+
res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-cache' });
|
|
152
|
+
res.end(JSON.stringify(dashboard.snapshot()));
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
if (req.method === 'POST') {
|
|
156
|
+
let payload: unknown;
|
|
157
|
+
try {
|
|
158
|
+
payload = JSON.parse(await readBody(req));
|
|
159
|
+
} catch {
|
|
160
|
+
res.writeHead(400, { 'content-type': 'application/json' });
|
|
161
|
+
res.end(JSON.stringify({ ok: false, message: 'invalid JSON body' }));
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
const { action, target, ...rest } = (payload ?? {}) as { action?: string; target?: string; [k: string]: unknown };
|
|
165
|
+
if (!action) {
|
|
166
|
+
res.writeHead(400, { 'content-type': 'application/json' });
|
|
167
|
+
res.end(JSON.stringify({ ok: false, message: 'action is required' }));
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
const result = await dashboard.control(action, target ?? '', rest);
|
|
171
|
+
res.writeHead(result.ok ? 200 : 409, { 'content-type': 'application/json' });
|
|
172
|
+
res.end(JSON.stringify(result));
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
res.writeHead(405, { 'content-type': 'text/plain' });
|
|
176
|
+
res.end('Method Not Allowed');
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
ctx.effect(() => () => {
|
|
180
|
+
dashboardApiRegistered = false;
|
|
181
|
+
apiRoute?.();
|
|
182
|
+
}, 'a2a: dashboard api cleanup');
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ── client mode: remote skills → ctx.tools ────────────────────────────────
|
|
187
|
+
if (wantsClient) {
|
|
188
|
+
const agentName = mergedConfig.name ?? 'remote';
|
|
189
|
+
const initialAgents: Array<{ name: string; agentCardUrl: string; configured: boolean; enabled: boolean; bearerToken?: string; timeoutMs?: number; mapSkills?: boolean }> = [];
|
|
190
|
+
// Persisted agents (a2a.json) — UI-managed, survive restart.
|
|
191
|
+
for (const a of persisted.agents ?? []) {
|
|
192
|
+
if (a.name && a.agentCardUrl) {
|
|
193
|
+
initialAgents.push({ name: a.name, agentCardUrl: a.agentCardUrl, configured: true, enabled: a.enabled !== false, bearerToken: a.bearerToken, timeoutMs: a.timeoutMs, mapSkills: a.mapSkills });
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
// Legacy static config (agentCardUrl in composition) — still honoured.
|
|
197
|
+
if (mergedConfig.agentCardUrl) {
|
|
198
|
+
initialAgents.push({ name: agentName, agentCardUrl: mergedConfig.agentCardUrl, configured: true, enabled: true, bearerToken: mergedConfig.bearerToken, timeoutMs: mergedConfig.timeoutMs, mapSkills: mergedConfig.mapSkills });
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
ctx.inject(['tools'], (ctx) => {
|
|
202
|
+
// Live session: maps connectionId → connection record.
|
|
203
|
+
const live = new Map<string, { disposeAll: () => void; name: string; agentCardUrl: string; configured?: boolean; enabled: boolean }>();
|
|
204
|
+
const configuredAgents = new Set<string>();
|
|
205
|
+
|
|
206
|
+
const connect = async (
|
|
207
|
+
name: string,
|
|
208
|
+
agentCardUrl: string,
|
|
209
|
+
configured = false,
|
|
210
|
+
agentOpts: { bearerToken?: string; timeoutMs?: number; mapSkills?: boolean } = {},
|
|
211
|
+
): Promise<string | undefined> => {
|
|
212
|
+
let disposers: Array<() => void> = [];
|
|
213
|
+
let connectionId: string | undefined;
|
|
214
|
+
try {
|
|
215
|
+
const regs = await registerAgentTools(ctx.tools, {
|
|
216
|
+
name,
|
|
217
|
+
agentCardUrl,
|
|
218
|
+
bearerToken: agentOpts.bearerToken ?? mergedConfig.bearerToken,
|
|
219
|
+
timeoutMs: agentOpts.timeoutMs ?? mergedConfig.timeoutMs,
|
|
220
|
+
mapSkills: agentOpts.mapSkills ?? mergedConfig.mapSkills,
|
|
221
|
+
onReady: (info) => {
|
|
222
|
+
connectionId = info.connectionId;
|
|
223
|
+
dashboard.upsertAgent({
|
|
224
|
+
connectionId: info.connectionId,
|
|
225
|
+
name,
|
|
226
|
+
agentCardUrl,
|
|
227
|
+
agentName: info.card.name,
|
|
228
|
+
skillCount: (info.card.skills ?? []).length,
|
|
229
|
+
toolCount: 0,
|
|
230
|
+
state: 'connected',
|
|
231
|
+
configured: configured ? true : undefined,
|
|
232
|
+
skills: (info.card.skills ?? []).map((s) => ({ id: s.id, name: s.name, description: s.description, tags: s.tags })),
|
|
233
|
+
});
|
|
234
|
+
},
|
|
235
|
+
onDispose: (cid) => {
|
|
236
|
+
dashboard.removeAgent(cid);
|
|
237
|
+
},
|
|
238
|
+
});
|
|
239
|
+
disposers = regs.map((r) => r.dispose);
|
|
240
|
+
if (connectionId) {
|
|
241
|
+
dashboard.upsertAgent({
|
|
242
|
+
connectionId,
|
|
243
|
+
name,
|
|
244
|
+
agentCardUrl,
|
|
245
|
+
skillCount: 0,
|
|
246
|
+
toolCount: regs.length,
|
|
247
|
+
state: 'connected',
|
|
248
|
+
configured: configured ? true : undefined,
|
|
249
|
+
});
|
|
250
|
+
live.set(connectionId, { disposeAll: () => disposers.forEach((d) => d()), name, agentCardUrl, configured, enabled: true });
|
|
251
|
+
if (configured) configuredAgents.add(connectionId);
|
|
252
|
+
}
|
|
253
|
+
logger?.info?.(`[a2a] registered ${regs.length} tool(s) for agent "${name}"`);
|
|
254
|
+
return connectionId;
|
|
255
|
+
} catch (err) {
|
|
256
|
+
logger?.error?.(`[a2a] failed to register agent "${name}": ${(err as Error).message}`);
|
|
257
|
+
return undefined;
|
|
258
|
+
}
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
// Persist the agent list to a2a.json (merge with existing).
|
|
262
|
+
const persistAgents = (): void => {
|
|
263
|
+
const next: PersistedA2AConfig = { ...loadPersistedA2A(), agents: [] };
|
|
264
|
+
for (const a of initialAgents) {
|
|
265
|
+
if (a.configured) next.agents!.push({ name: a.name, agentCardUrl: a.agentCardUrl, bearerToken: a.bearerToken, timeoutMs: a.timeoutMs, mapSkills: a.mapSkills, enabled: a.enabled === false ? false : undefined });
|
|
266
|
+
}
|
|
267
|
+
const res = savePersistedA2A(next);
|
|
268
|
+
if (!res.ok) logger?.error?.(`[a2a] failed to save ${res.path}: ${res.message}`);
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
// Find an initial (persisted) entry by live connection.
|
|
272
|
+
const findInitial = (entry: { name: string; agentCardUrl: string }): { idx: number; item: { name: string; agentCardUrl: string; configured: boolean; enabled: boolean } } | undefined => {
|
|
273
|
+
const idx = initialAgents.findIndex((a) => a.configured && a.name === entry.name && a.agentCardUrl === entry.agentCardUrl);
|
|
274
|
+
if (idx < 0) return undefined;
|
|
275
|
+
return { idx, item: initialAgents[idx] };
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
// Wire dashboard control hooks for outbound connections.
|
|
279
|
+
dashboard.setHooks({
|
|
280
|
+
closeAgent: async (connectionId) => {
|
|
281
|
+
const entry = live.get(connectionId);
|
|
282
|
+
if (!entry) return { ok: false, message: `connection ${connectionId} not found` };
|
|
283
|
+
entry.disposeAll();
|
|
284
|
+
live.delete(connectionId);
|
|
285
|
+
dashboard.removeAgent(connectionId);
|
|
286
|
+
dashboard.setAgentState(connectionId, 'disconnected');
|
|
287
|
+
dashboard.upsertAgent({
|
|
288
|
+
connectionId,
|
|
289
|
+
name: entry.name,
|
|
290
|
+
agentCardUrl: entry.agentCardUrl,
|
|
291
|
+
skillCount: 0,
|
|
292
|
+
toolCount: 0,
|
|
293
|
+
state: 'disconnected',
|
|
294
|
+
enabled: false,
|
|
295
|
+
configured: entry.configured ? true : undefined,
|
|
296
|
+
});
|
|
297
|
+
return { ok: true, message: `connection ${connectionId} closed` };
|
|
298
|
+
},
|
|
299
|
+
disableAgent: async (connectionId) => {
|
|
300
|
+
const entry = live.get(connectionId);
|
|
301
|
+
// A disabled row may still be present as a "configured but off" card.
|
|
302
|
+
let name = entry?.name;
|
|
303
|
+
let url = entry?.agentCardUrl;
|
|
304
|
+
let configured = entry?.configured ?? false;
|
|
305
|
+
if (!entry) {
|
|
306
|
+
// find by connectionId/proxy scan on live only; fall back to the
|
|
307
|
+
// last registered card id mapping stored by upsert below.
|
|
308
|
+
const snap = dashboard.snapshot().outbound.find((o) => o.connectionId === connectionId);
|
|
309
|
+
if (!snap) return { ok: false, message: `connection ${connectionId} not found` };
|
|
310
|
+
name = snap.name;
|
|
311
|
+
url = snap.agentCardUrl;
|
|
312
|
+
configured = snap.configured ?? false;
|
|
313
|
+
}
|
|
314
|
+
if (entry) {
|
|
315
|
+
entry.disposeAll();
|
|
316
|
+
live.delete(connectionId);
|
|
317
|
+
dashboard.setAgentState(connectionId, 'disconnected');
|
|
318
|
+
} else {
|
|
319
|
+
dashboard.setAgentState(connectionId, 'disconnected');
|
|
320
|
+
}
|
|
321
|
+
// If it came from the persisted config, flip enabled:false (keep entry).
|
|
322
|
+
const found = name !== undefined && url !== undefined ? findInitial({ name, agentCardUrl: url }) : undefined;
|
|
323
|
+
if (found) {
|
|
324
|
+
found.item.enabled = false;
|
|
325
|
+
persistAgents();
|
|
326
|
+
}
|
|
327
|
+
const prevSnap = dashboard.snapshot().outbound.find((o) => o.connectionId === connectionId);
|
|
328
|
+
dashboard.upsertAgent({
|
|
329
|
+
connectionId,
|
|
330
|
+
name: name ?? 'unknown',
|
|
331
|
+
agentCardUrl: url ?? '',
|
|
332
|
+
agentName: prevSnap?.agentName ?? name,
|
|
333
|
+
skillCount: 0,
|
|
334
|
+
toolCount: 0,
|
|
335
|
+
state: 'disconnected',
|
|
336
|
+
enabled: false,
|
|
337
|
+
configured: configured ? true : undefined,
|
|
338
|
+
});
|
|
339
|
+
return { ok: true, message: `agent ${connectionId} disabled (config kept)` };
|
|
340
|
+
},
|
|
341
|
+
enableAgent: async (connectionId) => {
|
|
342
|
+
const snap = dashboard.snapshot().outbound.find((o) => o.connectionId === connectionId);
|
|
343
|
+
if (!snap) return { ok: false, message: `connection ${connectionId} not found` };
|
|
344
|
+
const found = findInitial({ name: snap.name, agentCardUrl: snap.agentCardUrl });
|
|
345
|
+
if (found) {
|
|
346
|
+
found.item.enabled = true;
|
|
347
|
+
persistAgents();
|
|
348
|
+
}
|
|
349
|
+
dashboard.setAgentState(connectionId, 'reconnecting');
|
|
350
|
+
const newId = await connect(snap.name, snap.agentCardUrl, snap.configured ?? false);
|
|
351
|
+
if (!newId) return { ok: false, message: `enable of ${connectionId} failed to connect` };
|
|
352
|
+
dashboard.removeAgent(connectionId);
|
|
353
|
+
return { ok: true, message: `agent ${connectionId} enabled and reconnected as ${newId}` };
|
|
354
|
+
},
|
|
355
|
+
reconnectAgent: async (connectionId) => {
|
|
356
|
+
const existing = live.get(connectionId);
|
|
357
|
+
const prevName = existing?.name ?? agentName;
|
|
358
|
+
const prevUrl = existing?.agentCardUrl ?? mergedConfig.agentCardUrl!;
|
|
359
|
+
const wasConfigured = !!existing?.configured || configuredAgents.has(connectionId);
|
|
360
|
+
if (existing) existing.disposeAll();
|
|
361
|
+
live.delete(connectionId);
|
|
362
|
+
dashboard.setAgentState(connectionId, 'reconnecting');
|
|
363
|
+
const newId = await connect(prevName, prevUrl, wasConfigured);
|
|
364
|
+
if (!newId) return { ok: false, message: `reconnect of ${connectionId} failed` };
|
|
365
|
+
dashboard.removeAgent(connectionId);
|
|
366
|
+
return { ok: true, message: `connection ${connectionId} reconnected as ${newId}` };
|
|
367
|
+
},
|
|
368
|
+
addAgent: async (name, agentCardUrl) => {
|
|
369
|
+
const cid = await connect(name, agentCardUrl, true);
|
|
370
|
+
if (!cid) return { ok: false, message: `failed to connect to ${agentCardUrl}` };
|
|
371
|
+
// Persist: add to a2a.json so it survives restart.
|
|
372
|
+
initialAgents.push({ name, agentCardUrl, configured: true, enabled: true });
|
|
373
|
+
persistAgents();
|
|
374
|
+
return { ok: true, message: `agent "${name}" connected as ${cid} (saved to ${configPath})` };
|
|
375
|
+
},
|
|
376
|
+
discoverAgent: async (agentCardUrl) => {
|
|
377
|
+
try {
|
|
378
|
+
const card = await fetchAgentCard(agentCardUrl, {
|
|
379
|
+
timeoutMs: mergedConfig.timeoutMs ?? 8000,
|
|
380
|
+
bearerToken: mergedConfig.bearerToken,
|
|
381
|
+
});
|
|
382
|
+
const iface = pickInterface(card);
|
|
383
|
+
return {
|
|
384
|
+
ok: true,
|
|
385
|
+
message: JSON.stringify({
|
|
386
|
+
name: card.name,
|
|
387
|
+
description: card.description,
|
|
388
|
+
version: card.version,
|
|
389
|
+
agentCardUrl,
|
|
390
|
+
endpoint: iface?.url,
|
|
391
|
+
skills: (card.skills ?? []).map((s) => ({ id: s.id, name: s.name, description: s.description })),
|
|
392
|
+
capabilities: card.capabilities,
|
|
393
|
+
defaultInputModes: card.defaultInputModes,
|
|
394
|
+
defaultOutputModes: card.defaultOutputModes,
|
|
395
|
+
}),
|
|
396
|
+
};
|
|
397
|
+
} catch (err) {
|
|
398
|
+
return { ok: false, message: `discover failed: ${(err as Error).message}` };
|
|
399
|
+
}
|
|
400
|
+
},
|
|
401
|
+
removeAgent: async (connectionId) => {
|
|
402
|
+
const entry = live.get(connectionId);
|
|
403
|
+
let name: string | undefined;
|
|
404
|
+
let url: string | undefined;
|
|
405
|
+
let configured = false;
|
|
406
|
+
if (entry) {
|
|
407
|
+
name = entry.name;
|
|
408
|
+
url = entry.agentCardUrl;
|
|
409
|
+
configured = entry.configured ?? false;
|
|
410
|
+
entry.disposeAll();
|
|
411
|
+
live.delete(connectionId);
|
|
412
|
+
} else {
|
|
413
|
+
const snap = dashboard.snapshot().outbound.find((o) => o.connectionId === connectionId);
|
|
414
|
+
if (!snap) return { ok: false, message: `connection ${connectionId} not found` };
|
|
415
|
+
name = snap.name;
|
|
416
|
+
url = snap.agentCardUrl;
|
|
417
|
+
configured = snap.configured ?? false;
|
|
418
|
+
}
|
|
419
|
+
if (configured && name !== undefined && url !== undefined) {
|
|
420
|
+
// Drop it from the persisted list entirely (this is a real delete).
|
|
421
|
+
const found = findInitial({ name, agentCardUrl: url });
|
|
422
|
+
if (found) {
|
|
423
|
+
initialAgents.splice(found.idx, 1);
|
|
424
|
+
persistAgents();
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
dashboard.removeAgent(connectionId);
|
|
428
|
+
return { ok: true, message: `agent ${connectionId} removed` };
|
|
429
|
+
},
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
ctx.effect(() => {
|
|
433
|
+
// Initial connections: persisted agents + legacy static config.
|
|
434
|
+
for (const a of initialAgents) {
|
|
435
|
+
if (a.enabled === false) {
|
|
436
|
+
// Show a "configured but disabled" placeholder card (no tools).
|
|
437
|
+
const phId = `out-cfg-${a.name}-${a.agentCardUrl}`;
|
|
438
|
+
dashboard.upsertAgent({
|
|
439
|
+
connectionId: phId,
|
|
440
|
+
name: a.name,
|
|
441
|
+
agentCardUrl: a.agentCardUrl,
|
|
442
|
+
agentName: a.name,
|
|
443
|
+
skillCount: 0,
|
|
444
|
+
toolCount: 0,
|
|
445
|
+
state: 'disconnected',
|
|
446
|
+
enabled: false,
|
|
447
|
+
configured: true,
|
|
448
|
+
});
|
|
449
|
+
continue;
|
|
450
|
+
}
|
|
451
|
+
void connect(a.name, a.agentCardUrl, a.configured, { bearerToken: a.bearerToken, timeoutMs: a.timeoutMs, mapSkills: a.mapSkills });
|
|
452
|
+
}
|
|
453
|
+
return () => {
|
|
454
|
+
for (const { disposeAll } of live.values()) disposeAll();
|
|
455
|
+
live.clear();
|
|
456
|
+
};
|
|
457
|
+
}, 'a2a: client tools');
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// ── server mode: expose DSH as an A2A agent ───────────────────────────────
|
|
462
|
+
if (wantsServer) {
|
|
463
|
+
const store = new TaskStore();
|
|
464
|
+
const taskPeer = new Map<string, string>();
|
|
465
|
+
const pendingSettled = new Set<string>();
|
|
466
|
+
ctx.inject(['webServer'], (ctx) => {
|
|
467
|
+
ctx.effect(() => {
|
|
468
|
+
const webServer = ctx.webServer;
|
|
469
|
+
// Derive the advertised base URL from the real listen address when the
|
|
470
|
+
// config doesn't pin one; avoids advertising a stale port.
|
|
471
|
+
const baseUrl =
|
|
472
|
+
mergedConfig.baseUrl ??
|
|
473
|
+
`http://${webServer.host === '0.0.0.0' ? '127.0.0.1' : webServer.host}:${webServer.port}`;
|
|
474
|
+
const endpointPath = mergedConfig.endpointPath ?? DEFAULT_ENDPOINT;
|
|
475
|
+
|
|
476
|
+
const server = new A2AServer(
|
|
477
|
+
{
|
|
478
|
+
baseUrl,
|
|
479
|
+
agentName: mergedConfig.agentName ?? 'DSH Agent',
|
|
480
|
+
agentDescription: mergedConfig.agentDescription ?? 'DeepSeek Harness agent exposed over A2A v1.0',
|
|
481
|
+
agentVersion: mergedConfig.agentVersion ?? '0.1.0',
|
|
482
|
+
skills: mergedConfig.skills,
|
|
483
|
+
endpointPath,
|
|
484
|
+
execute: mergedConfig.execute,
|
|
485
|
+
onInbound: (facts) => {
|
|
486
|
+
const peerId = dashboard.touchInbound(
|
|
487
|
+
{ headers: facts.headers, source: facts.source },
|
|
488
|
+
facts.taskIds,
|
|
489
|
+
);
|
|
490
|
+
for (const taskId of facts.taskIds) {
|
|
491
|
+
taskPeer.set(taskId, peerId);
|
|
492
|
+
// The task may already be terminal (synchronous SendMessage
|
|
493
|
+
// settles before onInbound runs) — apply the pending settle.
|
|
494
|
+
if (pendingSettled.delete(taskId)) {
|
|
495
|
+
dashboard.finishTask(peerId, taskId);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
},
|
|
499
|
+
onTaskSettled: (taskId) => {
|
|
500
|
+
const peerId = taskPeer.get(taskId);
|
|
501
|
+
if (peerId) {
|
|
502
|
+
dashboard.finishTask(peerId, taskId);
|
|
503
|
+
taskPeer.delete(taskId);
|
|
504
|
+
} else {
|
|
505
|
+
pendingSettled.add(taskId);
|
|
506
|
+
}
|
|
507
|
+
},
|
|
508
|
+
},
|
|
509
|
+
store,
|
|
510
|
+
);
|
|
511
|
+
|
|
512
|
+
// ── runtime-enabled route set ──────────────────────────────────────
|
|
513
|
+
let enabled = true;
|
|
514
|
+
let disposers: Array<() => void> = [];
|
|
515
|
+
|
|
516
|
+
const registerRoutes = (): void => {
|
|
517
|
+
// AgentCard discovery route.
|
|
518
|
+
disposers.push(
|
|
519
|
+
webServer.register({
|
|
520
|
+
kind: 'exact',
|
|
521
|
+
path: '/.well-known/agent-card.json',
|
|
522
|
+
handler: async (_req: IncomingMessage, res: ServerResponse) => {
|
|
523
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
524
|
+
res.end(JSON.stringify(server.card));
|
|
525
|
+
},
|
|
526
|
+
}),
|
|
527
|
+
);
|
|
528
|
+
// JSON-RPC endpoint (POST) + SSE.
|
|
529
|
+
disposers.push(
|
|
530
|
+
webServer.register({
|
|
531
|
+
kind: 'prefix',
|
|
532
|
+
path: server.endpointPath,
|
|
533
|
+
handler: async (req: IncomingMessage, res: ServerResponse) => {
|
|
534
|
+
const isStream = (req.headers.accept ?? '').includes('text/event-stream');
|
|
535
|
+
if (req.method === 'GET' && (req.url ?? '').startsWith(server.endpointPath + '/card')) {
|
|
536
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
537
|
+
res.end(JSON.stringify(server.card));
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
if (req.method !== 'POST') {
|
|
541
|
+
res.writeHead(405, { 'content-type': 'text/plain' });
|
|
542
|
+
res.end('Method Not Allowed');
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
const body = await readBody(req);
|
|
546
|
+
if (isStream) {
|
|
547
|
+
res.writeHead(200, {
|
|
548
|
+
'content-type': 'text/event-stream',
|
|
549
|
+
'cache-control': 'no-cache',
|
|
550
|
+
connection: 'keep-alive',
|
|
551
|
+
});
|
|
552
|
+
await server.handleStream(toServerReq(req), body, (frame) => res.write(frame));
|
|
553
|
+
res.end();
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
const out = await server.handle(toServerReq(req), body);
|
|
557
|
+
res.writeHead(out.status, { 'content-type': out.contentType });
|
|
558
|
+
res.end(out.body);
|
|
559
|
+
},
|
|
560
|
+
}),
|
|
561
|
+
);
|
|
562
|
+
};
|
|
563
|
+
const unregisterRoutes = (): void => {
|
|
564
|
+
for (const d of disposers) d();
|
|
565
|
+
disposers = [];
|
|
566
|
+
};
|
|
567
|
+
|
|
568
|
+
registerRoutes();
|
|
569
|
+
logger?.info?.(
|
|
570
|
+
`[a2a] A2A server mounted: AgentCard at ${baseUrl}/.well-known/agent-card.json, JSON-RPC at ${server.endpointPath}`,
|
|
571
|
+
);
|
|
572
|
+
|
|
573
|
+
// ── serve panel hooks ──────────────────────────────────────────────
|
|
574
|
+
const serverCard = (): Record<string, unknown> => ({
|
|
575
|
+
baseUrl,
|
|
576
|
+
agentName: server.card.name,
|
|
577
|
+
agentDescription: server.card.description,
|
|
578
|
+
agentVersion: server.card.version,
|
|
579
|
+
endpoint: `${baseUrl}${server.endpointPath}`,
|
|
580
|
+
agentCardUrl: `${baseUrl}/.well-known/agent-card.json`,
|
|
581
|
+
skills: (server.card.skills ?? []).map((s) => ({ id: s.id, name: s.name, description: s.description })),
|
|
582
|
+
customExecutor: Boolean(mergedConfig.execute),
|
|
583
|
+
});
|
|
584
|
+
dashboard.setHooks({
|
|
585
|
+
setServerEnabled: async (enable) => {
|
|
586
|
+
if (enable === enabled) return { ok: true, message: `server already ${enable ? 'enabled' : 'disabled'}` };
|
|
587
|
+
if (enable) {
|
|
588
|
+
registerRoutes();
|
|
589
|
+
enabled = true;
|
|
590
|
+
logger?.info?.('[a2a] A2A server enabled');
|
|
591
|
+
return { ok: true, message: 'A2A server enabled' };
|
|
592
|
+
}
|
|
593
|
+
unregisterRoutes();
|
|
594
|
+
enabled = false;
|
|
595
|
+
logger?.info?.('[a2a] A2A server disabled');
|
|
596
|
+
return { ok: true, message: 'A2A server disabled' };
|
|
597
|
+
},
|
|
598
|
+
serverStatus: () => ({
|
|
599
|
+
ok: true,
|
|
600
|
+
message: JSON.stringify({ enabled, ...serverCard() }),
|
|
601
|
+
}),
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
return () => {
|
|
605
|
+
unregisterRoutes();
|
|
606
|
+
};
|
|
607
|
+
}, 'a2a: server routes');
|
|
608
|
+
});
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// ── control hooks for outbound reconnect/close ────────────────────────────
|
|
612
|
+
// Wired per-instance inside the client-mode effect above.
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/** Drain the request body from a Node IncomingMessage. */
|
|
616
|
+
async function readBody(req: IncomingMessage): Promise<string> {
|
|
617
|
+
return new Promise<string>((resolve, reject) => {
|
|
618
|
+
const chunks: Buffer[] = [];
|
|
619
|
+
req.on('data', (c: Buffer) => chunks.push(c));
|
|
620
|
+
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
|
621
|
+
req.once('error', reject);
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/** Loopback/same-origin trust fence for the dashboard API (no remote write). */
|
|
626
|
+
function trustedLoopback(req: IncomingMessage): boolean {
|
|
627
|
+
const host = req.headers.host;
|
|
628
|
+
if (!host) return false;
|
|
629
|
+
const hostname = host.split(':')[0];
|
|
630
|
+
if (hostname !== '127.0.0.1' && hostname !== 'localhost' && hostname !== '::1') return false;
|
|
631
|
+
if (req.headers['sec-fetch-site'] === 'cross-site') return false;
|
|
632
|
+
const origin = req.headers.origin;
|
|
633
|
+
if (!origin) return true;
|
|
634
|
+
try {
|
|
635
|
+
return new URL(origin).host === host;
|
|
636
|
+
} catch {
|
|
637
|
+
return false;
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/** Adapt a Node IncomingMessage to the A2A server's structural request type. */
|
|
642
|
+
function toServerReq(req: IncomingMessage): {
|
|
643
|
+
method?: string;
|
|
644
|
+
url?: string;
|
|
645
|
+
headers?: Record<string, string>;
|
|
646
|
+
socket?: { remoteAddress?: string; remotePort?: number };
|
|
647
|
+
} {
|
|
648
|
+
const headers: Record<string, string> = {};
|
|
649
|
+
for (const [k, v] of Object.entries(req.headers)) {
|
|
650
|
+
if (typeof v === 'string') headers[k] = v;
|
|
651
|
+
else if (Array.isArray(v)) headers[k] = v.join(', ');
|
|
652
|
+
}
|
|
653
|
+
return {
|
|
654
|
+
method: req.method,
|
|
655
|
+
url: req.url,
|
|
656
|
+
headers,
|
|
657
|
+
socket: req.socket ? { remoteAddress: req.socket.remoteAddress, remotePort: req.socket.remotePort } : undefined,
|
|
658
|
+
};
|
|
659
|
+
}
|