@ai_mind/dsh-a2a 1.2.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 CHANGED
@@ -115,7 +115,7 @@
115
115
  面板分三部分:
116
116
 
117
117
  1. **概览**:实时 Agent Card、JSON-RPC / HTTP+JSON 地址(一键复制)、流式/推送/扩展卡片能力、鉴权状态、运行状态与可复制的 `curl` / 官方 JS 客户端示例。
118
- 2. **配置表单**:直接编辑名称、描述、对外地址、挂载路径、流式/推送/扩展卡片、鉴权模式与令牌环境变量、访问令牌(写入后不再回显)、Agent 预设/模型/工作目录/沙箱/审批/并发/超时,以及请求体与任务保留限额。预设、模型提供方、模型和推理强度会从当前 Host 的 `agentPresets` / `llm` 服务动态列出选项;留空即使用部署默认,无法列出的自定义值仍可手动输入。
118
+ 2. **配置表单**:直接编辑名称、描述、对外地址、挂载路径、流式/推送/扩展卡片、鉴权模式与令牌环境变量、访问令牌(写入后不再回显)、Agent 预设/模型/工作目录/工作区名称/沙箱/审批/并发/超时,以及请求体与任务保留限额。预设、模型提供方、模型和推理强度会从当前 Host 的 `agentPresets` / `llm` 服务动态列出选项;留空即使用部署默认,无法列出的自定义值仍可手动输入。
119
119
  3. **保存**:写入 profile 的 `cordis.patch.yml`(`a2a-server` 行),校验通过后插件自动重载;修改挂载路径后刷新页面即可。
120
120
 
121
121
  配置接口 `GET/PUT <mountPath>/panel/config` 始终位于 DSH Web 的 origin(独立端口模式下也不在 A2A 端口上),使用 **Harness Web 连接凭据**(不是 A2A token)鉴权:只有能打开 Settings 的已登录浏览器可以读写,远程 A2A 客户端即使知道路径也会收到 401。令牌字段是只写字段,GET 只返回“是否已配置”。
@@ -266,16 +266,60 @@ server:
266
266
 
267
267
  ---
268
268
 
269
+ ### 工作区分组
270
+
271
+ A2A 创建的 Harness 会话使用 `agent.cwd` 作为工作目录,GUI 按会话目录把会话归入已注册的 Workspace。如果该目录还不是工作区,会话就会显示在「未分组」。
272
+
273
+ - 想让 A2A 会话归入**已有工作区**:把 `agent.cwd` 指向该工作区目录即可,插件会复用该工作区并附加新会话。
274
+ - 想为专用目录**新建分组**:设置 `agent.workspaceTitle`(面板里的「工作区名称(自动分组)」),插件会在该目录注册一个同名工作区,并把之后创建的 A2A 会话附加进去。
275
+ - 已有历史会话不会自动追溯重排;新创建的 A2A 任务会出现在分组下。
276
+
269
277
  ## Agent 运行时映射
270
278
 
271
279
  ### 会话与并发
272
280
 
273
- - **一个 `contextId` = 一个 Harness Agent 会话**。同一 context 下的多个任务串行执行,保留对话历史;不同 context 并行执行。
281
+ - **同一租户、认证主体和 `contextId` 对应一个持久化 Harness Session**。同一 context 下的多个任务串行执行,保留对话历史;不同 context 并行执行。
274
282
  - 创建 Agent 时会挂载 `agent.preset` 指定的 Agent 预设(默认取部署的默认预设),因此 A2A Agent 拥有与 GUI 会话一致的工具与提示词组合,再叠加下面的沙箱与审批策略。
275
- - 空闲 context 在 `agent.contextIdleTtlMs`(默认 15 分钟)后被回收,Agent 会话随之销毁。
283
+ - 空闲 context 在 `agent.contextIdleTtlMs`(默认 15 分钟)后释放运行中的 Agent;保留绑定与历史。下次通过 `ctx.agents.resume({ resumeSessionId })` 恢复同一 Session,服务重启后也一样。绑定不随任务 TTL 或空闲 TTL 删除。
276
284
  - 全局并发上限 `agent.maxConcurrentTasks`(默认 4);超出的任务最多等待 `agent.queueTimeoutMs`,仍无法开始时进入 `TASK_STATE_REJECTED`。
277
285
  - 单回合上限 `agent.turnTimeoutMs`(默认 15 分钟);超时会取消该回合并返回 `TASK_STATE_FAILED`。
278
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
+
279
323
  ### 提示词与附件
280
324
 
281
325
  - `text` 部分拼接为提示词;`data` 部分以 JSON 代码块注入;`url` 部分以引用形式注入。
@@ -400,12 +444,14 @@ agent:
400
444
  reasoningEffort: null
401
445
  maxTokens: null
