新客户端二开适配指南

September 7, 2026 · View on GitHub

定位:如果你要给 Memory Proxy 接入一个当前未适配的新 AI Agent 客户端(Aider / Cursor / 某新桌面 IDE / 自研 CLI / 某未来 harness),按本指南从"抓包摸底 → 列适配范围 → 20 步代码 → e2e 通过"一路做下来。已经实证过 5 家客户端(Claude Code / CodeBuddy / Codex / Workbuddy / dsh),把每次踩过的通用坑和适配点固化下来,避免下一个客户端再全部重踩一遍。

别偷懒跳步骤——每一步都是从踩坑中抽出来的。遇到坑先查§3 常见坑速查

参考实现:dsh 是目前踩坑最完整的接入案例,代码全在本仓库内,见文末参考实现:dsh(按功能分组)一节——里面按适配范围逐项列出 dsh 是在哪一处改的,可以对着自己的客户端照抄。


阶段 0:抓包摸底(30–60 分钟,不改代码)

为什么先抓包:假设跨客户端等价 = 每次都踩坑。5 家客户端每一家的 body shape / session_id header / 元数据 wrapper / options 数量上限 / ask-user tool 名都不一样。先摸清 5 处差异再动代码。

0.1 抓 3–5 条真实请求

mitmproxy

pip3 install --user mitmproxy    # 已装则跳
mitmdump -p 8888 -s /tmp/capture.py --set stream_large_bodies=100m

/tmp/capture.py 里的 addon 只做一件事:按 user-agent grep 出目标客户端的请求,把 request/response body 各存一份 JSON。把 grep 关键字换成新客户端指纹即可。

关键:Node ≥18 客户端要加 NODE_OPTIONS='--use-env-proxy' 才认 HTTPS_PROXY(undici 默认不认)。见§3 坑 G

0.2 抓完对着 5 处差异填表

新建 <client>-recon/ 目录(放本仓库外部或本地)存 fixture + 分析,按下表逐项对照:

