@hanphone/dsh-a2a 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +196 -0
  3. package/README.zh.md +195 -0
  4. package/cordis.patch.yml +11 -0
  5. package/lib/client.js +166 -0
  6. package/lib/index.js +2073 -0
  7. package/lib/tsconfig.client.tsbuildinfo +1 -0
  8. package/lib/tsconfig.tsbuildinfo +1 -0
  9. package/lib/types/api.d.ts +41 -0
  10. package/lib/types/api.d.ts.map +1 -0
  11. package/lib/types/api.js +86 -0
  12. package/lib/types/api.js.map +1 -0
  13. package/lib/types/client/index.d.ts +35 -0
  14. package/lib/types/client/index.d.ts.map +1 -0
  15. package/lib/types/client/index.js +155 -0
  16. package/lib/types/client/index.js.map +1 -0
  17. package/lib/types/commands.d.ts +10 -0
  18. package/lib/types/commands.d.ts.map +1 -0
  19. package/lib/types/commands.js +93 -0
  20. package/lib/types/commands.js.map +1 -0
  21. package/lib/types/events.d.ts +74 -0
  22. package/lib/types/events.d.ts.map +1 -0
  23. package/lib/types/events.js +10 -0
  24. package/lib/types/events.js.map +1 -0
  25. package/lib/types/index.d.ts +54 -0
  26. package/lib/types/index.d.ts.map +1 -0
  27. package/lib/types/index.js +296 -0
  28. package/lib/types/index.js.map +1 -0
  29. package/lib/types/jsonrpc.d.ts +20 -0
  30. package/lib/types/jsonrpc.d.ts.map +1 -0
  31. package/lib/types/jsonrpc.js +48 -0
  32. package/lib/types/jsonrpc.js.map +1 -0
  33. package/lib/types/outbound/calls.d.ts +37 -0
  34. package/lib/types/outbound/calls.d.ts.map +1 -0
  35. package/lib/types/outbound/calls.js +120 -0
  36. package/lib/types/outbound/calls.js.map +1 -0
  37. package/lib/types/outbound/registry.d.ts +102 -0
  38. package/lib/types/outbound/registry.d.ts.map +1 -0
  39. package/lib/types/outbound/registry.js +249 -0
  40. package/lib/types/outbound/registry.js.map +1 -0
  41. package/lib/types/outbound/tools.d.ts +30 -0
  42. package/lib/types/outbound/tools.d.ts.map +1 -0
  43. package/lib/types/outbound/tools.js +86 -0
  44. package/lib/types/outbound/tools.js.map +1 -0
  45. package/lib/types/protocol.d.ts +194 -0
  46. package/lib/types/protocol.d.ts.map +1 -0
  47. package/lib/types/protocol.js +78 -0
  48. package/lib/types/protocol.js.map +1 -0
  49. package/lib/types/server/a2a-server.d.ts +98 -0
  50. package/lib/types/server/a2a-server.d.ts.map +1 -0
  51. package/lib/types/server/a2a-server.js +397 -0
  52. package/lib/types/server/a2a-server.js.map +1 -0
  53. package/lib/types/server/card.d.ts +48 -0
  54. package/lib/types/server/card.d.ts.map +1 -0
  55. package/lib/types/server/card.js +75 -0
  56. package/lib/types/server/card.js.map +1 -0
  57. package/lib/types/server/exec/agent-runtime.d.ts +126 -0
  58. package/lib/types/server/exec/agent-runtime.d.ts.map +1 -0
  59. package/lib/types/server/exec/agent-runtime.js +138 -0
  60. package/lib/types/server/exec/agent-runtime.js.map +1 -0
  61. package/lib/types/server/exec/session.d.ts +11 -0
  62. package/lib/types/server/exec/session.d.ts.map +1 -0
  63. package/lib/types/server/exec/session.js +22 -0
  64. package/lib/types/server/exec/session.js.map +1 -0
  65. package/lib/types/server/exec/subagent.d.ts +39 -0
  66. package/lib/types/server/exec/subagent.d.ts.map +1 -0
  67. package/lib/types/server/exec/subagent.js +44 -0
  68. package/lib/types/server/exec/subagent.js.map +1 -0
  69. package/lib/types/server/executor.d.ts +63 -0
  70. package/lib/types/server/executor.d.ts.map +1 -0
  71. package/lib/types/server/executor.js +38 -0
  72. package/lib/types/server/executor.js.map +1 -0
  73. package/lib/types/server/routes.d.ts +32 -0
  74. package/lib/types/server/routes.d.ts.map +1 -0
  75. package/lib/types/server/routes.js +131 -0
  76. package/lib/types/server/routes.js.map +1 -0
  77. package/lib/types/server/store.d.ts +157 -0
  78. package/lib/types/server/store.d.ts.map +1 -0
  79. package/lib/types/server/store.js +239 -0
  80. package/lib/types/server/store.js.map +1 -0
  81. package/lib/types/service.d.ts +49 -0
  82. package/lib/types/service.d.ts.map +1 -0
  83. package/lib/types/service.js +46 -0
  84. package/lib/types/service.js.map +1 -0
  85. package/package.json +82 -0
  86. package/src/api.ts +115 -0
  87. package/src/client/index.ts +293 -0
  88. package/src/commands.ts +92 -0
  89. package/src/events.ts +58 -0
  90. package/src/index.ts +353 -0
  91. package/src/jsonrpc.ts +61 -0
  92. package/src/outbound/calls.ts +145 -0
  93. package/src/outbound/registry.ts +290 -0
  94. package/src/outbound/tools.ts +118 -0
  95. package/src/protocol.ts +212 -0
  96. package/src/server/a2a-server.ts +455 -0
  97. package/src/server/card.ts +106 -0
  98. package/src/server/exec/agent-runtime.ts +197 -0
  99. package/src/server/exec/session.ts +25 -0
  100. package/src/server/exec/subagent.ts +67 -0
  101. package/src/server/executor.ts +73 -0
  102. package/src/server/routes.ts +156 -0
  103. package/src/server/store.ts +320 -0
  104. package/src/service.ts +78 -0