402
446
  cwd: null # 绝对路径;留空用 workingDirectory.defaultDirectory
447
+ workspaceTitle: null # 可选;为 cwd 注册 GUI 工作区并让 A2A 会话归入该分组
403
448
  sandboxMode: workspace-write
404
449
  approvalPolicy: never
405
450
  maxConcurrentTasks: 4
406
451
  queueTimeoutMs: 60000
407
452
  turnTimeoutMs: 900000
408
453
  contextIdleTtlMs: 900000
454
+ contextStorePath: null # 绝对持久目录;默认 <cwd>/.a2a-contexts;生产务必固定路径
409
455
 
410
456
  limits:
411
457
  maxRequestBytes: 8388608
package/client.js CHANGED
@@ -108,9 +108,11 @@ window.__ModuleLoader__.load({
108
108
  fieldAgentProvider: '模型提供方',
109
109
  fieldAgentModel: '模型',
110
110
  fieldAgentReasoningEffort: '推理强度',
111
+ fieldAgentWorkspaceTitle: '工作区名称(自动分组)',
111
112
  optionDefault: '(使用默认)',
112
113
  defaultModel: '默认模型(留空时)',
113
114
  modelPairHint: '模型提供方和模型必须同时选择,或都留空使用默认。',
115
+ workspaceHint: 'A2A 会话会归入 agent.cwd 对应的工作区;若该目录还不是工作区,填写“工作区名称”即可为该目录创建分组。',
114
116
  fieldAgentSandboxMode: '沙箱模式',
115
117
  fieldAgentApprovalPolicy: '审批策略',
116
118
  fieldAgentMaxConcurrentTasks: '最大并发任务',
@@ -223,9 +225,11 @@ window.__ModuleLoader__.load({
223
225
  fieldAgentProvider: 'Model provider',
224
226
  fieldAgentModel: 'Model',
225
227
  fieldAgentReasoningEffort: 'Reasoning effort',
228
+ fieldAgentWorkspaceTitle: 'Workspace title (auto-group)',
226
229
  optionDefault: '(use default)',
227
230
  defaultModel: 'Default model (when empty)',
228
231
  modelPairHint: 'Choose both a provider and a model, or leave both empty for the default.',
232
+ workspaceHint: 'A2A sessions join the workspace that owns agent.cwd. Set a workspace title to create that workspace when the directory is not registered yet.',
229
233
  fieldAgentSandboxMode: 'Sandbox mode',
230
234
  fieldAgentApprovalPolicy: 'Approval policy',
231
235
  fieldAgentMaxConcurrentTasks: 'Max concurrent tasks',
@@ -821,6 +825,7 @@ window.__ModuleLoader__.load({
821
825
  (optionSets.default.reasoningEffort ? ` · ${optionSets.default.reasoningEffort}` : ''),
822
826
  )
823
827
  : null,
828
+ group === 'agent' ? h('p', { className: 'a2aui-field-note' }, t('workspaceHint')) : null,
824
829
  )
825
830
  return group === 'auth' ? [node, secretBlock] : [node]
826
831
  }),
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 long-lived Harness agent session, so several
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` so a busy A2A endpoint cannot leak sessions.
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.#turn !== undefined
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,50 +284,71 @@ class ContextWorker {
270
284
  async #createAgent() {
271
285
  const runtime = this.#runtime
272
286
  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)}`)
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 sessionId = `session-a2a-${newId()}`
279
292
  const agentOptions = runtime.agentOptions()
293
+ const bindings = await runtime.bindings()
280
294
  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
- }
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')
290
306
  }
307
+ const workspace = await runtime.ensureWorkspace(cwd)
291
308
  let handle
292
309
  try {
293
- handle = await runtime.ctx.agents.create({
294
- sessionId,
295
- meta: { cwd, ...(presetId === undefined ? {} : { agentPreset: presetId }) },
310
+ handle = await runtime.ctx.agents[fresh ? 'create' : 'resume']({
311
+ ...(fresh
312
+ ? { sessionId, meta: { cwd, ...(presetId === null ? {} : { agentPreset: presetId }) } }
313
+ : { resumeSessionId: sessionId }),
296
314
  ...(Object.keys(agentOptions).length > 0 ? { agentOptions } : {}),
297
315
  setup: async (agentCtx, agent) => {
298
316
  this.#installListeners(agentCtx, agent)
299
317
  // Mounting the preset composes the same tools, prompt sections and
300
318
  // hooks a GUI session receives; without it an A2A agent would run
301
319
  // with only the root composition's bare tool set.
302
- if (presets !== undefined && presetId !== undefined) await presets.mount(agentCtx, presetId)
320
+ if (presets !== undefined && presetId !== null) await presets.mount(agentCtx, presetId)
303
321
  },
304
322
  })
305
323
  } catch (error) {
306
- throw new Error(`cannot create a Harness agent for A2A context ${this.#contextId}: ${errorMessage(error)}`)
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)}`)
307
335
  }
308
336
  this.#handle = handle
309
337
  this.#agent = handle.agent
338
+ if (workspace !== null) {
339
+ try {
340
+ await workspace.attachSession(handle.agent.id)
341
+ } catch (error) {
342
+ runtime.logger.warn(`[a2a] could not attach session ${handle.agent.id} to workspace ${workspace.path}: ${errorMessage(error)}`)
343
+ }
344
+ }
310
345
  try {
311
346
  runtime.applyAgentPolicy(handle.agent)
312
347
  } catch (error) {
313
348
  runtime.logger.warn(`[a2a] could not apply agent policy: ${errorMessage(error)}`)
314
349
  }
315
350
  runtime.logger.info(
316
- `[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} ` +
317
352
  `(cwd=${cwd}, preset=${presetId ?? 'none'}, sandbox=${config.agent.sandboxMode}, approval=${config.agent.approvalPolicy})`,
318
353
  )
319
354
  return handle.agent
@@ -336,19 +371,19 @@ class ContextWorker {
336
371
  }
337
372
  }
338
373
 
339
- async dispose() {
340
- if (this.#disposed) return
374
+ dispose() {
375
+ if (this.#disposal) return this.#disposal
341
376
  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
- }
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
352
387
  }
353
388
  }
354
389
 
@@ -361,6 +396,8 @@ export class AgentRuntime {
361
396
  #logger
362
397
  #workers = new Map()
363
398
  #sweepTimer = undefined
399
+ #workspacePromise = undefined
400
+ #bindingsPromise
364
401
  #disposed = false
365
402
  #stats = { turns: 0, completed: 0, failed: 0, canceled: 0 }
366
403
 
@@ -388,6 +425,20 @@ export class AgentRuntime {
388
425
  return this.#logger
389
426
  }
390
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
+
391
442
  /**
392
443
  * Resolve the model route for a new A2A agent.
393
444
  *
@@ -422,6 +473,42 @@ export class AgentRuntime {
422
473
  return options
423
474
  }
424
475
 
476
+ /**
477
+ * Resolve the project (GUI workspace) that owns the agent working directory.
478
+ *
479
+ * An existing workspace is reused and its sessions are attached explicitly.
480
+ * When `agent.workspaceTitle` is set, a missing workspace is created for the
481
+ * directory so the GUI can group new A2A sessions under it.
482
+ *
483
+ * @param {string} cwd absolute agent working directory
484
+ * @returns {Promise<object|null>} the Workspace entity, or null when grouping is unavailable
485
+ */
486
+ async ensureWorkspace(cwd) {
487
+ if (this.#workspacePromise === undefined) {
488
+ this.#workspacePromise = (async () => {
489
+ const registry = this.#ctx.get?.('workspaceRegistry')
490
+ if (registry === undefined) {
491
+ if (this.#config.agent.workspaceTitle) {
492
+ this.#logger.warn('[a2a] agent.workspaceTitle is set but workspaceRegistry is unavailable')
493
+ }
494
+ return null
495
+ }
496
+ try {
497
+ const existing = await registry.resolveByPath(cwd)
498
+ if (existing !== undefined) return existing
499
+ if (!this.#config.agent.workspaceTitle) return null
500
+ const created = await registry.create(cwd, this.#config.agent.workspaceTitle)
501
+ this.#logger.info(`[a2a] workspace "${created.title}" registered for ${cwd}`)
502
+ return created
503
+ } catch (error) {
504
+ this.#logger.warn(`[a2a] cannot resolve or create a workspace for ${cwd}: ${errorMessage(error)}`)
505
+ return null
506
+ }
507
+ })()
508
+ }
509
+ return this.#workspacePromise
510
+ }
511
+
425
512
  /** @returns {Promise<string>} the absolute workspace directory for agents */
426
513
  async resolveCwd() {
427
514
  if (this.#config.agent.cwd) return this.#config.agent.cwd
@@ -464,25 +551,29 @@ export class AgentRuntime {
464
551
  if (now - worker.lastUsed < ttl) continue
465
552
  stale.push(worker)
466
553
  }
554
+ let disposed = 0
467
555
  for (const worker of stale) {
468
- this.#workers.delete(worker.contextId)
556
+ if (worker.disposed || worker.busy || now - worker.lastUsed < ttl) continue
469
557
  await worker.dispose()
558
+ disposed += 1
559
+ if (this.#workers.get(worker.contextId) === worker) this.#workers.delete(worker.contextId)
470
560
  }
471
- return stale.length
561
+ return disposed
472
562
  }
473
563
 
474
564
  /**
475
565
  * Get or create the worker for one context.
476
566
  *
477
567
  * @param {string} contextId
568
+ * @param {object} [callContext] SDK-authenticated owner and tenant (never message metadata)
478
569
  * @returns {ContextWorker}
479
570
  */
480
- acquire(contextId) {
571
+ acquire(contextId, callContext) {
481
572
  if (this.#disposed) throw new Error('A2A runtime is shutting down')
482
- const key = contextId === '' || contextId === undefined ? `context-${newId()}` : contextId
573
+ const key = contextKey(contextId === '' || contextId === undefined ? `context-${newId()}` : contextId, callContext)
483
574
  let worker = this.#workers.get(key)
484
575
  if (worker === undefined || worker.disposed) {
485
- worker = new ContextWorker(this, key)
576
+ worker = new ContextWorker(this, key, worker?.dispose())
486
577
  this.#workers.set(key, worker)
487
578
  }
488
579
  worker.touch()
@@ -513,6 +604,9 @@ export class AgentRuntime {
513
604
  }
514
605
  const workers = [...this.#workers.values()]
515
606
  this.#workers.clear()
516
- 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
+ }
517
611
  }
518
612
  }
package/lib/config.js CHANGED
@@ -96,12 +96,14 @@ export const DEFAULT_CONFIG = {
96
96
  reasoningEffort: null,
97
97
  maxTokens: null,
98
98
  cwd: null,
99
+ workspaceTitle: null,
99
100
  sandboxMode: 'workspace-write',
100
101
  approvalPolicy: 'never',
101
102
  maxConcurrentTasks: 4,
102
103
  queueTimeoutMs: 60000,
103
104
  turnTimeoutMs: 900000,
104
105
  contextIdleTtlMs: 900000,
106
+ contextStorePath: null,
105
107
  },
106
108
  limits: {
107
109
  maxRequestBytes: 8 * 1024 * 1024,
@@ -463,6 +465,13 @@ export function normalizeConfig(raw) {
463
465
  if (merged.agent.cwd !== null && !merged.agent.cwd.startsWith('/')) {
464
466
  fail('agent.cwd', 'must be an absolute path')
465
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
+ }
472
+ merged.agent.workspaceTitle = expectNullableString(merged.agent.workspaceTitle, 'agent.workspaceTitle', {
473
+ allowEmpty: false,
474
+ })
466
475
  merged.agent.sandboxMode = expectEnum(merged.agent.sandboxMode, 'agent.sandboxMode', [
467
476
  'inherit',
468
477
  'read-only',
@@ -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/lib/panel.js CHANGED
@@ -49,6 +49,7 @@ const FIELDS = [
49
49
  { path: 'auth.tokenEnv', type: 'string', group: 'auth', nullable: true, placeholder: 'A2A_AUTH_TOKEN' },
50
50
  { path: 'auth.allowRemoteUnauthenticated', type: 'boolean', group: 'auth' },
51
51
  { path: 'agent.cwd', type: 'string', group: 'agent', nullable: true, placeholder: '/srv/a2a-workspace' },
52
+ { path: 'agent.workspaceTitle', type: 'string', group: 'agent', nullable: true, placeholder: 'A2A Agents' },
52
53
  { path: 'agent.preset', type: 'string', group: 'agent', nullable: true, optionsKey: 'presets' },
53
54
  { path: 'agent.provider', type: 'string', group: 'agent', nullable: true, optionsKey: 'providers' },
54
55
  { path: 'agent.model', type: 'string', group: 'agent', nullable: true, optionsKey: 'models' },
@@ -305,7 +306,7 @@ export function createPanelRouter({ ctx, config, mountPath, logger, status, heal
305
306
  res.setHeader('Cache-Control', 'no-store')
306
307
  res.json({
307
308
  ok: true,
308
- plugin: { name: PLUGIN_NAME, version: '1.2.0' },
309
+ plugin: { name: PLUGIN_NAME, version: '1.3.0' },
309
310
  mountPath,
310
311
  ...payload,
311
312
  ...(typeof status === 'function' ? { server: status() } : {}),
@@ -484,7 +485,7 @@ export function createPanelRouter({ ctx, config, mountPath, logger, status, heal
484
485
  logger.info('[a2a] configuration updated from the settings panel')
485
486
  res.json({
486
487
  ok: true,
487
- plugin: { name: PLUGIN_NAME, version: '1.2.0' },
488
+ plugin: { name: PLUGIN_NAME, version: '1.3.0' },
488
489
  mountPath,
489
490
  ...describe(live, liveEntry, []),
490
491
  ...(typeof status === 'function' ? { server: status() } : {}),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai_mind/dsh-a2a",
3
- "version": "1.2.0",
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"