Skip to content

Latest commit

 

History

11 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

contactor

名字取自接触器 —— 让两路电路接通的器件。这里是让两个 agent 接上话。

让本机多个 agent 进程互相发现、互相委托。

你在同一台机器上装了 Hermes、dsh、Claude Code、Codex……它们各自很能干, 但它们之间不会说话。contactor 是一个跑在 127.0.0.1 的 A2A 桥: 任何一个 agent 都能把活派给另一个,并拿回结果。

Hermes ──┐                              ┌── dsh
         ├──── contactor  127.0.0.1:8791 ┤
dsh    ──┘         A2A over JSON-RPC    └── Hermes

实测(本机 Win11 + WSL2,两个真实 agent):

$ contactor send dsh "读 packages/acp/acp/README.md,三句话总结"
[working] → [completed]                                         7.5s

$ contactor answer <taskId> "yes"        # 它要放行 rm/写盘之类的操作
[working] → [completed]                                         继续跑完

它解决什么

本机 agent 之间的通信现状,通常是这样的:

怎么通信
Hermes ↔ 你 聊天
dsh ↔ 你 终端
Hermes ↔ dsh 没办法

但问题不在"它们不会讲同一种语言"。 实测下来,三家的 ACP(Agent Client Protocol)都能跑:

dsh        pnpm dsh --profile acp
Hermes     python -m acp_adapter.entry
grok-build 也支持 ACP

真正缺的是 ACP 没设计的三件事:

发现          我怎么知道本机还有哪些 agent、它们各自能干什么?
对等委托      A 把一个任务交给 B —— 而不是"编辑器驱动 agent"这种主从关系
跨进程互找    agent 找 agent,不经过人的手

这三件事正好是 A2A 协议的 Agent Card + Task + "agent 找 agent"。 所以 contactor 不是给 N 个 agent 写 N 个适配器,而是写一个 ACP ↔ A2A 的桥。


快速开始

pip install -e ".[dev]"
cp config.example.yaml config.yaml     # 改成你本机的 agent 启动命令
contactor up                           # 已在跑就直接用,没跑就自己起一个

up 是幂等的:先查 /health,通了直接用,没通就 detach 起一个。 这是给 agent 用的 —— 起桥是纯机械步骤,不该卡在等人上。 contactor down 停掉它。

另开一个终端:

python -m contactor.cli -c config.yaml agents
python -m contactor.cli -c config.yaml send <agent名> "你的任务"
python -m contactor.cli -c config.yaml answer <taskId> "yes"   # 需要放行时

从 WSL 里反向调(WSL 里的 agent 调 Windows 侧的桥)

WSL 可以直接执行 Windows 的 exe,不用碰防火墙、不用改绑定地址:

/mnt/c/Users/<你>/.../python.exe -m contactor.cli \
  -c "C:\path\to\config.yaml" send <agent名> "你好"

WSL2 是 NAT,Windows 侧绑 127.0.0.1 时 WSL 本来访问不到; 反过来走「WSL 执行 Windows 程序」这条路,双向都通了,且不引入任何网络暴露面。


为什么这样设计

四条不显然的判断。它们不是常识,是踩出来的。

1. input-required 是主角,不是边角料

其他 A2A 实现里,input-required 通常被当成罕见分支。 但在本地场景它是最高频的分支 —— 本机 agent 天天在问"这条命令放行吗"。

所以桥把 ACP 的 session/request_permission 映射成 A2A 的 INPUT_REQUIRED, 并把它当成一等公民。

2. ACP 的权限请求是同步阻塞,A2A 的 input-required 是异步状态机

这是全项目最容易做错的一处:

ACP 侧:agent 子进程卡在那里,等一个响应,什么都干不了
A2A 侧:任务处于 INPUT_REQUIRED,桥必须【立刻释放】这个 agent 的队列,去干别的

桥的做法:收到放行请求 → 存一个 asyncio.Future + 落盘 + 立即 return (绝不能 await,一等就占住分片队列)。 委托方回话时再兑现那个 future。

代价是:桥重启后这些 future 会失效。所以停在 input-required 的任务重启后仍可查, 但回话时会拿到一个明确的错误,而不是静默吞掉。

3. messageId 幂等 —— 但这是使用约定,不是协议保证

同一个 messageId 重发 → 返回同一个 Task,不重跑
★ 委托方不传 messageId,就没有幂等

AgentCard 是自我声明,不是担保。桥只校验格式,不验证能力声明是否属实; 信任边界由接收方划定。同理,TaskState.COMPLETED 的语义是 "执行完毕"不是"结果正确" —— 所以 Task.requires_review 恒为 True。

4. 委托是图遍历,不是树 —— 必须有回环检测

