Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .github/workflows/protocol-tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
name: Protocol regression tests

on:
pull_request:
workflow_dispatch:

permissions:
contents: read

jobs:
protocol-regressions:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.12"]
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: python -m pip install -e ".[dev]"
- name: Verify protocol adapters and safe tool pairing
run: |
python -m pytest -q \
test_anthropic_tool_pairing.py \
test_responses_tool_pairing.py \
test_desensitize_claude_harness.py
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -796,6 +796,24 @@ uv run --with workbuddy2api python -m codebuddy_proxy --desensitize --optimize-c
--log-file "$HOME/.workbuddy2api/codebuddy-proxy.jsonl"
```

### Upstream error 11148 "tool calls and tool results do not match"

Some agent clients produce nonconforming Anthropic / Responses tool histories.
The CodeBuddy backend requires Chat tool results to directly follow the
declaring assistant `tool_calls`. The proxy now repairs recoverable converted
histories in `/v1/messages` and `/v1/responses`: interleaved non-tool messages
are deferred, and orphan or identical duplicate results are dropped with
diagnostic warnings (no tool output is logged).

Ambiguous histories are **not silently truncated**. If a call has no result, a
tool-call ID is reused, or duplicate results disagree, the proxy returns HTTP
**422**, with `detail.code=invalid_tool_history` and a specific `reason`.
No request is sent upstream in that case. Verify the side effects of any
unfinished tools before retrying or starting a fresh conversation.

This repair does not apply to direct `/v1/chat/completions` requests and cannot
guarantee the absence of unrelated upstream 11148 errors.

### Port already in use

```bash
Expand Down
16 changes: 16 additions & 0 deletions README_zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -859,6 +859,22 @@ uv run --with workbuddy2api python -m codebuddy_proxy --desensitize --optimize-c
--log-file "$HOME/.workbuddy2api/codebuddy-proxy.jsonl"
```

### 上游错误 11148 "tool calls and tool results do not match"

部分 Agent 客户端会生成不符合标准格式的 Anthropic / Responses 工具调用历史。
CodeBuddy 后端要求 Chat `tool` 结果必须紧跟声明对应 `tool_calls` 的 assistant。
代理在 `/v1/messages` 与 `/v1/responses` 转换后修复能够安全恢复的历史:
延后夹杂的非工具消息;对孤儿结果及内容相同的重复结果进行丢弃并记录诊断日志
(不会记录工具输出正文)。

**不再静默删除无法配对的工具调用。** 如果调用缺少结果、工具调用 ID 重用、
或重复结果内容冲突,代理返回 HTTP **422**,错误详情包含
`detail.code=invalid_tool_history` 和对应的 `reason`,不会向上游发起请求。
再次尝试或新建会话前,应先核查未完成工具是否已经产生副作用,避免重复执行。

此修复不适用于直接访问 `/v1/chat/completions` 的请求,也不能保证完全消除其他
原因产生的上游 11148 错误。

### 端口被占用

```bash
Expand Down
17 changes: 14 additions & 3 deletions src/codebuddy_proxy/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@
from typing import Any, Optional

import httpx
from fastapi import FastAPI, Request, HTTPException
from fastapi import FastAPI, Request, HTTPException
from codebuddy_proxy.tool_pairing import ToolPairingError
from fastapi.responses import JSONResponse, StreamingResponse
import uvicorn
from codebuddy_proxy.dsml_parser import DSMLStreamBuffer, parse_all_tool_calls, remove_all_tool_call_markers
Expand Down Expand Up @@ -905,7 +906,12 @@ async def create_response(request: Request):
log_client_request("POST", "/v1/responses", body)

# 转换 Responses → Chat
chat_body = responses_request_to_chat(body)
try:
chat_body = responses_request_to_chat(body)
except ToolPairingError as exc:
diagnostic("tool_pairing_rejected", protocol="responses", reason=exc.reason,
call_count=len(exc.call_ids))
raise HTTPException(status_code=422, detail=exc.as_detail()) from exc

# 消息压缩优化(如果启用)
if state.enable_optimize_context and HAS_PROJECTION:
Expand Down Expand Up @@ -963,7 +969,12 @@ async def create_message(request: Request):
log_client_request("POST", "/v1/messages", body)

