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
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion docs/decisions/017-reasoning-replay-policy.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion installer/proxy-stable.txt
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.9.3
0.9.4
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
19 changes: 17 additions & 2 deletions src/forge/clients/anthropic.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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(
Expand All @@ -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"],
Expand Down
85 changes: 85 additions & 0 deletions tests/unit/test_anthropic_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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 = []
Expand Down Expand Up @@ -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")
Expand Down
67 changes: 67 additions & 0 deletions tests/unit/test_proxy_path1.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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):
Expand Down
Loading