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

@@ -2,8 +2,7 @@
import json
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from typing import Self
from typing import Any, Self
import httpx
@@ -101,11 +100,16 @@ class LLMClient:
f"Model '{model}' not found. Available models: {available_str}"
)
async def stream_chat(self, messages: list[Message]) -> AsyncIterator[dict]:
async def stream_chat(
self,
messages: list[Message],
tools: list[dict[str, Any]] | None = None,
) -> AsyncIterator[dict]:
"""Stream a chat completion request, yielding parsed SSE chunks.
Args:
messages: Conversation history to send to the model.
tools: Optional OpenAI function-calling tool schemas.
Yields:
Parsed JSON dicts from each SSE data line.
@@ -115,7 +119,7 @@ class LLMClient:
LLMResponseError: On non-2xx HTTP status.
LLMStreamError: On malformed SSE data (only if every line fails).
"""
payload = {
payload: dict[str, Any] = {
"model": self._config.model,
"messages": [m.to_api_dict() for m in messages],
"stream": True,
@@ -123,6 +127,9 @@ class LLMClient:
"max_tokens": self._config.max_tokens,
}
if tools:
payload["tools"] = tools
try:
async with self._client.stream(
"POST", self._config.api_path, json=payload