Initialize Hermes LineUp adapter
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
.pytest_cache/
|
||||||
|
.venv/
|
||||||
|
.env
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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()
|
||||||
|
|
||||||
@@ -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"}})
|
||||||
+826
@@ -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())
|
||||||
@@ -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 ""
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
Executable
+52
@@ -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
|
||||||
Executable
+27
@@ -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=<configured server secret>"
|
||||||
|
echo ""
|
||||||
|
echo "Set LINEUP_ADAPTER_AUTOSTART=1 only when Hermes itself is a long-running process."
|
||||||
+357
@@ -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)
|
||||||
@@ -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])
|
||||||
@@ -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()
|
||||||
@@ -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"))
|
||||||
@@ -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": "<main>Weather</main>"},
|
||||||
|
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": "<script>bad</script>"},
|
||||||
|
}}),
|
||||||
|
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": "<script>bad</script>"}},
|
||||||
|
}})
|
||||||
|
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()
|
||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user