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

This commit is contained in:
showtan001
2026-08-29 21:19:30 +08:00
parent 71fd182f50
commit 3c9e65c78e
5 changed files with 57 additions and 2 deletions
+4
View File
@@ -111,6 +111,10 @@ The compact form `{ "role": "user", "text": "...", "image_base64": "..." }`
is also accepted. The model can inspect the supplied image and then call a is also accepted. The model can inspect the supplied image and then call a
phone tool such as `tap` in the same conversation. phone tool such as `tap` in the same conversation.
Local mode records LLM responses, reasoning fields, tool calls, tool results,
and final replies as JSONL in `host_agent_data/conversations.jsonl`. Image
bytes are excluded; set `HOST_AGENT_CONVERSATION_LOG_PATH` to change the path.
In local mode, Appium supervision is enabled by default. Host Agent probes In local mode, Appium supervision is enabled by default. Host Agent probes
`/status`, adopts a healthy existing Appium instance, starts Appium when no `/status`, adopts a healthy existing Appium instance, starts Appium when no
listener exists, restarts only processes it started if they crash, and stops listener exists, restarts only processes it started if they crash, and stops
+3
View File
@@ -13,6 +13,7 @@ from host_agent.assignment import AssignmentExecutor
from host_agent.client import HostAgentClient, HostAgentEnrollmentClient from host_agent.client import HostAgentClient, HostAgentEnrollmentClient
from host_agent.config import HostAgentConfig, load_host_agent_config from host_agent.config import HostAgentConfig, load_host_agent_config
from host_agent.conversation import ConversationAgent from host_agent.conversation import ConversationAgent
from host_agent.conversation_log import ConversationLogStore
from host_agent.dependency_supervisor import DependencySupervisor from host_agent.dependency_supervisor import DependencySupervisor
from host_agent.devices import register_local_device from host_agent.devices import register_local_device
from host_agent.enrollment import resolve_host_identity from host_agent.enrollment import resolve_host_identity
@@ -245,10 +246,12 @@ def create_application(
status_tracker=status_tracker, status_tracker=status_tracker,
) )
planner_config = load_planner_config() planner_config = load_planner_config()
conversation_log = ConversationLogStore(resolved_config.conversation_log_path) if resolved_config.mode == "local" else None
conversation_agent = ( conversation_agent = (
ConversationAgent( ConversationAgent(
config=planner_config, config=planner_config,
tools=default_tool_registry(manager=resolved_manager), tools=default_tool_registry(manager=resolved_manager),
event_logger=conversation_log.append if conversation_log else None,
) )
if resolved_config.ai_planner_transport == "direct" if resolved_config.ai_planner_transport == "direct"
else None else None
@@ -49,6 +49,7 @@ class HostAgentConfig:
dependency_restart_max_attempts: int = 5 dependency_restart_max_attempts: int = 5
task_progress_db_path: Path = Path("host_agent_data/task_progress.sqlite3") task_progress_db_path: Path = Path("host_agent_data/task_progress.sqlite3")
task_artifact_dir: Path = Path("host_agent_data/history") 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_count: int = 50
task_retention_max_age_days: int = 7 task_retention_max_age_days: int = 7
skill_sync_interval_seconds: float = 300.0 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" "HOST_AGENT_TASK_ARTIFACT_DIR", "host_agent_data/history"
).strip() ).strip()
), ),
conversation_log_path=Path(values.get("HOST_AGENT_CONVERSATION_LOG_PATH", "host_agent_data/conversations.jsonl").strip()),
task_retention_max_count=_positive_int( task_retention_max_count=_positive_int(
values, "HOST_AGENT_TASK_RETENTION_MAX_COUNT", 50 values, "HOST_AGENT_TASK_RETENTION_MAX_COUNT", 50
), ),
@@ -32,10 +32,12 @@ class ConversationAgent:
config: PlannerConfig, config: PlannerConfig,
tools: dict[str, Callable[..., Any]], tools: dict[str, Callable[..., Any]],
max_rounds: int = 8, max_rounds: int = 8,
event_logger: Callable[[dict[str, Any]], None] | None = None,
) -> None: ) -> None:
self.config = config self.config = config
self.tools = tools self.tools = tools
self.max_rounds = max_rounds self.max_rounds = max_rounds
self.event_logger = event_logger
self._client: Any | None = None self._client: Any | None = None
def chat(self, messages: list[dict[str, Any]]) -> ChatResult: def chat(self, messages: list[dict[str, Any]]) -> ChatResult:
@@ -68,22 +70,30 @@ class ConversationAgent:
) )
message = response.choices[0].message message = response.choices[0].message
text = getattr(message, "content", None) text = getattr(message, "content", None)
thinking = getattr(message, "reasoning_content", None) or getattr(message, "reasoning", None)
tool_calls = getattr(message, "tool_calls", None) or [] 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: if not tool_calls:
self._log({"type": "final_reply", "content": str(text or "")})
return ChatResult(content=str(text or ""), tool_calls=calls) return ChatResult(content=str(text or ""), tool_calls=calls)
history.append(_message_dict(message)) history.append(_message_dict(message))
for tool_call in tool_calls: for tool_call in tool_calls:
name = tool_call.function.name name = tool_call.function.name
arguments: dict[str, Any] = {}
try: try:
arguments = json.loads(tool_call.function.arguments or "{}") decoded_arguments = json.loads(tool_call.function.arguments or "{}")
if not isinstance(arguments, dict): if not isinstance(decoded_arguments, dict):
raise ValueError("tool arguments must be an object") raise ValueError("tool arguments must be an object")
arguments = decoded_arguments
arguments.pop("purpose", None) arguments.pop("purpose", None)
arguments.pop("expected_outcome", None) arguments.pop("expected_outcome", None)
result = self.tools[name](**arguments) result = self.tools[name](**arguments)
except Exception as exc: except Exception as exc:
result = {"ok": False, "error": str(exc)} result = {"ok": False, "error": str(exc)}
calls += 1 calls += 1
self._log({"type": "tool_result", "tool_call_id": tool_call.id,
"tool_name": name, "arguments": arguments, "result": result})
history.append( history.append(
{ {
"role": "tool", "role": "tool",
@@ -93,6 +103,13 @@ class ConversationAgent:
) )
raise RuntimeError("conversation exceeded the maximum tool-call rounds") 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: def _get_client(self) -> Any:
if self._client is None: if self._client is None:
from openai import OpenAI 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)