From 3c9e65c78e62f83348e32855b1912794934739d7 Mon Sep 17 00:00:00 2001 From: showtan001 <240788545@qq.com> Date: Sat, 29 Aug 2026 21:19:30 +0800 Subject: [PATCH] Record local agent reasoning and tool activity --- README.md | 4 +++ apps/device-host-agent/host_agent/app.py | 3 ++ apps/device-host-agent/host_agent/config.py | 2 ++ .../host_agent/conversation.py | 21 ++++++++++++-- .../host_agent/conversation_log.py | 29 +++++++++++++++++++ 5 files changed, 57 insertions(+), 2 deletions(-) create mode 100644 apps/device-host-agent/host_agent/conversation_log.py diff --git a/README.md b/README.md index ec4e8d5..9d7a02b 100644 --- a/README.md +++ b/README.md @@ -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 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 `/status`, adopts a healthy existing Appium instance, starts Appium when no listener exists, restarts only processes it started if they crash, and stops diff --git a/apps/device-host-agent/host_agent/app.py b/apps/device-host-agent/host_agent/app.py index 5d84fcd..34eb49c 100644 --- a/apps/device-host-agent/host_agent/app.py +++ b/apps/device-host-agent/host_agent/app.py @@ -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 diff --git a/apps/device-host-agent/host_agent/config.py b/apps/device-host-agent/host_agent/config.py index 128ad09..d3597dc 100644 --- a/apps/device-host-agent/host_agent/config.py +++ b/apps/device-host-agent/host_agent/config.py @@ -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 ), diff --git a/apps/device-host-agent/host_agent/conversation.py b/apps/device-host-agent/host_agent/conversation.py index 7cbc865..8db57d8 100644 --- a/apps/device-host-agent/host_agent/conversation.py +++ b/apps/device-host-agent/host_agent/conversation.py @@ -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 diff --git a/apps/device-host-agent/host_agent/conversation_log.py b/apps/device-host-agent/host_agent/conversation_log.py new file mode 100644 index 0000000..2fd2118 --- /dev/null +++ b/apps/device-host-agent/host_agent/conversation_log.py @@ -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)