From f7bf5b9229488c8e5427b05d6af71e612390040e Mon Sep 17 00:00:00 2001 From: marcus Date: Wed, 21 Oct 2026 23:01:13 +0800 Subject: [PATCH 01/11] fix: repair Anthropic tool-call pairing to clear upstream 11148 Claude Code and CC Switch send legal Anthropic histories whose tool results are not adjacent to the declaring assistant tool_calls: parallel tool_use blocks answered in later turns, duplicated tool_result blocks, and orphan results. _convert_anthropic_message() only translated blocks, so the CodeBuddy backend rejected those histories with HTTP 400 code 11148 ("tool calls and tool results do not match"). The Responses path already had this repair (6274044); the Anthropic path did not, which is why the errors only appeared under protocol=anthropic. Apply _sanitize_tool_pairing() to the converted messages: defer interleaved messages until their tool group completes, and drop orphan/duplicate results plus unanswered tool calls while keeping the assistant text. Add regression tests covering the reported 59-message agentic history and the individual malformed shapes. --- README.md | 11 ++ README_zh.md | 8 + src/codebuddy_proxy/anthropic_adapter.py | 94 +++++++++++ test_anthropic_tool_pairing.py | 199 +++++++++++++++++++++++ 4 files changed, 312 insertions(+) create mode 100644 test_anthropic_tool_pairing.py diff --git a/README.md b/README.md index 205b109..c52f3a9 100644 --- a/README.md +++ b/README.md @@ -796,6 +796,17 @@ 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" + +The CodeBuddy backend requires every Chat `tool` message to answer a call +declared by the immediately preceding assistant message. Claude Code and Codex +CLI histories legally interleave user text or duplicate a `tool_result`, which +produces that mismatch. The proxy repairs `/v1/responses` and `/v1/messages` +histories before forwarding (deferring interleaved messages, dropping orphan or +duplicate results and unanswered tool calls), so this error should not occur on +the proxy path. If it appears in `proxy.log`, the client is sending a history +the repair could not pair; start a fresh conversation in that client. + ### Port already in use ```bash diff --git a/README_zh.md b/README_zh.md index 350d83f..216e38a 100644 --- a/README_zh.md +++ b/README_zh.md @@ -859,6 +859,14 @@ 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" + +CodeBuddy 后端要求每条 Chat `tool` 消息必须紧跟在声明该调用的 assistant 消息之后作答。 +Claude Code / Codex CLI 的历史记录合法地在两者之间夹杂用户文本或重复 `tool_result`, +从而产生此不匹配。代理在转发前会修复 `/v1/responses` 与 `/v1/messages` 的历史 +(延迟夹杂消息、丢弃孤儿/重复结果以及未应答的工具调用),因此代理路径上不应出现该错误。 +如果 `proxy.log` 中仍出现,说明客户端发送的历史无法配对,请在该客户端新建会话。 + ### 端口被占用 ```bash diff --git a/src/codebuddy_proxy/anthropic_adapter.py b/src/codebuddy_proxy/anthropic_adapter.py index 996d03f..bb83832 100644 --- a/src/codebuddy_proxy/anthropic_adapter.py +++ b/src/codebuddy_proxy/anthropic_adapter.py @@ -148,6 +148,97 @@ def _convert_anthropic_message(msg: dict) -> list[dict]: return messages +def _sanitize_tool_pairing(messages: list[dict]) -> list[dict]: + """Keep each Chat tool result adjacent to its declaring assistant tool_calls. + + Anthropic histories legitimately interleave blocks between a tool_use and + its tool_result (user text, several tool_use blocks answered in separate + turns, tool_result blocks that repeat an already answered call). 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 upstream: the + CodeBuddy backend rejects those with 11148 + ("tool calls and tool results do not match"). + """ + 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 anthropic_request_to_chat(body: dict) -> dict: """将Anthropic Messages API请求体转换为Chat Completions请求体 @@ -169,6 +260,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) # 构造Chat body chat: dict[str, Any] = { diff --git a/test_anthropic_tool_pairing.py b/test_anthropic_tool_pairing.py new file mode 100644 index 0000000..417a406 --- /dev/null +++ b/test_anthropic_tool_pairing.py @@ -0,0 +1,199 @@ +"""Regression tests for Anthropic to Chat tool-call/result pairing. + +The CodeBuddy backend rejects Chat histories whose tool results are not +adjacent to the assistant message declaring the call IDs with +``code 11148: tool calls and tool results do not match``. Legal Anthropic +histories routinely produce such shapes (tool_use blocks answered in later +turns, user text between a tool_use and its tool_result, duplicated +tool_result blocks), so conversion must repair them before forwarding. +""" + +import json + +from codebuddy_proxy.anthropic_adapter import anthropic_request_to_chat + + +def _tool_use(call_id, name="Bash"): + return {"type": "tool_use", "id": call_id, "name": name, "input": {"cmd": "ls"}} + + +def _tool_result(call_id, output="ok"): + return {"type": "tool_result", "tool_use_id": call_id, "content": output} + + +def _assert_well_formed_tool_groups(messages): + """Every tool result belongs to the immediately preceding assistant call group.""" + expected = set() + for message in messages: + if message["role"] == "assistant": + assert not expected + calls = message.get("tool_calls") or [] + ids = [call["id"] for call in calls] + assert len(ids) == len(set(ids)) + expected = set(ids) + elif message["role"] == "tool": + assert message["tool_call_id"] in expected + expected.remove(message["tool_call_id"]) + else: + assert not expected + assert not expected + + +def _convert(messages): + """Convert an Anthropic history through the real request path.""" + return anthropic_request_to_chat({"model": "m", "messages": messages})["messages"] + + +def test_user_text_between_tool_use_and_result_is_deferred(): + """The D1 shape: text interleaved inside a tool call group.""" + messages = _convert([ + {"role": "user", "content": [{"type": "text", "text": "run it"}]}, + {"role": "assistant", "content": [{"type": "text", "text": "sure"}, _tool_use("c1")]}, + {"role": "user", "content": [{"type": "text", "text": "ctx"}, _tool_result("c1")]}, + ]) + _assert_well_formed_tool_groups(messages) + assert [m["role"] for m in messages] == ["user", "assistant", "tool", "user"] + assert messages[2]["tool_call_id"] == "c1" + assert messages[3]["content"] == "ctx" + + chat = anthropic_request_to_chat({"model": "m", "messages": [ + {"role": "user", "content": [{"type": "text", "text": "run it"}]}, + {"role": "assistant", "content": [{"type": "text", "text": "sure"}, _tool_use("c1")]}, + {"role": "user", "content": [{"type": "text", "text": "ctx"}, _tool_result("c1")]}, + ]}) + _assert_well_formed_tool_groups(chat["messages"]) + + +def test_parallel_tool_uses_answered_in_separate_turns(): + """Parallel calls whose results arrive in later turns stay adjacent.""" + messages = _convert([ + {"role": "assistant", "content": [_tool_use("c1"), _tool_use("c2", "Read")]}, + {"role": "user", "content": [_tool_result("c1", "r1")]}, + {"role": "user", "content": [_tool_result("c2", "r2")]}, + ]) + _assert_well_formed_tool_groups(messages) + assert [m["role"] for m in messages] == ["assistant", "tool", "tool"] + assert [m["tool_call_id"] for m in messages if m["role"] == "tool"] == ["c1", "c2"] + + +def test_duplicate_result_for_answered_call_is_dropped(): + """A repeated tool_result must not reach the upstream service.""" + messages = _convert([ + {"role": "assistant", "content": [_tool_use("c1")]}, + {"role": "user", "content": [_tool_result("c1", "r1")]}, + {"role": "assistant", "content": [_tool_use("c2", "Read")]}, + {"role": "user", "content": [_tool_result("c2", "r2"), _tool_result("c1", "dup")]}, + ]) + _assert_well_formed_tool_groups(messages) + assert [m["tool_call_id"] for m in messages if m["role"] == "tool"] == ["c1", "c2"] + + +def test_unanswered_tool_use_is_removed_but_text_kept(): + """A trailing tool_use with no result is dropped, its text survives.""" + messages = _convert([ + {"role": "assistant", "content": [{"type": "text", "text": "I'll run it"}, _tool_use("c1")]}, + {"role": "user", "content": [{"type": "text", "text": "continue"}]}, + ]) + assert messages == [ + {"role": "assistant", "content": "I'll run it"}, + {"role": "user", "content": "continue"}, + ] + + +def test_orphan_result_without_declaring_assistant_is_dropped(): + messages = _convert([ + {"role": "assistant", "content": [{"type": "text", "text": "plain"}]}, + {"role": "user", "content": [_tool_result("c9", "orphan")]}, + ]) + assert messages == [{"role": "assistant", "content": "plain"}] + + +def test_missing_tool_use_id_drops_unmatched_group(): + """An id-less tool_use generates a random call id; an unmatched result is dropped.""" + messages = _convert([ + {"role": "assistant", "content": [{"type": "tool_use", "name": "Bash", "input": {}}]}, + {"role": "user", "content": [_tool_result("call_generated", "r")]}, + ]) + _assert_well_formed_tool_groups(messages) + assert messages == [] + + +def test_long_agentic_history_stays_well_formed(): + """The 59-message Claude Code shape from the reported 11148 failure.""" + items = [{"role": "user", "content": [{"type": "text", "text": "start"}]}] + for idx in range(29): + items.append({ + "role": "assistant", + "content": [{"type": "text", "text": f"step {idx}"}, _tool_use(f"call_{idx}")], + }) + items.append({"role": "user", "content": [_tool_result(f"call_{idx}", f"out {idx}")]}) + + messages = _convert(items) + _assert_well_formed_tool_groups(messages) + assert len(messages) == 1 + 29 * 2 + + chat = anthropic_request_to_chat({"model": "deepseek-v4.1-flash", "messages": items}) + _assert_well_formed_tool_groups(chat["messages"]) + assert len(chat["messages"]) == 59 + + +def test_interleaved_messages_are_deferred_until_group_completes(): + """Legal Anthropic history must not split a Chat tool result group.""" + messages = _convert([ + {"role": "user", "content": [{"type": "text", "text": "run two"}]}, + {"role": "assistant", "content": [_tool_use("a1"), _tool_use("a2", "Read")]}, + {"role": "user", "content": [_tool_result("a1", "out-1")]}, + {"role": "user", "content": [{"type": "text", "text": "ctx"}]}, + {"role": "user", "content": [_tool_result("a2", "out-2")]}, + ]) + _assert_well_formed_tool_groups(messages) + assert [m["role"] for m in messages] == [ + "user", "assistant", "tool", "tool", "user", + ] + assert [m["tool_call_id"] for m in messages if m["role"] == "tool"] == ["a1", "a2"] + assert messages[-1]["content"] == "ctx" + + +def test_deferred_messages_keep_their_relative_order(): + messages = _convert([ + {"role": "assistant", "content": [_tool_use("a"), _tool_use("b", "Read")]}, + {"role": "user", "content": [_tool_result("a")]}, + {"role": "user", "content": [{"type": "text", "text": "guidance"}]}, + {"role": "user", "content": [{"type": "text", "text": "later"}]}, + {"role": "assistant", "content": [{"type": "text", "text": "ack"}]}, + {"role": "user", "content": [_tool_result("b")]}, + ]) + _assert_well_formed_tool_groups(messages) + assert [m["role"] for m in messages] == [ + "assistant", "tool", "tool", "user", "user", "assistant", + ] + assert [m["content"] for m in messages[-3:]] == ["guidance", "later", "ack"] + + +def test_plain_conversation_is_untouched(): + body = { + "system": "be helpful", + "messages": [ + {"role": "user", "content": "hi"}, + {"role": "assistant", "content": "hello"}, + ], + } + chat = anthropic_request_to_chat(body) + assert chat["messages"] == [ + {"role": "system", "content": "be helpful"}, + {"role": "user", "content": "hi"}, + {"role": "assistant", "content": "hello"}, + ] + + +def test_tool_call_arguments_survive_sanitization(): + """Repair must not drop or alter tool call arguments.""" + arguments = json.dumps({"cmd": "echo hi"}) + messages = _convert([ + {"role": "assistant", "content": [{ + "type": "tool_use", "id": "c1", "name": "Bash", "input": {"cmd": "echo hi"}, + }]}, + {"role": "user", "content": [_tool_result("c1", "hi")]}, + ]) + _assert_well_formed_tool_groups(messages) + assert messages[0]["tool_calls"][0]["function"]["arguments"] == arguments From 2c42a686a2ca0b8c4140de0ea750c6bebc585118 Mon Sep 17 00:00:00 2001 From: Mayer Date: Thu, 22 Oct 2026 00:08:47 +0800 Subject: [PATCH 02/11] refactor: share safe tool pairing across protocol adapters --- src/codebuddy_proxy/tool_pairing.py | 136 ++++++++++++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 src/codebuddy_proxy/tool_pairing.py diff --git a/src/codebuddy_proxy/tool_pairing.py b/src/codebuddy_proxy/tool_pairing.py new file mode 100644 index 0000000..7ee5d4f --- /dev/null +++ b/src/codebuddy_proxy/tool_pairing.py @@ -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 From a66f1e65c4bfbe44cb4aa3bc7e6130b1dc3c2425 Mon Sep 17 00:00:00 2001 From: Mayer Date: Thu, 22 Oct 2026 00:09:06 +0800 Subject: [PATCH 03/11] refactor: use shared safe tool pairing in anthropic adapter --- src/codebuddy_proxy/anthropic_adapter.py | 95 +----------------------- 1 file changed, 3 insertions(+), 92 deletions(-) diff --git a/src/codebuddy_proxy/anthropic_adapter.py b/src/codebuddy_proxy/anthropic_adapter.py index bb83832..469621b 100644 --- a/src/codebuddy_proxy/anthropic_adapter.py +++ b/src/codebuddy_proxy/anthropic_adapter.py @@ -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 @@ -148,97 +150,6 @@ def _convert_anthropic_message(msg: dict) -> list[dict]: return messages -def _sanitize_tool_pairing(messages: list[dict]) -> list[dict]: - """Keep each Chat tool result adjacent to its declaring assistant tool_calls. - - Anthropic histories legitimately interleave blocks between a tool_use and - its tool_result (user text, several tool_use blocks answered in separate - turns, tool_result blocks that repeat an already answered call). 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 upstream: the - CodeBuddy backend rejects those with 11148 - ("tool calls and tool results do not match"). - """ - 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 anthropic_request_to_chat(body: dict) -> dict: """将Anthropic Messages API请求体转换为Chat Completions请求体 @@ -262,7 +173,7 @@ def anthropic_request_to_chat(body: dict) -> dict: messages.extend(_convert_anthropic_message(msg)) # 修复跨消息的工具调用配对,避免上游 11148 - messages = _sanitize_tool_pairing(messages) + messages = _sanitize_tool_pairing(messages, protocol="anthropic") # 构造Chat body chat: dict[str, Any] = { From 8e5fad426302e40a4a8680b34d198e0b5dcd8bde Mon Sep 17 00:00:00 2001 From: Mayer Date: Thu, 22 Oct 2026 00:09:08 +0800 Subject: [PATCH 04/11] refactor: use shared safe tool pairing in responses adapter --- src/codebuddy_proxy/responses_adapter.py | 92 +----------------------- 1 file changed, 3 insertions(+), 89 deletions(-) diff --git a/src/codebuddy_proxy/responses_adapter.py b/src/codebuddy_proxy/responses_adapter.py index df97b3d..62a60b4 100644 --- a/src/codebuddy_proxy/responses_adapter.py +++ b/src/codebuddy_proxy/responses_adapter.py @@ -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 @@ -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 @@ -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: From 8022e236a31c523781eb0d81927a5cddcef3761b Mon Sep 17 00:00:00 2001 From: Mayer Date: Thu, 22 Oct 2026 00:09:37 +0800 Subject: [PATCH 05/11] fix: return clear 422 errors for ambiguous tool execution histories --- src/codebuddy_proxy/__main__.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/codebuddy_proxy/__main__.py b/src/codebuddy_proxy/__main__.py index 20d9701..4a9e607 100755 --- a/src/codebuddy_proxy/__main__.py +++ b/src/codebuddy_proxy/__main__.py @@ -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 @@ -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: @@ -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) From d8edaf1ec0fc7e6cfb5fcd2272037b412619d896 Mon Sep 17 00:00:00 2001 From: Mayer Date: Thu, 22 Oct 2026 00:11:06 +0800 Subject: [PATCH 06/11] test: cover lossless pairing, 59-message regression and Anthropic HTTP 422 --- test_anthropic_tool_pairing.py | 159 +++++++++++++++++++++++++++------ 1 file changed, 134 insertions(+), 25 deletions(-) diff --git a/test_anthropic_tool_pairing.py b/test_anthropic_tool_pairing.py index 417a406..01b79b5 100644 --- a/test_anthropic_tool_pairing.py +++ b/test_anthropic_tool_pairing.py @@ -8,8 +8,16 @@ tool_result blocks), so conversion must repair them before forwarding. """ +import asyncio import json +from types import SimpleNamespace +import pytest +from fastapi.responses import JSONResponse +from httpx import ASGITransport, AsyncClient + +import codebuddy_proxy.__main__ as proxy +from codebuddy_proxy.tool_pairing import ToolPairingError from codebuddy_proxy.anthropic_adapter import anthropic_request_to_chat @@ -82,22 +90,21 @@ def test_duplicate_result_for_answered_call_is_dropped(): {"role": "assistant", "content": [_tool_use("c1")]}, {"role": "user", "content": [_tool_result("c1", "r1")]}, {"role": "assistant", "content": [_tool_use("c2", "Read")]}, - {"role": "user", "content": [_tool_result("c2", "r2"), _tool_result("c1", "dup")]}, + {"role": "user", "content": [_tool_result("c2", "r2"), _tool_result("c1", "r1")]}, ]) _assert_well_formed_tool_groups(messages) assert [m["tool_call_id"] for m in messages if m["role"] == "tool"] == ["c1", "c2"] -def test_unanswered_tool_use_is_removed_but_text_kept(): - """A trailing tool_use with no result is dropped, its text survives.""" - messages = _convert([ - {"role": "assistant", "content": [{"type": "text", "text": "I'll run it"}, _tool_use("c1")]}, - {"role": "user", "content": [{"type": "text", "text": "continue"}]}, - ]) - assert messages == [ - {"role": "assistant", "content": "I'll run it"}, - {"role": "user", "content": "continue"}, - ] +def test_unanswered_tool_use_is_rejected_instead_of_silently_removed(): + """Missing output must not erase a tool invocation that may have side effects.""" + with pytest.raises(ToolPairingError, match="unanswered_tool_calls"): + _convert([ + {"role": "assistant", "content": [ + {"type": "text", "text": "I'll run it"}, _tool_use("c1"), + ]}, + {"role": "user", "content": [{"type": "text", "text": "continue"}]}, + ]) def test_orphan_result_without_declaring_assistant_is_dropped(): @@ -108,33 +115,41 @@ def test_orphan_result_without_declaring_assistant_is_dropped(): assert messages == [{"role": "assistant", "content": "plain"}] -def test_missing_tool_use_id_drops_unmatched_group(): - """An id-less tool_use generates a random call id; an unmatched result is dropped.""" - messages = _convert([ - {"role": "assistant", "content": [{"type": "tool_use", "name": "Bash", "input": {}}]}, - {"role": "user", "content": [_tool_result("call_generated", "r")]}, - ]) - _assert_well_formed_tool_groups(messages) - assert messages == [] +def test_missing_tool_use_id_is_not_silently_rewritten(): + """An unknown tool call ID cannot be reconstructed from unrelated output.""" + with pytest.raises(ToolPairingError, match="unanswered_tool_calls"): + _convert([ + {"role": "assistant", "content": [ + {"type": "tool_use", "name": "Bash", "input": {}}, + ]}, + {"role": "user", "content": [_tool_result("call_generated", "r")]}, + ]) def test_long_agentic_history_stays_well_formed(): - """The 59-message Claude Code shape from the reported 11148 failure.""" + """59 raw messages with mixed text/results and a repeated identical result.""" items = [{"role": "user", "content": [{"type": "text", "text": "start"}]}] for idx in range(29): items.append({ "role": "assistant", "content": [{"type": "text", "text": f"step {idx}"}, _tool_use(f"call_{idx}")], }) - items.append({"role": "user", "content": [_tool_result(f"call_{idx}", f"out {idx}")]}) - + results = [_tool_result(f"call_{idx}", f"out {idx}")] + if idx == 14: + results.insert(0, {"type": "text", "text": "interleaved context"}) + if idx == 20: + results.append(_tool_result(f"call_{idx}", f"out {idx}")) + items.append({"role": "user", "content": results}) + + assert len(items) == 59 messages = _convert(items) _assert_well_formed_tool_groups(messages) - assert len(messages) == 1 + 29 * 2 + assert len(messages) == 60 # one deferred user text message + assert messages[0]["content"] == "start" + assert sum(m["role"] == "tool" for m in messages) == 29 chat = anthropic_request_to_chat({"model": "deepseek-v4.1-flash", "messages": items}) - _assert_well_formed_tool_groups(chat["messages"]) - assert len(chat["messages"]) == 59 + assert chat["messages"] == messages def test_interleaved_messages_are_deferred_until_group_completes(): @@ -197,3 +212,97 @@ def test_tool_call_arguments_survive_sanitization(): ]) _assert_well_formed_tool_groups(messages) assert messages[0]["tool_calls"][0]["function"]["arguments"] == arguments + + +def test_incomplete_parallel_group_is_rejected(): + with pytest.raises(ToolPairingError, match="unanswered_tool_calls") as error: + _convert([ + {"role": "assistant", "content": [_tool_use("a"), _tool_use("b")]}, + {"role": "user", "content": [_tool_result("a")]}, + {"role": "user", "content": [{"type": "text", "text": "next"}]}, + ]) + assert error.value.call_ids == ("b",) + + +def test_new_assistant_call_cannot_discard_previous_pending_call(): + with pytest.raises(ToolPairingError, match="unanswered_tool_calls"): + _convert([ + {"role": "assistant", "content": [_tool_use("a"), _tool_use("b")]}, + {"role": "user", "content": [_tool_result("a")]}, + {"role": "assistant", "content": [_tool_use("c")]}, + {"role": "user", "content": [_tool_result("b"), _tool_result("c")]}, + ]) + + +def test_conflicting_duplicate_result_is_not_silently_dropped(): + with pytest.raises(ToolPairingError, match="conflicting_duplicate_result"): + _convert([ + {"role": "assistant", "content": [_tool_use("a")]}, + {"role": "user", "content": [_tool_result("a", "success")]}, + {"role": "user", "content": [_tool_result("a", "failure")]}, + ]) + + +def test_duplicate_call_id_is_not_arbitrarily_deduplicated(): + with pytest.raises(ToolPairingError, match="duplicate_tool_call_id"): + _convert([ + {"role": "assistant", "content": [_tool_use("a"), _tool_use("a")]}, + {"role": "user", "content": [_tool_result("a")]}, + ]) + + +def test_orphan_result_is_dropped_with_diagnostic(caplog): + with caplog.at_level("WARNING", logger="codebuddy_proxy.tool_pairing"): + messages = _convert([ + {"role": "user", "content": [_tool_result("unknown", "payload")]}, + {"role": "user", "content": [{"type": "text", "text": "hello"}]}, + ]) + assert messages == [{"role": "user", "content": "hello"}] + assert "orphan_result_dropped" in caplog.text + assert "payload" not in caplog.text + + +@pytest.mark.parametrize("invalid", [False, True]) +def test_anthropic_http_route_validates_tool_history(monkeypatch, invalid): + state = SimpleNamespace( + enable_optimize_context=False, + verbose_llm=False, + write_log=lambda *args, **kwargs: None, + json_logger=None, + logger=None, + ) + monkeypatch.setattr(proxy, "proxy_state", state) + captured = [] + + async def fake_forward_chat(body, protocol, original=None): + captured.append(body) + assert protocol == "anthropic" + return JSONResponse({"ok": True}) + + monkeypatch.setattr(proxy, "forward_chat", fake_forward_chat) + history = [ + {"role": "user", "content": "run"}, + {"role": "assistant", "content": [_tool_use("a"), _tool_use("b")]}, + {"role": "user", "content": [_tool_result("a", "ok")]}, + ] + if not invalid: + history.append({"role": "user", "content": [_tool_result("b", "ok")]}) + + async def request(): + async with AsyncClient( + transport=ASGITransport(app=proxy.app), base_url="http://test", + ) as client: + return await client.post("/v1/messages", json={ + "model": "test", "messages": history, "stream": False, + }) + + response = asyncio.run(request()) + if invalid: + assert response.status_code == 422 + assert response.json()["detail"]["code"] == "invalid_tool_history" + assert response.json()["detail"]["tool_call_ids"] == ["b"] + assert not captured # bad history must never reach upstream + else: + assert response.status_code == 200 + assert len(captured) == 1 + _assert_well_formed_tool_groups(captured[0]["messages"]) From b2d7b78ca7bac41ac1580168573def3162829b3f Mon Sep 17 00:00:00 2001 From: Mayer Date: Thu, 22 Oct 2026 00:11:43 +0800 Subject: [PATCH 07/11] test: align Responses pairing with fail-closed execution-history handling --- test_responses_tool_pairing.py | 93 +++++++++++++++++++++++++--------- 1 file changed, 68 insertions(+), 25 deletions(-) diff --git a/test_responses_tool_pairing.py b/test_responses_tool_pairing.py index 9b0f4dc..29c6082 100644 --- a/test_responses_tool_pairing.py +++ b/test_responses_tool_pairing.py @@ -9,6 +9,7 @@ from httpx import ASGITransport, AsyncClient import codebuddy_proxy.__main__ as proxy +from codebuddy_proxy.tool_pairing import ToolPairingError from codebuddy_proxy.responses_adapter import ( ResponsesStreamConverter, _convert_input_items, responses_request_to_chat, ) @@ -95,32 +96,27 @@ def test_deferred_messages_keep_their_relative_order(): assert [m["content"] for m in messages[-3:]] == ["guidance", "later", "ack"] -def test_incomplete_group_keeps_only_answered_calls(): - messages = _convert_input_items([ - {"type": "message", "role": "user", "content": "go"}, - _call("a"), _call("b"), - _result("a"), - {"type": "message", "role": "user", "content": "next"}, - ]) - _assert_well_formed_tool_groups(messages) - assert [c["id"] for c in messages[1]["tool_calls"]] == ["a"] - assert messages[2]["tool_call_id"] == "a" - assert messages[3]["content"] == "next" +def test_incomplete_group_cannot_erase_unanswered_execution(): + with pytest.raises(ToolPairingError, match="unanswered_tool_calls") as error: + _convert_input_items([ + {"type": "message", "role": "user", "content": "go"}, + _call("a"), _call("b"), + _result("a"), + {"type": "message", "role": "user", "content": "next"}, + ]) + assert error.value.call_ids == ("b",) -def test_unanswered_group_removed_but_assistant_text_preserved(): - messages = _convert_input_items([ - {"type": "message", "role": "assistant", "content": "I'll run it"}, - _call("a"), - {"type": "message", "role": "user", "content": "continue"}, - ]) - assert messages == [ - {"role": "assistant", "content": "I'll run it"}, - {"role": "user", "content": "continue"}, - ] +def test_unanswered_group_fails_closed(): + with pytest.raises(ToolPairingError, match="unanswered_tool_calls"): + _convert_input_items([ + {"type": "message", "role": "assistant", "content": "I'll run it"}, + _call("a"), + {"type": "message", "role": "user", "content": "continue"}, + ]) - messages = _convert_input_items([_call("a")]) - assert messages == [] + with pytest.raises(ToolPairingError, match="unanswered_tool_calls"): + _convert_input_items([_call("a")]) def test_orphan_duplicate_missing_id_outputs_and_empty_assistants_are_dropped(): @@ -128,9 +124,8 @@ def test_orphan_duplicate_missing_id_outputs_and_empty_assistants_are_dropped(): _result("orphan"), {"type": "message", "role": "assistant", "content": ""}, _call("a"), - _call("a"), _result("a", "first"), - _result("a", "duplicate"), + _result("a", "first"), {"type": "function_call_output", "output": "missing id"}, {"type": "message", "role": "user", "content": "next"}, ]) @@ -267,3 +262,51 @@ async def send(): else: assert len(captured) < 4802 assert captured[-1]["content"] == "finish" + + +def test_responses_cross_group_pending_call_fails_closed(): + with pytest.raises(ToolPairingError, match="unanswered_tool_calls"): + _convert_input_items([ + _call("a"), _call("b"), _result("a"), + {"type": "message", "role": "assistant", "content": "new"}, + _call("c"), _result("b"), _result("c"), + ]) + + +def test_responses_conflicting_duplicate_result_fails_closed(): + with pytest.raises(ToolPairingError, match="conflicting_duplicate_result"): + _convert_input_items([ + _call("a"), _result("a", "success"), _result("a", "failure"), + ]) + + +def test_responses_http_route_rejects_incomplete_history(monkeypatch): + state = SimpleNamespace( + enable_optimize_context=False, + verbose_llm=False, + write_log=lambda *args, **kwargs: None, + json_logger=None, + logger=None, + ) + monkeypatch.setattr(proxy, "proxy_state", state) + forwarded = [] + + async def fake_forward_chat(body, protocol, original=None): + forwarded.append(body) + raise AssertionError("Invalid history must not be forwarded upstream") + + monkeypatch.setattr(proxy, "forward_chat", fake_forward_chat) + + async def send(): + transport = ASGITransport(app=proxy.app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + return await client.post("/v1/responses", json={ + "model": "test", + "input": [_call("a"), _call("b"), _result("a")], + }) + + response = asyncio.run(send()) + assert response.status_code == 422 + assert response.json()["detail"]["reason"] == "unanswered_tool_calls" + assert response.json()["detail"]["tool_call_ids"] == ["b"] + assert not forwarded From 2b1d0a2120f1dd2bdbe953a31e74481b067b8b66 Mon Sep 17 00:00:00 2001 From: Mayer Date: Thu, 22 Oct 2026 00:12:20 +0800 Subject: [PATCH 08/11] docs: clarify safe 11148 repair and explicit invalid-history errors --- README.md | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index c52f3a9..c4000cc 100644 --- a/README.md +++ b/README.md @@ -798,14 +798,21 @@ uv run --with workbuddy2api python -m codebuddy_proxy --desensitize --optimize-c ### Upstream error 11148 "tool calls and tool results do not match" -The CodeBuddy backend requires every Chat `tool` message to answer a call -declared by the immediately preceding assistant message. Claude Code and Codex -CLI histories legally interleave user text or duplicate a `tool_result`, which -produces that mismatch. The proxy repairs `/v1/responses` and `/v1/messages` -histories before forwarding (deferring interleaved messages, dropping orphan or -duplicate results and unanswered tool calls), so this error should not occur on -the proxy path. If it appears in `proxy.log`, the client is sending a history -the repair could not pair; start a fresh conversation in that client. +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 From 821fc2290bed67d57f374547061d876f5273fc2d Mon Sep 17 00:00:00 2001 From: Mayer Date: Thu, 22 Oct 2026 00:12:24 +0800 Subject: [PATCH 09/11] docs: clarify safe 11148 repair and explicit invalid-history errors --- README_zh.md | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/README_zh.md b/README_zh.md index 216e38a..9965978 100644 --- a/README_zh.md +++ b/README_zh.md @@ -861,11 +861,19 @@ uv run --with workbuddy2api python -m codebuddy_proxy --desensitize --optimize-c ### 上游错误 11148 "tool calls and tool results do not match" -CodeBuddy 后端要求每条 Chat `tool` 消息必须紧跟在声明该调用的 assistant 消息之后作答。 -Claude Code / Codex CLI 的历史记录合法地在两者之间夹杂用户文本或重复 `tool_result`, -从而产生此不匹配。代理在转发前会修复 `/v1/responses` 与 `/v1/messages` 的历史 -(延迟夹杂消息、丢弃孤儿/重复结果以及未应答的工具调用),因此代理路径上不应出现该错误。 -如果 `proxy.log` 中仍出现,说明客户端发送的历史无法配对,请在该客户端新建会话。 +部分 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 错误。 ### 端口被占用 From 4508f9e2b073ec1ffcadb72af08c4e354b865e80 Mon Sep 17 00:00:00 2001 From: Mayer Date: Thu, 22 Oct 2026 00:12:42 +0800 Subject: [PATCH 10/11] ci: exercise Anthropic and Responses pairing on Python 3.10 and 3.12 --- .github/workflows/protocol-tests.yml | 31 ++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 .github/workflows/protocol-tests.yml diff --git a/.github/workflows/protocol-tests.yml b/.github/workflows/protocol-tests.yml new file mode 100644 index 0000000..8295893 --- /dev/null +++ b/.github/workflows/protocol-tests.yml @@ -0,0 +1,31 @@ +name: Protocol regression tests + +on: + pull_request: + push: + branches: + - fix/anthropic-tool-pairing + +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@v4 + - uses: actions/setup-python@v5 + 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 From 930724352425fdb28d985f9f5bab15ca5b4ef4a0 Mon Sep 17 00:00:00 2001 From: Mayer Date: Thu, 22 Oct 2026 00:14:11 +0800 Subject: [PATCH 11/11] ci: use current GitHub Actions and avoid duplicate PR/push runs --- .github/workflows/protocol-tests.yml | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/.github/workflows/protocol-tests.yml b/.github/workflows/protocol-tests.yml index 8295893..57dbabd 100644 --- a/.github/workflows/protocol-tests.yml +++ b/.github/workflows/protocol-tests.yml @@ -2,9 +2,7 @@ name: Protocol regression tests on: pull_request: - push: - branches: - - fix/anthropic-tool-pairing + workflow_dispatch: permissions: contents: read @@ -17,8 +15,8 @@ jobs: matrix: python-version: ["3.10", "3.12"] steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@v6 + - uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} - name: Install dependencies