A 委托 B,B 又委托 A,两个桥互相占着对方的队列 → 死锁到超时。

两道防线:

① 精准命中:visitedAgents 里已有目标 agent → 拒绝
② 深度兜底:delegationDepth >= max_delegation_depth(默认 3) → 拒绝

★ 前提是多跳委托时要把这两个字段透传下去,否则只剩第二道防线。


接入方式

桥只认 AgentBackend 这一个端口。加一种接法 = 加一个文件, domain/、ports.py、dispatcher 一个字都不用改(有测试断言这一点)。

kind 适用 状态
acp 会讲 ACP 的 agent(dsh / Hermes / grok-build) ✅
subprocess_cli 兜底:什么协议都不会的 agent ✅
http_api OpenAI 兼容 HTTP 口 未实现

subprocess_cli —— 二等公民,缺口写在名片上

hermes_cli:
  kind: subprocess_cli
  command: ["C:\\path\\to\\hermes.exe"]
  prompt_via: arg          # stdin(默认)或 arg
  prompt_flag: ["-z"]      # arg 模式下插在 prompt 前的固定参数
  timeout_s: 300
❌ 无流式      进程跑完才有输出,中途看不到进展
❌ 无权限征求  没人能被问「这条命令放行吗」。agent 若等输入 → 只会挂到 timeout
❌ 无会话复用  每次委托起新进程,上下文攒不起来
✅ 喂 prompt 进 stdin(或当参数)→ 收 stdout 当 Artifact → 非零退出码当 FAILED

关键不在它有什么,在它缺什么能被告知:

$ contactor ... agents/card --agent hermes_cli
capabilities: {"streaming": false, "inputRequired": false, "contentVerified": false}
description : 命令行兜底 agent(无流式 / 无中断 / 无会话复用)

委托方在连接前读到 inputRequired: false,就该知道这个 agent 不能用来做需要逐步放行的任务 —— 而不是踩了坑才知道。

失败分类按"能不能重试"来分:

超时            → retryable=True    瞬时故障,可以重试(要带幂等键)
非零退出码      → retryable=False   逻辑故障,重试只会得到同样的错
退出 0 但空输出 → COMPLETED + meta.warning   桥不判断内容,但必须留痕

给 agent 用(不是给人用)

这个项目是给 agent 当工具用的,所以它自带一份 SKILL.md。

没有它,桥就是个「只有人知道怎么敲」的服务 —— agent 既不知道它在, 也不知道它能派活。装上这份 skill,agent 才知道先 agents/list 发现谁在、 再看名片上的 capabilities 决定能不能把活交给它。

### Hermes Agent
cp SKILL.md ~/AppData/Local/hermes/skills/<category>/contactor/

### Claude Code
cp SKILL.md .claude/skills/contactor/

### dsh (DeepSeek Harness)
mkdir -p ~/.dsh/skills/contactor && cp SKILL.md ~/.dsh/skills/contactor/
> dsh 的 frontmatter 只认 name + description,本仓的 SKILL.md 已只带这两个

SKILL.md 分两部分:

  • 怎么调:确保桥在跑 → 发现谁在(读名片)→ 派活(带幂等键)→ 需要放行时怎么答
  • 怎么交接(★ 这部分才是核心):该传什么不该传什么的判据、为什么不写步骤、 为什么不能给全部上下文、跨进程多了哪些失败域、有效增益的算式

桥只负责把话送到。话该怎么说,是委托方的责任。


接口

JSON-RPC 2.0 over HTTP,单一端点 POST /。

方法 说明
message/send 派活。agent / text / contextId? / messageId? / delegationDepth? / visitedAgents?
message/stream 同上,SSE 流式返回事件
tasks/get 查状态 + 历史 + 产出
tasks/cancel 取消
tasks/answer ⚠️ 本项目自定义,标准 A2A 里没有
agents/list 列出本机 agent + 每张名片
agents/card 取单个名片(连接前先读,看它能不能干这活)

另有两个 HTTP 端点:

GET /.well-known/agent-card.json    A2A 规范规定的名片路径
GET /health

⚠️ 非规范键(本桥扩展,别以为是 A2A 的一部分)

capabilities 里前三个是 A2A 规范键,后两个是本桥加的:

streaming              规范
pushNotifications      规范(本桥未实现,恒 false)
stateTransitionHistory 规范
─────────────────────────────────────────
inputRequired          本桥扩展 —— 会不会中途要放行(本地场景最关键的一个)
contentVerified        本桥扩展 —— 桥是否验证过内容(恒 false)

规范外的读者忽略它们即可;本桥的调用方应该读它们 —— inputRequired: false 的 agent 不能用来做需要逐步放行的任务。

事件类型映射(本桥的 SSE 形状 ≙ A2A)

