跳到主要内容

dsh-a2a 项目深度拆解

面向后端 / 插件工程师的教学文档。目标:读完能理解 @hanphone/dsh-a2a 每个设计决策背后的“为什么”,以及一套运行在别人宿主里的插件如何做到可验证、可升级。

项目定位:DeepSeek Harness 的 A2A v1.0.1 双端插件,当前版本 0.4.0,核对 harness 0.2.1-rc.2。


目录​

  1. 这个项目解决什么问题
  2. 背景:A2A 与 DeepSeek Harness
  3. 插件模型:一条 Cordis 行
  4. 双端多实例:核心抽象
  5. 入站任务:从 SendMessage 到 artifact
  6. 出站:把远端技能变成模型工具
  7. 会话层与存储:什么能跨重启
  8. 安全模型:回环、凭据引用、限流
  9. 测试与兼容性:0.3.6 的教训
  10. 设计取舍与已知限制
  11. 演进时间线
  12. 如果从零实现:路线

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 │
└───────────────────────────────────────────────────────────────────────┘

设计上有三个刻意的分离:

  1. 控制面与协议面分离:/a2a/api 只回环,远端客户端能调 /a2a/<id>,但永远驱动不了面板。
  2. 实例数据与插件配置分离:实例在 a2a 域里,插件 Config 只放宿主级默认值(baseUrl、subagentProvider、defaultTimeoutMs、限流/超时等)。这样 GUI 改实例不需要重启,也不会把用户数据写进 cordis.patch.yml。
  3. 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 两道门禁​

  1. 技能白名单:请求的 metadata.skill 必须在 AgentCard 宣告的集合里,否则执行前拒绝;
  2. a2a/inbound-task waterfall:策略插件可以否决或审计每一个入站任务。

这两道门禁把“协议可达”和“业务允许”分开了:端点鉴权通过不代表这个任务应该执行。

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
contextscontextId → 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 进程内限流​

配置默认作用
requestsPerMinute120每个入站来源的滑动窗口限流,超限 429
maxConcurrentTasks32每个入站实例的并发上限
maxBodyBytes1048576入站请求体上限
inboundTimeoutMs600000每个入站任务的执行预算,超时落 FAILED
outboundRefreshMs300000出站 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 的 peerDependenciespeer 预检拒绝加载
构建布局tsconfig*.json 的 referencestsc -b 在读取源码前就失败
挂载与清单契约cordis.patch.yml、dsh.bundle.patch、dsh.client.inject、window.__ModuleLoader__.load插件不挂载或浏览器包不注册
运行时 APIctx.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 或升级:

  1. 确定 harness 版本(根 package.json,tag 形如 dsh-v<semver>);
  2. pnpm install --frozen-lockfile && pnpm run clean && pnpm run build,清掉陈旧产物与无 manifest 的残留包;
  3. diff 四个兼容面;
  4. 在插件里跑 pnpm typecheck && pnpm test && pnpm build && pnpm test:e2e;
  5. 只在代码已兼容时改元数据;要改源码就同轮加行为测试;
  6. 提升版本号,在提交信息里写明验证过的 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.8CLI 修复:inbound create / outbound create 不再吃掉第一个参数
0.4.0协议一致性与安全加固:oneof envelope、SSE 事件 id、append 语义、totalSize、A2A-Version、按地址限流、运行中任务续接明确拒绝

v1.0 的完整需求与 v0.2 对比见 设计记录(历史);现行行为以 架构总览 与 HTTP API 与协议 为准。


12. 如果从零实现:路线​

  1. 先做协议最小闭环:一个端点、一个 AgentCard、SendMessage + GetTask + 任务状态机。跑通一次真实调用。
  2. 任务先落库再执行:SUBMITTED → WORKING → 终态;messageId 幂等、taskId 续接。
  3. 接宿主会话:contextId → 一个 DSH 会话;先只做 session 执行器。
  4. 再做多实例:把“端点 + 卡片 + preset + 鉴权”抽成实例,管理器负责生命周期与持久化。
  5. 技能自动化:从 preset 的技能目录派生,不手写;请求里的 metadata.skill 做门禁。
  6. 做出站:卡片发现 → 技能映射工具 → 流式/取消/重试/taskId 续接。
  7. 做控制面:先 facade,再 GUI 与 /a2a 命令;面板 API 只回环。
  8. 安全收口:token 走凭据服务、AgentCard 不含密钥、每个实例独立 token、限流与请求体上限。
  9. 补四层测试:单元/组合先跑,再加镜像检查、真实持久化、双实例 e2e。
  10. 建兼容性矩阵:把测过的 harness 版本与证据写下来,每次升级按流程走。

核心顺序是:先让协议闭环可验证,再让多实例与 GUI 好用,最后让升级可持续。 第三件事最容易被忽略,但它决定了插件能不能活过宿主的下一个版本。

加载评论中...