@hanphone/dsh-a2a 0.1.0 → 0.3.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.
Files changed (88) hide show
  1. package/README.md +133 -87
  2. package/README.zh.md +64 -87
  3. package/cordis.patch.yml +4 -7
  4. package/lib/client.js +228 -36
  5. package/lib/index.js +1271 -625
  6. package/lib/types/api.d.ts +43 -13
  7. package/lib/types/api.d.ts.map +1 -1
  8. package/lib/types/api.js +60 -20
  9. package/lib/types/api.js.map +1 -1
  10. package/lib/types/client/index.d.ts +50 -14
  11. package/lib/types/client/index.d.ts.map +1 -1
  12. package/lib/types/commands.d.ts +4 -2
  13. package/lib/types/commands.d.ts.map +1 -1
  14. package/lib/types/commands.js +59 -46
  15. package/lib/types/commands.js.map +1 -1
  16. package/lib/types/events.d.ts +32 -9
  17. package/lib/types/events.d.ts.map +1 -1
  18. package/lib/types/index.d.ts +21 -32
  19. package/lib/types/index.d.ts.map +1 -1
  20. package/lib/types/index.js +178 -213
  21. package/lib/types/index.js.map +1 -1
  22. package/lib/types/outbound/calls.d.ts.map +1 -1
  23. package/lib/types/outbound/calls.js +8 -6
  24. package/lib/types/outbound/calls.js.map +1 -1
  25. package/lib/types/protocol.d.ts +174 -109
  26. package/lib/types/protocol.d.ts.map +1 -1
  27. package/lib/types/protocol.js +58 -34
  28. package/lib/types/protocol.js.map +1 -1
  29. package/lib/types/server/a2a-server.d.ts +7 -1
  30. package/lib/types/server/a2a-server.d.ts.map +1 -1
  31. package/lib/types/server/a2a-server.js +11 -3
  32. package/lib/types/server/a2a-server.js.map +1 -1
  33. package/lib/types/server/card.d.ts +6 -30
  34. package/lib/types/server/card.d.ts.map +1 -1
  35. package/lib/types/server/card.js +13 -49
  36. package/lib/types/server/card.js.map +1 -1
  37. package/lib/types/server/exec/agent-runtime.d.ts +3 -0
  38. package/lib/types/server/exec/agent-runtime.d.ts.map +1 -1
  39. package/lib/types/server/exec/agent-runtime.js +2 -1
  40. package/lib/types/server/exec/agent-runtime.js.map +1 -1
  41. package/lib/types/server/identity.d.ts +38 -0
  42. package/lib/types/server/identity.d.ts.map +1 -0
  43. package/lib/types/server/identity.js +89 -0
  44. package/lib/types/server/identity.js.map +1 -0
  45. package/lib/types/server/inbound-registry.d.ts +60 -0
  46. package/lib/types/server/inbound-registry.d.ts.map +1 -0
  47. package/lib/types/server/inbound-registry.js +86 -0
  48. package/lib/types/server/inbound-registry.js.map +1 -0
  49. package/lib/types/server/routes.d.ts +5 -1
  50. package/lib/types/server/routes.d.ts.map +1 -1
  51. package/lib/types/server/routes.js +9 -5
  52. package/lib/types/server/routes.js.map +1 -1
  53. package/lib/types/server/store.d.ts +37 -1
  54. package/lib/types/server/store.d.ts.map +1 -1
  55. package/lib/types/server/store.js +14 -0
  56. package/lib/types/server/store.js.map +1 -1
  57. package/lib/types/servers/inbound-manager.d.ts +131 -0
  58. package/lib/types/servers/inbound-manager.d.ts.map +1 -0
  59. package/lib/types/servers/inbound-manager.js +312 -0
  60. package/lib/types/servers/inbound-manager.js.map +1 -0
  61. package/lib/types/servers/outbound-manager.d.ts +129 -0
  62. package/lib/types/servers/outbound-manager.d.ts.map +1 -0
  63. package/lib/types/servers/outbound-manager.js +238 -0
  64. package/lib/types/servers/outbound-manager.js.map +1 -0
  65. package/lib/types/service.d.ts +108 -23
  66. package/lib/types/service.d.ts.map +1 -1
  67. package/lib/types/service.js +39 -18
  68. package/lib/types/service.js.map +1 -1
  69. package/package.json +3 -3
  70. package/src/api.ts +70 -31
  71. package/src/client/index.ts +339 -81
  72. package/src/commands.ts +59 -43
  73. package/src/events.ts +14 -7
  74. package/src/index.ts +204 -249
  75. package/src/outbound/calls.ts +8 -6
  76. package/src/protocol.ts +204 -95
  77. package/src/server/a2a-server.ts +16 -4
  78. package/src/server/card.ts +13 -62
  79. package/src/server/exec/agent-runtime.ts +5 -2
  80. package/src/server/identity.ts +109 -0
  81. package/src/server/inbound-registry.ts +129 -0
  82. package/src/server/routes.ts +6 -4
  83. package/src/server/store.ts +50 -4
  84. package/src/servers/inbound-manager.ts +385 -0
  85. package/src/servers/outbound-manager.ts +289 -0
  86. package/src/service.ts +136 -28
  87. package/lib/tsconfig.client.tsbuildinfo +0 -1
  88. package/lib/tsconfig.tsbuildinfo +0 -1