本桥 kind=status    ≙ A2A TaskStatusUpdateEvent
本桥 kind=artifact  ≙ A2A TaskArtifactUpdateEvent
                      (Artifact.append=true 表示这是增量追加,大产出可以一节一节推)
本桥 kind=message   ≙ A2A Message
每个事件带 final: true/false

⚠️ 这是本桥的封装形状,不是 A2A 规范的事件结构 —— 缺口如实列在下面。

★「它在问我」和「它干完了」必须能分开

这是本桥解决的一个具体问题,值得单独说。

ACP 的 session/request_permission 只覆盖权限放行(allow / reject), 不覆盖「选 A 还是 B」。所以 agent 干到一半停下来问你要选哪个时,协议上:

任务状态 = completed
artifact = 一段问句
调用方   = 以为干完了,把问句当成果往下传

这个失败是静默的 —— 没有报错、没有超时,只是错的。比超时难查得多。

解法:显式契约,不做文本猜测

❌ 不要写「看到 1) 2) 3) 就当成选择题」——代码注释、清单、验收项里全是 1) 2)。

✅ 桥把一段契约追加到每个出站 prompt 末尾(append_decision_contract,默认开), agent 需要拍板时按格式收尾:

[[NEEDS_DECISION]]
- <option 1>
- <option 2>
RECOMMEND: <copy one option above verbatim>
REASON: <one short line>

桥解析到之后 → 任务转 input-required,pending 字段带上结构化的:

{"kind": "decision",
 "question": "...",
 "options": ["YAML", "TOML", "JSON"],
 "recommend": "YAML",
 "reason": "..."}

kind 把两件事分开了 —— 它们的语义完全不同,不该共用一个字段:

permission  —— 桥拦下的危险操作,要你放行(ACP 原生支持)
decision    —— agent 自己拿不准,要你选(ACP 没有这个概念,靠上面的契约)

⚠️ 契约进了 prompt,就会带来【复述误判】

契约跟着 prompt 一起进去了,所以 agent 只要复述或引用它, 标记就会出现 —— 而它根本没在问任何东西。

反制不是加正则,而是把契约里已经写明的规则变成解析规则:

契约说 "END your reply with exactly this block, and nothing after it" → 块必须收尾
契约说 "RECOMMEND: <copy one option above verbatim>"                → 拒绝占位符

于是「复述契约」天然不成立(复述后面还跟着 Rules 那几行)。 判据:解析的严格程度必须能从契约本身推出来,而不是靠调参试出来的。

这个 bug 是测试自己抓出来的:假后端把收到的 prompt 原样回显, 于是所有本该 completed 的任务全跑进了 input-required。

⚠️ inputRequired 和 interruptible 是两件事,别混

含义 命令行兜底
inputRequired 会不会在回合末尾停下来等人 ✅ 会(靠契约)
interruptible 能不能在执行中途被拦下 ❌ 不能(黑盒进程)

一个 agent 可以「干完会问你」但「干到一半拦不住」 —— 命令行兜底就是这种。 混起来会给出错误的适配判断:需要「危险命令先放行」的任务不能派给它。

⚠️ 增量推送只覆盖 ACP backend

backend 中途能看到产出吗
acp(dsh / Hermes 等) ✅ 每攒够 512 字符推一段(append=true 续写)
subprocess_cli(兜底) ❌ 一次性进程,天然推不了 —— 结束前恒为 0 产出

兜底 backend 的长任务仍然是"结束前什么也看不到",这是它的形态决定的,不是漏做。 需要中途可见 → 用 ACP 那条路。

冲突与资源抢占:分片 > 锁

同一 agent 的委托走单队列(runtime/shards.py),不是靠锁:

按 agent 分片        → 同一个 agent 的任务天然串行,不会互相抢
                      (而且它在 input-required 时会释放队列,不堵后面的)
不是"先抢到锁再执行" → 免掉锁超时 / 脑裂 / 续期

判据:能不能在结构上让冲突不发生?能就分片,不能才回头用锁 / 仲裁。

「合法但胡说」怎么拦(★ 桥不拦,但你必须拦)

桥不做内容校验 —— 它只保证"执行完毕",不保证"结果正确"。 state: completed + schema 合法 ≠ 对。这类错误会一路绿灯到下游。

调用方自己要有这几道(按代价从低到高):

① 外部事实源比对   它给的 ID / 金额 / 日期,去源系统核一遍
② 内部一致性       时间、金额、ID 之间自相矛盾吗
③ dry-run         有副作用的先空跑一遍
④ 抽样人工核
⑤ verifier agent  ★ 带边界:LLM 审 LLM 是【建议性】不是【结构性】

