From b9137e176e5f9c9fce4090622bc91ac5a9a187ba Mon Sep 17 00:00:00 2001 From: kyugao Date: Fri, 7 Aug 2026 13:35:27 +0800 Subject: [PATCH] Initialize Hermes LineUp adapter --- .gitignore | 5 + lineup/.env.example | 9 + lineup/README.md | 78 +++ lineup/__init__.py | 41 ++ lineup/acp_client.py | 422 ++++++++++++ lineup/core.py | 826 +++++++++++++++++++++++ lineup/execution_trace.py | 108 +++ lineup/plugin.yaml | 23 + lineup/protocol.py | 943 +++++++++++++++++++++++++++ lineup/run-hermes-adapter.sh | 52 ++ lineup/scripts/install.sh | 27 + lineup/state.py | 357 ++++++++++ lineup/tests/test_acp_client.py | 98 +++ lineup/tests/test_core.py | 406 ++++++++++++ lineup/tests/test_execution_trace.py | 37 ++ lineup/tests/test_protocol.py | 287 ++++++++ lineup/tests/test_state.py | 69 ++ 17 files changed, 3788 insertions(+) create mode 100644 .gitignore create mode 100644 lineup/.env.example create mode 100644 lineup/README.md create mode 100644 lineup/__init__.py create mode 100644 lineup/acp_client.py create mode 100644 lineup/core.py create mode 100644 lineup/execution_trace.py create mode 100644 lineup/plugin.yaml create mode 100644 lineup/protocol.py create mode 100755 lineup/run-hermes-adapter.sh create mode 100755 lineup/scripts/install.sh create mode 100644 lineup/state.py create mode 100644 lineup/tests/test_acp_client.py create mode 100644 lineup/tests/test_core.py create mode 100644 lineup/tests/test_execution_trace.py create mode 100644 lineup/tests/test_protocol.py create mode 100644 lineup/tests/test_state.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..101bf45 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +__pycache__/ +*.py[cod] +.pytest_cache/ +.venv/ +.env diff --git a/lineup/.env.example b/lineup/.env.example new file mode 100644 index 0000000..1694b6e --- /dev/null +++ b/lineup/.env.example @@ -0,0 +1,9 @@ +# Private runtime configuration for run-hermes-adapter.sh. +# Copy to .env, use the same shared secret as lineup-app-server/.env, and +# never commit either file. +LINEUP_APPSERVER_URL=http://127.0.0.1:8090 +LINEUP_AGENT_UID=agent_hermes_main +LINEUP_AGENT_CHANNEL_ID=agent_default_channel +LINEUP_CHANNEL_TYPE=2 +LINEUP_AGENT_SHARED_SECRET= +LINEUP_IGNORED_SENDER_UIDS=agent_default diff --git a/lineup/README.md b/lineup/README.md new file mode 100644 index 0000000..871a87e --- /dev/null +++ b/lineup/README.md @@ -0,0 +1,78 @@ +# LineUp Hermes Adapter + +This is the real Hermes integration. It uses the LineUp app server as its +transport boundary and invokes the +locally installed `hermes` CLI for each isolated LineUp conversation. + +## Install and enable + +```bash +cd lineup-adapter/hermes/lineup +./scripts/install.sh +hermes plugins enable lineup +``` + +The installer copies only this plugin directory into +`~/.hermes/plugins/lineup`; it never copies local state, logs, or credentials. + +## Configure and start + +Copy `.env.example` to a private `.env` file (the runner loads it) or set the +same values from a service configuration: + +```bash +export LINEUP_APPSERVER_URL=http://127.0.0.1:8090 +export LINEUP_AGENT_UID=agent_hermes_main +export LINEUP_AGENT_CHANNEL_ID=agent_default_channel +export LINEUP_AGENT_SHARED_SECRET='replace-with-a-random-secret' +./run-hermes-adapter.sh start +``` + +Set the same `LINEUP_AGENT_SHARED_SECRET` in `lineup-app-server/.env`. The +server maps it to `agent.shared_secret` and accepts the Adapter heartbeat only +through `X-LineUp-Agent-Secret`. + +The initial configuration uses the existing group channel. Restrict ignored +sender identities only when a separately deployed Adapter requires it. + +## Guarantees in this version + +- Input is consumed through `POST /messages/sync` on the LineUp app server, + not directly through WuKongIM's management API. +- Output is delivered through `POST /messages/send` on the app server. +- SQLite stores the received-message inbox, delivery outbox, and sequence + cursor. A restarted Adapter retries interrupted turns and unsent output. +- The first normal start advances to the current channel sequence and never + replays retained history into Hermes. Set `LINEUP_REPLAY_HISTORY=true` only + for an intentional controlled replay. +- Hermes sessions are named from a hash of the LineUp conversation and user; + the adapter derives that conversation from its configured channel and the + transport sender, so a client-supplied envelope `conversation_id` cannot + switch a user's `--continue` context. +- A newer message from the same user terminates that user's previous local + Hermes subprocess. Timeout is controlled by `LINEUP_HERMES_TIMEOUT_SECONDS`. +- All new outgoing payloads use the `lineup.v1.*` envelope. The input decoder + retains a narrow compatibility bridge for previously stored legacy messages. +- Standard M1 calls are strictly limited to `lineup.v1.tool.call` with + `choice`, `confirm`, or `input` declarations. Every call has a future + `expires_at`, is canonicalized before delivery, and is recorded by + `(conversation_id, call_id)` in SQLite before it is sent. +- `lineup.v1.tool.result` and `lineup.v1.tool.cancel` are consumed at most + once for that durable key. Duplicate, unknown, malformed, and expired + responses do not start another Hermes turn. The audit ledger records only + call id, conversation, event, disposition, and time; it never stores form + values. Hermes receives only the schema-bound result values it needs, not + client envelope metadata, labels, DOM/UI configuration, or audit data. +- This Adapter does not authorize `ui.*`, `app.*`, Surface, capability, or + bundle messages. Those boundaries remain future Milestones. + +## Operations + +```bash +./run-hermes-adapter.sh check +./run-hermes-adapter.sh start +./run-hermes-adapter.sh status +./run-hermes-adapter.sh stop +``` + +The retired JavaScript demo Agent is not part of this repository or runtime. diff --git a/lineup/__init__.py b/lineup/__init__.py new file mode 100644 index 0000000..ca10fa9 --- /dev/null +++ b/lineup/__init__.py @@ -0,0 +1,41 @@ +"""Hermes plugin entry point for the LineUp adapter. + +The transport worker deliberately runs out of process (``core.py``). Hermes +loads this module for every normal session, including the child one-shot +processes launched by the worker. Starting a polling thread unconditionally +here would create a worker per reply, so auto-start is explicit and child +processes set ``LINEUP_ADAPTER_CHILD=1``. +""" + +from __future__ import annotations + +import os + + +def _status_command(_raw_args: str = "") -> str: + from .core import AdapterConfig, StateStore + + config = AdapterConfig.from_env() + store = StateStore(config.state_path) + try: + return store.status_summary() + finally: + store.close() + + +def register(ctx) -> None: + """Register a lightweight in-session status command and optional worker.""" + ctx.register_command( + "lineup-status", + _status_command, + description="Show LineUp Adapter delivery and queue state.", + ) + + if ( + os.environ.get("LINEUP_ADAPTER_AUTOSTART", "").lower() in {"1", "true", "yes"} + and os.environ.get("LINEUP_ADAPTER_CHILD") != "1" + ): + from .core import start_background + + start_background() + diff --git a/lineup/acp_client.py b/lineup/acp_client.py new file mode 100644 index 0000000..3b2946c --- /dev/null +++ b/lineup/acp_client.py @@ -0,0 +1,422 @@ +"""Small, dependency-free ACP client for the LineUp Hermes Adapter. + +Hermes owns the ACP server. This module is intentionally only its host-side +transport: it preserves real tool lifecycle notifications and never derives +activity from model text, status text, or Hermes' later-written SQLite ledger. +""" + +from __future__ import annotations + +import json +import logging +import os +import queue +import subprocess +import threading +import time +import uuid +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Callable, Mapping + + +LOG = logging.getLogger("lineup.adapter.acp") +_ID = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_.:-" + + +class ACPError(RuntimeError): + """A failed, malformed, or unavailable ACP request.""" + + +@dataclass(frozen=True) +class ACPToolUpdate: + """A privacy-filtered, display-safe real tool lifecycle event.""" + + tool_id: str + title: str + status: str # running | completed | failed + + +@dataclass(frozen=True) +class ACPInteractiveOption: + """One user-visible option projected from an ACP interactive request.""" + + action_id: str + option_id: str + label: str + + +@dataclass(frozen=True) +class ACPInteractiveRequest: + """A bounded Hermes interactive request ready for Adapter-side projection.""" + + kind: str + request_id: int + session_id: str + title: str + prompt: str + options: tuple[ACPInteractiveOption, ...] = () + allow_other: bool = False + expects_text: bool = False + multi_select: bool = False + + +# Backward-compatible aliases while the rest of the adapter migrates from the +# initial permission-only bridge to the generic interaction bridge. +ACPPermissionOption = ACPInteractiveOption +ACPPermissionRequest = ACPInteractiveRequest + + +_PUBLIC_LABELS_BY_KIND = { + "read": "读取相关配置", + "search": "检索相关文件", + "fetch": "读取公开网页内容", + "execute": "执行已批准的系统检查", +} + + +def _is_identifier(value: object) -> bool: + return ( + isinstance(value, str) + and bool(value) + and len(value) <= 128 + and value[0].isalpha() + and all(char in _ID for char in value) + ) + + +def public_tool_update( + update: object, known_titles: Mapping[str, str] | None = None, +) -> ACPToolUpdate | None: + """Map a real ACP tool event to a bounded public label. + + ACP titles, content, raw output and arguments may contain commands, paths, + URLs, credentials, or model-provided text. None is trusted here. The + only inspected values are the ACP's closed tool-kind enum and a previously + stored fixed product label. Hermes deliberately omits ``kind`` from a + completion event, so its tool-call ID may only look up that earlier fixed + label; unknown tools have no public execution step. + """ + if not isinstance(update, dict): + return None + kind = update.get("sessionUpdate") + if kind not in {"tool_call", "tool_call_update"}: + return None + tool_id = update.get("toolCallId") + if (not isinstance(tool_id, str) or not tool_id or len(tool_id) > 128 + or tool_id[0] not in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" + or any(char not in _ID for char in tool_id)): + return None + if kind == "tool_call": + tool_kind = update.get("kind") + if not isinstance(tool_kind, str): + return None + label = _PUBLIC_LABELS_BY_KIND.get(tool_kind) + return ACPToolUpdate(tool_id, label, "running") if label else None + + status = update.get("status") + if status == "completed": + projected_status = "completed" + elif status == "failed": + projected_status = "failed" + else: + return None + label = known_titles.get(tool_id) if known_titles else None + return ACPToolUpdate(tool_id, label, projected_status) if label in _PUBLIC_LABELS_BY_KIND.values() else None + + +class HermesACPClient: + """Thread-safe ACP stdio client with one persistent Hermes process. + + Requests are synchronous from the Adapter's polling loop, while prompt + execution is started in a separate thread so ``session/update`` tool + notifications can be forwarded immediately. The class has no dependency + on the ACP Python package: the installed ``hermes acp`` command owns that + dependency and stdout remains newline-delimited JSON-RPC. + """ + + def __init__( + self, + hermes_bin: str, + cwd: str, + on_update: Callable[[dict[str, Any]], None], + on_request: Callable[[ACPInteractiveRequest], None], + on_closed: Callable[[Exception | None], None], + ) -> None: + self.hermes_bin = hermes_bin + self.cwd = str(Path(cwd).resolve()) + self.on_update = on_update + self.on_request = on_request + self.on_closed = on_closed + self.process: subprocess.Popen[str] | None = None + self.session_id: str | None = None + self._next_id = 1 + self._pending: dict[int, queue.Queue[dict[str, Any]]] = {} + self._interactive: dict[int, queue.Queue[str | None]] = {} + self._lock = threading.RLock() + self._write_lock = threading.Lock() + self._reader: threading.Thread | None = None + self._closed = False + + def start(self) -> None: + with self._lock: + if self.process and self.process.poll() is None: + return + env = os.environ.copy() + env["LINEUP_ADAPTER_CHILD"] = "1" + self.process = subprocess.Popen( + [self.hermes_bin, "acp"], cwd=self.cwd, env=env, text=True, + stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + bufsize=1, + ) + self._closed = False + self._reader = threading.Thread(target=self._read_loop, name="lineup-hermes-acp", daemon=True) + self._reader.start() + initialized = self.request("initialize", { + "protocolVersion": 1, + "clientCapabilities": {"fs": {"readTextFile": False, "writeTextFile": False}, "terminal": False, "auth": {"terminal": False}}, + "clientInfo": {"name": "lineup-hermes-adapter", "version": "1"}, + }) + if not isinstance(initialized, dict): + raise ACPError("Hermes ACP returned an invalid initialize response") + + def open_session(self) -> str: + self.start() + if self.session_id: + return self.session_id + response = self.request("session/new", {"cwd": self.cwd, "mcpServers": []}) + session_id = response.get("sessionId") if isinstance(response, dict) else None + if not isinstance(session_id, str) or not session_id: + raise ACPError("Hermes ACP did not return a session id") + self.session_id = session_id + return session_id + + def prompt_async(self, text: str, on_done: Callable[[dict[str, Any] | None, Exception | None], None]) -> None: + session_id = self.session_id or self.open_session() + def run() -> None: + try: + result = self.request("session/prompt", { + "sessionId": session_id, + "messageId": str(uuid.uuid4()), + "prompt": [{"type": "text", "text": text}], + }, timeout=1_800.0) + except Exception as exc: # callback creates the user-safe terminal outcome + on_done(None, exc) + else: + on_done(result if isinstance(result, dict) else None, None) + threading.Thread(target=run, name="lineup-hermes-acp-prompt", daemon=True).start() + + def request(self, method: str, params: dict[str, Any], timeout: float = 30.0) -> dict[str, Any]: + with self._lock: + process = self.process + if not process or process.poll() is not None or not process.stdin: + raise ACPError("Hermes ACP is not running") + request_id = self._next_id + self._next_id += 1 + result_queue: queue.Queue[dict[str, Any]] = queue.Queue(maxsize=1) + self._pending[request_id] = result_queue + self._send({"jsonrpc": "2.0", "id": request_id, "method": method, "params": params}) + try: + response = result_queue.get(timeout=timeout) + except queue.Empty as exc: + with self._lock: + self._pending.pop(request_id, None) + raise ACPError(f"Hermes ACP request timed out: {method}") from exc + if "error" in response: + error = response.get("error") + message = error.get("message") if isinstance(error, dict) else "unknown ACP error" + raise ACPError(f"Hermes ACP {method} failed: {message}") + result = response.get("result") + return result if isinstance(result, dict) else {} + + def close(self) -> None: + with self._lock: + self._closed = True + process = self.process + self.process = None + self.session_id = None + pending = list(self._pending.values()) + self._pending.clear() + interactive = list(self._interactive.values()) + self._interactive.clear() + for response in pending: + response.put({"error": {"message": "ACP client closed"}}) + for response in interactive: + response.put(None) + if process and process.poll() is None: + process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + + def _send(self, message: dict[str, Any]) -> None: + encoded = json.dumps(message, ensure_ascii=False, separators=(",", ":")) + "\n" + with self._write_lock: + process = self.process + if not process or not process.stdin: + raise ACPError("Hermes ACP stdin is unavailable") + try: + process.stdin.write(encoded) + process.stdin.flush() + except OSError as exc: + raise ACPError("Unable to write to Hermes ACP") from exc + + def _read_loop(self) -> None: + error: Exception | None = None + try: + process = self.process + if not process or not process.stdout: + return + for line in process.stdout: + try: + message = json.loads(line) + except (TypeError, ValueError): + LOG.warning("discarded malformed Hermes ACP frame") + continue + if not isinstance(message, dict): + continue + if "id" in message and "method" not in message: + self._resolve_response(message) + elif message.get("method") == "session/update": + params = message.get("params") + if isinstance(params, dict): + # Keep a diagnostic signal for the M1 execution-summary + # bridge without recording ACP tool titles, arguments, + # paths, commands, outputs, or model content. This + # distinguishes an absent lifecycle event from an + # unrecognised event shape in production safely. + update = params.get("update") + if isinstance(update, dict) and update.get("sessionUpdate") in {"tool_call", "tool_call_update"}: + status = update.get("status") + safe_status = status if status in {"pending", "in_progress", "completed", "failed"} else "other" + raw_input = update.get("rawInput") + safe_update_keys = sorted( + key for key in update + if isinstance(key, str) and len(key) <= 48 and key.replace("_", "").isalnum() + )[:16] + safe_raw_input_keys = sorted( + key for key in raw_input + if isinstance(raw_input, dict) and isinstance(key, str) and len(key) <= 48 and key.replace("_", "").isalnum() + )[:16] if isinstance(raw_input, dict) else [] + LOG.info( + "ACP tool lifecycle event=%s status=%s status_type=%s public_projection=%s update_keys=%s raw_input_type=%s raw_input_keys=%s", + update["sessionUpdate"], safe_status, type(status).__name__, public_tool_update(update) is not None, + safe_update_keys, type(raw_input).__name__, safe_raw_input_keys, + ) + self.on_update(params) + elif "id" in message and "method" in message: + self._handle_server_request(message) + except Exception as exc: + error = exc + LOG.exception("Hermes ACP reader stopped") + finally: + if not self._closed: + self.on_closed(error or ACPError("Hermes ACP exited")) + + def _resolve_response(self, message: dict[str, Any]) -> None: + request_id = message.get("id") + if not isinstance(request_id, int): + return + with self._lock: + pending = self._pending.pop(request_id, None) + if pending: + pending.put(message) + + def respond_permission(self, request_id: int, option_id: str | None) -> bool: + with self._lock: + pending = self._interactive.get(request_id) + if not pending: + return False + pending.put(option_id) + return True + + def respond_interaction(self, request_id: int, outcome: str | None) -> bool: + return self.respond_permission(request_id, outcome) + + @staticmethod + def _parse_permission_request(message: dict[str, Any]) -> ACPInteractiveRequest | None: + request_id = message.get("id") + params = message.get("params") + if not isinstance(request_id, int) or not isinstance(params, dict): + return None + session_id = params.get("sessionId") + tool_call = params.get("toolCall") + options = params.get("options") + if not isinstance(session_id, str) or not session_id or not isinstance(tool_call, dict) or not isinstance(options, list): + return None + title = tool_call.get("title") if isinstance(tool_call.get("title"), str) else "" + if len(title) > 200: + title = title[:200] + prompt = title + content = tool_call.get("content") + if isinstance(content, list): + for block in content: + if not isinstance(block, dict): + continue + content_block = block.get("content") + if isinstance(content_block, dict) and content_block.get("type") == "text" and isinstance(content_block.get("text"), str): + prompt = content_block["text"][:2_000] + break + if not prompt: + prompt = "Hermes 请求你确认是否允许本次操作。" + projected: list[ACPInteractiveOption] = [] + seen_action_ids: set[str] = set() + seen_option_ids: set[str] = set() + for index, item in enumerate(options, start=1): + if not isinstance(item, dict): + return None + option_id = item.get("optionId") + label = item.get("name") + if not _is_identifier(option_id) or option_id in seen_option_ids: + return None + if not isinstance(label, str) or not label.strip() or len(label.strip()) > 200: + return None + action_id = f"action_{index:02d}" + if action_id in seen_action_ids: + return None + seen_action_ids.add(action_id) + seen_option_ids.add(option_id) + projected.append(ACPInteractiveOption(action_id=action_id, option_id=option_id, label=label.strip())) + if not projected: + return None + return ACPInteractiveRequest( + kind="exec_approval", + request_id=request_id, + session_id=session_id, + title=title or "Hermes 权限请求", + prompt=prompt, + options=tuple(projected), + ) + + def _handle_server_request(self, message: dict[str, Any]) -> None: + request_id = message.get("id") + if not isinstance(request_id, int): + return + if message.get("method") == "session/request_permission": + request = self._parse_permission_request(message) + if request is None: + self._send({"jsonrpc": "2.0", "id": request_id, "result": {"outcome": {"outcome": "cancelled"}}}) + return + pending: queue.Queue[str | None] = queue.Queue(maxsize=1) + with self._lock: + self._interactive[request_id] = pending + try: + self.on_request(request) + try: + selected = pending.get(timeout=900.0) + except queue.Empty: + selected = None + finally: + with self._lock: + self._interactive.pop(request_id, None) + if isinstance(selected, str): + self._send({ + "jsonrpc": "2.0", + "id": request_id, + "result": {"outcome": {"optionId": selected, "outcome": "selected"}}, + }) + else: + self._send({"jsonrpc": "2.0", "id": request_id, "result": {"outcome": {"outcome": "cancelled"}}}) + return + self._send({"jsonrpc": "2.0", "id": request_id, "error": {"code": -32601, "message": "Unsupported ACP client method"}}) diff --git a/lineup/core.py b/lineup/core.py new file mode 100644 index 0000000..98b9c53 --- /dev/null +++ b/lineup/core.py @@ -0,0 +1,826 @@ +"""Supervised bridge between LineUp app server messages and real Hermes CLI.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import logging +import os +import signal +import uuid +import subprocess +import sys +import threading +import queue +import time +import urllib.error +import urllib.request +from dataclasses import dataclass, field +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from .protocol import ( + TOOL_CALL, + agent_output_message, + encode, + envelope, + execution_message, + error_message, + extract_user_input, + parse_client_inventory, + parse_agent_tool_invoke, + parse_agent_tool_call, + parse_agent_app_call, + parse_app_result, + parse_tool_response, + status_message, + tool_result_message, + validate_tool_response, +) +from .acp_client import ACPInteractiveRequest, ACPToolUpdate, HermesACPClient, public_tool_update +from .state import StateStore + +LOG = logging.getLogger("lineup.adapter") +_BACKGROUND: threading.Thread | None = None + + +@dataclass(frozen=True) +class AdapterConfig: + appserver_url: str + agent_uid: str + channel_id: str + channel_type: int + poll_interval: float + request_timeout: float + hermes_timeout: float + hermes_bin: str + hermes_cwd: str + state_path: str + hermes_state_path: str + agent_secret: str + ignored_senders: frozenset[str] + replay_history: bool + + @classmethod + def from_env(cls) -> "AdapterConfig": + root = Path(__file__).resolve().parent + return cls( + appserver_url=os.environ.get("LINEUP_APPSERVER_URL", "http://127.0.0.1:8090").rstrip("/"), + agent_uid=os.environ.get("LINEUP_AGENT_UID", "agent_hermes_main"), + channel_id=os.environ.get("LINEUP_AGENT_CHANNEL_ID", "agent_default_channel"), + channel_type=int(os.environ.get("LINEUP_CHANNEL_TYPE", "2")), + poll_interval=float(os.environ.get("LINEUP_POLL_INTERVAL", "2")), + request_timeout=float(os.environ.get("LINEUP_REQUEST_TIMEOUT_SECONDS", "10")), + hermes_timeout=float(os.environ.get("LINEUP_HERMES_TIMEOUT_SECONDS", "120")), + hermes_bin=os.environ.get("LINEUP_HERMES_BIN", "hermes"), + hermes_cwd=os.environ.get("LINEUP_HERMES_CWD", str(root)), + state_path=os.environ.get("LINEUP_STATE_PATH", str(root / "state.sqlite3")), + hermes_state_path=os.environ.get("LINEUP_HERMES_STATE_PATH", str(Path.home() / ".hermes" / "state.db")), + agent_secret=os.environ.get("LINEUP_AGENT_SHARED_SECRET", ""), + ignored_senders=frozenset( + value.strip() + for value in os.environ.get("LINEUP_IGNORED_SENDER_UIDS", "agent_default").split(",") + if value.strip() + ), + replay_history=os.environ.get("LINEUP_REPLAY_HISTORY", "").lower() in {"1", "true", "yes"}, + ) + + +class ApiError(RuntimeError): + pass + + +class LineUpApi: + def __init__(self, config: AdapterConfig): + self.config = config + + def request(self, method: str, path: str, body: dict[str, Any] | None = None, headers: dict[str, str] | None = None) -> Any: + data = json.dumps(body, ensure_ascii=False).encode() if body is not None else None + request = urllib.request.Request( + self.config.appserver_url + path, + data=data, + method=method, + headers={"Content-Type": "application/json", **(headers or {})}, + ) + try: + with urllib.request.urlopen(request, timeout=self.config.request_timeout) as response: + raw = response.read().decode("utf-8") + except urllib.error.HTTPError as exc: + raise ApiError(f"{method} {path}: HTTP {exc.code}: {exc.read().decode('utf-8', 'replace')[:500]}") from exc + except OSError as exc: + raise ApiError(f"{method} {path}: {exc}") from exc + try: + return json.loads(raw) if raw else {} + except ValueError as exc: + raise ApiError(f"{method} {path}: invalid JSON response") from exc + + def sync(self, start_message_seq: int) -> list[dict[str, Any]]: + response = self.request( + "POST", + "/messages/sync", + { + "login_uid": self.config.agent_uid, + "channel_id": self.config.channel_id, + "channel_type": self.config.channel_type, + "start_message_seq": start_message_seq + 1 if start_message_seq else 0, + "limit": 100, + }, + ) + messages = response.get("messages", []) + return messages if isinstance(messages, list) else [] + + def send(self, payload: str) -> None: + self.request( + "POST", + "/messages/send", + { + "from_uid": self.config.agent_uid, + "channel_id": self.config.channel_id, + "channel_type": self.config.channel_type, + "payload": payload, + }, + ) + + def heartbeat(self, status: str, last_message_seq: int, detail: str = "") -> None: + if not self.config.agent_secret: + return + self.request( + "POST", + f"/agents/{self.config.agent_uid}/heartbeat", + {"status": status, "last_message_seq": last_message_seq, "detail": detail}, + {"X-LineUp-Agent-Secret": self.config.agent_secret}, + ) + + +@dataclass +class RunningTurn: + message_id: str + sender_uid: str + conversation_id: str + process: subprocess.Popen[str] | None + started_at: float + execution_id: str + trace_marker: str + acp: HermesACPClient | None = None + reply_parts: list[str] = field(default_factory=list) + acp_steps: dict[str, dict[str, str]] = field(default_factory=dict) + acp_done: bool = False + acp_error: Exception | None = None + + +class LineUpAdapter: + def __init__(self, config: AdapterConfig | None = None): + self.config = config or AdapterConfig.from_env() + self.api = LineUpApi(self.config) + self.store = StateStore(self.config.state_path) + self.store.recover_interrupted_turns() + self.store.recover_pending_hermes_interactions() + self.running: dict[str, RunningTurn] = {} + self.acp_clients: dict[str, HermesACPClient] = {} + self.acp_events: queue.Queue[tuple[str, object, object | None]] = queue.Queue() + self.client_inventories: dict[str, dict[str, Any]] = {} + self.stop_requested = False + + def close(self) -> None: + for running in list(self.running.values()): + if running.acp: + running.acp.close() + elif running.process: + self._terminate(running.process) + for client in self.acp_clients.values(): + client.close() + self.acp_clients.clear() + self.store.close() + + def _send_or_queue(self, payload: str) -> None: + try: + self.api.send(payload) + except ApiError as exc: + LOG.warning("message delivery failed; queued for retry: %s", exc) + self.store.enqueue_outbox(payload) + + def _flush_outbox(self) -> None: + for item in self.store.outbox_items(): + try: + self.api.send(item["payload"]) + except ApiError as exc: + self.store.mark_outbox_failed(item["id"], str(exc)) + break + else: + self.store.mark_outbox_sent(item["id"]) + + def _conversation_id(self, sender_uid: str, _supplied: str | None = None) -> str: + """Return an adapter-owned session key, never a client-claimed one. + + The current AppServer group transport does not expose a separately + authenticated conversation object. Binding to configured channel plus + transport sender prevents a payload from switching Hermes to another + user's ``--continue`` session. A future server-authenticated + conversation field can be added here explicitly, rather than trusting + an envelope field by default. + """ + return f"lineup:{self.config.channel_type}:{self.config.channel_id}:{sender_uid}" + + def _session_name(self, conversation_id: str) -> str: + digest = hashlib.sha256(conversation_id.encode()).hexdigest()[:20] + return f"lineup-{digest}" + + def _start_turn(self, row: Any) -> None: + sender_uid = row["from_uid"] + text, supplied_conversation = extract_user_input(row["payload"]) + conversation_id = self._conversation_id(sender_uid, supplied_conversation) + inventory = parse_client_inventory(row["payload"]) + if inventory is not None: + # Inventory announces the host's current request surface. It is + # deliberately not a user prompt and must never itself create an + # Agent reply, capability call, or authorization decision. + self.client_inventories[conversation_id] = inventory + self.store.set_inbox_state(row["message_id"], "done", "client inventory updated") + return + app_result = parse_app_result(row["payload"]) + if app_result is not None: + # This is a host-normalized outcome only. It contains neither the + # original capability arguments nor native error detail, so the + # model cannot recover clipboard text, URLs, paths or Artifact + # data from the completion notification. + disposition = self.store.consume_app_result(conversation_id, app_result["call_id"], app_result["status"]) + if disposition != "accepted": + self.store.set_inbox_state(row["message_id"], "done", f"app result {disposition}") + return + text = "LineUp app capability result:\n" + json.dumps(app_result, ensure_ascii=False, separators=(",", ":")) + response = parse_tool_response(row["payload"]) + if response: + interaction = self.store.hermes_interaction(conversation_id, response.call_id) + if interaction: + call = self.store.tool_call(conversation_id, response.call_id) + if not call: + self.store.set_inbox_state(row["message_id"], "done", "unknown hermes interaction") + return + try: + schema = json.loads(call["response_schema"]) + except (TypeError, ValueError): + schema = None + model_result = validate_tool_response(response, schema) if isinstance(schema, dict) else None + if not model_result: + self.store.record_tool_audit(conversation_id, response.call_id, response.kind, "rejected") + self.store.set_inbox_state(row["message_id"], "done", "invalid hermes interaction response") + return + disposition = self.store.consume_tool_response(conversation_id, response.call_id, response.kind) + if disposition == "expired": + self._send_or_queue(tool_result_message( + response.call_id, "expired", conversation_id=conversation_id, + sender_id=self.config.agent_uid, target_id=sender_uid, + error="该交互已过期。", + )) + self.store.set_inbox_state(row["message_id"], "done", "tool response expired") + return + if disposition != "accepted": + self.store.set_inbox_state(row["message_id"], "done", f"tool response {disposition}") + return + if response.kind == "result": + self._send_or_queue(tool_result_message( + response.call_id, response.status or "completed", + conversation_id=conversation_id, sender_id=self.config.agent_uid, target_id=sender_uid, + )) + self._resolve_hermes_interaction(conversation_id, response.call_id, model_result) + self.store.set_inbox_state(row["message_id"], "done") + return + call = self.store.tool_call(conversation_id, response.call_id) + if not call: + self.store.record_tool_audit(conversation_id, response.call_id, response.kind, "rejected") + self.store.set_inbox_state(row["message_id"], "done", "unknown tool call") + return + try: + schema = json.loads(call["response_schema"]) + except (TypeError, ValueError): + schema = None + model_result = validate_tool_response(response, schema) if isinstance(schema, dict) else None + if not model_result: + self.store.record_tool_audit(conversation_id, response.call_id, response.kind, "rejected") + self.store.set_inbox_state(row["message_id"], "done", "invalid tool response") + return + disposition = self.store.consume_tool_response(conversation_id, response.call_id, response.kind) + if disposition == "expired": + self._send_or_queue(tool_result_message( + response.call_id, "expired", conversation_id=conversation_id, + sender_id=self.config.agent_uid, target_id=sender_uid, + error="该交互已过期。", + )) + self.store.set_inbox_state(row["message_id"], "done", "tool response expired") + return + if disposition != "accepted": + self.store.set_inbox_state(row["message_id"], "done", f"tool response {disposition}") + return + if response.kind == "result": + # The user's transport echo is deliberately not re-ingested by + # the client. Return this adapter-authoritative terminal + # projection so the original trusted card leaves `submitted` + # and refresh restores a definitive completed state. + self._send_or_queue(tool_result_message( + response.call_id, response.status or "completed", + conversation_id=conversation_id, sender_id=self.config.agent_uid, target_id=sender_uid, + )) + # This is the only form in which user interaction reaches Hermes: + # no envelope metadata, labels, DOM/UI schema, or audit data. + text = "LineUp standard interaction result:\n" + json.dumps(model_result, ensure_ascii=False, separators=(",", ":")) + if not text.strip(): + self.store.set_inbox_state(row["message_id"], "done") + return + previous = self.running.get(sender_uid) + if previous: + if previous.acp: + previous.acp.close() + elif previous.process: + self._terminate(previous.process) + self.store.set_inbox_state(previous.message_id, "cancelled", "superseded by a newer user message") + + current_utc = datetime.now(timezone.utc).isoformat() + execution_id = f"exec_{uuid.uuid4().hex}" + trace_marker = f"lineup-turn:{execution_id}" + inventory_context = json.dumps(self.client_inventories.get(conversation_id, { + "revision": "none", "standard_components": [], "applications": [], "tools": [], "capabilities": [], + }), ensure_ascii=False, separators=(",", ":")) + prompt = ( + "You are Hermes replying through LineUp. Reply directly and concisely to the user. " + "Do not claim to have sent a message yourself; your final text will be delivered by LineUp.\n\n" + "Default to Markdown. If a standard user interaction is essential, you may instead return exactly one valid " + "JSON object with v=1, type='lineup.v1.tool.call', and a payload for exactly one of: choice, confirm, input. " + "Every tool call needs a new call_id, a future ISO-8601 expires_at, title, prompt, and only its declaration: " + "choice data MUST be exactly {\"action_group\":{\"mode\":\"single-choice\",\"actions\":[{\"id\":\"staging\",\"label\":\"Staging\"}]}} " + "(or mode button with the same nested actions array); multi-choice is not supported here and must never be used. " + "Do not make action_group a string or put actions beside it. " + "confirm data MUST be {\"confirm\":{\"approve_label\":\"确认\",\"cancel_label\":\"取消\"}}. " + "input data MUST be {\"form\":{\"fields\":[{\"id\":\"version\",\"label\":\"Version\",\"type\":\"text\",\"required\":true,\"placeholder\":\"v1.2.3\",\"pattern\":\"^v\\\\d+\\\\.\\\\d+\\\\.\\\\d+$\"}],\"submit_label\":\"提交\",\"cancel_label\":\"取消\"}}. " + "form is an object, fields is its 1-8 item array, and every field uses id (never name). " + "All tool fields MUST be nested in the outer payload object. The only allowed shape is " + "{\"v\":1,\"type\":\"lineup.v1.tool.call\",\"payload\":{\"call_id\":\"call_...\",\"tool\":\"choice\",\"title\":\"...\",\"prompt\":\"...\",\"expires_at\":\"...\",\"data\":{...}}}. " + "Do not put call_id, tool, title, prompt, expires_at, or data beside type at the outer level.\n\n" + "For Runtime Agent Tools you may instead return exactly one JSON object with v=1, type='lineup.v1.tool.invoke'. " + "Use this only for a Tool that appears in the current inventory tools list. The only allowed shape is " + "{\"v\":1,\"type\":\"lineup.v1.tool.invoke\",\"payload\":{\"call_id\":\"call_...\",\"inventory_revision\":\"inv_...\",\"app_scope\":\"pomodoro\",\"tool_id\":\"pomodoro.start\",\"input\":{}}}. " + "Do not invent instance_id, operation_id, app_session_id, sender, target, conversation_id, or any other Runtime-internal field. " + "Use the exact latest inventory revision from the host inventory.\n\n" + f"The current UTC time is {current_utc}. expires_at must be a real ISO-8601 instant at least five minutes after that time; do not guess an earlier date or time.\n\n" + "When the user explicitly asks to open or update the task dashboard, you may instead return exactly one JSON object " + "for the host-local Surface only: ui.open payload is {instance_id,app:{app_id:'lineup.task-dashboard',version:'0.1.0'},state}; " + "ui.patch is {instance_id,state}; ui.close is {instance_id}. State is JSON only and may contain title, percent, status, steps, logs. " + "Never include bundle, URL, HTML, CSS, JavaScript, source, or any extra field. Do not emit any other ui.* message, " + "sender/target metadata, or a tool.result/tool.cancel/app.result envelope.\n\n" + "The following is a host-provided client inventory, not a grant of authority. You may only request a capability which is listed " + "there; never claim it was executed. If the user explicitly asks for one declared capability, you may instead return exactly one " + "lineup.v1.app.call JSON object with payload containing exactly call_id, capability, reason, expires_at and arguments. reason is a " + "short intent summary only and MUST NOT repeat a URL, clipboard text, file path, Artifact id, or any argument value. expires_at " + "must be at least five minutes in the future. arguments must be exactly {url} for app.open_url (https URL only), {text} for " + "clipboard.write, {} for device.pick_file, or {artifact_id} for artifact.save. The host will always ask the user to allow it once " + "and may reject it; a Surface can never bypass this native confirmation.\n\n" + "For a small generated Artifact you may instead return exactly one lineup.v1.artifact.offer JSON object. Its payload may contain only " + "artifact_id, name, mime_type, size_bytes, integrity, created_at, local_ref and optional content_base64. content_base64 is allowed only " + "for a small body with exact size_bytes and an opaque local_ref; never use URL, path, HTML, script, or source fields.\n\n" + f"{inventory_context}\n\n" + f"[Internal LineUp turn marker: {trace_marker}. Do not mention this marker in your response.]\n\n" + f"LineUp user ({sender_uid}) says:\n{text}" + ) + client = self.acp_clients.get(conversation_id) + if client is None: + client = HermesACPClient( + self.config.hermes_bin, + self.config.hermes_cwd, + on_update=lambda params: self.acp_events.put(("update", params, None)), + on_request=lambda request: self.acp_events.put(("request", request, client)), + on_closed=lambda error: self.acp_events.put(("closed", client, error)), + ) + self.acp_clients[conversation_id] = client + try: + client.open_session() + except (OSError, RuntimeError) as exc: + self.store.set_inbox_state(row["message_id"], "failed", str(exc)) + self._send_or_queue(error_message("hermes_start_failed", "无法启动 Hermes。", conversation_id=conversation_id, sender_id=self.config.agent_uid, target_id=sender_uid)) + return + running = RunningTurn(row["message_id"], sender_uid, conversation_id, None, time.monotonic(), execution_id, trace_marker, acp=client) + self.running[sender_uid] = running + self.store.set_inbox_state(row["message_id"], "running") + self._send_or_queue(status_message("thinking", conversation_id=conversation_id, sender_id=self.config.agent_uid, target_id=sender_uid, execution_id=execution_id)) + def done(result: dict[str, Any] | None, error: Exception | None) -> None: + self.acp_events.put(("done", running, error)) + client.prompt_async(prompt, done) + + @staticmethod + def _terminate(process: subprocess.Popen[str]) -> None: + if process.poll() is None: + process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + + def _flush_acp_events(self) -> None: + """Apply ACP notifications on the Adapter thread, not the reader thread.""" + while True: + try: + kind, subject, detail = self.acp_events.get_nowait() + except queue.Empty: + return + if kind == "done" and isinstance(subject, RunningTurn): + subject.acp_done = True + subject.acp_error = detail if isinstance(detail, Exception) else None + continue + if kind == "closed" and isinstance(subject, HermesACPClient): + for running in self.running.values(): + if running.acp is subject and not running.acp_done: + running.acp_done = True + running.acp_error = detail if isinstance(detail, Exception) else RuntimeError("Hermes ACP exited") + continue + if ( + kind == "request" + and isinstance(subject, ACPInteractiveRequest) + and detail is not None + and hasattr(detail, "session_id") + and (hasattr(detail, "respond_interaction") or hasattr(detail, "respond_permission")) + ): + running = next((item for item in self.running.values() if item.acp is detail and detail.session_id == subject.session_id), None) + if running: + self._emit_interaction_request(running, detail, subject) + else: + self._respond_interaction(detail, subject.request_id, None) + continue + if kind != "update" or not isinstance(subject, dict): + continue + session_id = subject.get("sessionId") + update = subject.get("update") + running = next((item for item in self.running.values() if item.acp and item.acp.session_id == session_id), None) + if not running or not isinstance(update, dict): + continue + public = public_tool_update( + update, + {tool_id: step["title"] for tool_id, step in running.acp_steps.items()}, + ) + if public: + self._apply_acp_tool_update(running, public) + continue + if update.get("sessionUpdate") == "agent_message_chunk": + content = update.get("content") + if isinstance(content, dict) and content.get("type") == "text" and isinstance(content.get("text"), str): + running.reply_parts.append(content["text"]) + + def _apply_acp_tool_update(self, running: RunningTurn, update: ACPToolUpdate) -> None: + step = running.acp_steps.get(update.tool_id) + if step is None: + step = {"id": f"step_{len(running.acp_steps) + 1:02d}", "title": update.title, "status": update.status} + running.acp_steps[update.tool_id] = step + else: + step["status"] = update.status + self._send_or_queue(execution_message( + running.execution_id, "running", list(running.acp_steps.values()), + conversation_id=running.conversation_id, sender_id=self.config.agent_uid, target_id=running.sender_uid, + )) + + @staticmethod + def _respond_interaction(client: Any, request_id: int, outcome: str | None) -> bool: + responder = getattr(client, "respond_interaction", None) or getattr(client, "respond_permission", None) + return bool(responder and responder(request_id, outcome)) + + def _emit_interaction_request( + self, running: RunningTurn, client: Any, request: ACPInteractiveRequest, + ) -> None: + if request.kind == "clarify" and request.multi_select: + self._respond_interaction(client, request.request_id, None) + return + expires_at_ts = time.time() + 900 + expires_at = datetime.fromtimestamp(expires_at_ts, tz=timezone.utc).isoformat() + call_id = f"call_{uuid.uuid4().hex}" + title = request.title or "Hermes 交互请求" + prompt = request.prompt or "Hermes 请求你补充确认。" + payload: dict[str, Any] + response_schema: dict[str, Any] + interaction_meta: dict[str, Any] + if request.kind == "update_prompt": + payload = { + "call_id": call_id, + "tool": "confirm", + "title": title, + "prompt": prompt, + "expires_at": expires_at, + "data": {"confirm": {"approve_label": "确认", "cancel_label": "取消"}}, + } + response_schema = {"tool": "confirm"} + interaction_meta = {"mode": "confirm", "confirmed": "y", "cancelled": "n"} + elif request.kind == "clarify" and request.expects_text and not request.options: + field_id = "answer" + payload = { + "call_id": call_id, + "tool": "input", + "title": title, + "prompt": prompt, + "expires_at": expires_at, + "data": { + "form": { + "fields": [{"id": field_id, "label": "你的回答", "type": "text", "required": True}], + "submit_label": "提交", + "cancel_label": "取消", + } + }, + } + response_schema = {"tool": "input", "fields": {field_id: {"type": "text", "required": True, "min_length": None, "max_length": None}}} + interaction_meta = {"mode": "input", "field_id": field_id} + else: + actions = [{"id": option.action_id, "label": option.label} for option in request.options] + mapping = {option.action_id: option.option_id for option in request.options} + other_action_id: str | None = None + if request.kind == "clarify" and request.allow_other: + other_action_id = f"action_{len(actions) + 1:02d}" + actions.append({"id": other_action_id, "label": "其他"}) + mapping[other_action_id] = "__other__" + payload = { + "call_id": call_id, + "tool": "choice", + "title": title, + "prompt": prompt, + "expires_at": expires_at, + "data": {"action_group": {"mode": "single-choice", "actions": actions}}, + } + response_schema = {"tool": "choice", "mode": "single-choice", "action_ids": sorted(action["id"] for action in actions)} + interaction_meta = {"mode": "choice", "mapping": mapping} + if other_action_id: + interaction_meta["other_action_id"] = other_action_id + disposition = self.store.register_tool_call(running.conversation_id, call_id, payload["tool"], expires_at_ts, response_schema) + if disposition != "accepted": + self._respond_interaction(client, request.request_id, None) + return + interaction_disposition = self.store.register_hermes_interaction( + running.conversation_id, + call_id, + kind=request.kind, + session_id=request.session_id, + acp_request_id=request.request_id, + option_map=interaction_meta, + ) + if interaction_disposition != "accepted": + self.store.complete_hermes_interaction(running.conversation_id, call_id, "duplicate") + self._respond_interaction(client, request.request_id, None) + return + self._send_or_queue(encode(envelope( + TOOL_CALL, + payload, + conversation_id=running.conversation_id, + sender_id=self.config.agent_uid, + target_id=running.sender_uid, + ))) + + def _resolve_hermes_interaction(self, conversation_id: str, call_id: str, model_result: dict[str, Any]) -> None: + interaction = self.store.hermes_interaction(conversation_id, call_id) + if not interaction: + return + outcome: str | None = None + state = "cancelled" + try: + metadata = json.loads(interaction["option_map"]) + except (TypeError, ValueError): + metadata = {} + mode = metadata.get("mode") if isinstance(metadata, dict) else None + if mode == "choice": + mapping = metadata.get("mapping") + selected = model_result.get("action_ids") + if isinstance(mapping, dict) and isinstance(selected, list) and len(selected) == 1 and selected[0] in mapping: + value = mapping[selected[0]] + if interaction["kind"] == "clarify" and value == "__other__": + self.store.complete_hermes_interaction(conversation_id, call_id, "continued") + client = self.acp_clients.get(conversation_id) + if client: + followup = ACPInteractiveRequest( + kind="clarify", + request_id=int(interaction["acp_request_id"]), + session_id=str(interaction["session_id"]), + title="补充说明", + prompt="请输入其他说明。", + expects_text=True, + ) + running = next((item for item in self.running.values() if item.conversation_id == conversation_id and item.acp is client), None) + if running: + self._emit_interaction_request(running, client, followup) + return + outcome = value if isinstance(value, str) else None + state = "resolved" + elif mode == "confirm": + if model_result.get("outcome") == "confirmed": + outcome = metadata.get("confirmed") + state = "resolved" + elif model_result.get("outcome") == "cancelled": + outcome = metadata.get("cancelled") + state = "cancelled" + elif mode == "input": + values = model_result.get("values") + field_id = metadata.get("field_id") + if isinstance(values, dict) and isinstance(field_id, str) and isinstance(values.get(field_id), str): + outcome = values[field_id] + state = "resolved" + client = self.acp_clients.get(conversation_id) + if client and isinstance(interaction["acp_request_id"], int): + self._respond_interaction(client, interaction["acp_request_id"], outcome) + self.store.complete_hermes_interaction(conversation_id, call_id, state) + + def _finish_acp(self, running: RunningTurn, failure: Exception | None = None) -> None: + self.running.pop(running.sender_uid, None) + if failure: + for step in running.acp_steps.values(): + if step["status"] == "running": + step["status"] = "failed" + self.store.set_inbox_state(running.message_id, "failed", str(failure)) + self._send_or_queue(execution_message(running.execution_id, "failed", list(running.acp_steps.values()), conversation_id=running.conversation_id, sender_id=self.config.agent_uid, target_id=running.sender_uid)) + self._send_or_queue(error_message("hermes_acp_failed", "Hermes 未能完成本次请求。", conversation_id=running.conversation_id, sender_id=self.config.agent_uid, target_id=running.sender_uid)) + return + reply = "".join(running.reply_parts).strip() + if not reply: + self._finish_acp(running, RuntimeError("empty Hermes ACP response")) + return + outgoing = agent_output_message(reply, conversation_id=running.conversation_id, sender_id=self.config.agent_uid, target_id=running.sender_uid, execution_id=running.execution_id, inventory=self.client_inventories.get(running.conversation_id)) + tool_invoke = parse_agent_tool_invoke(reply, self.client_inventories.get(running.conversation_id)) + tool_call = parse_agent_tool_call(reply) + if tool_invoke and tool_call: + outgoing = error_message("conflicting_tool_output", "Agent 同时返回了工具调用与交互请求,请只保留一种。", conversation_id=running.conversation_id, sender_id=self.config.agent_uid, target_id=running.sender_uid) + elif tool_call: + canonical, response_schema, expires_at = tool_call + disposition = self.store.register_tool_call(running.conversation_id, canonical["call_id"], canonical["tool"], expires_at, response_schema) + if disposition != "accepted": + outgoing = error_message("duplicate_tool_call", "Agent 重复创建了同一交互,请重新发起。", conversation_id=running.conversation_id, sender_id=self.config.agent_uid, target_id=running.sender_uid) + app_call = parse_agent_app_call(reply, self.client_inventories.get(running.conversation_id)) + if app_call: + canonical, expires_at = app_call + if self.store.register_app_call(running.conversation_id, canonical["call_id"], canonical["capability"], expires_at) != "accepted": + outgoing = error_message("duplicate_app_call", "Agent 重复创建了同一 App 操作,请重新发起。", conversation_id=running.conversation_id, sender_id=self.config.agent_uid, target_id=running.sender_uid) + self._send_or_queue(execution_message(running.execution_id, "completed", list(running.acp_steps.values()), conversation_id=running.conversation_id, sender_id=self.config.agent_uid, target_id=running.sender_uid)) + self._send_or_queue(outgoing) + self._send_or_queue(status_message("idle", conversation_id=running.conversation_id, sender_id=self.config.agent_uid, target_id=running.sender_uid, execution_id=running.execution_id)) + self.store.set_inbox_state(running.message_id, "done") + + def _reap_turns(self) -> None: + for sender_uid, running in list(self.running.items()): + timed_out = time.monotonic() - running.started_at > self.config.hermes_timeout + if running.acp: + if timed_out: + running.acp.close() + self._finish_acp(running, RuntimeError("Hermes ACP response timeout")) + elif running.acp_done: + self._finish_acp(running, running.acp_error) + continue + if running.process.poll() is None and not timed_out: + continue + if timed_out: + self._terminate(running.process) + stdout, stderr = running.process.communicate() + del self.running[sender_uid] + if timed_out: + self.store.set_inbox_state(running.message_id, "failed", "Hermes response timeout") + # A status "thinking" begins a client-side execution card. + # Always close that exact id on terminal failure as well: an + # error envelope has no execution_id, so relying on it alone + # leaves a stale running card after a timeout. + self._send_or_queue(execution_message( + running.execution_id, "failed", [], + conversation_id=running.conversation_id, + sender_id=self.config.agent_uid, + target_id=sender_uid, + )) + self._send_or_queue(error_message("hermes_timeout", "Hermes 响应超时,请重试。", conversation_id=running.conversation_id, sender_id=self.config.agent_uid, target_id=sender_uid)) + continue + if running.process.returncode != 0: + self.store.set_inbox_state(running.message_id, "failed", stderr.strip()[:1000]) + LOG.error("Hermes exited %s: %s", running.process.returncode, stderr.strip()) + self._send_or_queue(execution_message( + running.execution_id, "failed", [], + conversation_id=running.conversation_id, + sender_id=self.config.agent_uid, + target_id=sender_uid, + )) + self._send_or_queue(error_message("hermes_failed", "Hermes 未能完成本次请求。", conversation_id=running.conversation_id, sender_id=self.config.agent_uid, target_id=sender_uid)) + continue + reply = stdout.strip() + if not reply: + self.store.set_inbox_state(running.message_id, "failed", "empty Hermes response") + self._send_or_queue(execution_message( + running.execution_id, "failed", [], + conversation_id=running.conversation_id, + sender_id=self.config.agent_uid, + target_id=sender_uid, + )) + self._send_or_queue(error_message("hermes_empty_response", "Hermes 没有返回内容。", conversation_id=running.conversation_id, sender_id=self.config.agent_uid, target_id=sender_uid)) + continue + outgoing = agent_output_message(reply, conversation_id=running.conversation_id, sender_id=self.config.agent_uid, target_id=sender_uid, execution_id=running.execution_id, inventory=self.client_inventories.get(running.conversation_id)) + tool_invoke = parse_agent_tool_invoke(reply, self.client_inventories.get(running.conversation_id)) + tool_call = parse_agent_tool_call(reply) + if tool_invoke and tool_call: + outgoing = error_message( + "conflicting_tool_output", "Agent 同时返回了工具调用与交互请求,请只保留一种。", + conversation_id=running.conversation_id, sender_id=self.config.agent_uid, target_id=sender_uid, + ) + elif tool_call: + canonical, response_schema, expires_at = tool_call + disposition = self.store.register_tool_call( + running.conversation_id, canonical["call_id"], canonical["tool"], expires_at, response_schema + ) + if disposition != "accepted": + LOG.warning("suppressed duplicate tool call %s in %s", canonical["call_id"], running.conversation_id) + outgoing = error_message( + "duplicate_tool_call", "Agent 重复创建了同一交互,请重新发起。", + conversation_id=running.conversation_id, sender_id=self.config.agent_uid, target_id=sender_uid, + ) + app_call = parse_agent_app_call(reply, self.client_inventories.get(running.conversation_id)) + if app_call: + canonical, expires_at = app_call + if self.store.register_app_call(running.conversation_id, canonical["call_id"], canonical["capability"], expires_at) != "accepted": + outgoing = error_message( + "duplicate_app_call", "Agent 重复创建了同一 App 操作,请重新发起。", + conversation_id=running.conversation_id, sender_id=self.config.agent_uid, target_id=sender_uid, + ) + self._send_or_queue(outgoing) + self._send_or_queue(status_message("idle", conversation_id=running.conversation_id, sender_id=self.config.agent_uid, target_id=sender_uid, execution_id=running.execution_id)) + self.store.set_inbox_state(running.message_id, "done") + + def run_once(self) -> None: + self._flush_outbox() + self._flush_acp_events() + self._reap_turns() + cursor = self.store.cursor() + try: + messages = self.api.sync(cursor) + except ApiError as exc: + LOG.warning("sync failed: %s", exc) + return + # A brand-new worker must not turn the entire retained IM history into + # fresh Hermes prompts. Operators can explicitly opt into replay for a + # controlled migration or recovery run. + if not self.store.is_bootstrapped() and not self.config.replay_history: + newest = max((int(message.get("message_seq") or 0) for message in messages), default=cursor) + if newest > cursor: + self.store.set_cursor(newest) + self.store.set_bootstrapped() + LOG.info("adapter bootstrap complete at message sequence %s; retained history was not replayed", newest) + return + for message in messages: + seq = int(message.get("message_seq") or 0) + if message.get("from_uid") not in {self.config.agent_uid, *self.config.ignored_senders}: + self.store.claim_inbox(message) + if seq > cursor: + cursor = seq + self.store.set_cursor(cursor) + self.store.set_bootstrapped() + for row in self.store.pending(): + if row["state"] == "pending": + self._start_turn(row) + try: + self.api.heartbeat("running", self.store.cursor(), f"active_turns={len(self.running)}") + except ApiError as exc: + LOG.warning("heartbeat failed: %s", exc) + + def run_forever(self) -> None: + while not self.stop_requested: + self.run_once() + time.sleep(self.config.poll_interval) + + +def start_background() -> None: + global _BACKGROUND + if _BACKGROUND and _BACKGROUND.is_alive(): + return + + def runner() -> None: + adapter = LineUpAdapter() + try: + adapter.run_forever() + finally: + adapter.close() + + _BACKGROUND = threading.Thread(target=runner, name="lineup-adapter", daemon=True) + _BACKGROUND.start() + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="LineUp Hermes Adapter") + parser.add_argument("--once", action="store_true", help="process one poll cycle and exit") + parser.add_argument("--check", action="store_true", help="validate configuration and print persistent queue state") + args = parser.parse_args(argv) + logging.basicConfig(level=os.environ.get("LINEUP_LOG_LEVEL", "INFO"), format="%(asctime)s %(levelname)s %(name)s: %(message)s") + adapter = LineUpAdapter() + try: + if args.check: + print(adapter.store.status_summary()) + return 0 + if args.once: + adapter.run_once() + return 0 + + def stop(_signum: int, _frame: Any) -> None: + LOG.info("received termination signal; stopping LineUp Adapter") + adapter.stop_requested = True + + signal.signal(signal.SIGINT, stop) + signal.signal(signal.SIGTERM, stop) + adapter.run_forever() + return 0 + finally: + adapter.close() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/lineup/execution_trace.py b/lineup/execution_trace.py new file mode 100644 index 0000000..79765cd --- /dev/null +++ b/lineup/execution_trace.py @@ -0,0 +1,108 @@ +"""Safe public operation summaries derived from Hermes' local tool ledger. + +This module is deliberately a one-way privacy filter. It reads only enough +metadata to establish that a tool operation happened, then returns a bounded +allowlist of human labels. Raw commands, arguments, paths, URLs, outputs, +errors, and model reasoning never leave this process. +""" +from __future__ import annotations + +import json +import sqlite3 +from pathlib import Path +from typing import Any + +MAX_STEPS = 12 + + +def extract_public_execution_steps(state_db_path: str, marker: str) -> list[dict[str, str]]: + """Return public labels for the exact Hermes turn tagged by *marker*. + + Failure to open or recognize the best-effort local ledger is safe: callers + receive no trace rather than an invented one. + """ + if not marker or not Path(state_db_path).is_file(): + return [] + try: + connection = sqlite3.connect(f"file:{Path(state_db_path).resolve()}?mode=ro", uri=True) + connection.row_factory = sqlite3.Row + try: + marker_row = connection.execute( + "SELECT session_id, timestamp FROM messages WHERE content LIKE ? ORDER BY timestamp DESC LIMIT 1", + (f"%{marker}%",), + ).fetchone() + if not marker_row or not marker_row["session_id"] or marker_row["timestamp"] is None: + return [] + rows = connection.execute( + "SELECT role, tool_name, tool_calls, content FROM messages " + "WHERE session_id = ? AND timestamp >= ? ORDER BY timestamp ASC, id ASC", + (marker_row["session_id"], marker_row["timestamp"]), + ).fetchall() + finally: + connection.close() + except (sqlite3.Error, OSError, ValueError, KeyError, TypeError): + return [] + + labels: list[str] = [] + for row in rows: + if row["role"] not in {"assistant", "tool"}: + continue + for tool_name, opaque in _tool_operations(row): + label = _public_label(tool_name, opaque) + if label and label not in labels: + labels.append(label) + if len(labels) >= MAX_STEPS: + break + if len(labels) >= MAX_STEPS: + break + return [{"id": f"step_{index:02d}", "title": title, "status": "completed"} for index, title in enumerate(labels, start=1)] + + +def _tool_operations(row: sqlite3.Row) -> list[tuple[str, str]]: + """Read tool identity/opaque metadata only; never return it to callers.""" + result: list[tuple[str, str]] = [] + tool_name = row["tool_name"] + if isinstance(tool_name, str) and tool_name: + result.append((tool_name, _text(row["content"]))) + raw_calls = row["tool_calls"] + if not isinstance(raw_calls, str) or not raw_calls: + return result + try: + decoded: Any = json.loads(raw_calls) + except (TypeError, ValueError): + return result + calls = decoded if isinstance(decoded, list) else [decoded] + for call in calls: + if not isinstance(call, dict): + continue + name = call.get("name") or call.get("tool_name") or (call.get("function") or {}).get("name") + if isinstance(name, str) and name: + # This stays opaque: it is examined solely by fixed predicates. + result.append((name, json.dumps(call, ensure_ascii=False, separators=(",", ":")))) + return result + + +def _public_label(tool_name: str, opaque: str) -> str | None: + name = tool_name.lower() + lower = opaque.lower() + if name in {"web_search", "search_web"}: + return "查询公开资料" + if name in {"web_extract", "web_fetch", "read_web"}: + return "读取公开网页内容" + if name in {"search_files", "find_files"}: + return "检索相关文件" + if name in {"read_file", "read_files"}: + return "读取相关配置" + if name in {"terminal", "shell", "command"}: + # These labels are intentionally specific only for a narrow allowlist. + if "openapi.json" in lower: + return "获取服务接口说明" + if "/kline" in lower: + return "查询行情接口" + if "/health" in lower and ("qmtbridge" in lower or "qmt" in lower): + return "检查 qmtbridge health" + return None + + +def _text(value: Any) -> str: + return value if isinstance(value, str) else "" diff --git a/lineup/plugin.yaml b/lineup/plugin.yaml new file mode 100644 index 0000000..0eb8a7b --- /dev/null +++ b/lineup/plugin.yaml @@ -0,0 +1,23 @@ +manifest_version: 1 +name: lineup +label: LineUp +kind: standalone +version: 0.1.0 +description: > + Connects a supervised LineUp Adapter to the local Hermes runtime. The adapter + consumes LineUp messages through the LineUp app server, preserves per-user + Hermes sessions, and sends versioned LineUp protocol envelopes back. +author: LineUp +optional_env: + - name: LINEUP_APPSERVER_URL + description: "LineUp app server base URL (default: http://127.0.0.1:8090)" + prompt: "LineUp app server URL" + password: false + - name: LINEUP_AGENT_UID + description: "Stable WuKongIM/LineUp identity for this Hermes instance" + prompt: "LineUp agent UID" + password: false + - name: LINEUP_AGENT_SHARED_SECRET + description: "Agent heartbeat credential configured on the LineUp app server" + prompt: "LineUp agent shared secret" + password: true diff --git a/lineup/protocol.py b/lineup/protocol.py new file mode 100644 index 0000000..d7d00fe --- /dev/null +++ b/lineup/protocol.py @@ -0,0 +1,943 @@ +"""Versioned LineUp envelope helpers with a narrow legacy-input bridge.""" + +from __future__ import annotations + +import json +import re +import uuid +import base64 +import binascii +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Any + +PROTOCOL_VERSION = 1 +TEXT = "lineup.v1.text" +STATUS = "lineup.v1.agent.status" +EXECUTION = "lineup.v1.agent.execution" +PROGRESS = "lineup.v1.agent.progress" +TOOL_CALL = "lineup.v1.tool.call" +TOOL_INVOKE = "lineup.v1.tool.invoke" +TOOL_RESULT = "lineup.v1.tool.result" +TOOL_CANCEL = "lineup.v1.tool.cancel" +ERROR = "lineup.v1.error" +UI_OPEN = "lineup.v1.ui.open" +UI_PATCH = "lineup.v1.ui.patch" +UI_CLOSE = "lineup.v1.ui.close" +UI_EVENT = "lineup.v1.ui.event" +CLIENT_INVENTORY = "lineup.v1.client.inventory" +APP_LIST = "lineup.v1.app.list" +APP_CALL = "lineup.v1.app.call" +APP_RESULT = "lineup.v1.app.result" +ARTIFACT_OFFER = "lineup.v1.artifact.offer" + +# M2 permits one host-local, bundle-free Surface lifecycle. Capability calls +# stay outside this set: a Surface is presentation-only and never grants a +# device, DOM, network, or native bridge capability. +AGENT_OUTPUT_TYPES = frozenset({TEXT, STATUS, PROGRESS, TOOL_CALL, TOOL_INVOKE, ERROR, UI_OPEN, UI_PATCH, UI_CLOSE, ARTIFACT_OFFER}) + +_IDENTIFIER = re.compile(r"^[A-Za-z][A-Za-z0-9_.:-]{0,127}$") +_TOOL_KINDS = frozenset({"choice", "confirm", "input"}) +_AGENT_TOOL_HANDLING = frozenset({"direct", "interactive", "launch", "foreground", "operation"}) +_AGENT_TOOL_DELIVERY = frozenset({"miniapp_sdk", "runtime"}) +_AGENT_TOOL_ACTIVATION = frozenset({"activation_not_required", "foreground_required", "app_ready"}) +_ACTION_MODES = frozenset({"single-choice", "button"}) +_FIELD_TYPES = frozenset({"text", "textarea", "number"}) +_RESULT_STATUSES = frozenset({"completed", "failed", "cancelled", "expired"}) +_TASK_DASHBOARD_APP = "lineup.task-dashboard" +_TASK_DASHBOARD_VERSION = "0.1.0" +_SURFACE_OVERRIDE_FIELDS = frozenset({"bundle", "bundle_id", "bundle_url", "url", "html", "css", "javascript", "js", "script", "source"}) +_INVENTORY_CAPABILITIES = frozenset({"app.open_url", "clipboard.write", "device.pick_file", "artifact.save"}) +_APP_CALL_ARGUMENTS = { + "app.open_url": frozenset({"url"}), + "clipboard.write": frozenset({"text"}), + "device.pick_file": frozenset(), + "artifact.save": frozenset({"artifact_id"}), +} +_APP_RESULT_STATUSES = frozenset({"completed", "failed", "rejected", "cancelled", "expired", "unsupported"}) +_SENSITIVE_REASON = re.compile(r"https?://|(?:[A-Za-z]:)?[\\/]") +_ARTIFACT_INTEGRITY = frozenset({"verified", "unverified", "failed"}) +_OPAQUE_REF = re.compile(r"^[A-Za-z0-9_.:-]{1,128}$") +_BASE64 = re.compile(r"^[A-Za-z0-9+/]*={0,2}$") + + +@dataclass(frozen=True) +class ToolResponse: + """A validated client response, deliberately without envelope metadata.""" + + kind: str + call_id: str + status: str | None = None + result: dict[str, Any] | None = None + reason: str | None = None + + +def _identifier(value: Any) -> str | None: + return value if isinstance(value, str) and _IDENTIFIER.fullmatch(value) else None + + +def _bounded_text(value: Any, maximum: int, *, allow_empty: bool = True) -> str | None: + if not isinstance(value, str): + return None + normalized = value.strip() + if len(normalized) > maximum or (not allow_empty and not normalized): + return None + return normalized + + +def _timestamp(value: Any) -> tuple[str, float] | None: + if not isinstance(value, str): + return None + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + return None + if parsed.tzinfo is None: + return None + return value, parsed.timestamp() + + +def _json_object(value: Any) -> dict[str, Any] | None: + return value if isinstance(value, dict) else None + + +def parse_client_inventory(raw_payload: str) -> dict[str, Any] | None: + """Accept a bounded declarative inventory without granting authority. + + The Adapter stores this only as prompt context for the same transport + sender. It is not an Agent turn, does not authorize an app.call, and is + reduced to fields that cannot contain a bundle, path, token or user data. + """ + try: + envelope_value = json.loads(raw_payload) + except (TypeError, ValueError): + return None + if not isinstance(envelope_value, dict) or envelope_value.get("type") != CLIENT_INVENTORY: + return None + payload = _json_object(envelope_value.get("payload")) + if not payload or set(payload) not in ({"revision", "standard_components", "applications", "capabilities"}, {"revision", "standard_components", "applications", "tools", "capabilities"}): + return None + revision = _bounded_text(payload.get("revision"), 128, allow_empty=False) + components = payload.get("standard_components") + applications = payload.get("applications") + tools = payload.get("tools", []) + capabilities = payload.get("capabilities") + if not revision or not isinstance(components, list) or not isinstance(applications, list) or not isinstance(tools, list) or not isinstance(capabilities, list): + return None + if len(components) > 8 or len(applications) > 16 or len(tools) > 64 or len(capabilities) > 16: + return None + canonical_components: list[dict[str, str]] = [] + for value in components: + item = _json_object(value) + component_id = _bounded_text(item.get("id"), 64, allow_empty=False) if item else None + version = _bounded_text(item.get("version"), 32, allow_empty=False) if item else None + if not component_id or not version or set(item) != {"id", "version"}: + return None + canonical_components.append({"id": component_id, "version": version}) + canonical_applications: list[dict[str, Any]] = [] + for value in applications: + item = _json_object(value) + app_id = _bounded_text(item.get("id"), 96, allow_empty=False) if item else None + version = _bounded_text(item.get("version"), 64, allow_empty=False) if item else None + surfaces = item.get("surfaces") if item else None + if not app_id or not version or not isinstance(surfaces, list) or len(surfaces) > 8 or set(item) != {"id", "version", "surfaces"}: + return None + canonical_surfaces: list[dict[str, Any]] = [] + for surface in surfaces: + definition = _json_object(surface) + surface_id = _bounded_text(definition.get("id"), 64, allow_empty=False) if definition else None + events = definition.get("events") if definition else None + if not surface_id or not isinstance(events, list) or len(events) > 16 or set(definition) != {"id", "events"} or not all(_identifier(event) for event in events): + return None + canonical_surfaces.append({"id": surface_id, "events": list(events)}) + canonical_applications.append({"id": app_id, "version": version, "surfaces": canonical_surfaces}) + canonical_tools: list[dict[str, Any]] = [] + for value in tools: + item = _json_object(value) + app_scope = _bounded_text(item.get("app_scope"), 96, allow_empty=False) if item else None + tool_id = _bounded_text(item.get("tool_id"), 128, allow_empty=False) if item else None + description = _bounded_text(item.get("description"), 500, allow_empty=False) if item else None + input_schema = _json_object(item.get("input_schema")) if item else None + handling = item.get("handling") if item else None + delivery = item.get("delivery") if item else None + activation_requirement = item.get("activation_requirement") if item else None + if (not app_scope or not tool_id or not description or not input_schema + or handling not in _AGENT_TOOL_HANDLING + or delivery not in _AGENT_TOOL_DELIVERY + or activation_requirement not in _AGENT_TOOL_ACTIVATION): + return None + if set(item) != {"app_scope", "tool_id", "description", "input_schema", "handling", "delivery", "activation_requirement"}: + return None + canonical_tools.append({ + "app_scope": app_scope, + "tool_id": tool_id, + "description": description, + "input_schema": input_schema, + "handling": handling, + "delivery": delivery, + "activation_requirement": activation_requirement, + }) + canonical_capabilities: list[dict[str, Any]] = [] + for value in capabilities: + item = _json_object(value) + name = item.get("name") if item else None + version = _bounded_text(item.get("version"), 32, allow_empty=False) if item else None + risk = item.get("risk") if item else None + schema = _json_object(item.get("input_schema")) if item else None + foreground_required = item.get("foreground_required") if item else None + if name not in _INVENTORY_CAPABILITIES or not version or risk not in {"display_only", "user_confirmation", "system_permission", "restricted"} or not schema or foreground_required is not True: + return None + if set(item) != {"name", "version", "risk", "input_schema", "foreground_required"} or set(schema) != {"type", "required", "properties"}: + return None + canonical_capabilities.append({"name": name, "version": version, "risk": risk, "input_schema": schema, "foreground_required": True}) + return {"revision": revision, "standard_components": canonical_components, "applications": canonical_applications, "tools": canonical_tools, "capabilities": canonical_capabilities} + + +def validate_tool_invoke_payload(payload: dict[str, Any], inventory: dict[str, Any] | None) -> dict[str, Any] | None: + """Canonically admit one Runtime Agent Tool invoke for *this* inventory.""" + if set(payload) != {"call_id", "inventory_revision", "app_scope", "tool_id", "input"}: + return None + call_id = _identifier(payload.get("call_id")) + inventory_revision = _bounded_text(payload.get("inventory_revision"), 128, allow_empty=False) + app_scope = _bounded_text(payload.get("app_scope"), 96, allow_empty=False) + tool_id = _bounded_text(payload.get("tool_id"), 128, allow_empty=False) + input_value = _json_object(payload.get("input")) + if not call_id or not inventory_revision or not app_scope or not tool_id or input_value is None: + return None + if not isinstance(inventory, dict) or inventory.get("revision") != inventory_revision: + return None + visible = inventory.get("tools") + if not isinstance(visible, list): + return None + if not any( + isinstance(item, dict) + and item.get("app_scope") == app_scope + and item.get("tool_id") == tool_id + for item in visible + ): + return None + return { + "call_id": call_id, + "inventory_revision": inventory_revision, + "app_scope": app_scope, + "tool_id": tool_id, + "input": input_value, + } + + +def validate_app_call_payload(payload: dict[str, Any], inventory: dict[str, Any] | None, *, now: datetime | None = None) -> tuple[dict[str, Any], float] | None: + """Canonically admit one Agent capability request for *this* inventory. + + Inventory is still not authorization. It only narrows what the adapter + may place on the wire; the host performs its own policy, confirmation and + execution-time checks. Unknown keys and non-string nested values are + rejected rather than passed through as model-controlled UI or parameters. + """ + if set(payload) != {"call_id", "capability", "reason", "expires_at", "arguments"}: + return None + call_id = _identifier(payload.get("call_id")) + capability = payload.get("capability") + reason = _bounded_text(payload.get("reason"), 500, allow_empty=False) + expiry = _timestamp(payload.get("expires_at")) + arguments = _json_object(payload.get("arguments")) + if not call_id or capability not in _APP_CALL_ARGUMENTS or not reason or _SENSITIVE_REASON.search(reason) or not expiry or arguments is None: + return None + reference = now or datetime.now(timezone.utc) + if expiry[1] <= reference.timestamp(): + return None + visible = inventory.get("capabilities") if isinstance(inventory, dict) else None + declared = { + item.get("name") for item in visible + if isinstance(item, dict) and item.get("name") in _INVENTORY_CAPABILITIES + } if isinstance(visible, list) else set() + if capability not in declared or set(arguments) != _APP_CALL_ARGUMENTS[capability]: + return None + canonical_arguments: dict[str, str] = {} + for key in _APP_CALL_ARGUMENTS[capability]: + value = _bounded_text(arguments.get(key), 8_192, allow_empty=False) + if value is None: + return None + canonical_arguments[key] = value + return ({"call_id": call_id, "capability": capability, "reason": reason, "expires_at": expiry[0], "arguments": canonical_arguments}, expiry[1]) + + +def validate_artifact_offer_payload(payload: dict[str, Any]) -> dict[str, Any] | None: + """Canonicalize a bounded Artifact offer without accepting a URL/path.""" + allowed = {"artifact_id", "name", "mime_type", "size_bytes", "integrity", "created_at", "local_ref", "content_base64"} + if set(payload) - allowed: + return None + artifact_id = _identifier(payload.get("artifact_id")) + name = _bounded_text(payload.get("name"), 255, allow_empty=False) + mime_type = _bounded_text(payload.get("mime_type"), 128, allow_empty=False) + size = payload.get("size_bytes") + integrity = payload.get("integrity") + created = _timestamp(payload.get("created_at")) + local_ref = payload.get("local_ref") + if (not artifact_id or not name or not mime_type or not re.fullmatch(r"[A-Za-z0-9.+-]+/[A-Za-z0-9.+-]+", mime_type) + or not isinstance(size, int) or isinstance(size, bool) or size < 0 or size > 512 * 1024 * 1024 + or integrity not in _ARTIFACT_INTEGRITY or not created): + return None + canonical: dict[str, Any] = {"artifact_id": artifact_id, "name": name, "mime_type": mime_type, "size_bytes": size, "integrity": integrity, "created_at": created[0]} + if local_ref is not None: + if not isinstance(local_ref, str) or not _OPAQUE_REF.fullmatch(local_ref) or "/" in local_ref or "\\" in local_ref: + return None + canonical["local_ref"] = local_ref + content = payload.get("content_base64") + if content is not None: + if not isinstance(content, str) or len(content) > 8 * 1024 * 1024 or not _BASE64.fullmatch(content) or not local_ref: + return None + try: + decoded = base64.b64decode(content, validate=True) + except (ValueError, binascii.Error): + return None + if len(decoded) != size or len(decoded) > 6 * 1024 * 1024: + return None + canonical["content_base64"] = content + return canonical + + +def parse_app_result(raw_payload: str) -> dict[str, Any] | None: + """Accept the host's bounded outcome projection, not arbitrary result data.""" + try: + value = json.loads(raw_payload) + except (TypeError, ValueError): + return None + if not isinstance(value, dict) or value.get("v") != PROTOCOL_VERSION or value.get("type") != APP_RESULT: + return None + payload = _json_object(value.get("payload")) + if not payload or set(payload) - {"call_id", "capability", "status", "completed", "code"}: + return None + call_id = _identifier(payload.get("call_id")) + capability = payload.get("capability") + status = payload.get("status") + if not call_id or capability not in _INVENTORY_CAPABILITIES or status not in _APP_RESULT_STATUSES: + return None + if payload.get("completed") is not None and payload.get("completed") is not True: + return None + code = payload.get("code") + if code is not None and not _bounded_text(code, 128, allow_empty=False): + return None + return {"call_id": call_id, "capability": capability, "status": status, **({"completed": True} if payload.get("completed") is True else {}), **({"code": code} if isinstance(code, str) else {})} + + +def validate_surface_payload(message_type: str, payload: dict[str, Any]) -> dict[str, Any] | None: + """Canonicalize the sole M2 Surface lifecycle without accepting code.""" + instance_id = _identifier(payload.get("instance_id")) + if not instance_id or any(field in payload for field in _SURFACE_OVERRIDE_FIELDS): + return None + if message_type == UI_OPEN: + app = _json_object(payload.get("app")) + state = _json_object(payload.get("state", {})) + if (not app or set(app) != {"app_id", "version"} + or app.get("app_id") != _TASK_DASHBOARD_APP or app.get("version") != _TASK_DASHBOARD_VERSION + or state is None or set(payload) - {"instance_id", "app", "state"}): + return None + if len(json.dumps(state, ensure_ascii=False).encode("utf-8")) > 32 * 1024: + return None + return {"instance_id": instance_id, "app": {"app_id": _TASK_DASHBOARD_APP, "version": _TASK_DASHBOARD_VERSION}, "state": state} + if message_type == UI_PATCH: + state = _json_object(payload.get("state")) + if state is None or set(payload) != {"instance_id", "state"} or len(json.dumps(state, ensure_ascii=False).encode("utf-8")) > 32 * 1024: + return None + return {"instance_id": instance_id, "state": state} + if message_type == UI_CLOSE and set(payload) == {"instance_id"}: + return {"instance_id": instance_id} + return None + + +def validate_tool_call_payload(payload: dict[str, Any], *, now: datetime | None = None) -> tuple[dict[str, Any], dict[str, Any]] | None: + """Return the canonical tool wire payload and its minimal response schema. + + The canonicalization intentionally drops unknown keys. In particular it + prevents a model from smuggling HTML, DOM configuration, app identifiers, + or capability arguments through the standard-component path. + """ + call_id = _identifier(payload.get("call_id")) + tool = payload.get("tool") + title = _bounded_text(payload.get("title", ""), 200) + prompt = _bounded_text(payload.get("prompt", ""), 2_000) + expiry = _timestamp(payload.get("expires_at")) + data = _json_object(payload.get("data")) + if not call_id or tool not in _TOOL_KINDS or title is None or prompt is None or not expiry or not data: + return None + reference = now or datetime.now(timezone.utc) + if expiry[1] <= reference.timestamp(): + return None + + canonical: dict[str, Any] = { + "call_id": call_id, + "tool": tool, + "title": title, + "prompt": prompt, + "expires_at": expiry[0], + } + if tool == "choice": + group = _json_object(data.get("action_group")) + actions = group.get("actions") if group else None + mode = group.get("mode") if group else None + if mode not in _ACTION_MODES or not isinstance(actions, list) or not 1 <= len(actions) <= 12: + return None + seen: set[str] = set() + canonical_actions: list[dict[str, str]] = [] + for action in actions: + item = _json_object(action) + action_id = _identifier(item.get("id")) if item else None + label = _bounded_text(item.get("label"), 200, allow_empty=False) if item else None + description = _bounded_text(item.get("description", ""), 500) if item else None + if not action_id or not label or description is None or action_id in seen: + return None + seen.add(action_id) + entry = {"id": action_id, "label": label} + if description: + entry["description"] = description + canonical_actions.append(entry) + canonical["data"] = {"action_group": {"mode": mode, "actions": canonical_actions}} + return canonical, {"tool": tool, "mode": mode, "action_ids": sorted(seen)} + if tool == "confirm": + confirm = _json_object(data.get("confirm")) + if not confirm: + return None + approve = _bounded_text(confirm.get("approve_label", "确认"), 80, allow_empty=False) + cancel = _bounded_text(confirm.get("cancel_label", "取消"), 80, allow_empty=False) + if not approve or not cancel: + return None + canonical["data"] = {"confirm": {"approve_label": approve, "cancel_label": cancel}} + return canonical, {"tool": tool} + + form = _json_object(data.get("form")) + fields = form.get("fields") if form else None + if not isinstance(fields, list) or not 1 <= len(fields) <= 8: + return None + seen_fields: set[str] = set() + canonical_fields: list[dict[str, Any]] = [] + schema_fields: dict[str, dict[str, Any]] = {} + for field in fields: + item = _json_object(field) + field_id = _identifier(item.get("id")) if item else None + label = _bounded_text(item.get("label"), 120, allow_empty=False) if item else None + field_type = item.get("type") if item else None + placeholder = _bounded_text(item.get("placeholder", ""), 200) if item else None + pattern = _bounded_text(item.get("pattern", ""), 256) if item else None + minimum = item.get("min_length") if item else None + maximum = item.get("max_length") if item else None + if (not field_id or not label or field_type not in _FIELD_TYPES or placeholder is None or pattern is None + or field_id in seen_fields or (minimum is not None and (not isinstance(minimum, int) or isinstance(minimum, bool) or not 0 <= minimum <= 10_000)) + or (maximum is not None and (not isinstance(maximum, int) or isinstance(maximum, bool) or not 0 <= maximum <= 10_000)) + or (minimum is not None and maximum is not None and minimum > maximum)): + return None + if pattern: + try: + re.compile(pattern) + except re.error: + return None + seen_fields.add(field_id) + entry: dict[str, Any] = {"id": field_id, "label": label, "type": field_type, "required": item.get("required") is True} + if placeholder: + entry["placeholder"] = placeholder + if minimum is not None: + entry["min_length"] = minimum + if maximum is not None: + entry["max_length"] = maximum + if pattern: + entry["pattern"] = pattern + canonical_fields.append(entry) + # The durable response schema contains only field ids and constraints, + # never labels, prompts, or user-entered values. + schema_fields[field_id] = {"type": field_type, "required": item.get("required") is True, "min_length": minimum, "max_length": maximum} + submit = _bounded_text(form.get("submit_label", "提交"), 80, allow_empty=False) if form else None + cancel = _bounded_text(form.get("cancel_label", "取消"), 80, allow_empty=False) if form else None + if not submit or not cancel: + return None + canonical["data"] = {"form": {"fields": canonical_fields, "submit_label": submit, "cancel_label": cancel}} + return canonical, {"tool": tool, "fields": schema_fields} + + +def parse_agent_tool_call(raw: str) -> tuple[dict[str, Any], dict[str, Any], float] | None: + """Return a validated model tool call plus durable metadata, if present.""" + try: + candidate = json.loads(raw) + except (TypeError, ValueError): + return None + if not isinstance(candidate, dict) or candidate.get("v") != PROTOCOL_VERSION or candidate.get("type") != TOOL_CALL: + return None + payload = _json_object(candidate.get("payload")) + validated = validate_tool_call_payload(payload) if payload else None + if not validated: + return None + expiry = _timestamp(validated[0]["expires_at"]) + assert expiry is not None + return validated[0], validated[1], expiry[1] + + +def parse_agent_app_call(raw: str, inventory: dict[str, Any] | None) -> tuple[dict[str, Any], float] | None: + """Return an inventory-bound, canonical Agent app.call before sending it.""" + try: + candidate = json.loads(raw) + except (TypeError, ValueError): + return None + if not isinstance(candidate, dict) or candidate.get("v") != PROTOCOL_VERSION or candidate.get("type") != APP_CALL: + return None + payload = _json_object(candidate.get("payload")) + return validate_app_call_payload(payload, inventory) if payload else None + + +def parse_agent_tool_invoke(raw: str, inventory: dict[str, Any] | None) -> dict[str, Any] | None: + """Return an inventory-bound, canonical Agent tool.invoke before sending it.""" + try: + candidate = json.loads(raw) + except (TypeError, ValueError): + return None + if not isinstance(candidate, dict) or candidate.get("v") != PROTOCOL_VERSION or candidate.get("type") != TOOL_INVOKE: + return None + payload = _json_object(candidate.get("payload")) + return validate_tool_invoke_payload(payload, inventory) if payload else None + + +def parse_tool_response(raw_payload: str) -> ToolResponse | None: + """Decode only a valid M1 client tool response; all other input is None.""" + try: + value = json.loads(raw_payload) + except (TypeError, ValueError): + return None + if not isinstance(value, dict) or value.get("v") != PROTOCOL_VERSION: + return None + payload = _json_object(value.get("payload")) + call_id = _identifier(payload.get("call_id")) if payload else None + if not call_id: + return None + if value.get("type") == TOOL_RESULT: + status = payload.get("status") + result = _json_object(payload.get("result")) + if status not in _RESULT_STATUSES or result is None: + return None + return ToolResponse(kind="result", call_id=call_id, status=status, result=result) + if value.get("type") == TOOL_CANCEL: + reason = _bounded_text(payload.get("reason", ""), 500) + return ToolResponse(kind="cancel", call_id=call_id, reason=reason) if reason is not None else None + return None + + +def validate_tool_response(response: ToolResponse, schema: dict[str, Any]) -> dict[str, Any] | None: + """Build the compact model-facing result from a call's stored schema.""" + tool = schema.get("tool") + if response.kind == "cancel": + return {"call_id": response.call_id, "outcome": "cancelled", "reason": response.reason or ""} + if response.status != "completed" or not isinstance(response.result, dict): + # A client may report an expiry/failure, but that must not be reified as + # arbitrary structured input to the model. + return {"call_id": response.call_id, "outcome": response.status or "failed"} + if tool == "choice": + selected = response.result.get("action_ids") + allowed = set(schema.get("action_ids", [])) + mode = schema.get("mode") + # The Host emits the canonical single-choice projection as one + # `action_id`; normalize it to the Adapter's internal list shape. + if selected is None and mode == "single-choice": + action_id = response.result.get("action_id") + selected = [action_id] if isinstance(action_id, str) else None + if not isinstance(selected, list) or not selected or any(not isinstance(value, str) or value not in allowed for value in selected): + return None + if len(set(selected)) != len(selected) or (mode in {"single-choice", "button"} and len(selected) != 1): + return None + return {"call_id": response.call_id, "outcome": "completed", "action_ids": selected} + if tool == "confirm": + if response.result.get("confirmed") is not True or set(response.result) != {"confirmed"}: + return None + return {"call_id": response.call_id, "outcome": "confirmed"} + if tool == "input": + values = response.result.get("values") + fields = schema.get("fields") + if not isinstance(fields, dict): + return None + # The Host may emit a single-field input result as a compact `text` + # value instead of the full `values.{field_id}` map. Normalize it to + # the schema-bound internal shape before validation. + if values is None and len(fields) == 1: + text_value = response.result.get("text") + if isinstance(text_value, str): + field_id = next(iter(fields)) + values = {field_id: text_value} + if not isinstance(values, dict) or set(values) != set(fields): + return None + clean_values: dict[str, str] = {} + for field_id, definition in fields.items(): + value = values.get(field_id) + if not isinstance(value, str) or len(value) > 10_000: + return None + minimum = definition.get("min_length") + maximum = definition.get("max_length") + if (definition.get("required") and not value.strip()) or (minimum is not None and len(value) < minimum) or (maximum is not None and len(value) > maximum): + return None + clean_values[field_id] = value + return {"call_id": response.call_id, "outcome": "completed", "values": clean_values} + return None + + +def message_id() -> str: + return f"msg_{uuid.uuid4().hex}" + + +def envelope( + message_type: str, + payload: dict[str, Any], + *, + conversation_id: str, + sender_id: str, + sender_kind: str = "agent", + target_id: str | None = None, + target_kind: str = "human", +) -> dict[str, Any]: + result: dict[str, Any] = { + "v": PROTOCOL_VERSION, + "id": message_id(), + "type": message_type, + "conversation_id": conversation_id, + "sender": {"kind": sender_kind, "id": sender_id}, + "timestamp": datetime.now(timezone.utc).isoformat(), + "payload": payload, + } + if target_id: + result["target"] = {"kind": target_kind, "id": target_id} + return result + + +def encode(value: dict[str, Any]) -> str: + return json.dumps(value, ensure_ascii=False, separators=(",", ":")) + + +def text_message( + text: str, *, conversation_id: str, sender_id: str, target_id: str | None = None +) -> str: + return encode( + envelope( + TEXT, + {"text": text}, + conversation_id=conversation_id, + sender_id=sender_id, + target_id=target_id, + ) + ) + + +def status_message( + status: str, + *, + conversation_id: str, + sender_id: str, + target_id: str | None = None, + detail: str | None = None, + execution_id: str | None = None, +) -> str: + payload: dict[str, Any] = {"status": status} + if detail: + payload["detail"] = detail + if execution_id and _identifier(execution_id): + payload["execution_id"] = execution_id + return encode( + envelope( + STATUS, + payload, + conversation_id=conversation_id, + sender_id=sender_id, + target_id=target_id, + ) + ) + + +def execution_message( + execution_id: str, + status: str, + steps: list[dict[str, str]], + *, + conversation_id: str, + sender_id: str, + target_id: str | None = None, +) -> str: + """Emit an adapter-owned, display-only operation trace. + + Callers must already have reduced tool ledger data to the small public + schema. This helper intentionally has no command/result/metadata fields. + """ + if not _identifier(execution_id) or status not in {"running", "completed", "failed"}: + raise ValueError("invalid execution trace") + canonical: list[dict[str, str]] = [] + seen: set[str] = set() + for step in steps[:12]: + step_id = _identifier(step.get("id")) + title = _bounded_text(step.get("title"), 160, allow_empty=False) + step_status = step.get("status") + if not step_id or not title or step_status not in {"running", "completed", "failed"} or step_id in seen: + continue + seen.add(step_id) + canonical.append({"id": step_id, "title": title, "status": step_status}) + return encode(envelope(EXECUTION, {"execution_id": execution_id, "status": status, "steps": canonical}, conversation_id=conversation_id, sender_id=sender_id, target_id=target_id)) + + +def error_message( + code: str, + message: str, + *, + conversation_id: str, + sender_id: str, + target_id: str | None = None, +) -> str: + return encode( + envelope( + ERROR, + {"code": code, "message": message}, + conversation_id=conversation_id, + sender_id=sender_id, + target_id=target_id, + ) + ) + + +def tool_result_message( + call_id: str, + status: str, + *, + conversation_id: str, + sender_id: str, + target_id: str | None = None, + error: str | None = None, +) -> str: + """Emit an adapter-authoritative terminal state for one standard tool.""" + payload: dict[str, Any] = {"call_id": call_id, "status": status, "result": {}} + if error: + payload["error"] = error + return encode( + envelope( + TOOL_RESULT, + payload, + conversation_id=conversation_id, + sender_id=sender_id, + target_id=target_id, + ) + ) + + +def ui_open_message( + instance_id: str, + app: dict[str, Any], + *, + conversation_id: str, + sender_id: str, + target_id: str | None = None, + state: dict[str, Any] | None = None, +) -> str: + """Create a sandboxed UI Surface request. + + ``app`` is deliberately transport-only here. The receiving LineUp Client + owns the sandbox, validates its size/version, and never grants the UI + bundle access to client credentials or native app capabilities. + """ + payload: dict[str, Any] = {"instance_id": instance_id, "app": app} + if state is not None: + payload["state"] = state + return encode( + envelope( + UI_OPEN, + payload, + conversation_id=conversation_id, + sender_id=sender_id, + target_id=target_id, + ) + ) + + +def ui_event_message( + instance_id: str, + event: str, + data: dict[str, Any] | None, + *, + conversation_id: str, + sender_id: str, + target_id: str | None = None, +) -> str: + """Return a user interaction from a UI Surface to its owning Agent.""" + return encode( + envelope( + UI_EVENT, + {"instance_id": instance_id, "event": event, "data": data or {}}, + conversation_id=conversation_id, + sender_id=sender_id, + sender_kind="human", + target_id=target_id, + target_kind="agent", + ) + ) + + +def app_call_message( + call_id: str, + capability: str, + arguments: dict[str, Any], + *, + conversation_id: str, + sender_id: str, + target_id: str | None = None, + reason: str | None = None, + expires_at: str | None = None, +) -> str: + """Request one registered host-app capability. + + A client must still verify its local registry and obtain the user/system + authorization required by that capability before it executes anything. + """ + payload: dict[str, Any] = { + "call_id": call_id, + "capability": capability, + "arguments": arguments, + } + if reason: + payload["reason"] = reason + if expires_at: + payload["expires_at"] = expires_at + return encode( + envelope( + APP_CALL, + payload, + conversation_id=conversation_id, + sender_id=sender_id, + target_id=target_id, + ) + ) + + +def app_result_message( + call_id: str, + status: str, + *, + conversation_id: str, + sender_id: str, + target_id: str | None = None, + result: dict[str, Any] | None = None, + error: str | None = None, +) -> str: + """Return the definitive result of an app capability call.""" + payload: dict[str, Any] = {"call_id": call_id, "status": status, "result": result or {}} + if error: + payload["error"] = error + return encode( + envelope( + APP_RESULT, + payload, + conversation_id=conversation_id, + sender_id=sender_id, + sender_kind="human", + target_id=target_id, + target_kind="agent", + ) + ) + + +def agent_output_message( + raw: str, *, conversation_id: str, sender_id: str, target_id: str | None = None, execution_id: str | None = None, + inventory: dict[str, Any] | None = None, +) -> str: + """Safely promote a model-produced LineUp UI envelope, or wrap it as text. + + Hermes normally returns Markdown. When it deliberately returns one JSON + object with an allowlisted LineUp type, the adapter rebuilds the outer + envelope itself so a model cannot spoof actor metadata or emit a + client-only result type. + """ + try: + candidate = json.loads(raw) + except (TypeError, ValueError): + candidate = None + if not isinstance(candidate, dict): + payload: dict[str, Any] = {"text": str(raw)} + if execution_id and _identifier(execution_id): + payload["execution_id"] = execution_id + return encode(envelope(TEXT, payload, conversation_id=conversation_id, sender_id=sender_id, target_id=target_id)) + message_type = candidate.get("type") + payload = candidate.get("payload") + if candidate.get("v") == PROTOCOL_VERSION and message_type == APP_CALL and isinstance(payload, dict): + validated_app_call = validate_app_call_payload(payload, inventory) + if not validated_app_call: + return text_message(str(raw), conversation_id=conversation_id, sender_id=sender_id, target_id=target_id) + return encode(envelope(APP_CALL, validated_app_call[0], conversation_id=conversation_id, sender_id=sender_id, target_id=target_id)) + if candidate.get("v") == PROTOCOL_VERSION and message_type == TOOL_INVOKE and isinstance(payload, dict): + validated_tool_invoke = validate_tool_invoke_payload(payload, inventory) + if not validated_tool_invoke: + return text_message(str(raw), conversation_id=conversation_id, sender_id=sender_id, target_id=target_id) + return encode(envelope(TOOL_INVOKE, validated_tool_invoke, conversation_id=conversation_id, sender_id=sender_id, target_id=target_id)) + if candidate.get("v") == PROTOCOL_VERSION and message_type in AGENT_OUTPUT_TYPES and isinstance(payload, dict): + if message_type == TEXT: + # The execution association is adapter-owned. Rebuild even a + # model-shaped text envelope so the model cannot drop, spoof, or + # add unrelated metadata to this UI-only relationship. + rendered = payload.get("markdown") if isinstance(payload.get("markdown"), str) else payload.get("text") + if not isinstance(rendered, str): + rendered = str(raw) + canonical_text: dict[str, Any] = {"text": rendered} + if execution_id and _identifier(execution_id): + canonical_text["execution_id"] = execution_id + return encode(envelope(TEXT, canonical_text, conversation_id=conversation_id, sender_id=sender_id, target_id=target_id)) + if message_type == TOOL_CALL: + validated = validate_tool_call_payload(payload) + if not validated: + return text_message(str(raw), conversation_id=conversation_id, sender_id=sender_id, target_id=target_id) + payload = validated[0] + if message_type in {UI_OPEN, UI_PATCH, UI_CLOSE}: + payload = validate_surface_payload(message_type, payload) + if not payload: + return text_message(str(raw), conversation_id=conversation_id, sender_id=sender_id, target_id=target_id) + if message_type == ARTIFACT_OFFER: + payload = validate_artifact_offer_payload(payload) + if not payload: + return text_message(str(raw), conversation_id=conversation_id, sender_id=sender_id, target_id=target_id) + return encode( + envelope( + message_type, + payload, + conversation_id=conversation_id, + sender_id=sender_id, + target_id=target_id, + ) + ) + payload = {"text": str(raw)} + if execution_id and _identifier(execution_id): + payload["execution_id"] = execution_id + return encode(envelope(TEXT, payload, conversation_id=conversation_id, sender_id=sender_id, target_id=target_id)) + + +def extract_user_input(raw_payload: str) -> tuple[str, str | None]: + """Return LLM-facing text and an optional client-provided conversation ID. + + The legacy bridge is intentionally input-only. All adapter output uses the + versioned envelope above. + """ + try: + value = json.loads(raw_payload) + except (TypeError, ValueError): + return str(raw_payload), None + if not isinstance(value, dict): + return str(raw_payload), None + + conversation_id = value.get("conversation_id") + if not isinstance(conversation_id, str) or not conversation_id.strip(): + conversation_id = None + + message_type = value.get("type") + payload = value.get("payload") + if message_type == TEXT and isinstance(payload, dict) and isinstance(payload.get("text"), str): + return payload["text"], conversation_id + if message_type == TOOL_RESULT and isinstance(payload, dict): + # ``LineUpAdapter`` consumes this through ``parse_tool_response`` and + # its durable call ledger before it can reach Hermes. Keeping this + # compatibility text non-structured prevents direct callers from + # passing an unvalidated UI-shaped object to the model. + return "LineUp standard interaction response received.", conversation_id + if message_type == UI_EVENT and isinstance(payload, dict): + return "LineUp UI surface event:\n" + json.dumps(payload, ensure_ascii=False), conversation_id + if message_type == APP_RESULT and isinstance(payload, dict): + # Core consumes a separately validated and reduced app result. Never + # expose a raw client payload through this legacy compatibility path. + return "LineUp app capability result received.", conversation_id + if message_type == TOOL_CANCEL and isinstance(payload, dict): + return "LineUp standard interaction cancellation received.", conversation_id + if message_type == "choice.response": + return str(value.get("value", "")), conversation_id + return str(raw_payload), conversation_id diff --git a/lineup/run-hermes-adapter.sh b/lineup/run-hermes-adapter.sh new file mode 100755 index 0000000..7517f1e --- /dev/null +++ b/lineup/run-hermes-adapter.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# Manage the real Hermes-backed LineUp Adapter. +set -euo pipefail + +DIR="$(cd "$(dirname "$0")" && pwd)" +LOG="$DIR/hermes-adapter.log" +PIDFILE="$DIR/hermes-adapter.pid" +PYTHON_BIN="${LINEUP_PYTHON_BIN:-python3}" +RUNTIME_ENV="$DIR/.env" + +# Keep deployment-only credentials out of the plugin manifest and repository. +if [[ -f "$RUNTIME_ENV" ]]; then + set -a + # shellcheck disable=SC1090 + source "$RUNTIME_ENV" + set +a +fi + +is_running() { + [[ -f "$PIDFILE" ]] && kill -0 "$(<"$PIDFILE")" 2>/dev/null +} + +start() { + if is_running; then + echo "Hermes Adapter 已在运行 PID=$(<"$PIDFILE")" + return + fi + cd "$DIR/.." + # Detach from the invoking terminal/session. This keeps the Adapter alive + # when installed or started by an automation runner that tears down its + # process group after the command returns. + nohup setsid "$PYTHON_BIN" -m lineup.core >> "$LOG" 2>&1 < /dev/null & + echo "$!" > "$PIDFILE" + echo "Hermes Adapter 已启动 PID=$(<"$PIDFILE")" +} + +stop() { + if is_running; then + kill "$(<"$PIDFILE")" + echo "Hermes Adapter 已停止" + fi + rm -f "$PIDFILE" +} + +case "${1:-start}" in + start) start ;; + stop) stop ;; + restart) stop; start ;; + status) is_running && echo "Hermes Adapter 运行中 PID=$(<"$PIDFILE")" || echo "Hermes Adapter 未运行" ;; + check) cd "$DIR/.."; exec "$PYTHON_BIN" -m lineup.core --check ;; + *) echo "用法: $0 {start|stop|restart|status|check}"; exit 2 ;; +esac diff --git a/lineup/scripts/install.sh b/lineup/scripts/install.sh new file mode 100755 index 0000000..24fedc3 --- /dev/null +++ b/lineup/scripts/install.sh @@ -0,0 +1,27 @@ +#!/bin/bash +# Install LineUp Hermes plugin to Hermes plugins directory +set -euo pipefail + +PLUGIN_DIR="${HOME}/.hermes/plugins/lineup" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PLUGIN_SOURCE="$(cd "${SCRIPT_DIR}/.." && pwd)" + +echo "Installing LineUp plugin to ${PLUGIN_DIR}..." +mkdir -p "${PLUGIN_DIR}" + +rsync -av --delete \ + --exclude='.venv' --exclude='.git' --exclude='*.egg-info' \ + --exclude='state.sqlite3*' --exclude='*.log' --exclude='*.pid' --exclude='__pycache__' \ + "${PLUGIN_SOURCE}/" "${PLUGIN_DIR}/" + +echo "Done. Enable the plugin with: hermes plugins enable lineup" +echo "" +echo "Run the adapter separately (recommended):" +echo " ${PLUGIN_DIR}/run-hermes-adapter.sh start" +echo "" +echo "Required runtime environment:" +echo " LINEUP_APPSERVER_URL=http://127.0.0.1:8090" +echo " LINEUP_AGENT_UID=agent_hermes_main" +echo " LINEUP_AGENT_SHARED_SECRET=" +echo "" +echo "Set LINEUP_ADAPTER_AUTOSTART=1 only when Hermes itself is a long-running process." diff --git a/lineup/state.py b/lineup/state.py new file mode 100644 index 0000000..8d41e73 --- /dev/null +++ b/lineup/state.py @@ -0,0 +1,357 @@ +"""Durable inbox/outbox state for the LineUp Hermes Adapter (stdlib only).""" + +from __future__ import annotations + +import json +import sqlite3 +import time +from pathlib import Path +from typing import Any + + +class StateStore: + def __init__(self, path: str | Path): + self.path = Path(path) + self.path.parent.mkdir(parents=True, exist_ok=True) + self.db = sqlite3.connect(self.path, check_same_thread=False) + self.db.row_factory = sqlite3.Row + self.db.execute("PRAGMA journal_mode=WAL") + self.db.executescript( + """ + CREATE TABLE IF NOT EXISTS metadata (key TEXT PRIMARY KEY, value TEXT NOT NULL); + CREATE TABLE IF NOT EXISTS inbox ( + message_id TEXT PRIMARY KEY, + message_seq INTEGER NOT NULL, + from_uid TEXT NOT NULL, + payload TEXT NOT NULL, + state TEXT NOT NULL DEFAULT 'pending', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + error TEXT + ); + CREATE TABLE IF NOT EXISTS outbox ( + id TEXT PRIMARY KEY, + payload TEXT NOT NULL, + created_at INTEGER NOT NULL, + attempts INTEGER NOT NULL DEFAULT 0, + last_error TEXT + ); + CREATE TABLE IF NOT EXISTS tool_calls ( + conversation_id TEXT NOT NULL, + call_id TEXT NOT NULL, + tool TEXT NOT NULL, + expires_at REAL NOT NULL, + response_schema TEXT NOT NULL, + state TEXT NOT NULL DEFAULT 'emitted', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + PRIMARY KEY (conversation_id, call_id) + ); + CREATE TABLE IF NOT EXISTS tool_audit ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + conversation_id TEXT NOT NULL, + call_id TEXT NOT NULL, + event TEXT NOT NULL, + disposition TEXT NOT NULL, + created_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS tool_audit_conversation_call + ON tool_audit(conversation_id, call_id, id); + CREATE TABLE IF NOT EXISTS app_calls ( + conversation_id TEXT NOT NULL, + call_id TEXT NOT NULL, + capability TEXT NOT NULL, + expires_at REAL NOT NULL, + state TEXT NOT NULL DEFAULT 'emitted', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + PRIMARY KEY (conversation_id, call_id) + ); + CREATE TABLE IF NOT EXISTS app_audit ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + conversation_id TEXT NOT NULL, + call_id TEXT NOT NULL, + event TEXT NOT NULL, + disposition TEXT NOT NULL, + created_at INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS hermes_interactions ( + conversation_id TEXT NOT NULL, + call_id TEXT NOT NULL, + kind TEXT NOT NULL, + session_id TEXT NOT NULL, + acp_request_id INTEGER NOT NULL, + option_map TEXT NOT NULL, + state TEXT NOT NULL DEFAULT 'pending', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + PRIMARY KEY (conversation_id, call_id) + ); + """ + ) + self.db.commit() + + def close(self) -> None: + self.db.close() + + def cursor(self) -> int: + row = self.db.execute("SELECT value FROM metadata WHERE key='cursor'").fetchone() + return int(row["value"]) if row else 0 + + def set_cursor(self, value: int) -> None: + self.db.execute( + "INSERT INTO metadata(key, value) VALUES('cursor', ?) " + "ON CONFLICT(key) DO UPDATE SET value=excluded.value", + (str(value),), + ) + self.db.commit() + + def is_bootstrapped(self) -> bool: + row = self.db.execute("SELECT value FROM metadata WHERE key='bootstrapped'").fetchone() + return bool(row and row["value"] == "1") + + def set_bootstrapped(self) -> None: + self.db.execute( + "INSERT INTO metadata(key, value) VALUES('bootstrapped', '1') " + "ON CONFLICT(key) DO UPDATE SET value='1'" + ) + self.db.commit() + + def claim_inbox(self, message: dict[str, Any]) -> bool: + now = int(time.time()) + message_id = str(message.get("message_id") or f"seq:{message.get('message_seq')}") + cur = self.db.execute( + "INSERT OR IGNORE INTO inbox(message_id, message_seq, from_uid, payload, state, created_at, updated_at) " + "VALUES (?, ?, ?, ?, 'pending', ?, ?)", + ( + message_id, + int(message.get("message_seq") or 0), + str(message.get("from_uid") or ""), + str(message.get("payload") or ""), + now, + now, + ), + ) + self.db.commit() + return cur.rowcount == 1 + + def pending(self) -> list[sqlite3.Row]: + return self.db.execute( + "SELECT * FROM inbox WHERE state IN ('pending', 'running') ORDER BY message_seq ASC" + ).fetchall() + + def recover_interrupted_turns(self) -> None: + """A subprocess cannot survive an adapter restart. + + Returning its inbox rows to ``pending`` gives the user message one + deterministic retry instead of leaving it permanently stuck in the + old worker's ``running`` state. + """ + self.db.execute( + "UPDATE inbox SET state='pending', updated_at=?, error='adapter restarted before Hermes completed' " + "WHERE state='running'", + (int(time.time()),), + ) + self.db.commit() + + def recover_pending_hermes_interactions(self) -> None: + """Expire adapter-owned interactive bridges that cannot survive restart.""" + now = int(time.time()) + self.db.execute( + "UPDATE tool_calls SET state='expired', updated_at=? " + "WHERE state='emitted' AND EXISTS (" + " SELECT 1 FROM hermes_interactions hi " + " WHERE hi.conversation_id=tool_calls.conversation_id AND hi.call_id=tool_calls.call_id AND hi.state='pending'" + ")", + (now,), + ) + self.db.execute( + "UPDATE hermes_interactions SET state='aborted', updated_at=? WHERE state='pending'", + (now,), + ) + self.db.commit() + + def set_inbox_state(self, message_id: str, state: str, error: str | None = None) -> None: + self.db.execute( + "UPDATE inbox SET state=?, updated_at=?, error=? WHERE message_id=?", + (state, int(time.time()), error, message_id), + ) + self.db.commit() + + def register_tool_call( + self, conversation_id: str, call_id: str, tool: str, expires_at: float, response_schema: dict[str, Any] + ) -> str: + """Durably reserve a call before it can be sent to the client.""" + now = int(time.time()) + cur = self.db.execute( + "INSERT OR IGNORE INTO tool_calls(conversation_id, call_id, tool, expires_at, response_schema, state, created_at, updated_at) " + "VALUES (?, ?, ?, ?, ?, 'emitted', ?, ?)", + (conversation_id, call_id, tool, expires_at, json.dumps(response_schema, ensure_ascii=False, separators=(",", ":")), now, now), + ) + disposition = "accepted" if cur.rowcount == 1 else "duplicate" + self._audit_tool(conversation_id, call_id, "call", disposition, now) + self.db.commit() + return disposition + + def tool_call(self, conversation_id: str, call_id: str) -> sqlite3.Row | None: + return self.db.execute( + "SELECT * FROM tool_calls WHERE conversation_id=? AND call_id=?", (conversation_id, call_id) + ).fetchone() + + def register_hermes_interaction( + self, + conversation_id: str, + call_id: str, + *, + kind: str, + session_id: str, + acp_request_id: int, + option_map: dict[str, Any], + ) -> str: + now = int(time.time()) + cur = self.db.execute( + "INSERT OR IGNORE INTO hermes_interactions(" + "conversation_id, call_id, kind, session_id, acp_request_id, option_map, state, created_at, updated_at" + ") VALUES (?, ?, ?, ?, ?, ?, 'pending', ?, ?)", + ( + conversation_id, + call_id, + kind, + session_id, + acp_request_id, + json.dumps(option_map, ensure_ascii=False, separators=(",", ":")), + now, + now, + ), + ) + self.db.commit() + return "accepted" if cur.rowcount == 1 else "duplicate" + + def hermes_interaction(self, conversation_id: str, call_id: str) -> sqlite3.Row | None: + return self.db.execute( + "SELECT * FROM hermes_interactions WHERE conversation_id=? AND call_id=?", + (conversation_id, call_id), + ).fetchone() + + def complete_hermes_interaction(self, conversation_id: str, call_id: str, state: str) -> None: + self.db.execute( + "UPDATE hermes_interactions SET state=?, updated_at=? WHERE conversation_id=? AND call_id=?", + (state, int(time.time()), conversation_id, call_id), + ) + self.db.commit() + + def consume_tool_response(self, conversation_id: str, call_id: str, event: str) -> str: + """Atomically claim one client result/cancellation without storing it. + + The result itself can contain private form values, so it is forwarded to + the model only in memory after schema validation and never enters this + ledger or its audit trail. + """ + now = int(time.time()) + row = self.tool_call(conversation_id, call_id) + if not row: + self._audit_tool(conversation_id, call_id, event, "rejected", now) + self.db.commit() + return "rejected" + if float(row["expires_at"]) <= time.time(): + self.db.execute( + "UPDATE tool_calls SET state='expired', updated_at=? WHERE conversation_id=? AND call_id=? AND state='emitted'", + (now, conversation_id, call_id), + ) + self._audit_tool(conversation_id, call_id, event, "expired", now) + self.db.commit() + return "expired" + if row["state"] != "emitted": + self._audit_tool(conversation_id, call_id, event, "duplicate", now) + self.db.commit() + return "duplicate" + self.db.execute( + "UPDATE tool_calls SET state=?, updated_at=? WHERE conversation_id=? AND call_id=? AND state='emitted'", + ("completed" if event == "result" else "cancelled", now, conversation_id, call_id), + ) + self._audit_tool(conversation_id, call_id, event, "accepted", now) + self.db.commit() + return "accepted" + + def tool_audit(self, conversation_id: str, call_id: str) -> list[sqlite3.Row]: + return self.db.execute( + "SELECT event, disposition FROM tool_audit WHERE conversation_id=? AND call_id=? ORDER BY id ASC", + (conversation_id, call_id), + ).fetchall() + + def record_tool_audit(self, conversation_id: str, call_id: str, event: str, disposition: str) -> None: + self._audit_tool(conversation_id, call_id, event, disposition, int(time.time())) + self.db.commit() + + def register_app_call(self, conversation_id: str, call_id: str, capability: str, expires_at: float) -> str: + """Durably reserve a capability call before it can reach the host.""" + now = int(time.time()) + cur = self.db.execute( + "INSERT OR IGNORE INTO app_calls(conversation_id, call_id, capability, expires_at, state, created_at, updated_at) " + "VALUES (?, ?, ?, ?, 'emitted', ?, ?)", + (conversation_id, call_id, capability, expires_at, now, now), + ) + disposition = "accepted" if cur.rowcount == 1 else "duplicate" + self._audit_app(conversation_id, call_id, "call", disposition, now) + self.db.commit() + return disposition + + def consume_app_result(self, conversation_id: str, call_id: str, status: str) -> str: + """Atomically accept one terminal host result without storing payload data.""" + now = int(time.time()) + row = self.db.execute("SELECT * FROM app_calls WHERE conversation_id=? AND call_id=?", (conversation_id, call_id)).fetchone() + if not row: + self._audit_app(conversation_id, call_id, "result", "rejected", now) + self.db.commit() + return "rejected" + if float(row["expires_at"]) <= time.time() and row["state"] == "emitted": + self.db.execute("UPDATE app_calls SET state='expired', updated_at=? WHERE conversation_id=? AND call_id=? AND state='emitted'", (now, conversation_id, call_id)) + self._audit_app(conversation_id, call_id, "result", "expired", now) + self.db.commit() + return "expired" + if row["state"] != "emitted": + self._audit_app(conversation_id, call_id, "result", "duplicate", now) + self.db.commit() + return "duplicate" + self.db.execute("UPDATE app_calls SET state=?, updated_at=? WHERE conversation_id=? AND call_id=? AND state='emitted'", (status, now, conversation_id, call_id)) + self._audit_app(conversation_id, call_id, "result", "accepted", now) + self.db.commit() + return "accepted" + + def _audit_app(self, conversation_id: str, call_id: str, event: str, disposition: str, now: int) -> None: + self.db.execute("INSERT INTO app_audit(conversation_id, call_id, event, disposition, created_at) VALUES (?, ?, ?, ?, ?)", (conversation_id, call_id, event, disposition, now)) + + def _audit_tool(self, conversation_id: str, call_id: str, event: str, disposition: str, now: int) -> None: + self.db.execute( + "INSERT INTO tool_audit(conversation_id, call_id, event, disposition, created_at) VALUES (?, ?, ?, ?, ?)", + (conversation_id, call_id, event, disposition, now), + ) + + def enqueue_outbox(self, payload: str) -> str: + item_id = f"out_{time.time_ns()}" + self.db.execute( + "INSERT INTO outbox(id, payload, created_at) VALUES (?, ?, ?)", + (item_id, payload, int(time.time())), + ) + self.db.commit() + return item_id + + def outbox_items(self) -> list[sqlite3.Row]: + return self.db.execute("SELECT * FROM outbox ORDER BY created_at ASC").fetchall() + + def mark_outbox_sent(self, item_id: str) -> None: + self.db.execute("DELETE FROM outbox WHERE id=?", (item_id,)) + self.db.commit() + + def mark_outbox_failed(self, item_id: str, error: str) -> None: + self.db.execute( + "UPDATE outbox SET attempts=attempts+1, last_error=? WHERE id=?", (error[:1000], item_id) + ) + self.db.commit() + + def status_summary(self) -> str: + cursor = self.cursor() + pending = self.db.execute("SELECT count(*) AS n FROM inbox WHERE state != 'done'").fetchone()["n"] + outbox = self.db.execute("SELECT count(*) AS n FROM outbox").fetchone()["n"] + active_tools = self.db.execute("SELECT count(*) AS n FROM tool_calls WHERE state='emitted'").fetchone()["n"] + return json.dumps({"cursor": cursor, "pending_inbox": pending, "pending_outbox": outbox, "active_tools": active_tools}, ensure_ascii=False) diff --git a/lineup/tests/test_acp_client.py b/lineup/tests/test_acp_client.py new file mode 100644 index 0000000..5172bf7 --- /dev/null +++ b/lineup/tests/test_acp_client.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +import sys +import threading +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + +from lineup.acp_client import HermesACPClient, public_tool_update + + +class ACPProjectionTest(unittest.TestCase): + def test_projects_real_hermes_lifecycle_without_exposing_arguments_or_titles(self) -> None: + started = public_tool_update({ + "sessionUpdate": "tool_call", "toolCallId": "tool_001", "kind": "fetch", + "title": "curl http://private-host/health?token=secret", + "content": [{"type": "content", "text": "private token"}], + }) + completed = public_tool_update({ + "sessionUpdate": "tool_call_update", "toolCallId": "tool_001", "status": "completed", + "content": [{"type": "content", "text": "private output"}], + }, {"tool_001": "读取公开网页内容"}) + self.assertEqual(("tool_001", "读取公开网页内容", "running"), (started.tool_id, started.title, started.status)) + self.assertEqual(("tool_001", "读取公开网页内容", "completed"), (completed.tool_id, completed.title, completed.status)) + + def test_projects_real_kind_categories_and_terminal_failure(self) -> None: + labels = { + "read": "读取相关配置", + "search": "检索相关文件", + "fetch": "读取公开网页内容", + "execute": "执行已批准的系统检查", + } + for index, (tool_kind, label) in enumerate(labels.items(), start=1): + tool_id = f"tool_{index:03d}" + started = public_tool_update({"sessionUpdate": "tool_call", "toolCallId": tool_id, "kind": tool_kind}) + failed = public_tool_update({"sessionUpdate": "tool_call_update", "toolCallId": tool_id, "status": "failed"}, {tool_id: label}) + self.assertEqual((tool_id, label, "running"), (started.tool_id, started.title, started.status)) + self.assertEqual((tool_id, label, "failed"), (failed.tool_id, failed.title, failed.status)) + + def test_rejects_thoughts_unknown_tools_and_unsafe_ids(self) -> None: + self.assertIsNone(public_tool_update({"sessionUpdate": "agent_thought_chunk", "content": {"text": "secret"}})) + self.assertIsNone(public_tool_update({"sessionUpdate": "tool_call", "toolCallId": "1bad", "kind": "execute"})) + self.assertIsNone(public_tool_update({"sessionUpdate": "tool_call", "toolCallId": "tool_001", "kind": "edit"})) + self.assertIsNone(public_tool_update({"sessionUpdate": "tool_call_update", "toolCallId": "tool_001", "status": "completed"})) + + def test_projects_permission_request_and_selected_response(self) -> None: + seen = [] + sent = [] + client = HermesACPClient( + "hermes", + ".", + on_update=lambda params: None, + on_request=lambda request: seen.append(request), + on_closed=lambda error: None, + ) + client._send = lambda message: sent.append(message) # type: ignore[method-assign] + + message = { + "jsonrpc": "2.0", + "id": 7, + "method": "session/request_permission", + "params": { + "sessionId": "session-1", + "toolCall": { + "toolCallId": "perm-check-1", + "title": "dangerous command: rm -rf /tmp/demo", + "content": [{"content": {"type": "text", "text": "dangerous command\n$ rm -rf /tmp/demo"}}], + }, + "options": [ + {"optionId": "allow_once", "kind": "allow_once", "name": "Allow once"}, + {"optionId": "allow_session", "kind": "allow_always", "name": "Allow for session"}, + {"optionId": "deny", "kind": "reject_once", "name": "Deny"}, + ], + }, + } + + worker = threading.Thread(target=client._handle_server_request, args=(message,)) + worker.start() + for _ in range(50): + if seen: + break + worker.join(0.01) + self.assertTrue(seen) + request = seen[0] + self.assertEqual("exec_approval", request.kind) + self.assertEqual("session-1", request.session_id) + self.assertEqual(("action_01", "allow_once"), (request.options[0].action_id, request.options[0].option_id)) + self.assertIn("rm -rf /tmp/demo", request.prompt) + + self.assertTrue(client.respond_permission(request.request_id, "allow_session")) + worker.join(1.0) + + self.assertEqual({ + "jsonrpc": "2.0", + "id": 7, + "result": {"outcome": {"optionId": "allow_session", "outcome": "selected"}}, + }, sent[0]) diff --git a/lineup/tests/test_core.py b/lineup/tests/test_core.py new file mode 100644 index 0000000..8ed3c13 --- /dev/null +++ b/lineup/tests/test_core.py @@ -0,0 +1,406 @@ +from __future__ import annotations + +import sys +import tempfile +import unittest +import json +import time +from pathlib import Path +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + +from lineup.acp_client import ACPInteractiveOption, ACPInteractiveRequest +from lineup.core import AdapterConfig, LineUpAdapter, RunningTurn +from lineup.state import StateStore + + +class CoreIsolationTest(unittest.TestCase): + @staticmethod + def _config(temp_dir: str) -> AdapterConfig: + return AdapterConfig( + appserver_url="http://127.0.0.1:8090", agent_uid="agent", channel_id="channel-a", channel_type=2, + poll_interval=2, request_timeout=1, hermes_timeout=10, hermes_bin="hermes", hermes_cwd=temp_dir, + state_path=str(Path(temp_dir) / "state.sqlite3"), hermes_state_path=str(Path(temp_dir) / "hermes-state.db"), + agent_secret="", ignored_senders=frozenset(), replay_history=False, + ) + + def test_client_claimed_conversation_cannot_switch_the_hermes_session(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + config = self._config(temp_dir) + adapter = LineUpAdapter(config) + try: + canonical = adapter._conversation_id("user-a", "attacker-selected-conversation") + self.assertEqual("lineup:2:channel-a:user-a", canonical) + self.assertNotEqual(canonical, adapter._conversation_id("user-b", "attacker-selected-conversation")) + self.assertEqual(adapter._session_name(canonical), adapter._session_name(adapter._conversation_id("user-a", None))) + finally: + adapter.close() + + def test_acp_tool_event_is_sent_immediately_with_a_safe_public_label(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + adapter = LineUpAdapter(self._config(temp_dir)) + try: + class Client: + session_id = "session-1" + def close(self) -> None: pass + running = RunningTurn("message-1", "user", "conv", None, time.monotonic(), "exec_001", "marker", acp=Client()) + adapter.running["user"] = running + adapter.acp_events.put(("update", {"sessionId": "session-1", "update": { + "sessionUpdate": "tool_call", "toolCallId": "tool_001", "kind": "fetch", + "title": "curl http://private-host/health?token=secret", + "content": [{"type": "content", "text": "secret"}], + }}, None)) + with patch.object(adapter, "_send_or_queue") as send: + adapter._flush_acp_events() + wire = json.loads(send.call_args.args[0]) + self.assertEqual("lineup.v1.agent.execution", wire["type"]) + self.assertEqual("读取公开网页内容", wire["payload"]["steps"][0]["title"]) + self.assertNotIn("private-host", str(wire)) + self.assertNotIn("secret", str(wire)) + finally: + adapter.close() + + def test_timeout_finishes_its_exact_execution_without_inventing_steps(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + adapter = LineUpAdapter(self._config(temp_dir)) + try: + process = __import__("subprocess").Popen(["true"], text=True, stdout=__import__("subprocess").PIPE, stderr=__import__("subprocess").PIPE) + adapter.running["user"] = RunningTurn("message-1", "user", "conv", process, time.monotonic() - 20, "exec_001", "marker") + with patch.object(adapter, "_send_or_queue") as send: + adapter._reap_turns() + trace = json.loads(send.call_args_list[0].args[0]) + self.assertEqual("lineup.v1.agent.execution", trace["type"]) + self.assertEqual({"execution_id": "exec_001", "status": "failed", "steps": []}, trace["payload"]) + finally: + adapter.close() + + def test_app_capability_result_has_a_durable_one_shot_ledger(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + store = StateStore(Path(temp_dir) / "state.sqlite3") + try: + expires = time.time() + 60 + self.assertEqual("accepted", store.register_app_call("conv", "app-call-1", "app.open_url", expires)) + self.assertEqual("duplicate", store.register_app_call("conv", "app-call-1", "app.open_url", expires)) + self.assertEqual("accepted", store.consume_app_result("conv", "app-call-1", "rejected")) + self.assertEqual("duplicate", store.consume_app_result("conv", "app-call-1", "rejected")) + self.assertEqual("rejected", store.consume_app_result("conv", "unknown", "completed")) + finally: + store.close() + + def test_permission_request_becomes_tool_call_and_user_choice_resolves_acp(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + adapter = LineUpAdapter(self._config(temp_dir)) + try: + conversation_id = adapter._conversation_id("user", None) + class Client: + session_id = "session-1" + def __init__(self) -> None: + self.responses: list[tuple[int, str | None]] = [] + def close(self) -> None: pass + def respond_interaction(self, request_id: int, option_id: str | None) -> bool: + self.responses.append((request_id, option_id)) + return True + + client = Client() + running = RunningTurn("message-1", "user", conversation_id, None, time.monotonic(), "exec_001", "marker", acp=client) + adapter.running["user"] = running + adapter.acp_clients[conversation_id] = client + request = ACPInteractiveRequest( + kind="exec_approval", + request_id=11, + session_id="session-1", + title="Hermes 请求权限", + prompt="dangerous command\n$ rm -rf /tmp/demo", + options=( + ACPInteractiveOption(action_id="action_01", option_id="allow_once", label="Allow once"), + ACPInteractiveOption(action_id="action_02", option_id="allow_session", label="Allow for session"), + ACPInteractiveOption(action_id="action_03", option_id="deny", label="Deny"), + ), + ) + adapter.acp_events.put(("request", request, client)) + with patch.object(adapter, "_send_or_queue") as send: + adapter._flush_acp_events() + wire = json.loads(send.call_args.args[0]) + self.assertEqual("lineup.v1.tool.call", wire["type"]) + call_id = wire["payload"]["call_id"] + self.assertEqual("choice", wire["payload"]["tool"]) + interaction = adapter.store.hermes_interaction(conversation_id, call_id) + self.assertIsNotNone(interaction) + + response_payload = json.dumps({ + "v": 1, + "type": "lineup.v1.tool.result", + "payload": { + "call_id": call_id, + "status": "completed", + # This is the exact single-choice shape emitted by the + # Host card interaction. + "result": {"action_id": "action_02"}, + }, + }, ensure_ascii=False, separators=(",", ":")) + with patch.object(adapter, "_send_or_queue") as send: + adapter._start_turn({ + "message_id": "message-2", + "from_uid": "user", + "payload": response_payload, + }) + echoed = json.loads(send.call_args.args[0]) + self.assertEqual("lineup.v1.tool.result", echoed["type"]) + self.assertEqual([(11, "allow_session")], client.responses) + self.assertEqual("resolved", adapter.store.hermes_interaction(conversation_id, call_id)["state"]) + finally: + adapter.close() + + def test_update_prompt_request_becomes_confirm_and_resolves_yes(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + adapter = LineUpAdapter(self._config(temp_dir)) + try: + conversation_id = adapter._conversation_id("user", None) + + class Client: + session_id = "session-1" + def __init__(self) -> None: + self.responses: list[tuple[int, str | None]] = [] + def close(self) -> None: pass + def respond_interaction(self, request_id: int, outcome: str | None) -> bool: + self.responses.append((request_id, outcome)) + return True + + client = Client() + adapter.running["user"] = RunningTurn("message-1", "user", conversation_id, None, time.monotonic(), "exec_001", "marker", acp=client) + adapter.acp_clients[conversation_id] = client + request = ACPInteractiveRequest( + kind="update_prompt", + request_id=21, + session_id="session-1", + title="更新提示", + prompt="是否按新的提示继续?", + ) + adapter.acp_events.put(("request", request, client)) + with patch.object(adapter, "_send_or_queue") as send: + adapter._flush_acp_events() + wire = json.loads(send.call_args.args[0]) + self.assertEqual("confirm", wire["payload"]["tool"]) + call_id = wire["payload"]["call_id"] + response_payload = json.dumps({ + "v": 1, + "type": "lineup.v1.tool.result", + "payload": { + "call_id": call_id, + "status": "completed", + "result": {"confirmed": True}, + }, + }, ensure_ascii=False, separators=(",", ":")) + adapter._start_turn({"message_id": "message-2", "from_uid": "user", "payload": response_payload}) + self.assertEqual([(21, "y")], client.responses) + finally: + adapter.close() + + def test_clarify_choice_resolves_selected_value(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + adapter = LineUpAdapter(self._config(temp_dir)) + try: + conversation_id = adapter._conversation_id("user", None) + + class Client: + session_id = "session-1" + def __init__(self) -> None: + self.responses: list[tuple[int, str | None]] = [] + def close(self) -> None: pass + def respond_interaction(self, request_id: int, outcome: str | None) -> bool: + self.responses.append((request_id, outcome)) + return True + + client = Client() + adapter.running["user"] = RunningTurn("message-1", "user", conversation_id, None, time.monotonic(), "exec_001", "marker", acp=client) + adapter.acp_clients[conversation_id] = client + request = ACPInteractiveRequest( + kind="clarify", + request_id=31, + session_id="session-1", + title="需要澄清", + prompt="请选择部署环境。", + options=( + ACPInteractiveOption(action_id="action_01", option_id="staging", label="Staging"), + ACPInteractiveOption(action_id="action_02", option_id="production", label="Production"), + ), + ) + adapter.acp_events.put(("request", request, client)) + with patch.object(adapter, "_send_or_queue") as send: + adapter._flush_acp_events() + wire = json.loads(send.call_args.args[0]) + call_id = wire["payload"]["call_id"] + response_payload = json.dumps({ + "v": 1, + "type": "lineup.v1.tool.result", + "payload": { + "call_id": call_id, + "status": "completed", + "result": {"action_ids": ["action_02"]}, + }, + }, ensure_ascii=False, separators=(",", ":")) + adapter._start_turn({"message_id": "message-2", "from_uid": "user", "payload": response_payload}) + self.assertEqual([(31, "production")], client.responses) + finally: + adapter.close() + + def test_slash_confirm_choice_resolves_selected_value(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + adapter = LineUpAdapter(self._config(temp_dir)) + try: + conversation_id = adapter._conversation_id("user", None) + + class Client: + session_id = "session-1" + def __init__(self) -> None: + self.responses: list[tuple[int, str | None]] = [] + def close(self) -> None: pass + def respond_interaction(self, request_id: int, outcome: str | None) -> bool: + self.responses.append((request_id, outcome)) + return True + + client = Client() + adapter.running["user"] = RunningTurn("message-1", "user", conversation_id, None, time.monotonic(), "exec_001", "marker", acp=client) + adapter.acp_clients[conversation_id] = client + request = ACPInteractiveRequest( + kind="slash_confirm", + request_id=35, + session_id="session-1", + title="确认执行", + prompt="是否继续执行该 slash 命令?", + options=( + ACPInteractiveOption(action_id="action_01", option_id="once", label="仅本次"), + ACPInteractiveOption(action_id="action_02", option_id="always", label="始终允许"), + ACPInteractiveOption(action_id="action_03", option_id="cancel", label="取消"), + ), + ) + adapter.acp_events.put(("request", request, client)) + with patch.object(adapter, "_send_or_queue") as send: + adapter._flush_acp_events() + wire = json.loads(send.call_args.args[0]) + call_id = wire["payload"]["call_id"] + adapter._start_turn({ + "message_id": "message-2", + "from_uid": "user", + "payload": json.dumps({ + "v": 1, + "type": "lineup.v1.tool.result", + "payload": { + "call_id": call_id, + "status": "completed", + "result": {"action_ids": ["action_02"]}, + }, + }, ensure_ascii=False, separators=(",", ":")), + }) + self.assertEqual([(35, "always")], client.responses) + finally: + adapter.close() + + def test_clarify_other_triggers_followup_input_and_resolves_text(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + adapter = LineUpAdapter(self._config(temp_dir)) + try: + conversation_id = adapter._conversation_id("user", None) + + class Client: + session_id = "session-1" + def __init__(self) -> None: + self.responses: list[tuple[int, str | None]] = [] + def close(self) -> None: pass + def respond_interaction(self, request_id: int, outcome: str | None) -> bool: + self.responses.append((request_id, outcome)) + return True + + client = Client() + adapter.running["user"] = RunningTurn("message-1", "user", conversation_id, None, time.monotonic(), "exec_001", "marker", acp=client) + adapter.acp_clients[conversation_id] = client + request = ACPInteractiveRequest( + kind="clarify", + request_id=41, + session_id="session-1", + title="需要澄清", + prompt="请选择原因。", + options=(ACPInteractiveOption(action_id="action_01", option_id="known", label="已有选项"),), + allow_other=True, + ) + adapter.acp_events.put(("request", request, client)) + with patch.object(adapter, "_send_or_queue") as send: + adapter._flush_acp_events() + first_wire = json.loads(send.call_args.args[0]) + first_call_id = first_wire["payload"]["call_id"] + actions = first_wire["payload"]["data"]["action_group"]["actions"] + other_action_id = next(action["id"] for action in actions if action["label"] == "其他") + with patch.object(adapter, "_send_or_queue") as send: + adapter._start_turn({ + "message_id": "message-2", + "from_uid": "user", + "payload": json.dumps({ + "v": 1, + "type": "lineup.v1.tool.result", + "payload": { + "call_id": first_call_id, + "status": "completed", + "result": {"action_ids": [other_action_id]}, + }, + }, ensure_ascii=False, separators=(",", ":")), + }) + followup_wire = json.loads(send.call_args.args[0]) + self.assertEqual("input", followup_wire["payload"]["tool"]) + followup_call_id = followup_wire["payload"]["call_id"] + self.assertEqual([], client.responses) + adapter._start_turn({ + "message_id": "message-3", + "from_uid": "user", + "payload": json.dumps({ + "v": 1, + "type": "lineup.v1.tool.result", + "payload": { + "call_id": followup_call_id, + "status": "completed", + "result": {"text": "自定义说明"}, + }, + }, ensure_ascii=False, separators=(",", ":")), + }) + self.assertEqual([(41, "自定义说明")], client.responses) + finally: + adapter.close() + + def test_clarify_multi_select_is_rejected_without_emitting_host_interaction(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + adapter = LineUpAdapter(self._config(temp_dir)) + try: + conversation_id = adapter._conversation_id("user", None) + + class Client: + session_id = "session-1" + def __init__(self) -> None: + self.responses: list[tuple[int, str | None]] = [] + def close(self) -> None: pass + def respond_interaction(self, request_id: int, outcome: str | None) -> bool: + self.responses.append((request_id, outcome)) + return True + + client = Client() + adapter.running["user"] = RunningTurn("message-1", "user", conversation_id, None, time.monotonic(), "exec_001", "marker", acp=client) + adapter.acp_clients[conversation_id] = client + request = ACPInteractiveRequest( + kind="clarify", + request_id=51, + session_id="session-1", + title="需要澄清", + prompt="请选择多个选项。", + options=(ACPInteractiveOption(action_id="action_01", option_id="a", label="A"),), + multi_select=True, + ) + adapter.acp_events.put(("request", request, client)) + with patch.object(adapter, "_send_or_queue") as send: + adapter._flush_acp_events() + send.assert_not_called() + self.assertEqual([(51, None)], client.responses) + finally: + adapter.close() + + +if __name__ == "__main__": + unittest.main() diff --git a/lineup/tests/test_execution_trace.py b/lineup/tests/test_execution_trace.py new file mode 100644 index 0000000..10552d8 --- /dev/null +++ b/lineup/tests/test_execution_trace.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +import sqlite3 +import sys +import tempfile +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + +from lineup.execution_trace import extract_public_execution_steps + + +class ExecutionTraceTest(unittest.TestCase): + def test_projects_only_allowlisted_operations_without_raw_command_data(self) -> None: + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "state.db" + database = sqlite3.connect(path) + database.execute("CREATE TABLE messages (id TEXT PRIMARY KEY, session_id TEXT, role TEXT, tool_name TEXT, tool_calls TEXT, content TEXT, timestamp INTEGER)") + database.execute("INSERT INTO messages VALUES (?, ?, ?, ?, ?, ?, ?)", ("m1", "session", "user", "", "", "prompt [Internal LineUp turn marker: lineup-turn:exec_001]", 1)) + database.execute("INSERT INTO messages VALUES (?, ?, ?, ?, ?, ?, ?)", ("m2", "session", "assistant", "terminal", "", "curl http://private-host:8610/health?token=secret qmtbridge", 2)) + database.execute("INSERT INTO messages VALUES (?, ?, ?, ?, ?, ?, ?)", ("m3", "session", "assistant", "terminal", "", "curl http://private-host/openapi.json", 3)) + database.execute("INSERT INTO messages VALUES (?, ?, ?, ?, ?, ?, ?)", ("m4", "session", "assistant", "terminal", "", "curl http://private-host/kline", 4)) + database.execute("INSERT INTO messages VALUES (?, ?, ?, ?, ?, ?, ?)", ("m5", "session", "assistant", "terminal", "", "rm -rf /private/path", 5)) + database.commit() + database.close() + + steps = extract_public_execution_steps(str(path), "lineup-turn:exec_001") + self.assertEqual(["检查 qmtbridge health", "获取服务接口说明", "查询行情接口"], [step["title"] for step in steps]) + rendered = str(steps) + self.assertNotIn("private-host", rendered) + self.assertNotIn("secret", rendered) + self.assertNotIn("/private", rendered) + self.assertNotIn("rm -rf", rendered) + + def test_missing_or_unrecognized_ledger_never_invents_a_step(self) -> None: + self.assertEqual([], extract_public_execution_steps("/missing/state.db", "lineup-turn:exec_001")) diff --git a/lineup/tests/test_protocol.py b/lineup/tests/test_protocol.py new file mode 100644 index 0000000..0b0c107 --- /dev/null +++ b/lineup/tests/test_protocol.py @@ -0,0 +1,287 @@ +from __future__ import annotations + +import json +import sys +import unittest +from datetime import datetime, timedelta, timezone +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + +from lineup.protocol import APP_CALL, APP_RESULT, ARTIFACT_OFFER, CLIENT_INVENTORY, EXECUTION, TEXT, TOOL_CALL, TOOL_INVOKE, TOOL_RESULT, UI_EVENT, UI_OPEN, UI_PATCH, UI_CLOSE, agent_output_message, app_call_message, app_result_message, execution_message, extract_user_input, parse_agent_tool_call, parse_agent_tool_invoke, parse_app_result, parse_client_inventory, parse_tool_response, text_message, ui_event_message, ui_open_message, validate_app_call_payload, validate_artifact_offer_payload, validate_tool_response + + +class ProtocolTest(unittest.TestCase): + @staticmethod + def _inventory() -> dict[str, object]: + value = parse_client_inventory(json.dumps({"v": 1, "type": CLIENT_INVENTORY, "payload": { + "revision": "catalog-1", "standard_components": [], "applications": [], "tools": [], + "capabilities": [{"name": "app.open_url", "version": "1.0", "risk": "user_confirmation", "input_schema": {"type": "object", "required": ["url"], "properties": {"url": "string"}}, "foreground_required": True}], + }})) + assert value is not None + return value + + def test_client_inventory_is_bounded_declarative_context(self) -> None: + raw = json.dumps({"v": 1, "type": CLIENT_INVENTORY, "payload": { + "revision": "catalog-1", "standard_components": [{"id": "choice", "version": "1"}], + "applications": [{"id": "lineup.task-dashboard", "version": "0.1.0", "surfaces": [{"id": "task-dashboard", "events": ["cancel"]}]}], + "tools": [{"app_scope": "pomodoro", "tool_id": "pomodoro.start", "description": "开始专注", "input_schema": {"type": "object"}, "handling": "operation", "delivery": "runtime", "activation_requirement": "foreground_required"}], + "capabilities": [{"name": "app.open_url", "version": "1.0", "risk": "user_confirmation", "input_schema": {"type": "object", "required": ["url"], "properties": {"url": "string"}}, "foreground_required": True}], + }}) + value = parse_client_inventory(raw) + self.assertEqual("catalog-1", value["revision"] if value else None) + self.assertEqual("pomodoro.start", value["tools"][0]["tool_id"] if value else None) + self.assertIsNone(parse_client_inventory(raw.replace("catalog-1", "x" * 129))) + + def test_inventory_rejects_tools_with_unsafe_extra_fields(self) -> None: + raw = json.dumps({"v": 1, "type": CLIENT_INVENTORY, "payload": { + "revision": "catalog-1", "standard_components": [], "applications": [], "tools": [{ + "app_scope": "pomodoro", "tool_id": "pomodoro.start", "description": "开始专注", "input_schema": {"type": "object"}, + "handling": "operation", "delivery": "runtime", "activation_requirement": "foreground_required", "handler_key": "bad", + }], "capabilities": [], + }}) + self.assertIsNone(parse_client_inventory(raw)) + + def test_execution_trace_is_adapter_shaped_and_has_no_raw_operation_fields(self) -> None: + value = json.loads(execution_message("exec_001", "completed", [{"id": "step_01", "title": "检查 qmtbridge health", "status": "completed", "command": "curl secret"}], conversation_id="conv-1", sender_id="agent")) + self.assertEqual(EXECUTION, value["type"]) + self.assertEqual({"id": "step_01", "title": "检查 qmtbridge health", "status": "completed"}, value["payload"]["steps"][0]) + def test_round_trip_text_envelope(self) -> None: + payload = text_message("你好", conversation_id="conv-1", sender_id="agent") + value = json.loads(payload) + self.assertEqual(TEXT, value["type"]) + self.assertEqual("你好", value["payload"]["text"]) + self.assertEqual("conv-1", value["conversation_id"]) + + def test_final_text_can_carry_only_its_execution_association(self) -> None: + value = json.loads(agent_output_message("天气已查询。", conversation_id="conv-1", sender_id="agent", execution_id="exec_001")) + self.assertEqual("exec_001", value["payload"]["execution_id"]) + self.assertEqual("天气已查询。", value["payload"]["text"]) + + def test_model_shaped_text_is_rebuilt_with_adapter_execution_association(self) -> None: + value = json.loads(agent_output_message(json.dumps({"v": 1, "type": TEXT, "payload": {"text": "已分析", "untrusted": "discard"}}), conversation_id="conv-1", sender_id="agent", execution_id="exec_001")) + self.assertEqual({"text": "已分析", "execution_id": "exec_001"}, value["payload"]) + + def test_extracts_lineup_text(self) -> None: + raw = json.dumps({"type": TEXT, "conversation_id": "conv-1", "payload": {"text": "hello"}}) + self.assertEqual(("hello", "conv-1"), extract_user_input(raw)) + + def test_extracts_structured_tool_result(self) -> None: + raw = json.dumps({"v": 1, "type": TOOL_RESULT, "payload": {"call_id": "call-1", "status": "completed", "result": {}}}) + text, conversation = extract_user_input(raw) + self.assertEqual("LineUp standard interaction response received.", text) + self.assertIsNone(conversation) + + def test_ui_surface_messages_are_versioned_and_keep_the_instance_id(self) -> None: + opened = json.loads( + ui_open_message( + "surface_weather_1", + {"id": "weather", "version": "1.0.0", "html": "
Weather
"}, + conversation_id="conv-1", + sender_id="agent", + state={"city": "北京"}, + ) + ) + self.assertEqual(UI_OPEN, opened["type"]) + self.assertEqual("surface_weather_1", opened["payload"]["instance_id"]) + self.assertEqual("北京", opened["payload"]["state"]["city"]) + + event = json.loads( + ui_event_message( + "surface_weather_1", "refresh", {"unit": "c"}, + conversation_id="conv-1", sender_id="user-1", target_id="agent", + ) + ) + self.assertEqual(UI_EVENT, event["type"]) + self.assertEqual("human", event["sender"]["kind"]) + self.assertEqual("refresh", event["payload"]["event"]) + + def test_app_capability_call_and_result_keep_their_call_id(self) -> None: + call = json.loads( + app_call_message( + "call-1", "app.open_url", {"url": "https://example.com"}, + conversation_id="conv-1", sender_id="agent", target_id="user-1", reason="打开文档", + ) + ) + self.assertEqual(APP_CALL, call["type"]) + self.assertEqual("call-1", call["payload"]["call_id"]) + + result = json.loads( + app_result_message( + "call-1", "completed", conversation_id="conv-1", sender_id="user-1", target_id="agent", result={"opened": True}, + ) + ) + self.assertEqual(APP_RESULT, result["type"]) + self.assertTrue(result["payload"]["result"]["opened"]) + readable, conversation = extract_user_input(json.dumps(result)) + self.assertEqual("conv-1", conversation) + self.assertEqual("LineUp app capability result received.", readable) + + def test_agent_app_call_requires_current_inventory_and_strict_shape(self) -> None: + expires_at = (datetime.now(timezone.utc) + timedelta(minutes=5)).isoformat() + raw = json.dumps({"v": 1, "type": APP_CALL, "payload": { + "call_id": "app-call-1", "capability": "app.open_url", "reason": "打开文档", "expires_at": expires_at, + "arguments": {"url": "https://example.com"}, + }}) + accepted = json.loads(agent_output_message(raw, conversation_id="conv-1", sender_id="agent", inventory=self._inventory())) + self.assertEqual(APP_CALL, accepted["type"]) + self.assertEqual({"call_id", "capability", "reason", "expires_at", "arguments"}, set(accepted["payload"])) + self.assertEqual(TEXT, json.loads(agent_output_message(raw, conversation_id="conv-1", sender_id="agent"))["type"]) + self.assertIsNone(validate_app_call_payload({"call_id": "app-call-1", "capability": "app.open_url", "reason": "打开", "expires_at": expires_at, "arguments": {"url": "https://example.com", "x": "bad"}}, self._inventory())) + self.assertIsNone(validate_app_call_payload({"call_id": "app-call-1", "capability": "app.open_url", "reason": "打开 https://example.com", "expires_at": expires_at, "arguments": {"url": "https://example.com"}}, self._inventory())) + self.assertIsNone(validate_app_call_payload({"call_id": "app-call-1", "capability": "clipboard.write", "reason": "写", "expires_at": expires_at, "arguments": {"text": "x"}}, self._inventory())) + + def test_agent_tool_invoke_requires_current_inventory_and_strict_shape(self) -> None: + inventory = parse_client_inventory(json.dumps({"v": 1, "type": CLIENT_INVENTORY, "payload": { + "revision": "catalog-1", "standard_components": [], "applications": [], "tools": [ + {"app_scope": "pomodoro", "tool_id": "pomodoro.start", "description": "开始专注", "input_schema": {"type": "object"}, "handling": "operation", "delivery": "runtime", "activation_requirement": "foreground_required"} + ], "capabilities": [], + }})) + self.assertIsNotNone(inventory) + raw = json.dumps({"v": 1, "type": TOOL_INVOKE, "payload": { + "call_id": "tool-call-1", "inventory_revision": "catalog-1", "app_scope": "pomodoro", "tool_id": "pomodoro.start", + "input": {"duration_seconds": 300}, + }}) + accepted = json.loads(agent_output_message(raw, conversation_id="conv-1", sender_id="agent", inventory=inventory)) + self.assertEqual(TOOL_INVOKE, accepted["type"]) + self.assertEqual("pomodoro.start", accepted["payload"]["tool_id"]) + self.assertEqual(TEXT, json.loads(agent_output_message(raw, conversation_id="conv-1", sender_id="agent"))["type"]) + self.assertIsNotNone(parse_agent_tool_invoke(raw, inventory)) + self.assertIsNone(parse_agent_tool_invoke(json.dumps({"v": 1, "type": TOOL_INVOKE, "payload": { + "call_id": "tool-call-1", "inventory_revision": "catalog-1", "app_scope": "pomodoro", "tool_id": "pomodoro.start", + "input": {}, "instance_id": "bad", + }}), inventory)) + self.assertIsNone(parse_agent_tool_invoke(json.dumps({"v": 1, "type": TOOL_INVOKE, "payload": { + "call_id": "tool-call-1", "inventory_revision": "wrong", "app_scope": "pomodoro", "tool_id": "pomodoro.start", + "input": {}, + }}), inventory)) + + def test_app_result_is_reduced_before_it_can_reach_the_model(self) -> None: + safe = json.dumps({"v": 1, "type": APP_RESULT, "payload": {"call_id": "app-call-1", "capability": "clipboard.write", "status": "completed", "completed": True}}) + self.assertEqual({"call_id": "app-call-1", "capability": "clipboard.write", "status": "completed", "completed": True}, parse_app_result(safe)) + unsafe = json.dumps({"v": 1, "type": APP_RESULT, "payload": {"call_id": "app-call-1", "capability": "clipboard.write", "status": "completed", "clipboard": "secret"}}) + self.assertIsNone(parse_app_result(unsafe)) + + def test_agent_artifact_content_is_bounded_and_has_no_url_or_path(self) -> None: + payload = {"artifact_id": "report_001", "name": "report.txt", "mime_type": "text/plain", "size_bytes": 5, "integrity": "verified", "created_at": datetime.now(timezone.utc).isoformat(), "local_ref": "cache-report-001", "content_base64": "aGVsbG8="} + canonical = validate_artifact_offer_payload(payload) + self.assertEqual("aGVsbG8=", canonical["content_base64"] if canonical else None) + promoted = json.loads(agent_output_message(json.dumps({"v": 1, "type": ARTIFACT_OFFER, "payload": payload}), conversation_id="conv-1", sender_id="agent")) + self.assertEqual(ARTIFACT_OFFER, promoted["type"]) + self.assertEqual(TEXT, json.loads(agent_output_message(json.dumps({"v": 1, "type": ARTIFACT_OFFER, "payload": {**payload, "url": "https://bad.example"}}), conversation_id="conv-1", sender_id="agent"))["type"]) + self.assertIsNone(validate_artifact_offer_payload({**payload, "size_bytes": 4})) + + def test_allows_only_valid_standard_tool_calls_from_the_agent(self) -> None: + expires_at = (datetime.now(timezone.utc) + timedelta(minutes=5)).isoformat() + choice = json.loads(agent_output_message( + json.dumps({"v": 1, "type": TOOL_CALL, "payload": { + "call_id": "call-choice", "tool": "choice", "title": "环境", "prompt": "选择环境", "expires_at": expires_at, + "data": {"action_group": {"mode": "single-choice", "actions": [{"id": "staging", "label": "Staging"}]}, "html": ""}, + }}), + conversation_id="conv-1", sender_id="agent", target_id="user-1", + )) + self.assertEqual(TOOL_CALL, choice["type"]) + self.assertEqual("choice", choice["payload"]["tool"]) + self.assertNotIn("html", choice["payload"]["data"]) + self.assertEqual("agent", choice["sender"]["kind"]) + self.assertEqual("conv-1", choice["conversation_id"]) + + confirm = parse_agent_tool_call(json.dumps({"v": 1, "type": TOOL_CALL, "payload": { + "call_id": "call-confirm", "tool": "confirm", "title": "确认", "prompt": "继续吗", "expires_at": expires_at, + "data": {"confirm": {"approve_label": "继续", "cancel_label": "停止"}}, + }})) + self.assertIsNotNone(confirm) + form = parse_agent_tool_call(json.dumps({"v": 1, "type": TOOL_CALL, "payload": { + "call_id": "call-input", "tool": "input", "title": "版本", "prompt": "输入版本", "expires_at": expires_at, + "data": {"form": {"fields": [{"id": "version", "label": "Version", "type": "text", "required": True, "max_length": 32}], "submit_label": "提交", "cancel_label": "取消"}}, + }})) + self.assertIsNotNone(form) + self.assertIsNone(parse_agent_tool_call(json.dumps({"v": 1, "type": TOOL_CALL, "payload": { + "call_id": "call-multi", "tool": "choice", "title": "多选", "prompt": "选两项", "expires_at": expires_at, + "data": {"action_group": {"mode": "multi-choice", "actions": [{"id": "a", "label": "A"}, {"id": "b", "label": "B"}]}}, + }}))) + + def test_invalid_tool_or_surface_output_safely_degrades_to_text(self) -> None: + invalid = json.loads(agent_output_message( + json.dumps({"v": 1, "type": TOOL_CALL, "payload": {"call_id": "call-1", "tool": "shell", "data": {}}}), + conversation_id="conv-1", sender_id="agent", + )) + self.assertEqual(TEXT, invalid["type"]) + blocked_surface = json.loads(agent_output_message( + json.dumps({"v": 1, "type": UI_OPEN, "payload": {"instance_id": "surface-1", "app": {"app_id": "lineup.task-dashboard", "version": "0.1.0"}, "bundle_url": "https://bad.example/x.js"}}), + conversation_id="conv-1", sender_id="agent", + )) + self.assertEqual(TEXT, blocked_surface["type"]) + inventory = parse_client_inventory(json.dumps({"v": 1, "type": CLIENT_INVENTORY, "payload": { + "revision": "catalog-1", "standard_components": [], "applications": [], "tools": [], "capabilities": [], + }})) + blocked_invoke = json.loads(agent_output_message( + json.dumps({"v": 1, "type": TOOL_INVOKE, "payload": {"call_id": "call-1", "inventory_revision": "catalog-1", "app_scope": "pomodoro", "tool_id": "pomodoro.start", "input": {}}}), + conversation_id="conv-1", sender_id="agent", inventory=inventory, + )) + self.assertEqual(TEXT, blocked_invoke["type"]) + + def test_allows_only_the_canonical_m2_task_surface_lifecycle(self) -> None: + opened = json.loads(agent_output_message(json.dumps({"v": 1, "type": UI_OPEN, "payload": {"instance_id": "surface-1", "app": {"app_id": "lineup.task-dashboard", "version": "0.1.0"}, "state": {"title": "Build", "percent": 25}}}), conversation_id="conv-1", sender_id="agent")) + patched = json.loads(agent_output_message(json.dumps({"v": 1, "type": UI_PATCH, "payload": {"instance_id": "surface-1", "state": {"percent": 60}}}), conversation_id="conv-1", sender_id="agent")) + closed = json.loads(agent_output_message(json.dumps({"v": 1, "type": UI_CLOSE, "payload": {"instance_id": "surface-1"}}), conversation_id="conv-1", sender_id="agent")) + self.assertEqual(UI_OPEN, opened["type"]) + self.assertEqual({"app_id": "lineup.task-dashboard", "version": "0.1.0"}, opened["payload"]["app"]) + self.assertEqual(UI_PATCH, patched["type"]) + self.assertEqual(UI_CLOSE, closed["type"]) + + def test_tool_response_is_schema_bound_and_does_not_preserve_ui_injection(self) -> None: + raw = json.dumps({"v": 1, "type": TOOL_RESULT, "payload": { + "call_id": "call-choice", "status": "completed", "result": {"action_ids": ["staging"], "ui": {"html": ""}}, + }}) + response = parse_tool_response(raw) + self.assertIsNotNone(response) + self.assertEqual( + {"call_id": "call-choice", "outcome": "completed", "action_ids": ["staging"]}, + validate_tool_response(response, {"tool": "choice", "mode": "single-choice", "action_ids": ["staging"]}), + ) + clean = parse_tool_response(json.dumps({"v": 1, "type": TOOL_RESULT, "payload": { + "call_id": "call-choice", "status": "completed", "result": {"action_ids": ["staging"]}, + }})) + self.assertEqual( + {"call_id": "call-choice", "outcome": "completed", "action_ids": ["staging"]}, + validate_tool_response(clean, {"tool": "choice", "mode": "single-choice", "action_ids": ["staging"]}), + ) + + def test_single_choice_accepts_host_action_id_shape(self) -> None: + raw = json.dumps({"v": 1, "type": TOOL_RESULT, "payload": { + "call_id": "call-choice", "status": "completed", "result": {"action_id": "staging"}, + }}) + response = parse_tool_response(raw) + self.assertIsNotNone(response) + self.assertEqual( + {"call_id": "call-choice", "outcome": "completed", "action_ids": ["staging"]}, + validate_tool_response(response, {"tool": "choice", "mode": "single-choice", "action_ids": ["staging"]}), + ) + self.assertIsNone(validate_tool_response( + parse_tool_response(json.dumps({"v": 1, "type": TOOL_RESULT, "payload": { + "call_id": "call-choice", "status": "completed", "result": {"action_id": "unknown"}, + }})), + {"tool": "choice", "mode": "single-choice", "action_ids": ["staging"]}, + )) + + def test_single_field_input_accepts_host_text_shape(self) -> None: + raw = json.dumps({"v": 1, "type": TOOL_RESULT, "payload": { + "call_id": "call-input", "status": "completed", "result": {"text": "验收 04A other-input"}, + }}) + response = parse_tool_response(raw) + self.assertIsNotNone(response) + self.assertEqual( + {"call_id": "call-input", "outcome": "completed", "values": {"task": "验收 04A other-input"}}, + validate_tool_response(response, {"tool": "input", "fields": {"task": {"type": "text", "required": True, "min_length": None, "max_length": None}}}), + ) + self.assertIsNone(validate_tool_response( + parse_tool_response(json.dumps({"v": 1, "type": TOOL_RESULT, "payload": { + "call_id": "call-input", "status": "completed", "result": {"text": 1}, + }})), + {"tool": "input", "fields": {"task": {"type": "text", "required": True, "min_length": None, "max_length": None}}}, + )) + + +if __name__ == "__main__": + unittest.main() diff --git a/lineup/tests/test_state.py b/lineup/tests/test_state.py new file mode 100644 index 0000000..4be3507 --- /dev/null +++ b/lineup/tests/test_state.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +import sys +import tempfile +import time +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + +from lineup.state import StateStore + + +class StateStoreTest(unittest.TestCase): + def test_claim_is_idempotent_and_running_work_is_recovered(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + store = StateStore(Path(temp_dir) / "state.sqlite3") + message = {"message_id": 7, "message_seq": 4, "from_uid": "user-1", "payload": "hello"} + self.assertTrue(store.claim_inbox(message)) + self.assertFalse(store.claim_inbox(message)) + row = store.pending()[0] + store.set_inbox_state(row["message_id"], "running") + store.recover_interrupted_turns() + self.assertEqual("pending", store.pending()[0]["state"]) + store.close() + + def test_outbox_persists_until_acknowledged(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + store = StateStore(Path(temp_dir) / "state.sqlite3") + item_id = store.enqueue_outbox("payload") + self.assertEqual(1, len(store.outbox_items())) + store.mark_outbox_sent(item_id) + self.assertEqual([], store.outbox_items()) + store.close() + + def test_bootstrap_marker_is_persistent(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + store = StateStore(Path(temp_dir) / "state.sqlite3") + self.assertFalse(store.is_bootstrapped()) + store.set_bootstrapped() + self.assertTrue(store.is_bootstrapped()) + store.close() + + def test_tool_responses_are_idempotent_and_scoped_to_a_conversation(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + store = StateStore(Path(temp_dir) / "state.sqlite3") + schema = {"tool": "choice", "mode": "single-choice", "action_ids": ["staging"]} + self.assertEqual("accepted", store.register_tool_call("conv-a", "call-1", "choice", time.time() + 60, schema)) + self.assertEqual("accepted", store.register_tool_call("conv-b", "call-1", "choice", time.time() + 60, schema)) + self.assertEqual("accepted", store.consume_tool_response("conv-a", "call-1", "result")) + self.assertEqual("duplicate", store.consume_tool_response("conv-a", "call-1", "result")) + self.assertEqual("accepted", store.consume_tool_response("conv-b", "call-1", "cancel")) + audit = [(row["event"], row["disposition"]) for row in store.tool_audit("conv-a", "call-1")] + self.assertEqual([("call", "accepted"), ("result", "accepted"), ("result", "duplicate")], audit) + store.close() + + def test_expired_tool_response_is_audited_without_being_accepted(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + store = StateStore(Path(temp_dir) / "state.sqlite3") + store.register_tool_call("conv-a", "call-expired", "confirm", time.time() - 1, {"tool": "confirm"}) + self.assertEqual("expired", store.consume_tool_response("conv-a", "call-expired", "result")) + row = store.tool_call("conv-a", "call-expired") + self.assertEqual("expired", row["state"]) + self.assertEqual(("result", "expired"), tuple(store.tool_audit("conv-a", "call-expired")[-1])) + store.close() + + +if __name__ == "__main__": + unittest.main()