维度抓包看什么
body shapebody 主字段 = messages[](OpenAI/Anthropic)还是 input[](OpenAI Responses)?用户文本在 messages[i].content[j].text 还是 messages[i].content(str)还是 input[i].content[j].text
session_id header有哪些 header,哪个是 sid?有没有 body 里的备份字段?
首帧元数据 role=user数一下客户端在 messages[] 里塞几条 role=user,识别每条的稳定签名(如 <system-reminder> / runtime context / available_skills 等前缀)——只有真用户输入那条应该被 proxy 当"用户消息"处理,其它元数据条目要过滤
ask-user tool 名 + shape找客户端源码 packages/*/tool-ask-user/** 或类似,拿 tool 名 + 参数 schema(必填字段 / snake vs camel case)
options 数量上限打开客户端 UI 源码搜 options.length / maxOptions / slice;无截断 = 不需要分页

这 5 处差异决定后续代码改动。漏一个都会踩坑。

0.3 判断需不需要 aux 短路

看客户端会不会发独立类型请求(compaction / title-gen / memgen 等)。判据:

  • 有独立 endpoint 路径?(codex 有 /responses/compact,workbuddy 类似)
  • 有独立 header?(dsh 有 x-deepseek-harness-compact:1
  • 靠 body 特征?(dsh title-gen 靠 tools 缺 + thinking.disabled + max_tokens ≤ 128 + system prompt 前缀)

任一有 aux 请求的客户端,adapter classifyRequest 必须能判。CC/CB 全 main 无 aux,codex/workbuddy/dsh 都有。


阶段 1:适配范围一览(先明确要做什么)

Memory Proxy 对一个客户端至少做以下 10 件事。别一上来就 diff dsh、抄代码——先对着下表勾一勾:哪些能力必须在新客户端上跑起来,哪些可选,哪些不适用。列表勾完再进阶段 2 写代码,工作量心里有数。

#适配能力解决什么问题必需?关联模块
1路由 & 白名单让 proxy 认识 /<client>/<spaceId>/... 这条路径、把 <client> 加进各种正则白名单,否则 auth 401 / 请求走 404 fall-through✅ 必需MemoryProxy/src/server.tsMemoryProxy/src/credit-reporter.ts
2Session ID 解析从客户端 header/body 里找出稳定的"会话唯一标识",让同一次对话所有请求落到同一个 sessionKey;漏了 → 所有会话撞成同一个 key,session-init 状态错乱✅ 必需MemoryProxy/src/session/session-key.ts::resolveConversationId
3请求分类(main / aux / headless)区分"真用户对话"和"客户端后台辅助请求"(title-gen / compaction / memgen);aux 必须全部跳过 session-init / mem / injection / L0 / skill trigger,直接透传✅ 必需MemoryProxy/src/agent-adapters/<client>.ts::classifyRequestMemoryProxy/src/handler.ts 顶部
4用户文本提取从 body 里挑出"真用户输入"字符串(给 mem 命令识别 / L0 归档 / skill 抽取用),跳过元数据 role=user✅ 必需MemoryProxy/src/agent-adapters/<client>.ts::extractUserTextMemoryProxy/src/session/store.ts::tryHistoryScanMemoryProxy/src/session/codebuddy/init.ts::isFreshCBConversation
5Session Init Form首次对话弹一个 4 步表单让用户选 team / agent / task(asset_confirm → team → agent → task);载体是客户端 preset 里挂的 ask_user_question(或同类)tool_call⚠️ 客户端有交互能力 = 必需;纯 CLI headless = 可选(走坑 C 的 bypass)MemoryProxy/src/session/<client>/form.tsMemoryProxy/src/session/index.ts dispatch、MemoryProxy/src/session/codebuddy/init.ts split-stage gate
6Header 预选(跳过 form)CI/CD / 自动化 / 无法响应 form 的客户端可以直接携带 x-team-id + x-agent-id + x-task-id + x-conversation-id header 一步注册 session;已通用支持,多数客户端不用改代码➖ 可选(已通用)无需改动,由 MemoryProxy/src/session/registrar.ts 通用处理
7资产注入(Injection)每轮主对话都在 system message 里塞 <agent_skills> / <user_memory> / <session_context> / <tdai_profile_memory> 等资产块,把团队记忆送到 LLM✅ 必需用现成 MemoryProxy/src/injection/adapters/{openai,anthropic}.ts;若客户端 wire 有特殊字段(如 dsh reasoning_content)要加 metadata round-trip
8Wire 兼容 / 特殊字段透传客户端可能硬校验某些非标字段必须往返带(dsh 的 reasoning_content / DeepSeek thinking chain),injection pipeline 的 parse→serialize 不能抹掉⚠️ 客户端有特殊字段才需要MemoryProxy/src/injection/adapters/openai.tsanthropic.tsparseMessage / serializeMessageContextMessage.metadata
9Mem 命令拦截用户在对话里发 mem:help / mem:sync / mem:create-skill / mem:session-reset 时,proxy 拦截并返回短路响应(打开面板 / 刷新资产 / 触发抽取 / 重置状态),不走上游 LLM✅ 客户端有 form 能力时全部生效;headless bypass 客户端只支持部分MemoryProxy/src/handler.ts mem-command 段(通用,只要 agentSource 认得就自动生效)
10L0 归档 / Skill 抽取主对话双向消息落 tdai-recorder:write-l0;对话超阈值或用户 force-archive 时触发 skill/conversation/add 让 core 抽 skill✅ 必需MemoryProxy/src/handler.ts 尾部(通用,只要 aux/headless 判据正确就自动生效)
11观测埋点 / Langfusetrace 带 agent_source:<client> tag、session-init 各阶段 log、tool_call 埋点,方便线上排查✅ 必需(一行 tag,几乎零成本)agentAdapter.agentKind 通用注入;确认 langfuse trace tag 里能看到新 agent_source

判定原则

  • 一行"必需"的能力全部做完才能算基本可用
  • "可选"的按客户端场景决定(CLI-only 客户端可以跳 5、9,只保留 6 header 预选)
  • 客户端有独特的 wire 字段(如 DeepSeek 思维链)→ 8 号能力必做,否则上游 400
  • 客户端会发 title-gen / compact 等 aux → 3 号能力必做,否则 aux 请求误弹 form / 误写 L0

估工时:一次完整适配(覆盖 1–11)在有实证 dsh 参考的前提下,抓包 + 编码 + 单测 + e2e 大约 3–4 个工作日;踩到未见过的坑另算。


阶段 2:代码改动(20 步 checklist,按顺序)

对着阶段 1 表格勾出来的能力,落到具体文件。每步都有对应的能力编号(能力 #1 = "路由 & 白名单",以此类推)。

2.1 骨架 4 步(30 分钟)—— 对应能力 #1、#3、#4

#文件改动对应能力
1MemoryProxy/src/agent-adapters/<client>.ts新建 —— classifyRequest 三重信号 + extractUserText#3 #4
2MemoryProxy/src/agent-adapters/types.tsAgentKind 联合加 "<client>"#3
3MemoryProxy/src/agent-adapters/index.tsfactory switch 加 case#3
4MemoryProxy/src/server.ts加 9 条路由(带/不带 v1 × 主端点/aux/cost-guard/analyse marker),对照 dsh 段抄#1

2.2 白名单 & Session 识别 3 步 —— 对应能力 #1、#2、#7

#文件改动对应能力
5MemoryProxy/src/credit-reporter.ts::extractSpaceIdFromPath正则加 |<client> —— 漏了会返 auth 401 missing service_id#1
6MemoryProxy/src/session/session-key.ts::resolveConversationIdheader fallback 链加客户端专属 session header#2
7无独立 profile 需要时跳过;有的话 MemoryProxy/src/injection/agents/<client>/* 一套(参考 MemoryProxy/src/injection/agents/workbuddy/#7

2.3 Session Init Form 载体 4 步 —— 对应能力 #5

#文件改动对应能力
8MemoryProxy/src/session/<client>/form.ts新建 —— tool 名 / 参数 shape 完全按客户端 preset 定,别复用别家#5
9MemoryProxy/src/session/index.ts加 dispatch 分支(参考 workbuddy 那段:CB 状态机产 formData 后外层重渲染)#5
10MemoryProxy/src/session/codebuddy/init.ts split-stage gate5 处 gate 加 || agentSource === "<client>" —— 漏了会一次同问 agent+task 直接 bypass#5
11MemoryProxy/src/session/codebuddy/cleaner.ts tool_call_id 正则|<client>_ 前缀识别#5

2.4 元数据过滤 & Wire 兼容 3 步 —— 对应能力 #4、#8

#文件改动对应能力
12MemoryProxy/src/session/codebuddy/init.ts::isFreshCBConversation按客户端首帧元数据签名跳过 user 计数 —— 漏了会误判"有历史"直接 skip session-init#4
13MemoryProxy/src/session/store.ts::tryHistoryScan同款过滤逻辑#4
14wire 特殊字段 round-trip(如 dsh 的 reasoning_content) —— MemoryProxy/src/injection/adapters/openai.tsanthropic.ts 的 parse/serialize 用 metadata 保留;必须用 debug env 双向 body dump 对比验证#8

2.5 Handler 短路 2 步 —— 对应能力 #3、#5、#9、#10

#文件改动对应能力
15MemoryProxy/src/handler.ts 顶部agentAdapter.classifyRequest(body, path, headers) 分类;isAuxiliary=true 时 session-init / mem / injection / L0 / skill trigger 全部跳过,直接透传#3 #9 #10
16MemoryProxy/src/handler.ts 顶部若客户端有 CLI headless 场景(preset 少 tools 里无 ask-user),加类似 dsh 的 _headless 判据,同 aux 一样跳过 form;mem 命令给出提示或降级到 header 预选#5 #9

2.6 测试 & 验证 4 步 —— 对应能力 #5、#9、#10、#11

#内容对应能力
17单测:adapter classifyRequest + form builder shape + real-capture fixture 端到端 —— 落到 MemoryProxy/src/__tests__/agent-adapters/<client>.test.ts + MemoryProxy/src/session/<client>/__tests__/form.test.ts#3 #5
18curl smoke:直接打 /<client>/default/* 触发 form,验证 session-init 三态转移(asset_confirm → team → agent → task#5
19web e2e(推荐 playwright):真跑一遍客户端 web UI(session-init 4 步 + 1 轮真对话,验证注入 + 归档 + langfuse trace)#5 #7 #10 #11
20mem / L0 / skill 端到端:已初始化 session 里发 mem:help / mem:sync / mem:create-skill / 长对话触发 skill 提取 / grep proxy log 验 L0 写入#9 #10

阶段 3:常见坑速查

前 5 家客户端每一家都踩过至少 3 个坑。先查这里再自己 debug

坑 A:missing service_id (spaceId not in request path) 401

根因credit-reporter.ts::extractSpaceIdFromPath 白名单正则没含新客户端名。

:加进 ^(claude-code|codebuddy|codex|cursor|hermes|openclaw|workbuddy|dsh|<new-client>)$

坑 B:session-init form 弹了 / 或者永远不弹

  • 永远不弹 = resolveConversationId fallback 没识别客户端 session header → sessionKey 落到 keyId 兜底,或者 isFreshCBConversation 把首帧元数据 user 当"有历史" → 走 markerless bypass。
    • session-key.ts fallback 链 + codebuddy/init.ts::isFreshCBConversation + store.ts::tryHistoryScan 加元数据签名过滤
  • 弹了但选完 agent 没弹 task = split-stage gate 没加新客户端 → 走 CB 老一次同问 pending_agent_task → task 空直接 bypass。
    • codebuddy/init.ts 5 处 gate 加 || agentSource === "<client>"
  • 翻页无限循环 / default 任务每页开头都出现 = 沿用了 CC 的 4-per-page 分页但没写 MORE 拦截。
    • 修:客户端 UI 无 options 上限时直接关掉分页,全量渲染(dsh 就走这条)

坑 C:上游 400 unknown tool ""

根因:客户端 preset 没挂 ask_user_question(或本客户端的 UI tool),proxy 塞的 fake tool_call 校验拒。

:加 headless bypass —— body.tools 非空但无该 tool 时,直接透传不弹 form。

坑 D:上游 400 The reasoning_content in the thinking mode must be passed back to the API

根因(两个坑一起踩):

  1. fake session-init assistant 消息不带 reasoning_content 字段
  2. 塞了空串 "" → 客户端 translate.ts 用 length > 0 判据吃掉

:fake response 塞非空占位串(如 [proxy session-init form])。

次生坑:塞了非空占位,客户端也 replay 回 proxy 了,但injection pipeline parse→serialize 抹了字段

  • PROXY_DEBUG_DUMP_INBOUND + PROXY_DEBUG_DUMP_BODY 两个 env 抓入站/出站 body 对比。
  • MemoryProxy/src/injection/adapters/openai.ts / anthropic.tsparseMessage/serializeMessage,用 ContextMessage.metadata 存透传字段。

坑 E:aux 请求(compaction / title)误走 session-init 弹 form

根因handler.ts 顶部没 classifyRequest,所有请求全当 main。

handler.ts 顶部调 agentAdapter.classifyRequest(body, path, headers)isAuxiliary=true 时 session-init / mem / injection / L0 / skill trigger 全部跳过

坑 F:客户端特色 wire 字段(如 reasoning_content)round-trip 丢失

见坑 D 次生坑。通用做法PROXY_DEBUG_DUMP_INBOUND + PROXY_DEBUG_DUMP_BODY 对比入站/出站字段,任一丢失就补 adapter。

坑 G:抓包 Node 22 HTTPS_PROXY 无效

:加 NODE_OPTIONS='--use-env-proxy'。undici experimental,但目前唯一手段。

坑 H:用 headless 抓包,以为 tool 不存在

教训:preset 系统决定 tools,不同 profile 挂的 tool 差异是常态。web / tui 场景的 tools 数组一定比 headless 全。抓包最好抓 web,或者两种都抓一次。


完成标准

以下全部验证过才算适配完成(左侧标注对应的能力编号):

能力完成标准
#1 路由curl -X POST /<client>/default/chat/completions 不返 404、不返 401 missing service_id
#2 sessionKey同一 session_id 的多次请求在 proxy log 里 sessionKey= 一致
#3 请求分类aux 请求(compaction / title-gen)proxy log 显示 [request-classify] → auxiliary (skip ...) 直接透传
#5 Session Init首帧返 form(role=assistant + tool_call 是客户端 preset 的 ask-user tool 名);playwright 走完 asset_confirm → team → agent → task 4 步
#7 注入主对话上游返 200,且上游看到的 system message 里含 <agent_skills> / <user_memory> 等资产块
#8 wire 兼容主对话上游 不返 400(reasoning_content / unknown tool / invalid_request_error
#9 mem 命令mem:help / mem:sync / mem:create-skill 三命令拦截返短路响应
#10 L0 & skillproxy log 有 tdai-recorder:write-l0 表示 L0 落盘;有 [skill-conversation-add] archived reason=tool_calls 表示 skill 提取触发
#11 观测Langfuse trace 带 agent_source:<client> tag
— 单测npx vitest run src/session/<client> src/__tests__/agent-adapters/<client>.test.ts 全绿
— 全量测试npx vitest run 零回归

参考实现:dsh(按功能分组)

dsh 是目前踩坑最多、覆盖最完整的接入案例。下表按阶段 1 适配范围的能力编号,列出 dsh 每项能力对应的代码位置,可直接对着自己的客户端照抄改名。

能力dsh 实现位置说明
#1 路由 & 白名单MemoryProxy/src/server.ts dsh 段(9 条路由)
MemoryProxy/src/credit-reporter.ts::extractSpaceIdFromPath
主 `/chat/completions$ \times (带/不带 \text{v1}) \times (\text{main}/\text{aux}/\text{cost}-\text{guard}/\text{analyse} \text{marker}) 组合
#2 \text{Session} \text{ID} 解析$MemoryProxy/src/session/session-key.ts::resolveConversationIdx-deepseek-harness-session-id` fallback)dsh 只从 header 拿 sid,body 没 fallback
#3 请求分类MemoryProxy/src/agent-adapters/dsh.ts::classifyRequest
MemoryProxy/src/handler.ts 顶部 aux short-circuit
三重信号:compact header > title body-shape > main
#4 用户文本 & 元数据过滤MemoryProxy/src/agent-adapters/dsh.ts::extractUserText
MemoryProxy/src/session/codebuddy/init.ts::isFreshCBConversation
MemoryProxy/src/session/store.ts::tryHistoryScan
dsh 首帧塞 3 条元数据 role=user,签名匹配跳过
#5 Session Init FormMemoryProxy/src/session/dsh/form.ts(tool = ask_user_question,call_id 前缀 call_dsh_session_init_
MemoryProxy/src/session/index.ts dsh dispatch 分支
MemoryProxy/src/session/codebuddy/init.ts 5 处 split-stage gate 加 agentSource === "dsh"
MemoryProxy/src/session/codebuddy/cleaner.ts tool_call_id 正则加 dsh_ 前缀
状态机复用 CB,dsh UI 无 options 上限所以不分页
#7 注入复用 MemoryProxy/src/injection/adapters/openai.ts(无独立 profile)dsh 是标准 OpenAI Chat,注入模板走 CB 那一套
#8 Wire 兼容MemoryProxy/src/injection/adapters/openai.ts::parseMessage/serializeMessagereasoning_contentContextMessage.metadata 保留)DeepSeek thinking chain 硬校验必须往返带
#9 Mem 命令通用 mem-command 段自动生效;仅在 dsh headless 时降级(handler.ts::_dshHeadless 判据)headless bypass 客户端 mem:session-reset 有专门的降级提示
#10 L0 & Skill通用 handler 尾部自动生效;_dshHeadless 时跳过(handler.ts 相关 if !_dshHeadlessaux 判据正确后无需 dsh 特殊处理
#11 观测agentAdapter.agentKind = "dsh" 通用注入到 langfuse trace tag无需额外埋点代码
— Headless Bypass(dsh 独有能力)MemoryProxy/src/handler.ts::_dshHeadlessbody.tools 非空但无 ask_user_question → bypass)dsh CLI 场景无 preset,全流程跳过 form / mem / injection
— 单测(39 个,最完整 fixture 集)MemoryProxy/src/__tests__/agent-adapters/dsh.test.ts(19 adapter tests)
MemoryProxy/src/session/dsh/__tests__/form.test.ts(17 form tests)
MemoryProxy/src/injection/adapters/__tests__/openai.test.ts(3 openai round-trip tests)
照抄改客户端名基本能用

对外配置文档(用户视角的 baseURL / 配置文件 / session-init 交互流程)见 agents/dsh/README.md