为什么它比超时危险:超时是显式的,你必然走到错误分支; "合法但胡说"不报错 —— 失败成本从「任务没做成」变成「错误被当成果交付」。

A2A 子集缺口(如实列出)

已实现   message/send · message/stream · tasks/get · tasks/cancel
未实现   agent/authenticatedExtendedCard
         tasks/pushNotificationConfig/get|delete
         tasks/resubscribe
         Artifact 的流式增量(append 标志)

tasks/answer 是扩展,不是规范。 标准 A2A 里续接 input-required 靠再发一条 带 taskId 的 message/send;本桥简化成了独立方法。


不做什么

❌ 不做鉴权 —— 只绑 127.0.0.1,同用户回环。
   ⚠️ 不要暴露到网络。AgentCard 里留了 securitySchemes 字段,要加不用改协议。
❌ 不做公网 / 跨机
❌ 不做自治联邦 / 自由发现 / agent 市场
❌ 不做内容校验 —— 桥不判断 agent 返回的内容对不对。
   但会让这个风险在名片上可见(见上)。
❌ 不做多租户 / 配额 / 计费
❌ 不改任何 agent 的源码
❌ 不依赖官方 a2a-sdk —— 协议语义自己实现

架构

domain/       数据形状 + 状态机 + 错误两分      ← 最稳定,零外部依赖
ports.py      ★ 三个 Protocol:AgentBackend / TaskStore / EventSink
config.py     ★ 唯一允许出现 agent 名字字面量的地方
runtime/      dispatcher(编排)· shards(分片)· registry(发现)
backends/     ★ acp.py 一个类覆盖所有 ACP agent;subprocess_cli.py 兜底
stores/       sqlite_store
transport/    http_jsonrpc(默认)· stdio_jsonrpc
server.py     外壳 + 重启恢复 + 名片发布
wiring.py     ★ 全项目唯一 new 对象的地方

🔴 一条架构红线

ports.py 和 domain/ 的【代码】里不许出现 ACP|session|prompt|stdio|subprocess|进程。

由 tests/test_layering.py 机器化校验。判据: 把这个文件拿给一个不懂 coding agent 的人看,他能看懂吗?

将来加一个不用 ACP 的 agent,ports.py 一个字都不用改。

⚠️ 校验时必须剥掉 docstring 和注释再查 —— 否则"本文件不许出现 ACP/session/…"这句话本身会命中断言。规则解释自己的时候会违反规则。


常见故障

症状 原因 处置
session/new 返回 -32602 cwd must be an absolute path workspace 给了错的 OS 的路径 dsh 在 WSL → Linux 路径;Hermes 在 Windows → Windows 路径
agent 跑着跑着不动,无报错 子进程 stderr 写满 pipe buffer 桥已接 stderr 管道
日志刷屏说 stdout 被污染 agent 把日志打到了 stdout 桥会在 50 行后熔断该任务;根治要改 agent
任务永远停在 working 桥启动时会把它标 FAILED 若没标,查 _recover()
放行请求已失效 桥在 input-required 期间重启过 asyncio.Future 不能持久化,重新发起任务
agent 起来了但没反应 用了没装 acp 包的那份 Python 找有 venv 的那份

测试

pytest -q          # 33 passed

全部用 tests/fake_backend.py,不接真 agent。

★ 这一点是分水岭:在没有任何真 agent 的情况下把协议语义跑通, 说明协议层和 agent 层真的解耦了。 如果 dispatcher 里被迫 import 了 ACP 的东西,说明架构红线已经破了。


设计出处

本项目每一处设计都能在一个「A2A 与多 Agent 互操作」的课程里找到出处:

设计 出处
Agent Card + 文件注册表 连接前的名片
input-required 当一等公民 中断态,任务没死
ACP permission → A2A input-required 同步 → 异步的转换点
Task 只存元数据 编排器持有路由与生命周期标识,不持有内部执行细节
Agent Card 只校验格式不验证能力 名片是自我介绍,不是担保
委托回环双防线 agent 委托是图遍历不是树,有环必须检测
messageId 幂等 任何会被重试的写操作都要幂等键
requires_review 恒 True 桥不判断内容,但要让风险在名片上可见
兜底 backend 的缺口写进名片 能力边界要连接前可见
按 agent 名分片 能不能在结构上让冲突不发生?能就分片

术语出处:Agent2Agent (A2A) 协议由 Google 提出; Agent Client Protocol (ACP) 由 Zed Industries 提出。 本仓库是独立实现,与二者均无关联,不代表其官方实现。


许可证

MIT —— 见 LICENSE。

仅供本机同用户环境使用。本项目不提供鉴权,请勿暴露到网络。

About

Local bridge that lets AI agents on the same machine discover and delegate to each other over A2A/ACP

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages