dsh-a2a 项目深度拆解
面向后端 / 插件工程师的教学文档。目标:读完能理解
@hanphone/dsh-a2a每个设计决策背后的“为什么”,以及一套运行在别人宿主里的插件如何做到可验证、可升级。项目定位:
DeepSeek Harness 的 A2A v1.0.1 双端插件,当前版本0.4.0,核对 harness0.2.1-rc.2。
目录
- 这个项目解决什么问题
- 背景:A2A 与 DeepSeek Harness
- 插件模型:一条 Cordis 行
- 双端多实例:核心抽象
- 入站任务:从 SendMessage 到 artifact
- 出站:把远端技能变成模型工具
- 会话层与存储:什么能跨重启
- 安全模型:回环、凭据引用、限流
- 测试与兼容性:0.3.6 的教训
- 设计取舍与已知限制
- 演进时间线
- 如果从零实现:路线
1. 这个项目解决什么问题
DeepSeek Harness(DSH)本身是一个可组合的 agent 宿主:profile 由一组 Cordis 插件拼成,agent 的能力由 preset(指令、工具、技能、模型默认值的组合)决定。
问题是:DSH 的 agent 如何与外部 agent 世界互通?
A2A(Agent2Agent)协议给出了标准答案:一个 agent 发布 AgentCard(身份、端点、技能),客户端读卡片、发消息、拿任务结果。但把它接进 DSH 会遇到几件麻烦事:
| 需求 | 朴素做法的问题 |
|---|---|
| 同时对外发布多个“人格” | 一个 profile 只有一个 /a2a 端点,改一个就影响全部 |
| 每个入站 agent 用不同 preset | 端点与 preset 没有绑定关系,任务不知道该用哪套能力 |
| 技能要出现在 AgentCard 里 | 手写技能列表会与 preset 实际能力漂移 |
| 管理多个连接与实例 | 改 cordis.patch.yml 要重启,非技术用户不可用 |
| 出站远端技能给模型用 | 每个远端技能要映射成工具,还要处理流式、超时、取消 |
| 不把 token 写进配置/卡片 | 明文落盘是常见事故 |
dsh-a2a 的回答是:把“server 实例”做成核心抽象。每个实例 = 端点 + AgentCard + preset 绑定 + 鉴权 +(出站时)远端引用;实例由 GUI 管理、持久化在 a2a 存储域,而不是插件配置。
2. 背景:A2A 与 DeepSeek Harness
2.1 A2A 的两个角色
A2A 是“agent 调 agent”的协议,不是模型 API:
- server:发布 AgentCard,暴露 JSON-RPC 端点,接收
SendMessage,维护任务状态; - client:读取 AgentCard,发消息,等待任务落定,或订阅 SSE 流。
本插件实现 A2A 1.0.1 的 JSON-RPC 绑定(声明的协议版本是 1.0),不实现 gRPC 或 HTTP+JSON 绑定。方法名是 PascalCase:SendMessage、SendStreamingMessage、GetTask、ListTasks、CancelTask、SubscribeToTask、GetExtendedAgentCard。
2.2 DeepSeek Harness 的插件模型
DSH 用 Cordis 做依赖注入与插件组合:
- 一个 profile 是一组插件行,每行有
id和config; - 插件通过
apply(ctx)挂载,从ctx拿宿主服务(webServer、storageDomain、agents、agentPresets、skills、credentials…); - 插件可以注册路由、命令、设置面板(
settings.section)和存储域。
dsh-a2a 就是其中一行,id 为 a2a。它不是 harness 仓库的 workspace 成员:自带 package.json 与依赖图,@deepseek-ai/* 是 peer 依赖,zod 是唯一自有运行时依赖。类型检查通过 TypeScript project references 读兄弟目录 ../deepseek-harness 的源码图。
这带来一个贯穿全项目的约束:插件必须对宿主版本的变化保持敏感。第 9 节会讲它为此付出了什么代价。
3. 插件模型:一条 Cordis 行
┌─ GUI(浏览器, settings.section "A2A 连接") ──────────────────────────┐
│ 入站 server 实例(创建 / preset / 技能 / 鉴权 / 启停 / 编辑) │
│ 出站 server 实例(创建 / URL / preset / 鉴权 / 超时 / 启停) │
│ 任务 · 入站对端 · 会话 │
└──────────────────────────────────┬───────────────────────────────────┘
│ loopback-only /a2a/api
┌─ Host 半区 ──────────────────────▼───────────────────────────────────┐
│ InboundServerManager 每实例:preset 化会话池 + A2AServer + 路由 │
│ OutboundServerManager 每实例:独立 agent 存储 + 远端 A2AClient + 工具│
│ A2aDomain(tasks / contexts / agents / identity / │
│ inbound_servers / outbound_servers)· ctx.a2a facade │
└───────────────────────────────────────────────────────────────────────┘
设计上有三个刻意的分离:
- 控制面与协议面分离:
/a2a/api只回环,远端客户端能调/a2a/<id>,但永远驱动不了面板。 - 实例数据与插件配置分离:实例在
a2a域里,插件Config只放宿主级默认值(baseUrl、subagentProvider、defaultTimeoutMs、限流/超时等)。这样 GUI 改实例不需要重启,也不会把用户数据写进cordis.patch.yml。 - host 半区与 browser 半区分离:
src/是宿主插件,src/client/是 React 设置面板;浏览器包通过window.__ModuleLoader__.load({ id, factory })注册。
4. 双端多实例:核心抽象
4.1 入站 server
一个入站实例拥有:
- 端点
/a2a/<id>与卡片/a2a/<id>/agent-card.json; - 名称、描述、版本;
- 一个具体的 agent preset(不是“默认”占位项);
- 从 preset 派生的技能;
- 可选 Bearer Token;
- 启用标志。
InboundServerManager 拥有活跃实例集。每个持久化记录在启动时成为一个活实例:
- preset 化
ContextSessionPool; - 基于共享域存储的
ScopedTaskStore(按serverId过滤)上的A2AServer; - 独立端点与卡片路由;
ExecutorSet(session / subagent);LiveInboundRegistry(对端监控)。
管理器负责完整生命周期:add / update / setEnabled / remove。更新会重建卡片并在保留启用状态的前提下替换活实例;删除会 dispose 并反持久化。
4.2 出站连接
一个出站实例拥有:
- 远端 AgentCard URL;
- 本地名称(成为工具名前缀);
- 导入的远端技能;
- 可选 token;
- 自己的超时;
- 可选 preset(当前是前瞻性元数据);
- 启用标志。
OutboundServerManager 为每个实例创建独立的 OutboundAgentRegistry 与按实例 AgentStore(键 out:<id>),并用 A2AClient 读卡片、注册工具。支持启停、刷新、编辑、删除。
4.3 为什么“实例”是核心抽象
把入站与出站都抽象成“实例”后,很多问题变成同一组操作:创建、持久化、组装、启停、删除。GUI、/a2a 命令、回环 API 三个控制面都调同一个 facade,所以状态不会互相矛盾。
这也让“多租户”式的隔离变得自然:入站任务记录带 serverId,ScopedTaskStore 让 GetTask / ListTasks / CancelTask / SubscribeToTask 只能看到本实例的任务;context→session 绑定也带实例命名空间,两个实例可以安全复用同一个客户端 contextId。
5. 入站任务:从 SendMessage 到 artifact
POST /a2a/<id> (SendMessage)
→ parseRpc(JSON-RPC 2.0、A2A-Version、按实例鉴权)
→ server.ensureTask(message)
contextId + skill(metadata.skill ?? 'chat');任务记录来源 serverId
→ store.create → taskId a2a-<uuid>、SUBMITTED(急切持久化)
→ gate(input)
按实例的宣告技能白名单
ctx.waterfall('a2a/inbound-task', decision) # 否决 / 审计
→ runTask(record)
WORKING → executor.execute(...) # 事件 → store + SSE
成功 → artifact 'result' → COMPLETED
失败 → FAILED(携带执行器错误)
→ 取消路径:CancelTask / facade → abort signal → CANCELED
5.1 先落库,再执行
任务在进入执行器之前就已经持久化(SUBMITTED),因此:
- 客户端断连不会丢任务;
- 面板和
ListTasks能立即看到它; - 进程重启后任务历史仍在(但执行中的任务没有崩溃恢复,见第 10 节)。
5.2 两道门禁
- 技能白名单:请求的
metadata.skill必须在 AgentCard 宣告的集合里,否则执行前拒绝; a2a/inbound-taskwaterfall:策略插件可以否决或审计每一个入站任务。
这两道门禁把“协议可达”和“业务允许”分开了:端点鉴权通过不代表这个任务应该执行。
5.3 幂等与续接
messageId是幂等键:重发同一个 id 返回原任务,不会重复执行;taskId用于继续非终态任务;终态任务会拒绝后续消息;对仍在运行的任务续接会明确返回UNSUPPORTED_OPERATION,而不是静默丢弃。
5.4 执行器
- session:每个
contextId一个 DSH 会话,按实例 preset 组装;结果 = 落定回复。当前默认技能表里所有技能都走它。 - subagent:委托
ctx.subagents,工具调用过程以 artifact 事件流式回传。实现存在,挂载了ctx.subagents时会接线,但默认技能→执行器表只把chat绑到session,今天没有公开配置能选中subagent。
6. 出站:把远端技能变成模型工具
每个已连接实例的每个远端技能,注册为一个模型工具:
a2a__<连接名>__<技能 id>
名称规范化为 [A-Za-z0-9_-],截断到 64 字符,冲突时加哈希区分。工具参数只有一个:
{ "prompt": "要发给远端 agent 的任务或指令" }
工具执行时用稳定的 per-caller contextId 调 A2AClient.sendMessage,返回落定任务的文本。工程细节:
- 远端支持流式时走流式;
- 调用方 abort 会传播下去(拿到 task id 后本地取消还会发
CancelTask); - 瞬时传输 / 5xx 失败会重试;
FAILED任务抛工具错误并带远端消息;INPUT_REQUIRED/AUTH_REQUIRED以文本返回并带远端taskId,模型可以继续。
出站的 preset 目前只是元数据:它会被持久化并显示在面板里,但工具是直接调用远端的,背后没有“本地衔接会话”的组装。这是文档里明确标注的前瞻性字段,不要当成当前生效的路由规则。
7. 会话层与存储:什么能跨重启
7.1 上下文与会话
A2A 的 contextId 是一条对话。插件为每个上下文保留一个活跃 DSH 会话,id 固定为 a2a-<contextId>:
- 复用同一个
contextId就复用该会话; - 不同的
contextId打开不同会话; - 面板可以按 contextId 查看状态、任务数、流式状态、首次/最近活动;
- 取消活跃中止该上下文所有非终态任务;关闭额外释放活跃会话句柄。
关闭不是会话墓碑:A2A 没有“已关闭上下文”的概念,同一个 contextId 的下一个任务会自动重开新句柄,永不拒绝。重开后的会话是否恢复持久化历史属于宿主行为,不在插件控制内。
7.2 持久化的边界
a2a 存储域里的表:
| 表 | 内容 |
|---|---|
tasks | 任务记录,含来源实例 id |
contexts | contextId → sessionId 绑定 |
agents | 每个出站实例的 agent 记录 |
identity | 旧版服务身份记录 |
inbound_servers | 入站实例记录 |
outbound_servers | 出站实例记录 |
默认后端是 profile 的 JSON 存储;可以把 a2a 域单独路由到 SQLite。
能跨重启的:任务、context 绑定、实例定义、出站 agent 记录。
不能跨重启的:活跃会话、流订阅、对端注册表(都在内存里)。
没有崩溃恢复的:进程死亡时处于 TASK_STATE_WORKING 的任务可能一直停在该状态。
7.3 一个 DSH_HOME 只允许一个写者
a2a 域挂在 DSH_HOME 下。两个 DSH 进程共享同一个 home 会写同一个 JSON 域,可能互相覆盖实例与任务。双实例测试必须用独立 home 与端口——hermetic e2e 就是这么做的。
8. 安全模型:回环、凭据引用、限流
8.1 面板 API 只回环
/a2a/api 只接受 127.0.0.1、::1、::ffff:127.0.0.1,其他来源直接 403。即使把 web app 放到反向代理后面,也不会让面板 API 可远程调用。
8.2 Token 是凭据引用,不是域数据
GUI 的 Bearer Token 输入框把 token 经 harness 凭据服务写入托管变量(A2A_INBOUND_<id> / A2A_OUTBOUND_<id>);实例记录只保留变量名。运行时每次操作分层读取:
凭据服务 → 进程环境
外部 export 同名变量仍然兼容。token 明文绝不进 a2a 域、插件配置或 AgentCard;GUI 不回显。
8.3 入站鉴权
配置 token 后,端点要求 Authorization: Bearer <token>,否则返回 401 与 WWW-Authenticate: Bearer。流式请求在写 200 SSE 头之前先鉴权,所以 401 仍是干净的 HTTP 状态。
没有 OAuth、mTLS 或按客户端凭据:每个实例一个共享 Bearer Token。
8.4 进程内限流
| 配置 | 默认 | 作用 |
|---|---|---|
requestsPerMinute | 120 | 每个入站来源的滑动窗口限流,超限 429 |
maxConcurrentTasks | 32 | 每个入站实例的并发上限 |
maxBodyBytes | 1048576 | 入站请求体上限 |
inboundTimeoutMs | 600000 | 每个入站任务的执行预算,超时落 FAILED |
outboundRefreshMs | 300000 | 出站 AgentCard 自动刷新间隔 |
限流是进程内、按来源的,没有分布式配额。
9. 测试与兼容性:0.3.6 的教训
这是整个项目最值得迁移的一节。
9.1 110 个测试全绿,功能却是坏的
0.3.6 发布后,每个入站任务都以 COMPLETED + (no reply) 落定。
根因:入站执行器交给 agent 的消息 source 是 { kind: 'plugin', plugin: 'a2a' }。session.append 的内存校验只要求 source.kind 是非空字符串,所以 110 个 stub 测试全绿。真正拒绝 kind: 'plugin' 的是只在真实组合里执行的 JSONL v4 编码器:
SessionFormatError: format v4 message requires a producer-owned source kind
写入在 session handle 的后台 flush 中失败,agent loop 把驱动错误吞掉,任务于是“成功”但没有回复。
0.3.7 修复为生产者自己的 { kind: 'a2a' },更重要的是补上了三层能看见它的测试:
| 层 | 文件 | 抓什么 |
|---|---|---|
| 镜像检查 | tests/types/mirrors.typecheck.ts | 结构镜像与真实 @deepseek-ai/dsh-* 类型漂移 |
| 真实持久化集成 | tests/integration/persistence.spec.ts | 持久化 session codec、agent loop 契约、inbox source |
| Profile 冒烟 | scripts/e2e-a2a.mjs | 两个真实 harness 实例:manifest/mount、路由、发现、JSON-RPC、工具注册、模型 → 工具 → 远端 |
加上原有的单元 / 组合层,形成四层验收。stub 测试可以全绿而插件对着真实宿主是坏的——这是项目反复强调的一句话。
9.2 与 harness 的四个耦合面
| 兼容面 | 载体 | 漂移的后果 |
|---|---|---|
| 依赖版本 | package.json 的 peerDependencies | peer 预检拒绝加载 |
| 构建布局 | tsconfig*.json 的 references | tsc -b 在读取源码前就失败 |
| 挂载与清单契约 | cordis.patch.yml、dsh.bundle.patch、dsh.client.inject、window.__ModuleLoader__.load | 插件不挂载或浏览器包不注册 |
| 运行时 API | ctx.a2a、ctx.slots.inject、KvTable、commands、preset/skill/subagent 数据 | 行为漂移,typecheck 不一定能抓到 |
DSH 0.1.7+ 有一个 peer 预检:只评估 @deepseek-ai/dsh 与 @deepseek-ai/dsh-*,忽略 cordis 与 schemastery。两个 dsh 范围要对每个已测 harness 版本保持真实,不要写 *。
9.3 兼容性流程
每次 harness 切 tag 或升级:
- 确定 harness 版本(根
package.json,tag 形如dsh-v<semver>); pnpm install --frozen-lockfile && pnpm run clean && pnpm run build,清掉陈旧产物与无 manifest 的残留包;- diff 四个兼容面;
- 在插件里跑
pnpm typecheck && pnpm test && pnpm build && pnpm test:e2e; - 只在代码已兼容时改元数据;要改源码就同轮加行为测试;
- 提升版本号,在提交信息里写明验证过的 harness 版本。
几个实际踩过的坑:
lib/为空却“构建成功”:陈旧tsbuildinfo被tsc -b信任,tsdown 找不到入口。清node_modules/.cache/dsh-a2a/。- 两份 zod:
^4.4.3会浮动到新的 4.x,而 harness 停在4.4.3;收紧到~4.4.3。 - 无 manifest 的陈旧包:harness 升级后残留目录会被宿主 workspace 的 glob 匹配,打包时报
MISSING_EXPORT。升级前先pnpm run clean。
10. 设计取舍与已知限制
| 取舍 | 现状 | 为什么 |
|---|---|---|
| 协议层手写 | JSON-RPC/SSE 直接实现,对齐官方规范 | 需要精确控制帧、事件 id、错误码与 oneof 形状;SDK 未必跟得上 1.0.1 |
| 实例存域里,不进配置 | GUI 增删改,重启后重组装 | 配置是部署级的,实例是用户数据;写配置要重启,也不适合非技术用户 |
| 技能从 preset 派生 | 不提供手写技能表单 | 手写列表会与 preset 实际能力漂移;“preset 决定技能”是单一事实来源 |
| Token 走凭据服务 | 域里只有变量名 | 避免明文落盘、进卡片或进日志 |
| 面板仅回环 | 反向代理也救不了 | 控制面不能暴露给远端 peer |
| 不做 push notifications | 方法面声明,返回 -32003 | 当前场景不需要;实现完整推送是另一套投递基础设施 |
| 不做 gRPC / HTTP+JSON | 只做 JSON-RPC 绑定 | 一个绑定已足够覆盖 DSH 生态 |
| 会话不持久化 | 活跃会话只在内存 | DSH 会话本身不可复活;持久化任务已足够恢复“历史” |
| 出站 preset 仅元数据 | 工具直接调远端 | 本地衔接会话是文档化扩展点,不是当前路由规则 |
| subagent 执行器不默认选中 | 实现存在,默认表只绑 chat → session | 先保证默认路径可验证,避免未测试的组合成为默认 |
| 无分布式配额 | 限流在进程内 | 单机 profile 形态;多节点配额是另一个问题 |
其它已知限制:
SendMessage默认阻塞;returnImmediately: true才立即返回。acceptedOutputModes只接受文本模式。GetTask不返回 history 数组。Last-Event-ID只支持同进程重连(帧日志在内存,每任务 200 帧)。- 进程死亡时
WORKING任务没有崩溃恢复。 - 传输只做 JSON-RPC;push、gRPC、HTTP+JSON 均未实现。
11. 演进时间线
| 版本 | 一句话 |
|---|---|
| v0.2 | 单入站 server + 单出站 client;技能从工具白名单派生 |
| v1.0 | 多实例重构:多入站/多出站、每实例绑定 preset、GUI 管理、协议对齐 v1.0.1 |
| 0.3.4 ~ 0.3.6 | 跟随 harness 0.1.7 → 0.2.1-alpha.2 → 0.2.1-rc.2;修 peer 范围、zod、构建布局 |
| 0.3.7 | 修复入站 (no reply):source.kind 从 plugin 改为 a2a;补镜像/持久化/e2e 三层 |
| 0.3.8 | CLI 修复:inbound create / outbound create 不再吃掉第一个参数 |
| 0.4.0 | 协议一致性与安全加固:oneof envelope、SSE 事件 id、append 语义、totalSize、A2A-Version、按地址限流、运行中任务续接明确拒绝 |
v1.0 的完整需求与 v0.2 对比见 设计记录(历史);现行行为以 架构总览 与 HTTP API 与协议 为准。
12. 如果从零实现:路线
- 先做协议最小闭环:一个端点、一个 AgentCard、
SendMessage+GetTask+ 任务状态机。跑通一次真实调用。 - 任务先落库再执行:
SUBMITTED→WORKING→ 终态;messageId幂等、taskId续接。 - 接宿主会话:
contextId→ 一个 DSH 会话;先只做 session 执行器。 - 再做多实例:把“端点 + 卡片 + preset + 鉴权”抽成实例,管理器负责生命周期与持久化。
- 技能自动化:从 preset 的技能目录派生,不手写;请求里的
metadata.skill做门禁。 - 做出站:卡片发现 → 技能映射工具 → 流式/取消/重试/taskId 续接。
- 做控制面:先 facade,再 GUI 与
/a2a命令;面板 API 只回环。 - 安全收口:token 走凭据服务、AgentCard 不含密钥、每个实例独立 token、限流与请求体上限。
- 补四层测试:单元/组合先跑,再加镜像检查、真实持久化、双实例 e2e。
- 建兼容性矩阵:把测过的 harness 版本与证据写下来,每次升级按流程走。
核心顺序是:先让协议闭环可验证,再让多实例与 GUI 好用,最后让升级可持续。 第三件事最容易被忽略,但它决定了插件能不能活过宿主的下一个版本。