package/src/index.ts ADDED
@@ -0,0 +1,353 @@
1
+ /**
2
+ * @hanphone/dsh-a2a — Cordis plugin entry.
3
+ *
4
+ * Mounts the A2A v1.0 dual-end plugin: the inbound server (AgentCard derived
5
+ * from the live tool registry, JSON-RPC + SSE, durable SQLite task store,
6
+ * session/subagent executors, the `a2a/inbound-task` policy gate + audit)
7
+ * and the outbound client (persisted multi-agent registry, skills mapped to
8
+ * model tools, sync calls with per-agent timeout). Optional services are
9
+ * probed rather than injected so a composition lacking one half idles just
10
+ * that half; only the storage domain is required.
11
+ *
12
+ * Function-plugin export shape: named `name`/`inject`/`Config`/`apply`, no
13
+ * default export (mixing forms makes the Loader drop the namespace).
14
+ * @module dsh-a2a
15
+ */
16
+
17
+ import type { Context } from '@deepseek-ai/cordis'
18
+ import z from '@deepseek-ai/schemastery'
19
+ import { homedir } from 'node:os'
20
+ import { join } from 'node:path'
21
+ import { mkdirSync } from 'node:fs'
22
+ import { deriveSkills, buildCard, type ToolGetter } from './server/card.ts'
23
+ import { openDomain, DomainTaskStore, type A2aDomain, type TaskStore } from './server/store.ts'
24
+ import { ExecutorSet } from './server/executor.ts'
25
+ import { ContextSessionPool, probeService, type AgentPresetsLike, type AgentRegistryLike } 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 { A2AServer, type GateInput, type GateResult } from './server/a2a-server.ts'
29
+ import { A2aRoutes } from './server/routes.ts'
30
+ import { handleApiRequest } from './api.ts'
31
+ import { OutboundAgentRegistry, DomainAgentStore, type OutboundAgentSpec } from './outbound/registry.ts'
32
+ import { A2AService, type A2AServiceImpl, type OpResult } from './service.ts'
33
+ import { buildA2aCommand } from './commands.ts'
34
+ import type { InboundTaskDecision } from './events.ts'
35
+ import type { AgentCard, AgentSkill, TaskState } from './protocol.ts'
36
+
37
+ export const name = 'a2a'
38
+
39
+ /** Only the storage domain is required; the other halves are probed. */
40
+ export const inject = ['storageDomain'] as const
41
+
42
+ export interface A2AConfig {
43
+ server: {
44
+ enabled: boolean
45
+ name: string
46
+ description: string
47
+ version: string
48
+ /** Advertised base URL; null/absent lets the host webServer address be used. */
49
+ baseUrl?: string
50
+ endpointPath: string
51
+ /** Environment variable name for the inbound bearer token; absent = anonymous. */
52
+ authTokenEnv?: string
53
+ skills: { ids: string[]; exclude: string[] }
54
+ executors: Record<string, 'session' | 'subagent'>
55
+ subagentProvider: string
56
+ }
57
+ client: {
58
+ agents: OutboundAgentSpec[]
59
+ toolPrefix: string
60
+ }
61
+ }
62
+
63
+ /** Loader-validated config schema (defaults applied by the Loader). */
64
+ export const Config: z<A2AConfig> = z.object({
65
+ server: z.object({
66
+ enabled: z.boolean().default(true),
67
+ name: z.string().default('My DSH Agent'),
68
+ description: z.string().default('A DeepSeek Harness agent exposed over A2A v1.0'),
69
+ version: z.string().default('0.1.0'),
70
+ baseUrl: z.string(),
71
+ endpointPath: z.string().default('/a2a'),
72
+ authTokenEnv: z.string(),
73
+ skills: z.object({
74
+ ids: z.array(z.string()).default([]),
75
+ exclude: z.array(z.string()).default([]),
76
+ }),
77
+ executors: z.dict(z.union(['session', 'subagent'] as const)).default({ chat: 'session' }),
78
+ subagentProvider: z.string().default('in-process'),
79
+ }),
80
+ client: z.object({
81
+ agents: z.array(z.object({
82
+ name: z.string(),
83
+ agentCardUrl: z.string(),
84
+ bearerTokenEnv: z.string(),
85
+ enabled: z.boolean().default(true),
86
+ timeoutMs: z.number().default(60000),
87
+ })).default([]),
88
+ toolPrefix: z.string().default('a2a'),
89
+ }),
90
+ })
91
+
92
+ /** Mutable state shared across the plugin's halves. */
93
+ interface Shared {
94
+ readonly domain: A2aDomain
95
+ readonly store: TaskStore
96
+ registry: OutboundAgentRegistry | undefined
97
+ server: A2AServer | undefined
98
+ routes: A2aRoutes | undefined
99
+ card: AgentCard | undefined
100
+ executors: ExecutorSet | undefined
101
+ enabled: boolean
102
+ }
103
+
104
+ export function apply(ctx: Context, config: A2AConfig) {
105
+ const logger = ctx.logger('a2a')
106
+ const serverConfig = { ...config.server }
107
+ const clientConfig = { ...config.client }
108
+ const authToken = serverConfig.authTokenEnv !== undefined ? process.env[serverConfig.authTokenEnv] : undefined
109
+ const toolPrefix = clientConfig.toolPrefix
110
+ const subagentProvider = serverConfig.subagentProvider
111
+ const inboundCwd = inboundSessionCwd()
112
+
113
+ ctx.inject(['storageDomain'], (ctx) => {
114
+ ctx.effect(async () => {
115
+ const domain = await openDomain(ctx.storageDomain)
116
+ const store = new DomainTaskStore(domain)
117
+ const holder: Shared = {
118
+ domain,
119
+ store,
120
+ registry: undefined,
121
+ server: undefined,
122
+ routes: undefined,
123
+ card: undefined,
124
+ executors: undefined,
125
+ enabled: serverConfig.enabled,
126
+ }
127
+
128
+ // ── inbound server half ────────────────────────────────────────────
129
+ const webServer = probeService(ctx, 'webServer', 'register') as
130
+ | { register(route: { kind: 'exact' | 'prefix'; path: string; handler(...args: unknown[]): unknown }): () => void }
131
+ | undefined
132
+ if (webServer === undefined) {
133
+ logger.warn('a2a: webServer not mounted; inbound server idle')
134
+ } else {
135
+ try {
136
+ const skills = deriveSkills(
137
+ probeService(ctx, 'tools', 'get') as ToolGetter | undefined ?? { get: () => undefined },
138
+ { ids: serverConfig.skills.ids, exclude: serverConfig.skills.exclude },
139
+ )
140
+ const baseUrl = serverConfig.baseUrl ?? `http://127.0.0.1:${process.env['DSH_A2A_PORT'] ?? '3000'}`
141
+ const endpointPath = serverConfig.endpointPath ?? '/a2a'
142
+ const card = buildCard({
143
+ baseUrl,
144
+ endpointPath,
145
+ name: serverConfig.name ?? 'My DSH Agent',
146
+ description: serverConfig.description ?? 'A DeepSeek Harness agent exposed over A2A v1.0',
147
+ version: serverConfig.version ?? '0.1.0',
148
+ skills,
149
+ ...(authToken ? { authToken } : {}),
150
+ })
151
+ holder.card = card
152
+
153
+ // Executors: probe the agent loop; refuse tasks readably without it.
154
+ const agents = probeService(ctx, 'agents', 'create') as AgentRegistryLike | undefined
155
+ const presets = probeService(ctx, 'agentPresets', 'resolve') as AgentPresetsLike | undefined
156
+ const sessionPool = agents
157
+ ? new ContextSessionPool(agents, {
158
+ cwd: inboundCwd,
159
+ ...(presets ? { agentPresets: presets } : {}),
160
+ resolveAgentOptions: () => resolveDefaultModel(ctx),
161
+ })
162
+ : undefined
163
+ const sessionExecutor = sessionPool ? createSessionExecutor(sessionPool) : refuseExecutor('no agent loop mounted')
164
+ const subagents = probeService(ctx, 'subagents', 'start') as SubagentsLike | undefined
165
+ const subagentExecutor = sessionPool !== undefined && subagents !== undefined
166
+ ? createSubagentExecutor({ pool: sessionPool, subagents, provider: subagentProvider })
167
+ : undefined
168
+ const executors = new ExecutorSet(serverConfig.executors, sessionExecutor, subagentExecutor)
169
+ holder.executors = executors
170
+
171
+ const skillIds = new Set(skills.map((s) => s.id))
172
+ const gate = async (input: GateInput): Promise<GateResult> => {
173
+ if (!skillIds.has(input.skill)) {
174
+ return { ok: false, reason: `unknown skill "${input.skill}"` }
175
+ }
176
+ const decision: InboundTaskDecision = {
177
+ contextId: input.contextId,
178
+ skill: input.skill,
179
+ parts: input.parts,
180
+ remotePeerId: input.remotePeerId,
181
+ }
182
+ const decided = await ctx.waterfall('a2a/inbound-task', decision, async (d) => d)
183
+ // Built-in audit: every task decision is logged with its source.
184
+ logger.info(`[a2a] inbound skill=${decided.skill} remote=${decided.remotePeerId} rejected=${decided.rejected?.reason ?? 'no'}`)
185
+ if (decided.rejected !== undefined) return { ok: false, reason: decided.rejected.reason }
186
+ return { ok: true }
187
+ }
188
+
189
+ const server = new A2AServer({
190
+ card,
191
+ store,
192
+ executors,
193
+ ...(authToken ? { authToken } : {}),
194
+ gate,
195
+ onTaskSettled: (taskId) => logger.info(`[a2a] task settled ${taskId}`),
196
+ })
197
+ holder.server = server
198
+ const routes = new A2aRoutes(webServer as never, server)
199
+ holder.routes = routes
200
+ if (holder.enabled) routes.enable()
201
+ } catch (err) {
202
+ logger.error(`a2a: server half failed to build: ${(err as Error).message}`)
203
+ }
204
+ }
205
+
206
+ // ── outbound client half (after the inbound routes exist, so a
207
+ // loopback agent can fetch this server's own card) ──────────────────
208
+ const toolsService = probeService(ctx, 'tools', 'get') as
209
+ | (ToolGetter & { register(def: unknown): (() => void) | void })
210
+ | undefined
211
+ if (toolsService === undefined) {
212
+ logger.warn('a2a: tools service not mounted; outbound client idle')
213
+ } else {
214
+ const registry = new OutboundAgentRegistry({
215
+ registrar: { register: (def) => toolsService.register(def) },
216
+ store: new DomainAgentStore(domain.agents),
217
+ toolPrefix,
218
+ tokenOf: (env) => (env ? process.env[env] : undefined),
219
+ onError: (message) => logger.warn(message),
220
+ })
221
+ holder.registry = registry
222
+ registry.loadAll()
223
+ // Declared agents seed the registry: the persisted store stays the
224
+ // runtime state, so a disabled or removed agent is not reconnected.
225
+ if (clientConfig.agents.length > 0) await registry.seed(clientConfig.agents)
226
+ }
227
+
228
+ // ── service facade + commands ──────────────────────────────────────
229
+ const impl: A2AServiceImpl = makeFacade(holder, logger.info.bind(logger))
230
+ new A2AService(ctx, impl)
231
+
232
+ const commands = probeService(ctx, 'commands', 'register') as
233
+ | { register(def: { name: string; description: string; handler(...args: unknown[]): unknown }): () => void }
234
+ | undefined
235
+ if (commands !== undefined) {
236
+ ctx.effect(() => commands.register(buildA2aCommand(impl) as never), 'a2a: command')
237
+ }
238
+
239
+ // ── GUI dashboard API (loopback-only /a2a/api) ─────────────────────
240
+ const dashboardWebServer = probeService(ctx, 'webServer', 'register') as
241
+ | { register(route: { kind: 'exact' | 'prefix'; path: string; handler(...args: unknown[]): unknown }): () => void }
242
+ | undefined
243
+ if (dashboardWebServer !== undefined) {
244
+ ctx.effect(() => dashboardWebServer.register({
245
+ kind: 'prefix',
246
+ path: '/a2a/api',
247
+ handler: (req: unknown, res: unknown) => handleApiRequest(req as never, res as never, impl),
248
+ }), 'a2a: dashboard api')
249
+ } else {
250
+ logger.warn('a2a: webServer not mounted; GUI dashboard API idle')
251
+ }
252
+
253
+ return async () => {
254
+ holder.routes?.dispose()
255
+ await holder.executors?.disposeAll()
256
+ await holder.registry?.disposeAll()
257
+ await domain.close()
258
+ }
259
+ }, 'a2a: domain + halves')
260
+ })
261
+ }
262
+
263
+ /** Facade closure over the shared holder (commands and `ctx.a2a` consumers). */
264
+ function makeFacade(holder: Shared, log: (message: string) => void): A2AServiceImpl {
265
+ const serverEnabled = (): boolean => holder.routes?.active ?? false
266
+ return {
267
+ status(): unknown {
268
+ return {
269
+ server: {
270
+ enabled: serverEnabled(),
271
+ cardUrl: holder.card?.supportedInterfaces?.[0]?.url,
272
+ skills: holder.card?.skills?.map((s) => s.id) ?? [],
273
+ executors: holder.executors ? ['session', ...(holder.executors['subagent'] !== undefined ? ['subagent'] : [])] : [],
274
+ },
275
+ tasks: holder.store.list().length,
276
+ agents: holder.registry?.list() ?? [],
277
+ }
278
+ },
279
+ async enableServer(enable: boolean): Promise<OpResult> {
280
+ if (holder.routes === undefined) return { ok: false, message: 'inbound server is not mounted (no webServer)' }
281
+ if (enable) holder.routes.enable()
282
+ else holder.routes.disable()
283
+ log(`[a2a] server ${enable ? 'enabled' : 'disabled'}`)
284
+ return { ok: true, message: `server ${enable ? 'enabled' : 'disabled'}` }
285
+ },
286
+ getTask(taskId: string): unknown {
287
+ return holder.store.get(taskId)
288
+ },
289
+ listTasks(): unknown {
290
+ return holder.store.list()
291
+ },
292
+ async cancelTask(taskId: string): Promise<OpResult> {
293
+ const record = holder.store.get(taskId)
294
+ if (record === undefined) return { ok: false, message: `task ${taskId} not found` }
295
+ const aborted = holder.server?.abort?.(taskId) ?? false
296
+ return { ok: true, message: aborted ? `task ${taskId} canceled` : `task ${taskId} already terminal` }
297
+ },
298
+ agents(): unknown {
299
+ return holder.registry?.list() ?? []
300
+ },
301
+ async addAgent(spec: { name: string; agentCardUrl: string; bearerTokenEnv?: string }): Promise<OpResult> {
302
+ const registry = holder.registry
303
+ if (registry === undefined) return { ok: false, message: 'outbound client not mounted (no tools service)' }
304
+ const result = await registry.add({ ...spec, enabled: true, timeoutMs: 60000 })
305
+ return result
306
+ },
307
+ async removeAgent(id: string): Promise<OpResult> {
308
+ return holder.registry?.remove(id) ?? { ok: false, message: 'outbound client not mounted' }
309
+ },
310
+ async setAgentEnabled(id: string, enabled: boolean): Promise<OpResult> {
311
+ return holder.registry?.setEnabled(id, enabled) ?? { ok: false, message: 'outbound client not mounted' }
312
+ },
313
+ async refreshAgentCard(id: string): Promise<OpResult> {
314
+ return holder.registry?.refresh(id) ?? { ok: false, message: 'outbound client not mounted' }
315
+ },
316
+ }
317
+ }
318
+
319
+ /** A task that refuses readably on compositions without an agent loop. */
320
+ function refuseExecutor(reason: string) {
321
+ return {
322
+ name: 'refuse',
323
+ async execute(): Promise<{ parts: { text: string }[] }> {
324
+ throw new Error(`a2a: ${reason}; refusing inbound task`)
325
+ },
326
+ }
327
+ }
328
+
329
+ function resolveDefaultModel(ctx: Context): { provider?: string; model?: string } | undefined {
330
+ const service = probeService(ctx, 'agentDefaultModel', 'currentSelection') as
331
+ | { currentSelection(): { provider?: string; model?: string } }
332
+ | undefined
333
+ return service?.currentSelection()
334
+ }
335
+
336
+ /** Absolute stable cwd for inbound per-context sessions (never profile root). */
337
+ function inboundSessionCwd(): string {
338
+ const home = join(homedir(), '.dsh')
339
+ const dshHome = process.env['DSH_HOME'] ?? home
340
+ const dir = join(dshHome, 'a2a-sessions')
341
+ try {
342
+ mkdirSync(dir, { recursive: true })
343
+ } catch {
344
+ // cwd fallback below stays valid
345
+ }
346
+ return dir
347
+ }
348
+
349
+ export type { TaskState }
350
+ export type { AgentSkill }
351
+ export { A2AServer } from './server/a2a-server.ts'
352
+ export { A2AClient, A2AError } from './outbound/calls.ts'
353
+ export { A2AService } from './service.ts'
package/src/jsonrpc.ts ADDED
@@ -0,0 +1,61 @@
1
+ /**
2
+ * JSON-RPC 2.0 helpers and SSE framing for the A2A binding. Transport-agnostic:
3
+ * the A2AServer consumes these, and the HTTP route layer only maps requests to
4
+ * `handle` / `handleStream` and bytes back.
5
+ * @module dsh-a2a/jsonrpc
6
+ */
7
+
8
+ import {
9
+ A2A_ERROR_CODES,
10
+ type JsonRpcError,
11
+ type JsonRpcRequest,
12
+ type JsonRpcSuccess,
13
+ } from './protocol.ts'
14
+
15
+ /** Parse a request body into a JSON-RPC request, or undefined on malformed input. */
16
+ export function parseRpc(body: string): JsonRpcRequest | undefined {
17
+ let value: unknown
18
+ try {
19
+ value = JSON.parse(body)
20
+ } catch {
21
+ return undefined
22
+ }
23
+ if (typeof value !== 'object' || value === null) return undefined
24
+ const rpc = value as Partial<JsonRpcRequest>
25
+ if (rpc.jsonrpc !== '2.0' || typeof rpc.method !== 'string') return undefined
26
+ return rpc as JsonRpcRequest
27
+ }
28
+
29
+ /** Build a JSON-RPC error response object. */
30
+ export function rpcError(
31
+ id: string | number | null | undefined,
32
+ code: number,
33
+ message: string,
34
+ data?: unknown,
35
+ ): JsonRpcError {
36
+ return {
37
+ jsonrpc: '2.0',
38
+ id: id ?? null,
39
+ error: { code, message, ...(data === undefined ? {} : { data }) },
40
+ }
41
+ }
42
+
43
+ /** Build a JSON-RPC success response object. */
44
+ export function rpcSuccess(id: string | number | null | undefined, result: unknown): JsonRpcSuccess {
45
+ return { jsonrpc: '2.0', id: id ?? null, result }
46
+ }
47
+
48
+ /** Invalid-method error for the SSE path (no request id on the wire). */
49
+ export function methodNotFound(method: string): JsonRpcError {
50
+ return rpcError(null, A2A_ERROR_CODES.METHOD_NOT_FOUND, `Unknown method ${method}`)
51
+ }
52
+
53
+ /** One data frame of an SSE stream. */
54
+ export function sseData(payload: unknown): string {
55
+ return `data: ${JSON.stringify(payload)}\n\n`
56
+ }
57
+
58
+ /** One named event frame of an SSE stream (e.g. `event: error`). */
59
+ export function sseEvent(name: string, payload: unknown): string {
60
+ return `event: ${name}\ndata: ${JSON.stringify(payload)}\n\n`
61
+ }
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Outbound A2A client: AgentCard discovery plus the JSON-RPC methods the
3
+ * tools layer needs. Sync-by-polling for P0 (each tool call waits for the
4
+ * task to settle up to a timeout); passive result injection replaces this in
5
+ * P1 without changing the transport.
6
+ * @module dsh-a2a/client/calls
7
+ */
8
+
9
+ import {
10
+ A2A_ERROR_CODES,
11
+ A2A_METHODS,
12
+ isTerminal,
13
+ type AgentCard,
14
+ type JsonRpcResponse,
15
+ type Message,
16
+ type Task,
17
+ } from '../protocol.ts'
18
+
19
+ /** Error carrying the remote JSON-RPC error code. */
20
+ export class A2AError extends Error {
21
+ constructor(
22
+ readonly code: number,
23
+ message: string,
24
+ ) {
25
+ super(message)
26
+ this.name = 'A2AError'
27
+ }
28
+ }
29
+
30
+ export interface ClientOptions {
31
+ readonly bearerToken?: string
32
+ /** Per-call timeout in ms. Default 60000. */
33
+ readonly timeoutMs?: number
34
+ readonly fetchImpl?: typeof fetch
35
+ }
36
+
37
+ /** A connected remote A2A agent (one AgentCard endpoint). */
38
+ export class A2AClient {
39
+ readonly card: AgentCard
40
+ private readonly endpoint: string
41
+
42
+ private constructor(
43
+ card: AgentCard,
44
+ endpoint: string,
45
+ private readonly opts: ClientOptions,
46
+ ) {
47
+ this.card = card
48
+ this.endpoint = endpoint
49
+ }
50
+
51
+ /** Fetch and validate an AgentCard, then connect to its JSON-RPC endpoint. */
52
+ static async connect(agentCardUrl: string, opts: ClientOptions = {}): Promise<A2AClient> {
53
+ const timeoutMs = opts.timeoutMs ?? 60_000
54
+ const fetchImpl = opts.fetchImpl ?? fetch
55
+ const response = await fetchImpl(agentCardUrl, {
56
+ headers: {
57
+ accept: 'application/json',
58
+ ...(opts.bearerToken ? { authorization: `Bearer ${opts.bearerToken}` } : {}),
59
+ },
60
+ signal: AbortSignal.timeout(timeoutMs),
61
+ })
62
+ if (!response.ok) {
63
+ throw new A2AError(A2A_ERROR_CODES.AGENT_CARD_NOT_FOUND, `agent card fetch failed: HTTP ${response.status}`)
64
+ }
65
+ const card = (await response.json()) as AgentCard
66
+ if (typeof card.name !== 'string' || typeof card.description !== 'string') {
67
+ throw new A2AError(A2A_ERROR_CODES.AGENT_CARD_SIGNATURE_INVALID, `invalid agent card at ${agentCardUrl}`)
68
+ }
69
+ const iface = card.supportedInterfaces?.find((i) => (i.protocolBinding ?? 'JSONRPC') === 'JSONRPC')
70
+ ?? card.supportedInterfaces?.[0]
71
+ const endpoint = iface?.url ?? card.url
72
+ if (!endpoint) {
73
+ throw new A2AError(A2A_ERROR_CODES.AGENT_CARD_NOT_FOUND, `agent card at ${agentCardUrl} advertises no JSON-RPC interface`)
74
+ }
75
+ return new A2AClient(card, endpoint, opts)
76
+ }
77
+
78
+ /** Send a message and wait (or poll) until the task settles. */
79
+ async sendMessage(message: Message): Promise<Task> {
80
+ const task = await this.call<Task>(A2A_METHODS.sendMessage, { message })
81
+ if (isTerminal(task.status.state)) return task
82
+ return this.pollTask(task.id)
83
+ }
84
+
85
+ async getTask(taskId: string): Promise<Task> {
86
+ return this.call<Task>(A2A_METHODS.getTask, { id: taskId })
87
+ }
88
+
89
+ async listTasks(): Promise<Task[]> {
90
+ return this.call<Task[]>(A2A_METHODS.listTasks, {})
91
+ }
92
+
93
+ async cancelTask(taskId: string): Promise<Task> {
94
+ return this.call<Task>(A2A_METHODS.cancelTask, { id: taskId })
95
+ }
96
+
97
+ /** Poll a task until terminal or the overall timeout elapses. */
98
+ private async pollTask(taskId: string): Promise<Task> {
99
+ const budget = this.opts.timeoutMs ?? 60_000
100
+ const deadline = Date.now() + budget
101
+ for (;;) {
102
+ const task = await this.getTask(taskId)
103
+ if (isTerminal(task.status.state)) return task
104
+ if (Date.now() >= deadline) {
105
+ throw new A2AError(-32000, `task ${taskId} did not settle within ${budget}ms`)
106
+ }
107
+ await new Promise((resolve) => setTimeout(resolve, 250))
108
+ }
109
+ }
110
+
111
+ private async call<T>(method: string, params: unknown): Promise<T> {
112
+ const timeoutMs = this.opts.timeoutMs ?? 60_000
113
+ const fetchImpl = this.opts.fetchImpl ?? fetch
114
+ let response: Response
115
+ try {
116
+ response = await fetchImpl(this.endpoint, {
117
+ method: 'POST',
118
+ headers: {
119
+ 'content-type': 'application/json',
120
+ accept: 'application/json',
121
+ ...(this.opts.bearerToken ? { authorization: `Bearer ${this.opts.bearerToken}` } : {}),
122
+ },
123
+ body: JSON.stringify({ jsonrpc: '2.0', id: crypto.randomUUID(), method, params }),
124
+ signal: AbortSignal.timeout(timeoutMs),
125
+ })
126
+ } catch (err) {
127
+ if (err instanceof Error && err.name === 'TimeoutError') {
128
+ throw new A2AError(-32000, `A2A call ${method} timed out after ${timeoutMs}ms`)
129
+ }
130
+ throw err
131
+ }
132
+ if (response.status === 401) {
133
+ throw new A2AError(A2A_ERROR_CODES.UNAUTHORIZED, 'remote agent rejected credentials (401)')
134
+ }
135
+ if (!response.ok) {
136
+ throw new A2AError(A2A_ERROR_CODES.INTERNAL_ERROR, `A2A call ${method} failed: HTTP ${response.status}`)
137
+ }
138
+ const payload = (await response.json()) as JsonRpcResponse
139
+ if ('error' in payload) {
140
+ const error = payload.error
141
+ throw new A2AError(error.code, error.message)
142
+ }
143
+ return payload.result as T
144
+ }
145
+ }