🔧 CopilotX v2.3: 让 OpenClaw 终于能动手了

🔧 CopilotX v2.3: 让 OpenClaw 终于能动手了

凌晨 12:24,小龙虾终于写下了它的第一行字:

# 👋 Hello from the Claw

深夜写下的第一行字,像猫在键盘上踩出的脚印。

 /\_/\  
( o.o ) < 文件写入成功!
 > ^ <

这 149 个字节,承载的是两天的调试历程。

问题:Agent 在「幻想」自己写了文件

事情是这样开始的。

OpenClaw 是我在用的个人 AI 助手框架,它连接 Telegram 等多个渠道,后台跑着一个 Agent。我用 CopilotX 作为 LLM Provider,把 GitHub Copilot 的模型能力暴露成 Anthropic 兼容的 API。

理论上,这套架构应该完美:

Telegram → OpenClaw Gateway → Agent → CopilotX → Copilot (Claude)
                                 ↓
                         files/search/edit tools

但当我让 Agent 帮我写个文件时,它信誓旦旦地说:

✅ 写好了!📄 文件在 first-sign.md

然而——文件根本不存在。

根因分析:Tools 参数被「吃掉了」

我开始深挖 session logs。OpenClaw 会把每次对话记录成 JSONL 文件。打开一看:

{
  "type": "text",
  "text": "写好了!文件在 first-sign.md"
}

没有 tool_use block。Agent 根本没调用工具——它只是「说」自己调用了。

这是典型的 LLM 幻觉(Hallucination),但为什么会发生?

答案藏在 CopilotX 的 translator.py 里。这个文件负责把 Anthropic 格式的请求翻译成 OpenAI 格式发给 Copilot:

def anthropic_to_openai_request(body: dict) -> dict:
    # 处理 messages, system, model, temperature...
    # ❌ 但完全没处理 tools 参数!

OpenClaw 发送的请求里带着完整的 tools 定义(read/write/edit 等),但 CopilotX 在翻译时把它们丢弃了。模型收到的请求里根本没有可用工具,自然无法调用。

解决方案:完整的 Tools 双向转换

要修复这个问题,需要实现完整的 Anthropic ↔ OpenAI tools 格式双向转换。这涉及三个层面:

1. 请求侧:Tool Definitions 转换

Anthropic 格式:

{
  "tools": [{
    "name": "calculator",
    "description": "Math calc",
    "input_schema": {"type": "object", "properties": {...}}
  }]
}

OpenAI 格式:

{
  "tools": [{
    "type": "function",
    "function": {
      "name": "calculator",
      "description": "Math calc",
      "parameters": {"type": "object", "properties": {...}}
    }
  }]
}

2. 请求侧:历史消息中的 tool_use/tool_result

当 Agent 进行多轮工具调用时,历史消息中会包含之前的工具调用和结果。

Anthropic 把这些放在 content blocks 里:

{"role": "assistant", "content": [
  {"type": "text", "text": "Let me calculate..."},
  {"type": "tool_use", "name": "calculator", "input": {...}}
]}

而 OpenAI 用独立的 tool_calls 字段和 role: "tool" 消息:

{"role": "assistant", "content": "Let me calculate...", "tool_calls": [...]}
{"role": "tool", "tool_call_id": "xxx", "content": "425"}

3. 响应侧:tool_calls → tool_use

模型返回 tool 调用时,OpenAI 格式:

{
  "finish_reason": "tool_calls",
  "message": {
    "content": "I'll calculate that for you.",
    "tool_calls": [{"id": "...", "function": {"name": "calculator", "arguments": "{...}"}}]
  }
}

需要转成 Anthropic 格式:

{
  "stop_reason": "tool_use",
  "content": [
    {"type": "text", "text": "I'll calculate that for you."},
    {"type": "tool_use", "id": "...", "name": "calculator", "input": {...}}
  ]
}

踩坑:Copilot 的 Split-Choices 格式

当我以为搞定了,测试时发现响应里只有 text,tool_use block 消失了。

调试发现,GitHub Copilot 后端返回的格式很「特别」——它把 text 和 tool_calls 放在两个独立的 choices 里:

{
  "choices": [
    {"message": {"content": "I'll calculate..."}},      // choice[0]: 只有 text
    {"message": {"tool_calls": [...]}}                  // choice[1]: 只有 tool_calls
  ]
}

而标准 OpenAI 格式是把它们放在同一个 choice 里:

{
  "choices": [
    {"message": {"content": "...", "tool_calls": [...]}}
  ]
}

所以我的转换代码只读 choices[0],自然丢失了 tool_calls。

修复:遍历所有 choices,合并 text 和 tool_calls:

def openai_to_anthropic_response(openai_resp: dict, model: str) -> dict:
    content_text = ""
    all_tool_calls = []
    
    # 合并所有 choices 的内容
    for choice in openai_resp.get("choices", []):
        message = choice.get("message", {})
        if message.get("content"):
            content_text += message["content"]
        if message.get("tool_calls"):
            all_tool_calls.extend(message["tool_calls"])
    
    # 构建 Anthropic content blocks
    content_blocks = []
    if content_text:
        content_blocks.append({"type": "text", "text": content_text})
    for tc in all_tool_calls:
        content_blocks.append({
            "type": "tool_use",
            "id": tc["id"],
            "name": tc["function"]["name"],
            "input": json.loads(tc["function"]["arguments"])
        })
    
    return {
        "content": content_blocks,
        "stop_reason": "tool_use" if all_tool_calls else "end_turn",
        ...
    }

最终测试

修复部署后,测试 API:

curl -s https://api.polly.wang/v1/messages \
  -H "x-api-key: $KEY" \
  -d '{
    "model": "claude-sonnet-4",
    "messages": [{"role": "user", "content": "Use calculator to compute 25*17"}],
    "tools": [{"name": "calculator", ...}]
  }'

响应:

{
  "stop_reason": "tool_use",
  "content": [
    {"type": "text", "text": "I'll use the calculator tool to compute 25*17 for you."},
    {"type": "tool_use", "id": "tooluse_xxx", "name": "calculator", "input": {"expr": "25*17"}}
  ]
}

完美!✅

回到 OpenClaw,让小龙虾写文件:

🐾: 帮我写个 hello.md 文件

📄 Command: ~/.openclaw/workspace/hello.md
✅ Successfully wrote 149 bytes

这次是真的写入了。cat ~/.openclaw/workspace/hello.md 确认文件存在。

版本总结

版本特性代码量
v2.2.1Config 命令-
v2.3.0完整 Anthropic tools 支持+822 行
v2.3.1修复 Copilot split-choices 格式+115 行测试

关键实现:

  • anthropic_tools_to_openai(): 工具定义转换
  • anthropic_tool_use_to_openai(): tool_use block 转换
  • anthropic_tool_result_to_openai(): tool_result 转换
  • openai_tool_calls_to_anthropic(): 响应 tool_calls 转换
  • Split-choices 合并逻辑

经验教训

  1. LLM 幻觉不总是模型的错 —— 有时是你没把工具告诉它
  2. 不同后端的格式差异比想象中大 —— Copilot 的 split-choices 就是个例子
  3. 先测 API 再测应用 —— 用 curl 隔离问题比在 OpenClaw 里调试快得多
  4. session logs 是调试利器 —— 它告诉你 Agent 到底做了什么

下一步:让小龙虾帮我管理博客、记录日常,真正成为「数字分身」的一部分。


深夜的硬盘不睡觉,但程序员该睡了。🐾

Comments