@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.
- package/README.md +133 -87
- package/README.zh.md +64 -87
- package/cordis.patch.yml +4 -7
- package/lib/client.js +228 -36
- package/lib/index.js +1271 -625
- package/lib/types/api.d.ts +43 -13
- package/lib/types/api.d.ts.map +1 -1
- package/lib/types/api.js +60 -20
- package/lib/types/api.js.map +1 -1
- package/lib/types/client/index.d.ts +50 -14
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/commands.d.ts +4 -2
- package/lib/types/commands.d.ts.map +1 -1
- package/lib/types/commands.js +59 -46
- package/lib/types/commands.js.map +1 -1
- package/lib/types/events.d.ts +32 -9
- package/lib/types/events.d.ts.map +1 -1
- package/lib/types/index.d.ts +21 -32
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +178 -213
- package/lib/types/index.js.map +1 -1
- package/lib/types/outbound/calls.d.ts.map +1 -1
- package/lib/types/outbound/calls.js +8 -6
- package/lib/types/outbound/calls.js.map +1 -1
- package/lib/types/protocol.d.ts +174 -109
- package/lib/types/protocol.d.ts.map +1 -1
- package/lib/types/protocol.js +58 -34
- package/lib/types/protocol.js.map +1 -1
- package/lib/types/server/a2a-server.d.ts +7 -1
- package/lib/types/server/a2a-server.d.ts.map +1 -1
- package/lib/types/server/a2a-server.js +11 -3
- package/lib/types/server/a2a-server.js.map +1 -1
- package/lib/types/server/card.d.ts +6 -30
- package/lib/types/server/card.d.ts.map +1 -1
- package/lib/types/server/card.js +13 -49
- package/lib/types/server/card.js.map +1 -1
- package/lib/types/server/exec/agent-runtime.d.ts +3 -0
- package/lib/types/server/exec/agent-runtime.d.ts.map +1 -1
- package/lib/types/server/exec/agent-runtime.js +2 -1
- package/lib/types/server/exec/agent-runtime.js.map +1 -1
- package/lib/types/server/identity.d.ts +38 -0
- package/lib/types/server/identity.d.ts.map +1 -0
- package/lib/types/server/identity.js +89 -0
- package/lib/types/server/identity.js.map +1 -0
- package/lib/types/server/inbound-registry.d.ts +60 -0
- package/lib/types/server/inbound-registry.d.ts.map +1 -0
- package/lib/types/server/inbound-registry.js +86 -0
- package/lib/types/server/inbound-registry.js.map +1 -0
- package/lib/types/server/routes.d.ts +5 -1
- package/lib/types/server/routes.d.ts.map +1 -1
- package/lib/types/server/routes.js +9 -5
- package/lib/types/server/routes.js.map +1 -1
- package/lib/types/server/store.d.ts +37 -1
- package/lib/types/server/store.d.ts.map +1 -1
- package/lib/types/server/store.js +14 -0
- package/lib/types/server/store.js.map +1 -1
- package/lib/types/servers/inbound-manager.d.ts +131 -0
- package/lib/types/servers/inbound-manager.d.ts.map +1 -0
- package/lib/types/servers/inbound-manager.js +312 -0
- package/lib/types/servers/inbound-manager.js.map +1 -0
- package/lib/types/servers/outbound-manager.d.ts +129 -0
- package/lib/types/servers/outbound-manager.d.ts.map +1 -0
- package/lib/types/servers/outbound-manager.js +238 -0
- package/lib/types/servers/outbound-manager.js.map +1 -0
- package/lib/types/service.d.ts +108 -23
- package/lib/types/service.d.ts.map +1 -1
- package/lib/types/service.js +39 -18
- package/lib/types/service.js.map +1 -1
- package/package.json +3 -3
- package/src/api.ts +70 -31
- package/src/client/index.ts +339 -81
- package/src/commands.ts +59 -43
- package/src/events.ts +14 -7
- package/src/index.ts +204 -249
- package/src/outbound/calls.ts +8 -6
- package/src/protocol.ts +204 -95
- package/src/server/a2a-server.ts +16 -4
- package/src/server/card.ts +13 -62
- package/src/server/exec/agent-runtime.ts +5 -2
- package/src/server/identity.ts +109 -0
- package/src/server/inbound-registry.ts +129 -0
- package/src/server/routes.ts +6 -4
- package/src/server/store.ts +50 -4
- package/src/servers/inbound-manager.ts +385 -0
- package/src/servers/outbound-manager.ts +289 -0
- package/src/service.ts +136 -28
- package/lib/tsconfig.client.tsbuildinfo +0 -1
- 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
|
+
}
|