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>
52 lines
1.7 KiB
Python
52 lines
1.7 KiB
Python
"""Message schema for LLM conversation history."""
|
|
|
|
from typing import Any, Literal
|
|
|
|
from pydantic import BaseModel, Field
|
|
|
|
from app.models.tool_call import ToolCall
|
|
|
|
|
|
class Message(BaseModel):
|
|
"""A single message in the conversation history.
|
|
|
|
Follows the OpenAI chat completions message format with support for
|
|
system, user, assistant, and tool roles.
|
|
"""
|
|
|
|
role: Literal["system", "user", "assistant", "tool"] = Field(
|
|
description="Role of the message sender"
|
|
)
|
|
content: str | None = Field(default=None, description="Text content of the message")
|
|
tool_calls: list[ToolCall] | None = Field(
|
|
default=None, description="Tool calls made by the assistant"
|
|
)
|
|
tool_call_id: str | None = Field(
|
|
default=None, description="ID of the tool call this message responds to (role=tool)"
|
|
)
|
|
name: str | None = Field(
|
|
default=None, description="Name of the tool that produced this message (role=tool)"
|
|
)
|
|
|
|
def to_api_dict(self) -> dict[str, Any]:
|
|
"""Serialize to a dict suitable for the OpenAI-compatible API.
|
|
|
|
Strips None-valued fields to keep the payload clean.
|
|
"""
|
|
data: dict[str, Any] = {"role": self.role}
|
|
|
|
# Ollama requires content to be a string, never null/missing —
|
|
# even on assistant messages that only contain tool_calls.
|
|
data["content"] = self.content or ""
|
|
|
|
if self.tool_calls is not None:
|
|
data["tool_calls"] = [tc.model_dump() for tc in self.tool_calls]
|
|
|
|
if self.tool_call_id is not None:
|
|
data["tool_call_id"] = self.tool_call_id
|
|
|
|
if self.name is not None:
|
|
data["name"] = self.name
|
|
|
|
return data
|