@ai_mind/dsh-a2a 0.0.0-stage → 1.0.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/lib/bridge.js ADDED
@@ -0,0 +1,518 @@
1
+ /**
2
+ * DeepSeek Harness agent bridge for A2A execution.
3
+ *
4
+ * One A2A `contextId` maps to one long-lived Harness agent session, so several
5
+ * single-turn A2A tasks that share a context keep their conversation state.
6
+ * Turns inside a context run serially; distinct contexts run concurrently up
7
+ * to the configured limit. Idle contexts are disposed after
8
+ * `agent.contextIdleTtlMs` so a busy A2A endpoint cannot leak sessions.
9
+ *
10
+ * The bridge deliberately touches only public Harness services (`ctx.agents`,
11
+ * `ctx.approval`) and the documented `Agent` handle, never package internals.
12
+ *
13
+ * @module @ai_mind/dsh-a2a/bridge
14
+ */
15
+
16
+ import { mkdir } from 'node:fs/promises'
17
+ import { newId, errorMessage } from './util.js'
18
+ import { withTimeout } from './limits.js'
19
+
20
+ /** Marker used as the durable source of prompts injected by this plugin. */
21
+ const PROMPT_SOURCE = { kind: 'user' }
22
+
23
+ /**
24
+ * Build a Harness user message from a prompt string.
25
+ *
26
+ * @param {string} text
27
+ * @returns {object} a complete Harness `UserMessage`
28
+ */
29
+ function createUserMessage(text) {
30
+ return {
31
+ id: newId(),
32
+ role: 'user',
33
+ content: [{ type: 'text', text }],
34
+ source: PROMPT_SOURCE,
35
+ }
36
+ }
37
+
38
+ /** @returns {string} concatenated text of an assistant content-block array */
39
+ function assistantText(content) {
40
+ if (!Array.isArray(content)) return ''
41
+ return content
42
+ .map((block) => (block?.type === 'text' && typeof block.text === 'string' ? block.text : ''))
43
+ .join('')
44
+ }
45
+
46
+ function turnEndError(turnEnd) {
47
+ if (turnEnd === undefined) return undefined
48
+ switch (turnEnd.kind) {
49
+ case 'error':
50
+ return new Error(turnEnd.error?.message ? `Model request failed: ${turnEnd.error.message}` : 'Model request failed')
51
+ case 'blocked':
52
+ return new Error('The agent declined to process this request.')
53
+ case 'interrupted':
54
+ return new Error('The agent turn was interrupted.')
55
+ case 'aborted':
56
+ return new Error('The agent turn was aborted.')
57
+ default:
58
+ return undefined
59
+ }
60
+ }
61
+
62
+ /** One A2A turn: its live streaming callbacks and its cancellation flag. */
63
+ class Turn {
64
+ constructor(taskId, { onStart, onDelta, onReset }) {
65
+ this.taskId = taskId
66
+ this.onStart = onStart
67
+ this.onDelta = onDelta
68
+ this.onReset = onReset
69
+ this.cancelRequested = false
70
+ this.running = false
71
+ this.closed = false
72
+ this.emitted = ''
73
+ this.streamedAny = false
74
+ this.needsReset = false
75
+ this.finalText = ''
76
+ this.assistantSeen = false
77
+ this.turnEnd = undefined
78
+ this.error = undefined
79
+ }
80
+
81
+ emitDelta(text) {
82
+ if (this.cancelRequested || text === '') return
83
+ if (this.needsReset) {
84
+ this.needsReset = false
85
+ this.emitted = ''
86
+ try {
87
+ this.onReset?.()
88
+ } catch {
89
+ /* presentation only */
90
+ }
91
+ }
92
+ this.streamedAny = true
93
+ this.emitted += text
94
+ try {
95
+ this.onDelta?.(text)
96
+ } catch {
97
+ /* presentation only */
98
+ }
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Serial turn runner for one A2A context, owning exactly one Harness agent.
104
+ */
105
+ class ContextWorker {
106
+ #runtime
107
+ #contextId
108
+ #handle = undefined
109
+ #agent = undefined
110
+ #turn = undefined
111
+ #tail = Promise.resolve()
112
+ #lastUsed = Date.now()
113
+ #creating = undefined
114
+ #disposed = false
115
+
116
+ constructor(runtime, contextId) {
117
+ this.#runtime = runtime
118
+ this.#contextId = contextId
119
+ }
120
+
121
+ get contextId() {
122
+ return this.#contextId
123
+ }
124
+
125
+ get disposed() {
126
+ return this.#disposed
127
+ }
128
+
129
+ get lastUsed() {
130
+ return this.#lastUsed
131
+ }
132
+
133
+ get busy() {
134
+ return this.#turn !== undefined
135
+ }
136
+
137
+ touch() {
138
+ this.#lastUsed = Date.now()
139
+ }
140
+
141
+ /** @returns {Turn} a turn handle that may be cancelled before it starts */
142
+ createTurn(taskId, callbacks) {
143
+ return new Turn(taskId, callbacks)
144
+ }
145
+
146
+ /**
147
+ * Run one turn, waiting for any earlier turn in the same context.
148
+ *
149
+ * @param {Turn} turn
150
+ * @param {{ prompt: string, timeoutMs: number }} options
151
+ * @returns {Promise<{ canceled: boolean, error?: Error, text: string, turnEnd?: object }>}
152
+ */
153
+ async runTurn(turn, { prompt, timeoutMs }) {
154
+ const previous = this.#tail
155
+ let release
156
+ this.#tail = new Promise((resolveTail) => {
157
+ release = resolveTail
158
+ })
159
+ try {
160
+ await previous.catch(() => {})
161
+ if (this.#disposed) return { canceled: true, text: '', error: new Error('A2A context was disposed') }
162
+ if (turn.cancelRequested) return { canceled: true, text: '' }
163
+ return await this.#runNow(turn, prompt, timeoutMs)
164
+ } finally {
165
+ release()
166
+ this.touch()
167
+ }
168
+ }
169
+
170
+ async #runNow(turn, prompt, timeoutMs) {
171
+ this.touch()
172
+ const agent = await this.#ensureAgent()
173
+ if (turn.cancelRequested) return { canceled: true, text: '' }
174
+
175
+ this.#turn = turn
176
+ turn.running = true
177
+ try {
178
+ try {
179
+ turn.onStart?.()
180
+ } catch {
181
+ /* presentation only */
182
+ }
183
+ agent.followup(createUserMessage(prompt))
184
+ await withTimeout(
185
+ agent.whenIdle(),
186
+ timeoutMs,
187
+ () => {
188
+ try {
189
+ agent.cancel({ kind: 'hook', reason: 'A2A turn timeout' })
190
+ } catch {
191
+ /* already settled */
192
+ }
193
+ },
194
+ `agent turn exceeded agent.turnTimeoutMs (${timeoutMs} ms)`,
195
+ )
196
+ } catch (error) {
197
+ turn.error ??= error instanceof Error ? error : new Error(errorMessage(error))
198
+ } finally {
199
+ turn.running = false
200
+ turn.closed = true
201
+ if (this.#turn === turn) this.#turn = undefined
202
+ }
203
+
204
+ if (turn.cancelRequested) {
205
+ return { canceled: true, text: turn.finalText || turn.emitted, turnEnd: turn.turnEnd }
206
+ }
207
+ const failure = turn.error ?? turnEndError(turn.turnEnd)
208
+ const canceledByAgent =
209
+ turn.turnEnd?.kind === 'aborted' && (turn.turnEnd.reason?.kind === 'user' || turn.turnEnd.reason?.kind === 'parent')
210
+ if (canceledByAgent && failure === undefined) {
211
+ return { canceled: true, text: turn.finalText || turn.emitted, turnEnd: turn.turnEnd }
212
+ }
213
+ const text = turn.finalText !== '' ? turn.finalText : turn.emitted
214
+ return { canceled: false, text, error: failure, turnEnd: turn.turnEnd }
215
+ }
216
+
217
+ #installListeners(agentCtx, agent) {
218
+ agentCtx.on('agent/assistant-stream', (payload) => {
219
+ const turn = this.#turn
220
+ if (turn === undefined || turn.closed) return
221
+ if (payload?.agent !== undefined && payload.agent !== agent) return
222
+ const frame = payload?.frame
223
+ if (frame === undefined || frame === null) return
224
+ if (frame.type === 'start') {
225
+ if (turn.streamedAny) turn.needsReset = true
226
+ turn.streamedAny = false
227
+ return
228
+ }
229
+ if (frame.type !== 'chunk') return
230
+ const chunk = frame.chunk
231
+ if (chunk?.type !== 'text-delta') return
232
+ if (typeof chunk.text === 'string') turn.emitDelta(chunk.text)
233
+ })
234
+
235
+ agentCtx.on('session/event', (session, event) => {
236
+ const turn = this.#turn
237
+ if (turn === undefined || turn.closed) return
238
+ if (session !== agent.session) return
239
+ if (event?.type === 'assistant/message') {
240
+ const text = assistantText(event.data?.message?.content)
241
+ if (text !== '') {
242
+ turn.finalText = text
243
+ turn.assistantSeen = true
244
+ }
245
+ } else if (event?.type === 'turn/end') {
246
+ turn.turnEnd = event.data?.reason
247
+ }
248
+ })
249
+
250
+ agentCtx.on('agent/error', (payload) => {
251
+ const turn = this.#turn
252
+ if (turn === undefined || turn.closed) return
253
+ if (payload?.agent !== undefined && payload.agent !== agent) return
254
+ const failure = payload?.error
255
+ turn.error = new Error(failure?.message ? `${failure.code ?? 'AGENT_ERROR'}: ${failure.message}` : 'agent execution failed')
256
+ })
257
+ }
258
+
259
+ async #ensureAgent() {
260
+ if (this.#agent !== undefined && !this.#disposed) return this.#agent
261
+ if (this.#creating !== undefined) return this.#creating
262
+ this.#creating = this.#createAgent()
263
+ try {
264
+ return await this.#creating
265
+ } finally {
266
+ this.#creating = undefined
267
+ }
268
+ }
269
+
270
+ async #createAgent() {
271
+ const runtime = this.#runtime
272
+ const config = runtime.config
273
+ const cwd = await runtime.resolveCwd()
274
+ if (typeof cwd !== 'string' || !cwd.startsWith('/')) {
275
+ throw new Error(`A2A agent cwd must be an absolute path, got ${JSON.stringify(cwd)}`)
276
+ }
277
+ await mkdir(cwd, { recursive: true })
278
+ const sessionId = `session-a2a-${newId()}`
279
+ const agentOptions = runtime.agentOptions()
280
+ const presets = runtime.ctx.get?.('agentPresets')
281
+ let presetId
282
+ if (presets !== undefined) {
283
+ try {
284
+ presetId = (await presets.resolve(runtime.config.agent.preset ?? undefined)).id
285
+ } catch (error) {
286
+ throw new Error(
287
+ `cannot resolve the agent preset ${JSON.stringify(runtime.config.agent.preset ?? 'default')}: ${errorMessage(error)}`,
288
+ )
289
+ }
290
+ }
291
+ let handle
292
+ try {
293
+ handle = await runtime.ctx.agents.create({
294
+ sessionId,
295
+ meta: { cwd, ...(presetId === undefined ? {} : { agentPreset: presetId }) },
296
+ ...(Object.keys(agentOptions).length > 0 ? { agentOptions } : {}),
297
+ setup: async (agentCtx, agent) => {
298
+ this.#installListeners(agentCtx, agent)
299
+ // Mounting the preset composes the same tools, prompt sections and
300
+ // hooks a GUI session receives; without it an A2A agent would run
301
+ // with only the root composition's bare tool set.
302
+ if (presets !== undefined && presetId !== undefined) await presets.mount(agentCtx, presetId)
303
+ },
304
+ })
305
+ } catch (error) {
306
+ throw new Error(`cannot create a Harness agent for A2A context ${this.#contextId}: ${errorMessage(error)}`)
307
+ }
308
+ this.#handle = handle
309
+ this.#agent = handle.agent
310
+ try {
311
+ runtime.applyAgentPolicy(handle.agent)
312
+ } catch (error) {
313
+ runtime.logger.warn(`[a2a] could not apply agent policy: ${errorMessage(error)}`)
314
+ }
315
+ runtime.logger.info(
316
+ `[a2a] created agent session ${handle.agent.id} for context ${this.#contextId} ` +
317
+ `(cwd=${cwd}, preset=${presetId ?? 'none'}, sandbox=${config.agent.sandboxMode}, approval=${config.agent.approvalPolicy})`,
318
+ )
319
+ return handle.agent
320
+ }
321
+
322
+ /**
323
+ * Request cancellation of one turn. A queued turn is merely marked so it is
324
+ * skipped when its slot arrives; only the turn that is actually running may
325
+ * abort the shared agent, otherwise cancelling one task in a context would
326
+ * kill its sibling task.
327
+ */
328
+ cancel(turn) {
329
+ if (turn !== undefined) turn.cancelRequested = true
330
+ if (turn !== undefined && this.#turn === turn) {
331
+ try {
332
+ this.#agent?.cancel({ kind: 'user' })
333
+ } catch (error) {
334
+ this.#runtime.logger.warn(`[a2a] agent cancel failed: ${errorMessage(error)}`)
335
+ }
336
+ }
337
+ }
338
+
339
+ async dispose() {
340
+ if (this.#disposed) return
341
+ this.#disposed = true
342
+ const handle = this.#handle
343
+ this.#handle = undefined
344
+ this.#agent = undefined
345
+ if (handle === undefined) return
346
+ try {
347
+ await handle.dispose()
348
+ this.#runtime.logger.info(`[a2a] disposed agent session for context ${this.#contextId}`)
349
+ } catch (error) {
350
+ this.#runtime.logger.warn(`[a2a] agent dispose failed for context ${this.#contextId}: ${errorMessage(error)}`)
351
+ }
352
+ }
353
+ }
354
+
355
+ /**
356
+ * Owns A2A contexts and their Harness agents.
357
+ */
358
+ export class AgentRuntime {
359
+ #ctx
360
+ #config
361
+ #logger
362
+ #workers = new Map()
363
+ #sweepTimer = undefined
364
+ #disposed = false
365
+ #stats = { turns: 0, completed: 0, failed: 0, canceled: 0 }
366
+
367
+ /**
368
+ * @param {object} options
369
+ * @param {object} options.ctx owning Cordis context
370
+ * @param {Readonly<object>} options.config
371
+ * @param {{ info: (...args: unknown[]) => void, warn: (...args: unknown[]) => void }} options.logger
372
+ */
373
+ constructor({ ctx, config, logger }) {
374
+ this.#ctx = ctx
375
+ this.#config = config
376
+ this.#logger = logger
377
+ }
378
+
379
+ get ctx() {
380
+ return this.#ctx
381
+ }
382
+
383
+ get config() {
384
+ return this.#config
385
+ }
386
+
387
+ get logger() {
388
+ return this.#logger
389
+ }
390
+
391
+ /**
392
+ * Resolve the model route for a new A2A agent.
393
+ *
394
+ * Explicit `agent.provider`/`agent.model` win. Otherwise the deployment's
395
+ * default model selection is used, exactly like the GUI's new-session flow;
396
+ * without either, prompt assembly would later fail on `{{model}}`, so the
397
+ * caller gets an actionable error up front.
398
+ *
399
+ * @returns {object} agentOptions limited to configured values
400
+ */
401
+ agentOptions() {
402
+ const options = {}
403
+ if (this.#config.agent.provider && this.#config.agent.model) {
404
+ options.provider = this.#config.agent.provider
405
+ options.model = this.#config.agent.model
406
+ if (this.#config.agent.reasoningEffort) options.reasoningEffort = this.#config.agent.reasoningEffort
407
+ } else {
408
+ const selection = this.#ctx.get?.('agentDefaultModel')?.currentSelection?.()
409
+ if (selection?.provider && selection?.model) {
410
+ options.provider = selection.provider
411
+ options.model = selection.model
412
+ const effort = this.#config.agent.reasoningEffort ?? selection.reasoningEffort
413
+ if (effort) options.reasoningEffort = effort
414
+ } else {
415
+ throw new Error(
416
+ 'no model is configured for the A2A agent: set agent.provider and agent.model in the plugin config, ' +
417
+ 'or configure a default model for this profile',
418
+ )
419
+ }
420
+ }
421
+ if (this.#config.agent.maxTokens !== null) options.maxTokens = this.#config.agent.maxTokens
422
+ return options
423
+ }
424
+
425
+ /** @returns {Promise<string>} the absolute workspace directory for agents */
426
+ async resolveCwd() {
427
+ if (this.#config.agent.cwd) return this.#config.agent.cwd
428
+ const workingDirectory = this.#ctx.get?.('workingDirectory')
429
+ const fallback = workingDirectory?.defaultDirectory
430
+ return typeof fallback === 'string' && fallback !== '' ? fallback : process.cwd()
431
+ }
432
+
433
+ /**
434
+ * Apply the configured sandbox mode and approval policy to a created agent.
435
+ * The permission-preset listener has already pinned the deployment default,
436
+ * so this append/override must happen after `agents.create()` resolves.
437
+ */
438
+ applyAgentPolicy(agent) {
439
+ const approval = this.#ctx.get?.('approval')
440
+ if (approval !== undefined && this.#config.agent.approvalPolicy !== 'inherit') {
441
+ approval.setPolicy(agent, this.#config.agent.approvalPolicy)
442
+ }
443
+ if (this.#config.agent.sandboxMode !== 'inherit') {
444
+ agent.session.append('sandbox/mode', { mode: this.#config.agent.sandboxMode })
445
+ }
446
+ }
447
+
448
+ start() {
449
+ if (this.#sweepTimer !== undefined) return
450
+ const interval = Math.max(15000, Math.min(60000, this.#config.agent.contextIdleTtlMs || 60000))
451
+ this.#sweepTimer = setInterval(() => {
452
+ this.sweepIdleContexts().catch((error) => this.#logger.warn(`[a2a] context sweep failed: ${errorMessage(error)}`))
453
+ }, interval)
454
+ this.#sweepTimer.unref?.()
455
+ }
456
+
457
+ /** Dispose contexts that have been idle past the configured TTL. */
458
+ async sweepIdleContexts(now = Date.now()) {
459
+ const ttl = this.#config.agent.contextIdleTtlMs
460
+ if (ttl <= 0) return 0
461
+ const stale = []
462
+ for (const worker of this.#workers.values()) {
463
+ if (worker.busy) continue
464
+ if (now - worker.lastUsed < ttl) continue
465
+ stale.push(worker)
466
+ }
467
+ for (const worker of stale) {
468
+ this.#workers.delete(worker.contextId)
469
+ await worker.dispose()
470
+ }
471
+ return stale.length
472
+ }
473
+
474
+ /**
475
+ * Get or create the worker for one context.
476
+ *
477
+ * @param {string} contextId
478
+ * @returns {ContextWorker}
479
+ */
480
+ acquire(contextId) {
481
+ if (this.#disposed) throw new Error('A2A runtime is shutting down')
482
+ const key = contextId === '' || contextId === undefined ? `context-${newId()}` : contextId
483
+ let worker = this.#workers.get(key)
484
+ if (worker === undefined || worker.disposed) {
485
+ worker = new ContextWorker(this, key)
486
+ this.#workers.set(key, worker)
487
+ }
488
+ worker.touch()
489
+ return worker
490
+ }
491
+
492
+ /** Record one finished turn for the health endpoint. */
493
+ recordOutcome(kind) {
494
+ this.#stats.turns += 1
495
+ if (kind === 'completed') this.#stats.completed += 1
496
+ else if (kind === 'canceled') this.#stats.canceled += 1
497
+ else this.#stats.failed += 1
498
+ }
499
+
500
+ /** @returns {object} live runtime statistics */
501
+ stats() {
502
+ let active = 0
503
+ for (const worker of this.#workers.values()) if (worker.busy) active += 1
504
+ return { ...this.#stats, contexts: this.#workers.size, activeContexts: active }
505
+ }
506
+
507
+ async shutdown() {
508
+ if (this.#disposed) return
509
+ this.#disposed = true
510
+ if (this.#sweepTimer !== undefined) {
511
+ clearInterval(this.#sweepTimer)
512
+ this.#sweepTimer = undefined
513
+ }
514
+ const workers = [...this.#workers.values()]
515
+ this.#workers.clear()
516
+ await Promise.allSettled(workers.map((worker) => worker.dispose()))
517
+ }
518
+ }