@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/LICENSE +21 -0
- package/README.md +444 -2
- package/client.js +868 -0
- package/cordis.patch.yml +21 -0
- package/icon.svg +15 -0
- package/index.js +59 -0
- package/lib/auth.js +196 -0
- package/lib/bridge.js +518 -0
- package/lib/card.js +214 -0
- package/lib/client-discovery.js +58 -0
- package/lib/config.js +565 -0
- package/lib/executor.js +357 -0
- package/lib/files.js +52 -0
- package/lib/limits.js +133 -0
- package/lib/panel.js +357 -0
- package/lib/prompt.js +161 -0
- package/lib/server.js +456 -0
- package/lib/store.js +288 -0
- package/lib/util.js +221 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +54 -4
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
|
+
}
|