Record local agent reasoning and tool activity
Tests / Test apps.device-host-agent.tests.test_mcp_token.test_load_or_create_concurrent_calls_do_not_corrupt failed
Tests / Test apps.device-host-agent.tests.test_mcp_token.test_load_or_create_concurrent_calls_do_not_corrupt failed
This commit is contained in:
@@ -13,6 +13,7 @@ from host_agent.assignment import AssignmentExecutor
|
||||
from host_agent.client import HostAgentClient, HostAgentEnrollmentClient
|
||||
from host_agent.config import HostAgentConfig, load_host_agent_config
|
||||
from host_agent.conversation import ConversationAgent
|
||||
from host_agent.conversation_log import ConversationLogStore
|
||||
from host_agent.dependency_supervisor import DependencySupervisor
|
||||
from host_agent.devices import register_local_device
|
||||
from host_agent.enrollment import resolve_host_identity
|
||||
@@ -245,10 +246,12 @@ def create_application(
|
||||
status_tracker=status_tracker,
|
||||
)
|
||||
planner_config = load_planner_config()
|
||||
conversation_log = ConversationLogStore(resolved_config.conversation_log_path) if resolved_config.mode == "local" else None
|
||||
conversation_agent = (
|
||||
ConversationAgent(
|
||||
config=planner_config,
|
||||
tools=default_tool_registry(manager=resolved_manager),
|
||||
event_logger=conversation_log.append if conversation_log else None,
|
||||
)
|
||||
if resolved_config.ai_planner_transport == "direct"
|
||||
else None
|
||||
|
||||
@@ -49,6 +49,7 @@ class HostAgentConfig:
|
||||
dependency_restart_max_attempts: int = 5
|
||||
task_progress_db_path: Path = Path("host_agent_data/task_progress.sqlite3")
|
||||
task_artifact_dir: Path = Path("host_agent_data/history")
|
||||
conversation_log_path: Path = Path("host_agent_data/conversations.jsonl")
|
||||
task_retention_max_count: int = 50
|
||||
task_retention_max_age_days: int = 7
|
||||
skill_sync_interval_seconds: float = 300.0
|
||||
@@ -159,6 +160,7 @@ def load_host_agent_config(
|
||||
"HOST_AGENT_TASK_ARTIFACT_DIR", "host_agent_data/history"
|
||||
).strip()
|
||||
),
|
||||
conversation_log_path=Path(values.get("HOST_AGENT_CONVERSATION_LOG_PATH", "host_agent_data/conversations.jsonl").strip()),
|
||||
task_retention_max_count=_positive_int(
|
||||
values, "HOST_AGENT_TASK_RETENTION_MAX_COUNT", 50
|
||||
),
|
||||
|
||||
@@ -32,10 +32,12 @@ class ConversationAgent:
|
||||
config: PlannerConfig,
|
||||
tools: dict[str, Callable[..., Any]],
|
||||
max_rounds: int = 8,
|
||||
event_logger: Callable[[dict[str, Any]], None] | None = None,
|
||||
) -> None:
|
||||
self.config = config
|
||||
self.tools = tools
|
||||
self.max_rounds = max_rounds
|
||||
self.event_logger = event_logger
|
||||
self._client: Any | None = None
|
||||
|
||||
def chat(self, messages: list[dict[str, Any]]) -> ChatResult:
|
||||
@@ -68,22 +70,30 @@ class ConversationAgent:
|
||||
)
|
||||
message = response.choices[0].message
|
||||
text = getattr(message, "content", None)
|
||||
thinking = getattr(message, "reasoning_content", None) or getattr(message, "reasoning", None)
|
||||
tool_calls = getattr(message, "tool_calls", None) or []
|
||||
self._log({"type": "llm_response", "content": text, "thinking": thinking,
|
||||
"tool_calls": [{"id": c.id, "name": c.function.name, "arguments": c.function.arguments} for c in tool_calls]})
|
||||
if not tool_calls:
|
||||
self._log({"type": "final_reply", "content": str(text or "")})
|
||||
return ChatResult(content=str(text or ""), tool_calls=calls)
|
||||
history.append(_message_dict(message))
|
||||
for tool_call in tool_calls:
|
||||
name = tool_call.function.name
|
||||
arguments: dict[str, Any] = {}
|
||||
try:
|
||||
arguments = json.loads(tool_call.function.arguments or "{}")
|
||||
if not isinstance(arguments, dict):
|
||||
decoded_arguments = json.loads(tool_call.function.arguments or "{}")
|
||||
if not isinstance(decoded_arguments, dict):
|
||||
raise ValueError("tool arguments must be an object")
|
||||
arguments = decoded_arguments
|
||||
arguments.pop("purpose", None)
|
||||
arguments.pop("expected_outcome", None)
|
||||
result = self.tools[name](**arguments)
|
||||
except Exception as exc:
|
||||
result = {"ok": False, "error": str(exc)}
|
||||
calls += 1
|
||||
self._log({"type": "tool_result", "tool_call_id": tool_call.id,
|
||||
"tool_name": name, "arguments": arguments, "result": result})
|
||||
history.append(
|
||||
{
|
||||
"role": "tool",
|
||||
@@ -93,6 +103,13 @@ class ConversationAgent:
|
||||
)
|
||||
raise RuntimeError("conversation exceeded the maximum tool-call rounds")
|
||||
|
||||
def _log(self, event: dict[str, Any]) -> None:
|
||||
if self.event_logger is not None:
|
||||
try:
|
||||
self.event_logger(event)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _get_client(self) -> Any:
|
||||
if self._client is None:
|
||||
from openai import OpenAI
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from threading import Lock
|
||||
from typing import Any
|
||||
|
||||
|
||||
class ConversationLogStore:
|
||||
"""Append-only local audit log for chat and tool activity."""
|
||||
|
||||
def __init__(self, path: str | Path) -> None:
|
||||
self.path = Path(path)
|
||||
self._lock = Lock()
|
||||
|
||||
def append(self, event: dict[str, Any]) -> None:
|
||||
record = {"timestamp": datetime.now(UTC).isoformat(), **_safe(event)}
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with self._lock, self.path.open("a", encoding="utf-8") as stream:
|
||||
stream.write(json.dumps(record, ensure_ascii=True, default=str) + "\n")
|
||||
|
||||
|
||||
def _safe(value: Any) -> Any:
|
||||
if isinstance(value, dict):
|
||||
return {str(k): _safe(v) for k, v in value.items()}
|
||||
if isinstance(value, list):
|
||||
return [_safe(v) for v in value]
|
||||
return value if isinstance(value, (str, int, float, bool)) or value is None else str(value)
|
||||
Reference in New Issue
Block a user