diff --git a/README.md b/README.md index 9d7a02b..7c27f6a 100644 --- a/README.md +++ b/README.md @@ -112,8 +112,9 @@ 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. +and final replies in a local SQLite database. View them at +`http://127.0.0.1:8765/conversations`; image bytes are excluded. Set +`HOST_AGENT_CONVERSATION_LOG_PATH` to change the database path. In local mode, Appium supervision is enabled by default. Host Agent probes `/status`, adopts a healthy existing Appium instance, starts Appium when no diff --git a/apps/device-host-agent/host_agent/app.py b/apps/device-host-agent/host_agent/app.py index 34eb49c..75fae71 100644 --- a/apps/device-host-agent/host_agent/app.py +++ b/apps/device-host-agent/host_agent/app.py @@ -276,6 +276,7 @@ def create_application( mcp_token_store=mcp_token_store, mcp_busy_tracker=mcp_busy_tracker, conversation_agent=conversation_agent, + conversation_log=conversation_log, ) console_server = _EmbeddedConsoleServer( uvicorn.Config( diff --git a/apps/device-host-agent/host_agent/config.py b/apps/device-host-agent/host_agent/config.py index d3597dc..089decc 100644 --- a/apps/device-host-agent/host_agent/config.py +++ b/apps/device-host-agent/host_agent/config.py @@ -49,7 +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") + conversation_log_path: Path = Path("host_agent_data/conversations.sqlite3") task_retention_max_count: int = 50 task_retention_max_age_days: int = 7 skill_sync_interval_seconds: float = 300.0 @@ -160,7 +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()), + conversation_log_path=Path(values.get("HOST_AGENT_CONVERSATION_LOG_PATH", "host_agent_data/conversations.sqlite3").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_log.py b/apps/device-host-agent/host_agent/conversation_log.py index 2fd2118..d6796ae 100644 --- a/apps/device-host-agent/host_agent/conversation_log.py +++ b/apps/device-host-agent/host_agent/conversation_log.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import sqlite3 from datetime import UTC, datetime from pathlib import Path from threading import Lock @@ -8,17 +9,35 @@ from typing import Any class ConversationLogStore: - """Append-only local audit log for chat and tool activity.""" + """SQLite-backed local audit log for chat and tool activity.""" def __init__(self, path: str | Path) -> None: self.path = Path(path) self._lock = Lock() + self.path.parent.mkdir(parents=True, exist_ok=True) + with self._connect() as connection: + connection.execute("CREATE TABLE IF NOT EXISTS conversation_events (id INTEGER PRIMARY KEY AUTOINCREMENT, occurred_at TEXT NOT NULL, event_type TEXT NOT NULL, payload_json TEXT NOT NULL)") 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") + with self._lock, self._connect() as connection: + connection.execute("INSERT INTO conversation_events (occurred_at, event_type, payload_json) VALUES (?, ?, ?)", (datetime.now(UTC).isoformat(), str(event.get("type") or "event"), json.dumps(_safe(event), ensure_ascii=True, default=str))) + + def list_recent(self, *, limit: int = 200) -> list[dict[str, Any]]: + with self._connect() as connection: + rows = connection.execute("SELECT id, occurred_at, event_type, payload_json FROM conversation_events ORDER BY id DESC LIMIT ?", (max(1, min(limit, 1000)),)).fetchall() + result = [] + for row in rows: + try: + payload = json.loads(row["payload_json"]) + except (TypeError, ValueError): + payload = {} + result.append({"id": row["id"], "occurred_at": row["occurred_at"], "event_type": row["event_type"], **payload}) + return result + + def _connect(self) -> sqlite3.Connection: + connection = sqlite3.connect(self.path) + connection.row_factory = sqlite3.Row + return connection def _safe(value: Any) -> Any: diff --git a/apps/device-host-agent/host_agent/web/app.py b/apps/device-host-agent/host_agent/web/app.py index 8544a54..06123ba 100644 --- a/apps/device-host-agent/host_agent/web/app.py +++ b/apps/device-host-agent/host_agent/web/app.py @@ -21,6 +21,7 @@ from host_agent.client import ( ) from host_agent.config import HostAgentConfig from host_agent.conversation import ConversationAgent +from host_agent.conversation_log import ConversationLogStore from host_agent.devices import register_local_device, unregister_local_device from host_agent.history import ConsoleHistoryStore from host_agent.identity import HostIdentityStore @@ -234,6 +235,7 @@ def create_console_app( mcp_token_store: McpTokenStore | None = None, mcp_busy_tracker: McpBusyTracker | None = None, conversation_agent: ConversationAgent | None = None, + conversation_log: ConversationLogStore | None = None, ) -> FastAPI: app = FastAPI(title="Host Agent Console") cookie_secure = config.console_bind_host not in _LOOPBACK_BIND_HOSTS @@ -605,6 +607,15 @@ def create_console_app( entries=entries, ) + @app.get("/conversations", response_class=HTMLResponse) + async def conversations_page( + session: SessionState = Depends(require_session), + ) -> HTMLResponse: + events = await asyncio.to_thread( + conversation_log.list_recent if conversation_log is not None else (lambda: []) + ) + return _render("conversations.html", title="Conversations", session=session, events=events) + def _tasks_list_context( session: SessionState, *, diff --git a/apps/device-host-agent/host_agent/web/templates/base.html b/apps/device-host-agent/host_agent/web/templates/base.html index 89facd6..1066421 100644 --- a/apps/device-host-agent/host_agent/web/templates/base.html +++ b/apps/device-host-agent/host_agent/web/templates/base.html @@ -26,6 +26,7 @@ form.inline { display: inline; margin: 0; } Tasks Account History + Conversations
diff --git a/apps/device-host-agent/host_agent/web/templates/conversations.html b/apps/device-host-agent/host_agent/web/templates/conversations.html new file mode 100644 index 0000000..6efe160 --- /dev/null +++ b/apps/device-host-agent/host_agent/web/templates/conversations.html @@ -0,0 +1,14 @@ +{% extends "base.html" %} +{% block body %} +

Conversations

+{% if not events %}

No conversation activity recorded yet.

{% endif %} +{% for event in events %} +
+

{{ event["event_type"] }} {{ event["occurred_at"] }}

+ {% if event.get("content") %}
{{ event["content"] }}
{% endif %} + {% if event.get("thinking") %}
LLM reasoning
{{ event["thinking"] }}
{% endif %} + {% if event.get("tool_calls") %}
Tool calls
{{ event["tool_calls"] | tojson(indent=2) }}
{% endif %} + {% if event.get("tool_name") %}

{{ event["tool_name"] }}

{{ event.get("arguments") | tojson(indent=2) }}
{{ event.get("result") | tojson(indent=2) }}
{% endif %} +
+{% endfor %} +{% endblock %}