@ai_mind/dsh-a2a 1.3.0 → 1.4.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/README.md +39 -2
- package/lib/bridge.js +93 -44
- package/lib/config.js +5 -0
- package/lib/context-bindings.js +54 -0
- package/lib/executor.js +1 -1
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -278,12 +278,48 @@ A2A 创建的 Harness 会话使用 `agent.cwd` 作为工作目录,GUI 按会
|
|
|
278
278
|
|
|
279
279
|
### 会话与并发
|
|
280
280
|
|
|
281
|
-
-
|
|
281
|
+
- **同一租户、认证主体和 `contextId` 对应一个持久化 Harness Session**。同一 context 下的多个任务串行执行,保留对话历史;不同 context 并行执行。
|
|
282
282
|
- 创建 Agent 时会挂载 `agent.preset` 指定的 Agent 预设(默认取部署的默认预设),因此 A2A Agent 拥有与 GUI 会话一致的工具与提示词组合,再叠加下面的沙箱与审批策略。
|
|
283
|
-
- 空闲 context 在 `agent.contextIdleTtlMs`(默认 15
|
|
283
|
+
- 空闲 context 在 `agent.contextIdleTtlMs`(默认 15 分钟)后释放运行中的 Agent;保留绑定与历史。下次通过 `ctx.agents.resume({ resumeSessionId })` 恢复同一 Session,服务重启后也一样。绑定不随任务 TTL 或空闲 TTL 删除。
|
|
284
284
|
- 全局并发上限 `agent.maxConcurrentTasks`(默认 4);超出的任务最多等待 `agent.queueTimeoutMs`,仍无法开始时进入 `TASK_STATE_REJECTED`。
|
|
285
285
|
- 单回合上限 `agent.turnTimeoutMs`(默认 15 分钟);超时会取消该回合并返回 `TASK_STATE_FAILED`。
|
|
286
286
|
|
|
287
|
+
### 持久化部署与升级
|
|
288
|
+
|
|
289
|
+
运行时要求 Harness 的 `agents.resume` 与持久化 `sessionPersistence.flush` API。缺少服务时任务失败,不降级到易失会话。创建 Session 和完成每轮后使用公共 `flush()` 确认落盘;该 API 是服务级屏障,会刷新同一 Harness 中其他活跃 Session,因此磁盘故障可能同时影响多个任务。
|
|
290
|
+
|
|
291
|
+
```yaml
|
|
292
|
+
agent:
|
|
293
|
+
contextStorePath: /var/lib/dsh-a2a/context-bindings
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
- 绑定目录默认是 `<解析后的 agent.cwd>/.a2a-contexts`。生产应显式指定固定的绝对持久路径,独立于工作目录、发布目录和临时容器。修改 cwd 或切换挂载点时,不可遗漏原绑定目录。
|
|
297
|
+
- **绑定目录与 Harness Session 持久化目录必须一起保留/备份**。绑定记录使用原子创建、文件/目录 fsync,目录默认权限 `0700`、文件 `0600`;文件名是 scope 与 context 的哈希,不保存 token 或对话正文。需要支持硬链接和 fsync 的本地文件系统。
|
|
298
|
+
- 认证主体采用服务端 `auth.clients[].id`(或配置推导出的凭据 ID),再与 SDK 的 tenant、认证状态一起隔离。同一凭据下不同终端可恢复同一 context;不同用户不可共享同一个 client ID。轮换 token 时保留 client ID;重排 `auth.tokens` 可能改变自动生成的主体 ID,建议使用命名 clients。`auth.mode: none` 下所有匿名调用共享匿名主体,不提供用户隔离。
|
|
299
|
+
- 同一运行时内同 scope/context 串行;创建、排队和存储刷新期间不做空闲释放。不同进程/运行时竞争同一 Session 时依赖 Harness 的独占写所有权,冲突请求失败并可在原持有者释放后重试,不进行分布式排队。多副本须共享同一绑定及 Session 后端且保证后端所有权语义,不能各挂一套独立目录。
|
|
300
|
+
- 绑定已存在但损坏、Session 缺失、存储不可用、preset 无法挂载或恢复失败时,任务进入 `TASK_STATE_FAILED`,`status.message.parts[].text` 包含 `A2A_HISTORY_UNAVAILABLE`。不会自动删除绑定、替换 Session 或清空历史。首次绑定落盘后、首次 Session 创建前崩溃,也遵守此规则;保留该预留记录而不是猜测它未承载过历史。
|
|
301
|
+
- 故障恢复:先停止对应流量并备份两个目录,恢复丢失的 Session 后以原 context 重试。若能确认某预留记录从未成功创建 Session,可由维护者通过 Harness 在记录的**相同 Session ID** 下创建空 Session(使用记录中的 cwd/preset),然后重试。无法确认时不删除/改写绑定;保留记录,明确告知用户原历史不可恢复,由用户另开新对话。当前没有自动修复/重置接口。
|
|
302
|
+
- 绑定没有自动保留期。运维清理需按会话删除策略协调两份存储;单独删绑定会让服务器把再次到来的 context 视为首次使用,不能靠 Session ID 自动重建它。
|
|
303
|
+
|
|
304
|
+
**旧版本迁移边界:** 旧代码只在内存记录 context,随机 Session ID 的 metadata 只有 cwd/preset,没有 context 或认证主体。已回收或进程已退出的旧映射无法自动恢复;即使原 Session 日志还在,也不能靠内容猜测归属。若升级前能在可信运行时/日志中取得精确的 `(tenant, authenticated, client ID, contextId) → Session ID`,且已验证 Session 所有权和日志完整,维护者可离线导入 v1 绑定(格式见 `lib/context-bindings.js`,键通过其 `contextKey()` 生成),保留原 cwd/preset。没有该证据的旧对话只能从新映射开始,**不承诺自动继承旧历史**。应在升级前向用户说明,避免把新建空 Session 误称为历史恢复。
|
|
305
|
+
|
|
306
|
+
### 手机与其他 A2A 客户端契约
|
|
307
|
+
|
|
308
|
+
保持 A2A 1.0 `SendMessage`/`SendStreamingMessage` 契约:同一对话复用 contextId、每条消息使用新 messageId、仅发送本轮内容;普通任务完成后创建新 task,只有协议允许续接的非终态 task 才传旧 taskId。服务端通过 Session 事件恢复模型输入,不要求客户端回传整段历史。切换认证主体或 tenant 会进入隔离会话;客户端应持久化 contextId 及其 endpoint/身份归属。
|
|
309
|
+
|
|
310
|
+
历史恢复失败时保持原 contextId,不自动重置或以历史全文重放。建议显示失败状态中的 `A2A_HISTORY_UNAVAILABLE` 提示并让用户重试/联系维护者。这不新增请求字段;现有手机版本可继续使用。服务端 task 查询仍是有限保留的内存存储,Session 恢复不意味着旧 task 在重启后可查询。
|
|
311
|
+
|
|
312
|
+
### 本地恢复验证
|
|
313
|
+
|
|
314
|
+
```sh
|
|
315
|
+
npm test
|
|
316
|
+
DSH_NODE_MODULES="$HOME/.dsh/profiles/node_modules" npm run test:harness
|
|
317
|
+
# 从 ai-mind-mobile 仓库执行:
|
|
318
|
+
DSH_NODE_MODULES="$HOME/.dsh/profiles/node_modules" npm --prefix ../ai-mind-dsh-a2a run test:harness
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
`DSH_NODE_MODULES` 应指向待部署 Harness 的实际 node_modules,缺省会明确报错,不静默跳过。本机验证使用 Node 25.8.0、Harness `0.2.1-rc.2`。测试在临时目录构建真实 Cordis、AgentLoop、Session 和 JSONL 存储;只有模型适配器是无状态暗号回忆 oracle。它从真正传入适配器的 `request.messages` 查找随机标记,不持有上轮记忆。检查连续追问、TTL、整套 Harness 重建、真实子进程直接退出、主体/租户隔离、排队/回收竞争、重复写所有权及历史缺失。断言包含 Session ID、用户与助手历史和回忆输出;不需要模型凭据,不读取在线会话,不等同于实际云端模型效果或线上事故归因。
|
|
322
|
+
|
|
287
323
|
### 提示词与附件
|
|
288
324
|
|
|
289
325
|
- `text` 部分拼接为提示词;`data` 部分以 JSON 代码块注入;`url` 部分以引用形式注入。
|
|
@@ -415,6 +451,7 @@ agent:
|
|
|
415
451
|
queueTimeoutMs: 60000
|
|
416
452
|
turnTimeoutMs: 900000
|
|
417
453
|
contextIdleTtlMs: 900000
|
|
454
|
+
contextStorePath: null # 绝对持久目录;默认 <cwd>/.a2a-contexts;生产务必固定路径
|
|
418
455
|
|
|
419
456
|
limits:
|
|
420
457
|
maxRequestBytes: 8388608
|
package/lib/bridge.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* DeepSeek Harness agent bridge for A2A execution.
|
|
3
3
|
*
|
|
4
|
-
* One A2A `contextId` maps to one
|
|
4
|
+
* One scoped A2A `contextId` durably maps to one Harness session, so several
|
|
5
5
|
* single-turn A2A tasks that share a context keep their conversation state.
|
|
6
6
|
* Turns inside a context run serially; distinct contexts run concurrently up
|
|
7
7
|
* to the configured limit. Idle contexts are disposed after
|
|
8
|
-
* `agent.contextIdleTtlMs
|
|
8
|
+
* `agent.contextIdleTtlMs`; the next turn resumes their persisted history.
|
|
9
9
|
*
|
|
10
10
|
* The bridge deliberately touches only public Harness services (`ctx.agents`,
|
|
11
11
|
* `ctx.approval`) and the documented `Agent` handle, never package internals.
|
|
@@ -14,8 +14,10 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import { mkdir } from 'node:fs/promises'
|
|
17
|
+
import { join } from 'node:path'
|
|
17
18
|
import { newId, errorMessage } from './util.js'
|
|
18
19
|
import { withTimeout } from './limits.js'
|
|
20
|
+
import { ContextBindings, contextKey } from './context-bindings.js'
|
|
19
21
|
|
|
20
22
|
/** Marker used as the durable source of prompts injected by this plugin. */
|
|
21
23
|
const PROMPT_SOURCE = { kind: 'user' }
|
|
@@ -112,10 +114,14 @@ class ContextWorker {
|
|
|
112
114
|
#lastUsed = Date.now()
|
|
113
115
|
#creating = undefined
|
|
114
116
|
#disposed = false
|
|
117
|
+
#pending = 0
|
|
118
|
+
#disposal
|
|
119
|
+
#predecessor
|
|
115
120
|
|
|
116
|
-
constructor(runtime, contextId) {
|
|
121
|
+
constructor(runtime, contextId, predecessor) {
|
|
117
122
|
this.#runtime = runtime
|
|
118
123
|
this.#contextId = contextId
|
|
124
|
+
this.#predecessor = predecessor
|
|
119
125
|
}
|
|
120
126
|
|
|
121
127
|
get contextId() {
|
|
@@ -131,7 +137,7 @@ class ContextWorker {
|
|
|
131
137
|
}
|
|
132
138
|
|
|
133
139
|
get busy() {
|
|
134
|
-
return this.#
|
|
140
|
+
return this.#pending > 0
|
|
135
141
|
}
|
|
136
142
|
|
|
137
143
|
touch() {
|
|
@@ -151,6 +157,7 @@ class ContextWorker {
|
|
|
151
157
|
* @returns {Promise<{ canceled: boolean, error?: Error, text: string, turnEnd?: object }>}
|
|
152
158
|
*/
|
|
153
159
|
async runTurn(turn, { prompt, timeoutMs }) {
|
|
160
|
+
this.#pending += 1
|
|
154
161
|
const previous = this.#tail
|
|
155
162
|
let release
|
|
156
163
|
this.#tail = new Promise((resolveTail) => {
|
|
@@ -162,6 +169,7 @@ class ContextWorker {
|
|
|
162
169
|
if (turn.cancelRequested) return { canceled: true, text: '' }
|
|
163
170
|
return await this.#runNow(turn, prompt, timeoutMs)
|
|
164
171
|
} finally {
|
|
172
|
+
this.#pending -= 1
|
|
165
173
|
release()
|
|
166
174
|
this.touch()
|
|
167
175
|
}
|
|
@@ -170,7 +178,7 @@ class ContextWorker {
|
|
|
170
178
|
async #runNow(turn, prompt, timeoutMs) {
|
|
171
179
|
this.touch()
|
|
172
180
|
const agent = await this.#ensureAgent()
|
|
173
|
-
if (turn.cancelRequested) return { canceled: true, text: '' }
|
|
181
|
+
if (turn.cancelRequested || this.#disposed) return { canceled: true, text: '' }
|
|
174
182
|
|
|
175
183
|
this.#turn = turn
|
|
176
184
|
turn.running = true
|
|
@@ -204,6 +212,12 @@ class ContextWorker {
|
|
|
204
212
|
if (turn.cancelRequested) {
|
|
205
213
|
return { canceled: true, text: turn.finalText || turn.emitted, turnEnd: turn.turnEnd }
|
|
206
214
|
}
|
|
215
|
+
// whenIdle is a loop barrier, not a storage durability barrier.
|
|
216
|
+
try {
|
|
217
|
+
await this.#runtime.persistence().flush()
|
|
218
|
+
} catch (error) {
|
|
219
|
+
turn.error = new Error(`A2A_HISTORY_UNAVAILABLE: cannot persist conversation: ${errorMessage(error)}`)
|
|
220
|
+
}
|
|
207
221
|
const failure = turn.error ?? turnEndError(turn.turnEnd)
|
|
208
222
|
const canceledByAgent =
|
|
209
223
|
turn.turnEnd?.kind === 'aborted' && (turn.turnEnd.reason?.kind === 'user' || turn.turnEnd.reason?.kind === 'parent')
|
|
@@ -270,41 +284,54 @@ class ContextWorker {
|
|
|
270
284
|
async #createAgent() {
|
|
271
285
|
const runtime = this.#runtime
|
|
272
286
|
const config = runtime.config
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
287
|
+
await this.#predecessor
|
|
288
|
+
runtime.persistence()
|
|
289
|
+
if (typeof runtime.ctx.agents.resume !== 'function') {
|
|
290
|
+
throw new Error('A2A_HISTORY_UNAVAILABLE: Harness agents.resume is required')
|
|
276
291
|
}
|
|
277
|
-
await mkdir(cwd, { recursive: true })
|
|
278
|
-
const workspace = await runtime.ensureWorkspace(cwd)
|
|
279
|
-
const sessionId = `session-a2a-${newId()}`
|
|
280
292
|
const agentOptions = runtime.agentOptions()
|
|
293
|
+
const bindings = await runtime.bindings()
|
|
281
294
|
const presets = runtime.ctx.get?.('agentPresets')
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
295
|
+
const { binding, fresh } = await bindings.reserve(this.#contextId, async () => {
|
|
296
|
+
const cwd = await runtime.resolveCwd()
|
|
297
|
+
if (typeof cwd !== 'string' || !cwd.startsWith('/')) throw new Error('A2A agent cwd must be an absolute path')
|
|
298
|
+
await mkdir(cwd, { recursive: true })
|
|
299
|
+
let presetId
|
|
300
|
+
if (presets !== undefined) presetId = (await presets.resolve(config.agent.preset ?? undefined)).id
|
|
301
|
+
return { sessionId: `session-a2a-${newId()}`, cwd, presetId: presetId ?? null }
|
|
302
|
+
})
|
|
303
|
+
const { sessionId, cwd, presetId } = binding
|
|
304
|
+
if (presetId !== null && presets === undefined) {
|
|
305
|
+
throw new Error('A2A_HISTORY_UNAVAILABLE: original agent preset service is unavailable')
|
|
291
306
|
}
|
|
307
|
+
const workspace = await runtime.ensureWorkspace(cwd)
|
|
292
308
|
let handle
|
|
293
309
|
try {
|
|
294
|
-
handle = await runtime.ctx.agents
|
|
295
|
-
|
|
296
|
-
|
|
310
|
+
handle = await runtime.ctx.agents[fresh ? 'create' : 'resume']({
|
|
311
|
+
...(fresh
|
|
312
|
+
? { sessionId, meta: { cwd, ...(presetId === null ? {} : { agentPreset: presetId }) } }
|
|
313
|
+
: { resumeSessionId: sessionId }),
|
|
297
314
|
...(Object.keys(agentOptions).length > 0 ? { agentOptions } : {}),
|
|
298
315
|
setup: async (agentCtx, agent) => {
|
|
299
316
|
this.#installListeners(agentCtx, agent)
|
|
300
317
|
// Mounting the preset composes the same tools, prompt sections and
|
|
301
318
|
// hooks a GUI session receives; without it an A2A agent would run
|
|
302
319
|
// with only the root composition's bare tool set.
|
|
303
|
-
if (presets !== undefined && presetId !==
|
|
320
|
+
if (presets !== undefined && presetId !== null) await presets.mount(agentCtx, presetId)
|
|
304
321
|
},
|
|
305
322
|
})
|
|
306
323
|
} catch (error) {
|
|
307
|
-
throw new Error(`cannot
|
|
324
|
+
throw new Error(`A2A_HISTORY_UNAVAILABLE: cannot ${fresh ? 'create' : 'resume'} Harness session ${sessionId}: ${errorMessage(error)}`)
|
|
325
|
+
}
|
|
326
|
+
// Never acknowledge a session until it is durably materialized.
|
|
327
|
+
try {
|
|
328
|
+
if (handle.agent.id !== sessionId || handle.agent.session.id !== sessionId) {
|
|
329
|
+
throw new Error('Harness returned a different session identity')
|
|
330
|
+
}
|
|
331
|
+
await runtime.persistence().flush()
|
|
332
|
+
} catch (error) {
|
|
333
|
+
await handle.dispose().catch(() => {})
|
|
334
|
+
throw new Error(`A2A_HISTORY_UNAVAILABLE: ${errorMessage(error)}`)
|
|
308
335
|
}
|
|
309
336
|
this.#handle = handle
|
|
310
337
|
this.#agent = handle.agent
|
|
@@ -321,7 +348,7 @@ class ContextWorker {
|
|
|
321
348
|
runtime.logger.warn(`[a2a] could not apply agent policy: ${errorMessage(error)}`)
|
|
322
349
|
}
|
|
323
350
|
runtime.logger.info(
|
|
324
|
-
`[a2a] created agent session ${handle.agent.id} for context ${this.#contextId} ` +
|
|
351
|
+
`[a2a] ${fresh ? 'created' : 'resumed'} agent session ${handle.agent.id} for context ${this.#contextId} ` +
|
|
325
352
|
`(cwd=${cwd}, preset=${presetId ?? 'none'}, sandbox=${config.agent.sandboxMode}, approval=${config.agent.approvalPolicy})`,
|
|
326
353
|
)
|
|
327
354
|
return handle.agent
|
|
@@ -344,19 +371,19 @@ class ContextWorker {
|
|
|
344
371
|
}
|
|
345
372
|
}
|
|
346
373
|
|
|
347
|
-
|
|
348
|
-
if (this.#
|
|
374
|
+
dispose() {
|
|
375
|
+
if (this.#disposal) return this.#disposal
|
|
349
376
|
this.#disposed = true
|
|
350
|
-
|
|
351
|
-
this.#
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
this.#
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
377
|
+
if (this.#turn) this.cancel(this.#turn)
|
|
378
|
+
this.#disposal = (async () => {
|
|
379
|
+
// Includes queued work and in-flight factory setup, which busy used to miss.
|
|
380
|
+
await this.#tail
|
|
381
|
+
const handle = this.#handle
|
|
382
|
+
this.#handle = undefined
|
|
383
|
+
this.#agent = undefined
|
|
384
|
+
if (handle) await handle.dispose()
|
|
385
|
+
})()
|
|
386
|
+
return this.#disposal
|
|
360
387
|
}
|
|
361
388
|
}
|
|
362
389
|
|
|
@@ -370,6 +397,7 @@ export class AgentRuntime {
|
|
|
370
397
|
#workers = new Map()
|
|
371
398
|
#sweepTimer = undefined
|
|
372
399
|
#workspacePromise = undefined
|
|
400
|
+
#bindingsPromise
|
|
373
401
|
#disposed = false
|
|
374
402
|
#stats = { turns: 0, completed: 0, failed: 0, canceled: 0 }
|
|
375
403
|
|
|
@@ -397,6 +425,20 @@ export class AgentRuntime {
|
|
|
397
425
|
return this.#logger
|
|
398
426
|
}
|
|
399
427
|
|
|
428
|
+
persistence() {
|
|
429
|
+
const persistence = this.#ctx.get?.('sessionPersistence')
|
|
430
|
+
if (typeof persistence?.flush !== 'function') {
|
|
431
|
+
throw new Error('A2A_HISTORY_UNAVAILABLE: durable Harness sessionPersistence is required')
|
|
432
|
+
}
|
|
433
|
+
return persistence
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
bindings() {
|
|
437
|
+
return this.#bindingsPromise ??= (async () => new ContextBindings(
|
|
438
|
+
this.#config.agent.contextStorePath ?? join(await this.resolveCwd(), '.a2a-contexts'),
|
|
439
|
+
))()
|
|
440
|
+
}
|
|
441
|
+
|
|
400
442
|
/**
|
|
401
443
|
* Resolve the model route for a new A2A agent.
|
|
402
444
|
*
|
|
@@ -509,25 +551,29 @@ export class AgentRuntime {
|
|
|
509
551
|
if (now - worker.lastUsed < ttl) continue
|
|
510
552
|
stale.push(worker)
|
|
511
553
|
}
|
|
554
|
+
let disposed = 0
|
|
512
555
|
for (const worker of stale) {
|
|
513
|
-
|
|
556
|
+
if (worker.disposed || worker.busy || now - worker.lastUsed < ttl) continue
|
|
514
557
|
await worker.dispose()
|
|
558
|
+
disposed += 1
|
|
559
|
+
if (this.#workers.get(worker.contextId) === worker) this.#workers.delete(worker.contextId)
|
|
515
560
|
}
|
|
516
|
-
return
|
|
561
|
+
return disposed
|
|
517
562
|
}
|
|
518
563
|
|
|
519
564
|
/**
|
|
520
565
|
* Get or create the worker for one context.
|
|
521
566
|
*
|
|
522
567
|
* @param {string} contextId
|
|
568
|
+
* @param {object} [callContext] SDK-authenticated owner and tenant (never message metadata)
|
|
523
569
|
* @returns {ContextWorker}
|
|
524
570
|
*/
|
|
525
|
-
acquire(contextId) {
|
|
571
|
+
acquire(contextId, callContext) {
|
|
526
572
|
if (this.#disposed) throw new Error('A2A runtime is shutting down')
|
|
527
|
-
const key = contextId === '' || contextId === undefined ? `context-${newId()}` : contextId
|
|
573
|
+
const key = contextKey(contextId === '' || contextId === undefined ? `context-${newId()}` : contextId, callContext)
|
|
528
574
|
let worker = this.#workers.get(key)
|
|
529
575
|
if (worker === undefined || worker.disposed) {
|
|
530
|
-
worker = new ContextWorker(this, key)
|
|
576
|
+
worker = new ContextWorker(this, key, worker?.dispose())
|
|
531
577
|
this.#workers.set(key, worker)
|
|
532
578
|
}
|
|
533
579
|
worker.touch()
|
|
@@ -558,6 +604,9 @@ export class AgentRuntime {
|
|
|
558
604
|
}
|
|
559
605
|
const workers = [...this.#workers.values()]
|
|
560
606
|
this.#workers.clear()
|
|
561
|
-
await Promise.allSettled(workers.map((worker) => worker.dispose()))
|
|
607
|
+
const results = await Promise.allSettled(workers.map((worker) => worker.dispose()))
|
|
608
|
+
for (const result of results) {
|
|
609
|
+
if (result.status === 'rejected') this.#logger.warn(`[a2a] agent dispose failed: ${errorMessage(result.reason)}`)
|
|
610
|
+
}
|
|
562
611
|
}
|
|
563
612
|
}
|
package/lib/config.js
CHANGED
|
@@ -103,6 +103,7 @@ export const DEFAULT_CONFIG = {
|
|
|
103
103
|
queueTimeoutMs: 60000,
|
|
104
104
|
turnTimeoutMs: 900000,
|
|
105
105
|
contextIdleTtlMs: 900000,
|
|
106
|
+
contextStorePath: null,
|
|
106
107
|
},
|
|
107
108
|
limits: {
|
|
108
109
|
maxRequestBytes: 8 * 1024 * 1024,
|
|
@@ -464,6 +465,10 @@ export function normalizeConfig(raw) {
|
|
|
464
465
|
if (merged.agent.cwd !== null && !merged.agent.cwd.startsWith('/')) {
|
|
465
466
|
fail('agent.cwd', 'must be an absolute path')
|
|
466
467
|
}
|
|
468
|
+
merged.agent.contextStorePath = expectNullableString(merged.agent.contextStorePath, 'agent.contextStorePath')
|
|
469
|
+
if (merged.agent.contextStorePath !== null && !merged.agent.contextStorePath.startsWith('/')) {
|
|
470
|
+
fail('agent.contextStorePath', 'must be an absolute path')
|
|
471
|
+
}
|
|
467
472
|
merged.agent.workspaceTitle = expectNullableString(merged.agent.workspaceTitle, 'agent.workspaceTitle', {
|
|
468
473
|
allowEmpty: false,
|
|
469
474
|
})
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/** Durable, immutable context -> Harness session identities. No TTL: TTL only evicts live agents. */
|
|
2
|
+
import { createHash, randomUUID } from 'node:crypto'
|
|
3
|
+
import { mkdir, open, readFile, link, unlink } from 'node:fs/promises'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
|
|
6
|
+
export function contextKey(contextId, context) {
|
|
7
|
+
// Length-delimited encoding prevents separator collisions. Never trust message metadata.
|
|
8
|
+
return createHash('sha256').update(JSON.stringify([
|
|
9
|
+
context?.tenant ?? '', context?.user?.isAuthenticated === true,
|
|
10
|
+
context?.user?.userName ?? '', contextId,
|
|
11
|
+
])).digest('hex')
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export class ContextBindings {
|
|
15
|
+
constructor(directory) { this.directory = directory }
|
|
16
|
+
|
|
17
|
+
async reserve(key, create) {
|
|
18
|
+
const path = join(this.directory, `${key}.json`)
|
|
19
|
+
const read = async () => {
|
|
20
|
+
const binding = JSON.parse(await readFile(path, 'utf8'))
|
|
21
|
+
if (binding.version !== 1 || binding.key !== key ||
|
|
22
|
+
!/^session-a2a-[a-zA-Z0-9-]+$/.test(binding.sessionId) ||
|
|
23
|
+
typeof binding.cwd !== 'string' || !binding.cwd.startsWith('/') ||
|
|
24
|
+
!(binding.presetId === null || typeof binding.presetId === 'string')) {
|
|
25
|
+
throw new Error('invalid context binding')
|
|
26
|
+
}
|
|
27
|
+
return { binding, fresh: false }
|
|
28
|
+
}
|
|
29
|
+
try {
|
|
30
|
+
try { return await read() } catch (error) { if (error.code !== 'ENOENT') throw error }
|
|
31
|
+
await mkdir(this.directory, { recursive: true, mode: 0o700 })
|
|
32
|
+
const binding = { ...await create(), version: 1, key }
|
|
33
|
+
const temporary = join(this.directory, `.${key}-${randomUUID()}.tmp`)
|
|
34
|
+
try {
|
|
35
|
+
const file = await open(temporary, 'wx', 0o600)
|
|
36
|
+
try {
|
|
37
|
+
await file.writeFile(JSON.stringify(binding) + '\n')
|
|
38
|
+
await file.sync()
|
|
39
|
+
} finally { await file.close() }
|
|
40
|
+
// Atomic create-if-absent, including competing runtimes/processes. A loser
|
|
41
|
+
// must resume the winner's ID; Harness enforces exclusive session ownership.
|
|
42
|
+
try { await link(temporary, path) } catch (error) {
|
|
43
|
+
if (error.code === 'EEXIST') return await read()
|
|
44
|
+
throw error
|
|
45
|
+
}
|
|
46
|
+
const directory = await open(this.directory, 'r')
|
|
47
|
+
try { await directory.sync() } finally { await directory.close() }
|
|
48
|
+
return { binding, fresh: true }
|
|
49
|
+
} finally { await unlink(temporary).catch(error => { if (error.code !== 'ENOENT') throw error }) }
|
|
50
|
+
} catch (error) {
|
|
51
|
+
throw new Error(`A2A_HISTORY_UNAVAILABLE: context binding cannot be loaded or stored: ${error.message}`, { cause: error })
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
package/lib/executor.js
CHANGED
|
@@ -227,7 +227,7 @@ export class HarnessAgentExecutor {
|
|
|
227
227
|
return
|
|
228
228
|
}
|
|
229
229
|
|
|
230
|
-
const worker = this.#runtime.acquire(contextId)
|
|
230
|
+
const worker = this.#runtime.acquire(contextId, requestContext.context)
|
|
231
231
|
state.worker = worker
|
|
232
232
|
const turn = worker.createTurn(taskId, {
|
|
233
233
|
onStart: () => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ai_mind/dsh-a2a",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"description": "Production A2A (Agent2Agent) v1.0 server for DeepSeek Harness agents: JSON-RPC + HTTP+JSON bindings, streaming, tasks, push notifications and signed Agent Cards.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -48,7 +48,8 @@
|
|
|
48
48
|
"express": "^5.1.0"
|
|
49
49
|
},
|
|
50
50
|
"scripts": {
|
|
51
|
-
"test": "node --test test/*.test.js"
|
|
51
|
+
"test": "node --test test/*.test.js",
|
|
52
|
+
"test:harness": "node --test test/integration/*.test.js"
|
|
52
53
|
},
|
|
53
54
|
"engines": {
|
|
54
55
|
"node": ">=20"
|