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

@@ -8,16 +8,18 @@ from pathlib import Path
import structlog
from app.agent.context import SessionContext
from app.agent.loop import AgentLoop
from app.models.config import AppConfig, load_config
from app.services.llm import LLMClient, LLMConnectionError, LLMError
from app.services.permissions import PermissionsService
from app.services.streaming import StreamHandler
from app.tools.registry import create_default_registry
from app.utils.display import (
print_banner,
print_error,
print_history,
print_info,
print_success,
print_token_usage,
print_user_message,
print_warning,
)
@@ -73,8 +75,12 @@ async def _run_repl(
config: Application configuration.
logger: Structured logger instance.
"""
registry = create_default_registry(config.agent.workspace_root, config)
permissions = PermissionsService(config.permissions)
async with LLMClient(config.llm) as client:
handler = StreamHandler(config.display)
agent = AgentLoop(config, ctx, client, handler, registry, permissions)
while True:
try:
@@ -102,50 +108,9 @@ async def _run_repl(
print_warning(f"Unknown command: {user_input}")
continue
# Add user message and display it
ctx.add_message("user", user_input)
print_user_message(user_input)
# Stream LLM response
try:
chunk_iter = client.stream_chat(ctx.get_history())
assistant_msg = await handler.process_stream(chunk_iter)
except KeyboardInterrupt:
print_warning("Response interrupted.")
handler.reset()
continue
except LLMConnectionError as e:
print_error(f"Connection error: {e}")
continue
except LLMError as e:
print_error(f"LLM error: {e}")
continue
# Handle empty response
if assistant_msg.content is None and assistant_msg.tool_calls is None:
print_warning("Received empty response from model.")
# Record assistant message in history
ctx.add_message(
"assistant",
assistant_msg.content,
tool_calls=assistant_msg.tool_calls,
)
# Record API token usage if available, fall back to heuristic
if handler.usage:
ctx.token_counter.count_usage(handler.usage)
# Show token usage if configured
if config.display.show_token_usage:
print_token_usage(
ctx.token_counter.cumulative_usage.total_tokens
or ctx.estimated_tokens,
ctx.token_counter.budget,
)
handler.reset()
logger.debug("message_exchanged", message_count=ctx.message_count)
await agent.run_turn(user_input)
logger.debug("turn_complete", message_count=ctx.message_count)
def main() -> None: