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:
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user