@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 +49 -3
- package/client.js +5 -0
- package/lib/bridge.js +137 -43
- package/lib/config.js +9 -0
- package/lib/context-bindings.js +54 -0
- package/lib/executor.js +1 -1
- package/lib/panel.js +3 -2
- package/package.json +3 -2
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
|
|
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
|
-
-
|
|
281
|
+
- **同一租户、认证主体和 `contextId` 对应一个持久化 Harness Session**。同一 context 下的多个任务串行执行,保留对话历史;不同 context 并行执行。
|
|
274
282
|
- 创建 Agent 时会挂载 `agent.preset` 指定的 Agent 预设(默认取部署的默认预设),因此 A2A Agent 拥有与 GUI 会话一致的工具与提示词组合,再叠加下面的沙箱与审批策略。
|
|
275
|
-
- 空闲 context 在 `agent.contextIdleTtlMs`(默认 15
|
|
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
|
|
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,50 +284,71 @@ 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 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
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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
|
|
294
|
-
|
|
295
|
-
|
|
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 !==
|
|
320
|
+
if (presets !== undefined && presetId !== null) await presets.mount(agentCtx, presetId)
|
|
303
321
|
},
|
|
304
322
|
})
|
|
305
323
|
} catch (error) {
|
|
306
|
-
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)}`)
|
|
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
|
-
|
|
340
|
-
if (this.#
|
|
374
|
+
dispose() {
|
|
375
|
+
if (this.#disposal) return this.#disposal
|
|
341
376
|
this.#disposed = true
|
|
342
|
-
|
|
343
|
-
this.#
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
this.#
|
|
349
|
-
|
|
350
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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"
|