@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.
@@ -0,0 +1,357 @@
1
+ /**
2
+ * A2A `AgentExecutor` implementation backed by the Harness agent runtime.
3
+ *
4
+ * Responsibilities:
5
+ * - translate the SDK event-bus contract into a well-formed task lifecycle
6
+ * (`SUBMITTED` → `WORKING` → terminal state, with streaming artifacts);
7
+ * - serialize turns inside one A2A context and bound total concurrency;
8
+ * - implement cancellation for both queued and running turns;
9
+ * - turn every failure into a `TASK_STATE_FAILED` status carrying a
10
+ * human-readable message instead of leaving a task stuck in `WORKING`.
11
+ *
12
+ * @module @ai_mind/dsh-a2a/executor
13
+ */
14
+
15
+ import { AgentEvent } from '@a2a-js/sdk/server'
16
+ import { Role, TaskState } from '@a2a-js/sdk'
17
+ import { errorMessage, newId, nowIso, truncate } from './util.js'
18
+ import { buildAgentPrompt } from './prompt.js'
19
+ import { Semaphore } from './limits.js'
20
+
21
+ /** Artifact id used for the assistant's textual answer. */
22
+ const RESPONSE_ARTIFACT_ID = 'response'
23
+
24
+ /** @returns {object} an A2A `Message` in SDK object form */
25
+ function agentMessage({ taskId, contextId, text }) {
26
+ return {
27
+ role: Role.ROLE_AGENT,
28
+ messageId: newId(),
29
+ taskId,
30
+ contextId,
31
+ parts: [
32
+ {
33
+ content: { $case: 'text', value: text },
34
+ metadata: undefined,
35
+ filename: '',
36
+ mediaType: 'text/plain',
37
+ },
38
+ ],
39
+ metadata: {},
40
+ extensions: [],
41
+ referenceTaskIds: [],
42
+ }
43
+ }
44
+
45
+ /** @returns {object} a text-only A2A artifact */
46
+ function textArtifact(text) {
47
+ return {
48
+ artifactId: RESPONSE_ARTIFACT_ID,
49
+ name: 'response',
50
+ description: 'Final response from the DeepSeek Harness agent.',
51
+ parts: [
52
+ {
53
+ content: { $case: 'text', value: text },
54
+ metadata: undefined,
55
+ filename: '',
56
+ mediaType: 'text/plain',
57
+ },
58
+ ],
59
+ metadata: undefined,
60
+ extensions: [],
61
+ }
62
+ }
63
+
64
+ /**
65
+ * A2A executor bridging request events to `AgentRuntime`.
66
+ */
67
+ export class HarnessAgentExecutor {
68
+ #runtime
69
+ #config
70
+ #logger
71
+ #semaphore
72
+ #active = new Map()
73
+ #taskContexts = new Map()
74
+ #maxContextEntries
75
+ #disposed = false
76
+
77
+ /**
78
+ * @param {object} options
79
+ * @param {import('./bridge.js').AgentRuntime} options.runtime
80
+ * @param {Readonly<object>} options.config
81
+ * @param {{ info: (...args: unknown[]) => void, warn: (...args: unknown[]) => void }} options.logger
82
+ */
83
+ constructor({ runtime, config, logger }) {
84
+ this.#runtime = runtime
85
+ this.#config = config
86
+ this.#logger = logger
87
+ this.#semaphore = new Semaphore(config.agent.maxConcurrentTasks)
88
+ this.#maxContextEntries = Math.max(1000, config.tasks.maxTasks)
89
+ }
90
+
91
+ /** @returns {{ active: number, queued: number, max: number }} */
92
+ concurrency() {
93
+ return this.#semaphore.stats()
94
+ }
95
+
96
+ /** Publish one status update, remembering task context ids for cancellation. */
97
+ #status(eventBus, taskId, contextId, state, messageText) {
98
+ this.#remember(taskId, contextId)
99
+ const status = {
100
+ state,
101
+ message: messageText === undefined ? undefined : agentMessage({ taskId, contextId, text: messageText }),
102
+ timestamp: nowIso(),
103
+ }
104
+ eventBus.publish(
105
+ AgentEvent.statusUpdate({
106
+ taskId,
107
+ contextId,
108
+ status,
109
+ metadata: undefined,
110
+ }),
111
+ )
112
+ }
113
+
114
+ #remember(taskId, contextId) {
115
+ if (contextId === undefined || contextId === '') return
116
+ if (this.#taskContexts.has(taskId)) {
117
+ this.#taskContexts.delete(taskId)
118
+ } else if (this.#taskContexts.size >= this.#maxContextEntries) {
119
+ const oldest = this.#taskContexts.keys().next().value
120
+ if (oldest !== undefined) this.#taskContexts.delete(oldest)
121
+ }
122
+ this.#taskContexts.set(taskId, contextId)
123
+ }
124
+
125
+ #artifact(eventBus, taskId, contextId, text, { lastChunk, replace }) {
126
+ eventBus.publish(
127
+ AgentEvent.artifactUpdate({
128
+ taskId,
129
+ contextId,
130
+ artifact: textArtifact(text),
131
+ append: !replace,
132
+ lastChunk,
133
+ metadata: undefined,
134
+ }),
135
+ )
136
+ }
137
+
138
+ /**
139
+ * Execute one A2A message turn.
140
+ *
141
+ * @param {import('@a2a-js/sdk/server').RequestContext} requestContext
142
+ * @param {import('@a2a-js/sdk/server').ExecutionEventBus} eventBus
143
+ */
144
+ async execute(requestContext, eventBus) {
145
+ const taskId = requestContext.taskId
146
+ const contextId = requestContext.contextId
147
+ const userMessage = requestContext.userMessage
148
+ const existingTask = requestContext.task
149
+
150
+ const state = {
151
+ taskId,
152
+ contextId,
153
+ terminal: false,
154
+ cancelRequested: false,
155
+ turn: undefined,
156
+ worker: undefined,
157
+ emitted: '',
158
+ settle: undefined,
159
+ }
160
+ state.settled = new Promise((resolve) => {
161
+ state.settle = resolve
162
+ })
163
+ this.#active.set(taskId, state)
164
+ this.#remember(taskId, contextId)
165
+
166
+ const claimTerminal = () => {
167
+ if (state.terminal) return false
168
+ state.terminal = true
169
+ return true
170
+ }
171
+
172
+ // Every execution must begin with a Task or Message event.
173
+ const snapshot =
174
+ existingTask ??
175
+ {
176
+ id: taskId,
177
+ contextId,
178
+ status: { state: TaskState.TASK_STATE_SUBMITTED, message: undefined, timestamp: nowIso() },
179
+ artifacts: [],
180
+ history: [userMessage],
181
+ metadata: {},
182
+ }
183
+ eventBus.publish(AgentEvent.task(snapshot))
184
+
185
+ let permit
186
+ try {
187
+ try {
188
+ permit = await this.#semaphore.acquire({ timeoutMs: this.#config.agent.queueTimeoutMs })
189
+ } catch (error) {
190
+ if (claimTerminal()) {
191
+ this.#status(
192
+ eventBus,
193
+ taskId,
194
+ contextId,
195
+ TaskState.TASK_STATE_REJECTED,
196
+ `The A2A server is at capacity (${errorMessage(error)}). Retry later.`,
197
+ )
198
+ }
199
+ this.#runtime.recordOutcome('failed')
200
+ return
201
+ }
202
+
203
+ if (state.cancelRequested) {
204
+ if (claimTerminal()) {
205
+ this.#status(eventBus, taskId, contextId, TaskState.TASK_STATE_CANCELED, 'Task canceled.')
206
+ }
207
+ this.#runtime.recordOutcome('canceled')
208
+ return
209
+ }
210
+
211
+ let prompt
212
+ try {
213
+ const cwd = await this.#runtime.resolveCwd()
214
+ const built = await buildAgentPrompt({
215
+ message: userMessage,
216
+ config: this.#config,
217
+ cwd,
218
+ logger: this.#logger,
219
+ })
220
+ prompt = built.text
221
+ if (prompt.trim() === '') throw new Error('message contains no usable content parts')
222
+ } catch (error) {
223
+ if (claimTerminal()) {
224
+ this.#status(eventBus, taskId, contextId, TaskState.TASK_STATE_FAILED, `Invalid request: ${errorMessage(error)}`)
225
+ }
226
+ this.#runtime.recordOutcome('failed')
227
+ return
228
+ }
229
+
230
+ const worker = this.#runtime.acquire(contextId)
231
+ state.worker = worker
232
+ const turn = worker.createTurn(taskId, {
233
+ onStart: () => {
234
+ if (!state.terminal && !state.cancelRequested) {
235
+ this.#status(eventBus, taskId, contextId, TaskState.TASK_STATE_WORKING)
236
+ }
237
+ },
238
+ onDelta: (text) => {
239
+ if (state.terminal || state.cancelRequested) return
240
+ state.emitted += text
241
+ this.#artifact(eventBus, taskId, contextId, text, { lastChunk: false, replace: false })
242
+ },
243
+ onReset: () => {
244
+ if (state.terminal || state.cancelRequested) return
245
+ state.emitted = ''
246
+ this.#artifact(eventBus, taskId, contextId, '', { lastChunk: false, replace: true })
247
+ },
248
+ })
249
+ state.turn = turn
250
+
251
+ if (state.cancelRequested) {
252
+ if (claimTerminal()) {
253
+ this.#status(eventBus, taskId, contextId, TaskState.TASK_STATE_CANCELED, 'Task canceled.')
254
+ }
255
+ this.#runtime.recordOutcome('canceled')
256
+ return
257
+ }
258
+
259
+ let outcome
260
+ try {
261
+ outcome = await worker.runTurn(turn, {
262
+ prompt,
263
+ timeoutMs: this.#config.agent.turnTimeoutMs,
264
+ })
265
+ } catch (error) {
266
+ if (claimTerminal()) {
267
+ this.#status(eventBus, taskId, contextId, TaskState.TASK_STATE_FAILED, errorMessage(error))
268
+ }
269
+ this.#runtime.recordOutcome('failed')
270
+ return
271
+ }
272
+
273
+ if (outcome.canceled || state.cancelRequested) {
274
+ if (claimTerminal()) {
275
+ this.#status(eventBus, taskId, contextId, TaskState.TASK_STATE_CANCELED, 'Task canceled.')
276
+ }
277
+ this.#runtime.recordOutcome('canceled')
278
+ return
279
+ }
280
+
281
+ if (outcome.error) {
282
+ if (claimTerminal()) {
283
+ this.#status(eventBus, taskId, contextId, TaskState.TASK_STATE_FAILED, truncate(outcome.error.message, 4000))
284
+ }
285
+ this.#runtime.recordOutcome('failed')
286
+ return
287
+ }
288
+
289
+ const text = truncate(outcome.text ?? '', this.#config.limits.maxArtifactChars)
290
+ if (text !== '' && !state.terminal) {
291
+ // Consolidate the streamed chunks into one authoritative artifact.
292
+ this.#artifact(eventBus, taskId, contextId, text, { lastChunk: true, replace: true })
293
+ }
294
+ if (claimTerminal()) {
295
+ this.#status(eventBus, taskId, contextId, TaskState.TASK_STATE_COMPLETED)
296
+ }
297
+ this.#runtime.recordOutcome('completed')
298
+ } catch (error) {
299
+ this.#logger.warn(`[a2a] task ${taskId} failed unexpectedly: ${errorMessage(error)}`)
300
+ if (claimTerminal()) {
301
+ this.#status(eventBus, taskId, contextId, TaskState.TASK_STATE_FAILED, errorMessage(error))
302
+ }
303
+ this.#runtime.recordOutcome('failed')
304
+ } finally {
305
+ this.#active.delete(taskId)
306
+ state.settle()
307
+ permit?.()
308
+ }
309
+ }
310
+
311
+ /**
312
+ * Request cancellation of a running or queued turn.
313
+ *
314
+ * @param {string} taskId
315
+ * @param {import('@a2a-js/sdk/server').ExecutionEventBus} eventBus
316
+ */
317
+ async cancelTask(taskId, eventBus) {
318
+ const state = this.#active.get(taskId)
319
+ if (state === undefined) {
320
+ // No executor state: the task either finished concurrently or the SDK
321
+ // is cancelling a task whose execution never started. Publish a
322
+ // terminal CANCELED status so the SDK's cancel wait always settles.
323
+ const contextId = this.#taskContexts.get(taskId) ?? ''
324
+ this.#status(eventBus, taskId, contextId, TaskState.TASK_STATE_CANCELED, 'Task canceled.')
325
+ return
326
+ }
327
+ state.cancelRequested = true
328
+ if (state.turn !== undefined && state.worker !== undefined) {
329
+ state.worker.cancel(state.turn)
330
+ }
331
+ await Promise.race([state.settled, new Promise((resolve) => setTimeout(resolve, 10000).unref?.())])
332
+ if (!state.terminal) {
333
+ state.terminal = true
334
+ this.#status(eventBus, taskId, state.contextId, TaskState.TASK_STATE_CANCELED, 'Task canceled.')
335
+ }
336
+ }
337
+
338
+ /** Cancel every in-flight turn during shutdown and release the executor. */
339
+ async dispose() {
340
+ this.#disposed = true
341
+ for (const state of this.#active.values()) {
342
+ state.cancelRequested = true
343
+ try {
344
+ state.worker?.cancel(state.turn)
345
+ } catch {
346
+ /* shutting down */
347
+ }
348
+ }
349
+ this.#active.clear()
350
+ this.#taskContexts.clear()
351
+ }
352
+
353
+ /** @returns {boolean} whether shutdown has begun */
354
+ get disposed() {
355
+ return this.#disposed
356
+ }
357
+ }
package/lib/files.js ADDED
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Workspace attachment retention.
3
+ *
4
+ * Inbound `raw` file parts are written under `<cwd>/<files.directory>/<taskId>`
5
+ * so the agent's sandboxed tools can read them. Those directories must not
6
+ * accumulate forever, so the server sweeps ones older than the configured
7
+ * retention window on the same timer as task retention.
8
+ *
9
+ * @module @ai_mind/dsh-a2a/files
10
+ */
11
+
12
+ import { readdir, rm, stat } from 'node:fs/promises'
13
+ import { join, resolve, sep } from 'node:path'
14
+ import { errorMessage } from './util.js'
15
+
16
+ /**
17
+ * Delete stale task attachment directories.
18
+ *
19
+ * @param {object} options
20
+ * @param {string} options.cwd absolute session workspace
21
+ * @param {string} options.directory relative inbox directory
22
+ * @param {number} options.retentionMs maximum age before deletion (0 disables)
23
+ * @param {{ warn: (...args: unknown[]) => void }} options.logger
24
+ * @returns {Promise<{ removed: number }>}
25
+ */
26
+ export async function sweepAttachmentDirectories({ cwd, directory, retentionMs, logger }) {
27
+ if (retentionMs <= 0) return { removed: 0 }
28
+ const root = resolve(cwd, directory)
29
+ if (root !== resolve(cwd) && !root.startsWith(`${resolve(cwd)}${sep}`)) return { removed: 0 }
30
+ let entries
31
+ try {
32
+ entries = await readdir(root, { withFileTypes: true })
33
+ } catch (error) {
34
+ if (error?.code !== 'ENOENT') logger.warn(`[a2a] attachment sweep failed: ${errorMessage(error)}`)
35
+ return { removed: 0 }
36
+ }
37
+ const now = Date.now()
38
+ let removed = 0
39
+ for (const entry of entries) {
40
+ if (!entry.isDirectory()) continue
41
+ const path = join(root, entry.name)
42
+ try {
43
+ const info = await stat(path)
44
+ if (now - info.mtimeMs < retentionMs) continue
45
+ await rm(path, { recursive: true, force: true })
46
+ removed += 1
47
+ } catch (error) {
48
+ logger.warn(`[a2a] cannot remove attachment directory ${path}: ${errorMessage(error)}`)
49
+ }
50
+ }
51
+ return { removed }
52
+ }
package/lib/limits.js ADDED
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Concurrency primitives used by the executor.
3
+ *
4
+ * @module @ai_mind/dsh-a2a/limits
5
+ */
6
+
7
+ /**
8
+ * A FIFO counting semaphore with optional acquisition timeout and abort.
9
+ *
10
+ * A waiter that times out or aborts is removed from the queue, so a late
11
+ * release can never hand a permit to an abandoned request.
12
+ */
13
+ export class Semaphore {
14
+ #max
15
+ #active = 0
16
+ #queue = []
17
+
18
+ /** @param {number} max maximum simultaneous holders */
19
+ constructor(max) {
20
+ this.#max = Math.max(1, max)
21
+ }
22
+
23
+ /** @returns {{ active: number, queued: number, max: number }} */
24
+ stats() {
25
+ return { active: this.#active, queued: this.#queue.length, max: this.#max }
26
+ }
27
+
28
+ /**
29
+ * Acquire one permit.
30
+ *
31
+ * @param {{ timeoutMs?: number, signal?: AbortSignal }} [options]
32
+ * @returns {Promise<() => void>} an idempotent release function
33
+ * @throws when the wait times out or the signal aborts
34
+ */
35
+ acquire(options = {}) {
36
+ const { timeoutMs = 0, signal } = options
37
+ if (signal?.aborted) return Promise.reject(signal.reason ?? new Error('aborted'))
38
+
39
+ if (this.#active < this.#max) {
40
+ this.#active += 1
41
+ return Promise.resolve(this.#makeRelease())
42
+ }
43
+
44
+ return new Promise((resolve, reject) => {
45
+ const waiter = { resolve, reject, timer: undefined, onAbort: undefined, done: false }
46
+
47
+ const cleanup = () => {
48
+ if (waiter.timer !== undefined) clearTimeout(waiter.timer)
49
+ if (waiter.onAbort !== undefined) signal?.removeEventListener('abort', waiter.onAbort)
50
+ }
51
+
52
+ const remove = () => {
53
+ const index = this.#queue.indexOf(waiter)
54
+ if (index !== -1) this.#queue.splice(index, 1)
55
+ }
56
+
57
+ waiter.fail = (error) => {
58
+ if (waiter.done) return
59
+ waiter.done = true
60
+ cleanup()
61
+ remove()
62
+ reject(error)
63
+ }
64
+
65
+ waiter.grant = () => {
66
+ if (waiter.done) return
67
+ waiter.done = true
68
+ cleanup()
69
+ this.#active += 1
70
+ resolve(this.#makeRelease())
71
+ }
72
+
73
+ if (timeoutMs > 0) {
74
+ waiter.timer = setTimeout(() => {
75
+ waiter.fail(new Error('timed out waiting for an execution slot'))
76
+ }, timeoutMs)
77
+ waiter.timer.unref?.()
78
+ }
79
+ if (signal !== undefined) {
80
+ waiter.onAbort = () => waiter.fail(signal.reason ?? new Error('aborted'))
81
+ signal.addEventListener('abort', waiter.onAbort, { once: true })
82
+ }
83
+
84
+ this.#queue.push(waiter)
85
+ })
86
+ }
87
+
88
+ #makeRelease() {
89
+ let released = false
90
+ return () => {
91
+ if (released) return
92
+ released = true
93
+ this.#active = Math.max(0, this.#active - 1)
94
+ const next = this.#queue.shift()
95
+ if (next !== undefined) next.grant()
96
+ }
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Race a promise against a timeout. `onTimeout` runs before the rejection so
102
+ * callers can cancel underlying work.
103
+ *
104
+ * @template T
105
+ * @param {Promise<T>} promise
106
+ * @param {number} ms
107
+ * @param {() => void} [onTimeout]
108
+ * @param {string} [message]
109
+ * @returns {Promise<T>}
110
+ */
111
+ export function withTimeout(promise, ms, onTimeout, message = 'operation timed out') {
112
+ if (ms <= 0) return promise
113
+ return new Promise((resolve, reject) => {
114
+ const timer = setTimeout(() => {
115
+ try {
116
+ onTimeout?.()
117
+ } finally {
118
+ reject(new Error(message))
119
+ }
120
+ }, ms)
121
+ timer.unref?.()
122
+ promise.then(
123
+ (value) => {
124
+ clearTimeout(timer)
125
+ resolve(value)
126
+ },
127
+ (error) => {
128
+ clearTimeout(timer)
129
+ reject(error)
130
+ },
131
+ )
132
+ })
133
+ }