Add Phase 5: ReAct-style agent loop with tool execution

Implement the core autonomy layer — AgentLoop streams LLM responses,
parses tool calls, executes them with permission checks, feeds results
back, and repeats until the task completes or finish is called.

- Add FinishTool for explicit loop termination
- Add tools parameter to LLMClient.stream_chat() for function calling
- Add compact tool result display (status line, not full output)
- Refactor REPL to delegate to AgentLoop.run_turn()
- Fix Ollama null content rejection (always send content as string)
- Add finish to auto_approve permissions
- 9 unit tests for agent loop (34 total, zero regressions)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-11 08:37:22 -05:00
parent 501bf5c45b
commit 91187a0728
10 changed files with 609 additions and 54 deletions

View File

@@ -0,0 +1,289 @@
"""Unit tests for the AgentLoop ReAct-style tool-call loop."""
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from app.agent.context import SessionContext
from app.agent.loop import AgentLoop
from app.models.config import (
AgentConfig,
AppConfig,
DisplayConfig,
LLMConfig,
PermissionsConfig,
ToolsConfig,
)
from app.models.message import Message
from app.models.tool_call import ToolCall, ToolCallFunction, ToolResult, ToolResultStatus
from app.services.llm import LLMClient
from app.services.permissions import PermissionsService
from app.services.streaming import StreamHandler
from app.tools.registry import ToolRegistry, create_default_registry
@pytest.fixture
def config() -> AppConfig:
return AppConfig(
llm=LLMConfig(
model="test-model",
endpoint="http://localhost:11434",
),
agent=AgentConfig(
max_iterations=5,
max_conversation_tokens=32000,
workspace_root=Path("/tmp/test-workspace"),
),
permissions=PermissionsConfig(
auto_approve=["read_file", "list_dir", "grep_files", "find_files", "finish"],
),
display=DisplayConfig(
show_tool_calls=True,
show_token_usage=False,
stream_output=False,
),
)
@pytest.fixture
def ctx(config: AppConfig) -> SessionContext:
return SessionContext(config)
@pytest.fixture
def client() -> MagicMock:
return MagicMock(spec=LLMClient)
@pytest.fixture
def handler() -> MagicMock:
mock = MagicMock(spec=StreamHandler)
mock.usage = None
mock.reset = MagicMock()
return mock
@pytest.fixture
def registry(config: AppConfig) -> ToolRegistry:
return create_default_registry(config.agent.workspace_root, config)
@pytest.fixture
def permissions(config: AppConfig) -> PermissionsService:
return PermissionsService(config.permissions)
@pytest.fixture
def agent(
config: AppConfig,
ctx: SessionContext,
client: MagicMock,
handler: MagicMock,
registry: ToolRegistry,
permissions: PermissionsService,
) -> AgentLoop:
return AgentLoop(config, ctx, client, handler, registry, permissions)
def _make_text_message(content: str) -> Message:
"""Helper: create an assistant message with text only (no tool calls)."""
return Message(role="assistant", content=content, tool_calls=None)
def _make_tool_call_message(
tool_name: str,
arguments: str,
tc_id: str = "call_001",
content: str | None = None,
) -> Message:
"""Helper: create an assistant message with a single tool call."""
return Message(
role="assistant",
content=content,
tool_calls=[
ToolCall(
id=tc_id,
type="function",
function=ToolCallFunction(name=tool_name, arguments=arguments),
)
],
)
class TestAgentLoop:
@pytest.mark.asyncio
async def test_plain_text_response(self, agent: AgentLoop, handler: MagicMock, ctx: SessionContext) -> None:
"""LLM returns text with no tool calls — loop completes in 1 iteration."""
handler.process_stream = AsyncMock(return_value=_make_text_message("Hello!"))
await agent.run_turn("Hi there")
assert handler.process_stream.call_count == 1
# History: user + assistant
history = ctx.get_history()
assert len(history) == 2
assert history[0].role == "user"
assert history[1].role == "assistant"
assert history[1].content == "Hello!"
@pytest.mark.asyncio
async def test_single_tool_call(self, agent: AgentLoop, handler: MagicMock, ctx: SessionContext) -> None:
"""LLM calls a tool, then responds with text — 2 LLM calls."""
handler.process_stream = AsyncMock(
side_effect=[
_make_tool_call_message("list_dir", '{"directory_path": "."}'),
_make_text_message("Here are the files."),
]
)
await agent.run_turn("List files")
assert handler.process_stream.call_count == 2
history = ctx.get_history()
# user, assistant (tool_call), tool (result), assistant (text)
assert len(history) == 4
assert history[0].role == "user"
assert history[1].role == "assistant"
assert history[1].tool_calls is not None
assert history[2].role == "tool"
assert history[3].role == "assistant"
assert history[3].content == "Here are the files."
@pytest.mark.asyncio
async def test_finish_tool_breaks_loop(self, agent: AgentLoop, handler: MagicMock, ctx: SessionContext) -> None:
"""Calling the finish tool terminates the loop immediately."""
handler.process_stream = AsyncMock(
return_value=_make_tool_call_message("finish", '{"message": "All done!"}'),
)
await agent.run_turn("Do something")
assert handler.process_stream.call_count == 1
history = ctx.get_history()
# user, assistant (finish call), tool (finish result)
assert len(history) == 3
assert history[2].role == "tool"
assert "All done!" in (history[2].content or "")
@pytest.mark.asyncio
async def test_max_iterations(self, agent: AgentLoop, handler: MagicMock, config: AppConfig) -> None:
"""Loop stops at max_iterations when LLM keeps calling tools."""
handler.process_stream = AsyncMock(
return_value=_make_tool_call_message("list_dir", '{"directory_path": "."}'),
)
await agent.run_turn("Keep going")
# Should call LLM max_iterations times
assert handler.process_stream.call_count == config.agent.max_iterations
@pytest.mark.asyncio
async def test_bad_json_arguments(self, agent: AgentLoop, handler: MagicMock, ctx: SessionContext) -> None:
"""Invalid JSON in tool arguments produces an error result, no exception."""
handler.process_stream = AsyncMock(
side_effect=[
_make_tool_call_message("list_dir", "not valid json{{{"),
_make_text_message("Sorry about that."),
]
)
await agent.run_turn("Bad args")
history = ctx.get_history()
# user, assistant (bad call), tool (error), assistant (apology)
assert len(history) == 4
tool_msg = history[2]
assert tool_msg.role == "tool"
assert "Invalid JSON" in (tool_msg.content or "")
@pytest.mark.asyncio
async def test_unknown_tool(self, agent: AgentLoop, handler: MagicMock, ctx: SessionContext) -> None:
"""Unknown tool name produces an error result listing available tools."""
handler.process_stream = AsyncMock(
side_effect=[
_make_tool_call_message("nonexistent_tool", "{}"),
_make_text_message("I'll try something else."),
]
)
await agent.run_turn("Use fake tool")
history = ctx.get_history()
tool_msg = history[2]
assert tool_msg.role == "tool"
assert "Unknown tool" in (tool_msg.content or "")
assert "nonexistent_tool" in (tool_msg.content or "")
@pytest.mark.asyncio
async def test_permission_denied(self, agent: AgentLoop, handler: MagicMock, ctx: SessionContext, config: AppConfig) -> None:
"""Denied tool produces an error result."""
# Add list_dir to deny list
config.permissions.deny.append("list_dir")
# Recreate permissions service with updated config
agent._permissions = PermissionsService(config.permissions)
handler.process_stream = AsyncMock(
side_effect=[
_make_tool_call_message("list_dir", '{"directory_path": "."}'),
_make_text_message("Permission was denied."),
]
)
await agent.run_turn("List files")
history = ctx.get_history()
tool_msg = history[2]
assert tool_msg.role == "tool"
assert "Permission denied" in (tool_msg.content or "")
@pytest.mark.asyncio
async def test_llm_connection_error_stops_loop(self, agent: AgentLoop, handler: MagicMock, ctx: SessionContext) -> None:
"""LLM connection error terminates the loop gracefully."""
from app.services.llm import LLMConnectionError
handler.process_stream = AsyncMock(side_effect=LLMConnectionError("Connection refused"))
await agent.run_turn("Hello")
# Only the user message should be in history (no assistant message added)
history = ctx.get_history()
assert len(history) == 1
assert history[0].role == "user"
@pytest.mark.asyncio
async def test_multiple_tool_calls_in_single_response(self, agent: AgentLoop, handler: MagicMock, ctx: SessionContext) -> None:
"""Multiple tool calls in one response are all executed."""
multi_tc_msg = Message(
role="assistant",
content=None,
tool_calls=[
ToolCall(
id="call_001",
type="function",
function=ToolCallFunction(name="list_dir", arguments='{"directory_path": "."}'),
),
ToolCall(
id="call_002",
type="function",
function=ToolCallFunction(name="find_files", arguments='{"pattern": "*.py"}'),
),
],
)
handler.process_stream = AsyncMock(
side_effect=[
multi_tc_msg,
_make_text_message("Found everything."),
]
)
await agent.run_turn("List and find files")
history = ctx.get_history()
# user, assistant (2 tool calls), tool (result 1), tool (result 2), assistant (text)
assert len(history) == 5
assert history[2].role == "tool"
assert history[2].tool_call_id == "call_001"
assert history[3].role == "tool"
assert history[3].tool_call_id == "call_002"
assert history[4].content == "Found everything."

View File

@@ -96,12 +96,12 @@ class TestToolRegistry:
def test_create_default_registry(self, workspace: Path, config: AppConfig) -> None:
registry = create_default_registry(workspace, config)
names = set(registry.get_all().keys())
assert names == {"read_file", "list_dir", "grep_files", "find_files"}
assert names == {"read_file", "list_dir", "grep_files", "find_files", "finish"}
def test_schema_export(self, workspace: Path, config: AppConfig) -> None:
registry = create_default_registry(workspace, config)
schemas = registry.get_openai_tools_schema()
assert len(schemas) == 4
assert len(schemas) == 5
assert all(s["type"] == "function" for s in schemas)