diff --git a/CHANGELOG.md b/CHANGELOG.md index f9aa9ff..45cc795 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,21 @@ All notable changes to forge are documented here. +## [0.9.4] — 2026-08-26 + +An Anthropic interoperability release for Forge Proxy and direct Anthropic +client usage. Native plaintext thinking returned alongside tool calls now +participates in Forge's existing reasoning-replay policy. + +### Added + +- **Anthropic tool-call reasoning is captured consistently.** Streamed and + non-streamed native `thinking` content is mapped to `ToolCall.reasoning`, + preferred over the existing visible-text fallback, and exposed through + Forge Proxy's OpenAI-compatible responses according to the configured + replay policy. Text-only responses remain visible text, and native signed + thinking-block round-tripping remains outside this release's scope. #152 + ## [0.9.3] — 2026-08-21 A command-ownership hotfix for the standalone Forge Proxy distribution. Proxy diff --git a/docs/decisions/017-reasoning-replay-policy.md b/docs/decisions/017-reasoning-replay-policy.md index e2bba03..18c9fda 100644 --- a/docs/decisions/017-reasoning-replay-policy.md +++ b/docs/decisions/017-reasoning-replay-policy.md @@ -42,7 +42,11 @@ Full per-config tables: [results/raw/reasoning-replay.md](../results/raw/reasoni - **Behavioral change for reasoning-capable backends.** Upgraders who want the old behavior pin `--reasoning-replay full` (proxy) or `WorkflowRunner(reasoning_replay="full")`. For non-reasoning/instruct models the knob is inert and nothing changes. - **Token savings by default.** Backend-facing history stops accumulating reasoning; `full` remains the cost wildcard (context grows with run length). - **Eval surface.** `reasoning_replay` is part of the eval resume key and a first-class report/dashboard dimension; rows predating the knob count as `full` (that is what they ran). -- **Claude rows are unaffected.** The Anthropic client drops returned thinking blocks rather than capturing them into history, so the knob is request-inert there; carrying thinking across turns natively is deferred pending evidence it moves scores. +- **Claude tool-call reasoning follows the common policy.** Future Anthropic + tool-call responses capture plaintext thinking through + `ToolCall.reasoning`, so `none`, `keep-last`, and `full` apply through the + existing serializer. Historical published rows are unchanged. Forge still + does not preserve or synthesize signed native Anthropic thinking blocks. ## Alternatives considered diff --git a/installer/proxy-stable.txt b/installer/proxy-stable.txt index 965065d..a602fc9 100644 --- a/installer/proxy-stable.txt +++ b/installer/proxy-stable.txt @@ -1 +1 @@ -0.9.3 +0.9.4 diff --git a/pyproject.toml b/pyproject.toml index 2ef60c0..fb3b249 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "forge-guardrails" -version = "0.9.3" +version = "0.9.4" description = "A reliability layer for self-hosted LLM tool-calling. Guardrails, context management, and backend adapters for multi-step agentic workflows." requires-python = ">=3.12" license = "MIT" diff --git a/src/forge/clients/anthropic.py b/src/forge/clients/anthropic.py index d889f6a..7f272a1 100644 --- a/src/forge/clients/anthropic.py +++ b/src/forge/clients/anthropic.py @@ -379,20 +379,30 @@ def _convert_messages( # ── Response parsing ───────────────────────────────────────── + @staticmethod + def _resolve_reasoning(thinking: str, text: str) -> str | None: + """Prefer native thinking, preserving the existing text fallback.""" + return thinking or text or None + @staticmethod def _parse_response(response: Any) -> LLMResponse: """Anthropic Message → list[ToolCall] or TextResponse.""" tool_uses: list[Any] = [] + thinking_parts: list[str] = [] text_parts: list[str] = [] for block in response.content: if block.type == "tool_use": tool_uses.append(block) + elif block.type == "thinking": + thinking_parts.append(block.thinking) elif block.type == "text": text_parts.append(block.text) if tool_uses: - reasoning = "\n".join(text_parts) if text_parts else None + reasoning = AnthropicClient._resolve_reasoning( + "\n".join(thinking_parts), "\n".join(text_parts), + ) return [ ToolCall( tool=tu.name, @@ -574,6 +584,7 @@ async def send_stream( kwargs = self._prepare_sdk_kwargs(kwargs, extra_headers) accumulated_text = "" + accumulated_thinking = "" # Track multiple tool_use blocks by index. tool_blocks: list[dict[str, str]] = [] # [{name, args}, ...] _current_tool_idx: int = -1 @@ -594,6 +605,8 @@ async def send_stream( type=ChunkType.TEXT_DELTA, content=event.delta.text, ) + elif event.delta.type == "thinking_delta": + accumulated_thinking += event.delta.thinking elif event.delta.type == "input_json_delta" and _current_tool_idx >= 0: tool_blocks[_current_tool_idx]["args"] += event.delta.partial_json yield StreamChunk( @@ -605,7 +618,9 @@ async def send_stream( _current_tool_idx = -1 elif event.type == "message_stop": if tool_blocks: - reasoning = accumulated_text or None + reasoning = self._resolve_reasoning( + accumulated_thinking, accumulated_text, + ) final: LLMResponse = [ ToolCall( tool=tb["name"], diff --git a/tests/unit/test_anthropic_client.py b/tests/unit/test_anthropic_client.py index 1e4d821..6a7bc5c 100644 --- a/tests/unit/test_anthropic_client.py +++ b/tests/unit/test_anthropic_client.py @@ -94,6 +94,13 @@ def _text_delta(text: str) -> SimpleNamespace: ) +def _thinking_delta(thinking: str) -> SimpleNamespace: + return _stream_event( + "content_block_delta", + delta=SimpleNamespace(type="thinking_delta", thinking=thinking), + ) + + def _tool_start(name: str) -> SimpleNamespace: return _stream_event( "content_block_start", @@ -486,6 +493,49 @@ def test_tool_use_with_text_reasoning(self) -> None: assert result[0].tool == "get_weather" assert result[0].reasoning == "Let me check the weather." + def test_native_thinking_precedes_text_and_attaches_to_first_tool(self) -> None: + response = MagicMock() + thinking_block = MagicMock() + thinking_block.type = "thinking" + thinking_block.thinking = "I should check both tools." + text_block = MagicMock() + text_block.type = "text" + text_block.text = "Visible preamble." + first_tool = MagicMock() + first_tool.type = "tool_use" + first_tool.name = "get_weather" + first_tool.input = {"city": "Paris"} + second_tool = MagicMock() + second_tool.type = "tool_use" + second_tool.name = "set_unit" + second_tool.input = {"unit": "celsius"} + response.content = [thinking_block, text_block, first_tool, second_tool] + + result = AnthropicClient._parse_response(response) + + assert result == [ + ToolCall( + tool="get_weather", + args={"city": "Paris"}, + reasoning="I should check both tools.", + ), + ToolCall(tool="set_unit", args={"unit": "celsius"}, reasoning=None), + ] + + def test_text_response_excludes_native_thinking(self) -> None: + response = MagicMock() + thinking_block = MagicMock() + thinking_block.type = "thinking" + thinking_block.thinking = "Hidden reasoning." + text_block = MagicMock() + text_block.type = "text" + text_block.text = "Visible answer." + response.content = [thinking_block, text_block] + + result = AnthropicClient._parse_response(response) + + assert result == TextResponse(content="Visible answer.") + def test_empty_text_response(self) -> None: response = MagicMock() response.content = [] @@ -582,6 +632,41 @@ async def test_multi_tool_stream_keeps_fragment_and_block_boundaries(self) -> No ToolCall(tool="set_unit", args={"unit": "celsius"}, reasoning=None), ] + @pytest.mark.asyncio + async def test_stream_native_thinking_precedes_visible_text(self) -> None: + client = AnthropicClient(model="claude-test", api_key="dummy") + events = [ + _thinking_delta("I should "), + _thinking_delta("search."), + _text_delta("Visible preamble."), + _tool_start("search"), + _json_delta('{"q":"forge"}'), + _stream_event("content_block_stop"), + _stream_event("message_stop"), + ] + client._client = MagicMock() + client._client.messages.stream.return_value = _FakeAnthropicStream(events) + + chunks = [ + chunk + async for chunk in client.send_stream( + [{"role": "user", "content": "search for forge"}], + ) + ] + + assert [ + chunk.content + for chunk in chunks + if chunk.type == ChunkType.TEXT_DELTA + ] == ["Visible preamble."] + assert chunks[-1].response == [ + ToolCall( + tool="search", + args={"q": "forge"}, + reasoning="I should search.", + ), + ] + @pytest.mark.asyncio async def test_sdk_stream_failure_becomes_backend_error(self) -> None: client = AnthropicClient(model="claude-test", api_key="dummy") diff --git a/tests/unit/test_proxy_path1.py b/tests/unit/test_proxy_path1.py index 13b015c..c027021 100644 --- a/tests/unit/test_proxy_path1.py +++ b/tests/unit/test_proxy_path1.py @@ -18,6 +18,7 @@ from forge._backend_profiles import ClientAdapter from forge.clients.anthropic import AnthropicClient +from forge.context.manager import ContextManager from forge.context.strategies import NoCompact from forge.proxy.handler import handle_chat_completions from forge.proxy.proxy import ProxyServer @@ -218,6 +219,23 @@ def _stub_tool_response(name="search", **tool_input): return msg +def _stub_thinking_tool_response(name="search", **tool_input): + msg = MagicMock() + thinking_block = MagicMock() + thinking_block.type = "thinking" + thinking_block.thinking = "I should search." + tool_block = MagicMock() + tool_block.type = "tool_use" + tool_block.name = name + tool_block.input = tool_input + msg.content = [thinking_block, tool_block] + msg.usage.input_tokens = 1 + msg.usage.output_tokens = 1 + msg.usage.cache_creation_input_tokens = 0 + msg.usage.cache_read_input_tokens = 0 + return msg + + class TestCacheControlSurvivesWire: """The headline path-1 capability: a cache_control block on inbound must reach the Anthropic SDK call unchanged.""" @@ -323,6 +341,55 @@ async def test_rebuild_path_drops_cache_control(self): assert not isinstance(kwargs["system"], list) +class TestAnthropicReasoningProxyParity: + @pytest.mark.asyncio + @pytest.mark.parametrize("stream", [False, True]) + async def test_openai_client_receives_keep_last_reasoning(self, stream) -> None: + client = AnthropicClient(model="claude-test", api_key="dummy") + client._client.messages.create = AsyncMock( + return_value=_stub_thinking_tool_response(q="forge"), + ) + body = { + "model": "claude-test", + "messages": [{"role": "user", "content": "search for forge"}], + "tools": [{ + "type": "function", + "function": { + "name": "search", + "description": "Search.", + "parameters": { + "type": "object", + "properties": {"q": {"type": "string"}}, + }, + }, + }], + "stream": stream, + } + + result = await handle_chat_completions( + body, + client, + ContextManager(strategy=NoCompact(), budget_tokens=None), + client_adapter=ClientAdapter.ANTHROPIC, + protocol="openai", + backend_protocol="anthropic", + reasoning_replay="keep-last", + ) + + if stream: + assert any( + event.get("choices", [{}])[0] + .get("delta", {}) + .get("reasoning_content") == "I should search." + for event in result + ) + else: + assert ( + result["choices"][0]["message"]["reasoning_content"] + == "I should search." + ) + + class TestRequestLocalModelSurvivesMutation: @staticmethod def _body(model):