# 转换 Anthropic → Chat
chat_body = anthropic_to_chat(body)
try:
chat_body = anthropic_to_chat(body)
except ToolPairingError as exc:
diagnostic("tool_pairing_rejected", protocol="anthropic", reason=exc.reason,
call_count=len(exc.call_ids))
raise HTTPException(status_code=422, detail=exc.as_detail()) from exc
diagnostic("request", protocol="anthropic", **body_summary(chat_body))

return await forward_chat(chat_body, "anthropic", original=body)
Expand Down
5 changes: 5 additions & 0 deletions src/codebuddy_proxy/anthropic_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
import json
import os
import time
from codebuddy_proxy.tool_pairing import sanitize_tool_pairing as _sanitize_tool_pairing

from typing import Any


Expand Down Expand Up @@ -169,6 +171,9 @@ def anthropic_request_to_chat(body: dict) -> dict:
# messages转换
for msg in body.get("messages", []):
messages.extend(_convert_anthropic_message(msg))

# 修复跨消息的工具调用配对,避免上游 11148
messages = _sanitize_tool_pairing(messages, protocol="anthropic")

# 构造Chat body
chat: dict[str, Any] = {
Expand Down
92 changes: 3 additions & 89 deletions src/codebuddy_proxy/responses_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
import os
import time
import uuid
from codebuddy_proxy.tool_pairing import sanitize_tool_pairing as _sanitize_tool_pairing

from typing import Any, Iterator


Expand Down Expand Up @@ -88,94 +90,6 @@ def _convert_tools_for_chat(tools: list[dict]) -> list[dict]:
return result


def _sanitize_tool_pairing(messages: list[dict]) -> list[dict]:
"""Keep each Chat tool result adjacent to its declaring assistant tool_calls.

Responses histories can contain messages between outputs belonging to a
single assistant turn. Chat Completions requires the declared call IDs to
be answered together, so postpone intervening messages until that group
has finished. Never forward an orphan/duplicate result or an unanswered
tool call to the upstream service.
"""
out: list[dict] = []
deferred: list[dict] = []
pending: set[str] = set()
answered: set[str] = set()
assistant_index: int | None = None

def _close_group() -> None:
nonlocal assistant_index
if assistant_index is not None:
assistant = out[assistant_index]
matched = [
call for call in assistant["tool_calls"]
if call["id"] in answered
]
if matched:
assistant["tool_calls"] = matched
else:
assistant.pop("tool_calls", None)
if not str(assistant.get("content") or "").strip():
out.pop(assistant_index)

assistant_index = None
pending.clear()
answered.clear()
out.extend(deferred)
deferred.clear()

for message in messages:
if not isinstance(message, dict):
continue

role = message.get("role")
calls = message.get("tool_calls") or []
if role == "assistant" and calls:
_close_group()
# A duplicate/missing call ID would make matching ambiguous.
unique_calls: list[dict] = []
seen_ids: set[str] = set()
for call in calls:
if not isinstance(call, dict):
continue
call_id = call.get("id")
if not isinstance(call_id, str) or not call_id or call_id in seen_ids:
continue
seen_ids.add(call_id)
unique_calls.append(call)

if unique_calls:
out.append({**message, "tool_calls": unique_calls})
assistant_index = len(out) - 1
pending.update(seen_ids)
elif str(message.get("content") or "").strip():
out.append({key: value for key, value in message.items() if key != "tool_calls"})
continue

if role == "tool":
call_id = message.get("tool_call_id")
if isinstance(call_id, str) and assistant_index is not None and call_id in pending:
out.append(message)
pending.remove(call_id)
answered.add(call_id)
if not pending:
_close_group()
# Missing, repeated, or unmatched results are not valid Chat messages.
continue

# Blank assistant messages are also invalid/no-ops in this context.
if role == "assistant" and not str(message.get("content") or "").strip():
continue

if assistant_index is not None:
deferred.append(message)
else:
out.append(message)

_close_group()
return out


def _convert_input_items(items: list) -> list[dict]:
"""将Responses API的input数组转换为Chat messages

Expand Down Expand Up @@ -273,7 +187,7 @@ def _flush_assistant():

# 刷新最后一条assistant消息,并修复跨消息的工具调用配对
_flush_assistant()
return _sanitize_tool_pairing(messages)
return _sanitize_tool_pairing(messages, protocol="responses")


def responses_request_to_chat(body: dict) -> dict:
Expand Down
136 changes: 136 additions & 0 deletions src/codebuddy_proxy/tool_pairing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
"""Repair tool-call/result grouping after protocol adapters create Chat messages.

The proxy may reorder interleaved *text* to satisfy Chat tool-call adjacency.
It must never silently erase an unanswered call: execution may already have
had side effects. For ambiguous histories fail closed before contacting upstream.
"""
from __future__ import annotations

import hashlib
import logging
from typing import Any

_LOG = logging.getLogger("codebuddy_proxy.tool_pairing")


class ToolPairingError(ValueError):
"""An ambiguous tool history cannot be forwarded without losing meaning."""

def __init__(self, reason: str, call_ids: tuple[str, ...] = ()) -> None:
self.reason = reason
self.call_ids = call_ids
super().__init__(f"Invalid tool history ({reason}); calls={len(call_ids)}")

def as_detail(self) -> dict[str, Any]:
return {
"code": "invalid_tool_history",
"reason": self.reason,
"tool_call_ids": list(self.call_ids),
"message": "Tool execution history is ambiguous; no upstream request was sent.",
}


def _result_digest(content: Any) -> bytes:
"""Store a small comparison token rather than retaining tool output twice."""
return hashlib.sha256(repr(content).encode("utf-8", "backslashreplace")).digest()


def sanitize_tool_pairing(
messages: list[dict], *, protocol: str = "unknown",
) -> list[dict]:
"""Return a valid Chat tool history or raise ToolPairingError.

Non-tool messages interleaved with an *open* group are deferred in their
original relative order. Orphan results and identical duplicates cannot
be paired and are dropped with a warning. Missing calls, ambiguous IDs or
contradictory duplicate results are errors rather than silent data loss.
"""
out: list[dict] = []
deferred: list[dict] = []
pending: set[str] = set()
seen_call_ids: set[str] = set()
completed_results: dict[str, bytes] = {}
active = False

def warn(action: str, count: int = 1) -> None:
# Do not log tool outputs, arguments or conversation contents.
_LOG.warning(
"tool_pairing protocol=%s action=%s count=%d", protocol, action, count
)

def fail(reason: str, ids: set[str] | tuple[str, ...] = ()) -> None:
ordered_ids = tuple(sorted(ids))
warn(reason, len(ordered_ids))
raise ToolPairingError(reason, ordered_ids)

def close_group() -> None:
nonlocal active
if pending:
fail("unanswered_tool_calls", pending)
active = False
if deferred:
_LOG.info(
"tool_pairing protocol=%s action=deferred_messages count=%d",
protocol,
len(deferred),
)
out.extend(deferred)
deferred.clear()

for message in messages:
if not isinstance(message, dict):
fail("invalid_message")

role = message.get("role")
calls = message.get("tool_calls") if role == "assistant" else None
if calls:
close_group()
if not isinstance(calls, list):
fail("invalid_tool_calls")
call_ids: set[str] = set()
for call in calls:
if not isinstance(call, dict):
fail("invalid_tool_calls")
call_id = call.get("id")
if not isinstance(call_id, str) or not call_id:
fail("invalid_tool_call_id")
if call_id in call_ids or call_id in seen_call_ids:
fail("duplicate_tool_call_id", (call_id,))
call_ids.add(call_id)
seen_call_ids.update(call_ids)
pending.update(call_ids)
active = True
out.append(message)
continue

if role == "tool":
call_id = message.get("tool_call_id")
if not isinstance(call_id, str) or not call_id:
warn("invalid_result_id_dropped")
continue
digest = _result_digest(message.get("content"))
if active and call_id in pending:
out.append(message)
completed_results[call_id] = digest
pending.remove(call_id)
if not pending:
close_group()
elif call_id in completed_results:
if completed_results[call_id] != digest:
fail("conflicting_duplicate_result", (call_id,))
warn("duplicate_result_dropped")
else:
warn("orphan_result_dropped")
continue

# Empty assistant messages have no meaning in Chat histories.
if role == "assistant" and not str(message.get("content") or "").strip():
continue

if active:
deferred.append(message)
else:
out.append(message)

close_group()
return out
Loading
Loading