@@ -0,0 +1,385 @@
1
+ /**
2
+ * Inbound server manager: owns every inbound A2A server instance. Each
3
+ * instance binds one agent preset (its session pool is composed from that
4
+ * preset), advertises creator-declared skills, and serves its own endpoint and
5
+ * AgentCard route. Instances are persisted in the `inbound_servers` domain
6
+ * table and assembled on boot; the manager owns route registration and the
7
+ * full lifecycle (add/enable/disable/update/remove) driven from the GUI and
8
+ * facade, and disposes every instance's environment on teardown.
9
+ *
10
+ * Skill declaration: the stored `skills` list is exactly what the creator
11
+ * declared. When a creator leaves it empty at creation time, the default skill
12
+ * derives from the bound preset's display name (else the built-in `chat`
13
+ * skill), per the v1.0 design — the v0.2 tool white-list derivation is gone.
14
+ * @module dsh-a2a/servers/inbound-manager
15
+ */
16
+
17
+ import type { Context } from '@deepseek-ai/cordis'
18
+ import { buildCard } from '../server/card.ts'
19
+ import { A2AServer } from '../server/a2a-server.ts'
20
+ import { A2aRoutes } from '../server/routes.ts'
21
+ import { ContextSessionPool, type AgentPresetsLike, type AgentRegistryLike } from '../server/exec/agent-runtime.ts'
22
+ import { createSessionExecutor } from '../server/exec/session.ts'
23
+ import { createSubagentExecutor, type SubagentsLike } from '../server/exec/subagent.ts'
24
+ import { ExecutorSet } from '../server/executor.ts'
25
+ import { LiveInboundRegistry } from '../server/inbound-registry.ts'
26
+ import type { TaskStore, InboundServerRecord } from '../server/store.ts'
27
+ import type { AgentSkill } from '../protocol.ts'
28
+ import type { GateInput, GateResult } from '../server/a2a-server.ts'
29
+ import type { InboundTaskDecision } from '../events.ts'
30
+
31
+ /** Web-server slice the manager registers instance routes on. */
32
+ export interface WebServerLike {
33
+ register(route: {
34
+ readonly kind: 'exact' | 'prefix'
35
+ readonly path: string
36
+ handler(...args: unknown[]): unknown
37
+ }): () => void
38
+ }
39
+
40
+ /** Host service slices one manager needs. */
41
+ export interface InboundManagerHost {
42
+ readonly ctx: Context
43
+ readonly webServer: WebServerLike
44
+ readonly tasks: TaskStore
45
+ readonly logger: { info(message: string): void; warn(message: string): void; error(message: string): void }
46
+ readonly agents?: AgentRegistryLike
47
+ readonly agentPresets?: AgentPresetsLike
48
+ readonly subagents?: SubagentsLike
49
+ readonly sessionCwd: string
50
+ readonly defaultBaseUrl: string
51
+ readonly subagentProvider: string
52
+ /** Resolve the deployment's default model options (may be undefined). */
53
+ readonly resolveDefaultModel?: () => { readonly provider?: string; readonly model?: string } | undefined
54
+ /** Allocate a fresh stable instance id. */
55
+ readonly newId: () => string
56
+ }
57
+
58
+ /** One live inbound instance's runtime handle. */
59
+ export interface LiveInboundServer {
60
+ readonly id: string
61
+ readonly record: InboundServerRecord
62
+ readonly card: ReturnType<typeof buildCard>
63
+ readonly server: A2AServer
64
+ readonly routes: A2aRoutes
65
+ readonly inbound: LiveInboundRegistry
66
+ readonly executors: ExecutorSet
67
+ readonly endpointPath: string
68
+ readonly cardPath: string
69
+ dispose(): void
70
+ }
71
+
72
+ /** Creator input for a new (or updated) inbound instance. */
73
+ export interface InboundCreateInput {
74
+ readonly name: string
75
+ readonly description: string
76
+ readonly version: string
77
+ readonly endpointPath?: string
78
+ readonly preset?: string
79
+ readonly authTokenEnv?: string
80
+ /** Declared skills; empty defaults to the preset name (or `chat`). */
81
+ readonly skills?: readonly AgentSkill[]
82
+ readonly enabled?: boolean
83
+ }
84
+
85
+ export interface OpResult {
86
+ readonly ok: boolean
87
+ readonly message: string
88
+ }
89
+
90
+ function refuseExecutor(reason: string) {
91
+ return {
92
+ name: 'refuse',
93
+ async execute(): Promise<{ parts: { text: string }[] }> {
94
+ throw new Error(`a2a: ${reason}; refusing inbound task`)
95
+ },
96
+ }
97
+ }
98
+
99
+ /** Default skkill when a creator declares none: the preset display name. */
100
+ export function defaultSkillFor(
101
+ skills: readonly AgentSkill[] | undefined,
102
+ presetName: string | undefined,
103
+ ): readonly AgentSkill[] {
104
+ if (skills !== undefined && skills.length > 0) return skills
105
+ if (presetName !== undefined && presetName.length > 0) {
106
+ return [{ id: 'chat', name: presetName, description: `Compose inbound sessions from the "${presetName}" agent preset.`, tags: ['preset'] }]
107
+ }
108
+ return [{ id: 'chat', name: 'chat', description: 'Conversational assistance over a DSH agent session.', tags: ['chat'] }]
109
+ }
110
+
111
+ /** Assemble one live instance from a persisted record. */
112
+ function assemble(record: InboundServerRecord, host: InboundManagerHost): LiveInboundServer {
113
+ const skills: readonly AgentSkill[] = record.skills
114
+ const endpointPath = record.endpointPath
115
+ const cardPath = `${endpointPath.replace(/\/$/, '')}/agent-card.json`
116
+ const card = buildCard({
117
+ baseUrl: host.defaultBaseUrl,
118
+ endpointPath,
119
+ name: record.name,
120
+ description: record.description,
121
+ version: record.version,
122
+ skills,
123
+ ...(record.authTokenEnv !== undefined && process.env[record.authTokenEnv] !== undefined
124
+ ? { authToken: process.env[record.authTokenEnv]! }
125
+ : {}),
126
+ })
127
+
128
+ // One preset-bound session pool per instance.
129
+ const sessionPool = host.agents
130
+ ? new ContextSessionPool(host.agents, {
131
+ cwd: host.sessionCwd,
132
+ ...(host.agentPresets !== undefined ? { agentPresets: host.agentPresets } : {}),
133
+ ...(record.preset !== undefined ? { presetId: () => record.preset } : {}),
134
+ // `?.()` yields `{ provider?, model? } | undefined`, matching the
135
+ // AgentRuntimeOptions.resolveAgentOptions signature under
136
+ // exactOptionalPropertyTypes.
137
+ resolveAgentOptions: () => host.resolveDefaultModel?.(),
138
+ })
139
+ : undefined
140
+ const sessionExecutor = sessionPool ? createSessionExecutor(sessionPool) : refuseExecutor('no agent loop mounted')
141
+ const subagentExecutor = sessionPool !== undefined && host.subagents !== undefined
142
+ ? createSubagentExecutor({ pool: sessionPool, subagents: host.subagents, provider: host.subagentProvider })
143
+ : undefined
144
+ const executors = new ExecutorSet({ chat: 'session' }, sessionExecutor, subagentExecutor)
145
+
146
+ const inbound = new LiveInboundRegistry()
147
+ const skillIds = new Set(skills.map((s) => s.id))
148
+ const gate = async (input: GateInput): Promise<GateResult> => {
149
+ if (!skillIds.has(input.skill)) {
150
+ return { ok: false, reason: `unknown skill "${input.skill}"` }
151
+ }
152
+ const decision: InboundTaskDecision = {
153
+ contextId: input.contextId,
154
+ skill: input.skill,
155
+ parts: input.parts,
156
+ remotePeerId: input.remotePeerId,
157
+ }
158
+ const decided = await host.ctx.waterfall('a2a/inbound-task', decision, async (d) => d)
159
+ host.logger.info(`[a2a:${record.id}] inbound skill=${decided.skill} rejected=${decided.rejected?.reason ?? 'no'}`)
160
+ if (decided.rejected !== undefined) return { ok: false, reason: decided.rejected.reason }
161
+ return { ok: true }
162
+ }
163
+
164
+ const server = new A2AServer({
165
+ card,
166
+ store: host.tasks,
167
+ executors,
168
+ ...(card.securitySchemes !== undefined ? { authToken: 'configured' } : {}),
169
+ cardPath,
170
+ gate,
171
+ onInbound: (facts) => inbound.note({
172
+ method: facts.method,
173
+ ...(facts.source !== undefined ? { source: facts.source } : {}),
174
+ taskIds: facts.taskIds,
175
+ streaming: facts.streaming,
176
+ }),
177
+ onTaskSettled: (taskId) => {
178
+ host.logger.info(`[a2a:${record.id}] task settled ${taskId}`)
179
+ inbound.settle(taskId)
180
+ },
181
+ })
182
+ const routes = new A2aRoutes(host.webServer, server, cardPath)
183
+
184
+ return {
185
+ id: record.id,
186
+ record,
187
+ card,
188
+ server,
189
+ routes,
190
+ inbound,
191
+ executors,
192
+ endpointPath,
193
+ cardPath,
194
+ dispose: () => {
195
+ routes.dispose()
196
+ void executors.disposeAll().catch(() => {})
197
+ },
198
+ }
199
+ }
200
+
201
+ /** Persisted-instance store over the inbound_servers table. */
202
+ export class DomainInboundStore {
203
+ private static KEY(id: string): string {
204
+ return `in:${id}`
205
+ }
206
+
207
+ constructor(private readonly table: {
208
+ get(key: string): string | undefined
209
+ put(key: string, value: string): Promise<void>
210
+ entries?(): IterableIterator<[string, string]>
211
+ keys?(): IterableIterator<string>
212
+ }) {}
213
+
214
+ list(): InboundServerRecord[] {
215
+ const out: InboundServerRecord[] = []
216
+ const entries = this.table.entries
217
+ if (entries === undefined) return out
218
+ // Call through the table (method `this` stays bound); the real KvTable's
219
+ // `entries()` reads instance state.
220
+ for (const [key, raw] of entries.call(this.table)) {
221
+ if (!key.startsWith('in:')) continue
222
+ const parsed = safeParse(raw)
223
+ if (parsed !== undefined) out.push(parsed)
224
+ }
225
+ return out
226
+ }
227
+
228
+ get(id: string): InboundServerRecord | undefined {
229
+ const raw = this.table.get(DomainInboundStore.KEY(id))
230
+ return raw === undefined ? undefined : safeParse(raw)
231
+ }
232
+
233
+ async save(record: InboundServerRecord): Promise<void> {
234
+ await this.table.put(DomainInboundStore.KEY(record.id), JSON.stringify(record))
235
+ }
236
+
237
+ async remove(id: string): Promise<void> {
238
+ await this.table.put(DomainInboundStore.KEY(id), '')
239
+ }
240
+ }
241
+
242
+ function safeParse(raw: string): InboundServerRecord | undefined {
243
+ try {
244
+ const parsed = JSON.parse(raw) as InboundServerRecord
245
+ if (typeof parsed.id !== 'string' || typeof parsed.name !== 'string' || !Array.isArray(parsed.skills)) return undefined
246
+ return parsed
247
+ } catch {
248
+ return undefined
249
+ }
250
+ }
251
+
252
+ /** Manages the live set of inbound server instances. */
253
+ export class InboundServerManager {
254
+ private readonly live = new Map<string, LiveInboundServer>()
255
+
256
+ constructor(
257
+ private readonly host: InboundManagerHost,
258
+ private readonly store: DomainInboundStore,
259
+ ) {}
260
+
261
+ /** Assemble every persisted enabled instance (boot). */
262
+ boot(): void {
263
+ for (const record of this.store.list()) {
264
+ try {
265
+ const live = assemble(record, this.host)
266
+ this.live.set(record.id, live)
267
+ if (record.enabled) live.routes.enable()
268
+ } catch (err) {
269
+ this.host.logger.error(`[a2a:${record.id}] failed to assemble: ${String((err as Error).message)}`)
270
+ }
271
+ }
272
+ }
273
+
274
+ list(): readonly LiveInboundServer[] {
275
+ return [...this.live.values()]
276
+ }
277
+
278
+ get(id: string): LiveInboundServer | undefined {
279
+ return this.live.get(id)
280
+ }
281
+
282
+ /** Resolve the display name of a preset id (undefined when unnamed/absent). */
283
+ private async presetDisplayName(preset: string | undefined): Promise<string | undefined> {
284
+ if (preset === undefined || this.host.agentPresets === undefined) return undefined
285
+ try {
286
+ const resolved = await this.host.agentPresets.resolve(preset)
287
+ return resolved.name ?? resolved.id
288
+ } catch {
289
+ return undefined
290
+ }
291
+ }
292
+
293
+ /** Create, persist, and assemble a new inbound instance. */
294
+ async add(input: InboundCreateInput): Promise<OpResult & { readonly id?: string }> {
295
+ const id = this.newId(input.name)
296
+ if (this.live.has(id)) return { ok: false, message: `inbound server ${id} already exists` }
297
+ const presetName = await this.presetDisplayName(input.preset)
298
+ const record: InboundServerRecord = {
299
+ id,
300
+ name: input.name,
301
+ description: input.description,
302
+ version: input.version,
303
+ endpointPath: input.endpointPath ?? `/a2a/${id}`,
304
+ ...(input.preset !== undefined ? { preset: input.preset } : {}),
305
+ ...(input.authTokenEnv !== undefined ? { authTokenEnv: input.authTokenEnv } : {}),
306
+ skills: [...defaultSkillFor(input.skills, presetName)],
307
+ enabled: input.enabled ?? true,
308
+ }
309
+ try {
310
+ const live = assemble(record, this.host)
311
+ await this.store.save(record)
312
+ this.live.set(id, live)
313
+ if (record.enabled) live.routes.enable()
314
+ return { ok: true, message: `inbound server "${record.name}" created`, id }
315
+ } catch (err) {
316
+ return { ok: false, message: `failed to create inbound server: ${String((err as Error).message)}` }
317
+ }
318
+ }
319
+
320
+ /** Persist, rebuild, and apply a patch onto a live instance. */
321
+ async update(id: string, patch: Partial<Pick<InboundServerRecord, 'name' | 'description' | 'version' | 'endpointPath' | 'preset' | 'authTokenEnv' | 'skills'>>): Promise<OpResult> {
322
+ const live = this.live.get(id)
323
+ const stored = this.store.get(id)
324
+ if (live === undefined || stored === undefined) return { ok: false, message: `inbound server ${id} not found` }
325
+ const next: InboundServerRecord = {
326
+ ...stored,
327
+ name: patch.name ?? stored.name,
328
+ description: patch.description ?? stored.description,
329
+ version: patch.version ?? stored.version,
330
+ endpointPath: patch.endpointPath ?? stored.endpointPath,
331
+ ...(patch.preset !== undefined ? { preset: patch.preset } : {}),
332
+ ...(patch.authTokenEnv !== undefined ? { authTokenEnv: patch.authTokenEnv } : {}),
333
+ skills: patch.skills ?? stored.skills,
334
+ enabled: stored.enabled,
335
+ }
336
+ try {
337
+ await this.store.save(next)
338
+ const replaced = assemble(next, this.host)
339
+ // Preserve the route registration state while swapping runtime halves.
340
+ const wasEnabled = live.routes.active
341
+ live.dispose()
342
+ this.live.set(id, replaced)
343
+ if (wasEnabled || next.enabled) replaced.routes.enable()
344
+ return { ok: true, message: `inbound server ${id} updated` }
345
+ } catch (err) {
346
+ return { ok: false, message: `failed to update inbound server: ${String((err as Error).message)}` }
347
+ }
348
+ }
349
+
350
+ /** Enable/disable an instance's routes. */
351
+ setEnabled(id: string, enabled: boolean): OpResult {
352
+ const live = this.live.get(id)
353
+ if (live === undefined) return { ok: false, message: `inbound server ${id} not found` }
354
+ if (enabled) live.routes.enable()
355
+ else live.routes.disable()
356
+ const stored = this.store.get(id)
357
+ if (stored !== undefined) {
358
+ void this.store.save({ ...stored, enabled }).catch(() => {})
359
+ }
360
+ return { ok: true, message: `inbound server ${id} ${enabled ? 'enabled' : 'disabled'}` }
361
+ }
362
+
363
+ /** Remove, dispose, and unpersist an instance. */
364
+ async remove(id: string): Promise<OpResult> {
365
+ const live = this.live.get(id)
366
+ if (live === undefined) return { ok: false, message: `inbound server ${id} not found` }
367
+ live.dispose()
368
+ this.live.delete(id)
369
+ await this.store.remove(id)
370
+ return { ok: true, message: `inbound server ${id} removed` }
371
+ }
372
+
373
+ /** Dispose every instance. */
374
+ disposeAll(): void {
375
+ for (const live of this.live.values()) live.dispose()
376
+ this.live.clear()
377
+ }
378
+
379
+ private newId(seed: string): string {
380
+ const slug = seed.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'server'
381
+ return `${slug}-${this.host.newId()}`
382
+ }
383
+ }
384
+
385
+ export type { AgentSkill }
@@ -0,0 +1,289 @@
1
+ /**
2
+ * Outbound server manager: owns every outbound A2A connection instance. Each
3
+ * instance is one connected remote — its own AgentCard URL, auth env, timeout,
4
+ * creator-declared name, and an optional agent-preset binding. Each live
5
+ * instance is one `OutboundAgentRegistry` with an isolated per-instance agent
6
+ * store; enabled instances map their remote skills to `a2a__<name>__<skill>`
7
+ * model tools. The manager owns the full lifecycle (add/enable/disable/remove/
8
+ * refresh) driven from the GUI and facade, persists each instance in the
9
+ * `outbound_servers` domain table, and disposes every connection on teardown.
10
+ *
11
+ * The `preset` field names the agent preset this DSH would compose its local
12
+ * hand-off session with when driving that remote. P0 connects skills to tools
13
+ * and manages the connection; the hand-off session composition behind `preset`
14
+ * is a documented extension point (the outbound tools currently call the
15
+ * remote directly) and the value is persisted metadata surfaced to the GUI.
16
+ * @module dsh-a2a/servers/outbound-manager
17
+ */
18
+
19
+ import { OutboundAgentRegistry, type AgentStore } from '../outbound/registry.ts'
20
+ import type { OutboundAgentRecord, OutboundServerRecord } from '../server/store.ts'
21
+
22
+ /** Tool-registrar slice (structural `ctx.tools.register`). */
23
+ export interface ToolRegistrar {
24
+ register(def: unknown): (() => void) | void
25
+ }
26
+
27
+ /** Host slices one manager needs. */
28
+ export interface OutboundManagerHost {
29
+ readonly registrar: ToolRegistrar
30
+ readonly tokenOf: (env: string | undefined) => string | undefined
31
+ readonly onError: (message: string) => void
32
+ readonly defaultTimeoutMs: number
33
+ }
34
+
35
+ export interface OpResult {
36
+ readonly ok: boolean
37
+ readonly message: string
38
+ }
39
+
40
+ /** One outbound instance as the GUI/facade reads it. */
41
+ export interface OutboundServerView {
42
+ readonly id: string
43
+ readonly name: string
44
+ readonly agentCardUrl: string
45
+ readonly preset?: string
46
+ readonly enabled: boolean
47
+ readonly timeoutMs: number
48
+ readonly state: 'connected' | 'disconnected' | 'failed'
49
+ readonly skillCount: number
50
+ readonly toolCount: number
51
+ readonly lastError?: string
52
+ }
53
+
54
+ /** An `AgentStore` bound to one outbound server instance's agents-table key. */
55
+ export class ServerAgentStore implements AgentStore {
56
+ constructor(
57
+ private readonly table: {
58
+ get(key: string): string | undefined
59
+ put(key: string, value: string): Promise<void>
60
+ },
61
+ private readonly key: string,
62
+ ) {}
63
+
64
+ list(): OutboundAgentRecord[] {
65
+ const raw = this.table.get(this.key)
66
+ if (raw === undefined || raw === '') return []
67
+ try {
68
+ const parsed = JSON.parse(raw) as unknown
69
+ return Array.isArray(parsed) ? (parsed as OutboundAgentRecord[]) : []
70
+ } catch {
71
+ return []
72
+ }
73
+ }
74
+
75
+ async save(records: readonly OutboundAgentRecord[]): Promise<void> {
76
+ await this.table.put(this.key, JSON.stringify(records))
77
+ }
78
+ }
79
+
80
+ /** Persisted-instance store over the outbound_servers table. */
81
+ export class DomainOutboundStore {
82
+ private static KEY(id: string): string {
83
+ return `out:${id}`
84
+ }
85
+
86
+ constructor(private readonly table: {
87
+ get(key: string): string | undefined
88
+ put(key: string, value: string): Promise<void>
89
+ entries?(): IterableIterator<[string, string]>
90
+ }) {}
91
+
92
+ list(): OutboundServerRecord[] {
93
+ const out: OutboundServerRecord[] = []
94
+ const entries = this.table.entries
95
+ if (entries === undefined) return out
96
+ // Call through the table (method `this` stays bound); the real KvTable's
97
+ // `entries()` reads instance state.
98
+ for (const [key, raw] of entries.call(this.table)) {
99
+ if (!key.startsWith('out:')) continue
100
+ const parsed = safeParse(raw)
101
+ if (parsed !== undefined) out.push(parsed)
102
+ }
103
+ return out
104
+ }
105
+
106
+ get(id: string): OutboundServerRecord | undefined {
107
+ const raw = this.table.get(DomainOutboundStore.KEY(id))
108
+ return raw === undefined ? undefined : safeParse(raw)
109
+ }
110
+
111
+ async save(record: OutboundServerRecord): Promise<void> {
112
+ await this.table.put(DomainOutboundStore.KEY(record.id), JSON.stringify(record))
113
+ }
114
+
115
+ async remove(id: string): Promise<void> {
116
+ await this.table.put(DomainOutboundStore.KEY(id), '')
117
+ }
118
+ }
119
+
120
+ function safeParse(raw: string): OutboundServerRecord | undefined {
121
+ try {
122
+ const parsed = JSON.parse(raw) as OutboundServerRecord
123
+ if (typeof parsed.id !== 'string' || typeof parsed.name !== 'string' || typeof parsed.agentCardUrl !== 'string') return undefined
124
+ return parsed
125
+ } catch {
126
+ return undefined
127
+ }
128
+ }
129
+
130
+ /** Creator input for a new outbound instance (id optional; manager allocates). */
131
+ export interface OutboundCreateInput {
132
+ readonly id?: string
133
+ readonly name: string
134
+ readonly agentCardUrl: string
135
+ readonly bearerTokenEnv?: string
136
+ readonly preset?: string
137
+ readonly enabled?: boolean
138
+ readonly timeoutMs?: number
139
+ }
140
+
141
+ /** Manages the live set of outbound connection instances. */
142
+ export class OutboundServerManager {
143
+ private readonly registries = new Map<string, OutboundAgentRegistry>()
144
+
145
+ constructor(
146
+ private readonly host: OutboundManagerHost,
147
+ /** Persistence sink for each instance's agent records (the `agents` table). */
148
+ private readonly agentSink: { get(key: string): string | undefined; put(key: string, value: string): Promise<void> },
149
+ /** Persisted OutboundServerRecord store (the `outbound_servers` table). */
150
+ private readonly records: DomainOutboundStore,
151
+ /** Allocate a fresh stable instance id. */
152
+ private readonly newId: () => string,
153
+ ) {}
154
+
155
+ /** The host default connection timeout (what a minimal record falls back to). */
156
+ get defaultTimeoutMs(): number {
157
+ return this.host.defaultTimeoutMs
158
+ }
159
+
160
+ private agentKeyFor(id: string): string {
161
+ return `out:${id}`
162
+ }
163
+
164
+ private makeRegistry(record: OutboundServerRecord): OutboundAgentRegistry {
165
+ return new OutboundAgentRegistry({
166
+ registrar: { register: (def) => this.host.registrar.register(def) },
167
+ store: new ServerAgentStore(this.agentSink, this.agentKeyFor(record.id)),
168
+ toolPrefix: 'a2a',
169
+ defaultTimeoutMs: this.host.defaultTimeoutMs,
170
+ tokenOf: (env) => this.host.tokenOf(env),
171
+ onError: this.host.onError,
172
+ })
173
+ }
174
+
175
+ /** Record → registry spec for connecting one instance. */
176
+ private toSpec(record: OutboundServerRecord): { name: string; agentCardUrl: string; bearerTokenEnv?: string; enabled?: boolean; timeoutMs?: number } {
177
+ return {
178
+ name: record.name,
179
+ agentCardUrl: record.agentCardUrl,
180
+ ...(record.bearerTokenEnv !== undefined ? { bearerTokenEnv: record.bearerTokenEnv } : {}),
181
+ enabled: record.enabled,
182
+ timeoutMs: record.timeoutMs ?? this.host.defaultTimeoutMs,
183
+ }
184
+ }
185
+
186
+ /** Boot every persisted enabled instance as a connected registry. */
187
+ boot(): void {
188
+ for (const record of this.records.list()) {
189
+ try {
190
+ const registry = this.makeRegistry(record)
191
+ this.registries.set(record.id, registry)
192
+ if (record.enabled) registry.loadAll()
193
+ } catch (err) {
194
+ this.host.onError(`[a2a:out:${record.id}] failed to boot: ${String((err as Error).message)}`)
195
+ }
196
+ }
197
+ }
198
+
199
+ /** One view per live instance (enriched from its registry / record). */
200
+ list(): readonly OutboundServerView[] {
201
+ return [...this.registries.keys()].map((id) => this.viewFor(id)).filter((v): v is OutboundServerView => v !== undefined)
202
+ }
203
+
204
+ get(id: string): OutboundServerRegistryHandle | undefined {
205
+ const registry = this.registries.get(id)
206
+ return registry === undefined ? undefined : { id, registry }
207
+ }
208
+
209
+ private viewFor(id: string): OutboundServerView | undefined {
210
+ const registry = this.registries.get(id)
211
+ const record = this.records.get(id)
212
+ if (registry === undefined || record === undefined) return undefined
213
+ const agent = registry.list()[0]
214
+ return {
215
+ id,
216
+ name: record.name,
217
+ agentCardUrl: record.agentCardUrl,
218
+ ...(record.preset !== undefined ? { preset: record.preset } : {}),
219
+ enabled: agent?.enabled ?? record.enabled,
220
+ timeoutMs: record.timeoutMs,
221
+ state: agent?.state ?? 'disconnected',
222
+ skillCount: agent?.skillCount ?? 0,
223
+ toolCount: agent?.toolCount ?? 0,
224
+ ...(agent?.lastError !== undefined ? { lastError: agent.lastError } : {}),
225
+ }
226
+ }
227
+
228
+ /** Create, persist, and connect a new outbound instance. */
229
+ async add(input: OutboundCreateInput): Promise<OpResult & { readonly id?: string }> {
230
+ const record: OutboundServerRecord = {
231
+ id: input.id ?? this.newId(),
232
+ name: input.name,
233
+ agentCardUrl: input.agentCardUrl,
234
+ ...(input.bearerTokenEnv !== undefined ? { bearerTokenEnv: input.bearerTokenEnv } : {}),
235
+ ...(input.preset !== undefined ? { preset: input.preset } : {}),
236
+ enabled: input.enabled ?? true,
237
+ timeoutMs: input.timeoutMs ?? this.host.defaultTimeoutMs,
238
+ }
239
+ if (this.registries.has(record.id)) return { ok: false, message: `outbound server ${record.id} already exists` }
240
+ const registry = this.makeRegistry(record)
241
+ await this.records.save(record)
242
+ this.registries.set(record.id, registry)
243
+ if (record.enabled) {
244
+ const result = await registry.add(this.toSpec(record))
245
+ return result.ok ? { ...result, id: record.id } : result
246
+ }
247
+ return { ok: true, message: `outbound server "${record.name}" added (disabled)`, id: record.id }
248
+ }
249
+
250
+ async setEnabled(id: string, enabled: boolean): Promise<OpResult> {
251
+ const registry = this.registries.get(id)
252
+ if (registry === undefined) return { ok: false, message: `outbound server ${id} not found` }
253
+ const view = registry.list()[0]
254
+ const record = this.records.get(id)
255
+ if (record !== undefined) await this.records.save({ ...record, enabled })
256
+ if (view === undefined) return { ok: true, message: `outbound server ${id} marked ${enabled ? 'enabled' : 'disabled'}` }
257
+ return registry.setEnabled(view.id, enabled)
258
+ }
259
+
260
+ async remove(id: string): Promise<OpResult> {
261
+ const registry = this.registries.get(id)
262
+ if (registry === undefined) return { ok: false, message: `outbound server ${id} not found` }
263
+ const view = registry.list()[0]
264
+ if (view !== undefined) await registry.remove(view.id)
265
+ await registry.disposeAll()
266
+ this.registries.delete(id)
267
+ await this.records.remove(id)
268
+ return { ok: true, message: `outbound server ${id} removed` }
269
+ }
270
+
271
+ async refresh(id: string): Promise<OpResult> {
272
+ const registry = this.registries.get(id)
273
+ if (registry === undefined) return { ok: false, message: `outbound server ${id} not found` }
274
+ const view = registry.list()[0]
275
+ if (view === undefined) return { ok: false, message: `outbound server ${id} has no connected agent` }
276
+ return registry.refresh(view.id)
277
+ }
278
+
279
+ async disposeAll(): Promise<void> {
280
+ for (const registry of this.registries.values()) await registry.disposeAll()
281
+ this.registries.clear()
282
+ }
283
+ }
284
+
285
+ /** Handle to one live outbound connection (registry + derived view). */
286
+ export interface OutboundServerRegistryHandle {
287
+ readonly id: string
288
+ readonly registry: OutboundAgentRegistry
289
+ }