feat(reasoning): add inline think tag extraction and Anthropic thinking_blocks support
Add extract_think() and emit_incremental_think() helpers to extract thinking content from inline <think> and <thought> tags in the content field. This handles models served via Ollama, self-hosted vLLM, or other compatible endpoints that embed reasoning as inline tags instead of using the dedicated reasoning_content API field. Also adds Anthropic thinking_blocks support for extended thinking via the thinking content blocks array. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -101,6 +101,132 @@ async def test_runner_preserves_reasoning_fields_and_tool_results():
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runner_emits_anthropic_thinking_blocks():
|
||||
from nanobot.agent.hook import AgentHook, AgentHookContext
|
||||
from nanobot.agent.runner import AgentRunSpec, AgentRunner
|
||||
|
||||
provider = MagicMock()
|
||||
emitted_reasoning: list[str] = []
|
||||
|
||||
async def chat_with_retry(**kwargs):
|
||||
return LLMResponse(
|
||||
content="The answer is 42.",
|
||||
thinking_blocks=[
|
||||
{"type": "thinking", "thinking": "Let me analyze this step by step.", "signature": "sig1"},
|
||||
{"type": "thinking", "thinking": "After careful consideration.", "signature": "sig2"},
|
||||
],
|
||||
tool_calls=[],
|
||||
usage={"prompt_tokens": 5, "completion_tokens": 3},
|
||||
)
|
||||
|
||||
provider.chat_with_retry = chat_with_retry
|
||||
tools = MagicMock()
|
||||
tools.get_definitions.return_value = []
|
||||
|
||||
class ReasoningHook(AgentHook):
|
||||
async def emit_reasoning(self, reasoning_content: str | None) -> None:
|
||||
if reasoning_content:
|
||||
emitted_reasoning.append(reasoning_content)
|
||||
|
||||
runner = AgentRunner(provider)
|
||||
result = await runner.run(AgentRunSpec(
|
||||
initial_messages=[{"role": "user", "content": "question"}],
|
||||
tools=tools,
|
||||
model="test-model",
|
||||
max_iterations=3,
|
||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||
hook=ReasoningHook(),
|
||||
))
|
||||
|
||||
assert result.final_content == "The answer is 42."
|
||||
assert len(emitted_reasoning) == 1
|
||||
assert "Let me analyze this" in emitted_reasoning[0]
|
||||
assert "After careful consideration" in emitted_reasoning[0]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runner_emits_inline_think_content_as_reasoning():
|
||||
"""Models returning <think>...</think> in content should have thinking extracted and emitted."""
|
||||
from nanobot.agent.hook import AgentHook, AgentHookContext
|
||||
from nanobot.agent.runner import AgentRunSpec, AgentRunner
|
||||
|
||||
provider = MagicMock()
|
||||
emitted_reasoning: list[str] = []
|
||||
|
||||
async def chat_with_retry(**kwargs):
|
||||
return LLMResponse(
|
||||
content="<think>Let me think about this...\nThe answer is 42.</think>The answer is 42.",
|
||||
tool_calls=[],
|
||||
usage={"prompt_tokens": 5, "completion_tokens": 3},
|
||||
)
|
||||
|
||||
provider.chat_with_retry = chat_with_retry
|
||||
tools = MagicMock()
|
||||
tools.get_definitions.return_value = []
|
||||
|
||||
class ReasoningHook(AgentHook):
|
||||
async def emit_reasoning(self, reasoning_content: str | None) -> None:
|
||||
if reasoning_content:
|
||||
emitted_reasoning.append(reasoning_content)
|
||||
|
||||
runner = AgentRunner(provider)
|
||||
result = await runner.run(AgentRunSpec(
|
||||
initial_messages=[{"role": "user", "content": "what is the answer?"}],
|
||||
tools=tools,
|
||||
model="test-model",
|
||||
max_iterations=3,
|
||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||
hook=ReasoningHook(),
|
||||
))
|
||||
|
||||
assert result.final_content == "The answer is 42."
|
||||
assert len(emitted_reasoning) == 1
|
||||
assert "Let me think about this" in emitted_reasoning[0]
|
||||
assert "The answer is 42" in emitted_reasoning[0]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runner_prefers_reasoning_content_over_inline_think():
|
||||
from nanobot.agent.hook import AgentHook, AgentHookContext
|
||||
from nanobot.agent.runner import AgentRunSpec, AgentRunner
|
||||
|
||||
provider = MagicMock()
|
||||
emitted_reasoning: list[str] = []
|
||||
|
||||
async def chat_with_retry(**kwargs):
|
||||
return LLMResponse(
|
||||
content="<think>inline thinking</think>The answer.",
|
||||
reasoning_content="dedicated reasoning field",
|
||||
tool_calls=[],
|
||||
usage={"prompt_tokens": 5, "completion_tokens": 3},
|
||||
)
|
||||
|
||||
provider.chat_with_retry = chat_with_retry
|
||||
tools = MagicMock()
|
||||
tools.get_definitions.return_value = []
|
||||
|
||||
class ReasoningHook(AgentHook):
|
||||
async def emit_reasoning(self, reasoning_content: str | None) -> None:
|
||||
if reasoning_content:
|
||||
emitted_reasoning.append(reasoning_content)
|
||||
|
||||
runner = AgentRunner(provider)
|
||||
result = await runner.run(AgentRunSpec(
|
||||
initial_messages=[{"role": "user", "content": "question"}],
|
||||
tools=tools,
|
||||
model="test-model",
|
||||
max_iterations=3,
|
||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||
hook=ReasoningHook(),
|
||||
))
|
||||
|
||||
assert result.final_content == "The answer."
|
||||
# Only the dedicated field should be emitted, not the inline <think> content
|
||||
assert len(emitted_reasoning) == 1
|
||||
assert emitted_reasoning[0] == "dedicated reasoning field"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runner_calls_hooks_in_order():
|
||||
from nanobot.agent.hook import AgentHook, AgentHookContext
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from nanobot.utils.helpers import strip_think
|
||||
from nanobot.utils.helpers import extract_think, strip_think
|
||||
|
||||
|
||||
class TestStripThinkTag:
|
||||
@@ -144,3 +144,84 @@ class TestStripThinkConservativePreserve:
|
||||
def test_literal_channel_marker_in_code_block_preserved(self):
|
||||
text = "Example:\n```\nif line.startswith('<channel|>'):\n skip()\n```"
|
||||
assert strip_think(text) == text
|
||||
|
||||
|
||||
class TestExtractThink:
|
||||
|
||||
def test_no_think_tags(self):
|
||||
thinking, clean = extract_think("Hello World")
|
||||
assert thinking is None
|
||||
assert clean == "Hello World"
|
||||
|
||||
def test_single_think_block(self):
|
||||
text = "Hello <think>reasoning content\nhere</think> World"
|
||||
thinking, clean = extract_think(text)
|
||||
assert thinking == "reasoning content\nhere"
|
||||
assert clean == "Hello World"
|
||||
|
||||
def test_single_thought_block(self):
|
||||
text = "Hello <thought>reasoning content</thought> World"
|
||||
thinking, clean = extract_think(text)
|
||||
assert thinking == "reasoning content"
|
||||
assert clean == "Hello World"
|
||||
|
||||
def test_multiple_think_blocks(self):
|
||||
text = "A<think>first</think>B<thought>second</thought>C"
|
||||
thinking, clean = extract_think(text)
|
||||
assert thinking == "first\n\nsecond"
|
||||
assert clean == "ABC"
|
||||
|
||||
def test_think_only_no_content(self):
|
||||
text = "<think>just thinking</think>"
|
||||
thinking, clean = extract_think(text)
|
||||
assert thinking == "just thinking"
|
||||
assert clean == ""
|
||||
|
||||
def test_unclosed_think_not_extracted(self):
|
||||
# Unclosed blocks at start are stripped but NOT extracted
|
||||
text = "<think>unclosed thinking..."
|
||||
thinking, clean = extract_think(text)
|
||||
assert thinking is None
|
||||
assert clean == ""
|
||||
|
||||
def test_empty_think_block(self):
|
||||
text = "Hello <think></think> World"
|
||||
thinking, clean = extract_think(text)
|
||||
# Empty blocks result in empty string after strip
|
||||
assert thinking == ""
|
||||
assert clean == "Hello World"
|
||||
|
||||
def test_think_with_whitespace_only(self):
|
||||
text = "Hello <think> \n World"
|
||||
thinking, clean = extract_think(text)
|
||||
assert thinking is None
|
||||
assert clean == "Hello <think> \n World"
|
||||
|
||||
def test_mixed_think_and_thought(self):
|
||||
text = "Start<think>first reasoning</think>middle<thought>second reasoning</thought>End"
|
||||
thinking, clean = extract_think(text)
|
||||
assert thinking == "first reasoning\n\nsecond reasoning"
|
||||
assert clean == "StartmiddleEnd"
|
||||
|
||||
def test_real_world_ollama_response(self):
|
||||
text = """<think>
|
||||
The user is asking about Python list comprehensions.
|
||||
Let me explain the syntax and give examples.
|
||||
</think>
|
||||
|
||||
List comprehensions in Python provide a concise way to create lists. Here's the syntax:
|
||||
|
||||
```python
|
||||
[expression for item in iterable if condition]
|
||||
```
|
||||
|
||||
For example:
|
||||
```python
|
||||
squares = [x**2 for x in range(10)]
|
||||
```"""
|
||||
thinking, clean = extract_think(text)
|
||||
assert "list comprehensions" in thinking.lower()
|
||||
assert "Let me explain" in thinking
|
||||
assert "List comprehensions in Python" in clean
|
||||
assert "<think>" not in clean
|
||||
assert "</think>" not in clean
|
||||
|
||||
Reference in New Issue
Block a user