@hanphone/dsh-a2a 0.2.0 → 0.3.1
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 +106 -106
- package/README.zh.md +50 -74
- package/cordis.patch.yml +4 -7
- package/lib/client.js +186 -73
- package/lib/index.js +1275 -868
- package/lib/types/api.d.ts +30 -23
- package/lib/types/api.d.ts.map +1 -1
- package/lib/types/api.js +56 -23
- package/lib/types/api.js.map +1 -1
- package/lib/types/client/index.d.ts +50 -19
- 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 +177 -272
- 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 +4 -0
- package/lib/types/server/a2a-server.d.ts.map +1 -1
- package/lib/types/server/a2a-server.js +6 -2
- 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 +20 -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/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 +32 -0
- package/lib/types/server/store.d.ts.map +1 -1
- package/lib/types/server/store.js +10 -0
- package/lib/types/server/store.js.map +1 -1
- package/lib/types/servers/inbound-manager.d.ts +154 -0
- package/lib/types/servers/inbound-manager.d.ts.map +1 -0
- package/lib/types/servers/inbound-manager.js +335 -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 +104 -40
- package/lib/types/service.d.ts.map +1 -1
- package/lib/types/service.js +36 -27
- package/lib/types/service.js.map +1 -1
- package/package.json +2 -2
- package/src/api.ts +65 -53
- package/src/client/index.ts +272 -148
- package/src/commands.ts +59 -43
- package/src/events.ts +14 -7
- package/src/index.ts +203 -310
- package/src/outbound/calls.ts +8 -6
- package/src/protocol.ts +204 -95
- package/src/server/a2a-server.ts +9 -2
- package/src/server/card.ts +13 -62
- package/src/server/exec/agent-runtime.ts +20 -2
- package/src/server/routes.ts +6 -4
- package/src/server/store.ts +42 -3
- package/src/servers/inbound-manager.ts +416 -0
- package/src/servers/outbound-manager.ts +289 -0
- package/src/service.ts +125 -40
- package/lib/tsconfig.client.tsbuildinfo +0 -1
- package/lib/tsconfig.tsbuildinfo +0 -1
package/src/server/store.ts
CHANGED
|
@@ -32,6 +32,8 @@ export interface TaskRecord {
|
|
|
32
32
|
readonly executor: 'session' | 'subagent'
|
|
33
33
|
readonly summary: string | null
|
|
34
34
|
readonly error?: { readonly code: string; readonly message: string }
|
|
35
|
+
/** The inbound server instance this task arrived through. */
|
|
36
|
+
readonly serverId?: string
|
|
35
37
|
}
|
|
36
38
|
|
|
37
39
|
/** One durable context→session binding (inbound conversation ↔ DSH session). */
|
|
@@ -50,6 +52,31 @@ export interface OutboundAgentRecord {
|
|
|
50
52
|
readonly lastCardAt: string | null
|
|
51
53
|
}
|
|
52
54
|
|
|
55
|
+
/** One durable inbound server instance. */
|
|
56
|
+
export interface InboundServerRecord {
|
|
57
|
+
readonly id: string
|
|
58
|
+
readonly name: string
|
|
59
|
+
readonly description: string
|
|
60
|
+
readonly version: string
|
|
61
|
+
readonly endpointPath: string
|
|
62
|
+
/** Agent preset id composing inbound sessions; absent falls back to the deployment default. */
|
|
63
|
+
readonly preset?: string
|
|
64
|
+
readonly authTokenEnv?: string
|
|
65
|
+
readonly enabled: boolean
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** One durable outbound server connection instance. */
|
|
69
|
+
export interface OutboundServerRecord {
|
|
70
|
+
readonly id: string
|
|
71
|
+
readonly name: string
|
|
72
|
+
readonly agentCardUrl: string
|
|
73
|
+
readonly bearerTokenEnv?: string
|
|
74
|
+
/** Preset composing this DSH's local session when talking to the remote. */
|
|
75
|
+
readonly preset?: string
|
|
76
|
+
readonly enabled: boolean
|
|
77
|
+
readonly timeoutMs: number
|
|
78
|
+
}
|
|
79
|
+
|
|
53
80
|
/** JSON-encoded record schema shared by every `a2a` table. */
|
|
54
81
|
const json = z.string()
|
|
55
82
|
|
|
@@ -62,6 +89,8 @@ export const a2aDomainSpec = defineDomain({
|
|
|
62
89
|
contexts: domainTable<string, string>(json),
|
|
63
90
|
agents: domainTable<string, string>(json),
|
|
64
91
|
identity: domainTable<string, string>(json),
|
|
92
|
+
inbound_servers: domainTable<string, string>(json),
|
|
93
|
+
outbound_servers: domainTable<string, string>(json),
|
|
65
94
|
},
|
|
66
95
|
})
|
|
67
96
|
|
|
@@ -112,6 +141,14 @@ export class A2aDomain {
|
|
|
112
141
|
return this.handle.table('identity')
|
|
113
142
|
}
|
|
114
143
|
|
|
144
|
+
get inbound_servers(): KvTable<string, string> {
|
|
145
|
+
return this.handle.table('inbound_servers')
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
get outbound_servers(): KvTable<string, string> {
|
|
149
|
+
return this.handle.table('outbound_servers')
|
|
150
|
+
}
|
|
151
|
+
|
|
115
152
|
async close(): Promise<void> {
|
|
116
153
|
await this.handle.close()
|
|
117
154
|
}
|
|
@@ -119,7 +156,7 @@ export class A2aDomain {
|
|
|
119
156
|
|
|
120
157
|
/** Task-store contract shared by the domain-backed and memory stores. */
|
|
121
158
|
export interface TaskStore {
|
|
122
|
-
create(input: { contextId: string; skill: string; parts: readonly Part[]; remotePeerId: string | null }): TaskRecord
|
|
159
|
+
create(input: { contextId: string; skill: string; parts: readonly Part[]; remotePeerId: string | null; serverId?: string }): TaskRecord
|
|
123
160
|
get(taskId: string): TaskRecord | undefined
|
|
124
161
|
list(): TaskRecord[]
|
|
125
162
|
setState(taskId: string, state: TaskState, message?: { code: string; text: string }): TaskRecord
|
|
@@ -146,7 +183,7 @@ export class MemoryTaskStore implements TaskStore {
|
|
|
146
183
|
private tasks = new Map<string, TaskRecord>()
|
|
147
184
|
private contexts = new Map<string, string>()
|
|
148
185
|
|
|
149
|
-
create(input: { contextId: string; skill: string; parts: readonly Part[]; remotePeerId: string | null }): TaskRecord {
|
|
186
|
+
create(input: { contextId: string; skill: string; parts: readonly Part[]; remotePeerId: string | null; serverId?: string }): TaskRecord {
|
|
150
187
|
const record: TaskRecord = {
|
|
151
188
|
taskId: `a2a-${crypto.randomUUID()}`,
|
|
152
189
|
contextId: input.contextId,
|
|
@@ -160,6 +197,7 @@ export class MemoryTaskStore implements TaskStore {
|
|
|
160
197
|
artifacts: [],
|
|
161
198
|
executor: 'session',
|
|
162
199
|
summary: null,
|
|
200
|
+
...(input.serverId !== undefined ? { serverId: input.serverId } : {}),
|
|
163
201
|
}
|
|
164
202
|
this.tasks.set(record.taskId, record)
|
|
165
203
|
return record
|
|
@@ -244,7 +282,7 @@ export class DomainTaskStore implements TaskStore {
|
|
|
244
282
|
}
|
|
245
283
|
}
|
|
246
284
|
|
|
247
|
-
create(input: { contextId: string; skill: string; parts: readonly Part[]; remotePeerId: string | null }): TaskRecord {
|
|
285
|
+
create(input: { contextId: string; skill: string; parts: readonly Part[]; remotePeerId: string | null; serverId?: string }): TaskRecord {
|
|
248
286
|
const record: TaskRecord = {
|
|
249
287
|
taskId: `a2a-${crypto.randomUUID()}`,
|
|
250
288
|
contextId: input.contextId,
|
|
@@ -258,6 +296,7 @@ export class DomainTaskStore implements TaskStore {
|
|
|
258
296
|
artifacts: [],
|
|
259
297
|
executor: 'session',
|
|
260
298
|
summary: null,
|
|
299
|
+
...(input.serverId !== undefined ? { serverId: input.serverId } : {}),
|
|
261
300
|
}
|
|
262
301
|
this.records.set(record.taskId, record)
|
|
263
302
|
// Persist eagerly; a failed write must fail the task, not silently lose it.
|
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inbound server manager: owns every inbound A2A server instance. Each
|
|
3
|
+
* instance binds one concrete agent preset (its session pool is composed from
|
|
4
|
+
* that preset) and serves its own endpoint and AgentCard route. Instances are
|
|
5
|
+
* persisted in the `inbound_servers` domain table and assembled on boot; the
|
|
6
|
+
* manager owns route registration and the full lifecycle
|
|
7
|
+
* (add/enable/disable/update/remove) driven from the GUI and facade, and
|
|
8
|
+
* disposes every instance's environment on teardown.
|
|
9
|
+
*
|
|
10
|
+
* Skill declaration is DERIVED, never typed: "the preset decides its skills;
|
|
11
|
+
* everything is a plugin." An instance's AgentCard skills are the
|
|
12
|
+
* model-invocable entries of its preset's skill directory — the preset's
|
|
13
|
+
* standing scope key (`agentPresets.standingKeyFor`) plus
|
|
14
|
+
* `ctx.skills.list({ scope })` — resolved automatically at add/update/boot
|
|
15
|
+
* and cached on the live instance; the creator types no skill text. A missing
|
|
16
|
+
* skills service or standing mount falls back to a built-in `chat` skill so
|
|
17
|
+
* minimal compositions stay exercisable.
|
|
18
|
+
* @module dsh-a2a/servers/inbound-manager
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
22
|
+
import { buildCard } from '../server/card.ts'
|
|
23
|
+
import { A2AServer } from '../server/a2a-server.ts'
|
|
24
|
+
import { A2aRoutes } from '../server/routes.ts'
|
|
25
|
+
import { ContextSessionPool, type AgentPresetsLike, type AgentRegistryLike, type SkillsLike } from '../server/exec/agent-runtime.ts'
|
|
26
|
+
import { createSessionExecutor } from '../server/exec/session.ts'
|
|
27
|
+
import { createSubagentExecutor, type SubagentsLike } from '../server/exec/subagent.ts'
|
|
28
|
+
import { ExecutorSet } from '../server/executor.ts'
|
|
29
|
+
import { LiveInboundRegistry } from '../server/inbound-registry.ts'
|
|
30
|
+
import type { TaskStore, InboundServerRecord } from '../server/store.ts'
|
|
31
|
+
import type { AgentSkill } from '../protocol.ts'
|
|
32
|
+
import type { GateInput, GateResult } from '../server/a2a-server.ts'
|
|
33
|
+
import type { InboundTaskDecision } from '../events.ts'
|
|
34
|
+
|
|
35
|
+
/** Web-server slice the manager registers instance routes on. */
|
|
36
|
+
export interface WebServerLike {
|
|
37
|
+
register(route: {
|
|
38
|
+
readonly kind: 'exact' | 'prefix'
|
|
39
|
+
readonly path: string
|
|
40
|
+
handler(...args: unknown[]): unknown
|
|
41
|
+
}): () => void
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Host service slices one manager needs. */
|
|
45
|
+
export interface InboundManagerHost {
|
|
46
|
+
readonly ctx: Context
|
|
47
|
+
readonly webServer: WebServerLike
|
|
48
|
+
readonly tasks: TaskStore
|
|
49
|
+
readonly logger: { info(message: string): void; warn(message: string): void; error(message: string): void }
|
|
50
|
+
readonly agents?: AgentRegistryLike
|
|
51
|
+
readonly agentPresets?: AgentPresetsLike
|
|
52
|
+
readonly skills?: SkillsLike
|
|
53
|
+
readonly subagents?: SubagentsLike
|
|
54
|
+
readonly sessionCwd: string
|
|
55
|
+
readonly defaultBaseUrl: string
|
|
56
|
+
readonly subagentProvider: string
|
|
57
|
+
/** Deployment default preset id (from `agentPresets.defaultId`), when known. */
|
|
58
|
+
readonly defaultPresetId?: string
|
|
59
|
+
/** Resolve the deployment's default model options (may be undefined). */
|
|
60
|
+
readonly resolveDefaultModel?: () => { readonly provider?: string; readonly model?: string } | undefined
|
|
61
|
+
/** Allocate a fresh stable instance id. */
|
|
62
|
+
readonly newId: () => string
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** One live inbound instance's runtime handle. */
|
|
66
|
+
export interface LiveInboundServer {
|
|
67
|
+
readonly id: string
|
|
68
|
+
readonly record: InboundServerRecord
|
|
69
|
+
/** Effective preset id (record's or the deployment default). */
|
|
70
|
+
readonly preset: string | undefined
|
|
71
|
+
/** Derived skill declarations (cached at assemble time). */
|
|
72
|
+
readonly skills: readonly AgentSkill[]
|
|
73
|
+
readonly card: ReturnType<typeof buildCard>
|
|
74
|
+
readonly server: A2AServer
|
|
75
|
+
readonly routes: A2aRoutes
|
|
76
|
+
readonly inbound: LiveInboundRegistry
|
|
77
|
+
readonly executors: ExecutorSet
|
|
78
|
+
readonly endpointPath: string
|
|
79
|
+
readonly cardPath: string
|
|
80
|
+
dispose(): void
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Creator input for a new (or updated) inbound instance. */
|
|
84
|
+
export interface InboundCreateInput {
|
|
85
|
+
readonly name: string
|
|
86
|
+
readonly description: string
|
|
87
|
+
readonly version: string
|
|
88
|
+
readonly endpointPath?: string
|
|
89
|
+
/** Preset id; absent = the deployment default preset. */
|
|
90
|
+
readonly preset?: string
|
|
91
|
+
readonly authTokenEnv?: string
|
|
92
|
+
readonly enabled?: boolean
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface OpResult {
|
|
96
|
+
readonly ok: boolean
|
|
97
|
+
readonly message: string
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function refuseExecutor(reason: string) {
|
|
101
|
+
return {
|
|
102
|
+
name: 'refuse',
|
|
103
|
+
async execute(): Promise<{ parts: { text: string }[] }> {
|
|
104
|
+
throw new Error(`a2a: ${reason}; refusing inbound task`)
|
|
105
|
+
},
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Built-in fallback skill when no skills service / standing mount exists. */
|
|
110
|
+
export function chatFallbackSkills(): readonly AgentSkill[] {
|
|
111
|
+
return [{ id: 'chat', name: 'chat', description: 'Conversational assistance over a DSH agent session.', tags: ['chat'] }]
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Derive an instance's AgentCard skills from its preset's skill directory.
|
|
116
|
+
*
|
|
117
|
+
* `agentPresets.standingKeyFor(preset)` yields the preset's standing scope
|
|
118
|
+
* key (no agent required); `ctx.skills.list({ scope })` returns the catalogue
|
|
119
|
+
* that preset agent actually sees (its layer + the deployment global). Only
|
|
120
|
+
* model-invocable entries are advertised, mapped to `AgentSkill` (id = name,
|
|
121
|
+
* name = name). Any missing service, failed mount, or empty catalogue falls
|
|
122
|
+
* back to the built-in `chat` skill.
|
|
123
|
+
*
|
|
124
|
+
* @param agentPresets - the preset roster, or undefined.
|
|
125
|
+
* @param skills - the skill registry, or undefined.
|
|
126
|
+
* @param preset - the instance's preset id; undefined = deployment default.
|
|
127
|
+
*/
|
|
128
|
+
export async function derivePresetSkills(
|
|
129
|
+
agentPresets: AgentPresetsLike | undefined,
|
|
130
|
+
skills: SkillsLike | undefined,
|
|
131
|
+
preset: string | undefined,
|
|
132
|
+
): Promise<readonly AgentSkill[]> {
|
|
133
|
+
if (agentPresets?.standingKeyFor === undefined || skills === undefined) return chatFallbackSkills()
|
|
134
|
+
try {
|
|
135
|
+
const scope = await agentPresets.standingKeyFor(preset)
|
|
136
|
+
const rows = await skills.list({ scope })
|
|
137
|
+
const advertised: AgentSkill[] = rows
|
|
138
|
+
.filter((row) => row.invocation?.modelInvocable !== false)
|
|
139
|
+
.map((row) => ({
|
|
140
|
+
id: row.name,
|
|
141
|
+
name: row.name,
|
|
142
|
+
...(row.description !== undefined && row.description.length > 0 ? { description: row.description } : {}),
|
|
143
|
+
}))
|
|
144
|
+
return advertised.length > 0 ? advertised : chatFallbackSkills()
|
|
145
|
+
} catch {
|
|
146
|
+
return chatFallbackSkills()
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Assemble one live instance (skills already derived from the preset). */
|
|
151
|
+
function assemble(record: InboundServerRecord, skills: readonly AgentSkill[], host: InboundManagerHost): LiveInboundServer {
|
|
152
|
+
const endpointPath = record.endpointPath
|
|
153
|
+
const cardPath = `${endpointPath.replace(/\/$/, '')}/agent-card.json`
|
|
154
|
+
const preset = record.preset ?? host.defaultPresetId
|
|
155
|
+
const card = buildCard({
|
|
156
|
+
baseUrl: host.defaultBaseUrl,
|
|
157
|
+
endpointPath,
|
|
158
|
+
name: record.name,
|
|
159
|
+
description: record.description,
|
|
160
|
+
version: record.version,
|
|
161
|
+
skills,
|
|
162
|
+
...(record.authTokenEnv !== undefined && process.env[record.authTokenEnv] !== undefined
|
|
163
|
+
? { authToken: process.env[record.authTokenEnv]! }
|
|
164
|
+
: {}),
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
// One preset-bound session pool per instance.
|
|
168
|
+
const sessionPool = host.agents
|
|
169
|
+
? new ContextSessionPool(host.agents, {
|
|
170
|
+
cwd: host.sessionCwd,
|
|
171
|
+
...(host.agentPresets !== undefined ? { agentPresets: host.agentPresets } : {}),
|
|
172
|
+
...(preset !== undefined ? { presetId: () => preset } : {}),
|
|
173
|
+
// `?.()` yields `{ provider?, model? } | undefined`, matching the
|
|
174
|
+
// AgentRuntimeOptions.resolveAgentOptions signature under
|
|
175
|
+
// exactOptionalPropertyTypes.
|
|
176
|
+
resolveAgentOptions: () => host.resolveDefaultModel?.(),
|
|
177
|
+
})
|
|
178
|
+
: undefined
|
|
179
|
+
const sessionExecutor = sessionPool ? createSessionExecutor(sessionPool) : refuseExecutor('no agent loop mounted')
|
|
180
|
+
const subagentExecutor = sessionPool !== undefined && host.subagents !== undefined
|
|
181
|
+
? createSubagentExecutor({ pool: sessionPool, subagents: host.subagents, provider: host.subagentProvider })
|
|
182
|
+
: undefined
|
|
183
|
+
const executors = new ExecutorSet({ chat: 'session' }, sessionExecutor, subagentExecutor)
|
|
184
|
+
|
|
185
|
+
const inbound = new LiveInboundRegistry()
|
|
186
|
+
const skillIds = new Set(skills.map((s) => s.id))
|
|
187
|
+
const gate = async (input: GateInput): Promise<GateResult> => {
|
|
188
|
+
if (!skillIds.has(input.skill)) {
|
|
189
|
+
return { ok: false, reason: `unknown skill "${input.skill}"` }
|
|
190
|
+
}
|
|
191
|
+
const decision: InboundTaskDecision = {
|
|
192
|
+
contextId: input.contextId,
|
|
193
|
+
skill: input.skill,
|
|
194
|
+
parts: input.parts,
|
|
195
|
+
remotePeerId: input.remotePeerId,
|
|
196
|
+
}
|
|
197
|
+
const decided = await host.ctx.waterfall('a2a/inbound-task', decision, async (d) => d)
|
|
198
|
+
host.logger.info(`[a2a:${record.id}] inbound skill=${decided.skill} rejected=${decided.rejected?.reason ?? 'no'}`)
|
|
199
|
+
if (decided.rejected !== undefined) return { ok: false, reason: decided.rejected.reason }
|
|
200
|
+
return { ok: true }
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const server = new A2AServer({
|
|
204
|
+
card,
|
|
205
|
+
store: host.tasks,
|
|
206
|
+
executors,
|
|
207
|
+
...(card.securitySchemes !== undefined ? { authToken: 'configured' } : {}),
|
|
208
|
+
cardPath,
|
|
209
|
+
gate,
|
|
210
|
+
onInbound: (facts) => inbound.note({
|
|
211
|
+
method: facts.method,
|
|
212
|
+
...(facts.source !== undefined ? { source: facts.source } : {}),
|
|
213
|
+
taskIds: facts.taskIds,
|
|
214
|
+
streaming: facts.streaming,
|
|
215
|
+
}),
|
|
216
|
+
onTaskSettled: (taskId) => {
|
|
217
|
+
host.logger.info(`[a2a:${record.id}] task settled ${taskId}`)
|
|
218
|
+
inbound.settle(taskId)
|
|
219
|
+
},
|
|
220
|
+
})
|
|
221
|
+
const routes = new A2aRoutes(host.webServer, server, cardPath)
|
|
222
|
+
|
|
223
|
+
return {
|
|
224
|
+
id: record.id,
|
|
225
|
+
record,
|
|
226
|
+
preset,
|
|
227
|
+
skills,
|
|
228
|
+
card,
|
|
229
|
+
server,
|
|
230
|
+
routes,
|
|
231
|
+
inbound,
|
|
232
|
+
executors,
|
|
233
|
+
endpointPath,
|
|
234
|
+
cardPath,
|
|
235
|
+
dispose: () => {
|
|
236
|
+
routes.dispose()
|
|
237
|
+
void executors.disposeAll().catch(() => {})
|
|
238
|
+
},
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Persisted-instance store over the inbound_servers table. */
|
|
243
|
+
export class DomainInboundStore {
|
|
244
|
+
private static KEY(id: string): string {
|
|
245
|
+
return `in:${id}`
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
constructor(private readonly table: {
|
|
249
|
+
get(key: string): string | undefined
|
|
250
|
+
put(key: string, value: string): Promise<void>
|
|
251
|
+
entries?(): IterableIterator<[string, string]>
|
|
252
|
+
keys?(): IterableIterator<string>
|
|
253
|
+
}) {}
|
|
254
|
+
|
|
255
|
+
list(): InboundServerRecord[] {
|
|
256
|
+
const out: InboundServerRecord[] = []
|
|
257
|
+
const entries = this.table.entries
|
|
258
|
+
if (entries === undefined) return out
|
|
259
|
+
// Call through the table (method `this` stays bound); the real KvTable's
|
|
260
|
+
// `entries()` reads instance state.
|
|
261
|
+
for (const [key, raw] of entries.call(this.table)) {
|
|
262
|
+
if (!key.startsWith('in:')) continue
|
|
263
|
+
const parsed = safeParse(raw)
|
|
264
|
+
if (parsed !== undefined) out.push(parsed)
|
|
265
|
+
}
|
|
266
|
+
return out
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
get(id: string): InboundServerRecord | undefined {
|
|
270
|
+
const raw = this.table.get(DomainInboundStore.KEY(id))
|
|
271
|
+
return raw === undefined ? undefined : safeParse(raw)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async save(record: InboundServerRecord): Promise<void> {
|
|
275
|
+
await this.table.put(DomainInboundStore.KEY(record.id), JSON.stringify(record))
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async remove(id: string): Promise<void> {
|
|
279
|
+
await this.table.put(DomainInboundStore.KEY(id), '')
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function safeParse(raw: string): InboundServerRecord | undefined {
|
|
284
|
+
try {
|
|
285
|
+
const parsed = JSON.parse(raw) as InboundServerRecord
|
|
286
|
+
if (typeof parsed.id !== 'string' || typeof parsed.name !== 'string') return undefined
|
|
287
|
+
return parsed
|
|
288
|
+
} catch {
|
|
289
|
+
return undefined
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Manages the live set of inbound server instances. */
|
|
294
|
+
export class InboundServerManager {
|
|
295
|
+
private readonly live = new Map<string, LiveInboundServer>()
|
|
296
|
+
|
|
297
|
+
constructor(
|
|
298
|
+
private readonly host: InboundManagerHost,
|
|
299
|
+
private readonly store: DomainInboundStore,
|
|
300
|
+
) {}
|
|
301
|
+
|
|
302
|
+
/** Assemble every persisted instance (skills derived per preset). */
|
|
303
|
+
async boot(): Promise<void> {
|
|
304
|
+
for (const record of this.store.list()) {
|
|
305
|
+
try {
|
|
306
|
+
const skills = await derivePresetSkills(this.host.agentPresets, this.host.skills, record.preset ?? this.host.defaultPresetId)
|
|
307
|
+
const live = assemble(record, skills, this.host)
|
|
308
|
+
this.live.set(record.id, live)
|
|
309
|
+
if (record.enabled) live.routes.enable()
|
|
310
|
+
} catch (err) {
|
|
311
|
+
this.host.logger.error(`[a2a:${record.id}] failed to assemble: ${String((err as Error).message)}`)
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
list(): readonly LiveInboundServer[] {
|
|
317
|
+
return [...this.live.values()]
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
get(id: string): LiveInboundServer | undefined {
|
|
321
|
+
return this.live.get(id)
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** Create, persist, and assemble a new inbound instance. */
|
|
325
|
+
async add(input: InboundCreateInput): Promise<OpResult & { readonly id?: string }> {
|
|
326
|
+
const id = this.newId(input.name)
|
|
327
|
+
if (this.live.has(id)) return { ok: false, message: `inbound server ${id} already exists` }
|
|
328
|
+
const preset = input.preset ?? this.host.defaultPresetId
|
|
329
|
+
const record: InboundServerRecord = {
|
|
330
|
+
id,
|
|
331
|
+
name: input.name,
|
|
332
|
+
description: input.description,
|
|
333
|
+
version: input.version,
|
|
334
|
+
endpointPath: input.endpointPath ?? `/a2a/${id}`,
|
|
335
|
+
...(preset !== undefined ? { preset } : {}),
|
|
336
|
+
...(input.authTokenEnv !== undefined ? { authTokenEnv: input.authTokenEnv } : {}),
|
|
337
|
+
enabled: input.enabled ?? true,
|
|
338
|
+
}
|
|
339
|
+
try {
|
|
340
|
+
const skills = await derivePresetSkills(this.host.agentPresets, this.host.skills, preset)
|
|
341
|
+
const live = assemble(record, skills, this.host)
|
|
342
|
+
await this.store.save(record)
|
|
343
|
+
this.live.set(id, live)
|
|
344
|
+
if (record.enabled) live.routes.enable()
|
|
345
|
+
return { ok: true, message: `inbound server "${record.name}" created`, id }
|
|
346
|
+
} catch (err) {
|
|
347
|
+
return { ok: false, message: `failed to create inbound server: ${String((err as Error).message)}` }
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** Persist, rebuild (re-deriving skills), and apply a patch onto a live instance. */
|
|
352
|
+
async update(id: string, patch: Partial<Pick<InboundServerRecord, 'name' | 'description' | 'version' | 'endpointPath' | 'preset' | 'authTokenEnv'>>): Promise<OpResult> {
|
|
353
|
+
const live = this.live.get(id)
|
|
354
|
+
const stored = this.store.get(id)
|
|
355
|
+
if (live === undefined || stored === undefined) return { ok: false, message: `inbound server ${id} not found` }
|
|
356
|
+
const next: InboundServerRecord = {
|
|
357
|
+
...stored,
|
|
358
|
+
name: patch.name ?? stored.name,
|
|
359
|
+
description: patch.description ?? stored.description,
|
|
360
|
+
version: patch.version ?? stored.version,
|
|
361
|
+
endpointPath: patch.endpointPath ?? stored.endpointPath,
|
|
362
|
+
...(patch.preset !== undefined ? { preset: patch.preset } : {}),
|
|
363
|
+
...(patch.authTokenEnv !== undefined ? { authTokenEnv: patch.authTokenEnv } : {}),
|
|
364
|
+
enabled: stored.enabled,
|
|
365
|
+
}
|
|
366
|
+
try {
|
|
367
|
+
await this.store.save(next)
|
|
368
|
+
const skills = await derivePresetSkills(this.host.agentPresets, this.host.skills, next.preset ?? this.host.defaultPresetId)
|
|
369
|
+
const replaced = assemble(next, skills, this.host)
|
|
370
|
+
// Preserve the route registration state while swapping runtime halves.
|
|
371
|
+
const wasEnabled = live.routes.active
|
|
372
|
+
live.dispose()
|
|
373
|
+
this.live.set(id, replaced)
|
|
374
|
+
if (wasEnabled || next.enabled) replaced.routes.enable()
|
|
375
|
+
return { ok: true, message: `inbound server ${id} updated` }
|
|
376
|
+
} catch (err) {
|
|
377
|
+
return { ok: false, message: `failed to update inbound server: ${String((err as Error).message)}` }
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** Enable/disable an instance's routes. */
|
|
382
|
+
setEnabled(id: string, enabled: boolean): OpResult {
|
|
383
|
+
const live = this.live.get(id)
|
|
384
|
+
if (live === undefined) return { ok: false, message: `inbound server ${id} not found` }
|
|
385
|
+
if (enabled) live.routes.enable()
|
|
386
|
+
else live.routes.disable()
|
|
387
|
+
const stored = this.store.get(id)
|
|
388
|
+
if (stored !== undefined) {
|
|
389
|
+
void this.store.save({ ...stored, enabled }).catch(() => {})
|
|
390
|
+
}
|
|
391
|
+
return { ok: true, message: `inbound server ${id} ${enabled ? 'enabled' : 'disabled'}` }
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** Remove, dispose, and unpersist an instance. */
|
|
395
|
+
async remove(id: string): Promise<OpResult> {
|
|
396
|
+
const live = this.live.get(id)
|
|
397
|
+
if (live === undefined) return { ok: false, message: `inbound server ${id} not found` }
|
|
398
|
+
live.dispose()
|
|
399
|
+
this.live.delete(id)
|
|
400
|
+
await this.store.remove(id)
|
|
401
|
+
return { ok: true, message: `inbound server ${id} removed` }
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** Dispose every instance. */
|
|
405
|
+
disposeAll(): void {
|
|
406
|
+
for (const live of this.live.values()) live.dispose()
|
|
407
|
+
this.live.clear()
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
private newId(seed: string): string {
|
|
411
|
+
const slug = seed.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'server'
|
|
412
|
+
return `${slug}-${this.host.newId()}`
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
export type { AgentSkill }
|