From 05271dbd32fa83620b7dcd729198a00ea4cd4f42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8F=AD=E6=89=AC?= Date: Fri, 10 Jul 2026 11:43:18 +0800 Subject: [PATCH 01/17] fix(tui): harden input and daemon streaming --- src/leapflow/cli/cli.py | 47 ++++++- src/leapflow/cli/commands/interactive.py | 3 +- src/leapflow/cli/tui_app/app.py | 162 ++++++++++++++++------- src/leapflow/cli/tui_app/paste.py | 136 +++++++++++++++++++ src/leapflow/daemon/server.py | 57 +++++++- tests/test_cli_entrypoint.py | 45 +++++++ tests/test_daemon_rpc.py | 38 +++++- tests/test_tui_command_queue.py | 120 +++++++++++++++++ 8 files changed, 554 insertions(+), 54 deletions(-) create mode 100644 src/leapflow/cli/tui_app/paste.py diff --git a/src/leapflow/cli/cli.py b/src/leapflow/cli/cli.py index 720d7d2..fa4a828 100644 --- a/src/leapflow/cli/cli.py +++ b/src/leapflow/cli/cli.py @@ -15,6 +15,11 @@ import os import sys +try: + import termios +except ImportError: # pragma: no cover - non-POSIX platforms + termios = None + try: import gnureadline as readline # noqa: F401 except ImportError: @@ -87,6 +92,37 @@ async def _async_host(args: argparse.Namespace) -> int: return await cmd_host(args) +class _StdinEchoGuard: + """Temporarily hide pre-TUI stdin echo while leapd starts.""" + + def __init__(self) -> None: + self._fd: int | None = None + self._attrs: list | None = None + + def __enter__(self) -> "_StdinEchoGuard": + if termios is None or not sys.stdin.isatty(): + return self + try: + self._fd = sys.stdin.fileno() + self._attrs = termios.tcgetattr(self._fd) + muted = list(self._attrs) + muted[3] = muted[3] & ~termios.ECHO + termios.tcsetattr(self._fd, termios.TCSADRAIN, muted) + except termios.error: + self._fd = None + self._attrs = None + return self + + def __exit__(self, exc_type: object, exc: object, traceback: object) -> None: + if termios is None or self._fd is None or self._attrs is None: + return + try: + termios.tcsetattr(self._fd, termios.TCSADRAIN, self._attrs) + termios.tcflush(self._fd, termios.TCIFLUSH) + except termios.error: + return + + async def _async_daemon_main(args: argparse.Namespace) -> int: """Run chat/interactive through a shared leapd daemon.""" from leapflow.daemon.client import DaemonUnavailableError, ensure_daemon_client @@ -99,11 +135,12 @@ def _status(message: str) -> None: sys.stderr.flush() try: - client = await ensure_daemon_client( - settings, - mock_host=mock_host, - status_callback=_status, - ) + with _StdinEchoGuard(): + client = await ensure_daemon_client( + settings, + mock_host=mock_host, + status_callback=_status, + ) except DaemonUnavailableError as exc: sys.stderr.write( "\033[33m→ leapd unavailable; falling back to local volatile-capable mode.\033[0m\n" diff --git a/src/leapflow/cli/commands/interactive.py b/src/leapflow/cli/commands/interactive.py index 5119594..881ef93 100644 --- a/src/leapflow/cli/commands/interactive.py +++ b/src/leapflow/cli/commands/interactive.py @@ -687,7 +687,8 @@ async def _stream_response(prompt_text: str) -> None: elif event.type == "error": renderer.feed(event.content) elif event.type == "status": - console.system(event.content) + if not metadata.get("heartbeat"): + console.system(event.content) finally: renderer.finish() if renderer.has_output: diff --git a/src/leapflow/cli/tui_app/app.py b/src/leapflow/cli/tui_app/app.py index 0c222d1..9e99df7 100644 --- a/src/leapflow/cli/tui_app/app.py +++ b/src/leapflow/cli/tui_app/app.py @@ -24,7 +24,6 @@ import asyncio import os import time -from dataclasses import dataclass from pathlib import Path from typing import ( TYPE_CHECKING, @@ -50,6 +49,11 @@ from leapflow.cli.tui_app.command import TuiCommand from leapflow.cli.tui_app.input import build_completer +from leapflow.cli.tui_app.paste import ( + PASTE_FRAGMENT_WINDOW_S, + PasteHeuristics, + PasteStore, +) from leapflow.cli.tui_app.status import StatusBar from leapflow.cli.tui_app.theme import Theme @@ -58,21 +62,11 @@ _HISTORY_FILENAME = "history" _REFRESH_INTERVAL_S = 0.5 -_LARGE_PASTE_CHAR_THRESHOLD = 4_000 -_LARGE_PASTE_LINE_THRESHOLD = 24 _PASTE_COMPACTOR_ATTR = "_leapflow_paste_compactor_installed" InputHandler = Callable[[str], Union[Awaitable[None], None]] -@dataclass(frozen=True) -class _PasteBlock: - """Full pasted content stored outside the prompt buffer.""" - - marker: str - text: str - - class LeapApp: """Hybrid TUI: prompt_toolkit Application + Rich output. @@ -105,8 +99,13 @@ def __init__( self._spinner_text = "" self._tool_start_time: float = 0.0 self._next_command_id = 1 - self._next_paste_id = 1 - self._paste_blocks: dict[str, _PasteBlock] = {} + self._paste_heuristics = PasteHeuristics() + self._paste_store = PasteStore(self._paste_heuristics) + self._paste_blocks = self._paste_store.blocks + self._paste_fragment_text = "" + self._paste_fragment_start_cursor = 0 + self._paste_fragment_last_at = 0.0 + self._active_fragment_marker: Optional[str] = None self._active_command: Optional[TuiCommand] = None self._pending_input: asyncio.Queue[TuiCommand] = asyncio.Queue() @@ -165,39 +164,48 @@ def submit_text(self, text: str) -> TuiCommand: def _should_compact_paste(self, text: str) -> bool: """Return True when rendering pasted text directly would hurt TUI responsiveness.""" - if len(text) >= _LARGE_PASTE_CHAR_THRESHOLD: - return True - return text.count("\n") + 1 >= _LARGE_PASTE_LINE_THRESHOLD - - def _compact_tokens(self, text: str) -> str: - size = len(text) - if size < 1_000: - return f"{size} chars" - if size < 1_000_000: - return f"{size / 1000:.1f}K chars" - return f"{size / 1_000_000:.1f}M chars" + return self._paste_heuristics.should_compact_block(text) def _paste_marker(self, text: str) -> str: - paste_id = self._next_paste_id - self._next_paste_id += 1 - line_count = text.count("\n") + 1 - marker = ( - f"[pasted block #{paste_id}: {self._compact_tokens(text)}, " - f"{line_count} lines; full text will be submitted]" - ) - self._paste_blocks[marker] = _PasteBlock(marker=marker, text=text) - return marker + """Create a safe visible marker while keeping full text in the side channel.""" + return self._paste_store.create_marker(text) def _resolve_paste_blocks(self, text: str) -> str: """Replace compact paste markers with the original full pasted content.""" - if not self._paste_blocks: - return text - resolved = text - for marker, block in self._paste_blocks.items(): - if marker in resolved: - resolved = resolved.replace(marker, block.text) - self._paste_blocks.clear() - return resolved + return self._paste_store.resolve(text) + + def _clear_paste_state(self) -> None: + """Clear both side-channel paste blocks and in-flight fragment state.""" + self._paste_store.clear() + self._reset_paste_fragment_window() + + def _reset_paste_fragment_window(self) -> None: + """Forget pending fragmented paste detection state.""" + self._paste_fragment_text = "" + self._paste_fragment_start_cursor = 0 + self._paste_fragment_last_at = 0.0 + self._active_fragment_marker = None + + def _fragment_continues(self, now: float) -> bool: + """Return True when an insert arrives inside the paste-fragment window.""" + if self._paste_fragment_last_at <= 0: + return False + return now - self._paste_fragment_last_at <= PASTE_FRAGMENT_WINDOW_S + + def _replace_fragment_with_marker(self, buffer: Any, marker: str) -> bool: + """Replace already-rendered fragmented paste text with a safe marker.""" + start = self._paste_fragment_start_cursor + fragment = self._paste_fragment_text + end = start + len(fragment) + try: + current = buffer.text + if current[start:end] != fragment: + return False + buffer.text = f"{current[:start]}{marker}{current[end:]}" + buffer.cursor_position = start + len(marker) + return True + except (AttributeError, TypeError, ValueError): + return False def _insert_paste_text(self, buffer: Any, text: str) -> None: """Insert pasted text, compacting large blocks to keep terminal rendering smooth.""" @@ -205,27 +213,82 @@ def _insert_paste_text(self, buffer: Any, text: str) -> None: return if self._should_compact_paste(text): buffer.insert_text(self._paste_marker(text)) + self._reset_paste_fragment_window() self._invalidate() return buffer.insert_text(text) def _install_paste_compactor(self, buffer: Any) -> None: - """Compact bulk Buffer inserts even when paste bypasses key bindings.""" + """Compact bulk and fragmented Buffer inserts before they hurt rendering.""" if getattr(buffer, _PASTE_COMPACTOR_ATTR, False): return original_insert_text = buffer.insert_text ref = self def insert_text(text: str, *args: Any, **kwargs: Any) -> None: - if isinstance(text, str) and ref._should_compact_paste(text): + if not isinstance(text, str) or not text: + original_insert_text(text, *args, **kwargs) + return + + now = time.monotonic() + if ref._active_fragment_marker and ref._fragment_continues(now): + ref._paste_store.append_to_marker(ref._active_fragment_marker, text) + ref._paste_fragment_last_at = now + ref._invalidate() + return + if not ref._fragment_continues(now): + ref._reset_paste_fragment_window() + + if ref._should_compact_paste(text): original_insert_text(ref._paste_marker(text), *args, **kwargs) + ref._paste_fragment_last_at = now ref._invalidate() return + + if not ref._paste_fragment_text: + ref._paste_fragment_start_cursor = int(getattr(buffer, "cursor_position", 0)) original_insert_text(text, *args, **kwargs) + ref._paste_fragment_text += text + ref._paste_fragment_last_at = now + + if ref._paste_heuristics.should_compact_fragment_window(ref._paste_fragment_text): + marker = ref._paste_marker(ref._paste_fragment_text) + if ref._replace_fragment_with_marker(buffer, marker): + ref._paste_fragment_text = "" + ref._active_fragment_marker = marker + ref._paste_fragment_last_at = now + ref._invalidate() buffer.insert_text = insert_text setattr(buffer, _PASTE_COMPACTOR_ATTR, True) + def _accept_auto_suggestion(self, buffer: Any) -> bool: + """Accept the visible auto-suggestion when one is available.""" + suggestion = getattr(buffer, "suggestion", None) + suggestion_text = getattr(suggestion, "text", "") if suggestion else "" + if not suggestion_text: + return False + buffer.insert_text(suggestion_text) + return True + + def _accept_or_start_completion(self, buffer: Any) -> None: + """Accept the selected completion or open/cycle completion candidates.""" + complete_state = getattr(buffer, "complete_state", None) + current_completion = getattr(complete_state, "current_completion", None) + if current_completion is not None: + buffer.apply_completion(current_completion) + return + if self._accept_auto_suggestion(buffer): + return + buffer.complete_next() + + def _move_right_or_accept_suggestion(self, buffer: Any) -> None: + """Keep normal Right-arrow movement while accepting visible suggestions at EOL.""" + if getattr(buffer, "cursor_position", 0) >= len(getattr(buffer, "text", "")): + if self._accept_auto_suggestion(buffer): + return + buffer.cursor_right() + # ── Lifecycle ──────────────────────────────────────────────────── async def run(self) -> int: @@ -365,14 +428,23 @@ def _(event): text = event.app.current_buffer.text.strip() if not text: return - has_compacted_paste = bool(ref._paste_blocks) + has_compacted_paste = ref._paste_store.has_blocks ref.submit_text(text) event.app.current_buffer.reset(append_to_history=not has_compacted_paste) + ref._reset_paste_fragment_window() @kb.add(Keys.Escape, Keys.Enter) def _(event): event.current_buffer.insert_text("\n") + @kb.add(Keys.Tab) + def _(event): + ref._accept_or_start_completion(event.current_buffer) + + @kb.add(Keys.Right) + def _(event): + ref._move_right_or_accept_suggestion(event.current_buffer) + @kb.add(Keys.BracketedPaste) def _(event): ref._insert_paste_text(event.current_buffer, event.data) @@ -386,7 +458,7 @@ def _(event): def _(event): if event.current_buffer.text: event.current_buffer.reset() - ref._paste_blocks.clear() + ref._clear_paste_state() return if ref._agent_running: ref._console.system( diff --git a/src/leapflow/cli/tui_app/paste.py b/src/leapflow/cli/tui_app/paste.py new file mode 100644 index 0000000..65be2ac --- /dev/null +++ b/src/leapflow/cli/tui_app/paste.py @@ -0,0 +1,136 @@ +"""Safe paste handling for the TUI input buffer. + +This module keeps high-risk pasted content out of the visible prompt_toolkit +buffer while preserving the full logical text for command submission. +""" +from __future__ import annotations + +import re +from dataclasses import dataclass + +LARGE_PASTE_CHAR_THRESHOLD = 4_000 +LARGE_PASTE_LINE_THRESHOLD = 24 +FRAGMENTED_PASTE_CHAR_THRESHOLD = 800 +FRAGMENTED_PASTE_LINE_THRESHOLD = 8 +PASTE_FRAGMENT_WINDOW_S = 0.08 + +_ANSI_ESCAPE_RE = re.compile(r"\x1b(?:\[[0-?]*[ -/]*[@-~]|[@-Z\\-_])") + + +@dataclass +class PasteBlock: + """Full pasted content stored outside the visible prompt buffer.""" + + marker: str + text: str + + +class PasteHeuristics: + """Decide which text is unsafe to render directly in a terminal input row.""" + + def should_compact_block(self, text: str) -> bool: + """Return True when a single insert should be represented as a marker.""" + if len(text) >= LARGE_PASTE_CHAR_THRESHOLD: + return True + if text.count("\n") + 1 >= LARGE_PASTE_LINE_THRESHOLD: + return True + return self.has_display_unsafe_controls(text) + + def should_compact_fragment_window(self, text: str) -> bool: + """Return True when accumulated small inserts look like one pasted block.""" + if len(text) >= FRAGMENTED_PASTE_CHAR_THRESHOLD: + return True + return text.count("\n") + 1 >= FRAGMENTED_PASTE_LINE_THRESHOLD + + def normalize_original(self, text: str) -> str: + """Preserve semantic text while removing terminal-rendering control bytes.""" + normalized = _ANSI_ESCAPE_RE.sub("", text) + normalized = normalized.replace("\r\n", "\n").replace("\r", "\n") + return "".join(char for char in normalized if self._is_safe_original_char(char)) + + def has_display_unsafe_controls(self, text: str) -> bool: + """Return True for controls that should never reach the visible buffer.""" + if _ANSI_ESCAPE_RE.search(text): + return True + return any(not self._is_safe_display_char(char) for char in text) + + @staticmethod + def _is_safe_original_char(char: str) -> bool: + code = ord(char) + if char in {"\n", "\t"}: + return True + if code < 0x20 or code == 0x7F: + return False + if 0x200B <= code <= 0x200F: + return False + if 0x202A <= code <= 0x202E: + return False + if 0x2066 <= code <= 0x2069: + return False + return True + + def _is_safe_display_char(self, char: str) -> bool: + if char in {"\n", "\t"}: + return True + return self._is_safe_original_char(char) + + +class PasteStore: + """Side-channel store that maps safe visible markers to full pasted text.""" + + def __init__(self, heuristics: PasteHeuristics | None = None) -> None: + self._heuristics = heuristics or PasteHeuristics() + self._next_paste_id = 1 + self._blocks: dict[str, PasteBlock] = {} + + @property + def has_blocks(self) -> bool: + return bool(self._blocks) + + @property + def blocks(self) -> dict[str, PasteBlock]: + return self._blocks + + def create_marker(self, text: str) -> str: + """Store pasted text and return an ASCII-only display marker.""" + paste_id = self._next_paste_id + self._next_paste_id += 1 + normalized = self._heuristics.normalize_original(text) + line_count = normalized.count("\n") + 1 + marker = ( + f"[pasted block #{paste_id}: {self._compact_tokens(normalized)}, " + f"{line_count} lines; full text will be submitted]" + ) + self._blocks[marker] = PasteBlock(marker=marker, text=normalized) + return marker + + def append_to_marker(self, marker: str, text: str) -> None: + """Append continuation chunks to an existing pasted block.""" + block = self._blocks.get(marker) + if block is None: + return + block.text += self._heuristics.normalize_original(text) + + def resolve(self, text: str) -> str: + """Replace visible paste markers with full logical pasted text.""" + if not self._blocks: + return text + resolved = text + for marker, block in self._blocks.items(): + if marker in resolved: + resolved = resolved.replace(marker, block.text) + self.clear() + return resolved + + def clear(self) -> None: + """Clear all stored paste blocks.""" + self._blocks.clear() + + @staticmethod + def _compact_tokens(text: str) -> str: + size = len(text) + if size < 1_000: + return f"{size} chars" + if size < 1_000_000: + return f"{size / 1000:.1f}K chars" + return f"{size / 1_000_000:.1f}M chars" diff --git a/src/leapflow/daemon/server.py b/src/leapflow/daemon/server.py index 663daa4..88ba2a6 100644 --- a/src/leapflow/daemon/server.py +++ b/src/leapflow/daemon/server.py @@ -4,6 +4,7 @@ import asyncio import json import logging +import os import signal from collections.abc import Callable from pathlib import Path @@ -14,14 +15,32 @@ logger = logging.getLogger(__name__) +_DEFAULT_STREAM_HEARTBEAT_S = 10.0 + + +def _stream_heartbeat_interval() -> float: + raw = os.getenv("LEAPFLOW_DAEMON_STREAM_HEARTBEAT", str(_DEFAULT_STREAM_HEARTBEAT_S)).strip() + try: + return max(1.0, float(raw)) + except ValueError: + return _DEFAULT_STREAM_HEARTBEAT_S + class UnixRpcServer: """Newline-delimited JSON-RPC server bound to one Unix socket.""" - def __init__(self, service: Any, *, sock_path: Path, run_dir: Path) -> None: + def __init__( + self, + service: Any, + *, + sock_path: Path, + run_dir: Path, + stream_heartbeat_s: float | None = None, + ) -> None: self._service = service self._sock_path = sock_path self._run_dir = run_dir + self._stream_heartbeat_s = stream_heartbeat_s or _stream_heartbeat_interval() self._server: asyncio.AbstractServer | None = None self._active_connections = 0 if hasattr(service, "set_client_count_provider"): @@ -135,8 +154,21 @@ async def _dispatch_stream( params: dict[str, Any], writer: asyncio.StreamWriter, ) -> None: + stream = None + pending: asyncio.Task | None = None try: - async for chunk in method(**params): + stream = method(**params) + pending = asyncio.create_task(anext(stream)) + while True: + done, _ = await asyncio.wait({pending}, timeout=self._stream_heartbeat_s) + if not done: + await self._write_stream_heartbeat(request.id, writer) + continue + try: + chunk = pending.result() + except StopAsyncIteration: + pending = None + break notification = StreamChunk( request_id=request.id, content=chunk.content, @@ -145,7 +177,15 @@ async def _dispatch_stream( metadata=chunk.metadata, ).to_notification() await _write_json(writer, notification.to_json()) + pending = asyncio.create_task(anext(stream)) except Exception as exc: + if pending is not None and not pending.done(): + pending.cancel() + if stream is not None and hasattr(stream, "aclose"): + try: + await stream.aclose() + except Exception: + logger.debug("daemon: failed to close stream after error", exc_info=True) logger.exception("daemon: stream failed method=%s", request.method) response = RpcResponse.fail( request.id, @@ -161,6 +201,19 @@ async def _dispatch_stream( response = RpcResponse.success(request.id, {"ok": True}) await _write_json(writer, response.to_json()) + async def _write_stream_heartbeat( + self, + request_id: str, + writer: asyncio.StreamWriter, + ) -> None: + notification = StreamChunk( + request_id=request_id, + content="Still working...", + event_type="status", + metadata={"heartbeat": True}, + ).to_notification() + await _write_json(writer, notification.to_json()) + async def serve_daemon(settings: Any, *, mock_host: bool = False) -> int: """Run a daemon server for the provided settings until signalled.""" diff --git a/tests/test_cli_entrypoint.py b/tests/test_cli_entrypoint.py index 0512f88..9512da1 100644 --- a/tests/test_cli_entrypoint.py +++ b/tests/test_cli_entrypoint.py @@ -433,6 +433,51 @@ def fake_cmd_daemon(args): assert captured == {"action": "restart"} +def test_stdin_echo_guard_restores_and_flushes_tty(monkeypatch) -> None: + from leapflow.cli import cli + + calls = [] + + class FakeStdin: + def isatty(self) -> bool: + return True + + def fileno(self) -> int: + return 7 + + class FakeTermios: + ECHO = 8 + TCSADRAIN = 1 + TCIFLUSH = 2 + error = OSError + + @staticmethod + def tcgetattr(fd): + calls.append(("get", fd)) + return [0, 0, 0, 15] + + @staticmethod + def tcsetattr(fd, when, attrs): + calls.append(("set", fd, when, attrs[3])) + + @staticmethod + def tcflush(fd, queue): + calls.append(("flush", fd, queue)) + + monkeypatch.setattr(cli.sys, "stdin", FakeStdin()) + monkeypatch.setattr(cli, "termios", FakeTermios) + + with cli._StdinEchoGuard(): + pass + + assert calls == [ + ("get", 7), + ("set", 7, FakeTermios.TCSADRAIN, 7), + ("set", 7, FakeTermios.TCSADRAIN, 15), + ("flush", 7, FakeTermios.TCIFLUSH), + ] + + @pytest.mark.asyncio async def test_daemon_tui_exit_prompt_stops_by_default(monkeypatch, tmp_path) -> None: from leapflow.cli.commands import interactive as interactive_module diff --git a/tests/test_daemon_rpc.py b/tests/test_daemon_rpc.py index dcce213..e69bd39 100644 --- a/tests/test_daemon_rpc.py +++ b/tests/test_daemon_rpc.py @@ -40,11 +40,19 @@ async def engine_chat(self, message: str, **kwargs: Any) -> AsyncIterator[Stream raise RuntimeError("stream exploded") -async def _start_server(run_dir: Path, service=None): +class _SlowFirstChunkService(_FakeService): + async def engine_chat(self, message: str, **kwargs: Any) -> AsyncIterator[StreamChunk]: + await asyncio.sleep(0.08) + yield StreamChunk(request_id="", content=f"slow {message}", event_type="chunk") + yield StreamChunk(request_id="", content="done", event_type="final") + + +async def _start_server(run_dir: Path, service=None, *, stream_heartbeat_s: float | None = None): server = UnixRpcServer( service or _FakeService(), sock_path=run_dir / "leapd.sock", run_dir=run_dir, + stream_heartbeat_s=stream_heartbeat_s, ) task = asyncio.create_task(server.serve_forever()) for _ in range(50): @@ -78,6 +86,34 @@ async def test_daemon_client_receives_stream_events() -> None: assert events[0].metadata == {"session_id": "sess-1"} +@pytest.mark.asyncio +async def test_daemon_client_stream_heartbeat_prevents_idle_timeout() -> None: + with tempfile.TemporaryDirectory(prefix="lfd-", dir="/tmp") as root: + server, task, run_dir = await _start_server( + Path(root) / "run", + service=_SlowFirstChunkService(), + stream_heartbeat_s=0.01, + ) + client = DaemonClient(run_dir / "leapd.sock", timeout_s=0.03) + + try: + events = [event async for event in client.engine_chat("world")] + finally: + task.cancel() + await server.stop() + try: + await task + except asyncio.CancelledError: + pass + + heartbeat_events = [event for event in events if event.metadata == {"heartbeat": True}] + assert heartbeat_events + assert [(event.type, event.content) for event in events[-2:]] == [ + ("chunk", "slow world"), + ("final", "done"), + ] + + @pytest.mark.asyncio async def test_daemon_client_reports_unknown_method() -> None: with tempfile.TemporaryDirectory(prefix="lfd-", dir="/tmp") as root: diff --git a/tests/test_tui_command_queue.py b/tests/test_tui_command_queue.py index 4adb809..bd6e58e 100644 --- a/tests/test_tui_command_queue.py +++ b/tests/test_tui_command_queue.py @@ -5,6 +5,9 @@ import pytest +import leapflow.cli.tui_app.app as app_module +from prompt_toolkit.auto_suggest import Suggestion +from prompt_toolkit.completion import Completion from leapflow.cli.tui_app.app import LeapApp from leapflow.cli.tui_app.command import TuiCommand, TuiCommandStatus from leapflow.cli.tui_app.theme import _LIGHT, resolve_theme @@ -83,6 +86,53 @@ def test_submit_text_rejects_empty_commands() -> None: assert status.counts == [] +def test_tab_accepts_selected_completion() -> None: + app, _console, _status = _make_app() + buffer = app._input_area.buffer + buffer.text = "/he" + buffer.cursor_position = len(buffer.text) + completion = Completion("/help", start_position=-len(buffer.text)) + buffer._set_completions([completion]) + + app._accept_or_start_completion(buffer) + + assert buffer.text == "/help" + assert buffer.cursor_position == len("/help") + + +@pytest.mark.asyncio +async def test_tab_accepts_visible_auto_suggestion() -> None: + app, _console, _status = _make_app() + buffer = app._input_area.buffer + buffer.text = "你能" + buffer.cursor_position = len(buffer.text) + buffer.suggestion = Suggestion("帮我找一找 hub 上有啥 skill 么?") + + app._accept_or_start_completion(buffer) + + assert buffer.text == "你能帮我找一找 hub 上有啥 skill 么?" + + +@pytest.mark.asyncio +async def test_right_arrow_accepts_suggestion_only_at_end() -> None: + app, _console, _status = _make_app() + buffer = app._input_area.buffer + buffer.text = "abc" + buffer.cursor_position = 1 + buffer.suggestion = Suggestion("def") + + app._move_right_or_accept_suggestion(buffer) + + assert buffer.text == "abc" + assert buffer.cursor_position == 2 + + buffer.cursor_position = len(buffer.text) + app._move_right_or_accept_suggestion(buffer) + + assert buffer.text == "abcdef" + assert buffer.cursor_position == len("abcdef") + + @pytest.mark.asyncio async def test_process_loop_marks_failed_commands_and_recovers_counts() -> None: async def on_input(text: str) -> None: @@ -177,6 +227,76 @@ async def test_buffer_insert_compacts_large_chinese_paste_with_ascii_marker() -> assert app.submit_text(visible).text == pasted.strip() +@pytest.mark.asyncio +async def test_fragmented_chinese_paste_compacts_and_submits_full_text() -> None: + app, _console, _status = _make_app() + pasted = "经济活动达到最低点,经济增长理论,索洛增长模型。" * 80 + + for index in range(0, len(pasted), 18): + app._input_area.buffer.insert_text(pasted[index:index + 18]) + + visible = app._input_area.buffer.text + assert pasted not in visible + assert "经济活动" not in visible + assert visible.startswith("[pasted block #1:") + assert visible.isascii() + assert len(visible) < 120 + assert app.submit_text(visible).text == pasted.strip() + + +@pytest.mark.asyncio +async def test_fragmented_english_single_line_paste_compacts() -> None: + app, _console, _status = _make_app() + pasted = "capital accumulation and productivity growth " * 80 + + for index in range(0, len(pasted), 16): + app._input_area.buffer.insert_text(pasted[index:index + 16]) + + visible = app._input_area.buffer.text + assert pasted not in visible + assert visible.startswith("[pasted block #1:") + assert visible.isascii() + assert app.submit_text(visible).text == pasted.strip() + + +@pytest.mark.asyncio +async def test_control_character_paste_is_compacted_and_sanitized() -> None: + app, _console, _status = _make_app() + pasted = "normal\rtext\x1b[31mred\x1b[0m\x00tail\u202edone" + + app._input_area.buffer.insert_text(pasted) + + visible = app._input_area.buffer.text + assert visible.startswith("[pasted block #1:") + assert visible.isascii() + assert "\x1b" not in visible + command = app.submit_text(visible) + assert command.text == "normal\ntextredtaildone" + + +@pytest.mark.asyncio +async def test_fragmented_paste_window_expiry_keeps_followup_typing_visible(monkeypatch) -> None: + app, _console, _status = _make_app() + clock = 100.0 + + def fake_monotonic() -> float: + return clock + + monkeypatch.setattr(app_module.time, "monotonic", fake_monotonic) + pasted = "fragmented paste " * 80 + for index in range(0, len(pasted), 20): + app._input_area.buffer.insert_text(pasted[index:index + 20]) + + visible = app._input_area.buffer.text + assert visible.startswith("[pasted block #1:") + + clock = 101.0 + app._input_area.buffer.insert_text(" follow-up") + + assert app._input_area.buffer.text == f"{visible} follow-up" + assert app.submit_text(app._input_area.buffer.text).text == f"{pasted} follow-up".strip() + + def test_small_paste_stays_inline() -> None: app, _console, _status = _make_app() buffer = _FakeBuffer() From 4006eb9f4db78593552e55ccb8bd55b1deeb8107 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8F=AD=E6=89=AC?= Date: Fri, 10 Jul 2026 14:51:30 +0800 Subject: [PATCH 02/17] Implement action approval layer --- src/leapflow/cli/approval_view.py | 199 +++++++++++++++++++ src/leapflow/cli/commands/chat.py | 34 +++- src/leapflow/cli/commands/interactive.py | 20 ++ src/leapflow/cli/context.py | 130 ++++-------- src/leapflow/cli/tui_app/console.py | 6 +- src/leapflow/cli/tui_app/stream.py | 4 +- src/leapflow/daemon/client.py | 29 +++ src/leapflow/daemon/protocol.py | 30 ++- src/leapflow/daemon/service.py | 190 +++++++++++++++++- src/leapflow/engine/engine.py | 4 +- src/leapflow/security/__init__.py | 20 ++ src/leapflow/security/actions.py | 180 +++++++++++++++++ src/leapflow/security/approval.py | 153 ++++++++------ src/leapflow/security/grants.py | 183 +++++++++++++++++ src/leapflow/security/orchestrator.py | 242 +++++++++++++++++++++++ src/leapflow/security/policy.py | 60 ++++++ src/leapflow/security/risk.py | 199 +++++++++++++++++++ src/leapflow/tools/file_operations.py | 5 +- src/leapflow/tools/gateway_tool.py | 35 +++- src/leapflow/tools/shell_tools.py | 43 +++- tests/test_approval_layer.py | 180 +++++++++++++++++ tests/test_cli_entrypoint.py | 3 +- tests/test_daemon_rpc.py | 86 ++++++++ tests/test_tui_session_summary.py | 21 +- 24 files changed, 1876 insertions(+), 180 deletions(-) create mode 100644 src/leapflow/cli/approval_view.py create mode 100644 src/leapflow/security/actions.py create mode 100644 src/leapflow/security/grants.py create mode 100644 src/leapflow/security/orchestrator.py create mode 100644 src/leapflow/security/policy.py create mode 100644 src/leapflow/security/risk.py create mode 100644 tests/test_approval_layer.py diff --git a/src/leapflow/cli/approval_view.py b/src/leapflow/cli/approval_view.py new file mode 100644 index 0000000..2297aed --- /dev/null +++ b/src/leapflow/cli/approval_view.py @@ -0,0 +1,199 @@ +"""Terminal approval view helpers for LeapFlow CLI/TUI surfaces.""" +from __future__ import annotations + +import asyncio +import sys +import textwrap +import time +from dataclasses import dataclass + +from leapflow.security.approval import ApprovalDecision, ApprovalRequest +from leapflow.security.redact import redact_sensitive_text + + +@dataclass(frozen=True) +class ApprovalChoice: + """One selectable approval choice.""" + + key: str + label: str + decision: ApprovalDecision | None = None + + +_CHOICE_LABELS = { + "allow_once": "Allow once", + "allow_session": "Allow for this session", + "allow_always": "Add to permanent allowlist", + "deny": "Deny", + "deny_always": "Deny for this session", + "show_details": "Show full details", +} + +_CHOICE_DECISIONS = { + "allow_once": ApprovalDecision.ALLOW_ONCE, + "allow_session": ApprovalDecision.ALLOW_SESSION, + "allow_always": ApprovalDecision.ALLOW_ALWAYS, + "deny": ApprovalDecision.DENY, + "deny_always": ApprovalDecision.DENY_ALWAYS, +} + + +async def prompt_approval(request: ApprovalRequest) -> ApprovalDecision: + """Render an approval prompt and return a user decision.""" + if not sys.stdin.isatty(): + return ApprovalDecision.DENY + + choices = _build_choices(request) + show_details = False + while True: + if _is_expired(request): + return ApprovalDecision.DENY + _render(request, choices, show_details=show_details) + try: + answer = await asyncio.wait_for( + asyncio.get_running_loop().run_in_executor( + None, lambda: input(" → ").strip().lower(), + ), + timeout=_remaining_seconds(request), + ) + except TimeoutError: + return ApprovalDecision.DENY + except (EOFError, KeyboardInterrupt): + return ApprovalDecision.DENY + + selected = _resolve_choice(answer, choices) + if selected is None: + return ApprovalDecision.DENY + if selected.key == "show_details": + show_details = True + continue + return selected.decision or ApprovalDecision.DENY + + +def _build_choices(request: ApprovalRequest) -> list[ApprovalChoice]: + keys = list(request.choices or ("allow_once", "allow_session", "deny")) + if "deny" not in keys: + keys.append("deny") + choices = [] + for key in keys: + choices.append(ApprovalChoice( + key=key, + label=_CHOICE_LABELS.get(key, key.replace("_", " ").title()), + decision=_CHOICE_DECISIONS.get(key), + )) + return choices + + +def _resolve_choice(answer: str, choices: list[ApprovalChoice]) -> ApprovalChoice | None: + if not answer: + return next((choice for choice in choices if choice.key == "deny"), None) + aliases = { + "y": "allow_once", + "yes": "allow_once", + "o": "allow_once", + "once": "allow_once", + "s": "allow_session", + "session": "allow_session", + "a": "allow_always", + "always": "allow_always", + "n": "deny", + "no": "deny", + "d": "deny", + "deny": "deny", + "v": "show_details", + "view": "show_details", + "full": "show_details", + } + key = aliases.get(answer, answer) + if answer.isdigit(): + idx = int(answer) - 1 + if 0 <= idx < len(choices): + return choices[idx] + return next((choice for choice in choices if choice.key == key), None) + + +def _render(request: ApprovalRequest, choices: list[ApprovalChoice], *, show_details: bool) -> None: + title = str(request.display.get("title") or _title_for(request)) + summary = str(request.display.get("summary") or request.category) + reason = str(request.display.get("reason") or _risk_reason(request)) + detail = redact_sensitive_text(request.detail, force=True) + if not show_details: + detail = _truncate_detail(detail) + + try: + from rich.console import Console + from rich.panel import Panel + from rich.text import Text + + console = Console(stderr=True, highlight=False) + body = Text() + body.append(f"{summary}\n\n", style="bold") + body.append("Action detail:\n", style="dim") + body.append(_indent(detail) + "\n\n", style="yellow") + if reason: + body.append("Why approval is needed:\n", style="dim") + for line in textwrap.wrap(reason, width=72) or [reason]: + body.append(f"- {line}\n", style="dim") + body.append("\n") + remaining = _remaining_seconds(request) + if remaining is not None: + body.append(f"Defaults to Deny in {int(remaining)}s.\n\n", style="dim") + for idx, choice in enumerate(choices, start=1): + body.append(f" {idx}. {choice.label}\n", style="bold" if choice.key == request.default_choice else "") + console.print(Panel( + body, + title=f"[bold yellow]⚠ {title}[/]", + border_style="yellow", + padding=(0, 1), + )) + except ImportError: + sys.stderr.write(f"⚠ {title}\n\n{summary}\n\n{detail}\n\n") + if reason: + sys.stderr.write(f"Why approval is needed: {reason}\n\n") + remaining = _remaining_seconds(request) + if remaining is not None: + sys.stderr.write(f"Defaults to Deny in {int(remaining)}s.\n\n") + for idx, choice in enumerate(choices, start=1): + sys.stderr.write(f" {idx}. {choice.label}\n") + sys.stderr.flush() + + +def _title_for(request: ApprovalRequest) -> str: + if request.risk is not None: + if request.risk.level.value == "high": + return "High Risk Action" + if request.risk.level.value == "critical": + return "Critical Action" + return "Action Approval" + + +def _risk_reason(request: ApprovalRequest) -> str: + if request.risk is None: + return "" + if request.risk.explanation: + return request.risk.explanation + return ", ".join(request.risk.reasons) + + +def _remaining_seconds(request: ApprovalRequest) -> float | None: + if request.expires_at is None: + return None + return max(0.0, float(request.expires_at) - time.time()) + + +def _is_expired(request: ApprovalRequest) -> bool: + remaining = _remaining_seconds(request) + return remaining is not None and remaining <= 0.0 + + +def _truncate_detail(text: str, *, max_lines: int = 6, width: int = 88) -> str: + wrapped: list[str] = [] + for line in text.splitlines() or [text]: + wrapped.extend(textwrap.wrap(line, width=width, replace_whitespace=False) or [""]) + if len(wrapped) <= max_lines: + return "\n".join(wrapped) + return "\n".join(wrapped[: max_lines - 1] + ["… (choose Show full details)"]) + + +def _indent(text: str) -> str: + return "\n".join(f" {line}" for line in text.splitlines()) diff --git a/src/leapflow/cli/commands/chat.py b/src/leapflow/cli/commands/chat.py index f61b470..be0baae 100644 --- a/src/leapflow/cli/commands/chat.py +++ b/src/leapflow/cli/commands/chat.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING, AsyncIterator +from typing import TYPE_CHECKING, Any, AsyncIterator, Awaitable, Callable from leapflow.cli.helpers import require_initialized @@ -11,7 +11,13 @@ from leapflow.daemon.client import DaemonClient -async def render_chat_stream(events: AsyncIterator[object]) -> int: +ApprovalResolver = Callable[[str, str], Awaitable[object]] + + +async def render_chat_stream( + events: AsyncIterator[object], + approval_resolver: ApprovalResolver | None = None, +) -> int: """Render a stream of engine events to the terminal.""" from leapflow.cli.tui_app import detect_theme, LeapConsole, StreamRenderer @@ -37,16 +43,38 @@ async def render_chat_stream(events: AsyncIterator[object]) -> int: renderer.feed(event.content) elif event.type == "error": renderer.feed(event.content) + elif event.type == "approval_request": + await _handle_approval_event(event, approval_resolver) finally: renderer.finish() return 0 +async def _handle_approval_event(event: Any, approval_resolver: ApprovalResolver | None) -> None: + if approval_resolver is None: + return + from leapflow.cli.approval_view import prompt_approval + from leapflow.security.approval import ApprovalDecision, ApprovalRequest + + metadata = event.metadata or {} + payload = metadata.get("approval") + if not isinstance(payload, dict): + return + pending_id = str(payload.get("pending_id") or "") + if not pending_id: + return + request = ApprovalRequest.from_dict(payload) + decision = await prompt_approval(request) + value = decision.value if isinstance(decision, ApprovalDecision) else str(decision) + await approval_resolver(pending_id, value) + + async def cmd_chat_daemon(client: "DaemonClient", prompt: str, thinking: bool) -> int: """Single-turn conversational mode backed by leapd.""" return await render_chat_stream( - client.engine_chat(prompt, enable_thinking=thinking) + client.engine_chat(prompt, enable_thinking=thinking), + lambda pending_id, decision: client.approval_resolve(pending_id, decision), ) diff --git a/src/leapflow/cli/commands/interactive.py b/src/leapflow/cli/commands/interactive.py index 881ef93..405aa72 100644 --- a/src/leapflow/cli/commands/interactive.py +++ b/src/leapflow/cli/commands/interactive.py @@ -658,6 +658,24 @@ async def _print_daemon_status() -> None: if context_used is not None: console.system(f"Context used: {int(context_used):,} tokens") + async def _handle_daemon_approval(event: StreamEvent) -> None: + from leapflow.cli.approval_view import prompt_approval + from leapflow.security.approval import ApprovalDecision, ApprovalRequest + + metadata = event.metadata or {} + payload = metadata.get("approval") + if not isinstance(payload, dict): + return + pending_id = str(payload.get("pending_id") or "") + if not pending_id: + return + app.spinner_text = "Waiting for approval…" + request = ApprovalRequest.from_dict(payload) + decision = await prompt_approval(request) + value = decision.value if isinstance(decision, ApprovalDecision) else str(decision) + await client.approval_resolve(pending_id, value) + app.spinner_text = "Thinking…" + async def _stream_response(prompt_text: str) -> None: nonlocal active_session_id, turn_count, runtime_model_name nonlocal runtime_context_length, runtime_context_used @@ -686,6 +704,8 @@ async def _stream_response(prompt_text: str) -> None: renderer.feed(event.content) elif event.type == "error": renderer.feed(event.content) + elif event.type == "approval_request": + await _handle_daemon_approval(event) elif event.type == "status": if not metadata.get("heartbeat"): console.system(event.content) diff --git a/src/leapflow/cli/context.py b/src/leapflow/cli/context.py index f100a61..6482ac3 100644 --- a/src/leapflow/cli/context.py +++ b/src/leapflow/cli/context.py @@ -2,7 +2,6 @@ from __future__ import annotations -import asyncio import logging import os import re @@ -96,62 +95,28 @@ class _TUIApprovalGate: async def request_approval( self, request: "ApprovalRequest", ) -> "ApprovalDecision": - from leapflow.security.approval import ApprovalDecision + from leapflow.cli.approval_view import prompt_approval - if not sys.stdin.isatty(): - return ApprovalDecision.DENY - - label, color = self._CATEGORY_LABELS.get( - request.category, ("Action", "yellow"), - ) - - try: - from rich.console import Console - from rich.panel import Panel - from rich.text import Text - - console = Console(stderr=True, highlight=False) - body = Text() - body.append(f" {request.detail}\n\n", style="bold") - body.append(" [y]es — allow once\n", style="dim") - body.append(" [a]lways — allow for this session\n", style="dim") - body.append(" [n]o / Enter — deny\n", style="dim") - console.print(Panel( - body, - title=f"[bold {color}]⚠ {label} Approval[/]", - border_style=color, - padding=(0, 1), - )) - sys.stderr.write(" → ") - sys.stderr.flush() - except ImportError: - sys.stderr.write(f"⚠ {label}: {request.detail}\n") - sys.stderr.write("Approve? [y/a(lways)/N]: ") - sys.stderr.flush() - - try: - answer = await asyncio.get_running_loop().run_in_executor( - None, lambda: input().strip().lower(), - ) - except (EOFError, KeyboardInterrupt): - return ApprovalDecision.DENY - - if answer in ("a", "always"): - return ApprovalDecision.ALLOW_SESSION - if answer in ("y", "yes"): - return ApprovalDecision.ALLOW - return ApprovalDecision.DENY + return await prompt_approval(request) async def check(self, command: str) -> bool: """``CommandApprovalGate`` compatibility — shell_tools calls this.""" from leapflow.security.approval import ApprovalDecision, ApprovalRequest + from leapflow.security.actions import ActionDescriptor + action = ActionDescriptor.shell(command) decision = await self.request_approval(ApprovalRequest( - category="shell_dangerous", + category=action.kind, detail=command, risk_hint=0.7, + action=action, )) - return decision in (ApprovalDecision.ALLOW, ApprovalDecision.ALLOW_SESSION) + return decision in { + ApprovalDecision.ALLOW, + ApprovalDecision.ALLOW_ONCE, + ApprovalDecision.ALLOW_SESSION, + ApprovalDecision.ALLOW_ALWAYS, + } def _default_recording_profile(settings: Settings) -> Optional["RecordingProfile"]: @@ -493,9 +458,17 @@ def __init__(self, settings: Settings, mock_host: bool) -> None: # Unified approval gate is resource-free; create it in __init__ so all # initialize() wiring paths can safely reference the same session gate. from leapflow.security.approval import SessionAwareGate + from leapflow.security.grants import ApprovalAuditLog, JsonApprovalGrantStore + from leapflow.security.orchestrator import ApprovalOrchestrator + approval_dir = settings.profile_dir / "approval" self._tui_approval = _TUIApprovalGate() self._approval_gate = SessionAwareGate(self._tui_approval) + self._approval_orchestrator = ApprovalOrchestrator( + self._approval_gate, + grants=JsonApprovalGrantStore(approval_dir / "grants.json"), + audit=ApprovalAuditLog(approval_dir / "audit.jsonl"), + ) def _configure_llm_clients(self, settings: Settings) -> None: """Build LLM/VLM clients from a settings snapshot.""" @@ -1410,7 +1383,7 @@ async def _on_gateway_event(event: object) -> None: ) self.gateway_server.discover_manifests() set_gateway_server(self.gateway_server) - set_gateway_approval_gate(self._approval_gate) + set_gateway_approval_gate(self._approval_orchestrator) async def _gateway_send(source: Any, text: str) -> None: await self.gateway_server.send_reply(source, text) @@ -1517,8 +1490,8 @@ async def _handler(params: dict) -> dict: # ── Unified approval gate wiring (shell, file, gateway) ── try: from leapflow.tools.shell_tools import set_approval_gate - set_approval_gate(self._approval_gate) - logger.debug("Shell approval gate: unified TUI mode") + set_approval_gate(self._approval_orchestrator) + logger.debug("Shell approval gate: action orchestrator mode") except Exception: logger.debug("Shell approval gate setup skipped", exc_info=True) @@ -1627,69 +1600,50 @@ async def _archive_to_semantic(messages: List[Dict[str, Any]]) -> None: aux = self.auxiliary class _SmartApprovalGate: - """LLM-assisted approval that auto-approves low-risk commands. + """LLM-assisted shell approval adapter that preserves policy authority.""" - Uses the auxiliary LLM to score risk (0.0–1.0). - Low-risk (< 0.3) auto-approved; others prompt via TUI gate. - Wraps the unified approval gate — session memory still works. - """ def __init__(self, delegate: Any) -> None: self._delegate = delegate + async def evaluate(self, action: Any) -> Any: + return await self._delegate.evaluate(action) + async def check(self, command: str) -> bool: try: risk = await aux.classify_risk(command) except Exception: risk = 0.5 if risk < 0.3: - logger.debug("smart_approval: auto-approved (risk=%.2f)", risk) - return True + logger.debug("smart_approval: low auxiliary risk hint (risk=%.2f)", risk) return await self._delegate.check(command) from leapflow.tools.shell_tools import set_approval_gate - set_approval_gate(_SmartApprovalGate(self._approval_gate)) + set_approval_gate(_SmartApprovalGate(self._approval_orchestrator)) logger.debug("Smart approval gate enabled (auxiliary LLM)") except Exception: logger.debug("Smart approval setup skipped", exc_info=True) # ── Wire File Write Approval Gate ── try: + from leapflow.security.actions import ActionDescriptor from leapflow.tools.registry_bootstrap import set_file_write_gate - from leapflow.security.approval import ApprovalDecision, ApprovalRequest - _SAFE_EXTENSIONS = frozenset({ - ".tmp", ".log", ".txt", ".md", ".json", ".yaml", ".yml", - ".csv", ".tsv", - }) - approval_gate = self._approval_gate + approval_orchestrator = self._approval_orchestrator class _FileWriteGate: - """File write approval via the unified gate. - - Auto-approves safe extensions and small writes; delegates - everything else to the session-aware approval gate. - """ - async def check(self, path: str, content: str) -> bool: - from pathlib import Path as _P - p = _P(path) - if p.suffix.lower() in _SAFE_EXTENSIONS: - return True - if len(content) < 500: - return True - decision = await approval_gate.request_approval( - ApprovalRequest( - category="file_write", - detail=f"{p.name} ({len(content)} chars)", - risk_hint=0.4, - ), - ) - return decision in ( - ApprovalDecision.ALLOW, - ApprovalDecision.ALLOW_SESSION, - ) + """File write approval via the action approval orchestrator.""" + + def __init__(self) -> None: + self.denial_message = "" + + async def check(self, path: str, content: str, mode: str = "overwrite") -> bool: + action = ActionDescriptor.file_write(path, content, mode=mode) + result = await approval_orchestrator.evaluate(action) + self.denial_message = result.denial_message if not result.approved else "" + return result.approved set_file_write_gate(_FileWriteGate()) - logger.debug("File write approval gate: unified") + logger.debug("File write approval gate: action orchestrator") except Exception: logger.debug("File write gate setup skipped", exc_info=True) diff --git a/src/leapflow/cli/tui_app/console.py b/src/leapflow/cli/tui_app/console.py index 254f29d..83d3b39 100644 --- a/src/leapflow/cli/tui_app/console.py +++ b/src/leapflow/cli/tui_app/console.py @@ -13,6 +13,7 @@ from rich.console import Console from rich.markdown import Markdown +from rich.padding import Padding from rich.panel import Panel from rich.rule import Rule from rich.syntax import Syntax @@ -113,7 +114,7 @@ def command_card(self, command: TuiCommand) -> None: padding=(0, 1), )) - def markdown(self, text: str, *, code_theme: str = "monokai") -> None: + def markdown(self, text: str, *, code_theme: str = "monokai", indent: int = 0) -> None: """Render markdown content with syntax-highlighted code blocks.""" if not text.strip(): return @@ -121,7 +122,8 @@ def markdown(self, text: str, *, code_theme: str = "monokai") -> None: text, code_theme=code_theme if self._theme.name == "dark" else "default", ) - self._console.print(md) + renderable = Padding(md, (0, 0, 0, indent)) if indent > 0 else md + self._console.print(renderable) def code(self, source: str, language: str = "python", *, title: str = "") -> None: """Render a standalone code block with syntax highlighting.""" diff --git a/src/leapflow/cli/tui_app/stream.py b/src/leapflow/cli/tui_app/stream.py index 588ece2..3dbf91f 100644 --- a/src/leapflow/cli/tui_app/stream.py +++ b/src/leapflow/cli/tui_app/stream.py @@ -17,6 +17,8 @@ from rich.text import Text +_FINAL_RESPONSE_INDENT_SPACES = 4 + if TYPE_CHECKING: from leapflow.cli.tui_app.console import LeapConsole @@ -116,7 +118,7 @@ def finish(self) -> None: self._console.thinking(self._thinking_buffer) if self._buffer.strip(): - self._console.markdown(self._buffer) + self._console.markdown(self._buffer, indent=_FINAL_RESPONSE_INDENT_SPACES) self._console.response_label(self.elapsed, tool_count=self.tool_count) self._console.newline() diff --git a/src/leapflow/daemon/client.py b/src/leapflow/daemon/client.py index c3ce69b..49f056f 100644 --- a/src/leapflow/daemon/client.py +++ b/src/leapflow/daemon/client.py @@ -96,6 +96,33 @@ async def status(self) -> dict[str, Any]: result = await self.request("daemon.status") return dict(result or {}) + async def approval_status(self) -> dict[str, Any]: + """Return pending daemon approval requests.""" + result = await self.request("approval.status") + return dict(result or {}) + + async def approval_resolve( + self, + pending_id: str, + decision: str, + *, + reason: str = "", + ) -> dict[str, Any]: + """Resolve a pending daemon approval request.""" + result = await self.request( + "approval.resolve", + {"pending_id": pending_id, "decision": decision, "reason": reason}, + ) + return dict(result or {}) + + async def approval_cancel(self, pending_id: str, *, reason: str = "cancelled") -> dict[str, Any]: + """Cancel a pending daemon approval request.""" + result = await self.request( + "approval.cancel", + {"pending_id": pending_id, "reason": reason}, + ) + return dict(result or {}) + async def shutdown(self) -> None: """Request graceful daemon shutdown.""" await self.request("daemon.shutdown") @@ -178,6 +205,8 @@ def _event_from_params(params: dict[str, Any]) -> StreamEvent: "thinking", "status", "error", + "approval_request", + "approval_response", }: event_type = "chunk" metadata = params.get("metadata") diff --git a/src/leapflow/daemon/protocol.py b/src/leapflow/daemon/protocol.py index e9f42e5..326da6d 100644 --- a/src/leapflow/daemon/protocol.py +++ b/src/leapflow/daemon/protocol.py @@ -110,7 +110,15 @@ class StreamChunk: content: str done: bool = False event_type: Literal[ - "chunk", "final", "tool_start", "tool_complete", "thinking", "status", "error" + "chunk", + "final", + "tool_start", + "tool_complete", + "thinking", + "status", + "error", + "approval_request", + "approval_response", ] = "chunk" metadata: Optional[Dict[str, Any]] = None @@ -179,6 +187,23 @@ async def status(self) -> Dict[str, Any]: """Return daemon status (uptime, connections, db path, etc.).""" ... + async def approval_status(self) -> Dict[str, Any]: + """Return pending approval requests.""" + ... + + async def approval_resolve( + self, + pending_id: str, + decision: str, + reason: str = "", + ) -> Dict[str, Any]: + """Resolve a pending approval request.""" + ... + + async def approval_cancel(self, pending_id: str, reason: str = "cancelled") -> Dict[str, Any]: + """Cancel a pending approval request.""" + ... + async def shutdown(self) -> None: """Graceful shutdown.""" ... @@ -229,6 +254,9 @@ async def gateway_send( "scheduler.arm": "scheduler_arm", "daemon.status": "status", "daemon.shutdown": "shutdown", + "approval.status": "approval_status", + "approval.resolve": "approval_resolve", + "approval.cancel": "approval_cancel", "gateway.connect": "gateway_connect", "gateway.disconnect": "gateway_disconnect", "gateway.status": "gateway_status", diff --git a/src/leapflow/daemon/service.py b/src/leapflow/daemon/service.py index 6764b72..ecd4b04 100644 --- a/src/leapflow/daemon/service.py +++ b/src/leapflow/daemon/service.py @@ -6,6 +6,7 @@ import os import sys import time +import uuid from collections.abc import AsyncIterator, Callable from typing import Any @@ -26,6 +27,8 @@ def __init__(self, settings: Any, *, mock_host: bool = False) -> None: self._engine_lock = asyncio.Lock() self._started_at = time.time() self._client_count: Callable[[], int] = lambda: 0 + self._approval_pending: dict[str, dict[str, Any]] = {} + self._approval_event_queue: asyncio.Queue[StreamChunk] | None = None def set_client_count_provider(self, provider: Callable[[], int]) -> None: """Set a lightweight callback used by status reporting.""" @@ -39,6 +42,7 @@ async def start(self) -> None: ctx = Context(self._settings, self._mock_host) await ctx.initialize() + self._install_daemon_approval(ctx) self._ctx = ctx @property @@ -99,9 +103,15 @@ async def engine_chat(self, message: str, **kwargs: Any) -> AsyncIterator[Stream raise RuntimeError("leapd engine is not initialized") enable_thinking = bool(kwargs.get("enable_thinking", False)) - async for event in engine.run_stream(message, enable_thinking=enable_thinking): - stream_event = self._normalize_event(event) - yield self._chunk_from_event(stream_event) + approval_queue: asyncio.Queue[StreamChunk] = asyncio.Queue() + previous_queue = self._approval_event_queue + self._approval_event_queue = approval_queue + try: + stream = engine.run_stream(message, enable_thinking=enable_thinking) + async for chunk in self._stream_engine_events(stream, approval_queue): + yield chunk + finally: + self._approval_event_queue = previous_queue async def engine_cancel(self) -> bool: ctx = self.context @@ -143,8 +153,33 @@ async def status(self) -> dict[str, Any]: "runtime_source": self._runtime_source(), "runtime_executable": sys.executable, "runtime_version": self._runtime_version(), + "pending_approvals": len(self._approval_pending), } + async def approval_status(self) -> dict[str, Any]: + """Return currently pending daemon approval requests.""" + return {"pending": self._pending_payloads()} + + async def approval_resolve( + self, + pending_id: str, + decision: str, + reason: str = "", + ) -> dict[str, Any]: + """Resolve a pending approval request from a thin client.""" + pending = self._approval_pending.get(pending_id) + if pending is None: + return {"ok": False, "error": f"Unknown approval request: {pending_id}"} + future = pending.get("future") + if not isinstance(future, asyncio.Future) or future.done(): + return {"ok": False, "error": f"Approval request is no longer pending: {pending_id}"} + future.set_result({"decision": self._normalize_approval_decision(decision), "reason": reason}) + return {"ok": True, "pending_id": pending_id, "decision": self._normalize_approval_decision(decision)} + + async def approval_cancel(self, pending_id: str, reason: str = "cancelled") -> dict[str, Any]: + """Cancel a pending approval request, causing the action to be denied.""" + return await self.approval_resolve(pending_id, "deny", reason=reason) + @staticmethod def _runtime_source() -> str: import leapflow @@ -211,6 +246,139 @@ def _checkpoint_open_connection(self, ctx: Any) -> None: except Exception: logger.debug("daemon: DuckDB checkpoint skipped", exc_info=True) + def _install_daemon_approval(self, ctx: Any) -> None: + try: + from leapflow.security.approval import SessionAwareGate + from leapflow.security.actions import ActionDescriptor + from leapflow.security.orchestrator import ApprovalOrchestrator + from leapflow.tools.gateway_tool import set_gateway_approval_gate + from leapflow.tools.registry_bootstrap import set_file_write_gate + from leapflow.tools.shell_tools import set_approval_gate + + existing = getattr(ctx, "_approval_orchestrator", None) + gate = SessionAwareGate(_DaemonApprovalGate(self)) + orchestrator = ApprovalOrchestrator( + gate, + grants=getattr(existing, "grants", None), + audit=getattr(existing, "audit", None), + ) + ctx._approval_gate = gate + ctx._approval_orchestrator = orchestrator + set_approval_gate(orchestrator) + set_gateway_approval_gate(orchestrator) + + class _FileWriteGate: + def __init__(self) -> None: + self.denial_message = "" + + async def check(self, path: str, content: str, mode: str = "overwrite") -> bool: + result = await orchestrator.evaluate(ActionDescriptor.file_write(path, content, mode=mode)) + self.denial_message = result.denial_message if not result.approved else "" + return result.approved + + set_file_write_gate(_FileWriteGate()) + logger.debug("daemon approval gate installed") + except Exception: + logger.debug("daemon approval gate installation skipped", exc_info=True) + + async def _stream_engine_events( + self, + stream: AsyncIterator[object], + approval_queue: asyncio.Queue[StreamChunk], + ) -> AsyncIterator[StreamChunk]: + engine_task: asyncio.Task[Any] | None = asyncio.create_task(anext(stream)) + approval_task: asyncio.Task[StreamChunk] | None = asyncio.create_task(approval_queue.get()) + try: + while engine_task is not None: + wait_set = {task for task in (engine_task, approval_task) if task is not None} + done, _ = await asyncio.wait(wait_set, return_when=asyncio.FIRST_COMPLETED) + if approval_task is not None and approval_task in done: + yield approval_task.result() + approval_task = asyncio.create_task(approval_queue.get()) + continue + if engine_task in done: + try: + event = engine_task.result() + except StopAsyncIteration: + engine_task = None + break + stream_event = self._normalize_event(event) + yield self._chunk_from_event(stream_event) + engine_task = asyncio.create_task(anext(stream)) + finally: + for task in (engine_task, approval_task): + if task is not None and not task.done(): + task.cancel() + self._deny_pending_for_queue(approval_queue, reason="stream_closed") + if hasattr(stream, "aclose"): + try: + await stream.aclose() + except Exception: + logger.debug("daemon: failed to close engine stream", exc_info=True) + + def _pending_payloads(self) -> list[dict[str, Any]]: + return [dict(item.get("request") or {}) for item in self._approval_pending.values()] + + async def _request_approval(self, request: Any) -> str: + queue = self._approval_event_queue + if queue is None: + return "deny" + pending_id = str(getattr(request, "request_id", "") or uuid.uuid4().hex) + payload = request.to_dict() + payload["pending_id"] = pending_id + payload.setdefault("request_id", pending_id) + future: asyncio.Future[dict[str, Any]] = asyncio.get_running_loop().create_future() + self._approval_pending[pending_id] = { + "request": payload, + "future": future, + "queue": queue, + "created_at": time.time(), + } + await queue.put(StreamChunk( + request_id="", + content="Approval required", + event_type="approval_request", + metadata={"approval": payload}, + )) + timeout_s = 120.0 + if getattr(request, "expires_at", None): + timeout_s = max(1.0, float(request.expires_at) - time.time()) + try: + result = await asyncio.wait_for(future, timeout=timeout_s) + return str(result.get("decision") or "deny") + except TimeoutError: + return "deny" + finally: + self._approval_pending.pop(pending_id, None) + + @staticmethod + def _normalize_approval_decision(decision: str) -> str: + allowed = { + "allow", + "allow_once", + "allow_session", + "allow_always", + "deny", + "deny_always", + "cancel_workflow", + } + value = str(decision or "deny").strip().lower() + return value if value in allowed else "deny" + + def _deny_pending_for_queue( + self, + queue: asyncio.Queue[StreamChunk], + *, + reason: str, + ) -> None: + for pending_id, pending in list(self._approval_pending.items()): + if pending.get("queue") is not queue: + continue + future = pending.get("future") + if isinstance(future, asyncio.Future) and not future.done(): + future.set_result({"decision": "deny", "reason": reason}) + self._approval_pending.pop(pending_id, None) + def _normalize_event(self, event: object) -> StreamEvent: if isinstance(event, StreamEvent): return event @@ -232,3 +400,19 @@ def _chunk_from_event(self, event: StreamEvent) -> StreamChunk: event_type=event.type, metadata=metadata, ) + + +class _DaemonApprovalGate: + """Approval gate that bridges daemon-side actions to thin clients.""" + + def __init__(self, service: RuntimeLeapService) -> None: + self._service = service + + async def request_approval(self, request: Any) -> Any: + from leapflow.security.approval import ApprovalDecision + + decision = await self._service._request_approval(request) + try: + return ApprovalDecision(decision) + except ValueError: + return ApprovalDecision.DENY diff --git a/src/leapflow/engine/engine.py b/src/leapflow/engine/engine.py index fc20f6d..fbc4588 100644 --- a/src/leapflow/engine/engine.py +++ b/src/leapflow/engine/engine.py @@ -199,12 +199,14 @@ class StreamEvent: - tool_complete: tool execution finished (content = brief result). - thinking: reasoning/thinking phase indicator. - status: lifecycle status update. + - approval_request: human approval request from a daemon-side action. + - approval_response: human approval resolution notification. - error: error notification. """ type: Literal[ "chunk", "final", "tool_start", "tool_complete", - "thinking", "status", "error", + "thinking", "status", "error", "approval_request", "approval_response", ] content: str metadata: Optional[Dict[str, Any]] = None diff --git a/src/leapflow/security/__init__.py b/src/leapflow/security/__init__.py index 42f711e..26d5162 100644 --- a/src/leapflow/security/__init__.py +++ b/src/leapflow/security/__init__.py @@ -1,5 +1,6 @@ """Security module — redaction, threat scanning, approval, and trust boundary enforcement.""" +from leapflow.security.actions import ActionDescriptor, ActionEffect, ActionKind, ActionOrigin from leapflow.security.approval import ( ApprovalDecision, ApprovalGate, @@ -7,11 +8,30 @@ DenyAllGate, SessionAwareGate, ) +from leapflow.security.grants import ApprovalAuditLog, ApprovalGrant, ApprovalScope +from leapflow.security.orchestrator import ApprovalOrchestrator, ApprovalResult +from leapflow.security.policy import ApprovalPolicyEngine, PolicyDecision, PolicyVerdict +from leapflow.security.risk import DefaultRiskClassifier, RiskAssessment, RiskLevel __all__ = [ + "ActionDescriptor", + "ActionEffect", + "ActionKind", + "ActionOrigin", + "ApprovalAuditLog", "ApprovalDecision", "ApprovalGate", + "ApprovalGrant", + "ApprovalOrchestrator", + "ApprovalPolicyEngine", "ApprovalRequest", + "ApprovalResult", + "ApprovalScope", + "DefaultRiskClassifier", "DenyAllGate", + "PolicyDecision", + "PolicyVerdict", + "RiskAssessment", + "RiskLevel", "SessionAwareGate", ] diff --git a/src/leapflow/security/actions.py b/src/leapflow/security/actions.py new file mode 100644 index 0000000..3326d8d --- /dev/null +++ b/src/leapflow/security/actions.py @@ -0,0 +1,180 @@ +"""Structured action descriptors for human approval decisions.""" +from __future__ import annotations + +import hashlib +import json +import re +import uuid +from dataclasses import asdict, dataclass, field +from enum import Enum +from typing import Any + + +class ActionKind(str, Enum): + """High-level action families that may require approval.""" + + SHELL_COMMAND = "shell.command" + FILE_WRITE = "file.write" + FILE_DELETE = "file.delete" + GATEWAY_SEND = "gateway.send" + SCHEDULER_ARM = "scheduler.arm" + SKILL_EXECUTE = "skill.execute" + SKILL_PROMOTE = "skill.promote" + APP_INSTALL = "app.install" + RUNTIME_CONFIGURE = "runtime.configure" + EXTERNAL_ACTION = "external.action" + + +class ActionEffect(str, Enum): + """Observable effect of an action.""" + + READ = "read" + WRITE = "write" + EXECUTE = "execute" + SEND = "send" + DELETE = "delete" + CONFIGURE = "configure" + SCHEDULE = "schedule" + PROMOTE = "promote" + + +class ActionOrigin(str, Enum): + """Where an action originated.""" + + AGENT_TOOL = "agent_tool" + SKILL = "skill" + SCHEDULER = "scheduler" + GATEWAY = "gateway" + DAEMON = "daemon" + USER = "user" + + +@dataclass(frozen=True) +class ActionDescriptor: + """A normalized description of an operation before it mutates the world.""" + + kind: str + summary: str + detail: str + effect: str + resource: str = "" + origin: str = ActionOrigin.AGENT_TOOL.value + action_id: str = field(default_factory=lambda: uuid.uuid4().hex) + session_id: str = "" + turn_id: str = "" + tool_call_id: str = "" + metadata: dict[str, Any] = field(default_factory=dict) + + @classmethod + def shell( + cls, + command: str, + *, + cwd: str | None = None, + origin: str = ActionOrigin.AGENT_TOOL.value, + metadata: dict[str, Any] | None = None, + ) -> "ActionDescriptor": + merged = dict(metadata or {}) + if cwd: + merged["cwd"] = cwd + return cls( + kind=ActionKind.SHELL_COMMAND.value, + summary=_summarize_shell(command), + detail=command, + effect=ActionEffect.EXECUTE.value, + resource=str(cwd or "shell"), + origin=origin, + metadata=merged, + ) + + @classmethod + def file_write( + cls, + path: str, + content: str, + *, + mode: str = "overwrite", + metadata: dict[str, Any] | None = None, + ) -> "ActionDescriptor": + merged = dict(metadata or {}) + merged.update({"mode": mode, "bytes": len(content.encode("utf-8"))}) + preview = content[:500] + return cls( + kind=ActionKind.FILE_WRITE.value, + summary=f"Write file: {path}", + detail=preview, + effect=ActionEffect.WRITE.value, + resource=path, + metadata=merged, + ) + + @classmethod + def gateway_send( + cls, + platform: str, + chat_id: str, + text: str, + *, + metadata: dict[str, Any] | None = None, + ) -> "ActionDescriptor": + merged = dict(metadata or {}) + merged.update({"platform": platform, "chat_id": chat_id}) + return cls( + kind=ActionKind.GATEWAY_SEND.value, + summary=f"Send message to {platform}/{chat_id}", + detail=text, + effect=ActionEffect.SEND.value, + resource=f"{platform}:{chat_id}", + metadata=merged, + ) + + def signature(self) -> str: + """Return a stable signature suitable for session/profile grants.""" + payload = { + "kind": self.kind, + "effect": self.effect, + "resource": _normalize_resource(self.resource), + "detail": _normalize_detail(self.kind, self.detail), + "origin": self.origin, + } + encoded = json.dumps(payload, sort_keys=True, ensure_ascii=False) + return hashlib.sha256(encoded.encode("utf-8")).hexdigest()[:24] + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "ActionDescriptor": + return cls( + kind=str(data.get("kind") or ActionKind.EXTERNAL_ACTION.value), + summary=str(data.get("summary") or "Action"), + detail=str(data.get("detail") or ""), + effect=str(data.get("effect") or ActionEffect.EXECUTE.value), + resource=str(data.get("resource") or ""), + origin=str(data.get("origin") or ActionOrigin.AGENT_TOOL.value), + action_id=str(data.get("action_id") or uuid.uuid4().hex), + session_id=str(data.get("session_id") or ""), + turn_id=str(data.get("turn_id") or ""), + tool_call_id=str(data.get("tool_call_id") or ""), + metadata=dict(data.get("metadata") or {}), + ) + + +def _summarize_shell(command: str) -> str: + lowered = command.lower() + if "<<" in command: + return "Run script via heredoc" + if "curl" in lowered or "wget" in lowered: + return "Run shell command with network access" + return "Run shell command" + + +def _normalize_resource(resource: str) -> str: + return resource.replace("\\", "/").strip().lower() + + +def _normalize_detail(kind: str, detail: str) -> str: + text = re.sub(r"\s+", " ", detail.strip()) + if kind == ActionKind.GATEWAY_SEND.value: + return "" + return text[:4000] diff --git a/src/leapflow/security/approval.py b/src/leapflow/security/approval.py index 8f56e6d..1c1ab65 100644 --- a/src/leapflow/security/approval.py +++ b/src/leapflow/security/approval.py @@ -1,35 +1,20 @@ """Unified approval framework for actions requiring human confirmation. -Replaces fragmented per-tool gates with a single ``ApprovalGate`` Protocol -and a session-aware wrapper that remembers per-category decisions. - -Design principles: -- **Minimal interruption**: "always for this session" eliminates repeat prompts -- **Fail-closed**: non-interactive environments deny by default -- **Audit trail**: every decision is logged at INFO level -- **Protocol-based**: swappable implementations (TUI, Web UI, headless CI) - -Categories -~~~~~~~~~~ -``shell_dangerous`` - Shell commands matching dangerous regex patterns (sudo, rm -r, etc.) - -``file_write`` - File writes to non-trivial paths (> 500 chars, non-safe extensions) - -``gateway_send`` - Proactive outbound messaging to external platforms (per platform) - -``external_action`` - Reserved for future extensibility (webhooks, API calls, etc.) +This module is the compatibility-facing API for LeapFlow approvals. It keeps +legacy tool gates working while carrying the richer Action Approval model used +by the policy/orchestrator layer. """ from __future__ import annotations import logging import time +import uuid from dataclasses import dataclass, field from enum import Enum -from typing import Any, Dict, Protocol, Set, runtime_checkable +from typing import Any, Protocol, runtime_checkable + +from leapflow.security.actions import ActionDescriptor +from leapflow.security.risk import RiskAssessment logger = logging.getLogger(__name__) @@ -38,33 +23,78 @@ class ApprovalDecision(Enum): """Result of an approval request.""" ALLOW = "allow" - DENY = "deny" + ALLOW_ONCE = "allow_once" ALLOW_SESSION = "allow_session" + ALLOW_ALWAYS = "allow_always" + DENY = "deny" + DENY_ALWAYS = "deny_always" + CANCEL_WORKFLOW = "cancel_workflow" @dataclass(frozen=True) class ApprovalRequest: """Structured request for human approval. - ``category`` groups related actions; session memory uses this key. - ``detail`` is the human-readable description shown in the prompt. - ``risk_hint`` is advisory (0.0–1.0); the gate decides policy. + ``category`` and ``detail`` preserve the historical API. ``action`` and + ``risk`` carry the generalized Action Approval payload used by new callers. """ category: str detail: str + request_id: str = field(default_factory=lambda: uuid.uuid4().hex) risk_hint: float = 0.5 - metadata: Dict[str, Any] = field(default_factory=dict) + metadata: dict[str, Any] = field(default_factory=dict) + action: ActionDescriptor | None = None + risk: RiskAssessment | None = None + choices: tuple[str, ...] = ("allow_once", "allow_session", "deny") + default_choice: str = "deny" + expires_at: float | None = None + display: dict[str, Any] = field(default_factory=dict) + + @property + def grant_key(self) -> str: + """Return a fine-grained session grant key for this request.""" + if self.action is not None: + return f"{self.category}:{self.action.signature()}" + return self.category + + def to_dict(self) -> dict[str, Any]: + return { + "category": self.category, + "detail": self.detail, + "request_id": self.request_id, + "risk_hint": self.risk_hint, + "metadata": dict(self.metadata), + "action": self.action.to_dict() if self.action else None, + "risk": self.risk.to_dict() if self.risk else None, + "choices": list(self.choices), + "default_choice": self.default_choice, + "expires_at": self.expires_at, + "display": dict(self.display), + } + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "ApprovalRequest": + raw_action = data.get("action") + raw_risk = data.get("risk") + return cls( + category=str(data.get("category") or "external_action"), + detail=str(data.get("detail") or ""), + request_id=str(data.get("request_id") or uuid.uuid4().hex), + risk_hint=float(data.get("risk_hint") or 0.5), + metadata=dict(data.get("metadata") or {}), + action=ActionDescriptor.from_dict(raw_action) if isinstance(raw_action, dict) else None, + risk=RiskAssessment.from_dict(raw_risk) if isinstance(raw_risk, dict) else None, + choices=tuple(str(item) for item in data.get("choices") or ("allow_once", "allow_session", "deny")), + default_choice=str(data.get("default_choice") or "deny"), + expires_at=data.get("expires_at"), + display=dict(data.get("display") or {}), + ) @runtime_checkable class ApprovalGate(Protocol): - """Protocol for all human-in-the-loop approval decisions. - - Implementations must handle the prompt display and input collection. - Return ``ALLOW_SESSION`` to suppress future prompts for the same - ``category`` during this session. - """ + """Protocol for all human-in-the-loop approval decisions.""" async def request_approval( self, request: ApprovalRequest, @@ -72,55 +102,62 @@ async def request_approval( class SessionAwareGate: - """Wraps a base ``ApprovalGate`` with per-category session memory. - - Once a category is approved with ``ALLOW_SESSION``, all subsequent - requests for that category are auto-approved without prompting. - Individual ``ALLOW`` decisions are not remembered. - - Also implements ``check(command) -> bool`` for backward compatibility - with ``CommandApprovalGate`` in ``shell_tools.py``. - """ + """Wrap a base gate with fine-grained session memory and audit history.""" def __init__(self, delegate: ApprovalGate) -> None: self._delegate = delegate - self._approved_categories: Set[str] = set() - self._decision_log: list[Dict[str, Any]] = [] + self._approved_categories: set[str] = set() + self._decision_log: list[dict[str, Any]] = [] async def check(self, command: str) -> bool: - """``CommandApprovalGate`` compatibility for shell_tools.py.""" + """Legacy ``CommandApprovalGate`` compatibility for shell tools.""" + action = ActionDescriptor.shell(command) decision = await self.request_approval(ApprovalRequest( - category="shell_dangerous", + category=action.kind, detail=command, risk_hint=0.7, + action=action, )) - return decision in (ApprovalDecision.ALLOW, ApprovalDecision.ALLOW_SESSION) + return decision in { + ApprovalDecision.ALLOW, + ApprovalDecision.ALLOW_ONCE, + ApprovalDecision.ALLOW_SESSION, + ApprovalDecision.ALLOW_ALWAYS, + } async def request_approval( self, request: ApprovalRequest, ) -> ApprovalDecision: - if request.category in self._approved_categories: + grant_key = request.grant_key + if grant_key in self._approved_categories: self._log_decision(request, ApprovalDecision.ALLOW, auto=True) return ApprovalDecision.ALLOW decision = await self._delegate.request_approval(request) - if decision == ApprovalDecision.ALLOW_SESSION: - self._approved_categories.add(request.category) - self._log_decision(request, ApprovalDecision.ALLOW, session=True) - return ApprovalDecision.ALLOW + self._approved_categories.add(grant_key) + self._log_decision(request, ApprovalDecision.ALLOW_SESSION, session=True) + return ApprovalDecision.ALLOW_SESSION + if decision == ApprovalDecision.ALLOW_ALWAYS: + self._approved_categories.add(grant_key) + self._log_decision(request, ApprovalDecision.ALLOW_ALWAYS, session=True) + return ApprovalDecision.ALLOW_ALWAYS self._log_decision(request, decision) return decision def reset(self) -> None: - """Clear all session approvals (e.g. on session restart).""" + """Clear all session approvals.""" self._approved_categories.clear() @property def approved_categories(self) -> frozenset[str]: return frozenset(self._approved_categories) + @property + def decision_log(self) -> tuple[dict[str, Any], ...]: + return tuple(self._decision_log) + def _log_decision( self, request: ApprovalRequest, @@ -131,9 +168,12 @@ def _log_decision( ) -> None: entry = { "ts": time.time(), + "request_id": request.request_id, "category": request.category, + "grant_key": request.grant_key, "decision": decision.value, "detail": request.detail[:200], + "risk": request.risk.to_dict() if request.risk else None, } if auto: entry["reason"] = "session_approved" @@ -144,16 +184,17 @@ def _log_decision( level = logging.DEBUG if auto else logging.INFO logger.log( level, - "approval.%s category=%s detail=%s%s", + "approval.%s category=%s key=%s detail=%s%s", decision.value, request.category, + request.grant_key, request.detail[:80], " (session-approved)" if auto else "", ) class DenyAllGate: - """Gate that denies all requests (non-interactive / CI environments).""" + """Gate that denies all requests in non-interactive or headless contexts.""" async def request_approval( self, request: ApprovalRequest, diff --git a/src/leapflow/security/grants.py b/src/leapflow/security/grants.py new file mode 100644 index 0000000..24ed781 --- /dev/null +++ b/src/leapflow/security/grants.py @@ -0,0 +1,183 @@ +"""Approval grant and audit stores.""" +from __future__ import annotations + +import json +import time +from dataclasses import asdict, dataclass, field +from enum import Enum +from pathlib import Path +from typing import Any, Protocol, runtime_checkable + +from leapflow.security.actions import ActionDescriptor + + +class ApprovalScope(str, Enum): + """How long an approval decision applies.""" + + ONCE = "once" + TURN = "turn" + SESSION = "session" + PROFILE = "profile" + GLOBAL = "global" + + +@dataclass(frozen=True) +class ApprovalGrant: + """A reusable approval or deny decision.""" + + key: str + scope: str + decision: str + action_kind: str + effect: str + resource: str = "" + reason: str = "" + created_at: float = field(default_factory=time.time) + expires_at: float | None = None + + def is_expired(self, now: float | None = None) -> bool: + return self.expires_at is not None and (now or time.time()) >= self.expires_at + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "ApprovalGrant": + return cls( + key=str(data.get("key") or ""), + scope=str(data.get("scope") or ApprovalScope.SESSION.value), + decision=str(data.get("decision") or "allow"), + action_kind=str(data.get("action_kind") or ""), + effect=str(data.get("effect") or ""), + resource=str(data.get("resource") or ""), + reason=str(data.get("reason") or ""), + created_at=float(data.get("created_at") or time.time()), + expires_at=data.get("expires_at"), + ) + + +@runtime_checkable +class ApprovalGrantStore(Protocol): + """Storage abstraction for reusable approval decisions.""" + + def get(self, key: str) -> ApprovalGrant | None: ... + def put(self, grant: ApprovalGrant) -> None: ... + def list(self) -> list[ApprovalGrant]: ... + + +class InMemoryApprovalGrantStore: + """Session-local grant store.""" + + def __init__(self) -> None: + self._grants: dict[str, ApprovalGrant] = {} + + def get(self, key: str) -> ApprovalGrant | None: + grant = self._grants.get(key) + if grant is not None and grant.is_expired(): + self._grants.pop(key, None) + return None + return grant + + def put(self, grant: ApprovalGrant) -> None: + self._grants[grant.key] = grant + + def list(self) -> list[ApprovalGrant]: + now = time.time() + expired = [key for key, grant in self._grants.items() if grant.is_expired(now)] + for key in expired: + self._grants.pop(key, None) + return list(self._grants.values()) + + +class JsonApprovalGrantStore(InMemoryApprovalGrantStore): + """Profile-local JSON grant store used before DuckDB approval tables exist.""" + + def __init__(self, path: Path) -> None: + super().__init__() + self._path = path + self._load() + + def put(self, grant: ApprovalGrant) -> None: + super().put(grant) + self._save() + + def _load(self) -> None: + try: + payload = json.loads(self._path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError, ValueError): + return + if not isinstance(payload, list): + return + for item in payload: + if isinstance(item, dict): + grant = ApprovalGrant.from_dict(item) + if grant.key and not grant.is_expired(): + self._grants[grant.key] = grant + + def _save(self) -> None: + self._path.parent.mkdir(parents=True, exist_ok=True) + payload = [grant.to_dict() for grant in self.list()] + self._path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + + +class ApprovalAuditLog: + """Append-only JSONL audit trail for approval decisions.""" + + def __init__(self, path: Path | None = None) -> None: + self._path = path + self._entries: list[dict[str, Any]] = [] + + @property + def entries(self) -> tuple[dict[str, Any], ...]: + return tuple(self._entries) + + def record( + self, + *, + action: ActionDescriptor, + decision: str, + risk_level: str, + risk_reasons: tuple[str, ...] = (), + scope: str = ApprovalScope.ONCE.value, + actor: str = "user", + reason: str = "", + ) -> None: + entry = { + "ts": time.time(), + "action_id": action.action_id, + "session_id": action.session_id, + "turn_id": action.turn_id, + "tool_call_id": action.tool_call_id, + "action_kind": action.kind, + "effect": action.effect, + "resource": action.resource, + "decision": decision, + "scope": scope, + "actor": actor, + "risk_level": risk_level, + "risk_reasons": list(risk_reasons), + "reason": reason, + "detail": action.detail[:500], + } + self._entries.append(entry) + if self._path is not None: + self._path.parent.mkdir(parents=True, exist_ok=True) + with self._path.open("a", encoding="utf-8") as handle: + handle.write(json.dumps(entry, ensure_ascii=False) + "\n") + + +def grant_key(action: ActionDescriptor, scope: ApprovalScope | str) -> str: + """Build a grant key that avoids coarse category-wide approvals.""" + scope_value = scope.value if isinstance(scope, ApprovalScope) else str(scope) + session = action.session_id if scope_value in {ApprovalScope.SESSION.value, ApprovalScope.TURN.value} else "" + turn = action.turn_id if scope_value == ApprovalScope.TURN.value else "" + return ":".join( + part for part in ( + scope_value, + session, + turn, + action.kind, + action.effect, + action.signature(), + ) if part + ) diff --git a/src/leapflow/security/orchestrator.py b/src/leapflow/security/orchestrator.py new file mode 100644 index 0000000..809b172 --- /dev/null +++ b/src/leapflow/security/orchestrator.py @@ -0,0 +1,242 @@ +"""Approval orchestration: policy, grants, prompting, and audit.""" +from __future__ import annotations + +import time +from dataclasses import dataclass +from typing import Any + +from leapflow.security.actions import ActionDescriptor +from leapflow.security.grants import ( + ApprovalAuditLog, + ApprovalGrant, + ApprovalGrantStore, + ApprovalScope, + InMemoryApprovalGrantStore, + grant_key, +) +from leapflow.security.policy import ApprovalPolicyEngine, PolicyVerdict +from leapflow.security.risk import DefaultRiskClassifier, RiskAssessment, RiskClassifier, RiskLevel + + +@dataclass(frozen=True) +class ApprovalResult: + """Final approval decision consumed by tool handlers.""" + + approved: bool + decision: str + action: ActionDescriptor + risk: RiskAssessment + scope: str = ApprovalScope.ONCE.value + reason: str = "" + user_consent: bool = False + + @property + def denial_message(self) -> str: + if self.approved: + return "" + if self.risk.hardline or self.risk.level == RiskLevel.CRITICAL: + return ( + "BLOCKED: This action is prohibited by LeapFlow's hardline safety policy. " + "Run it manually outside the agent if you genuinely need it." + ) + return ( + "BLOCKED: User denied this action. The user has not consented to this outcome. " + "Do not retry, rephrase, or attempt the same outcome through another tool. " + "Ask the user for a revised instruction." + ) + + +class ApprovalOrchestrator: + """Coordinates risk assessment, grant lookup, human approval, and audit.""" + + def __init__( + self, + gate: Any, + *, + risk_classifier: RiskClassifier | None = None, + policy: ApprovalPolicyEngine | None = None, + grants: ApprovalGrantStore | None = None, + audit: ApprovalAuditLog | None = None, + ) -> None: + self._gate = gate + self._risk = risk_classifier or DefaultRiskClassifier() + self._policy = policy or ApprovalPolicyEngine() + self._grants = grants or InMemoryApprovalGrantStore() + self._audit = audit or ApprovalAuditLog() + + @property + def audit(self) -> ApprovalAuditLog: + return self._audit + + @property + def grants(self) -> ApprovalGrantStore: + return self._grants + + async def evaluate(self, action: ActionDescriptor) -> ApprovalResult: + """Return an approval result, prompting only when policy requires it.""" + from leapflow.security.approval import ApprovalDecision, ApprovalRequest + + risk = self._risk.assess(action) + policy = self._policy.evaluate(action, risk) + if policy.verdict == PolicyVerdict.ALLOW: + return self._approved(action, risk, actor="policy", reason=policy.reason) + if policy.verdict == PolicyVerdict.DENY: + return self._denied(action, risk, actor="policy", reason=policy.reason) + + existing = self._existing_grant(action) + if existing is not None: + if existing.decision.startswith("deny"): + return self._denied(action, risk, actor="grant", reason=existing.reason) + return self._approved(action, risk, actor="grant", scope=existing.scope, reason=existing.reason) + + request = ApprovalRequest( + category=action.kind, + detail=action.detail, + risk_hint=risk.score, + metadata={ + "action": action.to_dict(), + "risk": risk.to_dict(), + "allow_permanent": policy.allow_permanent, + }, + action=action, + risk=risk, + choices=self._choices(policy.allow_permanent), + default_choice="deny" if risk.level in {RiskLevel.HIGH, RiskLevel.CRITICAL} else "allow_once", + expires_at=time.time() + 120.0, + display={ + "title": self._title(risk), + "summary": action.summary, + "reason": risk.explanation, + }, + ) + decision = await self._gate.request_approval(request) + if decision in { + ApprovalDecision.ALLOW, + ApprovalDecision.ALLOW_ONCE, + ApprovalDecision.ALLOW_SESSION, + ApprovalDecision.ALLOW_ALWAYS, + }: + scope = self._scope_from_decision(decision) + if scope in {ApprovalScope.SESSION.value, ApprovalScope.PROFILE.value}: + self._grants.put(ApprovalGrant( + key=grant_key(action, ApprovalScope(scope)), + scope=scope, + decision="allow", + action_kind=action.kind, + effect=action.effect, + resource=action.resource, + reason="user_approved", + )) + return self._approved(action, risk, actor="user", scope=scope, reason=decision.value) + + if decision == ApprovalDecision.DENY_ALWAYS: + self._grants.put(ApprovalGrant( + key=grant_key(action, ApprovalScope.SESSION), + scope=ApprovalScope.SESSION.value, + decision="deny", + action_kind=action.kind, + effect=action.effect, + resource=action.resource, + reason="user_denied", + )) + return self._denied( + action, + risk, + actor="user", + reason=decision.value, + scope=ApprovalScope.SESSION.value, + ) + return self._denied(action, risk, actor="user", reason=decision.value) + + async def check(self, command: str) -> bool: + """Legacy shell approval adapter used by existing tool gates.""" + result = await self.evaluate(ActionDescriptor.shell(command)) + return result.approved + + def _existing_grant(self, action: ActionDescriptor) -> ApprovalGrant | None: + for scope in (ApprovalScope.TURN, ApprovalScope.SESSION, ApprovalScope.PROFILE): + existing = self._grants.get(grant_key(action, scope)) + if existing is not None: + return existing + return None + + def _approved( + self, + action: ActionDescriptor, + risk: RiskAssessment, + *, + actor: str, + scope: str = ApprovalScope.ONCE.value, + reason: str = "", + ) -> ApprovalResult: + self._audit.record( + action=action, + decision="allow", + risk_level=risk.level.value, + risk_reasons=risk.reasons, + scope=scope, + actor=actor, + reason=reason, + ) + return ApprovalResult( + approved=True, + decision="allow", + action=action, + risk=risk, + scope=scope, + reason=reason, + user_consent=actor == "user", + ) + + def _denied( + self, + action: ActionDescriptor, + risk: RiskAssessment, + *, + actor: str, + reason: str = "", + scope: str = ApprovalScope.ONCE.value, + ) -> ApprovalResult: + self._audit.record( + action=action, + decision="deny", + risk_level=risk.level.value, + risk_reasons=risk.reasons, + scope=scope, + actor=actor, + reason=reason, + ) + return ApprovalResult( + approved=False, + decision="deny", + action=action, + risk=risk, + scope=scope, + reason=reason, + user_consent=False, + ) + + @staticmethod + def _choices(allow_permanent: bool) -> tuple[str, ...]: + base = ["allow_once", "allow_session"] + if allow_permanent: + base.append("allow_always") + base.extend(["deny", "deny_always", "show_details"]) + return tuple(base) + + @staticmethod + def _scope_from_decision(decision: Any) -> str: + value = getattr(decision, "value", str(decision)) + if value in {"allow_session", "session"}: + return ApprovalScope.SESSION.value + if value in {"allow_always", "always"}: + return ApprovalScope.PROFILE.value + return ApprovalScope.ONCE.value + + @staticmethod + def _title(risk: RiskAssessment) -> str: + if risk.level == RiskLevel.CRITICAL: + return "Critical Action Blocked" + if risk.level == RiskLevel.HIGH: + return "High Risk Action" + return "Action Approval" diff --git a/src/leapflow/security/policy.py b/src/leapflow/security/policy.py new file mode 100644 index 0000000..3f07f6e --- /dev/null +++ b/src/leapflow/security/policy.py @@ -0,0 +1,60 @@ +"""Approval policy evaluation built on structured risk assessments.""" +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum +from typing import Protocol, runtime_checkable + +from leapflow.security.actions import ActionDescriptor +from leapflow.security.risk import RiskAssessment, RiskLevel + + +class PolicyVerdict(str, Enum): + """Policy result before human interaction.""" + + ALLOW = "allow" + ASK = "ask" + DENY = "deny" + + +@dataclass(frozen=True) +class PolicyDecision: + """Decision produced by ApprovalPolicyEngine.""" + + verdict: PolicyVerdict + reason: str = "" + allow_permanent: bool = True + + +@runtime_checkable +class ApprovalPolicyRule(Protocol): + """Optional extension rule for approval policy.""" + + def check(self, action: ActionDescriptor, risk: RiskAssessment) -> PolicyDecision | None: ... + + +class ApprovalPolicyEngine: + """Small policy engine: hardline deny, meaningful risk ask, safe allow.""" + + def __init__(self, rules: list[ApprovalPolicyRule] | None = None) -> None: + self._rules = list(rules or []) + + def evaluate(self, action: ActionDescriptor, risk: RiskAssessment) -> PolicyDecision: + for rule in self._rules: + decision = rule.check(action, risk) + if decision is not None: + return decision + + if risk.hardline or risk.level == RiskLevel.CRITICAL: + return PolicyDecision( + verdict=PolicyVerdict.DENY, + reason="; ".join(risk.reasons) or "hardline_block", + allow_permanent=False, + ) + if risk.level in {RiskLevel.HIGH, RiskLevel.MEDIUM} or risk.score >= 0.35: + return PolicyDecision( + verdict=PolicyVerdict.ASK, + reason="; ".join(risk.reasons) or "approval_required", + allow_permanent=risk.allow_permanent, + ) + return PolicyDecision(verdict=PolicyVerdict.ALLOW, reason="low_risk") diff --git a/src/leapflow/security/risk.py b/src/leapflow/security/risk.py new file mode 100644 index 0000000..32fab13 --- /dev/null +++ b/src/leapflow/security/risk.py @@ -0,0 +1,199 @@ +"""Risk assessment for structured approval actions.""" +from __future__ import annotations + +import os +import re +from dataclasses import asdict, dataclass, field +from enum import Enum +from pathlib import Path +from typing import Any, Protocol, runtime_checkable + +from leapflow.security.actions import ActionDescriptor, ActionKind + + +class RiskLevel(str, Enum): + """Risk levels used by approval policy.""" + + SAFE = "safe" + LOW = "low" + MEDIUM = "medium" + HIGH = "high" + CRITICAL = "critical" + + +@dataclass(frozen=True) +class RiskAssessment: + """Structured risk assessment for a pending action.""" + + level: RiskLevel + score: float = 0.0 + reasons: tuple[str, ...] = () + explanation: str = "" + hardline: bool = False + allow_permanent: bool = True + metadata: dict[str, Any] = field(default_factory=dict) + + def to_dict(self) -> dict[str, Any]: + data = asdict(self) + data["level"] = self.level.value + data["reasons"] = list(self.reasons) + return data + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "RiskAssessment": + raw_level = str(data.get("level") or RiskLevel.MEDIUM.value) + try: + level = RiskLevel(raw_level) + except ValueError: + level = RiskLevel.MEDIUM + return cls( + level=level, + score=float(data.get("score") or 0.0), + reasons=tuple(str(item) for item in data.get("reasons") or ()), + explanation=str(data.get("explanation") or ""), + hardline=bool(data.get("hardline", False)), + allow_permanent=bool(data.get("allow_permanent", True)), + metadata=dict(data.get("metadata") or {}), + ) + + +@runtime_checkable +class RiskClassifier(Protocol): + """Classifies action risk without prompting the user.""" + + def assess(self, action: ActionDescriptor) -> RiskAssessment: ... + + +class DefaultRiskClassifier: + """Small, explainable risk classifier for core LeapFlow actions.""" + + _SHELL_HARDLINE: tuple[tuple[re.Pattern[str], str], ...] = ( + (re.compile(r"\brm\s+.*-[^\s]*r[^\s]*f.*\s/\s*$", re.IGNORECASE), "recursive_delete_root"), + (re.compile(r"\bmkfs\b", re.IGNORECASE), "format_filesystem"), + (re.compile(r"\bdd\s+.*of=/dev/", re.IGNORECASE), "raw_device_write"), + (re.compile(r":\(\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:"), "fork_bomb"), + (re.compile(r"\bshutdown\b|\breboot\b|\bhalt\b|\bpoweroff\b", re.IGNORECASE), "system_shutdown"), + ) + _SHELL_HIGH: tuple[tuple[re.Pattern[str], str], ...] = ( + (re.compile(r"\b(?:python[23]?|perl|ruby|node|bash|sh|zsh|ksh)\s+<<", re.IGNORECASE), "script_execution_via_heredoc"), + (re.compile(r"\b(curl|wget)\b.*\|\s*(?:ba)?sh", re.IGNORECASE), "remote_script_execution"), + (re.compile(r"\bsudo\b.*(?:-S|--stdin|--askpass|-A)\b", re.IGNORECASE), "privileged_command_with_password_path"), + (re.compile(r"\bgit\s+push\b.*(?:--force|-f)\b", re.IGNORECASE), "git_force_push"), + (re.compile(r"\bsystemctl\s+(?:stop|restart|disable|mask)\b", re.IGNORECASE), "service_lifecycle_change"), + ) + _SHELL_MEDIUM: tuple[tuple[re.Pattern[str], str], ...] = ( + (re.compile(r"\bsudo\b", re.IGNORECASE), "privileged_command"), + (re.compile(r"\brm\s+-r\b", re.IGNORECASE), "recursive_delete"), + (re.compile(r"\bchmod\s+[0-7]*7[0-7]*\b", re.IGNORECASE), "broad_permission_change"), + (re.compile(r"\b(curl|wget|ssh|scp|rsync|nc|ncat)\b", re.IGNORECASE), "external_network_access"), + (re.compile(r"\b(pip|npm|brew)\s+install\b", re.IGNORECASE), "dependency_install"), + ) + _SENSITIVE_NAMES = frozenset({ + ".env", ".env.local", ".env.production", "config.yaml", "gateway.yaml", + ".credential_key", "credentials.json", "secrets.yaml", "secrets.yml", + ".npmrc", ".pypirc", ".netrc", + }) + _SENSITIVE_PARTS = ("/.ssh/", "/.gnupg/", "/.aws/", "/.kube/") + + def assess(self, action: ActionDescriptor) -> RiskAssessment: + if action.kind == ActionKind.SHELL_COMMAND.value: + return self._assess_shell(action) + if action.kind == ActionKind.FILE_WRITE.value: + return self._assess_file_write(action) + if action.kind == ActionKind.GATEWAY_SEND.value: + return RiskAssessment( + level=RiskLevel.HIGH, + score=0.72, + reasons=("external_message_send",), + explanation="This sends content to an external platform conversation.", + allow_permanent=False, + ) + if action.kind in { + ActionKind.SCHEDULER_ARM.value, + ActionKind.SKILL_PROMOTE.value, + ActionKind.APP_INSTALL.value, + ActionKind.RUNTIME_CONFIGURE.value, + }: + return RiskAssessment( + level=RiskLevel.HIGH, + score=0.75, + reasons=("long_lived_or_runtime_changing_action",), + explanation="This action can change future runtime behavior or run without the current terminal.", + allow_permanent=False, + ) + return RiskAssessment(level=RiskLevel.MEDIUM, score=0.5, reasons=("external_action",)) + + def _assess_shell(self, action: ActionDescriptor) -> RiskAssessment: + command = action.detail + for pattern, reason in self._SHELL_HARDLINE: + if pattern.search(command): + return RiskAssessment( + level=RiskLevel.CRITICAL, + score=1.0, + reasons=(reason,), + explanation="This command can irreversibly damage the host and is never run by LeapFlow.", + hardline=True, + allow_permanent=False, + ) + reasons = self._matched_reasons(command, self._SHELL_HIGH) + if reasons: + if self._mentions_sensitive_config(command): + reasons.append("writes_or_reads_sensitive_config") + return RiskAssessment( + level=RiskLevel.HIGH, + score=0.82, + reasons=tuple(reasons), + explanation="This shell command executes code, changes runtime state, or reaches sensitive resources.", + allow_permanent=False, + ) + reasons = self._matched_reasons(command, self._SHELL_MEDIUM) + if reasons: + return RiskAssessment( + level=RiskLevel.MEDIUM, + score=0.58, + reasons=tuple(reasons), + explanation="This shell command has side effects or reaches external systems.", + ) + return RiskAssessment(level=RiskLevel.LOW, score=0.15, reasons=("ordinary_shell_command",)) + + def _assess_file_write(self, action: ActionDescriptor) -> RiskAssessment: + path = Path(action.resource).expanduser() + name = path.name.lower() + normalized = str(path).replace("\\", "/").lower() + size = int(action.metadata.get("bytes") or 0) + if any(normalized.startswith(prefix) for prefix in ("/system", "/usr", "/bin", "/sbin", "/etc")): + return RiskAssessment( + level=RiskLevel.CRITICAL, + score=0.95, + reasons=("system_path_write",), + explanation="This writes to an operating-system controlled path.", + hardline=True, + allow_permanent=False, + ) + if name in self._SENSITIVE_NAMES or any(part in normalized for part in self._SENSITIVE_PARTS): + return RiskAssessment( + level=RiskLevel.HIGH, + score=0.8, + reasons=("sensitive_file_write",), + explanation="This writes to credentials, configuration, or security-sensitive files.", + allow_permanent=False, + ) + if size > 20_000: + return RiskAssessment( + level=RiskLevel.MEDIUM, + score=0.5, + reasons=("large_file_write",), + explanation="This writes a non-trivial amount of content.", + ) + return RiskAssessment(level=RiskLevel.LOW, score=0.2, reasons=("ordinary_file_write",)) + + @staticmethod + def _matched_reasons(command: str, rules: tuple[tuple[re.Pattern[str], str], ...]) -> list[str]: + return [reason for pattern, reason in rules if pattern.search(command)] + + @staticmethod + def _mentions_sensitive_config(command: str) -> bool: + lowered = command.lower().replace("\\", "/") + home = str(Path.home()).lower().replace("\\", "/") + data_dir = os.getenv("LEAPFLOW_DATA_DIR", "~/.leapflow").replace("~", home).lower() + return any(token in lowered for token in (".env", "config.yaml", "gateway.yaml", data_dir)) diff --git a/src/leapflow/tools/file_operations.py b/src/leapflow/tools/file_operations.py index 5eab555..b8767cc 100644 --- a/src/leapflow/tools/file_operations.py +++ b/src/leapflow/tools/file_operations.py @@ -184,9 +184,10 @@ async def file_write(params: Dict[str, Any]) -> Dict[str, Any]: from leapflow.tools.registry_bootstrap import get_file_write_gate gate = get_file_write_gate() if gate is not None: - approved = await gate.check(str(target), content) + approved = await gate.check(str(target), content, mode) if not approved: - return {"ok": False, "error": f"File write denied by approval gate: {target.name}"} + message = str(getattr(gate, "denial_message", "") or f"File write denied by approval gate: {target.name}") + return {"ok": False, "error": message} except ImportError: pass diff --git a/src/leapflow/tools/gateway_tool.py b/src/leapflow/tools/gateway_tool.py index bedbe9b..1d79b49 100644 --- a/src/leapflow/tools/gateway_tool.py +++ b/src/leapflow/tools/gateway_tool.py @@ -284,19 +284,36 @@ async def gateway_send_handler(params: Dict[str, Any]) -> Dict[str, Any]: if _approval_gate is not None: try: + from leapflow.security.actions import ActionDescriptor from leapflow.security.approval import ApprovalDecision, ApprovalRequest - preview = text[:80] + ("…" if len(text) > 80 else "") - decision = await _approval_gate.request_approval(ApprovalRequest( - category=f"gateway_send:{platform}", - detail=f"Send to {platform}/{chat_id}: {preview}", - risk_hint=0.5, - metadata={"platform": platform, "chat_id": chat_id}, - )) - if decision == ApprovalDecision.DENY: - return {"ok": False, "error": "Outbound message denied by approval gate"} + action = ActionDescriptor.gateway_send(platform, chat_id, text, metadata={ + "thread_id": params.get("thread_id", ""), + }) + if hasattr(_approval_gate, "evaluate"): + result = await _approval_gate.evaluate(action) + if not getattr(result, "approved", False): + error = str(getattr(result, "denial_message", "") or "Outbound message denied by approval gate") + return {"ok": False, "error": error} + else: + preview = text[:80] + ("…" if len(text) > 80 else "") + decision = await _approval_gate.request_approval(ApprovalRequest( + category=action.kind, + detail=f"Send to {platform}/{chat_id}: {preview}", + risk_hint=0.5, + metadata={"platform": platform, "chat_id": chat_id}, + action=action, + )) + if decision not in { + ApprovalDecision.ALLOW, + ApprovalDecision.ALLOW_ONCE, + ApprovalDecision.ALLOW_SESSION, + ApprovalDecision.ALLOW_ALWAYS, + }: + return {"ok": False, "error": "Outbound message denied by approval gate"} except Exception: logger.debug("gateway_send approval check failed", exc_info=True) + return {"ok": False, "error": "Outbound message approval check failed"} return await _gateway_server_ref.send_message( platform, diff --git a/src/leapflow/tools/shell_tools.py b/src/leapflow/tools/shell_tools.py index 4f3c049..7879c74 100644 --- a/src/leapflow/tools/shell_tools.py +++ b/src/leapflow/tools/shell_tools.py @@ -32,6 +32,15 @@ async def check(self, command: str) -> bool: ... +@runtime_checkable +class ActionApprovalEvaluator(Protocol): + """Protocol for structured action approval evaluators.""" + + async def evaluate(self, action: Any) -> Any: + """Return an approval result for a structured action.""" + ... + + # Hardline blocks: NEVER bypassed regardless of approval _HARDLINE_PATTERNS: list[re.Pattern[str]] = [ re.compile(r"\brm\s+.*-[^\s]*r[^\s]*f|\brm\s+.*-[^\s]*f[^\s]*r", re.IGNORECASE), @@ -50,7 +59,8 @@ async def check(self, command: str) -> bool: re.compile(r"\bchown\b", re.IGNORECASE), re.compile(r"\bcurl\b.*\|\s*(ba)?sh", re.IGNORECASE), re.compile(r"\bwget\b.*\|\s*(ba)?sh", re.IGNORECASE), - re.compile(r"\b(pip|npm)\s+install\s+--.*(-g|global)", re.IGNORECASE), + re.compile(r"\b(?:python[23]?|perl|ruby|node|bash|sh|zsh|ksh)\s+<<", re.IGNORECASE), + re.compile(r"\b(pip|npm|brew)\s+install\b", re.IGNORECASE), re.compile(r"\bgit\s+push\s+.*--force\b", re.IGNORECASE), re.compile(r"\brm\s+-r\b", re.IGNORECASE), re.compile(r"\bkill\s+-9\b", re.IGNORECASE), @@ -88,6 +98,25 @@ def _is_cwd_blocked(cwd: str | None) -> bool: return any(resolved.startswith(prefix) for prefix in _BLOCKED_CWD_PREFIXES) +async def _approve_command(command: str, cwd: str | None) -> tuple[bool, str]: + if _approval_gate is None: + return False, "Dangerous command blocked (no approval gate configured)" + try: + if isinstance(_approval_gate, ActionApprovalEvaluator): + from leapflow.security.actions import ActionDescriptor + + result = await _approval_gate.evaluate(ActionDescriptor.shell(command, cwd=cwd)) + if getattr(result, "approved", False): + return True, "" + message = str(getattr(result, "denial_message", "") or "Dangerous command requires approval (denied)") + return False, message + approved = await _approval_gate.check(command) + return approved, "" if approved else "Dangerous command requires approval (denied)" + except Exception: + logger.debug("shell approval check failed", exc_info=True) + return False, "Dangerous command requires approval (denied)" + + async def shell_run(params: Dict[str, Any]) -> Dict[str, Any]: """Execute a shell command with timeout protection and safety layers.""" command = params.get("command", "") @@ -104,15 +133,9 @@ async def shell_run(params: Dict[str, Any]) -> Dict[str, Any]: return {"ok": False, "error": f"Working directory blocked by safety policy: {cwd}"} if _is_dangerous(command): - if _approval_gate is not None: - try: - approved = await _approval_gate.check(command) - except Exception: - approved = False - if not approved: - return {"ok": False, "error": "Dangerous command requires approval (denied)"} - else: - return {"ok": False, "error": "Dangerous command blocked (no approval gate configured)"} + approved, message = await _approve_command(command, cwd) + if not approved: + return {"ok": False, "error": message} try: proc = await asyncio.create_subprocess_shell( diff --git a/tests/test_approval_layer.py b/tests/test_approval_layer.py new file mode 100644 index 0000000..30b52a7 --- /dev/null +++ b/tests/test_approval_layer.py @@ -0,0 +1,180 @@ +from __future__ import annotations + +from pathlib import Path +import sys +import time + +import pytest + +from leapflow.security.actions import ActionDescriptor +from leapflow.security.approval import ApprovalDecision +from leapflow.security.grants import ApprovalAuditLog, ApprovalGrant, ApprovalScope, JsonApprovalGrantStore, grant_key +from leapflow.security.orchestrator import ApprovalOrchestrator +from leapflow.security.risk import DefaultRiskClassifier, RiskLevel + + +class _Gate: + def __init__(self, decision: ApprovalDecision) -> None: + self.decision = decision + self.requests = [] + + async def request_approval(self, request): + self.requests.append(request) + return self.decision + + +@pytest.mark.asyncio +async def test_orchestrator_prompts_once_then_reuses_session_grant(tmp_path: Path) -> None: + gate = _Gate(ApprovalDecision.ALLOW_SESSION) + grants = JsonApprovalGrantStore(tmp_path / "grants.json") + audit = ApprovalAuditLog(tmp_path / "audit.jsonl") + orchestrator = ApprovalOrchestrator(gate, grants=grants, audit=audit) + action = ActionDescriptor.shell("python << 'EOF'\nprint('hello')\nEOF") + + first = await orchestrator.evaluate(action) + second = await orchestrator.evaluate(action) + + assert first.approved is True + assert second.approved is True + assert len(gate.requests) == 1 + assert grants.list() + assert [entry["actor"] for entry in audit.entries] == ["user", "grant"] + + +@pytest.mark.asyncio +async def test_orchestrator_hardline_denies_without_prompt() -> None: + gate = _Gate(ApprovalDecision.ALLOW_ONCE) + orchestrator = ApprovalOrchestrator(gate) + + result = await orchestrator.evaluate(ActionDescriptor.shell("sudo reboot")) + + assert result.approved is False + assert "hardline" in result.reason or result.risk.level == RiskLevel.CRITICAL + assert not gate.requests + + +def test_default_risk_classifier_detects_heredoc() -> None: + risk = DefaultRiskClassifier().assess( + ActionDescriptor.shell("python << 'EOF'\nprint('install')\nEOF"), + ) + + assert risk.level == RiskLevel.HIGH + assert "script_execution_via_heredoc" in risk.reasons + assert risk.allow_permanent is False + + +def test_approval_request_round_trips_request_id() -> None: + from leapflow.security.approval import ApprovalRequest + + request = ApprovalRequest( + category="shell.command", + detail="echo hello", + request_id="approval-1", + ) + + restored = ApprovalRequest.from_dict(request.to_dict()) + + assert restored.request_id == "approval-1" + assert restored.to_dict()["request_id"] == "approval-1" + + +@pytest.mark.asyncio +async def test_orchestrator_reuses_turn_grant(tmp_path: Path) -> None: + gate = _Gate(ApprovalDecision.DENY) + grants = JsonApprovalGrantStore(tmp_path / "grants.json") + action = ActionDescriptor.shell("sudo ls", metadata={"test": True}) + action = ActionDescriptor.from_dict({**action.to_dict(), "session_id": "sess", "turn_id": "turn"}) + grants.put(ApprovalGrant( + key=grant_key(action, ApprovalScope.TURN), + scope=ApprovalScope.TURN.value, + decision="allow", + action_kind=action.kind, + effect=action.effect, + resource=action.resource, + reason="turn_approved", + )) + orchestrator = ApprovalOrchestrator(gate, grants=grants) + + result = await orchestrator.evaluate(action) + + assert result.approved is True + assert result.scope == ApprovalScope.TURN.value + assert not gate.requests + + +@pytest.mark.asyncio +async def test_prompt_approval_expired_request_denies(monkeypatch) -> None: + from leapflow.cli.approval_view import prompt_approval + from leapflow.security.approval import ApprovalRequest + + monkeypatch.setattr(sys.stdin, "isatty", lambda: True) + request = ApprovalRequest( + category="shell.command", + detail="echo hello", + expires_at=time.time() - 1, + ) + + assert await prompt_approval(request) == ApprovalDecision.DENY + + +@pytest.mark.asyncio +async def test_orchestrator_persists_deny_always_as_session_grant(tmp_path: Path) -> None: + gate = _Gate(ApprovalDecision.DENY_ALWAYS) + grants = JsonApprovalGrantStore(tmp_path / "grants.json") + audit = ApprovalAuditLog(tmp_path / "audit.jsonl") + orchestrator = ApprovalOrchestrator(gate, grants=grants, audit=audit) + action = ActionDescriptor.shell("python << 'EOF'\nprint('blocked')\nEOF") + + first = await orchestrator.evaluate(action) + second = await orchestrator.evaluate(action) + + assert first.approved is False + assert first.scope == ApprovalScope.SESSION.value + assert second.approved is False + assert second.reason == "user_denied" + assert len(gate.requests) == 1 + assert [entry["actor"] for entry in audit.entries] == ["user", "grant"] + assert [entry["scope"] for entry in audit.entries] == [ + ApprovalScope.SESSION.value, + ApprovalScope.ONCE.value, + ] + + +@pytest.mark.asyncio +async def test_orchestrator_cancel_workflow_is_denied_with_strong_message() -> None: + gate = _Gate(ApprovalDecision.CANCEL_WORKFLOW) + orchestrator = ApprovalOrchestrator(gate) + + result = await orchestrator.evaluate( + ActionDescriptor.shell("python << 'EOF'\nprint('stop')\nEOF"), + ) + + assert result.approved is False + assert result.reason == ApprovalDecision.CANCEL_WORKFLOW.value + assert "Do not retry" in result.denial_message + + +@pytest.mark.asyncio +async def test_file_write_returns_gate_denial_message(tmp_path: Path) -> None: + from leapflow.tools.file_operations import file_write + from leapflow.tools.registry_bootstrap import set_file_write_gate + + class DenyingGate: + denial_message = "BLOCKED: User denied this action. Do not retry." + + async def check(self, path: str, content: str, mode: str = "overwrite") -> bool: + return False + + set_file_write_gate(DenyingGate()) + try: + result = await file_write({ + "path": str(tmp_path / "approval-output.py"), + "content": "print('hello')", + }) + finally: + set_file_write_gate(None) + + assert result == { + "ok": False, + "error": "BLOCKED: User denied this action. Do not retry.", + } diff --git a/tests/test_cli_entrypoint.py b/tests/test_cli_entrypoint.py index 9512da1..efef87d 100644 --- a/tests/test_cli_entrypoint.py +++ b/tests/test_cli_entrypoint.py @@ -27,7 +27,8 @@ async def test_context_initialize_wires_gateway_approval_gate(tmp_path) -> None: ctx = Context(make_settings(str(tmp_path)), mock_host=True) await ctx.initialize() try: - assert gateway_tool._approval_gate is ctx._approval_gate + assert gateway_tool._approval_gate is ctx._approval_orchestrator + assert gateway_tool._approval_gate.grants is ctx._approval_orchestrator.grants finally: await ctx.cleanup() diff --git a/tests/test_daemon_rpc.py b/tests/test_daemon_rpc.py index e69bd39..1a773e9 100644 --- a/tests/test_daemon_rpc.py +++ b/tests/test_daemon_rpc.py @@ -114,6 +114,92 @@ async def test_daemon_client_stream_heartbeat_prevents_idle_timeout() -> None: ] +@pytest.mark.asyncio +async def test_runtime_service_streams_pending_approval_and_resolves(tmp_path) -> None: + from conftest import make_settings + from leapflow.daemon.service import RuntimeLeapService + from leapflow.engine import StreamEvent + from leapflow.security.actions import ActionDescriptor + from leapflow.security.approval import ApprovalRequest + + service = RuntimeLeapService(make_settings(str(tmp_path)), mock_host=True) + + class ApprovalEngine: + context_token_count = 0 + _current_session_id = "sess-approval" + + async def run_stream(self, message: str, *, enable_thinking: bool = False): + request = ApprovalRequest( + category="shell.command", + detail="python << 'EOF'\nprint('ok')\nEOF", + action=ActionDescriptor.shell("python << 'EOF'\nprint('ok')\nEOF"), + ) + decision = await service._request_approval(request) + yield StreamEvent(type="final", content=f"decision={decision}") + + class FakeContext: + def __init__(self) -> None: + self.engine = ApprovalEngine() + + def reload_runtime_config_if_changed(self) -> bool: + return False + + service._ctx = FakeContext() + stream = service.engine_chat("needs approval") + try: + approval_event = await anext(stream) + payload = (approval_event.metadata or {}).get("approval") + + assert approval_event.event_type == "approval_request" + assert isinstance(payload, dict) + assert payload["pending_id"] + status = await service.approval_status() + assert len(status["pending"]) == 1 + + resolved = await service.approval_resolve(payload["pending_id"], "definitely_not_valid") + final = await anext(stream) + finally: + await stream.aclose() + + assert resolved == {"ok": True, "pending_id": payload["pending_id"], "decision": "deny"} + assert final.event_type == "final" + assert final.content == "decision=deny" + assert final.metadata["session_id"] == "sess-approval" + assert await service.approval_status() == {"pending": []} + + +@pytest.mark.asyncio +async def test_daemon_client_approval_resolve_rpc() -> None: + class ApprovalRpcService(_FakeService): + async def approval_resolve(self, pending_id: str, decision: str, reason: str = "") -> dict[str, Any]: + return {"ok": True, "pending_id": pending_id, "decision": decision, "reason": reason} + + async def approval_status(self) -> dict[str, Any]: + return {"pending": []} + + async def approval_cancel(self, pending_id: str, reason: str = "cancelled") -> dict[str, Any]: + return {"ok": True, "pending_id": pending_id, "decision": "deny", "reason": reason} + + with tempfile.TemporaryDirectory(prefix="lfd-", dir="/tmp") as root: + server, task, run_dir = await _start_server(Path(root) / "run", service=ApprovalRpcService()) + client = DaemonClient(run_dir / "leapd.sock") + try: + result = await client.approval_resolve("p1", "allow_once", reason="user") + status = await client.approval_status() + cancelled = await client.approval_cancel("p2") + finally: + task.cancel() + await server.stop() + try: + await task + except asyncio.CancelledError: + pass + + assert result == {"ok": True, "pending_id": "p1", "decision": "allow_once", "reason": "user"} + assert status == {"pending": []} + assert cancelled["decision"] == "deny" + + @pytest.mark.asyncio async def test_daemon_client_reports_unknown_method() -> None: with tempfile.TemporaryDirectory(prefix="lfd-", dir="/tmp") as root: diff --git a/tests/test_tui_session_summary.py b/tests/test_tui_session_summary.py index 74fe5e3..499ff6d 100644 --- a/tests/test_tui_session_summary.py +++ b/tests/test_tui_session_summary.py @@ -20,13 +20,13 @@ class _Message: class _Console: def __init__(self) -> None: - self.markdown_calls: list[str] = [] + self.markdown_calls: list[tuple[str, int]] = [] self.thinking_calls: list[str] = [] self.labels: list[tuple[float, int]] = [] self.lines = 0 - def markdown(self, text: str) -> None: - self.markdown_calls.append(text) + def markdown(self, text: str, *, indent: int = 0) -> None: + self.markdown_calls.append((text, indent)) def thinking(self, text: str) -> None: self.thinking_calls.append(text) @@ -130,6 +130,21 @@ def test_stream_renderer_exposes_output_without_private_access() -> None: assert renderer.has_output is True +def test_stream_renderer_indents_final_response_only() -> None: + console = _Console() + renderer = StreamRenderer(console) + renderer.start() + + renderer.feed_thinking("internal reasoning") + renderer.feed("final **answer**") + renderer.finish() + + assert console.thinking_calls == ["internal reasoning"] + assert console.markdown_calls == [("final **answer**", 4)] + assert len(console.labels) == 1 + assert console.lines == 1 + + def test_global_resume_routes_to_interactive(monkeypatch) -> None: from leapflow.cli import cli From b7f6e150d7fc8e65133709a4b961de5f5aa34b3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8F=AD=E6=89=AC?= Date: Fri, 10 Jul 2026 15:30:08 +0800 Subject: [PATCH 03/17] Manage CuaDriver lifecycle via daemon --- src/leapflow/cli/commands/daemon.py | 16 ++++ src/leapflow/cli/commands/host.py | 97 +++++++++++++++++-- src/leapflow/cli/context.py | 13 ++- src/leapflow/daemon/server.py | 54 ++++++++++- src/leapflow/daemon/service.py | 18 ++++ src/leapflow/platform/cua_client.py | 58 +++++++++++- tests/test_cli_entrypoint.py | 142 ++++++++++++++++++++++++++++ tests/test_daemon_rpc.py | 85 +++++++++++++++++ 8 files changed, 469 insertions(+), 14 deletions(-) diff --git a/src/leapflow/cli/commands/daemon.py b/src/leapflow/cli/commands/daemon.py index a1c4070..f6dd651 100644 --- a/src/leapflow/cli/commands/daemon.py +++ b/src/leapflow/cli/commands/daemon.py @@ -91,6 +91,22 @@ def _print_runtime_status(status: dict) -> None: print(f"project_env: {status['project_env_path']}") if status.get("db_path"): print(f"db: {status['db_path']}") + host = status.get("host_backend") + if isinstance(host, dict): + print( + "host: " + f"backend={host.get('backend')} " + f"started={host.get('started')} " + f"pid={host.get('pid')}" + ) + if host.get("command"): + args = " ".join(str(arg) for arg in host.get("args") or []) + command = f"{host.get('command')} {args}".strip() + print(f"host_command: {command}") + if host.get("capability_version"): + print(f"host_capability: {host['capability_version']}") + if host.get("last_error"): + print(f"host_error: {host['last_error']}") def _start(settings: object, mock_host: bool) -> int: diff --git a/src/leapflow/cli/commands/host.py b/src/leapflow/cli/commands/host.py index 2f1505b..d493aa3 100644 --- a/src/leapflow/cli/commands/host.py +++ b/src/leapflow/cli/commands/host.py @@ -7,6 +7,7 @@ from __future__ import annotations import argparse +import logging import os import platform as platform_mod import shutil @@ -19,6 +20,8 @@ from leapflow.config import load_config +logger = logging.getLogger(__name__) + # ── ANSI colors ────────────────────────────────────────────────────────── _RESET = "\033[0m" @@ -120,16 +123,50 @@ def _remove_pid_file() -> None: pass +async def _fetch_leapd_status(settings: object) -> tuple[object, Optional[dict], str]: + """Return leapd discovery info plus runtime status when available.""" + from leapflow.daemon.client import DaemonClient + from leapflow.daemon.lifecycle import DaemonInfo + + run_dir = getattr(settings, "profile_dir") / "run" + info = DaemonInfo.discover(run_dir) + if not info.is_healthy or info.sock_path is None: + return info, None, "" + try: + return info, await DaemonClient(info.sock_path).status(), "" + except Exception as exc: + return info, None, str(exc) + + +async def _stop_leapd_if_running(settings: object) -> bool: + """Stop leapd so the daemon-owned CuaDriverClient releases its MCP session.""" + from leapflow.daemon.lifecycle import DaemonInfo, send_signal + + run_dir = getattr(settings, "profile_dir") / "run" + info = DaemonInfo.discover(run_dir) + if not info.is_running: + return False + if send_signal(run_dir, signal.SIGTERM): + _ok(f"Sent SIGTERM to leapd (PID {info.pid})") + return True + _warn("leapd is running but could not be signalled") + return False + + # ── Subcommand implementations ────────────────────────────────────────── async def _cmd_status() -> int: - """Show cua-driver installation and ObservationDaemon status.""" - print(f"{_CYAN}LEAP Host \u2014 Status{_RESET}") + """Show cua-driver installation and background runtime status.""" + settings = load_config() + print(f"{_CYAN}LEAP Host — Status{_RESET}") print() # cua-driver installation print(f" {_BOLD}cua-driver{_RESET}") + if not getattr(settings, "use_cua_driver", True): + _warn("Disabled by LEAPFLOW_USE_CUA_DRIVER=false") + _info("LeapFlow will run in degraded mode without OS execution.") if _cua_driver_installed(): version = _cua_driver_version() version_str = version if version else "installed (version unknown)" @@ -140,6 +177,33 @@ async def _cmd_status() -> int: _info(f"Install: {_CUA_INSTALL_URL}") print() + # leapd-managed host backend status + print(f" {_BOLD}leapd host backend{_RESET}") + leapd_info, runtime, runtime_error = await _fetch_leapd_status(settings) + if getattr(leapd_info, "is_healthy", False): + _ok(f"leapd healthy (PID {getattr(leapd_info, 'pid', None)})") + host = runtime.get("host_backend") if isinstance(runtime, dict) else None + if isinstance(host, dict): + _info( + "Backend: " + f"{host.get('backend')} started={host.get('started')} " + f"pid={host.get('pid')} ({host.get('pid_source')})" + ) + if host.get("command"): + args = " ".join(str(arg) for arg in host.get("args") or []) + _info(f"Command: {str(host.get('command'))} {args}".strip()) + _info(f"Tools: {host.get('tools_count', 0)} restarts={host.get('restart_count', 0)}") + if host.get("last_error"): + _warn(f"Last error: {host['last_error']}") + elif runtime_error: + _warn(f"Runtime status unavailable: {runtime_error}") + else: + _info("No host backend details reported by leapd") + else: + _info(getattr(leapd_info, "format_status", lambda: "leapd not running")()) + _info("Start with: leap daemon start") + print() + # ObservationDaemon status print(f" {_BOLD}ObservationDaemon{_RESET}") pid = _read_pid_file() @@ -230,12 +294,21 @@ async def _cmd_start() -> int: async def _cmd_stop() -> int: - """Stop ObservationDaemon background process.""" - print(f"{_CYAN}LEAP Host \u2014 Stop{_RESET}") + """Stop leapd-owned host backend and ObservationDaemon background process.""" + print(f"{_CYAN}LEAP Host — Stop{_RESET}") + + settings = load_config() + leapd_stopped = await _stop_leapd_if_running(settings) + if leapd_stopped: + _info("daemon-owned CuaDriverClient will release its MCP session during shutdown") pid = _read_pid_file() if pid is None: - _warn("ObservationDaemon is not running") + if not leapd_stopped: + _warn("ObservationDaemon is not running") + else: + _info("ObservationDaemon is not running") + _info("For CuaDriver.app daemon debugging, upstream also supports: cua-driver stop") return 0 # Send SIGTERM for graceful shutdown @@ -261,6 +334,7 @@ async def _cmd_stop() -> int: _remove_pid_file() _ok("ObservationDaemon stopped") + _info("For CuaDriver.app daemon debugging, upstream also supports: cua-driver stop") return 0 @@ -286,6 +360,7 @@ async def _cmd_doctor() -> int: print(f" {_BOLD}2. MCP connectivity{_RESET}") _info("Starting MCP session...") + client = None try: from leapflow.platform.cua_client import CuaDriverClient @@ -318,10 +393,8 @@ async def _cmd_doctor() -> int: if cap_version: _info(f"Capability version: {cap_version}") - client.stop() - _ok("Session closed cleanly") print() - _ok("All checks passed \u2014 cua-driver is healthy") + _ok("All checks passed — cua-driver is healthy") return 0 except Exception as exc: @@ -329,6 +402,14 @@ async def _cmd_doctor() -> int: _info("Ensure cua-driver is properly installed and accessible.") _info(f"Documentation: {_CUA_INSTALL_URL}") return 1 + finally: + if client is not None: + try: + client.stop() + _ok("Session closed cleanly") + except Exception as exc: + logger.warning("host doctor: CuaDriverClient cleanup failed", exc_info=True) + _warn(f"Session cleanup failed: {exc}") async def _cmd_install() -> int: diff --git a/src/leapflow/cli/context.py b/src/leapflow/cli/context.py index 6482ac3..79c4b14 100644 --- a/src/leapflow/cli/context.py +++ b/src/leapflow/cli/context.py @@ -405,7 +405,10 @@ def __init__(self, settings: Settings, mock_host: bool) -> None: privacy_filter=self.privacy_manager, ) self.rpc: CuaDriverClient | MockBridge - if self.effective_mock: + if self.effective_mock or not settings.use_cua_driver: + if not self.effective_mock: + _emit_status("cua-driver disabled by LEAPFLOW_USE_CUA_DRIVER=false") + _emit_status("Running in degraded mode (no OS execution)") self.rpc = MockBridge() self.rpc.on_event(self.event_bus.handle_event) else: @@ -749,6 +752,9 @@ async def initialize(self) -> None: " Check permissions (macOS TCC) or run: leap host doctor" ) _emit_status("Running in degraded mode (no OS execution)") + self.rpc = MockBridge() + self.rpc.on_event(self.event_bus.handle_event) + vsi = VirtualSystemInterface(self.rpc) manifest = PlatformManifest.default_darwin() vsi._manifest = manifest else: @@ -2186,7 +2192,10 @@ async def cleanup(self) -> None: # Shutdown all memory providers (stops GC, closes DB) await self.memory.shutdown_all() if isinstance(self.rpc, CuaDriverClient): - self.rpc.stop() + try: + self.rpc.stop() + except Exception: + logger.debug("CuaDriverClient stop failed during cleanup", exc_info=True) if self.skill_lib: self.skill_lib.close() if self.session_store: diff --git a/src/leapflow/daemon/server.py b/src/leapflow/daemon/server.py index 88ba2a6..f343146 100644 --- a/src/leapflow/daemon/server.py +++ b/src/leapflow/daemon/server.py @@ -16,6 +16,7 @@ logger = logging.getLogger(__name__) _DEFAULT_STREAM_HEARTBEAT_S = 10.0 +_DEFAULT_IDLE_TIMEOUT_S = 600.0 def _stream_heartbeat_interval() -> float: @@ -26,6 +27,14 @@ def _stream_heartbeat_interval() -> float: return _DEFAULT_STREAM_HEARTBEAT_S +def _daemon_idle_timeout() -> float: + raw = os.getenv("LEAPFLOW_DAEMON_IDLE_TIMEOUT_S", str(_DEFAULT_IDLE_TIMEOUT_S)).strip() + try: + return max(0.0, float(raw)) + except ValueError: + return _DEFAULT_IDLE_TIMEOUT_S + + class UnixRpcServer: """Newline-delimited JSON-RPC server bound to one Unix socket.""" @@ -36,11 +45,13 @@ def __init__( sock_path: Path, run_dir: Path, stream_heartbeat_s: float | None = None, + on_shutdown: Callable[[], None] | None = None, ) -> None: self._service = service self._sock_path = sock_path self._run_dir = run_dir self._stream_heartbeat_s = stream_heartbeat_s or _stream_heartbeat_interval() + self._on_shutdown = on_shutdown self._server: asyncio.AbstractServer | None = None self._active_connections = 0 if hasattr(service, "set_client_count_provider"): @@ -146,6 +157,8 @@ async def _dispatch(self, request: RpcRequest, writer: asyncio.StreamWriter) -> result = await result response = RpcResponse.success(request.id, result) await _write_json(writer, response.to_json()) + if request.method == "daemon.shutdown" and self._on_shutdown is not None: + self._on_shutdown() async def _dispatch_stream( self, @@ -223,14 +236,19 @@ async def serve_daemon(settings: Any, *, mock_host: bool = False) -> int: sock_path = run_dir / "leapd.sock" service = RuntimeLeapService(settings, mock_host=mock_host) await service.start() - server = UnixRpcServer(service, sock_path=sock_path, run_dir=run_dir) - loop = asyncio.get_running_loop() stop_event = asyncio.Event() def _request_stop() -> None: stop_event.set() + server = UnixRpcServer( + service, + sock_path=sock_path, + run_dir=run_dir, + on_shutdown=_request_stop, + ) + for sig in (signal.SIGINT, signal.SIGTERM): try: loop.add_signal_handler(sig, _request_stop) @@ -238,9 +256,21 @@ def _request_stop() -> None: logger.debug("daemon: signal handlers unsupported on this event loop") task = asyncio.create_task(server.serve_forever()) + idle_task = asyncio.create_task( + _watch_idle_shutdown( + server, + stop_event, + idle_timeout_s=_daemon_idle_timeout(), + ) + ) try: await stop_event.wait() finally: + idle_task.cancel() + try: + await idle_task + except asyncio.CancelledError: + pass task.cancel() try: await task @@ -252,6 +282,26 @@ def _request_stop() -> None: return 0 +async def _watch_idle_shutdown( + server: UnixRpcServer, + stop_event: asyncio.Event, + *, + idle_timeout_s: float, +) -> None: + if idle_timeout_s <= 0: + return + last_active = asyncio.get_running_loop().time() + interval = min(30.0, max(1.0, idle_timeout_s / 10.0)) + while not stop_event.is_set(): + if server.active_connections > 0: + last_active = asyncio.get_running_loop().time() + elif asyncio.get_running_loop().time() - last_active >= idle_timeout_s: + logger.info("daemon: idle timeout reached; shutting down") + stop_event.set() + return + await asyncio.sleep(interval) + + async def _write_json(writer: asyncio.StreamWriter, text: str) -> None: writer.write(text.encode("utf-8") + b"\n") await writer.drain() diff --git a/src/leapflow/daemon/service.py b/src/leapflow/daemon/service.py index ecd4b04..6cd6b63 100644 --- a/src/leapflow/daemon/service.py +++ b/src/leapflow/daemon/service.py @@ -154,6 +154,7 @@ async def status(self) -> dict[str, Any]: "runtime_executable": sys.executable, "runtime_version": self._runtime_version(), "pending_approvals": len(self._approval_pending), + "host_backend": self._host_backend_status(ctx), } async def approval_status(self) -> dict[str, Any]: @@ -194,6 +195,23 @@ def _runtime_version() -> str: return "unknown" return str(__version__) + def _host_backend_status(self, ctx: Any | None) -> dict[str, Any]: + if ctx is None: + return {"backend": "none", "started": False, "reason": "runtime_not_initialized"} + rpc = getattr(ctx, "rpc", None) + snapshot = getattr(rpc, "status_snapshot", None) + if callable(snapshot): + try: + return dict(snapshot()) + except Exception as exc: + return {"backend": type(rpc).__name__, "started": False, "last_error": str(exc)} + return { + "backend": type(rpc).__name__ if rpc is not None else "none", + "started": rpc is not None, + "pid": None, + "pid_source": "unavailable", + } + async def shutdown(self) -> None: if self._ctx is None: return diff --git a/src/leapflow/platform/cua_client.py b/src/leapflow/platform/cua_client.py index 23a46c6..532df89 100644 --- a/src/leapflow/platform/cua_client.py +++ b/src/leapflow/platform/cua_client.py @@ -25,6 +25,7 @@ import subprocess import sys import threading +import time from pathlib import Path from typing import Any, Callable, Dict, List, Optional, Set, Tuple @@ -192,6 +193,10 @@ def __init__(self, bridge: _AsyncBridge) -> None: self._shutdown_event: Optional[asyncio.Event] = None self._lifecycle_future: Optional[concurrent.futures.Future] = None self._setup_error: Optional[BaseException] = None + self._command: str = _CUA_DRIVER_CMD + self._args: List[str] = list(_CUA_DRIVER_ARGS_DEFAULT) + self._last_error: str = "" + self._restart_count = 0 @property def started(self) -> bool: @@ -205,6 +210,22 @@ def available_tools(self) -> Dict[str, Set[str]]: def capability_version(self) -> str: return self._capability_version + @property + def command(self) -> str: + return self._command + + @property + def args(self) -> List[str]: + return list(self._args) + + @property + def last_error(self) -> str: + return self._last_error + + @property + def restart_count(self) -> int: + return self._restart_count + def has_tool(self, name: str) -> bool: """True if tools/list advertised this tool name.""" return name in self._tools @@ -231,6 +252,9 @@ async def _lifecycle_coro(self) -> None: ) command, args = _resolve_mcp_invocation(_CUA_DRIVER_CMD) + self._command = command + self._args = list(args) + self._last_error = "" params = StdioServerParameters( command=command, args=args, @@ -249,6 +273,7 @@ async def _lifecycle_coro(self) -> None: await self._shutdown_event.wait() except BaseException as e: self._setup_error = e + self._last_error = str(e) self._ready_event.set() raise finally: @@ -292,6 +317,7 @@ def _start_lifecycle(self) -> None: """Spawn lifecycle coroutine and wait for ready. Caller must hold lock.""" self._ready_event = threading.Event() self._setup_error = None + self._last_error = "" self._shutdown_event = None self._tools = {} self._capability_version = "" @@ -352,6 +378,7 @@ def _restart(self) -> None: except Exception as e: logger.debug("cleanup before reconnect: %s", e) self._started = False + self._restart_count += 1 self._start_lifecycle() self._started = True @@ -584,6 +611,8 @@ def __init__( self._keepalive_interval = keepalive_interval self._keepalive_task: Optional[asyncio.Task] = None self._closed = False + self._last_start_time: Optional[float] = None + self._last_error = "" # Per-method-prefix timeout overrides self._timeout_map: Dict[str, float] = { "ping": 3.0, @@ -608,8 +637,16 @@ def _resolve_timeout(self, method: str) -> float: def start(self) -> None: """Initialize the bridge and MCP session.""" - self._session.start() - self._start_keepalive() + self._closed = False + try: + self._session.start() + self._start_keepalive() + except Exception as exc: + self._last_error = str(exc) + self._bridge.stop() + raise + self._last_start_time = time.time() + self._last_error = "" logger.info("CuaDriverClient started (tools: %d)", len(self._session.available_tools)) def stop(self) -> None: @@ -628,6 +665,23 @@ def stop(self) -> None: self._bridge.stop() logger.info("CuaDriverClient stopped") + def status_snapshot(self) -> Dict[str, Any]: + """Return a diagnostic snapshot for daemon and host status surfaces.""" + return { + "backend": "cua-driver", + "started": self._session.started, + "closed": self._closed, + "command": self._session.command, + "args": self._session.args, + "pid": None, + "pid_source": "unavailable", + "capability_version": self._session.capability_version, + "tools_count": len(self._session.available_tools), + "last_start_time": self._last_start_time, + "last_error": self._last_error or self._session.last_error, + "restart_count": self._session.restart_count, + } + def _start_keepalive(self) -> None: """Start periodic heartbeat on the bridge loop.""" loop = self._bridge.loop diff --git a/tests/test_cli_entrypoint.py b/tests/test_cli_entrypoint.py index efef87d..b8dc9aa 100644 --- a/tests/test_cli_entrypoint.py +++ b/tests/test_cli_entrypoint.py @@ -479,6 +479,148 @@ def tcflush(fd, queue): ] +def test_context_uses_mock_bridge_when_cua_driver_disabled(tmp_path) -> None: + from leapflow.cli.context import Context + from leapflow.platform.mock import MockBridge + + settings = replace(make_settings(str(tmp_path)), mock_host=False, use_cua_driver=False) + ctx = Context(settings, mock_host=False) + + assert isinstance(ctx.rpc, MockBridge) + + +@pytest.mark.asyncio +async def test_context_initialize_replaces_failed_cua_driver_with_mock(monkeypatch, tmp_path) -> None: + import leapflow.cli.context as context_module + from leapflow.platform.mock import MockBridge + + class FailingCuaDriverClient: + def start(self) -> None: + raise RuntimeError("cua-driver unavailable") + + def stop(self) -> None: + raise AssertionError("failed driver should be replaced") + + settings = replace(make_settings(str(tmp_path)), mock_host=False, use_cua_driver=True) + monkeypatch.setattr(context_module, "CuaDriverClient", FailingCuaDriverClient) + + ctx = context_module.Context(settings, mock_host=False) + await ctx.initialize() + try: + assert isinstance(ctx.rpc, MockBridge) + assert ctx.engine is not None + finally: + await ctx.cleanup() + + +@pytest.mark.asyncio +async def test_context_cleanup_continues_when_cua_driver_stop_fails(tmp_path) -> None: + from leapflow.cli.context import Context + from leapflow.platform.cua_client import CuaDriverClient + + class FailingCuaDriverClient(CuaDriverClient): + def __init__(self) -> None: + pass + + def stop(self) -> None: + raise RuntimeError("stop failed") + + class CloseTracker: + def __init__(self) -> None: + self.closed = False + + def close(self) -> None: + self.closed = True + + ctx = Context(make_settings(str(tmp_path)), mock_host=True) + await ctx.initialize() + tracker = CloseTracker() + ctx.rpc = FailingCuaDriverClient() + ctx.skill_lib = tracker + + await ctx.cleanup() + + assert tracker.closed is True + + +@pytest.mark.asyncio +async def test_host_doctor_stops_client_when_probe_fails(monkeypatch) -> None: + from leapflow.cli.commands import host as host_module + import leapflow.platform.cua_client as cua_module + + calls: list[str] = [] + + class FakeSession: + available_tools = {"list_apps": set()} + capability_version = "test-cap" + + def call_tool_sync(self, name, args, timeout=5.0): + calls.append(f"probe:{name}") + raise RuntimeError("probe failed") + + class FakeClient: + def __init__(self, *args, **kwargs) -> None: + self._session = FakeSession() + + def start(self) -> None: + calls.append("start") + + def stop(self) -> None: + calls.append("stop") + + monkeypatch.setattr(host_module, "_cua_driver_installed", lambda: True) + monkeypatch.setattr(host_module, "_cua_driver_version", lambda: "test-version") + monkeypatch.setattr(host_module.shutil, "which", lambda command: "/tmp/cua-driver") + monkeypatch.setattr(cua_module, "CuaDriverClient", FakeClient) + + result = await host_module._cmd_doctor() + + assert result == 1 + assert calls == ["start", "probe:list_apps", "stop"] + + +@pytest.mark.asyncio +async def test_host_status_reports_daemon_host_backend(monkeypatch, tmp_path, capsys) -> None: + from conftest import make_settings + from leapflow.cli.commands import host as host_module + + class Info: + pid = 123 + is_healthy = True + is_running = True + sock_path = tmp_path / "leapd.sock" + + settings = replace(make_settings(str(tmp_path)), use_cua_driver=True) + + async def fake_fetch(settings_obj): + return Info(), { + "host_backend": { + "backend": "cua-driver", + "started": True, + "pid": None, + "pid_source": "unavailable", + "command": "/tmp/cua-driver", + "args": ["mcp"], + "tools_count": 3, + "restart_count": 1, + } + }, "" + + monkeypatch.setattr(host_module, "load_config", lambda: settings) + monkeypatch.setattr(host_module, "_fetch_leapd_status", fake_fetch) + monkeypatch.setattr(host_module, "_read_pid_file", lambda: None) + monkeypatch.setattr(host_module, "_cua_driver_installed", lambda: True) + monkeypatch.setattr(host_module, "_cua_driver_version", lambda: "test-version") + monkeypatch.setattr(host_module.shutil, "which", lambda command: "/tmp/cua-driver") + + assert await host_module._cmd_status() == 0 + + output = capsys.readouterr().out + assert "leapd healthy" in output + assert "Backend: cua-driver started=True" in output + assert "Tools: 3 restarts=1" in output + + @pytest.mark.asyncio async def test_daemon_tui_exit_prompt_stops_by_default(monkeypatch, tmp_path) -> None: from leapflow.cli.commands import interactive as interactive_module diff --git a/tests/test_daemon_rpc.py b/tests/test_daemon_rpc.py index 1a773e9..c7a56fb 100644 --- a/tests/test_daemon_rpc.py +++ b/tests/test_daemon_rpc.py @@ -168,6 +168,80 @@ def reload_runtime_config_if_changed(self) -> bool: assert await service.approval_status() == {"pending": []} +@pytest.mark.asyncio +async def test_daemon_shutdown_rpc_triggers_server_stop() -> None: + class ShutdownService(_FakeService): + def __init__(self) -> None: + super().__init__() + self.shutdown_called = False + + async def shutdown(self) -> None: + self.shutdown_called = True + + with tempfile.TemporaryDirectory(prefix="lfd-", dir="/tmp") as root: + run_dir = Path(root) / "run" + service = ShutdownService() + shutdown_event = asyncio.Event() + server = UnixRpcServer( + service, + sock_path=run_dir / "leapd.sock", + run_dir=run_dir, + on_shutdown=shutdown_event.set, + ) + task = asyncio.create_task(server.serve_forever()) + for _ in range(50): + if (run_dir / "leapd.sock").exists(): + break + await asyncio.sleep(0.02) + else: + task.cancel() + raise AssertionError("server did not start") + client = DaemonClient(run_dir / "leapd.sock") + try: + await client.shutdown() + await asyncio.wait_for(shutdown_event.wait(), timeout=1.0) + finally: + task.cancel() + await server.stop() + try: + await task + except asyncio.CancelledError: + pass + + assert service.shutdown_called is True + + +@pytest.mark.asyncio +async def test_runtime_service_status_reports_host_backend() -> None: + from conftest import make_settings + from leapflow.daemon.service import RuntimeLeapService + + class FakeRpc: + def status_snapshot(self) -> dict[str, Any]: + return {"backend": "cua-driver", "started": True, "tools_count": 2} + + class FakeContext: + rpc = FakeRpc() + engine = None + storage_volatile = False + + def __init__(self, settings) -> None: + self.settings = settings + self._db_holder = None + + with tempfile.TemporaryDirectory(prefix="lfd-", dir="/tmp") as root: + settings = make_settings(root) + service = RuntimeLeapService(settings, mock_host=True) + service._ctx = FakeContext(settings) + status = await service.status() + + assert status["host_backend"] == { + "backend": "cua-driver", + "started": True, + "tools_count": 2, + } + + @pytest.mark.asyncio async def test_daemon_client_approval_resolve_rpc() -> None: class ApprovalRpcService(_FakeService): @@ -426,6 +500,14 @@ def test_daemon_runtime_status_prints_diagnostics(capsys) -> None: "config_path": "/home/.leapflow/.env", "project_env_path": "/repo/.env", "db_path": "/home/.leapflow/db/leap.duckdb", + "host_backend": { + "backend": "cua-driver", + "started": True, + "pid": None, + "command": "/tmp/cua-driver", + "args": ["mcp"], + "capability_version": "test-cap", + }, }) output = capsys.readouterr().out @@ -434,6 +516,9 @@ def test_daemon_runtime_status_prints_diagnostics(capsys) -> None: assert "version: 0.0.test" in output assert "source: /repo/src/leapflow/__init__.py" in output assert "python: /venv/bin/python" in output + assert "host: backend=cua-driver started=True pid=None" in output + assert "host_command: /tmp/cua-driver mcp" in output + assert "host_capability: test-cap" in output def test_daemon_restart_stops_waits_and_starts(monkeypatch, tmp_path) -> None: From af443f68742f8b129992f767d1262fe201007246 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8F=AD=E6=89=AC?= Date: Fri, 10 Jul 2026 15:54:10 +0800 Subject: [PATCH 04/17] feat(tui): refine layout and runtime UX --- AGENTS.md | 37 +++++- src/leapflow/cli/commands/interactive.py | 91 ++++++++++++- src/leapflow/cli/commands/registry.py | 6 +- src/leapflow/cli/context.py | 159 +++++++++++++++++++++++ src/leapflow/cli/tui_app/console.py | 32 ++++- src/leapflow/cli/tui_app/stream.py | 7 +- src/leapflow/daemon/client.py | 20 +++ src/leapflow/daemon/protocol.py | 20 +++ src/leapflow/daemon/service.py | 35 +++++ src/leapflow/engine/engine.py | 27 ++++ src/leapflow/platform/cua_client.py | 5 + src/leapflow/platform/event_bus.py | 4 + src/leapflow/platform/mock.py | 15 +++ tests/test_daemon_rpc.py | 81 ++++++++++++ tests/test_tui_command_queue.py | 27 ++++ tests/test_tui_session_summary.py | 27 +++- 16 files changed, 568 insertions(+), 25 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 539c69c..3eacf81 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,7 @@ # AGENTS.md +This document is the LeapFlow engineering collaboration contract. It is not only a style guide: it defines the design, runtime, UX, safety, and verification rules that every implementation change must follow. + ## Design Philosophy 1. **Signal-Driven Intelligence** — All agent intelligence derives from observing real-world signals, not from hardcoded rules. If a behavior cannot be learned from signals, it is not in scope. @@ -12,52 +14,73 @@ 5. **LLM-Native Design** — Design for LLM reasoning first. Protocols over classes. Declarative over imperative. Context over configuration. +6. **User-Centric Reliability** — User experience is part of correctness. Every change must keep common paths easy, predictable, recoverable, and must not degrade adjacent workflows. + ## Code Quality Requirements -- SOLID principles are non-negotiable +- SOLID principles are non-negotiable; implementations must be cohesive, well-factored, and easy to reason about - Occam's Razor: maximize elegance and efficiency, reject unnecessary complexity -- Design for generalization and universality +- Design for generalization and universality; prefer reusable domain concepts over one-off special cases - Easy to extend, avoid hardcoding and hard rules - Industrial-grade robustness: every external call has timeout, retry, and fallback +- User experience is a first-class quality bar: optimize for clarity, ease of use, fast feedback, and graceful recovery - All comments and docstrings in English - Type annotations on all public APIs - No bare except — always specify exception types ## Architecture Principles +- **System Boundary Awareness**: LeapFlow is a multi-entry, multi-module runtime. Changes must account for the affected path across CLI/TUI, leapd, engine, skills/tools, LLM, storage, memory, gateway, hub, and platform adapters. +- **TUI as the Primary User Entry**: The interactive TUI is the default product surface. Preserve streaming feedback, command queue behavior, approval prompts, status bar accuracy, long-input robustness, history, and session continuity. +- **leapd Runtime Consistency**: Daemon-backed behavior must preserve lifecycle correctness: start, stop, restart, status, RPC streaming, cancellation, pending approvals, runtime config reload, multi-client state, and version consistency. - **Dependency Inversion**: Core logic depends on Protocol abstractions, never on concrete implementations - **Protocol over ABC**: Use `typing.Protocol` with `runtime_checkable` for all extension points - **Event-Driven Communication**: Modules interact through typed events on EventBus, not direct imports - **Immutable Domain Types**: Use `@dataclass(frozen=True)` or `NamedTuple` for domain objects -- **Config-Driven Behavior**: Thresholds, intervals, feature flags — all configurable via env vars -- **Graceful Degradation**: Every optional component (LLM, Hub, OS Host) can be absent without crash +- **Config-Driven Behavior**: Thresholds, intervals, feature flags, model budgets, platform capabilities, hub backends, gateway manifests, and paths must be configurable through Settings/env/config layers. +- **Graceful Degradation**: Every optional component (LLM, Hub) can be absent without crash - **Single Source of Truth**: DuckDB for persistence, EventBus for communication, Settings for configuration ## Implementation Guidelines - Define the Protocol first — the contract is the design - Implement against the Protocol, never against another implementation +- Consider affected user journeys before changing shared flows; do not introduce regressions, broken links, or worse experiences in adjacent paths +- Keep common paths transparent: long-running work must stream progress, surface recoverable errors clearly, and avoid silent stalls. +- Preserve security and audit paths: dangerous actions, file writes, outbound messages, credentials, and path access must flow through the existing policy, approval, redaction, and audit mechanisms. +- Maintain backward-compatible migrations for persistent state, configuration, skills, trajectories, sessions, and profile data. - Write unit tests before or alongside the implementation - Integrate via EventBus events, not direct function calls between modules - Every module must be importable standalone without side effects - No placeholder stubs — implement fully or do not add the code - ANSI output must check `sys.stdout.isatty()` before emitting escape codes +## Review Requirements + +- **Deep review for large changes**: When a change substantially affects architecture, runtime behavior, user flows, persistence, safety, or multiple modules, perform an additional deep review before considering the work complete. +- **Design goal check**: Verify that the implementation actually achieves the intended design goal and is not just a local patch. +- **Optimality check**: Evaluate whether the solution is the simplest robust design, avoids unnecessary abstractions, and fits the existing architecture. +- **Regression impact check**: Inspect affected modules and user journeys for logic bugs, degraded UX, broken compatibility, slower feedback, weaker diagnostics, or worse failure recovery. +- **SOLID and extensibility check**: Look for responsibility leaks, tight coupling, hardcoded paths/thresholds/rules, magic strings, and choices that reduce generalization or future extension. +- **Fix what the review finds**: If the review identifies correctness, design, UX, SOLID, hardcoding, or extensibility issues, fix and simplify them directly rather than only reporting them. + ## Testing Philosophy -- **Unit tests must be hermetic**: no network, no OS Host, no LLM calls +- **Unit tests must be hermetic**: no network, no LLM calls - **py_compile all modified files**: syntax errors caught before test run - **Import chain verification**: every new module must be importable standalone - **Existing tests must not regress**: all tests must pass after every change +- **User-facing flows must not regress**: preserve or improve usability, feedback clarity, and failure recovery for impacted paths - **Verification sequence**: compile → import → unit test → integration (if applicable) - **Behavior contracts over snapshots**: assert invariants, not frozen values -- **Mock at boundaries only**: mock external I/O (network, disk, OS Host), never internal logic +- **Mock at boundaries only**: mock external I/O (network, disk), never internal logic +- **Change-scoped validation**: Run the most specific relevant tests first, then broaden only as needed: CLI/TUI changes require CLI/TUI tests; leapd changes require daemon RPC/lifecycle tests; storage or memory changes require persistence tests; gateway or approval changes require security/approval tests; skills, learning, perception, and copilot changes require their lifecycle or pipeline tests. ## What to Avoid - Over-engineering: if you need 3+ files for a simple feature, rethink - Premature optimization: measure first, optimize only bottlenecks -- God objects: no class should exceed 300 lines +- God objects: no class should exceed 500 lines; approaching this limit requires checking whether policy, state, rendering, protocol, storage, or adapter responsibilities should be split. - Magic strings: use constants or enums - Blocking the event loop: all IO must be async or `run_in_executor` - Hardcoded paths, URLs, thresholds without config escape hatch diff --git a/src/leapflow/cli/commands/interactive.py b/src/leapflow/cli/commands/interactive.py index 405aa72..381c230 100644 --- a/src/leapflow/cli/commands/interactive.py +++ b/src/leapflow/cli/commands/interactive.py @@ -75,6 +75,40 @@ async def _ask_yes_no_default_yes(prompt: str) -> bool: return answer not in {"n", "no"} +def _host_started(status: dict[str, Any]) -> bool: + return bool(status.get("started")) and str(status.get("backend") or "") != "mock" + + +def _print_host_status(console: Any, host: dict[str, Any]) -> None: + backend = str(host.get("backend") or "unknown") + if _host_started(host): + console.success("Host is on — CuaDriver OS control is connected.") + else: + console.system("Host is off — CuaDriver is not running for this session.") + console.system( + "Host/CuaDriver lets LeapFlow see and control the desktop: screenshots, UI automation, " + "app/clipboard actions." + ) + console.system( + "Stopping it releases the background CuaDriver process; chat, memory, approvals, " + "skills, and non-OS tools keep working." + ) + tools = host.get("tools_count") + extra = f" tools={tools}" if tools is not None else "" + console.system(f"backend={backend} started={host.get('started')}{extra}") + if host.get("last_error"): + console.warning(f"host error: {host['last_error']}") + + +def _host_action(args: str) -> str: + action = (args or "status").strip().lower() + if action in {"on", "up", "enable"}: + return "start" + if action in {"off", "down", "disable"}: + return "stop" + return action + + async def cmd_interactive(ctx: "Context", *, resume_id: Optional[str] = None) -> int: """Persistent REPL session with hybrid TUI (Application + Rich).""" require_initialized(ctx) @@ -366,6 +400,26 @@ async def handle_input(text: str) -> None: handle_status(ctx, console, cmd_args) return + if canonical == "host": + action = _host_action(cmd_args) + if action == "status": + result = await ctx.host_backend_status() + elif action == "start": + console.system("Starting CuaDriver OS control for this session…") + result = await ctx.host_backend_start() + elif action == "stop": + console.system("Stopping CuaDriver OS control; chat and memory stay available…") + result = await ctx.host_backend_stop() + elif action == "restart": + console.system("Restarting CuaDriver OS control…") + result = await ctx.host_backend_restart() + else: + console.warning("Usage: /host [status|start|stop|restart]") + return + _print_host_status(console, result) + _update_status() + return + if canonical == "clear": handle_clear(ctx, console, cmd_args) _render_banner() @@ -566,10 +620,11 @@ async def cmd_interactive_daemon( runtime_context_length = int(getattr(settings, "llm_context_length", 0) or 0) runtime_context_used = 0 runtime_daemon_pid = "" + runtime_host_online = False def _apply_daemon_runtime_metadata(metadata: dict[str, Any]) -> None: nonlocal active_session_id, runtime_model_name, runtime_context_length - nonlocal runtime_context_used, runtime_daemon_pid + nonlocal runtime_context_used, runtime_daemon_pid, runtime_host_online if metadata.get("pid"): runtime_daemon_pid = str(metadata["pid"]) if metadata.get("session_id"): @@ -588,12 +643,15 @@ def _apply_daemon_runtime_metadata(metadata: dict[str, Any]) -> None: runtime_context_used = max(0, int(metadata["context_used"])) except (TypeError, ValueError): pass + host = metadata.get("host_backend") + if isinstance(host, dict): + runtime_host_online = _host_started(host) def _update_status() -> None: status.update( mode="daemon", skill_count=0, - platform_online=True, + platform_online=runtime_host_online, model_name=runtime_model_name, session_turns=turn_count, context_used=runtime_context_used, @@ -606,7 +664,7 @@ def _render_banner() -> None: model=runtime_model_name, cwd=os.getcwd(), session_id=active_session_id, - platform_online=True, + platform_online=runtime_host_online, tool_defs=TOOL_DEFINITIONS, skills=[], context_length=runtime_context_length, @@ -657,6 +715,9 @@ async def _print_daemon_status() -> None: context_used = daemon_status.get("context_used") if context_used is not None: console.system(f"Context used: {int(context_used):,} tokens") + host = daemon_status.get("host_backend") + if isinstance(host, dict): + _print_host_status(console, host) async def _handle_daemon_approval(event: StreamEvent) -> None: from leapflow.cli.approval_view import prompt_approval @@ -734,6 +795,30 @@ async def handle_input(text: str) -> None: if canonical == "status": await _print_daemon_status() return + if canonical == "host": + action = _host_action(cmd_text[len(canonical):].strip()) + try: + if action == "status": + result = await client.host_status() + elif action == "start": + console.system("Starting CuaDriver OS control for this session…") + result = await client.host_start() + elif action == "stop": + console.system("Stopping CuaDriver OS control; chat and memory stay available…") + result = await client.host_stop() + elif action == "restart": + console.system("Restarting CuaDriver OS control…") + result = await client.host_restart() + else: + console.warning("Usage: /host [status|start|stop|restart]") + return + except Exception as exc: + console.warning(f"Host control failed: {exc}") + return + _apply_daemon_runtime_metadata({"host_backend": result}) + _print_host_status(console, result) + _update_status() + return if canonical == "clear": _render_banner() return diff --git a/src/leapflow/cli/commands/registry.py b/src/leapflow/cli/commands/registry.py index 9edc1e8..670ad97 100644 --- a/src/leapflow/cli/commands/registry.py +++ b/src/leapflow/cli/commands/registry.py @@ -11,8 +11,8 @@ from __future__ import annotations -from dataclasses import dataclass, field -from typing import Dict, List, Optional, Sequence, Tuple +from dataclasses import dataclass +from typing import Dict, List, Optional, Tuple @dataclass(frozen=True) @@ -33,6 +33,7 @@ class CommandDef: CommandDef("help", "Show available commands", "Session", aliases=("?", "帮助")), CommandDef("clear", "Clear screen and reset display", "Session"), CommandDef("status", "Show session info, model, context, and platform", "Session"), + CommandDef("host", "Start, stop, or inspect CuaDriver OS control", "Session", args_hint="[status|start|stop|restart]"), CommandDef("exit", "Quit LeapFlow", "Session", aliases=("quit", "q", "退出")), # Chat @@ -114,6 +115,5 @@ def completion_entries() -> List[Tuple[str, str]]: """Build the completion list for LeapApp's TextArea completer.""" entries: List[Tuple[str, str]] = [] for cmd in COMMAND_REGISTRY: - full = f"/{cmd.name}" if cmd.args_hint else f"/{cmd.name}" entries.append((cmd.name, cmd.description)) return entries diff --git a/src/leapflow/cli/context.py b/src/leapflow/cli/context.py index 79c4b14..2785317 100644 --- a/src/leapflow/cli/context.py +++ b/src/leapflow/cli/context.py @@ -439,6 +439,10 @@ def __init__(self, settings: Settings, mock_host: bool) -> None: self.session_store: Optional[LearningSessionStore] = None self.engine: Optional[AgentEngine] = None self.intent_classifier: Optional[IntentClassifier] = None + self._platform_manifest: Optional[PlatformManifest] = None + self._platform_perception: Optional[Any] = None + self._platform_execution: Optional[Any] = None + self._platform_event_callback: Optional[Callable[[Any], None]] = None # World Model components (wired during initialize) self.learning_budget: Optional[Any] = None @@ -709,6 +713,155 @@ def reload_runtime_config_if_changed(self) -> bool: logger.info("Runtime LLM configuration reloaded") return True + def _host_backend_snapshot(self) -> dict[str, Any]: + snapshot = getattr(self.rpc, "status_snapshot", None) + if callable(snapshot): + try: + return dict(snapshot()) + except Exception as exc: + return { + "backend": type(self.rpc).__name__, + "started": False, + "pid": None, + "pid_source": "unavailable", + "last_error": str(exc), + } + return { + "backend": type(self.rpc).__name__, + "started": bool(getattr(self.rpc, "connected", False)), + "pid": None, + "pid_source": "unavailable", + } + + async def host_backend_status(self) -> dict[str, Any]: + """Return the current daemon-owned host backend status.""" + return self._host_backend_snapshot() + + async def host_backend_start(self) -> dict[str, Any]: + """Start CuaDriver and rewire runtime host adapters in-place.""" + if self.effective_mock: + return { + "ok": False, + "backend": "mock", + "started": False, + "pid": None, + "pid_source": "unavailable", + "last_error": "host backend is disabled by mock mode", + } + if isinstance(self.rpc, CuaDriverClient) and self.rpc.connected: + status = self._host_backend_snapshot() + status.update({"ok": True, "changed": False}) + return status + + previous_rpc = self.rpc + next_rpc = CuaDriverClient() + try: + next_rpc.start() + manifest = await VirtualSystemInterface(next_rpc).handshake() + await self._rewire_host_backend(next_rpc, manifest, bridge_online=True) + except Exception as exc: + try: + next_rpc.stop() + except Exception: + logger.debug("CuaDriverClient cleanup failed after start error", exc_info=True) + return { + "ok": False, + "backend": "cua-driver", + "started": False, + "pid": None, + "pid_source": "unavailable", + "last_error": str(exc), + } + + if isinstance(previous_rpc, CuaDriverClient) and previous_rpc is not next_rpc: + try: + previous_rpc.stop() + except Exception: + logger.debug("previous CuaDriverClient stop failed after host start", exc_info=True) + status = self._host_backend_snapshot() + status.update({"ok": True, "changed": True}) + return status + + async def host_backend_stop(self) -> dict[str, Any]: + """Stop CuaDriver while preserving chat, memory, and daemon runtime state.""" + previous_rpc = self.rpc + stop_error = "" + if isinstance(previous_rpc, CuaDriverClient): + try: + previous_rpc.stop() + except Exception as exc: + stop_error = str(exc) + logger.debug("CuaDriverClient stop failed during host_backend_stop", exc_info=True) + + mock_rpc = MockBridge() + mock_rpc.on_event(self.event_bus.handle_event) + manifest = PlatformManifest.default_darwin() + await self._rewire_host_backend(mock_rpc, manifest, bridge_online=False) + status = self._host_backend_snapshot() + status.update({"ok": not stop_error, "changed": True}) + if stop_error: + status["last_error"] = stop_error + return status + + async def host_backend_restart(self) -> dict[str, Any]: + """Restart CuaDriver and keep daemon/session state intact.""" + await self.host_backend_stop() + return await self.host_backend_start() + + async def _rewire_host_backend( + self, + rpc: CuaDriverClient | MockBridge, + manifest: PlatformManifest, + *, + bridge_online: bool, + ) -> None: + previous_callback = self._platform_event_callback + if previous_callback is not None: + self.event_bus.unsubscribe(previous_callback) + self._platform_event_callback = None + + self.rpc = rpc + self.event_bus.set_normalizer(EventNormalizer(manifest)) + + perception: Any + execution_adapter: Any + if not self.effective_mock and bridge_online: + perception = DarwinPerceptionAdapter(rpc, manifest) + execution_adapter = DarwinExecutionAdapter(rpc, manifest) + self.event_bus.subscribe(perception.enqueue_event) + self._platform_event_callback = perception.enqueue_event + home = str(Path.home()) + cwd = str(Path.cwd()) + for watch_path in dict.fromkeys([home, cwd]): + try: + result = await rpc.call("fs.subscribe", {"path": watch_path}) + logger.info("Subscribed to FS events after host switch: %s", watch_path) + for evt in (result.get("recent") or []): + await self.event_bus.handle_event("event.fs_change", dict(evt)) + except Exception as exc: + logger.warning("Failed to subscribe FS events for %s: %s", watch_path, exc) + else: + from leapflow.platform.adapters.mock import MockExecutionAdapter, MockPerceptionAdapter + perception = MockPerceptionAdapter() + execution_adapter = MockExecutionAdapter() + + self._platform_manifest = manifest + self._platform_perception = perception + self._platform_execution = execution_adapter + + if self.engine is not None: + from leapflow.skills.bridge_factory import build_tool_bridge + from leapflow.tools import bootstrap_tools + + tool_bridge = build_tool_bridge(execution_adapter, perception) + bootstrap_tools(tool_bridge) + self.engine.reconfigure_host_backend( + rpc=rpc, + perception=perception, + execution=execution_adapter, + tool_bridge=tool_bridge, + ) + @property def storage_volatile(self) -> bool: """Return True when this process uses non-persistent fallback storage.""" @@ -771,6 +924,7 @@ async def initialize(self) -> None: perception = DarwinPerceptionAdapter(self.rpc, manifest) execution_adapter = DarwinExecutionAdapter(self.rpc, manifest) self.event_bus.subscribe(perception.enqueue_event) + self._platform_event_callback = perception.enqueue_event home = str(Path.home()) cwd = str(Path.cwd()) for watch_path in dict.fromkeys([home, cwd]): @@ -785,6 +939,11 @@ async def initialize(self) -> None: from leapflow.platform.adapters.mock import MockExecutionAdapter, MockPerceptionAdapter perception = MockPerceptionAdapter() execution_adapter = MockExecutionAdapter() + self._platform_event_callback = None + + self._platform_manifest = manifest + self._platform_perception = perception + self._platform_execution = execution_adapter logger.info( "Platform: %s (v%s) | Capabilities: %d", diff --git a/src/leapflow/cli/tui_app/console.py b/src/leapflow/cli/tui_app/console.py index 83d3b39..35ec6e1 100644 --- a/src/leapflow/cli/tui_app/console.py +++ b/src/leapflow/cli/tui_app/console.py @@ -27,6 +27,16 @@ _COMMAND_CARD_PADDING = 24 +def _format_card_elapsed(seconds: float) -> str: + """Format command-card elapsed time without consuming a body line.""" + if seconds < 1.0: + return f"{seconds * 1000:.0f}ms" + if seconds < 60.0: + return f"{seconds:.1f}s" + minutes = int(seconds // 60) + return f"{minutes}m{seconds - minutes * 60:.0f}s" + + def _build_rich_theme(theme: Theme | ResolvedTheme) -> RichTheme: """Map LeapFlow theme to a Rich style dict.""" return RichTheme({ @@ -100,12 +110,11 @@ def command_card(self, command: TuiCommand) -> None: if command.error: body.append("\n") body.append(command.error, style="leap.error") - if command.elapsed_s > 0: - body.append("\n") - body.append(f"elapsed: {command.elapsed_s:.1f}s", style="leap.dim") title = Text() title.append(command.label, style="bold") title.append(f" {command.status.value}", style=title_styles[command.status]) + if command.elapsed_s > 0: + title.append(f" {_format_card_elapsed(command.elapsed_s)}", style="leap.dim") self._console.print(Panel( body, title=title, @@ -114,15 +123,26 @@ def command_card(self, command: TuiCommand) -> None: padding=(0, 1), )) - def markdown(self, text: str, *, code_theme: str = "monokai", indent: int = 0) -> None: - """Render markdown content with syntax-highlighted code blocks.""" + def markdown( + self, + text: str, + *, + code_theme: str = "monokai", + indent: int = 0, + margin_top: int = 0, + margin_bottom: int = 0, + ) -> None: + """Render markdown content with optional visual spacing.""" if not text.strip(): return md = Markdown( text, code_theme=code_theme if self._theme.name == "dark" else "default", ) - renderable = Padding(md, (0, 0, 0, indent)) if indent > 0 else md + if indent > 0 or margin_top > 0 or margin_bottom > 0: + renderable = Padding(md, (margin_top, 0, margin_bottom, indent)) + else: + renderable = md self._console.print(renderable) def code(self, source: str, language: str = "python", *, title: str = "") -> None: diff --git a/src/leapflow/cli/tui_app/stream.py b/src/leapflow/cli/tui_app/stream.py index 3dbf91f..c1f3f6c 100644 --- a/src/leapflow/cli/tui_app/stream.py +++ b/src/leapflow/cli/tui_app/stream.py @@ -18,6 +18,7 @@ from rich.text import Text _FINAL_RESPONSE_INDENT_SPACES = 4 +_FINAL_RESPONSE_MARGIN_TOP = 1 if TYPE_CHECKING: from leapflow.cli.tui_app.console import LeapConsole @@ -118,7 +119,11 @@ def finish(self) -> None: self._console.thinking(self._thinking_buffer) if self._buffer.strip(): - self._console.markdown(self._buffer, indent=_FINAL_RESPONSE_INDENT_SPACES) + self._console.markdown( + self._buffer, + indent=_FINAL_RESPONSE_INDENT_SPACES, + margin_top=_FINAL_RESPONSE_MARGIN_TOP, + ) self._console.response_label(self.elapsed, tool_count=self.tool_count) self._console.newline() diff --git a/src/leapflow/daemon/client.py b/src/leapflow/daemon/client.py index 49f056f..829fb2e 100644 --- a/src/leapflow/daemon/client.py +++ b/src/leapflow/daemon/client.py @@ -96,6 +96,26 @@ async def status(self) -> dict[str, Any]: result = await self.request("daemon.status") return dict(result or {}) + async def host_status(self) -> dict[str, Any]: + """Return daemon-owned host backend status.""" + result = await self.request("host.status") + return dict(result or {}) + + async def host_start(self) -> dict[str, Any]: + """Start the daemon-owned host backend.""" + result = await self.request("host.start") + return dict(result or {}) + + async def host_stop(self) -> dict[str, Any]: + """Stop the daemon-owned host backend.""" + result = await self.request("host.stop") + return dict(result or {}) + + async def host_restart(self) -> dict[str, Any]: + """Restart the daemon-owned host backend.""" + result = await self.request("host.restart") + return dict(result or {}) + async def approval_status(self) -> dict[str, Any]: """Return pending daemon approval requests.""" result = await self.request("approval.status") diff --git a/src/leapflow/daemon/protocol.py b/src/leapflow/daemon/protocol.py index 326da6d..df565ff 100644 --- a/src/leapflow/daemon/protocol.py +++ b/src/leapflow/daemon/protocol.py @@ -187,6 +187,22 @@ async def status(self) -> Dict[str, Any]: """Return daemon status (uptime, connections, db path, etc.).""" ... + async def host_status(self) -> Dict[str, Any]: + """Return host backend status.""" + ... + + async def host_start(self) -> Dict[str, Any]: + """Start the host backend if available.""" + ... + + async def host_stop(self) -> Dict[str, Any]: + """Stop the host backend and keep the daemon runtime alive.""" + ... + + async def host_restart(self) -> Dict[str, Any]: + """Restart the host backend.""" + ... + async def approval_status(self) -> Dict[str, Any]: """Return pending approval requests.""" ... @@ -254,6 +270,10 @@ async def gateway_send( "scheduler.arm": "scheduler_arm", "daemon.status": "status", "daemon.shutdown": "shutdown", + "host.status": "host_status", + "host.start": "host_start", + "host.stop": "host_stop", + "host.restart": "host_restart", "approval.status": "approval_status", "approval.resolve": "approval_resolve", "approval.cancel": "approval_cancel", diff --git a/src/leapflow/daemon/service.py b/src/leapflow/daemon/service.py index 6cd6b63..4bf6a2c 100644 --- a/src/leapflow/daemon/service.py +++ b/src/leapflow/daemon/service.py @@ -157,6 +157,41 @@ async def status(self) -> dict[str, Any]: "host_backend": self._host_backend_status(ctx), } + async def host_status(self) -> dict[str, Any]: + """Return daemon-owned host backend status.""" + ctx = self.context + status = getattr(ctx, "host_backend_status", None) + if callable(status): + return dict(await status()) + return self._host_backend_status(ctx) + + async def host_start(self) -> dict[str, Any]: + """Start daemon-owned CuaDriver without resetting chat state.""" + async with self._engine_lock: + ctx = self.context + start = getattr(ctx, "host_backend_start", None) + if not callable(start): + return {"ok": False, "started": False, "last_error": "host lifecycle is unavailable"} + return dict(await start()) + + async def host_stop(self) -> dict[str, Any]: + """Stop daemon-owned CuaDriver without shutting down leapd.""" + async with self._engine_lock: + ctx = self.context + stop = getattr(ctx, "host_backend_stop", None) + if not callable(stop): + return {"ok": False, "started": False, "last_error": "host lifecycle is unavailable"} + return dict(await stop()) + + async def host_restart(self) -> dict[str, Any]: + """Restart daemon-owned CuaDriver without resetting chat state.""" + async with self._engine_lock: + ctx = self.context + restart = getattr(ctx, "host_backend_restart", None) + if not callable(restart): + return {"ok": False, "started": False, "last_error": "host lifecycle is unavailable"} + return dict(await restart()) + async def approval_status(self) -> dict[str, Any]: """Return currently pending daemon approval requests.""" return {"pending": self._pending_payloads()} diff --git a/src/leapflow/engine/engine.py b/src/leapflow/engine/engine.py index fbc4588..2872866 100644 --- a/src/leapflow/engine/engine.py +++ b/src/leapflow/engine/engine.py @@ -375,6 +375,33 @@ def set_sanitizer(self, sanitizer: MessageSanitizer | None) -> None: """Configure output message sanitizer.""" self._sanitizer = sanitizer + def reconfigure_host_backend( + self, + *, + rpc: HostRpc, + perception: Optional[Any], + execution: Optional[Any], + tool_bridge: Optional[Any], + ) -> None: + """Refresh host RPC and adapters without resetting chat/session state.""" + self._rpc = rpc + self._perception = perception + self._execution = execution + self._tool_bridge = tool_bridge + self._skill_merger = SkillMerger( + registry=self._registry, + llm=self._llm, + execution=execution, + ) + if self._settings.has_llm_credentials: + self._scheduler = TaskScheduler( + self._registry, + rpc, + graph_planner=self._graph_planner, + ) + else: + self._scheduler = None + def reconfigure_runtime( self, *, diff --git a/src/leapflow/platform/cua_client.py b/src/leapflow/platform/cua_client.py index 532df89..271e140 100644 --- a/src/leapflow/platform/cua_client.py +++ b/src/leapflow/platform/cua_client.py @@ -633,6 +633,11 @@ def _resolve_timeout(self, method: str) -> float: prefix = method.split(".", 1)[0] if method else "" return self._timeout_map.get(prefix, self._call_timeout) + @property + def connected(self) -> bool: + """Return True when the cua-driver MCP session is active.""" + return self._session.started + # ── Lifecycle ──────────────────────────────────────────────────────── def start(self) -> None: diff --git a/src/leapflow/platform/event_bus.py b/src/leapflow/platform/event_bus.py index 7f2933e..debaa90 100644 --- a/src/leapflow/platform/event_bus.py +++ b/src/leapflow/platform/event_bus.py @@ -69,6 +69,10 @@ def subscribe(self, callback: EventCallback) -> None: """Register a callback that receives every normalized SystemEvent.""" self._subscribers.append(callback) + def unsubscribe(self, callback: EventCallback) -> None: + """Remove a previously registered callback if present.""" + self._subscribers = [item for item in self._subscribers if item != callback] + def enable_reorder(self, settle_s: float = 0.05) -> None: """Activate the reorder buffer (call at recording start).""" self._reorder_buffer = EventReorderBuffer(settle_s, self._process_event) diff --git a/src/leapflow/platform/mock.py b/src/leapflow/platform/mock.py index 2b9a79c..9276b74 100644 --- a/src/leapflow/platform/mock.py +++ b/src/leapflow/platform/mock.py @@ -38,6 +38,21 @@ def on_event(self, handler: EventHandler) -> None: """Register an event handler (mirrors HostRpc interface).""" self._event_handlers.append(handler) + @property + def connected(self) -> bool: + """Mock backend never represents a live OS host connection.""" + return False + + def status_snapshot(self) -> Dict[str, Any]: + """Return a daemon-friendly host status snapshot for degraded mode.""" + return { + "backend": "mock", + "started": False, + "pid": None, + "pid_source": "unavailable", + "reason": "host_backend_off", + } + async def call(self, method: str, params: Optional[Dict[str, Any]] = None) -> Any: p = params or {} if method == Methods.PING: diff --git a/tests/test_daemon_rpc.py b/tests/test_daemon_rpc.py index c7a56fb..45146b2 100644 --- a/tests/test_daemon_rpc.py +++ b/tests/test_daemon_rpc.py @@ -242,6 +242,87 @@ def __init__(self, settings) -> None: } +@pytest.mark.asyncio +async def test_daemon_client_host_lifecycle_rpc() -> None: + class HostRpcService(_FakeService): + async def host_status(self) -> dict[str, Any]: + return {"backend": "mock", "started": False} + + async def host_start(self) -> dict[str, Any]: + return {"ok": True, "backend": "cua-driver", "started": True} + + async def host_stop(self) -> dict[str, Any]: + return {"ok": True, "backend": "mock", "started": False} + + async def host_restart(self) -> dict[str, Any]: + return {"ok": True, "backend": "cua-driver", "started": True, "changed": True} + + with tempfile.TemporaryDirectory(prefix="lfd-", dir="/tmp") as root: + server, task, run_dir = await _start_server(Path(root) / "run", service=HostRpcService()) + client = DaemonClient(run_dir / "leapd.sock") + try: + status = await client.host_status() + started = await client.host_start() + stopped = await client.host_stop() + restarted = await client.host_restart() + finally: + task.cancel() + await server.stop() + try: + await task + except asyncio.CancelledError: + pass + + assert status == {"backend": "mock", "started": False} + assert started["started"] is True + assert stopped["backend"] == "mock" + assert restarted["changed"] is True + + +@pytest.mark.asyncio +async def test_runtime_service_host_lifecycle_delegates_to_context() -> None: + from conftest import make_settings + from leapflow.daemon.service import RuntimeLeapService + + class FakeContext: + def __init__(self, settings) -> None: + self.settings = settings + self.calls: list[str] = [] + self.rpc = object() + + async def host_backend_status(self) -> dict[str, Any]: + self.calls.append("status") + return {"backend": "mock", "started": False} + + async def host_backend_start(self) -> dict[str, Any]: + self.calls.append("start") + return {"ok": True, "backend": "cua-driver", "started": True} + + async def host_backend_stop(self) -> dict[str, Any]: + self.calls.append("stop") + return {"ok": True, "backend": "mock", "started": False} + + async def host_backend_restart(self) -> dict[str, Any]: + self.calls.append("restart") + return {"ok": True, "backend": "cua-driver", "started": True} + + with tempfile.TemporaryDirectory(prefix="lfd-", dir="/tmp") as root: + settings = make_settings(root) + service = RuntimeLeapService(settings, mock_host=True) + ctx = FakeContext(settings) + service._ctx = ctx + status = await service.host_status() + started = await service.host_start() + stopped = await service.host_stop() + restarted = await service.host_restart() + + assert status["backend"] == "mock" + assert started["started"] is True + assert stopped["started"] is False + assert restarted["backend"] == "cua-driver" + assert ctx.calls == ["status", "start", "stop", "restart"] + + @pytest.mark.asyncio async def test_daemon_client_approval_resolve_rpc() -> None: class ApprovalRpcService(_FakeService): diff --git a/tests/test_tui_command_queue.py b/tests/test_tui_command_queue.py index bd6e58e..d4cdefa 100644 --- a/tests/test_tui_command_queue.py +++ b/tests/test_tui_command_queue.py @@ -9,6 +9,7 @@ from prompt_toolkit.auto_suggest import Suggestion from prompt_toolkit.completion import Completion from leapflow.cli.tui_app.app import LeapApp +from leapflow.cli.tui_app.console import LeapConsole from leapflow.cli.tui_app.command import TuiCommand, TuiCommandStatus from leapflow.cli.tui_app.theme import _LIGHT, resolve_theme @@ -133,6 +134,32 @@ async def test_right_arrow_accepts_suggestion_only_at_end() -> None: assert buffer.cursor_position == len("abcdef") +def test_command_card_keeps_elapsed_in_title_not_body(monkeypatch) -> None: + class CapturingConsole: + width = 100 + + def __init__(self) -> None: + self.rendered = [] + + def print(self, renderable) -> None: + self.rendered.append(renderable) + + capture = CapturingConsole() + leap_console = LeapConsole(resolve_theme(_LIGHT, terminal_bg="#FFFFFF")) + monkeypatch.setattr(leap_console, "_console", capture) + + command = TuiCommand.create(command_id=1, text="summarize the current project layout") + command = command.mark_running().mark_done() + leap_console.command_card(command) + + panel = capture.rendered[0] + title = getattr(panel.title, "plain", str(panel.title)) + body = getattr(panel.renderable, "plain", str(panel.renderable)) + assert "done" in title + assert "elapsed:" not in body + assert "summarize the current project layout" in body + + @pytest.mark.asyncio async def test_process_loop_marks_failed_commands_and_recovers_counts() -> None: async def on_input(text: str) -> None: diff --git a/tests/test_tui_session_summary.py b/tests/test_tui_session_summary.py index 499ff6d..f2f5cee 100644 --- a/tests/test_tui_session_summary.py +++ b/tests/test_tui_session_summary.py @@ -20,13 +20,25 @@ class _Message: class _Console: def __init__(self) -> None: - self.markdown_calls: list[tuple[str, int]] = [] + self.markdown_calls: list[dict[str, object]] = [] self.thinking_calls: list[str] = [] self.labels: list[tuple[float, int]] = [] self.lines = 0 - def markdown(self, text: str, *, indent: int = 0) -> None: - self.markdown_calls.append((text, indent)) + def markdown( + self, + text: str, + *, + indent: int = 0, + margin_top: int = 0, + margin_bottom: int = 0, + ) -> None: + self.markdown_calls.append({ + "text": text, + "indent": indent, + "margin_top": margin_top, + "margin_bottom": margin_bottom, + }) def thinking(self, text: str) -> None: self.thinking_calls.append(text) @@ -130,7 +142,7 @@ def test_stream_renderer_exposes_output_without_private_access() -> None: assert renderer.has_output is True -def test_stream_renderer_indents_final_response_only() -> None: +def test_stream_renderer_spaces_and_indents_final_response_only() -> None: console = _Console() renderer = StreamRenderer(console) renderer.start() @@ -140,7 +152,12 @@ def test_stream_renderer_indents_final_response_only() -> None: renderer.finish() assert console.thinking_calls == ["internal reasoning"] - assert console.markdown_calls == [("final **answer**", 4)] + assert console.markdown_calls == [{ + "text": "final **answer**", + "indent": 4, + "margin_top": 1, + "margin_bottom": 0, + }] assert len(console.labels) == 1 assert console.lines == 1 From 071e93761b2a778a03257a452622b6937199152c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8F=AD=E6=89=AC?= Date: Fri, 10 Jul 2026 16:19:10 +0800 Subject: [PATCH 05/17] fix(daemon): recover tui runtime connections --- src/leapflow/cli/cli.py | 1 + src/leapflow/cli/commands/daemon.py | 7 +- src/leapflow/cli/commands/interactive.py | 210 +++++++++++++++++++++-- src/leapflow/daemon/client.py | 41 +++++ src/leapflow/daemon/lease.py | 180 +++++++++++++++++++ src/leapflow/daemon/server.py | 20 ++- src/leapflow/daemon/service.py | 8 + tests/test_cli_entrypoint.py | 110 ++++++++++++ tests/test_daemon_rpc.py | 49 +++++- 9 files changed, 606 insertions(+), 20 deletions(-) create mode 100644 src/leapflow/daemon/lease.py diff --git a/src/leapflow/cli/cli.py b/src/leapflow/cli/cli.py index fa4a828..ec33e6a 100644 --- a/src/leapflow/cli/cli.py +++ b/src/leapflow/cli/cli.py @@ -157,6 +157,7 @@ def _status(message: str) -> None: client, settings, resume_id=getattr(args, "resume", None), + mock_host=mock_host, ) if cmd == "chat": from leapflow.cli.commands.chat import cmd_chat_daemon diff --git a/src/leapflow/cli/commands/daemon.py b/src/leapflow/cli/commands/daemon.py index f6dd651..587fa83 100644 --- a/src/leapflow/cli/commands/daemon.py +++ b/src/leapflow/cli/commands/daemon.py @@ -66,10 +66,15 @@ async def _runtime_status(sock_path: Path) -> dict: def _print_runtime_status(status: dict) -> None: + connected_clients = status.get("connected_clients") + connection_suffix = ( + f" connected={connected_clients}" if connected_clients is not None else "" + ) print( "runtime: " f"profile={status.get('profile')} " - f"clients={status.get('active_clients')} " + f"clients={status.get('active_clients')}" + f"{connection_suffix} " f"volatile={status.get('volatile')}" ) print( diff --git a/src/leapflow/cli/commands/interactive.py b/src/leapflow/cli/commands/interactive.py index 381c230..5c1c7b9 100644 --- a/src/leapflow/cli/commands/interactive.py +++ b/src/leapflow/cli/commands/interactive.py @@ -47,7 +47,19 @@ async def _prompt_stop_daemon_on_exit( "leapd runs in the background; stop/restart it after reinstalling LeapFlow " "to load new code." ) - stop = await _ask_yes_no_default_yes(f"Stop leapd now (pid={pid})? [Y/n]: ") + connected_clients = daemon_status.get("connected_clients") + other_clients = 0 + try: + other_clients = max(0, int(connected_clients or 0)) + except (TypeError, ValueError): + other_clients = 0 + if other_clients > 0: + console.system( + f"Detected {other_clients} other Leap client(s); keeping leapd running by default." + ) + stop = await _ask_yes_no_default_no(f"Stop leapd anyway (pid={pid})? [y/N]: ") + else: + stop = await _ask_yes_no_default_yes(f"Stop leapd now (pid={pid})? [Y/n]: ") if not stop: console.system("leapd kept running. Use `leap daemon restart` when needed.") return @@ -75,6 +87,20 @@ async def _ask_yes_no_default_yes(prompt: str) -> bool: return answer not in {"n", "no"} +async def _ask_yes_no_default_no(prompt: str) -> bool: + """Return False by default, including non-interactive or interrupted prompts.""" + if not sys.stdin.isatty(): + return False + try: + answer = await asyncio.get_running_loop().run_in_executor( + None, + lambda: input(prompt).strip().lower(), + ) + except (EOFError, KeyboardInterrupt): + return False + return answer in {"y", "yes"} + + def _host_started(status: dict[str, Any]) -> bool: return bool(status.get("started")) and str(status.get("backend") or "") != "mock" @@ -109,6 +135,76 @@ def _host_action(args: str) -> str: return action +class _DaemonRuntimeBridge: + """Reconnectable bridge for daemon-backed TUI runtime calls.""" + + def __init__( + self, + client: "DaemonClient", + settings: Any, + console: Any, + *, + session_id_getter: Any, + session_id_setter: Any, + metadata_applier: Any, + lease: Any | None = None, + mock_host: bool = False, + client_factory: Any | None = None, + ) -> None: + self.client = client + self._settings = settings + self._console = console + self._session_id_getter = session_id_getter + self._session_id_setter = session_id_setter + self._metadata_applier = metadata_applier + self._lease = lease + self._mock_host = mock_host + self._client_factory = client_factory + + async def call(self, operation: Any, *, description: str) -> Any: + """Run one RPC operation and recover the daemon once on connection failure.""" + from leapflow.daemon.client import DaemonUnavailableError + + try: + return await operation(self.client) + except DaemonUnavailableError as exc: + await self.recover(f"{description} failed: {exc}") + return await operation(self.client) + + async def recover(self, reason: str) -> None: + """Reconnect or restart leapd, then restore runtime metadata and session.""" + from leapflow.daemon.client import recover_daemon_client + + self._console.warning(f"Lost connection to leapd; attempting recovery. {reason}") + + def _status(message: str) -> None: + self._console.system(message) + + factory = self._client_factory or recover_daemon_client + self.client = await factory( + self._settings, + mock_host=self._mock_host, + status_callback=_status, + ) + status = await self.client.status() + self._metadata_applier(status) + session_id = str(self._session_id_getter() or "") + if session_id: + result = await self.client.session_resume(session_id) + if result.get("found"): + restored = str(result.get("session_id") or session_id) + self._session_id_setter(restored) + self._console.success(f"Reconnected to leapd and resumed session {restored}") + else: + self._console.warning( + f"Reconnected to leapd, but session '{session_id}' was not found." + ) + else: + self._console.success("Reconnected to leapd.") + if self._lease is not None: + await self._lease.touch(state="idle", session_id=str(self._session_id_getter() or "")) + + async def cmd_interactive(ctx: "Context", *, resume_id: Optional[str] = None) -> int: """Persistent REPL session with hybrid TUI (Application + Rich).""" require_initialized(ctx) @@ -595,6 +691,7 @@ async def cmd_interactive_daemon( settings: Any, *, resume_id: Optional[str] = None, + mock_host: bool = False, ) -> int: """Persistent REPL backed by leapd thin-client RPC.""" from leapflow.cli.banner import display_rich_banner @@ -608,6 +705,7 @@ async def cmd_interactive_daemon( detect_theme, ) from leapflow.cli.tui_app.status import StatusBar + from leapflow.daemon.lease import ClientLease from leapflow.tools.registry_bootstrap import TOOL_DEFINITIONS theme = detect_theme() @@ -621,6 +719,11 @@ async def cmd_interactive_daemon( runtime_context_used = 0 runtime_daemon_pid = "" runtime_host_online = False + client_lease = ClientLease( + settings.profile_dir / "run", + kind="tui", + session_id=active_session_id, + ) def _apply_daemon_runtime_metadata(metadata: dict[str, Any]) -> None: nonlocal active_session_id, runtime_model_name, runtime_context_length @@ -647,6 +750,21 @@ def _apply_daemon_runtime_metadata(metadata: dict[str, Any]) -> None: if isinstance(host, dict): runtime_host_online = _host_started(host) + def _set_active_session_id(session_id: str) -> None: + nonlocal active_session_id + active_session_id = session_id + + bridge = _DaemonRuntimeBridge( + client, + settings, + console, + session_id_getter=lambda: active_session_id, + session_id_setter=_set_active_session_id, + metadata_applier=_apply_daemon_runtime_metadata, + lease=client_lease, + mock_host=mock_host, + ) + def _update_status() -> None: status.update( mode="daemon", @@ -678,7 +796,10 @@ def _render_banner() -> None: async def _print_daemon_status() -> None: try: - daemon_status = await client.status() + daemon_status = await bridge.call( + lambda current_client: current_client.status(), + description="daemon status", + ) except Exception as exc: console.warning(f"Daemon status unavailable: {exc}") return @@ -689,6 +810,7 @@ async def _print_daemon_status() -> None: f"pid={daemon_status.get('pid')} " f"profile={daemon_status.get('profile')} " f"clients={daemon_status.get('active_clients')} " + f"connected={daemon_status.get('connected_clients', 0)} " f"volatile={daemon_status.get('volatile')}" ) db_path = daemon_status.get("db_path") @@ -734,22 +856,39 @@ async def _handle_daemon_approval(event: StreamEvent) -> None: request = ApprovalRequest.from_dict(payload) decision = await prompt_approval(request) value = decision.value if isinstance(decision, ApprovalDecision) else str(decision) - await client.approval_resolve(pending_id, value) + await bridge.call( + lambda current_client: current_client.approval_resolve(pending_id, value), + description="approval resolve", + ) app.spinner_text = "Thinking…" - async def _stream_response(prompt_text: str) -> None: - nonlocal active_session_id, turn_count, runtime_model_name - nonlocal runtime_context_length, runtime_context_used - exit_stats.record_user_message() + async def _stream_response( + prompt_text: str, + *, + allow_retry: bool = True, + record_user: bool = True, + ) -> None: + nonlocal turn_count + from leapflow.daemon.client import DaemonUnavailableError + + if record_user: + exit_stats.record_user_message() status.mark_turn_start() app.agent_running = True app.spinner_text = "Thinking…" + await client_lease.touch(state="streaming", session_id=active_session_id) renderer = StreamRenderer(console) renderer.start() + retry_error: DaemonUnavailableError | None = None + saw_real_event = False + turn_completed = False try: - async for event in client.engine_chat(prompt_text): + async for event in bridge.client.engine_chat(prompt_text): metadata = event.metadata or {} + is_heartbeat = event.type == "status" and metadata.get("heartbeat") + if not is_heartbeat: + saw_real_event = True _apply_daemon_runtime_metadata(metadata) if event.type == "chunk": renderer.feed(event.content) @@ -766,21 +905,40 @@ async def _stream_response(prompt_text: str) -> None: elif event.type == "error": renderer.feed(event.content) elif event.type == "approval_request": + await client_lease.touch(state="approval", session_id=active_session_id) await _handle_daemon_approval(event) + await client_lease.touch(state="streaming", session_id=active_session_id) elif event.type == "status": if not metadata.get("heartbeat"): console.system(event.content) + turn_completed = True + except DaemonUnavailableError as exc: + if allow_retry and not saw_real_event: + retry_error = exc + else: + console.warning( + "Lost connection to leapd after the turn started; " + "the command was not replayed to avoid duplicate side effects." + ) + raise finally: renderer.finish() if renderer.has_output: exit_stats.record_assistant_message() exit_stats.record_tool_calls(renderer.tool_count) - turn_count += 1 + if turn_completed: + turn_count += 1 app.spinner_text = "" app.agent_running = False status.mark_turn_end() + await client_lease.touch(state="idle", session_id=active_session_id) _update_status() + if retry_error is not None: + await bridge.recover(f"chat stream failed before output: {retry_error}") + console.system("Retrying the interrupted request once after reconnecting to leapd.") + await _stream_response(prompt_text, allow_retry=False, record_user=False) + async def handle_input(text: str) -> None: cmd_text = text.lstrip("/") if text.startswith("/") else text cmd_def = resolve_command(cmd_text) @@ -799,16 +957,28 @@ async def handle_input(text: str) -> None: action = _host_action(cmd_text[len(canonical):].strip()) try: if action == "status": - result = await client.host_status() + result = await bridge.call( + lambda current_client: current_client.host_status(), + description="host status", + ) elif action == "start": console.system("Starting CuaDriver OS control for this session…") - result = await client.host_start() + result = await bridge.call( + lambda current_client: current_client.host_start(), + description="host start", + ) elif action == "stop": console.system("Stopping CuaDriver OS control; chat and memory stay available…") - result = await client.host_stop() + result = await bridge.call( + lambda current_client: current_client.host_stop(), + description="host stop", + ) elif action == "restart": console.system("Restarting CuaDriver OS control…") - result = await client.host_restart() + result = await bridge.call( + lambda current_client: current_client.host_restart(), + description="host restart", + ) else: console.warning("Usage: /host [status|start|stop|restart]") return @@ -861,7 +1031,10 @@ def _print_exit_summary() -> None: ) if resume_id: - result = await client.session_resume(resume_id) + result = await bridge.call( + lambda current_client: current_client.session_resume(resume_id), + description="session resume", + ) if result.get("found"): active_session_id = str(result.get("session_id") or resume_id) console.success(f"Resumed session {active_session_id}") @@ -870,7 +1043,10 @@ def _print_exit_summary() -> None: active_session_id = "" try: - _apply_daemon_runtime_metadata(await client.status()) + _apply_daemon_runtime_metadata(await bridge.call( + lambda current_client: current_client.status(), + description="daemon status", + )) except Exception as exc: console.warning(f"Daemon status unavailable: {exc}") @@ -878,10 +1054,12 @@ def _print_exit_summary() -> None: _update_status() exit_code = 0 try: + await client_lease.start() exit_code = await app.run() finally: + await client_lease.stop() _print_exit_summary() - await _prompt_stop_daemon_on_exit(client, settings, console) + await _prompt_stop_daemon_on_exit(bridge.client, settings, console) return exit_code diff --git a/src/leapflow/daemon/client.py b/src/leapflow/daemon/client.py index 829fb2e..d3ed27c 100644 --- a/src/leapflow/daemon/client.py +++ b/src/leapflow/daemon/client.py @@ -13,6 +13,7 @@ DaemonInfo, DaemonLock, cleanup_stale, + send_signal, spawn_daemon, wait_ready, ) @@ -215,6 +216,46 @@ async def ensure_daemon_client( return DaemonClient(sock_path) +async def recover_daemon_client( + settings: Any, + *, + mock_host: bool = False, + status_callback: StatusCallback | None = None, +) -> DaemonClient: + """Return a usable daemon client, restarting an unhealthy daemon once.""" + try: + return await ensure_daemon_client( + settings, + mock_host=mock_host, + status_callback=status_callback, + ) + except DaemonUnavailableError as exc: + run_dir = settings.profile_dir / "run" + info = DaemonInfo.discover(run_dir) + if not info.is_running: + raise + _emit(status_callback, f"Restarting unhealthy leapd (pid={info.pid})...") + if not send_signal(run_dir): + raise exc + if not await _wait_stopped(run_dir): + raise exc + cleanup_stale(run_dir) + return await ensure_daemon_client( + settings, + mock_host=mock_host, + status_callback=status_callback, + ) + + +async def _wait_stopped(run_dir: Path, *, timeout_s: float = 10.0) -> bool: + deadline = asyncio.get_running_loop().time() + timeout_s + while asyncio.get_running_loop().time() < deadline: + if not DaemonInfo.discover(run_dir).is_running: + return True + await asyncio.sleep(0.1) + return not DaemonInfo.discover(run_dir).is_running + + def _event_from_params(params: dict[str, Any]) -> StreamEvent: event_type = str(params.get("event_type") or "chunk") if event_type not in { diff --git a/src/leapflow/daemon/lease.py b/src/leapflow/daemon/lease.py new file mode 100644 index 0000000..91fb0b1 --- /dev/null +++ b/src/leapflow/daemon/lease.py @@ -0,0 +1,180 @@ +"""Client lease files for leapd multi-client lifecycle tracking.""" +from __future__ import annotations + +import asyncio +import json +import os +import time +import uuid +from dataclasses import dataclass +from pathlib import Path + +_CLIENTS_DIR = "clients" +_DEFAULT_LEASE_TTL_S = 120.0 +_DEFAULT_TOUCH_INTERVAL_S = 30.0 + + +@dataclass(frozen=True) +class ClientLeaseSnapshot: + """Immutable view of one active client lease.""" + + client_id: str + pid: int + kind: str + state: str + session_id: str + started_at: float + last_seen_at: float + path: Path + + +def default_lease_ttl_s() -> float: + """Return the maximum age for a live client lease.""" + raw = os.getenv("LEAPFLOW_CLIENT_LEASE_TTL_S", str(_DEFAULT_LEASE_TTL_S)).strip() + try: + return max(1.0, float(raw)) + except ValueError: + return _DEFAULT_LEASE_TTL_S + + +def read_active_client_leases( + run_dir: Path, + *, + now: float | None = None, + ttl_s: float | None = None, +) -> list[ClientLeaseSnapshot]: + """Return currently active client leases and remove stale entries.""" + clients_dir = run_dir / _CLIENTS_DIR + if not clients_dir.exists(): + return [] + current = time.time() if now is None else now + max_age = default_lease_ttl_s() if ttl_s is None else max(1.0, ttl_s) + active: list[ClientLeaseSnapshot] = [] + for path in clients_dir.glob("*.json"): + snapshot = _read_lease(path) + if snapshot is None: + path.unlink(missing_ok=True) + continue + if current - snapshot.last_seen_at > max_age or not _process_alive(snapshot.pid): + path.unlink(missing_ok=True) + continue + active.append(snapshot) + return active + + +def has_active_client_leases( + run_dir: Path, + *, + now: float | None = None, + ttl_s: float | None = None, +) -> bool: + """Return True when any live client lease exists.""" + return bool(read_active_client_leases(run_dir, now=now, ttl_s=ttl_s)) + + +class ClientLease: + """Maintain one client lease file while a TUI client is alive.""" + + def __init__( + self, + run_dir: Path, + *, + kind: str, + session_id: str = "", + state: str = "idle", + touch_interval_s: float = _DEFAULT_TOUCH_INTERVAL_S, + ) -> None: + self._run_dir = run_dir + self._client_id = uuid.uuid4().hex + self._kind = kind + self.session_id = session_id + self.state = state + self._started_at = time.time() + self._touch_interval_s = max(1.0, touch_interval_s) + self._task: asyncio.Task[None] | None = None + + @property + def client_id(self) -> str: + """Return this lease's stable client id.""" + return self._client_id + + @property + def path(self) -> Path: + """Return the lease file path.""" + return self._run_dir / _CLIENTS_DIR / f"{self._client_id}.json" + + async def start(self) -> None: + """Create the lease and start periodic touch updates.""" + await self.touch() + self._task = asyncio.create_task(self._touch_loop()) + + async def stop(self) -> None: + """Stop updating and remove the lease.""" + if self._task is not None: + self._task.cancel() + try: + await self._task + except asyncio.CancelledError: + pass + self._task = None + await asyncio.to_thread(self.path.unlink, True) + + async def touch( + self, + *, + state: str | None = None, + session_id: str | None = None, + ) -> None: + """Update the lease timestamp and optional mutable state.""" + if state is not None: + self.state = state + if session_id is not None: + self.session_id = session_id + await asyncio.to_thread(self._write_sync) + + async def _touch_loop(self) -> None: + while True: + await asyncio.sleep(self._touch_interval_s) + await self.touch() + + def _write_sync(self) -> None: + path = self.path + path.parent.mkdir(parents=True, exist_ok=True) + payload = { + "client_id": self._client_id, + "pid": os.getpid(), + "kind": self._kind, + "state": self.state, + "session_id": self.session_id, + "cwd": os.getcwd(), + "started_at": self._started_at, + "last_seen_at": time.time(), + } + tmp_path = path.with_suffix(".tmp") + tmp_path.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8") + tmp_path.replace(path) + + +def _read_lease(path: Path) -> ClientLeaseSnapshot | None: + try: + payload = json.loads(path.read_text(encoding="utf-8")) + return ClientLeaseSnapshot( + client_id=str(payload["client_id"]), + pid=int(payload["pid"]), + kind=str(payload.get("kind") or "unknown"), + state=str(payload.get("state") or "idle"), + session_id=str(payload.get("session_id") or ""), + started_at=float(payload.get("started_at") or 0.0), + last_seen_at=float(payload.get("last_seen_at") or 0.0), + path=path, + ) + except (OSError, json.JSONDecodeError, KeyError, TypeError, ValueError): + return None + + +def _process_alive(pid: int) -> bool: + try: + os.kill(pid, 0) + return True + except OSError: + return False diff --git a/src/leapflow/daemon/server.py b/src/leapflow/daemon/server.py index f343146..b2f39a1 100644 --- a/src/leapflow/daemon/server.py +++ b/src/leapflow/daemon/server.py @@ -10,6 +10,7 @@ from pathlib import Path from typing import Any +from leapflow.daemon.lease import default_lease_ttl_s, read_active_client_leases from leapflow.daemon.lifecycle import cleanup_run_dir, write_pid_file from leapflow.daemon.protocol import ErrorCode, METHOD_REGISTRY, RpcRequest, RpcResponse, StreamChunk @@ -56,6 +57,13 @@ def __init__( self._active_connections = 0 if hasattr(service, "set_client_count_provider"): service.set_client_count_provider(lambda: self._active_connections) + if hasattr(service, "set_client_lease_provider"): + service.set_client_lease_provider(lambda: read_active_client_leases(self._run_dir)) + + @property + def run_dir(self) -> Path: + """Return the daemon runtime directory.""" + return self._run_dir @property def active_connections(self) -> int: @@ -287,13 +295,21 @@ async def _watch_idle_shutdown( stop_event: asyncio.Event, *, idle_timeout_s: float, + lease_ttl_s: float | None = None, + poll_interval_s: float | None = None, ) -> None: if idle_timeout_s <= 0: return last_active = asyncio.get_running_loop().time() - interval = min(30.0, max(1.0, idle_timeout_s / 10.0)) + interval = poll_interval_s or min(30.0, max(1.0, idle_timeout_s / 10.0)) + max_lease_age = default_lease_ttl_s() if lease_ttl_s is None else lease_ttl_s while not stop_event.is_set(): - if server.active_connections > 0: + has_lease = await asyncio.to_thread( + read_active_client_leases, + server.run_dir, + ttl_s=max_lease_age, + ) + if server.active_connections > 0 or has_lease: last_active = asyncio.get_running_loop().time() elif asyncio.get_running_loop().time() - last_active >= idle_timeout_s: logger.info("daemon: idle timeout reached; shutting down") diff --git a/src/leapflow/daemon/service.py b/src/leapflow/daemon/service.py index 4bf6a2c..3c83af8 100644 --- a/src/leapflow/daemon/service.py +++ b/src/leapflow/daemon/service.py @@ -10,6 +10,7 @@ from collections.abc import AsyncIterator, Callable from typing import Any +from leapflow.daemon.lease import ClientLeaseSnapshot from leapflow.daemon.protocol import StreamChunk from leapflow.engine import StreamEvent from leapflow.memory.protocol import MemoryEntry, MemoryQuery @@ -27,6 +28,7 @@ def __init__(self, settings: Any, *, mock_host: bool = False) -> None: self._engine_lock = asyncio.Lock() self._started_at = time.time() self._client_count: Callable[[], int] = lambda: 0 + self._client_leases: Callable[[], list[ClientLeaseSnapshot]] = lambda: [] self._approval_pending: dict[str, dict[str, Any]] = {} self._approval_event_queue: asyncio.Queue[StreamChunk] | None = None @@ -34,6 +36,10 @@ def set_client_count_provider(self, provider: Callable[[], int]) -> None: """Set a lightweight callback used by status reporting.""" self._client_count = provider + def set_client_lease_provider(self, provider: Callable[[], list[ClientLeaseSnapshot]]) -> None: + """Set a callback used to report live client leases.""" + self._client_leases = provider + async def start(self) -> None: """Initialize the daemon-owned runtime once.""" if self._ctx is not None: @@ -146,6 +152,8 @@ async def status(self) -> dict[str, Any]: "volatile": bool(getattr(ctx, "storage_volatile", False)) if ctx is not None else False, "uptime_s": max(0.0, time.time() - self._started_at), "active_clients": max(0, self._client_count()), + "active_connections": max(0, self._client_count()), + "connected_clients": len(self._client_leases()), "model": getattr(settings, "llm_model", ""), "llm_context_length": getattr(settings, "llm_context_length", 0), "context_used": getattr(engine, "context_token_count", 0) if engine is not None else 0, diff --git a/tests/test_cli_entrypoint.py b/tests/test_cli_entrypoint.py index b8dc9aa..68ff167 100644 --- a/tests/test_cli_entrypoint.py +++ b/tests/test_cli_entrypoint.py @@ -369,6 +369,70 @@ async def fake_interactive(ctx, *, resume_id=None) -> int: assert (data_dir / ".env").exists() +@pytest.mark.asyncio +async def test_daemon_runtime_bridge_recovers_and_resumes_session() -> None: + from leapflow.cli.commands.interactive import _DaemonRuntimeBridge + from leapflow.daemon.client import DaemonUnavailableError + + class Console: + def __init__(self) -> None: + self.warnings: list[str] = [] + self.systems: list[str] = [] + self.successes: list[str] = [] + + def warning(self, message: str) -> None: + self.warnings.append(message) + + def system(self, message: str) -> None: + self.systems.append(message) + + def success(self, message: str) -> None: + self.successes.append(message) + + class BrokenClient: + async def status(self): + raise DaemonUnavailableError("socket disappeared") + + class RecoveredClient: + def __init__(self) -> None: + self.resumed: list[str] = [] + + async def status(self): + return {"pid": 99, "session_id": "sess-1"} + + async def session_resume(self, session_id: str): + self.resumed.append(session_id) + return {"found": True, "session_id": session_id} + + class Settings: + pass + + active_session_id = "sess-1" + metadata: list[dict] = [] + recovered = RecoveredClient() + + async def factory(settings, *, mock_host: bool = False, status_callback=None): + if status_callback is not None: + status_callback("Connected to recovered leapd.") + return recovered + + bridge = _DaemonRuntimeBridge( + BrokenClient(), + Settings(), + Console(), + session_id_getter=lambda: active_session_id, + session_id_setter=lambda value: None, + metadata_applier=metadata.append, + client_factory=factory, + ) + + result = await bridge.call(lambda current_client: current_client.status(), description="status") + + assert result == {"pid": 99, "session_id": "sess-1"} + assert recovered.resumed == ["sess-1"] + assert metadata == [{"pid": 99, "session_id": "sess-1"}] + + def test_leap_default_command_uses_daemon_client(monkeypatch) -> None: from leapflow.cli import cli @@ -700,6 +764,52 @@ async def no(prompt: str) -> bool: assert any("kept running" in message for message in console.systems) +@pytest.mark.asyncio +async def test_daemon_tui_exit_prompt_keeps_daemon_by_default_for_other_clients( + monkeypatch, + tmp_path, +) -> None: + from leapflow.cli.commands import interactive as interactive_module + import leapflow.daemon.lifecycle as lifecycle_module + + class Client: + async def status(self): + return {"pid": 1234, "connected_clients": 2} + + class Console: + def __init__(self) -> None: + self.systems: list[str] = [] + self.warnings: list[str] = [] + + def system(self, message: str) -> None: + self.systems.append(message) + + def warning(self, message: str) -> None: + self.warnings.append(message) + + class Settings: + profile_dir = tmp_path + + prompts: list[str] = [] + + async def default_no(prompt: str) -> bool: + prompts.append(prompt) + return False + + def fail_send_signal(*args, **kwargs): + raise AssertionError("daemon should be kept running while other clients exist") + + monkeypatch.setattr(interactive_module, "_ask_yes_no_default_no", default_no) + monkeypatch.setattr(lifecycle_module, "send_signal", fail_send_signal) + console = Console() + + await interactive_module._prompt_stop_daemon_on_exit(Client(), Settings(), console) + + assert prompts == ["Stop leapd anyway (pid=1234)? [y/N]: "] + assert any("other Leap client" in message for message in console.systems) + assert any("kept running" in message for message in console.systems) + + def test_leap_prompt_uses_daemon_chat_route(monkeypatch) -> None: from leapflow.cli import cli diff --git a/tests/test_daemon_rpc.py b/tests/test_daemon_rpc.py index 45146b2..029111d 100644 --- a/tests/test_daemon_rpc.py +++ b/tests/test_daemon_rpc.py @@ -114,6 +114,52 @@ async def test_daemon_client_stream_heartbeat_prevents_idle_timeout() -> None: ] +@pytest.mark.asyncio +async def test_client_lease_blocks_daemon_idle_shutdown(tmp_path) -> None: + from leapflow.daemon.lease import ClientLease, read_active_client_leases + from leapflow.daemon.server import _watch_idle_shutdown + + class IdleServer: + active_connections = 0 + + def __init__(self, run_dir: Path) -> None: + self._run_dir = run_dir + + @property + def run_dir(self) -> Path: + return self._run_dir + + run_dir = tmp_path / "run" + lease = ClientLease(run_dir, kind="tui", session_id="sess-live", touch_interval_s=1.0) + await lease.start() + stop_event = asyncio.Event() + task = asyncio.create_task( + _watch_idle_shutdown( + IdleServer(run_dir), + stop_event, + idle_timeout_s=0.03, + lease_ttl_s=1.0, + poll_interval_s=0.01, + ) + ) + try: + await asyncio.sleep(0.08) + assert not stop_event.is_set() + snapshots = read_active_client_leases(run_dir, ttl_s=1.0) + assert [(item.kind, item.session_id, item.state) for item in snapshots] == [ + ("tui", "sess-live", "idle") + ] + + await lease.stop() + await asyncio.wait_for(stop_event.wait(), timeout=0.2) + finally: + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + + @pytest.mark.asyncio async def test_runtime_service_streams_pending_approval_and_resolves(tmp_path) -> None: from conftest import make_settings @@ -570,6 +616,7 @@ def test_daemon_runtime_status_prints_diagnostics(capsys) -> None: _print_runtime_status({ "profile": "default", "active_clients": 1, + "connected_clients": 2, "volatile": False, "model": "qwen3.7-plus", "context_used": 256, @@ -592,7 +639,7 @@ def test_daemon_runtime_status_prints_diagnostics(capsys) -> None: }) output = capsys.readouterr().out - assert "runtime: profile=default clients=1 volatile=False" in output + assert "runtime: profile=default clients=1 connected=2 volatile=False" in output assert "model: qwen3.7-plus context=256/256000" in output assert "version: 0.0.test" in output assert "source: /repo/src/leapflow/__init__.py" in output From 5b1af9b268302a1f3a2eedd29670f15a12442470 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8F=AD=E6=89=AC?= Date: Fri, 10 Jul 2026 17:15:39 +0800 Subject: [PATCH 06/17] feat(tui): enrich command logs and slash routing --- src/leapflow/cli/commands/interactive.py | 116 ++++++++-- src/leapflow/cli/commands/registry.py | 74 +++++- src/leapflow/cli/commands/router.py | 95 ++++++++ src/leapflow/cli/commands/slash_handlers.py | 239 +++++++++++++------- src/leapflow/cli/tui_app/stream.py | 88 ++++++- src/leapflow/daemon/client.py | 16 ++ src/leapflow/daemon/protocol.py | 15 ++ src/leapflow/daemon/service.py | 18 ++ src/leapflow/engine/engine.py | 110 ++++++++- tests/test_daemon_rpc.py | 101 +++++++++ tests/test_slash_command_router.py | 28 +++ tests/test_tui_command_queue.py | 65 ++++++ 12 files changed, 830 insertions(+), 135 deletions(-) create mode 100644 src/leapflow/cli/commands/router.py create mode 100644 tests/test_slash_command_router.py diff --git a/src/leapflow/cli/commands/interactive.py b/src/leapflow/cli/commands/interactive.py index 5c1c7b9..0abe450 100644 --- a/src/leapflow/cli/commands/interactive.py +++ b/src/leapflow/cli/commands/interactive.py @@ -220,7 +220,8 @@ async def cmd_interactive(ctx: "Context", *, resume_id: Optional[str] = None) -> ) from leapflow.cli.tui_app.status import StatusBar from leapflow.cli.banner import display_rich_banner - from leapflow.cli.commands.registry import completion_entries, resolve_command + from leapflow.cli.commands.registry import completion_entries + from leapflow.cli.commands.router import CommandRouter, render_command_result from leapflow.cli.commands.slash_handlers import ( handle_status, handle_tools, @@ -238,6 +239,7 @@ async def cmd_interactive(ctx: "Context", *, resume_id: Optional[str] = None) -> status = StatusBar(theme) io = TerminalIOProvider() exit_stats = SessionExitStats() + command_router = CommandRouter("in_process") active_resume_id = "" storage_volatile = bool(getattr(ctx, "storage_volatile", False)) @@ -407,9 +409,12 @@ async def _stream_response(prompt_text: str) -> None: elif event.type == "thinking": renderer.feed_thinking(event.content) elif event.type == "tool_start": - app.spinner_text = renderer.tool_started(event.content) + app.spinner_text = renderer.tool_started( + event.content, + metadata=event.metadata or {}, + ) elif event.type == "tool_complete": - renderer.tool_finished(event.content) + renderer.tool_finished(event.content, metadata=event.metadata or {}) app.spinner_text = "Thinking…" elif event.type == "final": if not renderer.text: @@ -470,11 +475,16 @@ async def handle_input(text: str) -> None: console.rule() # ── Slash command dispatch ── - cmd_text = text.lstrip("/") if text.startswith("/") else text - cmd_def = resolve_command(cmd_text) - if cmd_def is not None: + invocation = command_router.parse(text) + if invocation is not None: + unsupported = command_router.unsupported_result(invocation) + if unsupported is not None: + render_command_result(console, unsupported) + return + cmd_text = invocation.text + cmd_def = invocation.command canonical = cmd_def.name - cmd_args = cmd_text[len(canonical):].strip() + cmd_args = invocation.args if canonical == "exit": if ctx.session and ctx.session.mode == SessionMode.LEARNING: @@ -489,7 +499,7 @@ async def handle_input(text: str) -> None: return if canonical == "help": - _show_help(console) + _show_help(console, runtime="in_process") return if canonical == "status": @@ -695,7 +705,13 @@ async def cmd_interactive_daemon( ) -> int: """Persistent REPL backed by leapd thin-client RPC.""" from leapflow.cli.banner import display_rich_banner - from leapflow.cli.commands.registry import completion_entries, resolve_command + from leapflow.cli.commands.registry import completion_entries + from leapflow.cli.commands.router import CommandRouter, render_command_result + from leapflow.cli.commands.slash_handlers import ( + render_model_payload, + render_tools_payload, + render_usage_payload, + ) from leapflow.cli.tui_app import ( LeapApp, LeapConsole, @@ -712,6 +728,7 @@ async def cmd_interactive_daemon( console = LeapConsole(theme) status = StatusBar(theme) exit_stats = SessionExitStats() + command_router = CommandRouter("daemon") active_session_id = str(resume_id or "") turn_count = 0 runtime_model_name = str(getattr(settings, "llm_model", "")) @@ -895,9 +912,12 @@ async def _stream_response( elif event.type == "thinking": renderer.feed_thinking(event.content) elif event.type == "tool_start": - app.spinner_text = renderer.tool_started(event.content) + app.spinner_text = renderer.tool_started( + event.content, + metadata=metadata, + ) elif event.type == "tool_complete": - renderer.tool_finished(event.content) + renderer.tool_finished(event.content, metadata=metadata) app.spinner_text = "Thinking…" elif event.type == "final": if not renderer.text: @@ -940,21 +960,25 @@ async def _stream_response( await _stream_response(prompt_text, allow_retry=False, record_user=False) async def handle_input(text: str) -> None: - cmd_text = text.lstrip("/") if text.startswith("/") else text - cmd_def = resolve_command(cmd_text) - if cmd_def is not None: - canonical = cmd_def.name + invocation = command_router.parse(text) + if invocation is not None: + unsupported = command_router.unsupported_result(invocation) + if unsupported is not None: + render_command_result(console, unsupported) + return + canonical = invocation.command.name + cmd_args = invocation.args if canonical == "exit": app.exit() return if canonical == "help": - _show_help(console) + _show_help(console, runtime="daemon") return if canonical == "status": await _print_daemon_status() return if canonical == "host": - action = _host_action(cmd_text[len(canonical):].strip()) + action = _host_action(cmd_args) try: if action == "status": result = await bridge.call( @@ -992,11 +1016,45 @@ async def handle_input(text: str) -> None: if canonical == "clear": _render_banner() return - if canonical in {"tools", "usage", "model"}: - console.system( - f"/{canonical} is not available in daemon mode yet; " - "chat and resume are daemon-backed in this phase." - ) + if canonical == "tools": + try: + payload = await bridge.call( + lambda current_client: current_client.tools_list(), + description="tools list", + ) + except Exception as exc: + console.warning(f"Tools unavailable: {exc}") + return + render_tools_payload(console, payload) + return + if canonical == "usage": + try: + payload = await bridge.call( + lambda current_client: current_client.usage_summary(), + description="usage summary", + ) + except Exception as exc: + console.warning(f"Usage unavailable: {exc}") + return + render_usage_payload(console, payload) + return + if canonical == "model": + try: + payload = await bridge.call( + lambda current_client: current_client.model_info(cmd_args), + description="model info", + ) + except Exception as exc: + console.warning(f"Model info unavailable: {exc}") + return + render_model_payload(console, payload) + return + if canonical == "run": + if not cmd_args: + console.warning("Usage: /run ") + return + console.system("Running through daemon chat stream; approvals and host state stay visible.") + await _stream_response(cmd_args) return console.warning( f"/{canonical} is not available in daemon mode yet. " @@ -1372,11 +1430,11 @@ def _handle_shortcuts(ctx: "Context", console, line: str) -> bool: return False -def _show_help(console) -> None: +def _show_help(console, runtime: str = "in_process") -> None: """Display categorized help using the command registry.""" from leapflow.cli.commands.registry import commands_by_category - categories = commands_by_category() + categories = commands_by_category(runtime=runtime) # type: ignore[arg-type] console.print() for category, cmds in categories.items(): @@ -1394,8 +1452,16 @@ def _show_help(console) -> None: ] if visible: aliases = f" [dim]({', '.join(visible)})[/]" + support = "" + if not cmd.supports_runtime(runtime): # type: ignore[arg-type] + support = " [dim]not in daemon[/]" if runtime == "daemon" else " [dim]not in this mode[/]" + effect = "" + if cmd.requires_host: + effect = " [dim]host[/]" + elif cmd.requires_llm: + effect = " [dim]llm[/]" console.print( - f" [cyan]{name:<28}[/] {cmd.description}{aliases}" + f" [cyan]{name:<28}[/] {cmd.description}{aliases}{effect}{support}" ) console.print() diff --git a/src/leapflow/cli/commands/registry.py b/src/leapflow/cli/commands/registry.py index 670ad97..5cbf6dc 100644 --- a/src/leapflow/cli/commands/registry.py +++ b/src/leapflow/cli/commands/registry.py @@ -12,7 +12,31 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Dict, List, Optional, Tuple +from enum import Enum +from typing import Dict, List, Literal, Optional, Tuple + + +class CommandEffect(str, Enum): + """User-visible impact level for a slash command.""" + + READ_ONLY = "read_only" + SESSION = "session" + HOST_CONTROL = "host_control" + EXTERNAL = "external" + DESTRUCTIVE = "destructive" + + +class CommandExecution(str, Enum): + """Execution shape used by the TUI to explain command behavior.""" + + INSTANT = "instant" + SHORT_OPERATION = "short_operation" + STREAMING = "streaming" + LONG_RUNNING = "long_running" + BACKGROUND = "background" + + +CommandRuntime = Literal["in_process", "daemon"] @dataclass(frozen=True) @@ -24,21 +48,41 @@ class CommandDef: category: str aliases: Tuple[str, ...] = () args_hint: str = "" + supports_in_process: bool = True + supports_daemon: bool = False + requires_host: bool = False + requires_llm: bool = False + effect: CommandEffect = CommandEffect.READ_ONLY + execution: CommandExecution = CommandExecution.INSTANT + + def supports_runtime(self, runtime: CommandRuntime) -> bool: + """Return whether this command can execute in the requested runtime.""" + if runtime == "daemon": + return self.supports_daemon + return self.supports_in_process # ── Registry (single source of truth) ──────────────────────────────── COMMAND_REGISTRY: Tuple[CommandDef, ...] = ( # Session - CommandDef("help", "Show available commands", "Session", aliases=("?", "帮助")), - CommandDef("clear", "Clear screen and reset display", "Session"), - CommandDef("status", "Show session info, model, context, and platform", "Session"), - CommandDef("host", "Start, stop, or inspect CuaDriver OS control", "Session", args_hint="[status|start|stop|restart]"), - CommandDef("exit", "Quit LeapFlow", "Session", aliases=("quit", "q", "退出")), + CommandDef("help", "Show available commands", "Session", aliases=("?", "帮助"), supports_daemon=True), + CommandDef("clear", "Clear screen and reset display", "Session", supports_daemon=True), + CommandDef("status", "Show session info, model, context, and platform", "Session", supports_daemon=True), + CommandDef( + "host", + "Start, stop, or inspect CuaDriver OS control", + "Session", + args_hint="[status|start|stop|restart]", + supports_daemon=True, + effect=CommandEffect.HOST_CONTROL, + execution=CommandExecution.SHORT_OPERATION, + ), + CommandDef("exit", "Quit LeapFlow", "Session", aliases=("quit", "q", "退出"), supports_daemon=True), # Chat - CommandDef("model", "Show or switch active model", "Chat", args_hint="[model_name]"), - CommandDef("usage", "Show token usage for current session", "Chat"), + CommandDef("model", "Show or switch active model", "Chat", args_hint="[model_name]", supports_daemon=True, requires_llm=True), + CommandDef("usage", "Show token usage for current session", "Chat", supports_daemon=True, requires_llm=True), # Teaching CommandDef("teach start", "Start teaching mode", "Teaching", aliases=("teach",), args_hint="[goal]"), @@ -54,8 +98,8 @@ class CommandDef: CommandDef("skills show", "Show skill details", "Skills & Tools", args_hint=""), CommandDef("skills disable", "Disable a skill", "Skills & Tools", args_hint=""), CommandDef("skills delete", "Delete a skill", "Skills & Tools", args_hint=""), - CommandDef("tools", "List available tools", "Skills & Tools"), - CommandDef("run", "Execute a skill by trigger", "Skills & Tools", args_hint=""), + CommandDef("tools", "List available tools", "Skills & Tools", supports_daemon=True), + CommandDef("run", "Execute a skill by trigger", "Skills & Tools", args_hint="", supports_daemon=True, requires_llm=True, execution=CommandExecution.STREAMING), # Hub CommandDef("hub push", "Push skill to hub", "Hub", args_hint=""), @@ -68,7 +112,7 @@ class CommandDef: CommandDef("shortcut remove", "Remove a shortcut", "Shortcuts", args_hint=""), # Gateway - CommandDef("gateway", "Show connected platforms and gateway status", "Gateway"), + CommandDef("gateway", "Show connected platforms and gateway status", "Gateway", effect=CommandEffect.EXTERNAL), # Scheduler CommandDef("arm", "Schedule a skill for timed execution", "Scheduler", args_hint=" "), @@ -103,10 +147,16 @@ def resolve_command(text: str) -> Optional[CommandDef]: return None -def commands_by_category() -> Dict[str, List[CommandDef]]: +def commands_by_category( + runtime: CommandRuntime | None = None, + *, + include_unsupported: bool = True, +) -> Dict[str, List[CommandDef]]: """Group commands by category for display.""" groups: Dict[str, List[CommandDef]] = {} for cmd in COMMAND_REGISTRY: + if runtime is not None and not include_unsupported and not cmd.supports_runtime(runtime): + continue groups.setdefault(cmd.category, []).append(cmd) return groups diff --git a/src/leapflow/cli/commands/router.py b/src/leapflow/cli/commands/router.py new file mode 100644 index 0000000..ac55837 --- /dev/null +++ b/src/leapflow/cli/commands/router.py @@ -0,0 +1,95 @@ +"""Slash command routing primitives. + +The router keeps parsing and result semantics independent from the TUI +implementation so in-process and daemon-backed sessions can share one command +contract. +""" +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Literal + +from leapflow.cli.commands.registry import CommandDef, resolve_command + +CommandRuntime = Literal["in_process", "daemon"] + + +@dataclass(frozen=True) +class CommandInvocation: + """Parsed slash command invocation.""" + + raw_text: str + text: str + command: CommandDef + args: str + runtime: CommandRuntime + + +@dataclass(frozen=True) +class CommandResult: + """Structured result for short slash commands.""" + + ok: bool + title: str + summary: str = "" + details: tuple[str, ...] = field(default_factory=tuple) + next_actions: tuple[str, ...] = field(default_factory=tuple) + refresh_status: bool = False + refresh_banner: bool = False + + +def render_command_result(console: object, result: CommandResult) -> None: + """Render a structured command result with a consistent tone.""" + if result.ok: + success = getattr(console, "success", None) + if callable(success): + success(result.title) + else: + getattr(console, "system")(result.title) + else: + getattr(console, "warning")(result.title) + if result.summary: + getattr(console, "system")(result.summary) + for detail in result.details: + getattr(console, "system")(detail) + if result.next_actions: + getattr(console, "system")("Next: " + " · ".join(result.next_actions)) + + +class CommandRouter: + """Resolve raw user input into a runtime-aware command invocation.""" + + def __init__(self, runtime: CommandRuntime) -> None: + self._runtime = runtime + + @property + def runtime(self) -> CommandRuntime: + """Return the runtime this router validates against.""" + return self._runtime + + def parse(self, raw_text: str) -> CommandInvocation | None: + """Parse raw user input into a CommandInvocation when it is a command.""" + text = raw_text.lstrip("/") if raw_text.startswith("/") else raw_text + command = resolve_command(text) + if command is None: + return None + args = text[len(command.name):].strip() + return CommandInvocation( + raw_text=raw_text, + text=text, + command=command, + args=args, + runtime=self._runtime, + ) + + def unsupported_result(self, invocation: CommandInvocation) -> CommandResult | None: + """Return a standard unsupported-runtime result when needed.""" + if invocation.command.supports_runtime(invocation.runtime): + return None + mode = "daemon" if invocation.runtime == "daemon" else "in-process" + return CommandResult( + ok=False, + title=f"/{invocation.command.name} is not available in {mode} mode yet.", + summary="The command is registered, but its execution backend has not reached parity in this runtime.", + next_actions=("Use /help to see runtime support", "Try --no-daemon if this is a legacy command"), + ) diff --git a/src/leapflow/cli/commands/slash_handlers.py b/src/leapflow/cli/commands/slash_handlers.py index 326f3b0..bbf2161 100644 --- a/src/leapflow/cli/commands/slash_handlers.py +++ b/src/leapflow/cli/commands/slash_handlers.py @@ -7,13 +7,162 @@ from __future__ import annotations import os -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any if TYPE_CHECKING: from leapflow.cli.context import Context from leapflow.cli.tui_app.console import LeapConsole +def build_tools_payload(ctx: "Context") -> dict[str, Any]: + """Build a serializable tools summary for local or daemon rendering.""" + from leapflow.cli.banner import _categorize_tools + from leapflow.tools.registry_bootstrap import TOOL_DEFINITIONS + + tool_groups = _categorize_tools(TOOL_DEFINITIONS) + groups = {category: sorted(names) for category, names in tool_groups.items()} + mcp_count = 0 + if hasattr(ctx.rpc, "connected") and ctx.rpc.connected: + mcp_count = len(getattr(ctx, "platform_tools", [])) + return { + "ok": True, + "groups": groups, + "total": sum(len(names) for names in groups.values()), + "mcp_count": mcp_count, + } + + +def render_tools_payload(console: "LeapConsole", payload: dict[str, Any]) -> None: + """Render a serializable tools summary.""" + from rich.table import Table + + if not payload.get("ok", True): + console.warning(str(payload.get("error") or "Tools are not available.")) + return + + groups = dict(payload.get("groups") or {}) + table = Table( + title="Available Tools", + show_header=True, + header_style="bold", + border_style="bright_black", + title_style="bold cyan", + padding=(0, 1), + ) + table.add_column("Category", style="cyan", no_wrap=True) + table.add_column("Tools") + + for category, names in groups.items(): + table.add_row(str(category), ", ".join(sorted(str(name) for name in names))) + + mcp_count = int(payload.get("mcp_count") or 0) + if mcp_count > 0: + table.add_row("mcp", f"{mcp_count} platform tools") + + console.print(table) + console.system(f"{int(payload.get('total') or 0)} tools available") + + +def build_usage_payload(ctx: "Context") -> dict[str, Any]: + """Build a serializable token usage summary.""" + engine = ctx.engine + if engine is None: + return {"ok": False, "error": "No active engine — send a message first."} + + tracker = getattr(engine, "usage_tracker", None) + if tracker is None: + return {"ok": False, "error": "Usage tracking not available."} + + summary = tracker.summary() + cap_registry = getattr(engine, "model_capabilities", None) + context_length = 0 + if cap_registry is not None: + caps = cap_registry.resolve(ctx.settings.llm_model) + context_length = int(caps.context_length) + + return { + "ok": True, + "model": ctx.settings.llm_model, + "prompt_tokens": int(summary.prompt_tokens), + "completion_tokens": int(summary.completion_tokens), + "total_tokens": int(summary.total_tokens), + "turn_count": int(getattr(engine, "turn_count", 0)), + "context_used": int(getattr(engine, "context_token_count", 0)), + "context_length": context_length, + } + + +def render_usage_payload(console: "LeapConsole", payload: dict[str, Any]) -> None: + """Render a serializable token usage summary.""" + from leapflow.cli.tui_app.status import _compact_tokens + + if not payload.get("ok", True): + console.warning(str(payload.get("error") or "Usage tracking not available.")) + return + + console.print() + prompt_tokens = int(payload.get("prompt_tokens") or 0) + completion_tokens = int(payload.get("completion_tokens") or 0) + total_tokens = int(payload.get("total_tokens") or 0) + turn_count = int(payload.get("turn_count") or 0) + context_used = int(payload.get("context_used") or 0) + context_length = int(payload.get("context_length") or 0) + lines = [ + f" Model: {payload.get('model') or ''}", + f" Input tokens: {_compact_tokens(prompt_tokens):>8} ({prompt_tokens:,})", + f" Output tokens: {_compact_tokens(completion_tokens):>8} ({completion_tokens:,})", + f" Total tokens: {_compact_tokens(total_tokens):>8} ({total_tokens:,})", + f" Turns: {turn_count}", + ] + if context_length > 0: + pct = int(context_used * 100 / context_length) + lines.append( + f" Context: {_compact_tokens(context_used)}/{_compact_tokens(context_length)} ({pct}%)" + ) + for line in lines: + console.system(line) + console.print() + + +def build_model_payload(ctx: "Context", args: str = "") -> dict[str, Any]: + """Build a serializable model summary.""" + model_arg = args.strip() + engine = ctx.engine + context_length = 0 + if engine is not None: + cap_registry = getattr(engine, "model_capabilities", None) + if cap_registry is not None: + caps = cap_registry.resolve(ctx.settings.llm_model) + context_length = int(caps.context_length) + return { + "ok": True, + "model": ctx.settings.llm_model, + "context_length": context_length, + "requested_model": model_arg, + "switch_supported": False, + "env_var": "LEAPFLOW_LLM_MODEL", + } + + +def render_model_payload(console: "LeapConsole", payload: dict[str, Any]) -> None: + """Render a serializable model summary.""" + if not payload.get("ok", True): + console.warning(str(payload.get("error") or "Model information is not available.")) + return + requested = str(payload.get("requested_model") or "") + model = str(payload.get("model") or "") + if not requested: + console.system(f"Current model: {model}") + context_length = int(payload.get("context_length") or 0) + if context_length > 0: + console.system(f"Context length: {context_length:,}") + return + + console.warning("Model switching requires restarting with LEAPFLOW_LLM_MODEL env var.") + console.system(f" Current: {model}") + console.system(f" Example: LEAPFLOW_LLM_MODEL={requested} leap") + + def handle_status(ctx: "Context", console: "LeapConsole", args: str) -> None: """Display session status: model, context, platform, session info.""" from rich.panel import Panel @@ -99,99 +248,17 @@ def handle_status(ctx: "Context", console: "LeapConsole", args: str) -> None: def handle_tools(ctx: "Context", console: "LeapConsole", args: str) -> None: """Display available tools grouped by category.""" - from rich.table import Table - from leapflow.tools.registry_bootstrap import TOOL_DEFINITIONS - from leapflow.cli.banner import _categorize_tools - - tool_groups = _categorize_tools(TOOL_DEFINITIONS) - - table = Table( - title="Available Tools", - show_header=True, - header_style="bold", - border_style="bright_black", - title_style="bold cyan", - padding=(0, 1), - ) - table.add_column("Category", style="cyan", no_wrap=True) - table.add_column("Tools") - - for cat, names in tool_groups.items(): - table.add_row(cat, ", ".join(sorted(names))) - - mcp_count = 0 - if hasattr(ctx.rpc, "connected") and ctx.rpc.connected: - mcp_count = len(getattr(ctx, "platform_tools", [])) - if mcp_count > 0: - table.add_row("mcp", f"{mcp_count} platform tools") - - console.print(table) - console.system(f"{sum(len(v) for v in tool_groups.values())} tools available") + render_tools_payload(console, build_tools_payload(ctx)) def handle_usage(ctx: "Context", console: "LeapConsole", args: str) -> None: """Display token usage for the current session.""" - from leapflow.cli.tui_app.status import _compact_tokens - - engine = ctx.engine - if engine is None: - console.warning("No active engine — send a message first.") - return - - tracker = getattr(engine, "usage_tracker", None) - if tracker is None: - console.warning("Usage tracking not available.") - return - - console.print() - - summary = tracker.summary() - turn_count = getattr(engine, "turn_count", 0) - - cap_registry = getattr(engine, "model_capabilities", None) - ctx_len = 0 - if cap_registry is not None: - caps = cap_registry.resolve(ctx.settings.llm_model) - ctx_len = caps.context_length - ctx_used = getattr(engine, "context_token_count", 0) - - lines = [ - f" Model: {ctx.settings.llm_model}", - f" Input tokens: {_compact_tokens(summary.prompt_tokens):>8} ({summary.prompt_tokens:,})", - f" Output tokens: {_compact_tokens(summary.completion_tokens):>8} ({summary.completion_tokens:,})", - f" Total tokens: {_compact_tokens(summary.total_tokens):>8} ({summary.total_tokens:,})", - f" Turns: {turn_count}", - ] - - if ctx_len > 0: - pct = int(ctx_used * 100 / ctx_len) if ctx_len else 0 - lines.append( - f" Context: {_compact_tokens(ctx_used)}/{_compact_tokens(ctx_len)} ({pct}%)" - ) - - for line in lines: - console.system(line) - console.print() + render_usage_payload(console, build_usage_payload(ctx)) def handle_model(ctx: "Context", console: "LeapConsole", args: str) -> None: """Show or switch the active model.""" - model_arg = args.strip() - if not model_arg: - console.system(f"Current model: {ctx.settings.llm_model}") - engine = ctx.engine - if engine is not None: - cap_registry = getattr(engine, "model_capabilities", None) - if cap_registry is not None: - caps = cap_registry.resolve(ctx.settings.llm_model) - console.system(f"Context length: {caps.context_length:,}") - return - - console.warning( - "Model switching requires restarting with LEAPFLOW_LLM_MODEL env var." - ) - console.system(f" Current: {ctx.settings.llm_model}") - console.system(f" Example: LEAPFLOW_LLM_MODEL={model_arg} leap") + render_model_payload(console, build_model_payload(ctx, args)) def handle_gateway(ctx: "Context", console: "LeapConsole", args: str) -> None: diff --git a/src/leapflow/cli/tui_app/stream.py b/src/leapflow/cli/tui_app/stream.py index c1f3f6c..e7952e4 100644 --- a/src/leapflow/cli/tui_app/stream.py +++ b/src/leapflow/cli/tui_app/stream.py @@ -13,12 +13,74 @@ from __future__ import annotations import time -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from rich.text import Text _FINAL_RESPONSE_INDENT_SPACES = 4 _FINAL_RESPONSE_MARGIN_TOP = 1 +_TOOL_DETAIL_LIMIT = 180 + + +def _metadata_text(metadata: dict[str, Any] | None, key: str) -> str: + if not metadata: + return "" + value = metadata.get(key) + return value if isinstance(value, str) else ("" if value is None else str(value)) + + +def _truncate_detail(text: str, *, limit: int = _TOOL_DETAIL_LIMIT) -> str: + compact = " ".join(text.split()) + if len(compact) <= limit: + return compact + return compact[: limit - 1] + "…" + + +def _tool_action_detail(metadata: dict[str, Any] | None) -> str: + if not metadata: + return "" + command = _metadata_text(metadata, "command") or _metadata_text(metadata, "cmd") + if command: + return f"$ {_truncate_detail(command)}" + path = _metadata_text(metadata, "path") + pattern = _metadata_text(metadata, "pattern") + if path and pattern: + return f"path={_truncate_detail(path)} pattern={_truncate_detail(pattern, limit=80)}" + if path: + return f"path={_truncate_detail(path)}" + query = _metadata_text(metadata, "query") + if query: + return f"query={_truncate_detail(query)}" + url = _metadata_text(metadata, "url") + if url: + return f"url={_truncate_detail(url)}" + return _truncate_detail(_metadata_text(metadata, "args_summary")) + + +def _tool_result_detail(metadata: dict[str, Any] | None) -> str: + if not metadata: + return "" + if metadata.get("ok") is False: + exit_code = metadata.get("exit_code") + prefix = f"exit={exit_code} " if exit_code is not None else "" + detail = ( + _metadata_text(metadata, "stderr_preview") + or _metadata_text(metadata, "error_preview") + or _metadata_text(metadata, "result_preview") + ) + return _truncate_detail(prefix + detail) if detail or prefix else "failed" + detail = ( + _metadata_text(metadata, "stdout_preview") + or _metadata_text(metadata, "content_preview") + or _metadata_text(metadata, "output_preview") + or _metadata_text(metadata, "result_preview") + ) + if detail: + return _truncate_detail(detail) + if metadata.get("path"): + return f"path={_truncate_detail(str(metadata['path']))}" + return "ok (no output)" + if TYPE_CHECKING: from leapflow.cli.tui_app.console import LeapConsole @@ -93,22 +155,40 @@ def feed_thinking(self, chunk: str) -> None: """Append a thinking/reasoning chunk.""" self._thinking_buffer += chunk - def tool_started(self, name: str) -> str: + def tool_started(self, name: str, metadata: dict[str, Any] | None = None) -> str: """Mark a tool call as started. Returns spinner text for LeapApp.""" self._active_tool = name self._tool_start_time = time.monotonic() + detail = _tool_action_detail(metadata) + line = Text() + line.append(" ⚡ ", style="leap.accent") + line.append(name, style="bold") + if detail: + line.append(" ", style="dim") + line.append(detail, style="leap.dim") + self._console.print(line) return f"⚡ {name}" - def tool_finished(self, name: str = "", output: str = "") -> None: + def tool_finished( + self, + name: str = "", + output: str = "", + metadata: dict[str, Any] | None = None, + ) -> None: """Mark a tool call as finished; print completion line immediately.""" tool_name = name or self._active_tool if tool_name and self._tool_start_time > 0: duration = time.monotonic() - self._tool_start_time self._tool_history.append((tool_name, duration)) + ok = metadata.get("ok", True) if metadata else True line = Text() - line.append(" ✓ ", style="leap.success") + line.append(" ✓ " if ok else " ✗ ", style="leap.success" if ok else "leap.error") line.append(tool_name, style="bold") line.append(f" {_format_elapsed(duration)}", style="dim") + detail = _tool_result_detail(metadata) or _truncate_detail(output) + if detail: + line.append(" ", style="dim") + line.append(detail, style="leap.dim" if ok else "leap.error") self._console.print(line) self._active_tool = "" self._tool_start_time = 0.0 diff --git a/src/leapflow/daemon/client.py b/src/leapflow/daemon/client.py index d3ed27c..0a4791f 100644 --- a/src/leapflow/daemon/client.py +++ b/src/leapflow/daemon/client.py @@ -117,6 +117,22 @@ async def host_restart(self) -> dict[str, Any]: result = await self.request("host.restart") return dict(result or {}) + async def tools_list(self) -> dict[str, Any]: + """Return daemon-owned tool summary for slash-command rendering.""" + result = await self.request("tools.list") + return dict(result or {}) + + async def usage_summary(self) -> dict[str, Any]: + """Return token usage for the daemon-owned session.""" + result = await self.request("usage.summary") + return dict(result or {}) + + async def model_info(self, model_name: str = "") -> dict[str, Any]: + """Return daemon-owned model information.""" + params = {"model_name": model_name} if model_name else {} + result = await self.request("model.info", params) + return dict(result or {}) + async def approval_status(self) -> dict[str, Any]: """Return pending daemon approval requests.""" result = await self.request("approval.status") diff --git a/src/leapflow/daemon/protocol.py b/src/leapflow/daemon/protocol.py index df565ff..2a0d482 100644 --- a/src/leapflow/daemon/protocol.py +++ b/src/leapflow/daemon/protocol.py @@ -203,6 +203,18 @@ async def host_restart(self) -> Dict[str, Any]: """Restart the host backend.""" ... + async def tools_list(self) -> Dict[str, Any]: + """Return available tool groups for slash-command rendering.""" + ... + + async def usage_summary(self) -> Dict[str, Any]: + """Return token usage for the current daemon session.""" + ... + + async def model_info(self, model_name: str = "") -> Dict[str, Any]: + """Return active model information and switch guidance.""" + ... + async def approval_status(self) -> Dict[str, Any]: """Return pending approval requests.""" ... @@ -274,6 +286,9 @@ async def gateway_send( "host.start": "host_start", "host.stop": "host_stop", "host.restart": "host_restart", + "tools.list": "tools_list", + "usage.summary": "usage_summary", + "model.info": "model_info", "approval.status": "approval_status", "approval.resolve": "approval_resolve", "approval.cancel": "approval_cancel", diff --git a/src/leapflow/daemon/service.py b/src/leapflow/daemon/service.py index 3c83af8..36a2356 100644 --- a/src/leapflow/daemon/service.py +++ b/src/leapflow/daemon/service.py @@ -200,6 +200,24 @@ async def host_restart(self) -> dict[str, Any]: return {"ok": False, "started": False, "last_error": "host lifecycle is unavailable"} return dict(await restart()) + async def tools_list(self) -> dict[str, Any]: + """Return daemon-owned tool summary for slash-command rendering.""" + from leapflow.cli.commands.slash_handlers import build_tools_payload + + return build_tools_payload(self.context) + + async def usage_summary(self) -> dict[str, Any]: + """Return token usage for the current daemon-owned session.""" + from leapflow.cli.commands.slash_handlers import build_usage_payload + + return build_usage_payload(self.context) + + async def model_info(self, model_name: str = "") -> dict[str, Any]: + """Return active daemon model information and restart guidance.""" + from leapflow.cli.commands.slash_handlers import build_model_payload + + return build_model_payload(self.context, model_name) + async def approval_status(self) -> dict[str, Any]: """Return currently pending daemon approval requests.""" return {"pending": self._pending_payloads()} diff --git a/src/leapflow/engine/engine.py b/src/leapflow/engine/engine.py index 2872866..f76f4fe 100644 --- a/src/leapflow/engine/engine.py +++ b/src/leapflow/engine/engine.py @@ -59,6 +59,67 @@ logger = logging.getLogger(__name__) +_TOOL_ARGS_PREVIEW_LIMIT = 160 +_TOOL_RESULT_PREVIEW_LIMIT = 240 + + +def _single_line_preview(value: Any, *, limit: int) -> str: + """Return a compact single-line preview for UI metadata.""" + if value is None: + return "" + text = value if isinstance(value, str) else json.dumps(value, default=str, ensure_ascii=False) + compact = " ".join(text.split()) + if len(compact) <= limit: + return compact + return compact[: limit - 1] + "…" + + +def _tool_args_metadata(tool_name: str, arguments: Dict[str, Any] | None) -> Dict[str, Any]: + """Build safe, compact tool-start metadata for streaming UIs.""" + args = dict(arguments or {}) + metadata: Dict[str, Any] = { + "tool_name": tool_name, + "args_summary": _single_line_preview(args, limit=_TOOL_ARGS_PREVIEW_LIMIT), + } + for key in ("command", "cmd", "path", "pattern", "query", "url"): + value = args.get(key) + if value: + metadata[key] = _single_line_preview(value, limit=_TOOL_ARGS_PREVIEW_LIMIT) + return metadata + + +def _tool_result_metadata( + tool_name: str, + arguments: Dict[str, Any] | None, + result: Any, +) -> Dict[str, Any]: + """Build safe, compact tool-completion metadata for streaming UIs.""" + metadata = _tool_args_metadata(tool_name, arguments) + metadata["ok"] = True + if isinstance(result, dict): + metadata["ok"] = bool(result.get("ok", True)) + for key in ("exit_code", "path", "lines", "truncated", "bytes_written"): + if key in result: + metadata[key] = result[key] + for key in ("stdout", "stderr", "content", "output", "error"): + value = result.get(key) + if value: + metadata[f"{key}_preview"] = _single_line_preview( + value, + limit=_TOOL_RESULT_PREVIEW_LIMIT, + ) + if not any(key.endswith("_preview") for key in metadata): + metadata["result_preview"] = _single_line_preview( + result, + limit=_TOOL_RESULT_PREVIEW_LIMIT, + ) + else: + metadata["result_preview"] = _single_line_preview( + result, + limit=_TOOL_RESULT_PREVIEW_LIMIT, + ) + return metadata + def _estimate_text_tokens(text: str) -> int: """Approximate token count for status display when provider usage is absent.""" @@ -1645,12 +1706,22 @@ async def _unified_tool_loop_stream( ) for tc in native_calls: - yield StreamEvent(type="tool_start", content=tc.name) - await self._execute_tools_concurrent( + yield StreamEvent( + type="tool_start", + content=tc.name, + metadata=_tool_args_metadata(tc.name, tc.arguments), + ) + results = await self._execute_tools_concurrent( native_calls, TOOL_HANDLERS, trace=trace, messages=messages ) + result_by_id = {str(item.get("id")): item for item in results} for tc in native_calls: - yield StreamEvent(type="tool_complete", content=tc.name) + item = result_by_id.get(str(tc.id), {}) + yield StreamEvent( + type="tool_complete", + content=tc.name, + metadata=_tool_result_metadata(tc.name, tc.arguments, item.get("result")), + ) failures = self._count_consecutive_tool_failures(messages) turn_recovery.consecutive_tool_failures = failures @@ -1768,10 +1839,22 @@ async def _unified_tool_loop_stream( return messages.append(build_assistant_message(content)) - yield StreamEvent(type="tool_start", content=tool_call['name']) + yield StreamEvent( + type="tool_start", + content=tool_call['name'], + metadata=_tool_args_metadata(tool_call['name'], tool_call.get("arguments")), + ) result = await self._execute_general_tool(tool_call, TOOL_HANDLERS) _clear_indicator() - yield StreamEvent(type="tool_complete", content=tool_call['name']) + yield StreamEvent( + type="tool_complete", + content=tool_call['name'], + metadata=_tool_result_metadata( + tool_call['name'], + tool_call.get("arguments"), + result, + ), + ) _print_tool_result(tool_call['name'], result, enabled=self._settings.verbose_progress) trace.record( ExecutionMode.ACTING, @@ -1854,13 +1937,15 @@ async def _execute_tools_concurrent( *, trace: ExecutionTrace, messages: List[Dict[str, Any]], - ) -> None: + ) -> list[Dict[str, Any]]: """Execute native tool calls respecting concurrency policy. Concurrent group runs via asyncio.gather; sequential group runs one-by-one. - Results are appended to messages in OpenAI tool-result format. + Results are appended to messages in OpenAI tool-result format and returned + for streaming UI metadata. """ result_budget = self._effective_tool_result_budget() + executed: list[Dict[str, Any]] = [] tc_wrappers = [ ConcurrentToolCall(id=tc.id, name=tc.name, arguments=tc.arguments) for tc in native_calls @@ -1880,7 +1965,8 @@ async def _execute_tools_concurrent( ) result_text = json.dumps(result, default=str, ensure_ascii=False)[:result_budget] messages.append({"role": "tool", "tool_call_id": tc.id, "content": result_text}) - return + executed.append({"id": tc.id, "name": tc.name, "arguments": tc.arguments, "result": result}) + return executed concurrent, sequential = self._concurrency_policy.partition(tc_wrappers) logger.info( @@ -1922,6 +2008,12 @@ async def _run_one(ctc: ConcurrentToolCall) -> Dict[str, Any]: ) result_text = json.dumps(result, default=str, ensure_ascii=False)[:result_budget] messages.append({"role": "tool", "tool_call_id": ctc.id, "content": result_text}) + executed.append({ + "id": ctc.id, + "name": ctc.name, + "arguments": ctc.arguments, + "result": error_result if isinstance(result, Exception) else result, + }) for i, ctc in enumerate(sequential): _show_progress("executing", ctc.name, step=i + 1, total=len(sequential)) @@ -1936,6 +2028,8 @@ async def _run_one(ctc: ConcurrentToolCall) -> Dict[str, Any]: ) result_text = json.dumps(result, default=str, ensure_ascii=False)[:result_budget] messages.append({"role": "tool", "tool_call_id": ctc.id, "content": result_text}) + executed.append({"id": ctc.id, "name": ctc.name, "arguments": ctc.arguments, "result": result}) + return executed async def _execute_general_tool( self, tool_call: Dict[str, Any], handlers: Dict[str, Any] diff --git a/tests/test_daemon_rpc.py b/tests/test_daemon_rpc.py index 029111d..0430c61 100644 --- a/tests/test_daemon_rpc.py +++ b/tests/test_daemon_rpc.py @@ -325,6 +325,107 @@ async def host_restart(self) -> dict[str, Any]: assert restarted["changed"] is True +@pytest.mark.asyncio +async def test_daemon_client_slash_metadata_rpc() -> None: + class SlashMetadataService(_FakeService): + async def tools_list(self) -> dict[str, Any]: + return {"ok": True, "groups": {"core": ["chat"]}, "total": 1, "mcp_count": 0} + + async def usage_summary(self) -> dict[str, Any]: + return { + "ok": True, + "model": "test-model", + "prompt_tokens": 10, + "completion_tokens": 5, + "total_tokens": 15, + "turn_count": 2, + "context_used": 15, + "context_length": 100, + } + + async def model_info(self, model_name: str = "") -> dict[str, Any]: + return { + "ok": True, + "model": "test-model", + "context_length": 100, + "requested_model": model_name, + "switch_supported": False, + "env_var": "LEAPFLOW_LLM_MODEL", + } + + with tempfile.TemporaryDirectory(prefix="lfd-", dir="/tmp") as root: + server, task, run_dir = await _start_server(Path(root) / "run", service=SlashMetadataService()) + client = DaemonClient(run_dir / "leapd.sock") + try: + tools = await client.tools_list() + usage = await client.usage_summary() + model = await client.model_info("next-model") + finally: + task.cancel() + await server.stop() + try: + await task + except asyncio.CancelledError: + pass + + assert tools["groups"] == {"core": ["chat"]} + assert usage["total_tokens"] == 15 + assert model["requested_model"] == "next-model" + + +@pytest.mark.asyncio +async def test_runtime_service_slash_metadata_payloads() -> None: + from conftest import make_settings + from leapflow.daemon.service import RuntimeLeapService + + class FakeSummary: + prompt_tokens = 12 + completion_tokens = 8 + total_tokens = 20 + + class FakeUsageTracker: + def summary(self) -> FakeSummary: + return FakeSummary() + + class FakeCapabilities: + context_length = 4096 + + class FakeCapabilityRegistry: + def resolve(self, model: str) -> FakeCapabilities: + return FakeCapabilities() + + class FakeEngine: + usage_tracker = FakeUsageTracker() + model_capabilities = FakeCapabilityRegistry() + context_token_count = 20 + turn_count = 3 + + class FakeRpc: + connected = False + + class FakeContext: + def __init__(self, settings) -> None: + self.settings = settings + self.engine = FakeEngine() + self.rpc = FakeRpc() + self.platform_tools: list[Any] = [] + + with tempfile.TemporaryDirectory(prefix="lfd-", dir="/tmp") as root: + settings = make_settings(root) + service = RuntimeLeapService(settings, mock_host=True) + service._ctx = FakeContext(settings) + tools = await service.tools_list() + usage = await service.usage_summary() + model = await service.model_info("other-model") + + assert tools["ok"] is True + assert tools["total"] > 0 + assert usage["total_tokens"] == 20 + assert usage["context_length"] == 4096 + assert model["model"] == settings.llm_model + assert model["requested_model"] == "other-model" + + @pytest.mark.asyncio async def test_runtime_service_host_lifecycle_delegates_to_context() -> None: from conftest import make_settings diff --git a/tests/test_slash_command_router.py b/tests/test_slash_command_router.py new file mode 100644 index 0000000..2c39cca --- /dev/null +++ b/tests/test_slash_command_router.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from leapflow.cli.commands.router import CommandRouter + + +def test_command_router_parses_command_args_and_runtime_support() -> None: + router = CommandRouter("daemon") + + invocation = router.parse("/host restart") + + assert invocation is not None + assert invocation.command.name == "host" + assert invocation.args == "restart" + assert invocation.command.supports_runtime("daemon") is True + assert router.unsupported_result(invocation) is None + + +def test_command_router_returns_standard_unsupported_result() -> None: + router = CommandRouter("daemon") + + invocation = router.parse("/skills show demo") + + assert invocation is not None + assert invocation.command.name == "skills show" + result = router.unsupported_result(invocation) + assert result is not None + assert result.ok is False + assert "daemon" in result.title diff --git a/tests/test_tui_command_queue.py b/tests/test_tui_command_queue.py index d4cdefa..89cd1df 100644 --- a/tests/test_tui_command_queue.py +++ b/tests/test_tui_command_queue.py @@ -11,6 +11,7 @@ from leapflow.cli.tui_app.app import LeapApp from leapflow.cli.tui_app.console import LeapConsole from leapflow.cli.tui_app.command import TuiCommand, TuiCommandStatus +from leapflow.cli.tui_app.stream import StreamRenderer from leapflow.cli.tui_app.theme import _LIGHT, resolve_theme @@ -160,6 +161,70 @@ def print(self, renderable) -> None: assert "summarize the current project layout" in body +def test_stream_renderer_prints_tool_command_and_success_preview() -> None: + class CaptureConsole: + def __init__(self) -> None: + self.lines: list[str] = [] + + def print(self, renderable) -> None: + self.lines.append(getattr(renderable, "plain", str(renderable))) + + def thinking(self, text: str) -> None: + pass + + def markdown(self, text: str, *, indent: int = 0, margin_top: int = 0) -> None: + pass + + def response_label(self, elapsed_s: float, *, tool_count: int = 0) -> None: + pass + + def newline(self) -> None: + pass + + console = CaptureConsole() + renderer = StreamRenderer(console) # type: ignore[arg-type] + renderer.start() + + renderer.tool_started("shell_run", metadata={"command": "python -V"}) + renderer.tool_finished("shell_run", metadata={"ok": True, "stdout_preview": "Python 3.13.0"}) + + assert any("shell_run" in line and "$ python -V" in line for line in console.lines) + assert any("shell_run" in line and "Python 3.13.0" in line for line in console.lines) + + +def test_stream_renderer_prints_tool_failure_exit_and_stderr() -> None: + class CaptureConsole: + def __init__(self) -> None: + self.lines: list[str] = [] + + def print(self, renderable) -> None: + self.lines.append(getattr(renderable, "plain", str(renderable))) + + def thinking(self, text: str) -> None: + pass + + def markdown(self, text: str, *, indent: int = 0, margin_top: int = 0) -> None: + pass + + def response_label(self, elapsed_s: float, *, tool_count: int = 0) -> None: + pass + + def newline(self) -> None: + pass + + console = CaptureConsole() + renderer = StreamRenderer(console) # type: ignore[arg-type] + renderer.start() + + renderer.tool_started("shell_run", metadata={"command": "false"}) + renderer.tool_finished( + "shell_run", + metadata={"ok": False, "exit_code": 1, "stderr_preview": "permission denied"}, + ) + + assert any("✗" in line and "exit=1 permission denied" in line for line in console.lines) + + @pytest.mark.asyncio async def test_process_loop_marks_failed_commands_and_recovers_counts() -> None: async def on_input(text: str) -> None: From 3180c8fa813920ddd60ede5daeb1de7c5fcdc3f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8F=AD=E6=89=AC?= Date: Fri, 10 Jul 2026 18:19:55 +0800 Subject: [PATCH 07/17] Implement context governance for long tasks --- .env.example | 6 + src/leapflow/cli/tui_app/stream.py | 32 +- src/leapflow/config.py | 15 + src/leapflow/daemon/service.py | 33 +- src/leapflow/engine/context_control.py | 374 +++++++++++++++++++++++ src/leapflow/engine/engine.py | 195 ++++++++++-- src/leapflow/tools/file_operations.py | 115 ++++++- src/leapflow/tools/registry_bootstrap.py | 19 +- tests/test_context_governance.py | 139 +++++++++ tests/test_daemon_rpc.py | 13 + tests/test_tui_command_queue.py | 47 +++ 11 files changed, 937 insertions(+), 51 deletions(-) create mode 100644 src/leapflow/engine/context_control.py create mode 100644 tests/test_context_governance.py diff --git a/.env.example b/.env.example index baf98b9..35a47fc 100644 --- a/.env.example +++ b/.env.example @@ -186,6 +186,12 @@ LEAPFLOW_RECORDING_MODE=video # LEAPFLOW_COMPRESS_THRESHOLD=16 # LEAPFLOW_COMPRESS_KEEP_TAIL=4 # LEAPFLOW_MAX_TOOL_OUTPUT_CHARS=2000 +# LEAPFLOW_MAX_TOOL_RESULT_CHARS=3000 +# LEAPFLOW_CONTEXT_HARD_LIMIT_RATIO=0.92 +# LEAPFLOW_CONTEXT_WARNING_RATIO=0.75 +# LEAPFLOW_TOOL_EVIDENCE_MAX_CHARS=1200 +# LEAPFLOW_REPEATED_READ_LIMIT=2 +# LEAPFLOW_LONG_TASK_CONVERGENCE_ROUND=12 # LEAPFLOW_ERROR_TRANSIENT_MAX_RETRIES=3 # LEAPFLOW_ERROR_RATE_LIMIT_BASE_DELAY=5.0 diff --git a/src/leapflow/cli/tui_app/stream.py b/src/leapflow/cli/tui_app/stream.py index e7952e4..044db24 100644 --- a/src/leapflow/cli/tui_app/stream.py +++ b/src/leapflow/cli/tui_app/stream.py @@ -57,6 +57,23 @@ def _tool_action_detail(metadata: dict[str, Any] | None) -> str: return _truncate_detail(_metadata_text(metadata, "args_summary")) +def _tool_context_detail(metadata: dict[str, Any] | None) -> str: + if not metadata: + return "" + parts: list[str] = [] + mode = _metadata_text(metadata, "mode") + if mode and mode != "raw": + parts.append(f"mode={mode}") + if metadata.get("tool_truncated"): + parts.append("truncated") + read_count = metadata.get("read_count") + if metadata.get("repeat_read") and read_count is not None: + parts.append(f"repeat-read x{read_count}") + elif metadata.get("context_evidence"): + parts.append("evidence") + return ", ".join(parts) + + def _tool_result_detail(metadata: dict[str, Any] | None) -> str: if not metadata: return "" @@ -68,7 +85,9 @@ def _tool_result_detail(metadata: dict[str, Any] | None) -> str: or _metadata_text(metadata, "error_preview") or _metadata_text(metadata, "result_preview") ) - return _truncate_detail(prefix + detail) if detail or prefix else "failed" + base = _truncate_detail(prefix + detail) if detail or prefix else "failed" + context = _tool_context_detail(metadata) + return f"{base} ({context})" if context else base detail = ( _metadata_text(metadata, "stdout_preview") or _metadata_text(metadata, "content_preview") @@ -76,10 +95,13 @@ def _tool_result_detail(metadata: dict[str, Any] | None) -> str: or _metadata_text(metadata, "result_preview") ) if detail: - return _truncate_detail(detail) - if metadata.get("path"): - return f"path={_truncate_detail(str(metadata['path']))}" - return "ok (no output)" + base = _truncate_detail(detail) + elif metadata.get("path"): + base = f"path={_truncate_detail(str(metadata['path']))}" + else: + base = "ok (no output)" + context = _tool_context_detail(metadata) + return f"{base} ({context})" if context else base if TYPE_CHECKING: diff --git a/src/leapflow/config.py b/src/leapflow/config.py index a9d0563..994b9e0 100644 --- a/src/leapflow/config.py +++ b/src/leapflow/config.py @@ -340,6 +340,11 @@ class Settings: compress_keep_tail: int = 4 max_tool_output_chars: int = 2000 max_tool_result_chars: int = 3000 # Per-tool result truncation for LLM context + context_hard_limit_ratio: float = 0.92 + context_warning_ratio: float = 0.75 + tool_evidence_max_chars: int = 1200 + repeated_read_limit: int = 2 + long_task_convergence_round: int = 12 # ── Error Recovery ── error_transient_max_retries: int = 3 @@ -763,6 +768,11 @@ def _build_settings_from_env() -> Settings: compress_keep_tail = int(os.getenv("LEAPFLOW_COMPRESS_KEEP_TAIL", "4")) max_tool_output_chars = int(os.getenv("LEAPFLOW_MAX_TOOL_OUTPUT_CHARS", "2000")) max_tool_result_chars = int(os.getenv("LEAPFLOW_MAX_TOOL_RESULT_CHARS", "3000")) + context_hard_limit_ratio = float(os.getenv("LEAPFLOW_CONTEXT_HARD_LIMIT_RATIO", "0.92")) + context_warning_ratio = float(os.getenv("LEAPFLOW_CONTEXT_WARNING_RATIO", "0.75")) + tool_evidence_max_chars = int(os.getenv("LEAPFLOW_TOOL_EVIDENCE_MAX_CHARS", "1200")) + repeated_read_limit = int(os.getenv("LEAPFLOW_REPEATED_READ_LIMIT", "2")) + long_task_convergence_round = int(os.getenv("LEAPFLOW_LONG_TASK_CONVERGENCE_ROUND", "12")) # Error Recovery error_transient_max_retries = int(os.getenv("LEAPFLOW_ERROR_TRANSIENT_MAX_RETRIES", "3")) @@ -1011,6 +1021,11 @@ def _build_settings_from_env() -> Settings: compress_keep_tail=compress_keep_tail, max_tool_output_chars=max_tool_output_chars, max_tool_result_chars=max_tool_result_chars, + context_hard_limit_ratio=context_hard_limit_ratio, + context_warning_ratio=context_warning_ratio, + tool_evidence_max_chars=tool_evidence_max_chars, + repeated_read_limit=repeated_read_limit, + long_task_convergence_round=long_task_convergence_round, # Error Recovery error_transient_max_retries=error_transient_max_retries, error_rate_limit_base_delay=error_rate_limit_base_delay, diff --git a/src/leapflow/daemon/service.py b/src/leapflow/daemon/service.py index 36a2356..cff6924 100644 --- a/src/leapflow/daemon/service.py +++ b/src/leapflow/daemon/service.py @@ -99,9 +99,8 @@ async def engine_chat(self, message: str, **kwargs: Any) -> AsyncIterator[Stream content="Configuration reloaded — LLM settings updated in leapd.", event_type="status", metadata={ + **self._engine_context_metadata(getattr(ctx, "engine", None), ctx.settings), "llm_model": getattr(ctx.settings, "llm_model", ""), - "llm_context_length": getattr(ctx.settings, "llm_context_length", 0), - "context_used": getattr(getattr(ctx, "engine", None), "context_token_count", 0), }, ) engine = getattr(ctx, "engine", None) @@ -142,6 +141,7 @@ async def status(self) -> dict[str, Any]: db_holder = getattr(ctx, "_db_holder", None) if ctx is not None else None config_path = os.path.join(str(getattr(settings, "data_dir", "")), ".env") project_env_path = os.path.join(os.getcwd(), ".env") + context_metadata = self._engine_context_metadata(engine, settings) return { "pid": os.getpid(), "profile": getattr(settings, "profile", "default"), @@ -155,8 +155,9 @@ async def status(self) -> dict[str, Any]: "active_connections": max(0, self._client_count()), "connected_clients": len(self._client_leases()), "model": getattr(settings, "llm_model", ""), - "llm_context_length": getattr(settings, "llm_context_length", 0), - "context_used": getattr(engine, "context_token_count", 0) if engine is not None else 0, + "llm_context_length": context_metadata.get("llm_context_length", getattr(settings, "llm_context_length", 0)), + "context_used": context_metadata.get("context_used", 0), + "context_budget_snapshot": context_metadata.get("context_budget_snapshot", {}), "session_id": str(getattr(engine, "_current_session_id", "") or ""), "runtime_source": self._runtime_source(), "runtime_executable": sys.executable, @@ -256,6 +257,28 @@ def _runtime_version() -> str: return "unknown" return str(__version__) + def _engine_context_metadata(self, engine: Any | None, settings: Any) -> dict[str, Any]: + """Return safe context-budget metadata for daemon status and stream events.""" + context_length = max(0, int(getattr(settings, "llm_context_length", 0) or 0)) + metadata: dict[str, Any] = { + "llm_context_length": context_length, + "context_used": 0, + } + if engine is None: + return metadata + metadata["context_used"] = max(0, int(getattr(engine, "context_token_count", 0) or 0)) + snapshot = getattr(engine, "context_budget_snapshot", {}) + if callable(snapshot): + snapshot = snapshot() + if isinstance(snapshot, dict) and snapshot: + safe_snapshot = dict(snapshot) + if safe_snapshot.get("context_length"): + metadata["llm_context_length"] = max(1, int(safe_snapshot["context_length"])) + if safe_snapshot.get("total_tokens") is not None: + metadata["context_used"] = max(0, int(safe_snapshot["total_tokens"])) + metadata["context_budget_snapshot"] = safe_snapshot + return metadata + def _host_backend_status(self, ctx: Any | None) -> dict[str, Any]: if ctx is None: return {"backend": "none", "started": False, "reason": "runtime_not_initialized"} @@ -471,7 +494,7 @@ def _chunk_from_event(self, event: StreamEvent) -> StreamChunk: if session_id: metadata.setdefault("session_id", str(session_id)) if engine is not None: - metadata.setdefault("context_used", getattr(engine, "context_token_count", 0)) + metadata.update(self._engine_context_metadata(engine, getattr(ctx, "settings", self._settings))) return StreamChunk( request_id="", content=event.content, diff --git a/src/leapflow/engine/context_control.py b/src/leapflow/engine/context_control.py new file mode 100644 index 0000000..c0ce353 --- /dev/null +++ b/src/leapflow/engine/context_control.py @@ -0,0 +1,374 @@ +"""Context-window control primitives for long-running agent tasks. + +The module keeps context accounting, overflow prevention, and tool-result +compaction independent from AgentEngine so the execution loop can remain small +and policy can evolve without touching tool dispatch code. +""" +from __future__ import annotations + +import json +import logging +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Dict, List, Protocol, Sequence, runtime_checkable + +logger = logging.getLogger(__name__) + +_IMAGE_TOKEN_ESTIMATE = 1600 +_MESSAGE_OVERHEAD_TOKENS = 8 +_TOOL_SCHEMA_OVERHEAD_TOKENS = 12 +_DEFAULT_HEAD_RATIO = 0.55 +_DEFAULT_TAIL_RATIO = 0.25 + + +@runtime_checkable +class MessageCompressor(Protocol): + """Protocol for components that can aggressively shrink chat messages.""" + + def force_compress(self, messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """Return a smaller message list while preserving recent intent.""" + ... + + +@dataclass(frozen=True) +class ContextBudgetSnapshot: + """Observed prompt payload size before an LLM call.""" + + message_tokens: int + tool_schema_tokens: int + total_tokens: int + context_length: int + ratio: float + + @property + def percent(self) -> int: + """Rounded utilization percentage.""" + return int(self.ratio * 100) + + +@dataclass(frozen=True) +class ContextBudgetDecision: + """Result of preparing an LLM payload for the active context window.""" + + messages: List[Dict[str, Any]] + snapshot: ContextBudgetSnapshot + compressed: bool = False + forced_final_answer: bool = False + notice: str = "" + + +class ContextBudgetEstimator: + """Estimate provider-visible prompt tokens including tool schemas.""" + + def estimate_messages(self, messages: Sequence[Dict[str, Any]]) -> int: + """Estimate token usage for chat messages and tool-call envelopes.""" + if not messages: + return 0 + total = 3 + for message in messages: + total += _MESSAGE_OVERHEAD_TOKENS + total += self._estimate_value(message.get("role", "")) + total += self._estimate_value(message.get("content", "")) + total += self._estimate_tool_calls(message.get("tool_calls", [])) + total += self._estimate_value(message.get("tool_call_id", "")) + return max(1, total) + + def estimate_tools(self, tools: Any) -> int: + """Estimate token usage for native function/tool schemas.""" + if not tools: + return 0 + try: + text = json.dumps(tools, ensure_ascii=False, default=str, sort_keys=True) + except (TypeError, ValueError): + text = str(tools) + return _TOOL_SCHEMA_OVERHEAD_TOKENS + self._estimate_text(text) + + def snapshot( + self, + messages: Sequence[Dict[str, Any]], + *, + tools: Any = None, + context_length: int, + ) -> ContextBudgetSnapshot: + """Build a complete prompt-budget snapshot.""" + safe_context = max(1, int(context_length or 1)) + message_tokens = self.estimate_messages(messages) + tool_schema_tokens = self.estimate_tools(tools) + total_tokens = message_tokens + tool_schema_tokens + return ContextBudgetSnapshot( + message_tokens=message_tokens, + tool_schema_tokens=tool_schema_tokens, + total_tokens=total_tokens, + context_length=safe_context, + ratio=total_tokens / safe_context, + ) + + def _estimate_value(self, value: Any) -> int: + if value is None: + return 0 + if isinstance(value, str): + return self._estimate_text(value) + if isinstance(value, list): + return sum(self._estimate_content_part(item) for item in value) + if isinstance(value, dict): + try: + return self._estimate_text(json.dumps(value, ensure_ascii=False, default=str)) + except (TypeError, ValueError): + return self._estimate_text(str(value)) + return self._estimate_text(str(value)) + + def _estimate_content_part(self, item: Any) -> int: + if not isinstance(item, dict): + return self._estimate_value(item) + part_type = str(item.get("type", "")) + if part_type in {"image_url", "input_image", "image"}: + return _IMAGE_TOKEN_ESTIMATE + if part_type == "text": + return self._estimate_text(str(item.get("text", ""))) + return self._estimate_value(item) + + def _estimate_tool_calls(self, tool_calls: Any) -> int: + if not tool_calls: + return 0 + return self._estimate_value(tool_calls) + + @staticmethod + def _estimate_text(text: str) -> int: + if not text: + return 0 + cjk_count = sum( + 1 for ch in text if "\u4e00" <= ch <= "\u9fff" or "\u3000" <= ch <= "\u303f" + ) + latin_chars = len(text) - cjk_count + return max(1, cjk_count + latin_chars // 4) + + +class ContextWindowController: + """Apply budget-aware hard gates before provider calls.""" + + def __init__( + self, + *, + estimator: ContextBudgetEstimator | None = None, + hard_limit_ratio: float = 0.92, + warning_ratio: float = 0.75, + ) -> None: + self._estimator = estimator or ContextBudgetEstimator() + self._hard_limit_ratio = min(max(hard_limit_ratio, 0.50), 0.99) + self._warning_ratio = min(max(warning_ratio, 0.10), self._hard_limit_ratio) + + @property + def estimator(self) -> ContextBudgetEstimator: + """Return the estimator used by this controller.""" + return self._estimator + + def prepare( + self, + messages: List[Dict[str, Any]], + *, + tools: Any = None, + context_length: int, + compressor: MessageCompressor | None = None, + ) -> ContextBudgetDecision: + """Return messages that fit the active budget as safely as possible.""" + snapshot = self._estimator.snapshot(messages, tools=tools, context_length=context_length) + if snapshot.ratio < self._hard_limit_ratio: + return ContextBudgetDecision(messages=messages, snapshot=snapshot) + + compressed = False + prepared = messages + if compressor is not None: + prepared = compressor.force_compress(prepared) + compressed = True + snapshot = self._estimator.snapshot(prepared, tools=tools, context_length=context_length) + + forced_final = False + notice = "" + if snapshot.ratio >= self._hard_limit_ratio: + prepared = self._tail_preserving_drop(prepared) + compressed = True + forced_final = True + notice = ( + "SYSTEM: Context budget is critically high after compression. " + "Use the remaining evidence and provide the final answer now; " + "do not call more exploratory tools unless absolutely required." + ) + prepared.append({"role": "user", "content": notice}) + snapshot = self._estimator.snapshot(prepared, tools=tools, context_length=context_length) + + return ContextBudgetDecision( + messages=prepared, + snapshot=snapshot, + compressed=compressed, + forced_final_answer=forced_final, + notice=notice, + ) + + def warning_notice(self, snapshot: ContextBudgetSnapshot, *, round_number: int) -> str: + """Return a concise system notice when the payload is growing too large.""" + if snapshot.ratio < self._warning_ratio: + return "" + return ( + "SYSTEM: Context utilization is high " + f"({snapshot.total_tokens:,}/{snapshot.context_length:,} estimated tokens, " + f"round {round_number}). Prefer summaries, targeted reads, and final synthesis." + ) + + @staticmethod + def _tail_preserving_drop(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + if len(messages) <= 6: + return messages + head = messages[:1] if messages and messages[0].get("role") == "system" else [] + tail = messages[-5:] + dropped = max(0, len(messages) - len(head) - len(tail)) + notice = { + "role": "system", + "content": ( + f"[Context hard gate: {dropped} older messages compacted out. " + "Recent evidence and the current user request are authoritative.]" + ), + } + return head + [notice] + tail + + +class ToolEvidenceBuilder: + """Convert verbose tool outputs into compact evidence for LLM replay.""" + + def __init__(self, *, max_content_chars: int = 1200, max_items: int = 40) -> None: + self._max_content_chars = max(200, max_content_chars) + self._max_items = max(5, max_items) + + def build(self, tool_name: str, arguments: Dict[str, Any] | None, result: Any) -> Any: + """Return a compact, JSON-serializable result preserving task evidence.""" + if not isinstance(result, dict): + return self._compact_value(result) + if result.get("ok") is False: + return self._compact_error(result) + if tool_name in {"file_read", "gp_file_read"}: + return self._file_read_evidence(arguments or {}, result) + if tool_name in {"file_list", "gp_file_list"}: + return self._file_list_evidence(result) + if tool_name in {"shell_run", "gp_shell_run"}: + return self._shell_evidence(result) + return self._compact_mapping(result) + + def _file_read_evidence(self, arguments: Dict[str, Any], result: Dict[str, Any]) -> Dict[str, Any]: + content = str(result.get("content", "")) + excerpt = self._head_tail(content, self._max_content_chars) + evidence = { + "ok": True, + "kind": "file_read_evidence", + "path": result.get("path") or arguments.get("path", ""), + "lines": result.get("lines", 0), + "truncated": bool(result.get("truncated", False)), + "mode": result.get("mode") or arguments.get("mode") or "raw", + "excerpt": excerpt, + } + for key in ("start_line", "end_line", "selected_lines", "outline"): + if key in result: + evidence[key] = result[key] + return evidence + + def _file_list_evidence(self, result: Dict[str, Any]) -> Dict[str, Any]: + entries = result.get("entries", []) + compact_entries = entries[: self._max_items] if isinstance(entries, list) else [] + return { + "ok": True, + "kind": "file_list_evidence", + "path": result.get("path", ""), + "entries": compact_entries, + "entry_count": result.get("entry_count", len(entries) if isinstance(entries, list) else 0), + "truncated": bool(result.get("truncated", False) or (isinstance(entries, list) and len(entries) > len(compact_entries))), + } + + def _shell_evidence(self, result: Dict[str, Any]) -> Dict[str, Any]: + return { + "ok": bool(result.get("ok", True)), + "kind": "shell_evidence", + "exit_code": result.get("exit_code"), + "stdout": self._head_tail(str(result.get("stdout", "")), self._max_content_chars), + "stderr": self._head_tail(str(result.get("stderr", "")), self._max_content_chars // 2), + } + + def _compact_error(self, result: Dict[str, Any]) -> Dict[str, Any]: + return { + "ok": False, + "error": self._head_tail(str(result.get("error", "unknown error")), self._max_content_chars), + } + + def _compact_mapping(self, result: Dict[str, Any]) -> Dict[str, Any]: + compact: Dict[str, Any] = {} + for key, value in result.items(): + compact[key] = self._compact_value(value) + return compact + + def _compact_value(self, value: Any) -> Any: + if isinstance(value, str): + return self._head_tail(value, self._max_content_chars) + if isinstance(value, list): + return [self._compact_value(item) for item in value[: self._max_items]] + if isinstance(value, dict): + return {str(k): self._compact_value(v) for k, v in value.items()} + return value + + @staticmethod + def _head_tail(text: str, limit: int) -> str: + if len(text) <= limit: + return text + head = max(80, int(limit * _DEFAULT_HEAD_RATIO)) + tail = max(40, int(limit * _DEFAULT_TAIL_RATIO)) + omitted = len(text) - head - tail + return f"{text[:head]}\n\n[... {omitted:,} chars omitted ...]\n\n{text[-tail:]}" + + +@dataclass +class LongTaskContextController: + """Per-turn controller for exploration-heavy tasks.""" + + evidence_builder: ToolEvidenceBuilder + repeated_read_limit: int = 2 + convergence_round: int = 12 + + def __post_init__(self) -> None: + self._reads: dict[str, int] = {} + + def compact_tool_result(self, tool_name: str, arguments: Dict[str, Any] | None, result: Any) -> Any: + """Return evidence and update exploration ledger.""" + if tool_name in {"file_read", "gp_file_read"}: + path = str((arguments or {}).get("path") or (result.get("path") if isinstance(result, dict) else "")) + if path: + key = str(Path(path).expanduser()) + self._reads[key] = self._reads.get(key, 0) + 1 + return self.evidence_builder.build(tool_name, arguments, result) + + def tool_metadata(self, tool_name: str, arguments: Dict[str, Any] | None, result: Any) -> Dict[str, Any]: + """Build UX metadata about long-task context handling.""" + metadata: Dict[str, Any] = {} + evidence_tools = { + "file_read", "gp_file_read", + "file_list", "gp_file_list", + "shell_run", "gp_shell_run", + } + if tool_name in evidence_tools: + metadata["context_evidence"] = True + if tool_name in {"file_read", "gp_file_read"}: + path = str((arguments or {}).get("path") or "") + if path: + count = self._reads.get(str(Path(path).expanduser()), 0) + metadata["read_count"] = count + metadata["repeat_read"] = count > self.repeated_read_limit + if isinstance(result, dict): + if result.get("truncated"): + metadata["tool_truncated"] = True + if result.get("mode"): + metadata["mode"] = result.get("mode") + return metadata + + def convergence_notice(self, round_number: int) -> str: + """Return a notice that nudges synthesis after excessive exploration.""" + if round_number < self.convergence_round: + return "" + return ( + "SYSTEM: You have explored for many rounds. Stop broad reading, " + "deduplicate evidence already gathered, and synthesize the final answer." + ) diff --git a/src/leapflow/engine/engine.py b/src/leapflow/engine/engine.py index f76f4fe..7d0cb1e 100644 --- a/src/leapflow/engine/engine.py +++ b/src/leapflow/engine/engine.py @@ -16,6 +16,12 @@ from leapflow.config import Settings from leapflow.engine.budget import BudgetConfig, BudgetStatus, IterationBudget from leapflow.engine.context_compressor import CompressorConfig, ContextCompressor +from leapflow.engine.context_control import ( + ContextBudgetEstimator, + ContextWindowController, + LongTaskContextController, + ToolEvidenceBuilder, +) from leapflow.engine.error_classifier import ( ErrorCategory, ErrorClassifier, @@ -414,10 +420,24 @@ def __init__( ) ) self._compressor = ContextCompressor(CompressorConfig( + token_budget=max(1, int(settings.llm_context_length * settings.context_hard_limit_ratio)), threshold=settings.compress_threshold, keep_tail=settings.compress_keep_tail, max_output_chars=settings.max_tool_output_chars, )) + self._context_controller = ContextWindowController( + estimator=ContextBudgetEstimator(), + hard_limit_ratio=settings.context_hard_limit_ratio, + warning_ratio=settings.context_warning_ratio, + ) + self._long_task_controller = LongTaskContextController( + evidence_builder=ToolEvidenceBuilder( + max_content_chars=settings.tool_evidence_max_chars, + ), + repeated_read_limit=settings.repeated_read_limit, + convergence_round=settings.long_task_convergence_round, + ) + self._last_context_snapshot: dict[str, Any] = {} self._healer = MessageHealer() # B2: Prompt cache optimization (None = disabled) @@ -476,6 +496,24 @@ def reconfigure_runtime( self._llm = llm self._vlm = vlm self._classifier = classifier + self._compressor = ContextCompressor(CompressorConfig( + token_budget=max(1, int(settings.llm_context_length * settings.context_hard_limit_ratio)), + threshold=settings.compress_threshold, + keep_tail=settings.compress_keep_tail, + max_output_chars=settings.max_tool_output_chars, + )) + self._context_controller = ContextWindowController( + estimator=ContextBudgetEstimator(), + hard_limit_ratio=settings.context_hard_limit_ratio, + warning_ratio=settings.context_warning_ratio, + ) + self._long_task_controller = LongTaskContextController( + evidence_builder=ToolEvidenceBuilder( + max_content_chars=settings.tool_evidence_max_chars, + ), + repeated_read_limit=settings.repeated_read_limit, + convergence_round=settings.long_task_convergence_round, + ) self._skill_merger = SkillMerger( registry=self._registry, llm=llm, @@ -717,9 +755,97 @@ def turn_count(self) -> int: @property def context_token_count(self) -> int: - """Prompt tokens from the most recent API call (context utilization).""" + """Estimated provider-visible prompt tokens from the most recent API call.""" return self._last_context_tokens + @property + def context_budget_snapshot(self) -> dict[str, Any]: + """Last prompt-budget snapshot for status/daemon metadata.""" + return dict(self._last_context_snapshot) + + def _active_context_length(self) -> int: + """Return the runtime context length for the active model/provider.""" + if self._model_capabilities is not None: + try: + return max(1, int(self._model_capabilities.resolve(self._settings.llm_model).context_length)) + except Exception: + logger.debug("model capability lookup failed", exc_info=True) + return max(1, int(self._settings.llm_context_length)) + + def _prepare_llm_messages( + self, + messages: List[Dict[str, Any]], + *, + tools: Any = None, + round_number: int = 0, + ) -> List[Dict[str, Any]]: + """Compress and hard-gate messages before sending them to the provider.""" + context_length = self._active_context_length() + token_count = self._context_controller.estimator.estimate_messages(messages) + prepared = self._compressor.compress(messages, token_count=token_count) + prepared = self._compressor.preflight_check(prepared, context_length=context_length) + if self._cache_strategy: + prepared = self._cache_strategy.optimize(prepared) + decision = self._context_controller.prepare( + prepared, + tools=tools, + context_length=context_length, + compressor=self._compressor, + ) + prepared = decision.messages + warning = self._context_controller.warning_notice( + decision.snapshot, + round_number=round_number, + ) + convergence = self._long_task_controller.convergence_notice(round_number) + for notice in (warning, convergence): + if notice: + prepared = [*prepared, build_user_message_text(notice)] + snapshot = self._context_controller.estimator.snapshot( + prepared, + tools=tools, + context_length=context_length, + ) + self._last_context_tokens = snapshot.total_tokens + self._last_context_snapshot = { + "message_tokens": snapshot.message_tokens, + "tool_schema_tokens": snapshot.tool_schema_tokens, + "total_tokens": snapshot.total_tokens, + "context_length": snapshot.context_length, + "ratio": snapshot.ratio, + "compressed": decision.compressed, + "forced_final_answer": decision.forced_final_answer, + } + if decision.compressed: + self._usage_tracker.mark_compression() + return prepared + + def _record_provider_usage(self, model: str, usage: Dict[str, Any]) -> None: + """Prefer provider prompt usage when available and learn observed limits.""" + provider_prompt = int(usage.get("prompt_tokens", 0) or 0) + if provider_prompt > 0: + self._last_context_tokens = provider_prompt + self._last_context_snapshot = { + **self._last_context_snapshot, + "provider_prompt_tokens": provider_prompt, + "total_tokens": provider_prompt, + } + if self._model_capabilities and model and usage: + self._model_capabilities.update_from_usage(model, usage) + + def _compact_tool_result(self, tool_name: str, arguments: Dict[str, Any] | None, result: Any) -> Any: + """Return compact tool evidence for LLM replay.""" + return self._long_task_controller.compact_tool_result(tool_name, arguments, result) + + def _tool_context_metadata( + self, + tool_name: str, + arguments: Dict[str, Any] | None, + result: Any, + ) -> Dict[str, Any]: + """Return additional UI metadata from long-task context handling.""" + return self._long_task_controller.tool_metadata(tool_name, arguments, result) + async def run(self, user_text: str, *, enable_thinking: bool = False) -> str: """Entrypoint: simplified routing with unified tool loop as default path.""" self._session_turn_count += 1 @@ -1274,11 +1400,11 @@ async def _unified_tool_loop( self._inject_live_signals(messages, _signal_watermark) healed = self._healer.heal(messages) - compressed = self._compressor.compress(healed) - compressed = self._compressor.preflight_check(compressed) - if self._cache_strategy: - compressed = self._cache_strategy.optimize(compressed) - self._last_context_tokens = _estimate_prompt_tokens(compressed) + compressed = self._prepare_llm_messages( + healed, + tools=tools_kwarg.get("tools"), + round_number=budget.used, + ) _show_progress("thinking", f"round {budget.used}") try: @@ -1295,9 +1421,7 @@ async def _unified_tool_loop( ) provider_prompt = usage.get("prompt_tokens", 0) if provider_prompt > 0: - self._last_context_tokens = provider_prompt - if self._model_capabilities and resp.model and usage: - self._model_capabilities.update_from_usage(resp.model, usage) + self._record_provider_usage(resp.model or '', usage) except Exception as exc: _clear_indicator() classified = self._error_classifier.classify(exc) @@ -1400,8 +1524,8 @@ async def _unified_tool_loop( break else: recovery.record_tool_success() - - result_text = json.dumps(result, default=str, ensure_ascii=False)[:result_budget] + result_payload = self._compact_tool_result(tool_call['name'], tool_call.get("arguments"), result) + result_text = json.dumps(result_payload, default=str, ensure_ascii=False)[:result_budget] messages.append(build_user_message_text( f"Tool result ({tool_call['name']}):\n{result_text}" )) @@ -1630,11 +1754,11 @@ async def _unified_tool_loop_stream( self._inject_live_signals(messages, _signal_watermark) healed = self._healer.heal(messages) - compressed = self._compressor.compress(healed) - compressed = self._compressor.preflight_check(compressed) - if self._cache_strategy: - compressed = self._cache_strategy.optimize(compressed) - self._last_context_tokens = _estimate_prompt_tokens(compressed) + compressed = self._prepare_llm_messages( + healed, + tools=tools_kwarg.get("tools") if use_native_tools else None, + round_number=budget.used, + ) yield StreamEvent(type="thinking", content=f"round {budget.used}") @@ -1655,7 +1779,7 @@ async def _unified_tool_loop_stream( ) provider_prompt = usage.get("prompt_tokens", 0) if provider_prompt > 0: - self._last_context_tokens = provider_prompt + self._record_provider_usage(resp.model or '', usage) except Exception as exc: _clear_indicator() classified = self._error_classifier.classify(exc) @@ -1720,7 +1844,10 @@ async def _unified_tool_loop_stream( yield StreamEvent( type="tool_complete", content=tc.name, - metadata=_tool_result_metadata(tc.name, tc.arguments, item.get("result")), + metadata={ + **_tool_result_metadata(tc.name, tc.arguments, item.get("result")), + **self._tool_context_metadata(tc.name, tc.arguments, item.get("result")), + }, ) failures = self._count_consecutive_tool_failures(messages) @@ -1849,11 +1976,18 @@ async def _unified_tool_loop_stream( yield StreamEvent( type="tool_complete", content=tool_call['name'], - metadata=_tool_result_metadata( - tool_call['name'], - tool_call.get("arguments"), - result, - ), + metadata={ + **_tool_result_metadata( + tool_call['name'], + tool_call.get("arguments"), + result, + ), + **self._tool_context_metadata( + tool_call['name'], + tool_call.get("arguments"), + result, + ), + }, ) _print_tool_result(tool_call['name'], result, enabled=self._settings.verbose_progress) trace.record( @@ -1872,7 +2006,8 @@ async def _unified_tool_loop_stream( else: turn_recovery.record_tool_success() - result_text = json.dumps(result, default=str, ensure_ascii=False)[:result_budget] + result_payload = self._compact_tool_result(tool_call['name'], tool_call.get("arguments"), result) + result_text = json.dumps(result_payload, default=str, ensure_ascii=False)[:result_budget] messages.append(build_user_message_text( f"Tool result ({tool_call['name']}):\n{result_text}" )) @@ -1963,7 +2098,8 @@ async def _execute_tools_concurrent( action=tool_call_dict, observation=result if isinstance(result, dict) else {"result": str(result)}, ) - result_text = json.dumps(result, default=str, ensure_ascii=False)[:result_budget] + result_payload = self._compact_tool_result(tc.name, tc.arguments, result) + result_text = json.dumps(result_payload, default=str, ensure_ascii=False)[:result_budget] messages.append({"role": "tool", "tool_call_id": tc.id, "content": result_text}) executed.append({"id": tc.id, "name": tc.name, "arguments": tc.arguments, "result": result}) return executed @@ -1998,7 +2134,8 @@ async def _run_one(ctc: ConcurrentToolCall) -> Dict[str, Any]: action=tool_call_dict, observation=error_result, ) - result_text = json.dumps(error_result, default=str, ensure_ascii=False)[:result_budget] + result_payload = self._compact_tool_result(ctc.name, ctc.arguments, error_result) + result_text = json.dumps(result_payload, default=str, ensure_ascii=False)[:result_budget] else: _print_tool_result(ctc.name, result, enabled=self._settings.verbose_progress) trace.record( @@ -2006,7 +2143,8 @@ async def _run_one(ctc: ConcurrentToolCall) -> Dict[str, Any]: action=tool_call_dict, observation=result if isinstance(result, dict) else {"result": str(result)}, ) - result_text = json.dumps(result, default=str, ensure_ascii=False)[:result_budget] + result_payload = self._compact_tool_result(ctc.name, ctc.arguments, result) + result_text = json.dumps(result_payload, default=str, ensure_ascii=False)[:result_budget] messages.append({"role": "tool", "tool_call_id": ctc.id, "content": result_text}) executed.append({ "id": ctc.id, @@ -2026,7 +2164,8 @@ async def _run_one(ctc: ConcurrentToolCall) -> Dict[str, Any]: action=tool_call_dict, observation=result if isinstance(result, dict) else {"result": str(result)}, ) - result_text = json.dumps(result, default=str, ensure_ascii=False)[:result_budget] + result_payload = self._compact_tool_result(ctc.name, ctc.arguments, result) + result_text = json.dumps(result_payload, default=str, ensure_ascii=False)[:result_budget] messages.append({"role": "tool", "tool_call_id": ctc.id, "content": result_text}) executed.append({"id": ctc.id, "name": ctc.name, "arguments": ctc.arguments, "result": result}) return executed diff --git a/src/leapflow/tools/file_operations.py b/src/leapflow/tools/file_operations.py index b8767cc..8c4db27 100644 --- a/src/leapflow/tools/file_operations.py +++ b/src/leapflow/tools/file_operations.py @@ -13,7 +13,7 @@ import logging from pathlib import Path -from typing import Any, Dict, FrozenSet +from typing import Any, Dict, FrozenSet, Iterable logger = logging.getLogger(__name__) @@ -56,6 +56,67 @@ }) _MAX_READ_CHARS = 100_000 +_FILE_LIST_LIMIT = 100 +_FILE_READ_MODES = frozenset({"raw", "outline", "symbols"}) +_SYMBOL_PREFIXES = ( + "class ", "def ", "async def ", "function ", "const ", "let ", "var ", + "interface ", "type ", "enum ", "struct ", "trait ", "impl ", +) + + +def _safe_int(value: Any, default: int, *, minimum: int = 1, maximum: int | None = None) -> int: + try: + parsed = int(value) + except (TypeError, ValueError): + parsed = default + parsed = max(minimum, parsed) + if maximum is not None: + parsed = min(maximum, parsed) + return parsed + + +def _line_window(lines: list[str], *, start_line: int, max_lines: int) -> tuple[list[str], int, int]: + start = max(0, start_line - 1) + end = min(len(lines), start + max_lines) + return lines[start:end], start + 1, end + + +def _outline_lines(lines: Iterable[str], *, limit: int) -> list[tuple[int, str]]: + outline: list[tuple[int, str]] = [] + for index, line in enumerate(lines, start=1): + stripped = line.strip() + if not stripped: + continue + if stripped.startswith(('#', '##', '###', '- ', '* ')): + outline.append((index, f"{index}: {stripped}")) + elif stripped.endswith((':', '{')) and len(stripped) < 140: + outline.append((index, f"{index}: {stripped}")) + if len(outline) >= limit: + break + return outline + + +def _symbol_lines(lines: Iterable[str], *, limit: int) -> list[tuple[int, str]]: + symbols: list[tuple[int, str]] = [] + for index, line in enumerate(lines, start=1): + stripped = line.strip() + if not stripped: + continue + if stripped.startswith(_SYMBOL_PREFIXES) or stripped.startswith("@dataclass"): + symbols.append((index, f"{index}: {stripped}")) + if len(symbols) >= limit: + break + return symbols + + +def _read_text_window(path: Path, *, max_chars: int) -> tuple[str, bool]: + """Read at most max_chars characters plus one sentinel without loading huge files.""" + with path.open("r", encoding="utf-8", errors="replace") as handle: + raw = handle.read(max_chars + 1) + if len(raw) <= max_chars: + return raw, False + logger.debug("file_read: truncated to %d chars", max_chars) + return raw[:max_chars], True def _is_write_blocked(path: Path) -> bool: @@ -100,13 +161,25 @@ async def file_list(params: Dict[str, Any]) -> Dict[str, Any]: "size": item.stat().st_size if item.is_file() else None, }) - return {"ok": True, "path": str(target), "entries": entries[:100]} + visible = entries[:_FILE_LIST_LIMIT] + return { + "ok": True, + "path": str(target), + "entries": visible, + "entry_count": len(entries), + "truncated": len(entries) > len(visible), + } async def file_read(params: Dict[str, Any]) -> Dict[str, Any]: - """Read text file content with line limit and security guards.""" + """Read text file content with context-aware modes and security guards.""" path = params.get("path", "") - max_lines = int(params.get("max_lines", 200)) + max_lines = _safe_int(params.get("max_lines", 200), 200, minimum=1, maximum=2000) + start_line = _safe_int(params.get("start_line", 1), 1, minimum=1) + max_chars = _safe_int(params.get("max_chars", _MAX_READ_CHARS), _MAX_READ_CHARS, minimum=200, maximum=_MAX_READ_CHARS) + mode = str(params.get("mode", "raw") or "raw").strip().lower() + if mode not in _FILE_READ_MODES: + mode = "raw" if not path: return {"ok": False, "error": "Missing required parameter: path"} @@ -128,14 +201,30 @@ async def file_read(params: Dict[str, Any]) -> Dict[str, Any]: return {"ok": False, "error": f"Binary file cannot be read as text: {target.name}"} try: - raw = target.read_text(errors="replace") - if len(raw) > _MAX_READ_CHARS: - raw = raw[:_MAX_READ_CHARS] - logger.debug("file_read: truncated to %d chars", _MAX_READ_CHARS) + raw, raw_truncated = _read_text_window(target, max_chars=max_chars) lines = raw.splitlines() - truncated = len(lines) > max_lines - content = "\n".join(lines[:max_lines]) + selected_lines, selected_start, selected_end = _line_window( + lines, + start_line=start_line, + max_lines=max_lines, + ) + line_truncated = selected_end < len(lines) + + if mode == "outline": + outline = _outline_lines(lines, limit=max_lines) + content = "\n".join(text for _, text in outline) + selected_start = outline[0][0] if outline else 1 + selected_end = outline[-1][0] if outline else 0 + line_truncated = raw_truncated or len(outline) >= max_lines + elif mode == "symbols": + symbols = _symbol_lines(lines, limit=max_lines) + content = "\n".join(text for _, text in symbols) + selected_start = symbols[0][0] if symbols else 1 + selected_end = symbols[-1][0] if symbols else 0 + line_truncated = raw_truncated or len(symbols) >= max_lines + else: + content = "\n".join(selected_lines) try: from leapflow.security.redact import redact_sensitive_text @@ -157,7 +246,11 @@ async def file_read(params: Dict[str, Any]) -> Dict[str, Any]: "path": str(target), "content": content, "lines": len(lines), - "truncated": truncated, + "start_line": selected_start, + "end_line": selected_end, + "selected_lines": len(content.splitlines()) if content else 0, + "mode": mode, + "truncated": raw_truncated or line_truncated, } except Exception as e: return {"ok": False, "error": str(e)} diff --git a/src/leapflow/tools/registry_bootstrap.py b/src/leapflow/tools/registry_bootstrap.py index fd6359c..4e724d7 100644 --- a/src/leapflow/tools/registry_bootstrap.py +++ b/src/leapflow/tools/registry_bootstrap.py @@ -49,12 +49,22 @@ "type": "function", "function": { "name": "file_read", - "description": "Read the content of a text file.", + "description": ( + "Read the content of a text file. For long tasks over large files, " + "prefer mode='outline' or mode='symbols' first, then use mode='raw' " + "with start_line for the specific range you actually need." + ), "parameters": { "type": "object", "properties": { "path": {"type": "string", "description": "File path to read"}, "max_lines": {"type": "integer", "description": "Max lines to return (default: 200)"}, + "start_line": {"type": "integer", "description": "1-based line to start reading from (default: 1)"}, + "mode": { + "type": "string", + "enum": ["raw", "outline", "symbols"], + "description": "raw=exact lines, outline=headings/structure, symbols=class/function signatures", + }, }, "required": ["path"], }, @@ -241,10 +251,15 @@ }, { "name": "gp_file_read", - "description": "Read the content of a text file.", + "description": ( + "Read the content of a text file. Use mode='outline'/'symbols' for large " + "files before reading raw ranges, to reduce context usage in long tasks." + ), "parameters": { "path": "string (required) — file path to read", "max_lines": "integer (optional) — max lines to return (default: 200)", + "start_line": "integer (optional) — 1-based starting line (default: 1)", + "mode": "string (optional) — raw|outline|symbols (default: raw)", }, "handler": file_read, }, diff --git a/tests/test_context_governance.py b/tests/test_context_governance.py new file mode 100644 index 0000000..282e2de --- /dev/null +++ b/tests/test_context_governance.py @@ -0,0 +1,139 @@ +from __future__ import annotations + +from typing import Any + +import pytest + +from leapflow.engine.context_control import ( + ContextBudgetEstimator, + ContextWindowController, + LongTaskContextController, + ToolEvidenceBuilder, +) +from leapflow.tools.file_operations import file_read + + +class _NoopCompressor: + def force_compress(self, messages: list[dict[str, Any]]) -> list[dict[str, Any]]: + return messages + + +def test_context_estimator_counts_messages_and_tool_schemas() -> None: + estimator = ContextBudgetEstimator() + messages = [{"role": "user", "content": "hello 世界"}] + tools = [{"type": "function", "function": {"name": "demo", "description": "tool"}}] + + snapshot = estimator.snapshot(messages, tools=tools, context_length=1000) + + assert snapshot.message_tokens > 0 + assert snapshot.tool_schema_tokens > 0 + assert snapshot.total_tokens == snapshot.message_tokens + snapshot.tool_schema_tokens + assert 0 < snapshot.ratio < 1 + + +def test_context_window_controller_forces_final_answer_when_over_budget() -> None: + controller = ContextWindowController( + estimator=ContextBudgetEstimator(), + hard_limit_ratio=0.50, + warning_ratio=0.25, + ) + messages = [{"role": "user", "content": "x" * 400} for _ in range(10)] + + decision = controller.prepare( + messages, + context_length=100, + compressor=_NoopCompressor(), + ) + + assert decision.compressed is True + assert decision.forced_final_answer is True + assert any("final answer now" in str(item.get("content", "")) for item in decision.messages) + assert len(decision.messages) < len(messages) + 1 + + +def test_tool_evidence_builder_compacts_file_read_content() -> None: + builder = ToolEvidenceBuilder(max_content_chars=240) + result = { + "ok": True, + "path": "/tmp/example.py", + "content": "head\n" + "x" * 1000 + "\ntail", + "lines": 3, + "mode": "raw", + "start_line": 1, + "end_line": 3, + "selected_lines": 3, + "truncated": True, + } + + evidence = builder.build("file_read", {"path": "/tmp/example.py"}, result) + + assert evidence["kind"] == "file_read_evidence" + assert evidence["path"] == "/tmp/example.py" + assert evidence["truncated"] is True + assert "chars omitted" in evidence["excerpt"] + assert len(evidence["excerpt"]) < len(result["content"]) + + +def test_long_task_controller_reports_repeated_reads() -> None: + controller = LongTaskContextController( + evidence_builder=ToolEvidenceBuilder(max_content_chars=240), + repeated_read_limit=1, + convergence_round=2, + ) + args = {"path": "/tmp/repeated.py"} + result = {"ok": True, "path": "/tmp/repeated.py", "content": "print(1)", "mode": "raw"} + + controller.compact_tool_result("file_read", args, result) + controller.compact_tool_result("file_read", args, result) + metadata = controller.tool_metadata("file_read", args, result) + + assert metadata["context_evidence"] is True + assert metadata["read_count"] == 2 + assert metadata["repeat_read"] is True + assert "synthesize" in controller.convergence_notice(2) + + +def test_long_task_metadata_avoids_noise_for_uncompacted_tools() -> None: + controller = LongTaskContextController( + evidence_builder=ToolEvidenceBuilder(max_content_chars=240), + ) + + metadata = controller.tool_metadata("time_get", {}, {"ok": True, "result": "now"}) + + assert metadata == {} + + +@pytest.mark.asyncio +async def test_file_read_supports_range_outline_symbols_and_bounded_content(tmp_path) -> None: + source = tmp_path / "sample.py" + source.write_text( + "# Title\n" + "intro = 1\n" + "\n" + "class Demo:\n" + " def method(self):\n" + " return 'x'\n" + "\n" + "def helper():\n" + " return 'y'\n" + + "z" * 500, + encoding="utf-8", + ) + + raw = await file_read({"path": str(source), "start_line": 4, "max_lines": 2}) + outline = await file_read({"path": str(source), "mode": "outline", "max_lines": 5}) + symbols = await file_read({"path": str(source), "mode": "symbols", "max_lines": 5}) + bounded = await file_read({"path": str(source), "max_chars": 220, "max_lines": 2000}) + + assert raw["ok"] is True + assert raw["start_line"] == 4 + assert raw["end_line"] == 5 + assert "class Demo" in raw["content"] + assert outline["mode"] == "outline" + assert outline["selected_lines"] >= 2 + assert "# Title" in outline["content"] + assert symbols["mode"] == "symbols" + assert "class Demo" in symbols["content"] + assert "def helper" in symbols["content"] + assert bounded["truncated"] is True + assert len(bounded["content"]) <= 220 diff --git a/tests/test_daemon_rpc.py b/tests/test_daemon_rpc.py index 0430c61..8671973 100644 --- a/tests/test_daemon_rpc.py +++ b/tests/test_daemon_rpc.py @@ -644,6 +644,15 @@ def __init__(self) -> None: self.active = 0 self.max_active = 0 self.context_token_count = 2_048 + self.context_budget_snapshot = { + "message_tokens": 1_800, + "tool_schema_tokens": 248, + "total_tokens": 2_048, + "context_length": 16_000, + "ratio": 0.128, + "compressed": False, + "forced_final_answer": False, + } self._current_session_id = "sess-daemon" async def run_stream(self, message: str, *, enable_thinking: bool = False): @@ -677,9 +686,13 @@ async def collect(message: str) -> list: assert [event.content for event in first] == ["start:one", "done:one"] assert [event.content for event in second] == ["start:two", "done:two"] assert first[0].metadata["context_used"] == 2_048 + assert first[0].metadata["context_budget_snapshot"]["total_tokens"] == 2_048 + assert first[0].metadata["context_budget_snapshot"]["tool_schema_tokens"] == 248 + assert first[0].metadata["llm_context_length"] == 16_000 assert first[0].metadata["session_id"] == "sess-daemon" assert second[0].metadata["context_used"] == 2_048 assert status["context_used"] == 2_048 + assert status["context_budget_snapshot"]["total_tokens"] == 2_048 assert context.engine.max_active == 1 diff --git a/tests/test_tui_command_queue.py b/tests/test_tui_command_queue.py index 89cd1df..a1c4fca 100644 --- a/tests/test_tui_command_queue.py +++ b/tests/test_tui_command_queue.py @@ -225,6 +225,53 @@ def newline(self) -> None: assert any("✗" in line and "exit=1 permission denied" in line for line in console.lines) +def test_stream_renderer_prints_context_evidence_metadata() -> None: + class CaptureConsole: + def __init__(self) -> None: + self.lines: list[str] = [] + + def print(self, renderable) -> None: + self.lines.append(getattr(renderable, "plain", str(renderable))) + + def thinking(self, text: str) -> None: + pass + + def markdown(self, text: str, *, indent: int = 0, margin_top: int = 0) -> None: + pass + + def response_label(self, elapsed_s: float, *, tool_count: int = 0) -> None: + pass + + def newline(self) -> None: + pass + + console = CaptureConsole() + renderer = StreamRenderer(console) # type: ignore[arg-type] + renderer.start() + + renderer.tool_started("file_read", metadata={"path": "/tmp/sample.py"}) + renderer.tool_finished( + "file_read", + metadata={ + "ok": True, + "path": "/tmp/sample.py", + "mode": "symbols", + "context_evidence": True, + "tool_truncated": True, + "repeat_read": True, + "read_count": 3, + }, + ) + + assert any( + "file_read" in line + and "mode=symbols" in line + and "truncated" in line + and "repeat-read x3" in line + for line in console.lines + ) + + @pytest.mark.asyncio async def test_process_loop_marks_failed_commands_and_recovers_counts() -> None: async def on_input(text: str) -> None: From cea7372fa81edee9505dda597d84ebdfef1afbcd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8F=AD=E6=89=AC?= Date: Fri, 10 Jul 2026 20:33:16 +0800 Subject: [PATCH 08/17] feat(context): improve adaptive governance telemetry --- src/leapflow/cli/commands/interactive.py | 16 +- src/leapflow/cli/tui_app/status.py | 10 ++ src/leapflow/cli/tui_app/stream.py | 16 ++ src/leapflow/config.py | 18 +++ src/leapflow/daemon/service.py | 17 +++ src/leapflow/engine/context_compressor.py | 175 +++++++++++++++++++++- src/leapflow/engine/context_control.py | 161 +++++++++++++++++--- src/leapflow/engine/engine.py | 72 +++++++-- src/leapflow/tools/registry_bootstrap.py | 10 +- tests/test_context_governance.py | 74 +++++++++ tests/test_daemon_rpc.py | 13 ++ tests/test_tui_command_queue.py | 49 ++++++ tests/test_tui_theme.py | 20 ++- 13 files changed, 613 insertions(+), 38 deletions(-) diff --git a/src/leapflow/cli/commands/interactive.py b/src/leapflow/cli/commands/interactive.py index 0abe450..82e602c 100644 --- a/src/leapflow/cli/commands/interactive.py +++ b/src/leapflow/cli/commands/interactive.py @@ -290,9 +290,15 @@ def _platform_online() -> bool: def _update_status() -> None: ctx_used = 0 ctx_max = ctx.settings.llm_context_length + ctx_state = "baseline" engine = ctx.engine if engine is not None: ctx_used = getattr(engine, "context_token_count", 0) + snapshot = getattr(engine, "context_budget_snapshot", {}) + if callable(snapshot): + snapshot = snapshot() + if isinstance(snapshot, dict): + ctx_state = str(snapshot.get("context_posture") or "baseline") mode = _mode_name() status.update( mode=mode, @@ -302,6 +308,7 @@ def _update_status() -> None: session_turns=getattr(engine, "turn_count", 0) if engine else 0, context_used=ctx_used, context_max=ctx_max, + context_state=ctx_state, ) app.prompt_mode = mode @@ -734,6 +741,7 @@ async def cmd_interactive_daemon( runtime_model_name = str(getattr(settings, "llm_model", "")) runtime_context_length = int(getattr(settings, "llm_context_length", 0) or 0) runtime_context_used = 0 + runtime_context_state = "baseline" runtime_daemon_pid = "" runtime_host_online = False client_lease = ClientLease( @@ -744,7 +752,7 @@ async def cmd_interactive_daemon( def _apply_daemon_runtime_metadata(metadata: dict[str, Any]) -> None: nonlocal active_session_id, runtime_model_name, runtime_context_length - nonlocal runtime_context_used, runtime_daemon_pid, runtime_host_online + nonlocal runtime_context_used, runtime_context_state, runtime_daemon_pid, runtime_host_online if metadata.get("pid"): runtime_daemon_pid = str(metadata["pid"]) if metadata.get("session_id"): @@ -763,6 +771,11 @@ def _apply_daemon_runtime_metadata(metadata: dict[str, Any]) -> None: runtime_context_used = max(0, int(metadata["context_used"])) except (TypeError, ValueError): pass + if metadata.get("context_posture"): + runtime_context_state = str(metadata["context_posture"]) + elif isinstance(metadata.get("context_budget_snapshot"), dict): + snapshot = metadata["context_budget_snapshot"] + runtime_context_state = str(snapshot.get("context_posture") or runtime_context_state) host = metadata.get("host_backend") if isinstance(host, dict): runtime_host_online = _host_started(host) @@ -791,6 +804,7 @@ def _update_status() -> None: session_turns=turn_count, context_used=runtime_context_used, context_max=runtime_context_length, + context_state=runtime_context_state, ) app.prompt_mode = "daemon" diff --git a/src/leapflow/cli/tui_app/status.py b/src/leapflow/cli/tui_app/status.py index 27e1a95..a05c035 100644 --- a/src/leapflow/cli/tui_app/status.py +++ b/src/leapflow/cli/tui_app/status.py @@ -91,6 +91,7 @@ def __init__(self, theme: Optional[Theme | ResolvedTheme] = None) -> None: self.session_turns: int = 0 self.context_used: int = 0 self.context_max: int = 0 + self.context_state: str = "baseline" self.running_tasks: int = 0 self.queued_tasks: int = 0 self.last_turn_elapsed: float = 0.0 @@ -160,6 +161,12 @@ def __call__(self) -> list[tuple[str, str]]: bar = _progress_bar(pct) parts.append((bar_cls, f"[{bar}] ")) parts.append(("class:status-bar", f"{pct_text} ")) + if self.context_state and self.context_state not in {"baseline", "healthy"}: + state_cls = "class:status-bar" + if self.context_state in {"converging", "finalizing"}: + state_cls = "class:status-bar.warn" if self.context_state == "converging" else "class:status-bar.bad" + parts.append(("class:status-bar.dim", "· ")) + parts.append((state_cls, f"{_truncate(self.context_state, 12)} ")) parts.append(("class:status-bar.dim", "│ ")) elapsed = time.monotonic() - self._session_start @@ -189,6 +196,7 @@ def update( session_turns: Optional[int] = None, context_used: Optional[int] = None, context_max: Optional[int] = None, + context_state: Optional[str] = None, running_tasks: Optional[int] = None, queued_tasks: Optional[int] = None, ) -> None: @@ -207,6 +215,8 @@ def update( self.context_used = context_used if context_max is not None: self.context_max = context_max + if context_state is not None: + self.context_state = context_state if running_tasks is not None: self.running_tasks = running_tasks if queued_tasks is not None: diff --git a/src/leapflow/cli/tui_app/stream.py b/src/leapflow/cli/tui_app/stream.py index 044db24..ad33afe 100644 --- a/src/leapflow/cli/tui_app/stream.py +++ b/src/leapflow/cli/tui_app/stream.py @@ -66,6 +66,22 @@ def _tool_context_detail(metadata: dict[str, Any] | None) -> str: parts.append(f"mode={mode}") if metadata.get("tool_truncated"): parts.append("truncated") + stages = metadata.get("compression_stages") + if isinstance(stages, list) and stages: + stage_text = "+".join(str(stage) for stage in stages[:3]) + parts.append(f"compressed={stage_text}") + savings = metadata.get("compression_savings_ratio") + if isinstance(savings, (int, float)) and savings > 0: + parts.append(f"saved≈{int(savings * 100)}%") + reason = _metadata_text(metadata, "compression_reason") + if reason: + parts.append(reason) + posture = _metadata_text(metadata, "context_posture") + if posture and posture != "baseline": + parts.append(posture) + guidance = _metadata_text(metadata, "context_guidance") + if guidance: + parts.append(_truncate_detail(guidance, limit=72)) read_count = metadata.get("read_count") if metadata.get("repeat_read") and read_count is not None: parts.append(f"repeat-read x{read_count}") diff --git a/src/leapflow/config.py b/src/leapflow/config.py index 994b9e0..c55a1ef 100644 --- a/src/leapflow/config.py +++ b/src/leapflow/config.py @@ -345,6 +345,12 @@ class Settings: tool_evidence_max_chars: int = 1200 repeated_read_limit: int = 2 long_task_convergence_round: int = 12 + context_expanded_ratio: float = 0.60 + context_finalizing_ratio: float = 0.90 + context_expanded_evidence_threshold: int = 2 + context_expanded_tool_call_threshold: int = 3 + context_research_source_threshold: int = 3 + context_research_evidence_threshold: int = 5 # ── Error Recovery ── error_transient_max_retries: int = 3 @@ -773,6 +779,12 @@ def _build_settings_from_env() -> Settings: tool_evidence_max_chars = int(os.getenv("LEAPFLOW_TOOL_EVIDENCE_MAX_CHARS", "1200")) repeated_read_limit = int(os.getenv("LEAPFLOW_REPEATED_READ_LIMIT", "2")) long_task_convergence_round = int(os.getenv("LEAPFLOW_LONG_TASK_CONVERGENCE_ROUND", "12")) + context_expanded_ratio = float(os.getenv("LEAPFLOW_CONTEXT_EXPANDED_RATIO", "0.60")) + context_finalizing_ratio = float(os.getenv("LEAPFLOW_CONTEXT_FINALIZING_RATIO", "0.90")) + context_expanded_evidence_threshold = int(os.getenv("LEAPFLOW_CONTEXT_EXPANDED_EVIDENCE_THRESHOLD", "2")) + context_expanded_tool_call_threshold = int(os.getenv("LEAPFLOW_CONTEXT_EXPANDED_TOOL_CALL_THRESHOLD", "3")) + context_research_source_threshold = int(os.getenv("LEAPFLOW_CONTEXT_RESEARCH_SOURCE_THRESHOLD", "3")) + context_research_evidence_threshold = int(os.getenv("LEAPFLOW_CONTEXT_RESEARCH_EVIDENCE_THRESHOLD", "5")) # Error Recovery error_transient_max_retries = int(os.getenv("LEAPFLOW_ERROR_TRANSIENT_MAX_RETRIES", "3")) @@ -1026,6 +1038,12 @@ def _build_settings_from_env() -> Settings: tool_evidence_max_chars=tool_evidence_max_chars, repeated_read_limit=repeated_read_limit, long_task_convergence_round=long_task_convergence_round, + context_expanded_ratio=context_expanded_ratio, + context_finalizing_ratio=context_finalizing_ratio, + context_expanded_evidence_threshold=context_expanded_evidence_threshold, + context_expanded_tool_call_threshold=context_expanded_tool_call_threshold, + context_research_source_threshold=context_research_source_threshold, + context_research_evidence_threshold=context_research_evidence_threshold, # Error Recovery error_transient_max_retries=error_transient_max_retries, error_rate_limit_base_delay=error_rate_limit_base_delay, diff --git a/src/leapflow/daemon/service.py b/src/leapflow/daemon/service.py index cff6924..9b89c57 100644 --- a/src/leapflow/daemon/service.py +++ b/src/leapflow/daemon/service.py @@ -157,6 +157,11 @@ async def status(self) -> dict[str, Any]: "model": getattr(settings, "llm_model", ""), "llm_context_length": context_metadata.get("llm_context_length", getattr(settings, "llm_context_length", 0)), "context_used": context_metadata.get("context_used", 0), + "context_posture": context_metadata.get("context_posture", "baseline"), + "context_signal": context_metadata.get("context_signal", ""), + "context_guidance": context_metadata.get("context_guidance", ""), + "compression_reason": context_metadata.get("compression_reason", ""), + "compression_savings_ratio": context_metadata.get("compression_savings_ratio", 0.0), "context_budget_snapshot": context_metadata.get("context_budget_snapshot", {}), "session_id": str(getattr(engine, "_current_session_id", "") or ""), "runtime_source": self._runtime_source(), @@ -276,6 +281,18 @@ def _engine_context_metadata(self, engine: Any | None, settings: Any) -> dict[st metadata["llm_context_length"] = max(1, int(safe_snapshot["context_length"])) if safe_snapshot.get("total_tokens") is not None: metadata["context_used"] = max(0, int(safe_snapshot["total_tokens"])) + posture = safe_snapshot.get("context_posture") + if posture: + metadata["context_posture"] = str(posture) + signal = safe_snapshot.get("context_signal") + if signal: + metadata["context_signal"] = str(signal) + guidance = safe_snapshot.get("context_guidance") + if guidance: + metadata["context_guidance"] = str(guidance) + for key in ("compression_reason", "compression_savings_ratio", "compression_saved_tokens"): + if safe_snapshot.get(key) is not None: + metadata[key] = safe_snapshot[key] metadata["context_budget_snapshot"] = safe_snapshot return metadata diff --git a/src/leapflow/engine/context_compressor.py b/src/leapflow/engine/context_compressor.py index 2e661d0..95df4d1 100644 --- a/src/leapflow/engine/context_compressor.py +++ b/src/leapflow/engine/context_compressor.py @@ -17,7 +17,6 @@ import asyncio import hashlib -import json import logging from dataclasses import dataclass, field from typing import Any, Awaitable, Callable, Dict, List, Optional, Protocol, runtime_checkable @@ -544,6 +543,39 @@ def apply(self, messages: List[Dict[str, Any]], budget: int) -> List[Dict[str, A return head + [drop_notice] + tail +@dataclass(frozen=True) +class CompressionTrace: + """Observable summary of a compression pass for UI and audit metadata.""" + + stages_applied: List[str] = field(default_factory=list) + stage_effects: List[Dict[str, Any]] = field(default_factory=list) + tokens_before: int = 0 + tokens_after: int = 0 + messages_before: int = 0 + messages_after: int = 0 + savings_ratio: float = 0.0 + saved_tokens: int = 0 + forced: bool = False + preflight_truncated_messages: int = 0 + decision_reason: str = "" + + def as_dict(self) -> Dict[str, Any]: + """Return a compact JSON-serializable representation.""" + return { + "stages_applied": list(self.stages_applied), + "stage_effects": [dict(effect) for effect in self.stage_effects], + "tokens_before": self.tokens_before, + "tokens_after": self.tokens_after, + "messages_before": self.messages_before, + "messages_after": self.messages_after, + "savings_ratio": self.savings_ratio, + "saved_tokens": self.saved_tokens, + "forced": self.forced, + "preflight_truncated_messages": self.preflight_truncated_messages, + "decision_reason": self.decision_reason, + } + + class ContextCompressor: """Multi-stage context compression orchestrator. @@ -555,6 +587,12 @@ class ContextCompressor: def __init__(self, config: CompressorConfig = CompressorConfig()) -> None: self._config = config self._stages: List[CompressionStage] = self._build_stages(config) + self._last_trace = CompressionTrace() + + @property + def last_trace(self) -> CompressionTrace: + """Return the most recent compression trace.""" + return self._last_trace def _build_stages(self, config: CompressorConfig) -> List[CompressionStage]: """Build stage chain from config.""" @@ -588,21 +626,106 @@ def compress(self, messages: List[Dict[str, Any]], *, token_count: int = 0) -> L if token_count <= 0: token_count = self._count_tokens(messages) + tokens_before = token_count + messages_before = len(messages) + stages_applied: List[str] = [] + stage_effects: List[Dict[str, Any]] = [] for stage in self._stages: if stage.should_apply(messages, token_count, budget): + before_count = token_count + before_messages = len(messages) messages = stage.apply(messages, budget) - token_count = self._count_tokens(messages) - + next_count = self._count_tokens(messages) + changed = next_count != before_count or len(messages) != before_messages + if changed: + stages_applied.append(stage.name) + stage_effects.append({ + "stage": stage.name, + "tokens_before": before_count, + "tokens_after": next_count, + "messages_before": before_messages, + "messages_after": len(messages), + }) + token_count = next_count + + self._last_trace = self._build_trace( + stages_applied=stages_applied, + stage_effects=stage_effects, + tokens_before=tokens_before, + tokens_after=token_count, + messages_before=messages_before, + messages_after=len(messages), + decision_reason="threshold-triggered" if stages_applied else "within-budget", + ) return messages def force_compress(self, messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]: """Force all stages regardless of thresholds (for overflow recovery).""" budget = self._config.token_budget + original_tokens = self._count_tokens(messages) + current_tokens = original_tokens + messages_before = len(messages) + stages_applied: List[str] = [] + stage_effects: List[Dict[str, Any]] = [] for stage in self._stages: + before_count = current_tokens + before_messages = len(messages) messages = stage.apply(messages, budget) + next_count = self._count_tokens(messages) + changed = next_count != before_count or len(messages) != before_messages + if changed: + stages_applied.append(stage.name) + stage_effects.append({ + "stage": stage.name, + "tokens_before": before_count, + "tokens_after": next_count, + "messages_before": before_messages, + "messages_after": len(messages), + }) + current_tokens = next_count + tokens_after = self._count_tokens(messages) + self._last_trace = self._build_trace( + stages_applied=stages_applied, + stage_effects=stage_effects, + tokens_before=original_tokens, + tokens_after=tokens_after, + messages_before=messages_before, + messages_after=len(messages), + forced=True, + decision_reason="hard-gate-overflow", + ) return messages + @staticmethod + def _build_trace( + *, + stages_applied: List[str], + tokens_before: int, + tokens_after: int, + messages_before: int, + messages_after: int, + stage_effects: List[Dict[str, Any]] | None = None, + forced: bool = False, + preflight_truncated_messages: int = 0, + decision_reason: str = "", + ) -> CompressionTrace: + saved = max(0, tokens_before - tokens_after) + savings_ratio = saved / tokens_before if tokens_before > 0 else 0.0 + return CompressionTrace( + stages_applied=stages_applied, + stage_effects=stage_effects or [], + tokens_before=max(0, tokens_before), + tokens_after=max(0, tokens_after), + messages_before=max(0, messages_before), + messages_after=max(0, messages_after), + savings_ratio=savings_ratio, + saved_tokens=saved, + forced=forced, + preflight_truncated_messages=max(0, preflight_truncated_messages), + decision_reason=decision_reason, + ) + def _count_tokens(self, messages: List[Dict[str, Any]]) -> int: """Count tokens using external tokenizer if available, else estimate.""" if self._config.token_count_fn is not None: @@ -635,7 +758,7 @@ def preflight_check( Strategy: truncate any non-system message exceeding huge_message_chars, preserving head + tail with a truncation notice. """ - modified = False + truncated_count = 0 result = [] for msg in messages: if msg.get("role") == "system": @@ -656,15 +779,57 @@ def preflight_check( ) new_msg = dict(msg, content=truncated_content) result.append(new_msg) - modified = True + truncated_count += 1 logger.info( "preflight: truncated %s message from %d to %d chars", msg.get("role", "?"), len(content), len(truncated_content), ) estimated_tokens = self._count_tokens(result) + preflight_messages_after = len(result) if estimated_tokens > context_length * 0.9: result = self.force_compress(result) + previous = self._last_trace + self._last_trace = CompressionTrace( + stages_applied=[*(['preflight'] if truncated_count else []), *previous.stages_applied], + stage_effects=[ + *([{ + "stage": "preflight", + "tokens_before": self._count_tokens(messages), + "tokens_after": estimated_tokens, + "messages_before": len(messages), + "messages_after": preflight_messages_after, + }] if truncated_count else []), + *previous.stage_effects, + ], + tokens_before=max(previous.tokens_before, estimated_tokens), + tokens_after=previous.tokens_after, + messages_before=max(previous.messages_before, len(messages)), + messages_after=previous.messages_after, + savings_ratio=previous.savings_ratio, + saved_tokens=previous.saved_tokens, + forced=previous.forced, + preflight_truncated_messages=truncated_count, + decision_reason="huge-message-preflight+hard-gate-overflow" if truncated_count else previous.decision_reason, + ) + elif truncated_count: + tokens_before = self._count_tokens(messages) + self._last_trace = self._build_trace( + stages_applied=["preflight"], + stage_effects=[{ + "stage": "preflight", + "tokens_before": tokens_before, + "tokens_after": estimated_tokens, + "messages_before": len(messages), + "messages_after": len(result), + }], + tokens_before=tokens_before, + tokens_after=estimated_tokens, + messages_before=len(messages), + messages_after=len(result), + preflight_truncated_messages=truncated_count, + decision_reason="huge-message-preflight", + ) return result diff --git a/src/leapflow/engine/context_control.py b/src/leapflow/engine/context_control.py index c0ce353..f7d00fd 100644 --- a/src/leapflow/engine/context_control.py +++ b/src/leapflow/engine/context_control.py @@ -1,14 +1,14 @@ -"""Context-window control primitives for long-running agent tasks. +"""Adaptive context governance primitives for every agent interaction. -The module keeps context accounting, overflow prevention, and tool-result -compaction independent from AgentEngine so the execution loop can remain small -and policy can evolve without touching tool dispatch code. +The module keeps context accounting, overflow prevention, exploration-ledger +tracking, and tool-result compaction independent from AgentEngine so all normal +interactions get long-task resilience without exposing a separate user mode. """ from __future__ import annotations import json import logging -from dataclasses import dataclass +from dataclasses import dataclass, field from pathlib import Path from typing import Any, Dict, List, Protocol, Sequence, runtime_checkable @@ -19,6 +19,16 @@ _TOOL_SCHEMA_OVERHEAD_TOKENS = 12 _DEFAULT_HEAD_RATIO = 0.55 _DEFAULT_TAIL_RATIO = 0.25 +_EVIDENCE_TOOLS = frozenset({ + "file_read", "gp_file_read", + "file_list", "gp_file_list", + "shell_run", "gp_shell_run", +}) +_POSTURE_BASELINE = "baseline" +_POSTURE_EXPANDED = "expanded" +_POSTURE_RESEARCH = "research" +_POSTURE_CONVERGING = "converging" +_POSTURE_FINALIZING = "finalizing" @runtime_checkable @@ -321,35 +331,95 @@ def _head_tail(text: str, limit: int) -> str: return f"{text[:head]}\n\n[... {omitted:,} chars omitted ...]\n\n{text[-tail:]}" +@dataclass(frozen=True) +class ContextPostureConfig: + """Configurable thresholds for adaptive context-governance posture.""" + + expanded_ratio: float = 0.60 + finalizing_ratio: float = 0.90 + expanded_evidence_threshold: int = 2 + expanded_tool_call_threshold: int = 3 + research_source_threshold: int = 3 + research_evidence_threshold: int = 5 + + +@dataclass(frozen=True) +class ExplorationSnapshot: + """Compact user-visible state for the adaptive exploration ledger.""" + + posture: str = _POSTURE_BASELINE + sources_seen: int = 0 + evidence_count: int = 0 + repeated_reads: int = 0 + tool_calls: int = 0 + dominant_signal: str = "" + should_converge: bool = False + convergence_reason: str = "" + guidance: str = "" + + def as_dict(self) -> Dict[str, Any]: + """Return a JSON-serializable snapshot for daemon/TUI metadata.""" + return { + "posture": self.posture, + "sources_seen": self.sources_seen, + "evidence_count": self.evidence_count, + "repeated_reads": self.repeated_reads, + "tool_calls": self.tool_calls, + "dominant_signal": self.dominant_signal, + "should_converge": self.should_converge, + "convergence_reason": self.convergence_reason, + "guidance": self.guidance, + } + + @dataclass -class LongTaskContextController: - """Per-turn controller for exploration-heavy tasks.""" +class ContextGovernanceController: + """Adaptive exploration ledger for all interactions, not a separate mode.""" evidence_builder: ToolEvidenceBuilder repeated_read_limit: int = 2 convergence_round: int = 12 + posture_config: ContextPostureConfig = field(default_factory=ContextPostureConfig) + evidence_tools: frozenset[str] = _EVIDENCE_TOOLS + research_source_threshold: int | None = None + research_evidence_threshold: int | None = None def __post_init__(self) -> None: + if self.research_source_threshold is not None or self.research_evidence_threshold is not None: + self.posture_config = ContextPostureConfig( + expanded_ratio=self.posture_config.expanded_ratio, + finalizing_ratio=self.posture_config.finalizing_ratio, + expanded_evidence_threshold=self.posture_config.expanded_evidence_threshold, + expanded_tool_call_threshold=self.posture_config.expanded_tool_call_threshold, + research_source_threshold=self.research_source_threshold or self.posture_config.research_source_threshold, + research_evidence_threshold=self.research_evidence_threshold or self.posture_config.research_evidence_threshold, + ) self._reads: dict[str, int] = {} + self._sources_seen: set[str] = set() + self._tool_counts: dict[str, int] = {} + self._evidence_count = 0 def compact_tool_result(self, tool_name: str, arguments: Dict[str, Any] | None, result: Any) -> Any: - """Return evidence and update exploration ledger.""" + """Return evidence and update the session exploration ledger.""" + self._tool_counts[tool_name] = self._tool_counts.get(tool_name, 0) + 1 + if tool_name in self.evidence_tools: + self._evidence_count += 1 if tool_name in {"file_read", "gp_file_read"}: path = str((arguments or {}).get("path") or (result.get("path") if isinstance(result, dict) else "")) if path: key = str(Path(path).expanduser()) self._reads[key] = self._reads.get(key, 0) + 1 + self._sources_seen.add(key) + elif tool_name in {"file_list", "gp_file_list"}: + path = str((arguments or {}).get("path") or (result.get("path") if isinstance(result, dict) else "")) + if path: + self._sources_seen.add(str(Path(path).expanduser())) return self.evidence_builder.build(tool_name, arguments, result) def tool_metadata(self, tool_name: str, arguments: Dict[str, Any] | None, result: Any) -> Dict[str, Any]: - """Build UX metadata about long-task context handling.""" + """Build UX metadata about adaptive context handling.""" metadata: Dict[str, Any] = {} - evidence_tools = { - "file_read", "gp_file_read", - "file_list", "gp_file_list", - "shell_run", "gp_shell_run", - } - if tool_name in evidence_tools: + if tool_name in self.evidence_tools: metadata["context_evidence"] = True if tool_name in {"file_read", "gp_file_read"}: path = str((arguments or {}).get("path") or "") @@ -362,13 +432,68 @@ def tool_metadata(self, tool_name: str, arguments: Dict[str, Any] | None, result metadata["tool_truncated"] = True if result.get("mode"): metadata["mode"] = result.get("mode") + ledger = self.snapshot() + if metadata and ledger.posture != _POSTURE_BASELINE: + metadata["context_posture"] = ledger.posture + metadata["context_signal"] = ledger.dominant_signal + if ledger.guidance: + metadata["context_guidance"] = ledger.guidance return metadata + def snapshot(self, *, context_ratio: float = 0.0, round_number: int = 0) -> ExplorationSnapshot: + """Return the current adaptive-governance posture without exposing a mode.""" + cfg = self.posture_config + repeated_reads = sum(1 for count in self._reads.values() if count > self.repeated_read_limit) + tool_calls = sum(self._tool_counts.values()) + sources_seen = len(self._sources_seen) + dominant_signal = "" + posture = _POSTURE_BASELINE + guidance = "" + convergence_reason = "" + + if context_ratio >= cfg.finalizing_ratio: + posture = _POSTURE_FINALIZING + dominant_signal = "context-critical" + convergence_reason = "context budget is critical" + guidance = "finalize with existing evidence" + elif repeated_reads > 0 or round_number >= self.convergence_round: + posture = _POSTURE_CONVERGING + dominant_signal = "repeat-read" if repeated_reads > 0 else "long-exploration" + convergence_reason = "repeat reads detected" if repeated_reads > 0 else "exploration round limit reached" + guidance = "deduplicate evidence and prefer targeted reads" + elif sources_seen >= cfg.research_source_threshold or self._evidence_count >= cfg.research_evidence_threshold: + posture = _POSTURE_RESEARCH + dominant_signal = "multi-source" if sources_seen >= cfg.research_source_threshold else "evidence-volume" + guidance = "maintain research ledger and synthesize findings" + elif context_ratio >= cfg.expanded_ratio or self._evidence_count >= cfg.expanded_evidence_threshold or tool_calls >= cfg.expanded_tool_call_threshold: + posture = _POSTURE_EXPANDED + dominant_signal = "context-growing" if context_ratio >= cfg.expanded_ratio else "tool-activity" + guidance = "prefer outline, symbols, or range reads before raw content" + + should_converge = posture in {_POSTURE_CONVERGING, _POSTURE_FINALIZING} + return ExplorationSnapshot( + posture=posture, + sources_seen=sources_seen, + evidence_count=self._evidence_count, + repeated_reads=repeated_reads, + tool_calls=tool_calls, + dominant_signal=dominant_signal, + should_converge=should_converge, + convergence_reason=convergence_reason, + guidance=guidance, + ) + def convergence_notice(self, round_number: int) -> str: """Return a notice that nudges synthesis after excessive exploration.""" - if round_number < self.convergence_round: + snapshot = self.snapshot(round_number=round_number) + if not snapshot.should_converge: return "" + reason = snapshot.convergence_reason or snapshot.dominant_signal or "context pressure" return ( - "SYSTEM: You have explored for many rounds. Stop broad reading, " - "deduplicate evidence already gathered, and synthesize the final answer." + "SYSTEM: Adaptive context governance is converging " + f"({reason}). Stop broad reading, deduplicate evidence already gathered, " + "prefer targeted reads, and synthesize the final answer." ) + + +LongTaskContextController = ContextGovernanceController diff --git a/src/leapflow/engine/engine.py b/src/leapflow/engine/engine.py index 7d0cb1e..6251fa3 100644 --- a/src/leapflow/engine/engine.py +++ b/src/leapflow/engine/engine.py @@ -18,8 +18,9 @@ from leapflow.engine.context_compressor import CompressorConfig, ContextCompressor from leapflow.engine.context_control import ( ContextBudgetEstimator, + ContextGovernanceController, + ContextPostureConfig, ContextWindowController, - LongTaskContextController, ToolEvidenceBuilder, ) from leapflow.engine.error_classifier import ( @@ -430,12 +431,20 @@ def __init__( hard_limit_ratio=settings.context_hard_limit_ratio, warning_ratio=settings.context_warning_ratio, ) - self._long_task_controller = LongTaskContextController( + self._context_governance_controller = ContextGovernanceController( evidence_builder=ToolEvidenceBuilder( max_content_chars=settings.tool_evidence_max_chars, ), repeated_read_limit=settings.repeated_read_limit, convergence_round=settings.long_task_convergence_round, + posture_config=ContextPostureConfig( + expanded_ratio=settings.context_expanded_ratio, + finalizing_ratio=settings.context_finalizing_ratio, + expanded_evidence_threshold=settings.context_expanded_evidence_threshold, + expanded_tool_call_threshold=settings.context_expanded_tool_call_threshold, + research_source_threshold=settings.context_research_source_threshold, + research_evidence_threshold=settings.context_research_evidence_threshold, + ), ) self._last_context_snapshot: dict[str, Any] = {} self._healer = MessageHealer() @@ -507,12 +516,20 @@ def reconfigure_runtime( hard_limit_ratio=settings.context_hard_limit_ratio, warning_ratio=settings.context_warning_ratio, ) - self._long_task_controller = LongTaskContextController( + self._context_governance_controller = ContextGovernanceController( evidence_builder=ToolEvidenceBuilder( max_content_chars=settings.tool_evidence_max_chars, ), repeated_read_limit=settings.repeated_read_limit, convergence_round=settings.long_task_convergence_round, + posture_config=ContextPostureConfig( + expanded_ratio=settings.context_expanded_ratio, + finalizing_ratio=settings.context_finalizing_ratio, + expanded_evidence_threshold=settings.context_expanded_evidence_threshold, + expanded_tool_call_threshold=settings.context_expanded_tool_call_threshold, + research_source_threshold=settings.context_research_source_threshold, + research_evidence_threshold=settings.context_research_evidence_threshold, + ), ) self._skill_merger = SkillMerger( registry=self._registry, @@ -783,6 +800,7 @@ def _prepare_llm_messages( context_length = self._active_context_length() token_count = self._context_controller.estimator.estimate_messages(messages) prepared = self._compressor.compress(messages, token_count=token_count) + compression_trace = self._compressor.last_trace.as_dict() prepared = self._compressor.preflight_check(prepared, context_length=context_length) if self._cache_strategy: prepared = self._cache_strategy.optimize(prepared) @@ -793,11 +811,12 @@ def _prepare_llm_messages( compressor=self._compressor, ) prepared = decision.messages + compression_trace = self._compressor.last_trace.as_dict() warning = self._context_controller.warning_notice( decision.snapshot, round_number=round_number, ) - convergence = self._long_task_controller.convergence_notice(round_number) + convergence = self._context_governance_controller.convergence_notice(round_number) for notice in (warning, convergence): if notice: prepared = [*prepared, build_user_message_text(notice)] @@ -806,6 +825,11 @@ def _prepare_llm_messages( tools=tools, context_length=context_length, ) + governance = self._context_governance_controller.snapshot( + context_ratio=snapshot.ratio, + round_number=round_number, + ).as_dict() + compressed = decision.compressed or bool(compression_trace.get("stages_applied")) self._last_context_tokens = snapshot.total_tokens self._last_context_snapshot = { "message_tokens": snapshot.message_tokens, @@ -813,10 +837,19 @@ def _prepare_llm_messages( "total_tokens": snapshot.total_tokens, "context_length": snapshot.context_length, "ratio": snapshot.ratio, - "compressed": decision.compressed, + "compressed": compressed, "forced_final_answer": decision.forced_final_answer, + "compression_trace": compression_trace, + "compression_reason": compression_trace.get("decision_reason", ""), + "compression_savings_ratio": compression_trace.get("savings_ratio", 0.0), + "compression_saved_tokens": compression_trace.get("saved_tokens", 0), + "context_governance": governance, + "context_posture": governance.get("posture", "baseline"), + "context_signal": governance.get("dominant_signal", ""), + "context_guidance": governance.get("guidance", ""), + "context_convergence_reason": governance.get("convergence_reason", ""), } - if decision.compressed: + if compressed: self._usage_tracker.mark_compression() return prepared @@ -829,13 +862,14 @@ def _record_provider_usage(self, model: str, usage: Dict[str, Any]) -> None: **self._last_context_snapshot, "provider_prompt_tokens": provider_prompt, "total_tokens": provider_prompt, + "ratio": provider_prompt / max(1, int(self._last_context_snapshot.get("context_length") or 1)), } if self._model_capabilities and model and usage: self._model_capabilities.update_from_usage(model, usage) def _compact_tool_result(self, tool_name: str, arguments: Dict[str, Any] | None, result: Any) -> Any: """Return compact tool evidence for LLM replay.""" - return self._long_task_controller.compact_tool_result(tool_name, arguments, result) + return self._context_governance_controller.compact_tool_result(tool_name, arguments, result) def _tool_context_metadata( self, @@ -843,8 +877,28 @@ def _tool_context_metadata( arguments: Dict[str, Any] | None, result: Any, ) -> Dict[str, Any]: - """Return additional UI metadata from long-task context handling.""" - return self._long_task_controller.tool_metadata(tool_name, arguments, result) + """Return additional UI metadata from adaptive context handling.""" + metadata = self._context_governance_controller.tool_metadata(tool_name, arguments, result) + snapshot = self._last_context_snapshot + if snapshot: + posture = snapshot.get("context_posture") + if posture and posture != "baseline": + metadata.setdefault("context_posture", posture) + signal = snapshot.get("context_signal") + if signal: + metadata.setdefault("context_signal", signal) + guidance = snapshot.get("context_guidance") + if guidance: + metadata.setdefault("context_guidance", guidance) + trace = snapshot.get("compression_trace") + if isinstance(trace, dict) and trace.get("stages_applied"): + metadata.setdefault("compression_stages", trace.get("stages_applied")) + metadata.setdefault("compression_savings_ratio", trace.get("savings_ratio", 0.0)) + metadata.setdefault("compression_saved_tokens", trace.get("saved_tokens", 0)) + metadata.setdefault("compression_reason", trace.get("decision_reason", "")) + if snapshot.get("forced_final_answer"): + metadata.setdefault("context_posture", "finalizing") + return metadata async def run(self, user_text: str, *, enable_thinking: bool = False) -> str: """Entrypoint: simplified routing with unified tool loop as default path.""" diff --git a/src/leapflow/tools/registry_bootstrap.py b/src/leapflow/tools/registry_bootstrap.py index 4e724d7..625ee19 100644 --- a/src/leapflow/tools/registry_bootstrap.py +++ b/src/leapflow/tools/registry_bootstrap.py @@ -50,9 +50,9 @@ "function": { "name": "file_read", "description": ( - "Read the content of a text file. For long tasks over large files, " + "Read text file content with adaptive context governance. For large or unfamiliar files, " "prefer mode='outline' or mode='symbols' first, then use mode='raw' " - "with start_line for the specific range you actually need." + "with start_line/max_lines for the specific range you actually need." ), "parameters": { "type": "object", @@ -60,6 +60,7 @@ "path": {"type": "string", "description": "File path to read"}, "max_lines": {"type": "integer", "description": "Max lines to return (default: 200)"}, "start_line": {"type": "integer", "description": "1-based line to start reading from (default: 1)"}, + "max_chars": {"type": "integer", "description": "Max characters to read before line filtering (default bounded by runtime guard)"}, "mode": { "type": "string", "enum": ["raw", "outline", "symbols"], @@ -252,13 +253,14 @@ { "name": "gp_file_read", "description": ( - "Read the content of a text file. Use mode='outline'/'symbols' for large " - "files before reading raw ranges, to reduce context usage in long tasks." + "Read text file content with adaptive context governance. Use mode='outline'/'symbols' for large " + "or unfamiliar files before reading raw ranges, to reduce context usage by default." ), "parameters": { "path": "string (required) — file path to read", "max_lines": "integer (optional) — max lines to return (default: 200)", "start_line": "integer (optional) — 1-based starting line (default: 1)", + "max_chars": "integer (optional) — max characters to read before line filtering", "mode": "string (optional) — raw|outline|symbols (default: raw)", }, "handler": file_read, diff --git a/tests/test_context_governance.py b/tests/test_context_governance.py index 282e2de..02a95a3 100644 --- a/tests/test_context_governance.py +++ b/tests/test_context_governance.py @@ -4,8 +4,11 @@ import pytest +from leapflow.engine.context_compressor import CompressorConfig, ContextCompressor from leapflow.engine.context_control import ( ContextBudgetEstimator, + ContextGovernanceController, + ContextPostureConfig, ContextWindowController, LongTaskContextController, ToolEvidenceBuilder, @@ -51,6 +54,29 @@ def test_context_window_controller_forces_final_answer_when_over_budget() -> Non assert len(decision.messages) < len(messages) + 1 +def test_context_compressor_records_transparent_trace() -> None: + compressor = ContextCompressor(CompressorConfig( + token_budget=100, + max_output_chars=120, + enabled_stages=["trim"], + )) + messages = [ + {"role": "system", "content": "system"}, + {"role": "tool", "content": "A" * 1_000}, + ] + + prepared = compressor.compress(messages) + trace = compressor.last_trace.as_dict() + + assert prepared[1]["content"] != messages[1]["content"] + assert trace["stages_applied"] == ["trim"] + assert trace["stage_effects"][0]["stage"] == "trim" + assert trace["decision_reason"] == "threshold-triggered" + assert trace["tokens_after"] < trace["tokens_before"] + assert trace["saved_tokens"] > 0 + assert trace["savings_ratio"] > 0 + + def test_tool_evidence_builder_compacts_file_read_content() -> None: builder = ToolEvidenceBuilder(max_content_chars=240) result = { @@ -103,6 +129,54 @@ def test_long_task_metadata_avoids_noise_for_uncompacted_tools() -> None: assert metadata == {} +def test_context_governance_controller_keeps_long_task_alias() -> None: + controller = ContextGovernanceController( + evidence_builder=ToolEvidenceBuilder(max_content_chars=240), + posture_config=ContextPostureConfig( + expanded_evidence_threshold=1, + expanded_tool_call_threshold=10, + research_source_threshold=10, + research_evidence_threshold=10, + ), + ) + + controller.compact_tool_result( + "shell_run", + {"command": "pytest"}, + {"ok": True, "stdout": "passed", "stderr": ""}, + ) + snapshot = controller.snapshot().as_dict() + + assert isinstance(LongTaskContextController( + evidence_builder=ToolEvidenceBuilder(max_content_chars=240), + ), ContextGovernanceController) + assert snapshot["posture"] == "expanded" + assert snapshot["guidance"] == "prefer outline, symbols, or range reads before raw content" + + +def test_exploration_ledger_promotes_without_explicit_mode() -> None: + controller = LongTaskContextController( + evidence_builder=ToolEvidenceBuilder(max_content_chars=240), + repeated_read_limit=1, + convergence_round=20, + ) + + for index in range(3): + path = f"/tmp/source_{index}.py" + controller.compact_tool_result( + "file_read", + {"path": path}, + {"ok": True, "path": path, "content": "print(1)", "mode": "symbols"}, + ) + + snapshot = controller.snapshot().as_dict() + + assert snapshot["posture"] == "research" + assert snapshot["sources_seen"] == 3 + assert snapshot["dominant_signal"] == "multi-source" + assert snapshot["guidance"] == "maintain research ledger and synthesize findings" + + @pytest.mark.asyncio async def test_file_read_supports_range_outline_symbols_and_bounded_content(tmp_path) -> None: source = tmp_path / "sample.py" diff --git a/tests/test_daemon_rpc.py b/tests/test_daemon_rpc.py index 8671973..aa3eae7 100644 --- a/tests/test_daemon_rpc.py +++ b/tests/test_daemon_rpc.py @@ -652,6 +652,11 @@ def __init__(self) -> None: "ratio": 0.128, "compressed": False, "forced_final_answer": False, + "context_posture": "research", + "context_signal": "multi-source", + "context_guidance": "maintain research ledger and synthesize findings", + "compression_reason": "threshold-triggered", + "compression_savings_ratio": 0.25, } self._current_session_id = "sess-daemon" @@ -689,10 +694,18 @@ async def collect(message: str) -> list: assert first[0].metadata["context_budget_snapshot"]["total_tokens"] == 2_048 assert first[0].metadata["context_budget_snapshot"]["tool_schema_tokens"] == 248 assert first[0].metadata["llm_context_length"] == 16_000 + assert first[0].metadata["context_posture"] == "research" + assert first[0].metadata["context_signal"] == "multi-source" + assert first[0].metadata["context_guidance"] == "maintain research ledger and synthesize findings" + assert first[0].metadata["compression_reason"] == "threshold-triggered" + assert first[0].metadata["compression_savings_ratio"] == 0.25 assert first[0].metadata["session_id"] == "sess-daemon" assert second[0].metadata["context_used"] == 2_048 assert status["context_used"] == 2_048 assert status["context_budget_snapshot"]["total_tokens"] == 2_048 + assert status["context_posture"] == "research" + assert status["context_guidance"] == "maintain research ledger and synthesize findings" + assert status["compression_reason"] == "threshold-triggered" assert context.engine.max_active == 1 diff --git a/tests/test_tui_command_queue.py b/tests/test_tui_command_queue.py index a1c4fca..6ae92aa 100644 --- a/tests/test_tui_command_queue.py +++ b/tests/test_tui_command_queue.py @@ -272,6 +272,55 @@ def newline(self) -> None: ) +def test_stream_renderer_prints_compression_and_posture_metadata() -> None: + class CaptureConsole: + def __init__(self) -> None: + self.lines: list[str] = [] + + def print(self, renderable) -> None: + self.lines.append(getattr(renderable, "plain", str(renderable))) + + def thinking(self, text: str) -> None: + pass + + def markdown(self, text: str, *, indent: int = 0, margin_top: int = 0) -> None: + pass + + def response_label(self, elapsed_s: float, *, tool_count: int = 0) -> None: + pass + + def newline(self) -> None: + pass + + console = CaptureConsole() + renderer = StreamRenderer(console) # type: ignore[arg-type] + renderer.start() + + renderer.tool_started("shell_run", metadata={"command": "pytest"}) + renderer.tool_finished( + "shell_run", + metadata={ + "ok": True, + "stdout_preview": "passed", + "context_evidence": True, + "compression_stages": ["trim", "summarize"], + "compression_savings_ratio": 0.42, + "compression_reason": "threshold-triggered", + "context_posture": "research", + "context_guidance": "maintain research ledger and synthesize findings", + }, + ) + + assert any( + "compressed=trim+summarize" in line + and "saved≈42%" in line + and "threshold-triggered" in line + and "research" in line + and "maintain research ledger" in line + for line in console.lines + ) + + @pytest.mark.asyncio async def test_process_loop_marks_failed_commands_and_recovers_counts() -> None: async def on_input(text: str) -> None: diff --git a/tests/test_tui_theme.py b/tests/test_tui_theme.py index 4cfc448..f6f7de1 100644 --- a/tests/test_tui_theme.py +++ b/tests/test_tui_theme.py @@ -184,4 +184,22 @@ def test_status_bar_shows_fractional_k_for_small_context_usage(monkeypatch) -> N rendered = "".join(text for _, text in status()) assert "0.2K/256K" in rendered assert "0.1%" in rendered - assert "[█░░░░░░░░░]" in rendered \ No newline at end of file + assert "[█░░░░░░░░░]" in rendered + + +def test_status_bar_shows_adaptive_context_state(monkeypatch) -> None: + monkeypatch.setattr( + "leapflow.cli.tui_app.status.shutil.get_terminal_size", + lambda: terminal_size((120, 24)), + ) + status = StatusBar(resolve_theme(_LIGHT, terminal_bg="#FFFFFF")) + status.update( + model_name="qwen3.7-plus", + context_used=80_000, + context_max=100_000, + context_state="research", + ) + + rendered = "".join(text for _, text in status()) + assert "80%" in rendered + assert "research" in rendered \ No newline at end of file From 3cdb60556bac562fe574078fe9fcfae1861cf0cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8F=AD=E6=89=AC?= Date: Fri, 10 Jul 2026 21:58:14 +0800 Subject: [PATCH 09/17] feat(engine): add progressive context disclosure --- AGENTS.md | 3 + src/leapflow/cli/commands/interactive.py | 41 --- src/leapflow/cli/commands/registry.py | 5 - src/leapflow/cli/context.py | 3 - src/leapflow/cli/tui_app/stream.py | 6 + src/leapflow/daemon/service.py | 9 +- src/leapflow/engine/context_disclosure.py | 381 ++++++++++++++++++++++ src/leapflow/engine/engine.py | 266 +++++++++------ src/leapflow/engine/shortcuts.py | 120 ------- tests/test_agent_execution.py | 104 ++++++ tests/test_cli_entrypoint.py | 9 + tests/test_context_disclosure.py | 107 ++++++ tests/test_tui_command_queue.py | 4 + 13 files changed, 780 insertions(+), 278 deletions(-) create mode 100644 src/leapflow/engine/context_disclosure.py delete mode 100644 src/leapflow/engine/shortcuts.py create mode 100644 tests/test_context_disclosure.py diff --git a/AGENTS.md b/AGENTS.md index 3eacf81..5858828 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,6 +33,7 @@ This document is the LeapFlow engineering collaboration contract. It is not only - **System Boundary Awareness**: LeapFlow is a multi-entry, multi-module runtime. Changes must account for the affected path across CLI/TUI, leapd, engine, skills/tools, LLM, storage, memory, gateway, hub, and platform adapters. - **TUI as the Primary User Entry**: The interactive TUI is the default product surface. Preserve streaming feedback, command queue behavior, approval prompts, status bar accuracy, long-input robustness, history, and session continuity. - **leapd Runtime Consistency**: Daemon-backed behavior must preserve lifecycle correctness: start, stop, restart, status, RPC streaming, cancellation, pending approvals, runtime config reload, multi-client state, and version consistency. +- **Progressive Context Disclosure (PCD)**: Keep one unified execution loop, but never default every turn to full disclosure. Each LLM call must use the smallest sufficient PromptAssemblyPlan for tools, memory, history, reasoning, streaming, and risk; upgrade progressively only when observable signals require it. - **Dependency Inversion**: Core logic depends on Protocol abstractions, never on concrete implementations - **Protocol over ABC**: Use `typing.Protocol` with `runtime_checkable` for all extension points - **Event-Driven Communication**: Modules interact through typed events on EventBus, not direct imports @@ -47,6 +48,7 @@ This document is the LeapFlow engineering collaboration contract. It is not only - Implement against the Protocol, never against another implementation - Consider affected user journeys before changing shared flows; do not introduce regressions, broken links, or worse experiences in adjacent paths - Keep common paths transparent: long-running work must stream progress, surface recoverable errors clearly, and avoid silent stalls. +- For context assembly, prefer manifest-driven progressive disclosure over shortcuts or intent-handler sprawl: expose compact capability indexes, selected schemas, and targeted memory only when the current plan needs them. - Preserve security and audit paths: dangerous actions, file writes, outbound messages, credentials, and path access must flow through the existing policy, approval, redaction, and audit mechanisms. - Maintain backward-compatible migrations for persistent state, configuration, skills, trajectories, sessions, and profile data. - Write unit tests before or alongside the implementation @@ -86,6 +88,7 @@ This document is the LeapFlow engineering collaboration contract. It is not only - Hardcoded paths, URLs, thresholds without config escape hatch - Chinese comments in source code (English only) - Speculative infrastructure: no hooks or extension points without a concrete consumer +- Shortcut-style natural-language fitting and large intent-handler taxonomies; use stable runtime gates plus capability manifests instead. - Bare `except:` clauses — always specify the exception type - `# TODO: implement` stubs — implement or don't commit diff --git a/src/leapflow/cli/commands/interactive.py b/src/leapflow/cli/commands/interactive.py index 82e602c..bba9320 100644 --- a/src/leapflow/cli/commands/interactive.py +++ b/src/leapflow/cli/commands/interactive.py @@ -590,10 +590,6 @@ async def handle_input(text: str) -> None: _print_execution_result(result) return - if canonical.startswith("shortcut"): - if _handle_shortcuts(ctx, console, cmd_text): - return - if canonical == "arm": from leapflow.cli.commands.scheduler import cmd_arm @@ -1407,43 +1403,6 @@ def _handle_skills(ctx: "Context", console, line: str) -> bool: return False -def _handle_shortcuts(ctx: "Context", console, line: str) -> bool: - """Handle shortcut commands. Returns True if handled.""" - if line in ("shortcut list", "shortcut", "快捷短语"): - shortcuts = ctx.shortcuts.list_all() - if not shortcuts: - console.system("No shortcuts configured.") - else: - console.system(f"Shortcuts ({len(shortcuts)}):") - for pattern, reply in shortcuts.items(): - console.system(f" {pattern} → {reply}") - return True - - if line.startswith("shortcut add ") or line.startswith("快捷短语 添加 "): - rest = line.split(" ", 2)[-1] - if "=" not in rest: - console.warning("Usage: shortcut add = ") - return True - pattern, reply = rest.split("=", 1) - pattern, reply = pattern.strip(), reply.strip() - if not pattern or not reply: - console.warning("Usage: shortcut add = ") - return True - ctx.shortcuts.add(pattern, reply) - console.success(f"Shortcut added: {pattern} → {reply}") - return True - - if line.startswith("shortcut remove ") or line.startswith("快捷短语 删除 "): - pattern = line.split(" ", 2)[-1].strip() - if ctx.shortcuts.remove(pattern): - console.success(f"Shortcut removed: {pattern}") - else: - console.warning(f"Shortcut not found: {pattern}") - return True - - return False - - def _show_help(console, runtime: str = "in_process") -> None: """Display categorized help using the command registry.""" from leapflow.cli.commands.registry import commands_by_category diff --git a/src/leapflow/cli/commands/registry.py b/src/leapflow/cli/commands/registry.py index 5cbf6dc..fd4209a 100644 --- a/src/leapflow/cli/commands/registry.py +++ b/src/leapflow/cli/commands/registry.py @@ -106,11 +106,6 @@ def supports_runtime(self, runtime: CommandRuntime) -> bool: CommandDef("hub pull", "Pull skill from hub", "Hub", args_hint=""), CommandDef("hub search", "Search hub for skills", "Hub", args_hint=""), - # Shortcuts - CommandDef("shortcut", "List shortcuts", "Shortcuts", aliases=("shortcut list",)), - CommandDef("shortcut add", "Add a quick-reply shortcut", "Shortcuts", args_hint=" = "), - CommandDef("shortcut remove", "Remove a shortcut", "Shortcuts", args_hint=""), - # Gateway CommandDef("gateway", "Show connected platforms and gateway status", "Gateway", effect=CommandEffect.EXTERNAL), diff --git a/src/leapflow/cli/context.py b/src/leapflow/cli/context.py index 2785317..1564452 100644 --- a/src/leapflow/cli/context.py +++ b/src/leapflow/cli/context.py @@ -60,7 +60,6 @@ from leapflow.learning.similarity import HeuristicSimilarityScorer, LLMSimilarityScorer from leapflow.platform.adapters.darwin import DarwinExecutionAdapter, DarwinPerceptionAdapter from leapflow.domain.platform import PlatformManifest -from leapflow.engine.shortcuts import ShortcutStore from leapflow.engine.situational_assessor import LLMSituationalAssessor from leapflow.platform.facade import VirtualSystemInterface from leapflow.platform.normalizer import EventNormalizer @@ -427,7 +426,6 @@ def __init__(self, settings: Settings, mock_host: bool) -> None: self.audit = AuditLogger(settings.audit_log_path) - self.shortcuts = ShortcutStore(Path.cwd() / ".leapflow" / "shortcuts.yaml") self.assessor: Optional[LLMSituationalAssessor] = None self.perception_session: Optional[Any] = None @@ -1671,7 +1669,6 @@ async def _handler(params: dict) -> dict: execution=execution_adapter, skill_activator=activator, session=self.session, - shortcuts=self.shortcuts, vlm=self.vlm, memory_manager=self.memory, evolution=self._evolution, diff --git a/src/leapflow/cli/tui_app/stream.py b/src/leapflow/cli/tui_app/stream.py index ad33afe..ad59717 100644 --- a/src/leapflow/cli/tui_app/stream.py +++ b/src/leapflow/cli/tui_app/stream.py @@ -82,6 +82,12 @@ def _tool_context_detail(metadata: dict[str, Any] | None) -> str: guidance = _metadata_text(metadata, "context_guidance") if guidance: parts.append(_truncate_detail(guidance, limit=72)) + disclosure = _metadata_text(metadata, "disclosure_level") + if disclosure: + parts.append(f"disclosure={disclosure}") + disclosure_reason = _metadata_text(metadata, "disclosure_reason") + if disclosure_reason: + parts.append(_truncate_detail(disclosure_reason, limit=72)) read_count = metadata.get("read_count") if metadata.get("repeat_read") and read_count is not None: parts.append(f"repeat-read x{read_count}") diff --git a/src/leapflow/daemon/service.py b/src/leapflow/daemon/service.py index 9b89c57..fec0fee 100644 --- a/src/leapflow/daemon/service.py +++ b/src/leapflow/daemon/service.py @@ -290,7 +290,14 @@ def _engine_context_metadata(self, engine: Any | None, settings: Any) -> dict[st guidance = safe_snapshot.get("context_guidance") if guidance: metadata["context_guidance"] = str(guidance) - for key in ("compression_reason", "compression_savings_ratio", "compression_saved_tokens"): + for key in ( + "compression_reason", + "compression_savings_ratio", + "compression_saved_tokens", + "disclosure_level", + "disclosure_reason", + "disclosure", + ): if safe_snapshot.get(key) is not None: metadata[key] = safe_snapshot[key] metadata["context_budget_snapshot"] = safe_snapshot diff --git a/src/leapflow/engine/context_disclosure.py b/src/leapflow/engine/context_disclosure.py new file mode 100644 index 0000000..e54ae06 --- /dev/null +++ b/src/leapflow/engine/context_disclosure.py @@ -0,0 +1,381 @@ +"""Progressive context disclosure for unified agent turns. + +This module decides how much runtime context a turn should disclose before the +provider call. It does not answer the user, execute tools, or split the agent +runtime into multiple loops. +""" +from __future__ import annotations + +import re +from dataclasses import dataclass, field +from enum import Enum +from typing import Any, Iterable, Mapping, Sequence + + +class DisclosureLevel(str, Enum): + """Stable disclosure levels understood by the unified loop.""" + + LIGHT = "light" + INDEXED_CAPABILITIES = "indexed_capabilities" + SELECTED_TOOLS = "selected_tools" + FULL = "full" + + +class MemoryDisclosure(str, Enum): + """Memory disclosure policy for a turn.""" + + NONE = "none" + SESSION_SUMMARY = "session_summary" + QUERY_RETRIEVAL = "query_retrieval" + TASK_RETRIEVAL = "task_retrieval" + + +class HistoryDisclosure(str, Enum): + """Prior-turn disclosure policy for a turn.""" + + SHORT = "short" + RECENT = "recent" + FULL_WINDOW = "full_window" + + +class ReasoningDisclosure(str, Enum): + """Provider reasoning mode requested by the plan.""" + + OFF = "off" + AUTO = "auto" + ON = "on" + + +@dataclass(frozen=True) +class CapabilityManifest: + """Compact runtime-facing capability metadata derived from tool schemas.""" + + name: str + category: str + summary: str + input_signals: tuple[str, ...] = () + risk_level: str = "read_only" + requires_approval: bool = False + schema_cost: str = "medium" + + @classmethod + def from_tool_definition(cls, tool_definition: Mapping[str, Any]) -> "CapabilityManifest": + """Derive a manifest from an OpenAI-style tool definition.""" + function = tool_definition.get("function", {}) if isinstance(tool_definition, Mapping) else {} + name = str(function.get("name") or tool_definition.get("name") or "") + description = str(function.get("description") or "") + raw_metadata = ( + tool_definition.get("x_leapflow") + or tool_definition.get("x-leapflow") + or function.get("x_leapflow") + or function.get("x-leapflow") + or {} + ) + metadata = raw_metadata if isinstance(raw_metadata, Mapping) else {} + category = str(metadata.get("category") or _infer_category(name, description)) + signals = _metadata_signals(metadata.get("input_signals")) or _signals_for_category(category) + risk_level = str(metadata.get("risk_level") or _risk_for_category(category)) + requires_approval = bool(metadata.get("requires_approval", category in {"write", "shell", "gateway"})) + schema_cost = str(metadata.get("schema_cost") or ("high" if category in {"hub", "gateway", "delegate"} else "medium")) + return cls( + name=name, + category=category, + summary=str(metadata.get("summary") or description), + input_signals=signals, + risk_level=risk_level, + requires_approval=requires_approval, + schema_cost=schema_cost, + ) + + +@dataclass(frozen=True) +class PromptAssemblyPlan: + """Provider payload plan consumed by the unified loop.""" + + level: DisclosureLevel + tool_definitions: tuple[Mapping[str, Any], ...] = () + catalog_definitions: tuple[Mapping[str, Any], ...] = () + memory: MemoryDisclosure = MemoryDisclosure.NONE + history: HistoryDisclosure = HistoryDisclosure.SHORT + reasoning: ReasoningDisclosure = ReasoningDisclosure.OFF + native_tools: bool = False + stream_mode: str = "direct" + risk_level: str = "none" + reason: str = "" + selected_tool_names: tuple[str, ...] = () + max_prior_turns: int = 2 + + def metadata(self) -> dict[str, Any]: + """Return a JSON-serializable disclosure summary.""" + return { + "level": self.level.value, + "reason": self.reason, + "tools": list(self.selected_tool_names), + "tool_count": len(self.selected_tool_names), + "memory": self.memory.value, + "history": self.history.value, + "reasoning": self.reasoning.value, + "native_tools": self.native_tools, + "stream_mode": self.stream_mode, + "risk_level": self.risk_level, + } + + +@dataclass(frozen=True) +class DisclosureRuntimeState: + """Low-cost signals available before assembling the provider payload.""" + + enable_thinking: bool = False + native_tools_enabled: bool = False + slash_command: bool = False + context_posture: str = "baseline" + recent_failure: bool = False + + +@dataclass(frozen=True) +class DisclosurePlanner: + """Manifest-driven planner for progressive context disclosure.""" + + manifests: tuple[CapabilityManifest, ...] = field(default_factory=tuple) + + def plan( + self, + user_text: str, + tool_definitions: Sequence[Mapping[str, Any]], + runtime: DisclosureRuntimeState, + ) -> PromptAssemblyPlan: + """Build a prompt assembly plan without invoking another LLM.""" + manifests = self.manifests or tuple(CapabilityManifest.from_tool_definition(td) for td in tool_definitions) + if runtime.slash_command or runtime.context_posture in {"research", "finalizing"}: + return self.full_plan(user_text, tool_definitions, runtime, "runtime posture requires full agent context") + + if _asks_about_capabilities(user_text): + return PromptAssemblyPlan( + level=DisclosureLevel.INDEXED_CAPABILITIES, + catalog_definitions=tuple(tool_definitions), + memory=MemoryDisclosure.SESSION_SUMMARY, + history=HistoryDisclosure.SHORT, + reasoning=ReasoningDisclosure.OFF, + native_tools=False, + stream_mode="direct", + risk_level="none", + reason="capability question needs compact index but no executable schemas", + selected_tool_names=tuple(_tool_name(td) for td in tool_definitions if _tool_name(td)), + max_prior_turns=2, + ) + + selected = self._select_capabilities(user_text, manifests) + if self._needs_full_context(user_text, selected, runtime): + return self.full_plan(user_text, tool_definitions, runtime, "task requires broad execution context") + + if selected: + selected_names = {manifest.name for manifest in selected} + selected_defs = tuple(td for td in tool_definitions if _tool_name(td) in selected_names) + risk = _highest_risk(selected) + return PromptAssemblyPlan( + level=DisclosureLevel.SELECTED_TOOLS, + tool_definitions=selected_defs, + catalog_definitions=selected_defs, + memory=MemoryDisclosure.QUERY_RETRIEVAL if _needs_memory(user_text, selected) else MemoryDisclosure.NONE, + history=HistoryDisclosure.RECENT, + reasoning=ReasoningDisclosure.AUTO if runtime.enable_thinking else ReasoningDisclosure.OFF, + native_tools=runtime.native_tools_enabled and bool(selected_defs), + stream_mode="tool_aware", + risk_level=risk, + reason="selected capabilities matched observable task signals", + selected_tool_names=tuple(sorted(selected_names)), + max_prior_turns=6, + ) + + return PromptAssemblyPlan( + level=DisclosureLevel.LIGHT, + memory=MemoryDisclosure.NONE, + history=HistoryDisclosure.SHORT, + reasoning=ReasoningDisclosure.OFF, + native_tools=False, + stream_mode="direct", + risk_level="none", + reason="no external capability signal detected", + max_prior_turns=2, + ) + + def full_plan( + self, + user_text: str, + tool_definitions: Sequence[Mapping[str, Any]], + runtime: DisclosureRuntimeState, + reason: str, + ) -> PromptAssemblyPlan: + """Build a full-disclosure fallback plan for safety and compatibility.""" + names = tuple(_tool_name(td) for td in tool_definitions if _tool_name(td)) + return PromptAssemblyPlan( + level=DisclosureLevel.FULL, + tool_definitions=tuple(tool_definitions), + catalog_definitions=tuple(tool_definitions), + memory=MemoryDisclosure.TASK_RETRIEVAL, + history=HistoryDisclosure.FULL_WINDOW, + reasoning=ReasoningDisclosure.AUTO if runtime.enable_thinking else ReasoningDisclosure.OFF, + native_tools=runtime.native_tools_enabled and bool(tool_definitions), + stream_mode="tool_aware", + risk_level="high" if _has_mutation_signal(user_text) else "medium", + reason=reason, + selected_tool_names=names, + max_prior_turns=10, + ) + + def _select_capabilities( + self, + user_text: str, + manifests: Sequence[CapabilityManifest], + ) -> tuple[CapabilityManifest, ...]: + normalized = _normalize(user_text) + selected: list[CapabilityManifest] = [] + for manifest in manifests: + haystack = " ".join((manifest.name, manifest.category, manifest.summary, *manifest.input_signals)).lower() + if any(signal and signal in normalized for signal in manifest.input_signals): + selected.append(manifest) + continue + if any(token and token in normalized for token in _name_tokens(manifest.name)): + selected.append(manifest) + continue + if manifest.category in {"hub", "gateway"} and any(token in normalized for token in ("hub", "gateway", "message", "send")): + selected.append(manifest) + continue + if manifest.category == "delegate" and any(token in normalized for token in ("delegate", "subagent", "parallel", "子任务")): + selected.append(manifest) + continue + if any(token in normalized for token in _description_tokens(haystack)): + selected.append(manifest) + return tuple(_dedupe_by_name(selected)) + + def _needs_full_context( + self, + user_text: str, + selected: Sequence[CapabilityManifest], + runtime: DisclosureRuntimeState, + ) -> bool: + normalized = _normalize(user_text) + if runtime.recent_failure: + return True + if _has_complexity_signal(normalized): + return True + risky_selected = any(item.requires_approval for item in selected) + return risky_selected and _has_mutation_signal(normalized) + + +def build_capability_manifests(tool_definitions: Sequence[Mapping[str, Any]]) -> tuple[CapabilityManifest, ...]: + """Build manifests for all known tools.""" + return tuple(CapabilityManifest.from_tool_definition(tool) for tool in tool_definitions) + + +def _tool_name(tool_definition: Mapping[str, Any]) -> str: + function = tool_definition.get("function", {}) if isinstance(tool_definition, Mapping) else {} + return str(function.get("name") or tool_definition.get("name") or "") + + +def _metadata_signals(value: Any) -> tuple[str, ...]: + if not isinstance(value, (list, tuple, set)): + return () + return tuple(str(item).lower().strip() for item in value if str(item).strip()) + + +def _infer_category(name: str, description: str) -> str: + text = f"{name} {description}".lower() + if any(token in text for token in ("write", "replace", "delete", "store", "add")): + return "write" + if any(token in text for token in ("shell", "command", "execute")): + return "shell" + if any(token in text for token in ("file", "directory", "path")): + return "file" + if "memory" in text: + return "memory" + if "skill" in text: + return "skill" + if "delegate" in text or "subagent" in text: + return "delegate" + if "hub" in text: + return "hub" + if "gateway" in text or "message" in text: + return "gateway" + if "time" in text or "environment" in text or "date" in text: + return "system" + return "general" + + +def _signals_for_category(category: str) -> tuple[str, ...]: + signals = { + "file": ("file", "path", "read", "list", "文件", "目录", "路径", ".py", ".md"), + "write": ("write", "edit", "modify", "replace", "save", "store", "写", "改", "保存", "记住"), + "shell": ("run", "command", "terminal", "execute", "pytest", "命令", "执行", "测试"), + "memory": ("memory", "remember", "recall", "history", "记忆", "回忆"), + "skill": ("skill", "capability", "能力", "技能"), + "system": ("time", "date", "env", "environment", "时间", "环境"), + "delegate": ("delegate", "subagent", "parallel", "子任务", "并行"), + "hub": ("hub", "publish", "install", "marketplace"), + "gateway": ("gateway", "message", "send", "notify", "发送", "通知"), + } + return signals.get(category, ()) + + +def _risk_for_category(category: str) -> str: + if category in {"write", "shell", "gateway"}: + return "high" + if category in {"delegate", "hub"}: + return "medium" + return "read_only" + + +def _normalize(text: str) -> str: + return text.lower().strip() + + +def _name_tokens(name: str) -> tuple[str, ...]: + return tuple(token for token in re.split(r"[_\W]+", name.lower()) if len(token) >= 3) + + +def _description_tokens(text: str) -> tuple[str, ...]: + return tuple(token for token in re.split(r"\W+", text.lower()) if len(token) >= 8) + + +def _dedupe_by_name(manifests: Iterable[CapabilityManifest]) -> list[CapabilityManifest]: + seen: set[str] = set() + result: list[CapabilityManifest] = [] + for manifest in manifests: + if manifest.name and manifest.name not in seen: + seen.add(manifest.name) + result.append(manifest) + return result + + +def _highest_risk(manifests: Sequence[CapabilityManifest]) -> str: + order = {"none": 0, "read_only": 1, "medium": 2, "high": 3} + highest = "none" + for manifest in manifests: + if order.get(manifest.risk_level, 0) > order.get(highest, 0): + highest = manifest.risk_level + return highest + + +def _asks_about_capabilities(text: str) -> bool: + normalized = _normalize(text) + return any(token in normalized for token in ("what can you do", "capabilities", "skills", "tools", "你能做", "有哪些能力", "技能", "工具")) + + +def _needs_memory(text: str, manifests: Sequence[CapabilityManifest]) -> bool: + normalized = _normalize(text) + return any(manifest.category == "memory" for manifest in manifests) or any(token in normalized for token in ("memory", "remember", "recall", "history", "记忆", "之前")) + + +def _has_complexity_signal(normalized_text: str) -> bool: + return any( + token in normalized_text + for token in ( + "implement", "refactor", "debug", "root cause", "architecture", "design", "analyze", "test", + "实现", "重构", "调试", "根因", "架构", "设计", "深入分析", "执行", "代码", "测试", + ) + ) + + +def _has_mutation_signal(text: str) -> bool: + normalized = _normalize(text) + return any(token in normalized for token in ("write", "edit", "modify", "delete", "send", "execute", "写", "改", "删", "发送", "执行")) diff --git a/src/leapflow/engine/engine.py b/src/leapflow/engine/engine.py index 6251fa3..b66920e 100644 --- a/src/leapflow/engine/engine.py +++ b/src/leapflow/engine/engine.py @@ -23,6 +23,13 @@ ContextWindowController, ToolEvidenceBuilder, ) +from leapflow.engine.context_disclosure import ( + DisclosureLevel, + DisclosurePlanner, + DisclosureRuntimeState, + MemoryDisclosure, + PromptAssemblyPlan, +) from leapflow.engine.error_classifier import ( ErrorCategory, ErrorClassifier, @@ -42,7 +49,6 @@ ToolCall as ConcurrentToolCall, ToolConcurrencyPolicy, ) -from leapflow.engine.shortcuts import ShortcutStore from leapflow.engine.graph_planner import GraphPlanner from leapflow.engine.scheduler import TaskScheduler from leapflow.engine.session import SessionController @@ -293,6 +299,15 @@ class _LoopContext: prefetch_done: bool = False # track whether memory prefetch ran this loop +@dataclass(frozen=True) +class _PromptAssembly: + """Resolved prompt pieces for a unified-loop turn.""" + + system: str + plan: PromptAssemblyPlan + prior_turns: List[Dict[str, Any]] + + class AgentEngine: """Coordinates perception memory, LLM reasoning, RPC execution, and skills.""" @@ -314,7 +329,6 @@ def __init__( execution: Optional[Any] = None, skill_activator: Optional[Any] = None, session: Optional[SessionController] = None, - shortcuts: Optional["ShortcutStore"] = None, vlm: Optional[Any] = None, memory_manager: Optional[MemoryManager] = None, evolution: Optional[EvolutionMemoryProvider] = None, @@ -332,7 +346,6 @@ def __init__( self._imm = imm self._registry = registry self._classifier = classifier - self._shortcuts = shortcuts self._imitation = imitation self._skill_library = skill_library self._skill_merger = SkillMerger( @@ -447,6 +460,8 @@ def __init__( ), ) self._last_context_snapshot: dict[str, Any] = {} + self._last_disclosure_metadata: dict[str, Any] = {} + self._disclosure_planner = DisclosurePlanner() self._healer = MessageHealer() # B2: Prompt cache optimization (None = disabled) @@ -789,6 +804,106 @@ def _active_context_length(self) -> int: logger.debug("model capability lookup failed", exc_info=True) return max(1, int(self._settings.llm_context_length)) + async def _assemble_unified_prompt( + self, + user_text: str, + *, + tool_definitions: List[Dict[str, Any]], + enable_thinking: bool, + slash_command: bool = False, + ) -> _PromptAssembly: + """Resolve progressive disclosure and build the system prompt.""" + from leapflow.prompts.templates import UNIFIED_SYSTEM_TEMPLATE + + runtime = DisclosureRuntimeState( + enable_thinking=enable_thinking, + native_tools_enabled=self._settings.native_tool_calling_enabled, + slash_command=slash_command, + context_posture=str(self._last_context_snapshot.get("context_posture") or "baseline"), + recent_failure=bool(self._last_context_snapshot.get("forced_final_answer")), + ) + try: + plan = self._disclosure_planner.plan(user_text, tool_definitions, runtime) + except (TypeError, ValueError, RuntimeError) as exc: + logger.warning("disclosure planning failed; falling back to full context: %s", exc) + plan = DisclosurePlanner().full_plan( + user_text, + tool_definitions, + runtime, + "planner fallback preserved unified-loop behavior", + ) + + tool_catalog = self._format_tool_catalog(list(plan.catalog_definitions)) + memory_context = "" + if plan.memory == MemoryDisclosure.SESSION_SUMMARY: + memory_context = self._build_session_summary_context(max_messages=plan.max_prior_turns) + elif plan.memory in {MemoryDisclosure.QUERY_RETRIEVAL, MemoryDisclosure.TASK_RETRIEVAL}: + memory_context = await self._prefetch_and_freeze_memory(user_text) + skill_section = self._build_skill_section(include_skills=plan.level != DisclosureLevel.LIGHT) + system = UNIFIED_SYSTEM_TEMPLATE.format( + tool_catalog=tool_catalog, + skill_section=skill_section, + memory_context=memory_context, + ) + self._last_disclosure_metadata = plan.metadata() + prior_turns = self._prior_turns_for_plan(plan) + return _PromptAssembly(system=system, plan=plan, prior_turns=prior_turns) + + def _build_session_summary_context(self, *, max_messages: int) -> str: + """Return a compact local session summary without retrieval or extra LLM calls.""" + messages = self._wm.as_chat_messages() + summary_lines: list[str] = [] + for message in messages[-max(0, max_messages):]: + role = str(message.get("role") or "").strip() + if role not in {"user", "assistant"}: + continue + content = message.get("content", "") + if isinstance(content, list): + content = " ".join(str(part.get("text", part)) if isinstance(part, dict) else str(part) for part in content) + elif not isinstance(content, str): + content = str(content) + preview = _single_line_preview(content, limit=180) + if preview: + summary_lines.append(f"- {role}: {preview}") + if not summary_lines: + return "" + return "\n## Recent Session Summary\n" + "\n".join(summary_lines) + "\n" + + def _build_skill_section(self, *, include_skills: bool) -> str: + """Return compact learned-skill prompt text when the plan allows it.""" + if not include_skills or not self._skill_index: + return "" + entries = self._skill_index.get_entries() + if not entries: + return "" + skill_index_text = self._skill_index.compact_index_text(entries) + return ( + "\n## Learned Skills\n" + "You have access to the following learned skills. " + "Use `gp_skills_list` to browse or `gp_skill_view` to read details:\n" + f"{skill_index_text}\n" + ) + + def _prior_turns_for_plan(self, plan: PromptAssemblyPlan) -> List[Dict[str, Any]]: + """Return bounded prior conversation turns according to the disclosure plan.""" + wm_history = self._wm.as_chat_messages() + prior_turns: List[Dict[str, Any]] = [ + message for message in wm_history + if isinstance(message.get("role"), str) and message["role"] in ("user", "assistant") + ] + return prior_turns[-max(0, plan.max_prior_turns):] + + @staticmethod + def _planned_enable_thinking(plan: PromptAssemblyPlan, requested: bool) -> bool: + """Apply the plan-level reasoning gate to the provider request.""" + return requested and plan.reasoning.value != "off" + + def _planned_tools_kwarg(self, plan: PromptAssemblyPlan) -> Dict[str, Any]: + """Return provider tool schemas only when the plan discloses native tools.""" + if plan.native_tools and plan.tool_definitions: + return {"tools": list(plan.tool_definitions)} + return {} + def _prepare_llm_messages( self, messages: List[Dict[str, Any]], @@ -848,6 +963,9 @@ def _prepare_llm_messages( "context_signal": governance.get("dominant_signal", ""), "context_guidance": governance.get("guidance", ""), "context_convergence_reason": governance.get("convergence_reason", ""), + "disclosure": dict(self._last_disclosure_metadata), + "disclosure_level": self._last_disclosure_metadata.get("level", ""), + "disclosure_reason": self._last_disclosure_metadata.get("reason", ""), } if compressed: self._usage_tracker.mark_compression() @@ -890,6 +1008,12 @@ def _tool_context_metadata( guidance = snapshot.get("context_guidance") if guidance: metadata.setdefault("context_guidance", guidance) + disclosure_level = snapshot.get("disclosure_level") + if disclosure_level: + metadata.setdefault("disclosure_level", disclosure_level) + disclosure_reason = snapshot.get("disclosure_reason") + if disclosure_reason: + metadata.setdefault("disclosure_reason", disclosure_reason) trace = snapshot.get("compression_trace") if isinstance(trace, dict) and trace.get("stages_applied"): metadata.setdefault("compression_stages", trace.get("stages_applied")) @@ -906,15 +1030,7 @@ async def run(self, user_text: str, *, enable_thinking: bool = False) -> str: logger.info("audit.user_input chars=%s", len(user_text)) self._memory_context_snapshot = None # reset per-turn for fresh prefetch - # 1. Shortcut match (zero cost, exact keyword) - if self._shortcuts: - reply = self._shortcuts.match(user_text) - if reply: - self._wm.remember_chat(build_user_message_text(user_text)) - self._wm.remember_chat(build_assistant_message(reply)) - return reply - - # 2. Slash command (skill injection — zero-ambiguity activation) + # 1. Slash command (skill injection — zero-ambiguity activation) if user_text.startswith("/") and self._skill_injector: self._inject_pending_skill_reminder() self._wm.remember_chat(build_user_message_text(user_text)) @@ -924,11 +1040,11 @@ async def run(self, user_text: str, *, enable_thinking: bool = False) -> str: self._inject_pending_skill_reminder() self._wm.remember_chat(build_user_message_text(user_text)) - # 3. Teach command (special session mode switch) + # 2. Teach command (special session mode switch) if self._is_teach_command(user_text): return await self._handle_learn_command(user_text) - # 4. Everything else → unified tool loop (LLM decides tools vs direct response) + # 3. Everything else → unified tool loop (LLM decides tools vs direct response) logger.debug("route.unified user_text_len=%d", len(user_text)) if not self._settings.has_llm_credentials: msg = self._error_classifier.friendly_message(ErrorCategory.AUTH_PERMANENT) @@ -942,7 +1058,7 @@ async def run_stream( """Like run(), but yields text chunks for streamable responses. Yields: - str: legacy plain-text chunks (shortcuts, teach commands). + str: legacy plain-text chunks (teach commands). StreamEvent(type="chunk"): real-time token fragments. StreamEvent(type="final"): complete assembled response. StreamEvent(type="tool_call"): internal tool invocation (suppress display). @@ -951,16 +1067,7 @@ async def run_stream( logger.info("audit.user_input chars=%s", len(user_text)) self._memory_context_snapshot = None # reset per-turn for fresh prefetch - # 1. Shortcut match (zero cost) - if self._shortcuts: - reply = self._shortcuts.match(user_text) - if reply: - self._wm.remember_chat(build_user_message_text(user_text)) - self._wm.remember_chat(build_assistant_message(reply)) - yield reply - return - - # 2. Slash command (skill injection) + # 1. Slash command (skill injection) if user_text.startswith("/") and self._skill_injector: self._inject_pending_skill_reminder() self._wm.remember_chat(build_user_message_text(user_text)) @@ -972,13 +1079,13 @@ async def run_stream( self._inject_pending_skill_reminder() self._wm.remember_chat(build_user_message_text(user_text)) - # 3. Teach command (special session mode switch) + # 2. Teach command (special session mode switch) if self._is_teach_command(user_text): result = await self._handle_learn_command(user_text) yield result return - # 4. Everything else → unified tool loop (streaming) + # 3. Everything else → unified tool loop (streaming) logger.debug("route.unified user_text_len=%d", len(user_text)) if not self._settings.has_llm_credentials: msg = self._error_classifier.friendly_message(ErrorCategory.AUTH_PERMANENT) @@ -1379,63 +1486,32 @@ async def _unified_tool_loop( if injection: user_text = injection # Replace user_text with skill injection - from leapflow.prompts.templates import UNIFIED_SYSTEM_TEMPLATE from leapflow.tools.registry_bootstrap import TOOL_DEFINITIONS, TOOL_HANDLERS budget = IterationBudget.for_react(self._budget_config) trace = ExecutionTrace() - - # Build tool catalog text (for system prompt readability) - tool_catalog = self._format_tool_catalog(TOOL_DEFINITIONS) - - memory_context = await self._prefetch_and_freeze_memory(user_text) - - skill_section = "" - if self._skill_index: - entries = self._skill_index.get_entries() - if entries: - skill_index_text = self._skill_index.compact_index_text(entries) - skill_section = ( - "\n## Learned Skills\n" - "You have access to the following learned skills. " - "Use `gp_skills_list` to browse or `gp_skill_view` to read details:\n" - f"{skill_index_text}\n" - ) - - # Build system prompt - system = UNIFIED_SYSTEM_TEMPLATE.format( - tool_catalog=tool_catalog, - skill_section=skill_section, - memory_context=memory_context, + assembly = await self._assemble_unified_prompt( + user_text, + tool_definitions=TOOL_DEFINITIONS, + enable_thinking=enable_thinking, + slash_command=user_text.startswith("/"), ) - - # Inject prior conversation turns from working memory for multi-turn coherence - wm_history = self._wm.as_chat_messages() - # Filter to keep only recent user/assistant exchanges (skip system events) - prior_turns: List[Dict[str, Any]] = [ - m for m in wm_history - if isinstance(m.get("role"), str) and m["role"] in ("user", "assistant") - ] - # Limit to last N turns to avoid overwhelming context - max_prior_turns = 10 - prior_turns = prior_turns[-max_prior_turns:] + planned_enable_thinking = self._planned_enable_thinking(assembly.plan, enable_thinking) messages: List[Dict[str, Any]] = [ - build_system_message(system), - *prior_turns, + build_system_message(assembly.system), + *assembly.prior_turns, build_user_message_text(user_text), ] content = "" fatal_error: Optional[str] = None recovery = TurnRecoveryState() - use_native_tools = self._settings.native_tool_calling_enabled + use_native_tools = assembly.plan.native_tools result_budget = self._effective_tool_result_budget() self._usage_tracker.reset() - tools_kwarg: Dict[str, Any] = {} - if use_native_tools and TOOL_DEFINITIONS: - tools_kwarg["tools"] = TOOL_DEFINITIONS + tools_kwarg: Dict[str, Any] = self._planned_tools_kwarg(assembly.plan) self._cancel_requested = False _signal_watermark = [time.time()] @@ -1463,7 +1539,7 @@ async def _unified_tool_loop( _show_progress("thinking", f"round {budget.used}") try: resp = await self._llm.achat( - compressed, stream=False, enable_thinking=enable_thinking, + compressed, stream=False, enable_thinking=planned_enable_thinking, **tools_kwarg, ) recovery.record_api_success() @@ -1738,58 +1814,32 @@ async def _unified_tool_loop_stream( if injection: user_text = injection - from leapflow.prompts.templates import UNIFIED_SYSTEM_TEMPLATE from leapflow.tools.registry_bootstrap import TOOL_DEFINITIONS, TOOL_HANDLERS budget = IterationBudget.for_react(self._budget_config) trace = ExecutionTrace() - - tool_catalog = self._format_tool_catalog(TOOL_DEFINITIONS) - - memory_context = await self._prefetch_and_freeze_memory(user_text) - - skill_section = "" - if self._skill_index: - idx_entries = self._skill_index.get_entries() - if idx_entries: - skill_index_text = self._skill_index.compact_index_text(idx_entries) - skill_section = ( - "\n## Learned Skills\n" - "You have access to the following learned skills. " - "Use `gp_skills_list` to browse or `gp_skill_view` to read details:\n" - f"{skill_index_text}\n" - ) - - system = UNIFIED_SYSTEM_TEMPLATE.format( - tool_catalog=tool_catalog, - skill_section=skill_section, - memory_context=memory_context, + assembly = await self._assemble_unified_prompt( + user_text, + tool_definitions=TOOL_DEFINITIONS, + enable_thinking=enable_thinking, + slash_command=user_text.startswith("/"), ) - - # Prior conversation context - wm_history = self._wm.as_chat_messages() - prior_turns: List[Dict[str, Any]] = [ - m for m in wm_history - if isinstance(m.get("role"), str) and m["role"] in ("user", "assistant") - ] - prior_turns = prior_turns[-10:] + planned_enable_thinking = self._planned_enable_thinking(assembly.plan, enable_thinking) messages: List[Dict[str, Any]] = [ - build_system_message(system), - *prior_turns, + build_system_message(assembly.system), + *assembly.prior_turns, build_user_message_text(user_text), ] content = "" fatal_error: Optional[str] = None turn_recovery = TurnRecoveryState() - use_native_tools = self._settings.native_tool_calling_enabled + use_native_tools = assembly.plan.native_tools result_budget = self._effective_tool_result_budget() self._usage_tracker.reset() - tools_kwarg: Dict[str, Any] = {} - if use_native_tools and TOOL_DEFINITIONS: - tools_kwarg["tools"] = TOOL_DEFINITIONS + tools_kwarg: Dict[str, Any] = self._planned_tools_kwarg(assembly.plan) session_id = self._ensure_session(user_text) @@ -1821,7 +1871,7 @@ async def _unified_tool_loop_stream( if use_native_tools and tools_kwarg: try: resp = await self._llm.achat( - compressed, stream=False, enable_thinking=enable_thinking, + compressed, stream=False, enable_thinking=planned_enable_thinking, **tools_kwarg, ) turn_recovery.record_api_success() @@ -1928,7 +1978,7 @@ async def _unified_tool_loop_stream( try: _clear_indicator() raw_stream = self._llm.achat_stream( - compressed, enable_thinking=enable_thinking, + compressed, enable_thinking=planned_enable_thinking, ) guarded = stale_guarded_stream( raw_stream, timeout_s=self._stale_stream_timeout_s, @@ -1970,7 +2020,7 @@ async def _unified_tool_loop_stream( else: try: resp = await self._llm.achat( - compressed, stream=False, enable_thinking=enable_thinking, + compressed, stream=False, enable_thinking=planned_enable_thinking, ) turn_recovery.record_api_success() usage = resp.usage or {} diff --git a/src/leapflow/engine/shortcuts.py b/src/leapflow/engine/shortcuts.py deleted file mode 100644 index 12d4dd7..0000000 --- a/src/leapflow/engine/shortcuts.py +++ /dev/null @@ -1,120 +0,0 @@ -"""User-customizable quick-reply shortcuts. - -Shortcuts bypass intent classification and LLM calls entirely, -returning a canned reply for matched patterns. - -Storage: `.leapflow/shortcuts.yaml` in the working directory. -""" - -from __future__ import annotations - -from pathlib import Path -from typing import Optional - -import yaml - -_DEFAULTS: dict[str, str] = { - # English greetings - "hi": "你好!我是 LeapFlow,有什么可以帮你的?", - "hey": "你好!我是 LeapFlow,有什么可以帮你的?", - "hello": "你好!我是 LeapFlow,有什么可以帮你的?", - "yo": "你好!我是 LeapFlow,有什么可以帮你的?", - "sup": "你好!我是 LeapFlow,有什么可以帮你的?", - "howdy": "你好!我是 LeapFlow,有什么可以帮你的?", - "what's up": "你好!我是 LeapFlow,有什么可以帮你的?", - "whats up": "你好!我是 LeapFlow,有什么可以帮你的?", - # Chinese greetings - "你好": "你好!我是 LeapFlow,有什么可以帮你的?", - "您好": "你好!我是 LeapFlow,有什么可以帮你的?", - "嗨": "你好!我是 LeapFlow,有什么可以帮你的?", - "嘿": "你好!我是 LeapFlow,有什么可以帮你的?", - # Time-of-day - "good morning": "早上好!今天有什么可以帮你的?", - "morning": "早上好!今天有什么可以帮你的?", - "早": "早上好!今天有什么可以帮你的?", - "早上好": "早上好!今天有什么可以帮你的?", - "good afternoon": "下午好!有什么需要帮忙的?", - "afternoon": "下午好!有什么需要帮忙的?", - "下午好": "下午好!有什么需要帮忙的?", - "good evening": "晚上好!需要帮忙就说。", - "evening": "晚上好!需要帮忙就说。", - "good night": "晚上好!需要帮忙就说。", - "晚上好": "晚上好!需要帮忙就说。", - "晚安": "晚上好!需要帮忙就说。", - # Thanks - "thanks": "不客气,随时可以找我 :)", - "thank you": "不客气,随时可以找我 :)", - "thx": "不客气,随时可以找我 :)", - "ty": "不客气,随时可以找我 :)", - "谢谢": "不客气,随时可以找我 :)", - "感谢": "不客气,随时可以找我 :)", - "多谢": "不客气,随时可以找我 :)", - "辛苦了": "不客气,随时可以找我 :)", - # Goodbye - "bye": "再见!需要时随时唤我。", - "goodbye": "再见!需要时随时唤我。", - "see you": "再见!需要时随时唤我。", - "see ya": "再见!需要时随时唤我。", - "再见": "再见!需要时随时唤我。", - "拜拜": "再见!需要时随时唤我。", - "拜": "再见!需要时随时唤我。", -} - -_STRIP_CHARS = "!!.。~??" - - -def _normalize(text: str) -> str: - return text.strip().lower().rstrip(_STRIP_CHARS) - - -class ShortcutStore: - """Manages user-customizable quick-reply shortcuts.""" - - def __init__(self, path: Path) -> None: - self._path = path - self._shortcuts: dict[str, str] = dict(_DEFAULTS) - self._load() - - def _load(self) -> None: - if not self._path.exists(): - return - try: - data = yaml.safe_load(self._path.read_text(encoding="utf-8")) - if isinstance(data, dict) and isinstance(data.get("shortcuts"), dict): - self._shortcuts.update(data["shortcuts"]) - except Exception: - pass - - def _save(self) -> None: - self._path.parent.mkdir(parents=True, exist_ok=True) - content = yaml.dump( - {"shortcuts": self._shortcuts}, - allow_unicode=True, - default_flow_style=False, - sort_keys=False, - ) - self._path.write_text(content, encoding="utf-8") - - def match(self, text: str) -> Optional[str]: - """Return reply if text matches a shortcut, else None.""" - key = _normalize(text) - return self._shortcuts.get(key) - - def add(self, pattern: str, reply: str) -> None: - """Add or update a shortcut mapping and persist.""" - key = _normalize(pattern) - self._shortcuts[key] = reply - self._save() - - def remove(self, pattern: str) -> bool: - """Remove a shortcut. Returns True if it existed.""" - key = _normalize(pattern) - if key in self._shortcuts: - del self._shortcuts[key] - self._save() - return True - return False - - def list_all(self) -> dict[str, str]: - """Return all shortcut mappings.""" - return dict(self._shortcuts) diff --git a/tests/test_agent_execution.py b/tests/test_agent_execution.py index f4875a8..83223d4 100644 --- a/tests/test_agent_execution.py +++ b/tests/test_agent_execution.py @@ -237,6 +237,110 @@ async def achat_stream(self, messages, *, enable_thinking=False, **kwargs): lt.close() +@pytest.mark.asyncio +async def test_progressive_disclosure_light_query_omits_tools_and_thinking() -> None: + """Plain chat should stay on the light path even when thinking is requested.""" + from leapflow.llm.base import LLMChatResponse, LLMProvider + from leapflow.platform.mock import MockBridge + + class CaptureLLM(LLMProvider): + def __init__(self) -> None: + self.messages: list[dict] = [] + self.kwargs: dict = {} + self.enable_thinking = True + self.call_count = 0 + + async def achat(self, messages, *, stream=True, enable_thinking=False, on_chunk=None, **kwargs): + self.call_count += 1 + self.messages = list(messages) + self.kwargs = dict(kwargs) + self.enable_thinking = enable_thinking + return LLMChatResponse(content="I am LeapFlow.") + + async def achat_stream(self, messages, *, enable_thinking=False, **kwargs): + if False: + yield "" + + with tempfile.TemporaryDirectory() as td: + settings = make_settings(td) + settings = settings.__class__( + **{ + **settings.__dict__, + "native_tool_calling_enabled": True, + } + ) + rpc = MockBridge() + llm = CaptureLLM() + wm = WorkingMemoryProvider(max_tokens=1024) + lt = SemanticMemoryProvider(source=settings.duckdb_path) + imm = EpisodicMemoryProvider() + try: + reg = build_default_registry(rpc, llm, wm, lt) + classifier = _FixedClassifier("chat") + engine = AgentEngine(settings, rpc, llm, wm, lt, imm, reg, classifier) + + out = await engine.run("hello", enable_thinking=True) + + assert out == "I am LeapFlow." + assert llm.call_count == 1 + assert "tools" not in llm.kwargs + assert llm.enable_thinking is False + assert "file_read" not in str(llm.messages[0].get("content", "")) + snapshot = engine.context_budget_snapshot + assert snapshot["disclosure_level"] == "light" + assert snapshot["disclosure"]["native_tools"] is False + finally: + lt.close() + + +@pytest.mark.asyncio +async def test_progressive_disclosure_file_query_selects_file_schemas() -> None: + """File-oriented requests should disclose file schemas without the full catalog.""" + from leapflow.llm.base import LLMChatResponse, LLMProvider + from leapflow.platform.mock import MockBridge + + class CaptureLLM(LLMProvider): + def __init__(self) -> None: + self.kwargs: dict = {} + + async def achat(self, messages, *, stream=True, enable_thinking=False, on_chunk=None, **kwargs): + self.kwargs = dict(kwargs) + return LLMChatResponse(content="Done") + + async def achat_stream(self, messages, *, enable_thinking=False, **kwargs): + if False: + yield "" + + with tempfile.TemporaryDirectory() as td: + settings = make_settings(td) + settings = settings.__class__( + **{ + **settings.__dict__, + "native_tool_calling_enabled": True, + } + ) + rpc = MockBridge() + llm = CaptureLLM() + wm = WorkingMemoryProvider(max_tokens=1024) + lt = SemanticMemoryProvider(source=settings.duckdb_path) + imm = EpisodicMemoryProvider() + try: + reg = build_default_registry(rpc, llm, wm, lt) + classifier = _FixedClassifier("file") + engine = AgentEngine(settings, rpc, llm, wm, lt, imm, reg, classifier) + + await engine.run("Read src/leapflow/engine/engine.py") + + tools = llm.kwargs.get("tools", []) + names = {tool.get("function", {}).get("name", "") for tool in tools} + assert "file_read" in names + assert "file_list" in names + assert "shell_run" not in names + assert engine.context_budget_snapshot["disclosure_level"] == "selected_tools" + finally: + lt.close() + + @pytest.mark.asyncio async def test_immediate_memory_integration() -> None: """EpisodicMemoryProvider fragments surface in memory_recent responses.""" diff --git a/tests/test_cli_entrypoint.py b/tests/test_cli_entrypoint.py index 68ff167..790be36 100644 --- a/tests/test_cli_entrypoint.py +++ b/tests/test_cli_entrypoint.py @@ -17,6 +17,15 @@ def test_context_constructs_approval_gate_before_initialize(tmp_path) -> None: assert hasattr(ctx, "_approval_gate") assert hasattr(ctx, "_tui_approval") + assert not hasattr(ctx, "shortcuts") + + +def test_shortcut_commands_are_not_registered() -> None: + from leapflow.cli.commands.registry import commands_by_category, resolve_command + + assert resolve_command("shortcut") is None + assert resolve_command("shortcut add hello = hi") is None + assert "Shortcuts" not in commands_by_category() @pytest.mark.asyncio diff --git a/tests/test_context_disclosure.py b/tests/test_context_disclosure.py new file mode 100644 index 0000000..1169eab --- /dev/null +++ b/tests/test_context_disclosure.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +from leapflow.engine.context_disclosure import ( + CapabilityManifest, + DisclosureLevel, + DisclosurePlanner, + DisclosureRuntimeState, +) +from leapflow.tools.registry_bootstrap import TOOL_DEFINITIONS + + +def _tool_names(plan) -> set[str]: + return { + item.get("function", {}).get("name", "") + for item in plan.tool_definitions + } + + +def test_disclosure_planner_keeps_plain_chat_light() -> None: + planner = DisclosurePlanner() + + plan = planner.plan( + "Hello, who are you?", + TOOL_DEFINITIONS, + DisclosureRuntimeState(enable_thinking=True, native_tools_enabled=True), + ) + + assert plan.level == DisclosureLevel.LIGHT + assert plan.native_tools is False + assert plan.tool_definitions == () + assert plan.memory.value == "none" + assert plan.reasoning.value == "off" + + +def test_disclosure_planner_selects_file_tools_without_full_catalog() -> None: + planner = DisclosurePlanner() + + plan = planner.plan( + "Read src/leapflow/engine/engine.py and summarize the relevant section", + TOOL_DEFINITIONS, + DisclosureRuntimeState(native_tools_enabled=True), + ) + + names = _tool_names(plan) + assert plan.level == DisclosureLevel.SELECTED_TOOLS + assert plan.native_tools is True + assert "file_read" in names + assert "file_list" in names + assert "shell_run" not in names + + +def test_disclosure_planner_uses_index_for_capability_questions() -> None: + planner = DisclosurePlanner() + + plan = planner.plan( + "What tools can you use?", + TOOL_DEFINITIONS, + DisclosureRuntimeState(native_tools_enabled=True), + ) + + assert plan.level == DisclosureLevel.INDEXED_CAPABILITIES + assert plan.native_tools is False + assert plan.tool_definitions == () + assert plan.catalog_definitions + assert plan.memory.value == "session_summary" + + +def test_disclosure_planner_uses_full_context_for_runtime_escalation() -> None: + planner = DisclosurePlanner() + + slash_plan = planner.plan( + "/run organize downloads", + TOOL_DEFINITIONS, + DisclosureRuntimeState(slash_command=True, native_tools_enabled=True), + ) + failure_plan = planner.plan( + "continue", + TOOL_DEFINITIONS, + DisclosureRuntimeState(recent_failure=True, native_tools_enabled=True), + ) + + assert slash_plan.level == DisclosureLevel.FULL + assert slash_plan.native_tools is True + assert failure_plan.level == DisclosureLevel.FULL + + +def test_capability_manifest_prefers_explicit_tool_metadata() -> None: + manifest = CapabilityManifest.from_tool_definition({ + "type": "function", + "function": { + "name": "notify_user", + "description": "Send an external notification.", + "x_leapflow": { + "category": "gateway", + "summary": "Notify a person through an external gateway.", + "input_signals": ["alert", "notify"], + "risk_level": "high", + "requires_approval": True, + "schema_cost": "high", + }, + }, + }) + + assert manifest.category == "gateway" + assert manifest.input_signals == ("alert", "notify") + assert manifest.requires_approval is True + assert manifest.schema_cost == "high" diff --git a/tests/test_tui_command_queue.py b/tests/test_tui_command_queue.py index 6ae92aa..abccad8 100644 --- a/tests/test_tui_command_queue.py +++ b/tests/test_tui_command_queue.py @@ -308,6 +308,8 @@ def newline(self) -> None: "compression_reason": "threshold-triggered", "context_posture": "research", "context_guidance": "maintain research ledger and synthesize findings", + "disclosure_level": "selected_tools", + "disclosure_reason": "selected capabilities matched observable task signals", }, ) @@ -317,6 +319,8 @@ def newline(self) -> None: and "threshold-triggered" in line and "research" in line and "maintain research ledger" in line + and "disclosure=selected_tools" in line + and "selected capabilities matched" in line for line in console.lines ) From e80460fe380c0db3e1f1a58653f64ac0c75aedf1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8F=AD=E6=89=AC?= Date: Fri, 10 Jul 2026 22:06:14 +0800 Subject: [PATCH 10/17] fix(tui): use terminal background for code blocks --- src/leapflow/cli/tui_app/console.py | 37 ++++++++++++++++++++++++++--- tests/test_tui_theme.py | 13 ++++++++++ 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/src/leapflow/cli/tui_app/console.py b/src/leapflow/cli/tui_app/console.py index 35ec6e1..affcd14 100644 --- a/src/leapflow/cli/tui_app/console.py +++ b/src/leapflow/cli/tui_app/console.py @@ -12,7 +12,7 @@ from typing import Optional from rich.console import Console -from rich.markdown import Markdown +from rich.markdown import CodeBlock, Markdown from rich.padding import Padding from rich.panel import Panel from rich.rule import Rule @@ -55,6 +55,31 @@ def _build_rich_theme(theme: Theme | ResolvedTheme) -> RichTheme: }) +class _TerminalBackgroundCodeBlock(CodeBlock): + """Markdown code block that keeps the user's terminal background.""" + + def __rich_console__(self, console, options): + code = str(self.text).rstrip() + yield Syntax( + code, + self.lexer_name, + theme=self.theme, + word_wrap=True, + background_color="default", + padding=1, + ) + + +class _TerminalBackgroundMarkdown(Markdown): + """Rich Markdown variant with transparent fenced code blocks.""" + + elements = { + **Markdown.elements, + "fence": _TerminalBackgroundCodeBlock, + "code_block": _TerminalBackgroundCodeBlock, + } + + class LeapConsole: """Unified output surface wrapping ``rich.Console``. @@ -135,7 +160,7 @@ def markdown( """Render markdown content with optional visual spacing.""" if not text.strip(): return - md = Markdown( + md = _TerminalBackgroundMarkdown( text, code_theme=code_theme if self._theme.name == "dark" else "default", ) @@ -152,6 +177,7 @@ def code(self, source: str, language: str = "python", *, title: str = "") -> Non language, theme="monokai" if self._theme.name == "dark" else "default", line_numbers=len(source.splitlines()) > 5, + background_color="default", padding=(0, 1), ) if title: @@ -208,7 +234,12 @@ def tool_result(self, name: str, output: str, *, is_error: bool = False) -> None style = "leap.error" if is_error else "leap.border" self._console.print(Panel( - Syntax(display, "text", theme="monokai" if self._theme.name == "dark" else "default") + Syntax( + display, + "text", + theme="monokai" if self._theme.name == "dark" else "default", + background_color="default", + ) if not is_error else Text(display), title=f"{'✗' if is_error else '↳'} {name}", border_style=style, diff --git a/tests/test_tui_theme.py b/tests/test_tui_theme.py index f6f7de1..a27574e 100644 --- a/tests/test_tui_theme.py +++ b/tests/test_tui_theme.py @@ -6,6 +6,7 @@ from leapflow.cli.banner import display_rich_banner from leapflow.cli.tui_app.app import LeapApp +from leapflow.cli.tui_app.console import _TerminalBackgroundCodeBlock, _TerminalBackgroundMarkdown from leapflow.cli.tui_app.status import StatusBar from leapflow.cli.tui_app.theme import ( _DARK, @@ -151,6 +152,18 @@ def test_rich_banner_accepts_resolved_theme(capsys) -> None: assert "#FFF8DC" not in output +def test_markdown_code_blocks_use_terminal_background() -> None: + from rich.console import Console + + assert _TerminalBackgroundMarkdown.elements["fence"] is _TerminalBackgroundCodeBlock + block = _TerminalBackgroundCodeBlock("text", "monokai") + block.text = "config -> engine" + + syntax = next(block.__rich_console__(Console(), Console().options)) + + assert syntax.background_color == "default" + + def test_status_bar_compacts_on_narrow_terminal(monkeypatch) -> None: monkeypatch.setattr( "leapflow.cli.tui_app.status.shutil.get_terminal_size", From 765d7c767aa3b9f3354108ff0d8d1505bdac66ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8F=AD=E6=89=AC?= Date: Sat, 11 Jul 2026 01:38:40 +0800 Subject: [PATCH 11/17] feat(core): improve adaptive context and TUI UX --- leapflow-architecture.html | 457 ++++++++++++++++++++++ src/leapflow/cli/banner.py | 23 +- src/leapflow/cli/tui_app/app.py | 141 ++++++- src/leapflow/cli/tui_app/console.py | 26 +- src/leapflow/cli/tui_app/input.py | 74 ++-- src/leapflow/cli/tui_app/stream.py | 245 ++++++++---- src/leapflow/cli/tui_app/theme.py | 163 +++++--- src/leapflow/config.py | 5 + src/leapflow/engine/context_control.py | 22 +- src/leapflow/engine/context_disclosure.py | 92 ++++- src/leapflow/engine/engine.py | 172 +++++++- src/leapflow/memory/manager.py | 90 ++++- src/leapflow/memory/protocol.py | 3 + src/leapflow/prompts/templates.py | 10 +- tests/test_agent_execution.py | 48 +++ tests/test_context_disclosure.py | 27 ++ tests/test_context_governance.py | 27 +- tests/test_memory_and_storage.py | 34 ++ tests/test_pure_algorithms.py | 12 + tests/test_tui_command_queue.py | 189 ++++++++- tests/test_tui_session_summary.py | 84 ++++ tests/test_tui_theme.py | 106 ++++- 22 files changed, 1844 insertions(+), 206 deletions(-) create mode 100644 leapflow-architecture.html diff --git a/leapflow-architecture.html b/leapflow-architecture.html new file mode 100644 index 0000000..8beeb16 --- /dev/null +++ b/leapflow-architecture.html @@ -0,0 +1,457 @@ + + + + + + LeapFlow System Architecture + + + + +
+
+
+
+

LeapFlow System Architecture

+
+

Signal-Driven Self-Evolving Agent Framework — Learning and Evolving from Actual Practice

+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + signal feedback + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + INTELLIGENCE CORE (Python) + + + + COGNITIVE PIPELINE + + + + + + + CLI / TUI + prompt_toolkit + rich + Primary User Entry + + + + + Gateway + Multi-Platform Router + Telegram / Discord / Slack / ... + + + + + + Engine — ReAct Loop + Intent Classification · Planning · Tool Dispatch · Budget Control + context_compressor · error_classifier · turn_recovery · prompt_cache + + + + + + + Copilot — Workflow Prediction + L0 Hash → L1 Markov → L2 Embed → L3 LLM · Idle Detection · Proactive Suggestions + + + + + World Model — State Prediction + Trajectory Grading · Curiosity-Driven Learning · Prediction-Error Updates + Experience Store · Embedding Index · Replay + + + + + Skill Synthesis — Hypothesis → Verified → Production + Progressive Trust: STEP → CONFIRM → NOTIFY → AUTO + Registry · Evolution · Sandbox · Action Policy · UI Selector + + + + + Causal Engine — Why Things Happen + Causal Inference · Pattern Mining · Rule/Heuristic/VLM Verification + Intent Inference · Episode Dedup · Segmenter · Synthesis + + + + + Perception — Multi-Channel Signal Fusion (7ch) + Vision · AX Tree · Filesystem · Clipboard · Input · App Focus · Video + Filter (SNR) → Compress (intent-preserving) → Store (multi-layer) + + + + + + Platform Adaptation Layer + MCP Protocol (stdio/WebSocket/gRPC) · Event Normalizer + Reorder Buffer · Capability Negotiation · EventBus + + + + + + Execution Layer (Pluggable Backends) + cua-driver (macOS native, default) · Mock Host (testing) + Screen Capture · Input Injection · Accessibility Tree + + + + + + + Memory System + • Working (token-bounded) + • Episodic (TTL-managed) + • Semantic (vector-indexed) + • Narrative (conversation) + • Evolution (skill history) + Manager · Priority Routing + Protocol-based Providers + + + + + Learning & Evolution + • Active Learning · Skill Merger + • Pattern Miner · Feedback + • Cold Start · Distiller + • Codegen · Doc Generator + Learnability · Similarity + + + + + Analysis Pipeline + • Imitation Learning Pipeline + • Segmenter · Denoiser + • Abstractor · Consensus + • Pattern Pass · FS Pattern + Episode Dedup · Synthesis + + + + + Recording & Attention + • Perceptual Field · Attention + • Video Recorder · Segmenter + • Timeline · Health Monitor + + + + + + + LLM Provider Chain + OpenAI-compatible · DashScope + Provider Chain (fallback) + Model Capabilities · Message Builder + + + + + Security & Privacy + • Approval Workflow (HITL) + • Risk Assessment · Policy + • Redaction · Threat Patterns + Grants · Orchestrator + + + + + Skills Hub + ModelScope · GitHub · HuggingFace + Sync · Signature Verification + Serializer · Security + + + + + Scheduler + Cron · Interval · Event · Condition + Local + Cloud (ModelScope Studio) + Coordinator · Worker Packager + + + + + Storage — DuckDB + • Trajectory Store + • Skill Library · Conversation + • Session · Evolution Store + Schema · Migrate · Write Buffer + + + + + + + Observers (Signal Sources) + App Focus · Clipboard · FS Watcher + Input Tap · Daemon + watchdog · Accessibility API + + + + + + + + + Daemon (leapd) + RPC Server · Lifecycle · Lease · Service + Background Service · Multi-Client + + + + Legend + + + User Entry + + + Intelligence + + + Platform + + + Storage/Memory + + + Security + + + External/LLM + + + Feedback Loop + + + Auth/Security Flow + + +
+ + +
+
+
+
+

Core Design Principles

+
+
    +
  • • Signal-Driven Intelligence — learn from real-world signals
  • +
  • • Context Pipeline — Signal → Filter → Compress → Store → Retrieve → Decide
  • +
  • • Progressive Trust — STEP → CONFIRM → NOTIFY → AUTO
  • +
  • • Prediction-Error Learning — world model learns from deltas
  • +
  • • Event-Driven — typed EventBus, Protocol over ABC
  • +
  • • Dependency Inversion — core depends on abstractions
  • +
+
+ +
+
+
+

Three-Layer Architecture

+
+
    +
  • • Intelligence Core (Python) — Engine, Copilot, World Model, Skills
  • +
  • • Platform Adaptation — MCP protocol, event normalization, capability negotiation
  • +
  • • Execution Layer — cua-driver (macOS), Mock Host, pluggable backends
  • +
  • • 7-Channel Perception — Vision, AX, FS, Clipboard, Input, App, Video
  • +
  • • DuckDB persistence — single source of truth for all state
  • +
+
+ +
+
+
+

Key Modules & Stats

+
+
    +
  • • 18 packages · 180+ Python modules
  • +
  • • Engine: ReAct loop, graph planner, context compressor
  • +
  • • Memory: 5 providers (Working, Episodic, Semantic, Narrative, Evolution)
  • +
  • • Copilot: 4-level predictors (Hash → Markov → Embed → LLM)
  • +
  • • Security: approval workflow, risk assessment, redaction
  • +
  • • Hub: ModelScope / GitHub / HuggingFace backends
  • +
+
+
+ + +
+ + diff --git a/src/leapflow/cli/banner.py b/src/leapflow/cli/banner.py index 87959a2..28c154e 100644 --- a/src/leapflow/cli/banner.py +++ b/src/leapflow/cli/banner.py @@ -74,17 +74,24 @@ def _categorize_skills( _MAX_PANEL_WIDTH = 132 _NARROW_WIDTH = 70 _MEDIUM_WIDTH = 100 +_BANNER_ACCENT = "#FFBF00" +_BANNER_ACCENT_DIM = "#B8860B" +_BANNER_TEXT = "#FFF8DC" +_BANNER_MUTED = "#8B8682" +_BANNER_BORDER = "#CD7F32" +_BANNER_TITLE = "bold #FFD700" +_BANNER_SUCCESS = "#87D687" class _BannerPalette: - def __init__(self, theme: Theme | ResolvedTheme) -> None: - self.accent = theme.accent - self.accent_dim = theme.accent_dim - self.text = theme.text - self.muted = theme.text_muted - self.border = theme.border - self.title = theme.panel_title - self.success = theme.success + def __init__(self, _theme: Theme | ResolvedTheme) -> None: + self.accent = _BANNER_ACCENT + self.accent_dim = _BANNER_ACCENT_DIM + self.text = _BANNER_TEXT + self.muted = _BANNER_MUTED + self.border = _BANNER_BORDER + self.title = _BANNER_TITLE + self.success = _BANNER_SUCCESS def _trim(text: str, limit: int) -> str: diff --git a/src/leapflow/cli/tui_app/app.py b/src/leapflow/cli/tui_app/app.py index 9e99df7..ec21b6b 100644 --- a/src/leapflow/cli/tui_app/app.py +++ b/src/leapflow/cli/tui_app/app.py @@ -41,8 +41,10 @@ from prompt_toolkit.key_binding import KeyBindings from prompt_toolkit.keys import Keys from prompt_toolkit.layout import HSplit, Layout, Window +from prompt_toolkit.layout.containers import Float, FloatContainer from prompt_toolkit.layout.controls import FormattedTextControl from prompt_toolkit.layout.dimension import Dimension +from prompt_toolkit.layout.menus import CompletionsMenu from prompt_toolkit.patch_stdout import patch_stdout from prompt_toolkit.styles import Style as PTStyle from prompt_toolkit.widgets import TextArea @@ -271,16 +273,89 @@ def _accept_auto_suggestion(self, buffer: Any) -> bool: buffer.insert_text(suggestion_text) return True + def _completion_is_open(self, buffer: Any) -> bool: + """Return True when prompt_toolkit is showing completion candidates.""" + return getattr(buffer, "complete_state", None) is not None + + def _close_completion(self, buffer: Any) -> bool: + """Close the active completion menu when present.""" + if not self._completion_is_open(buffer): + return False + buffer.cancel_completion() + self._invalidate() + return True + + def _cursor_at_first_input_line(self, buffer: Any) -> bool: + """Return True when Up should leave line navigation and enter history.""" + try: + return buffer.document.cursor_position_row <= 0 + except (AttributeError, TypeError): + return True + + def _cursor_at_last_input_line(self, buffer: Any) -> bool: + """Return True when Down should leave line navigation and enter history.""" + try: + document = buffer.document + return document.cursor_position_row >= document.line_count - 1 + except (AttributeError, TypeError): + return True + + def _move_history_backward(self, buffer: Any) -> bool: + """Move to an older history entry while preserving the current draft.""" + before_index = getattr(buffer, "working_index", None) + try: + buffer.history_backward() + except AttributeError: + return False + moved = getattr(buffer, "working_index", None) != before_index + if moved: + self._invalidate() + return moved + + def _move_history_forward(self, buffer: Any) -> bool: + """Move to a newer history entry, restoring the draft at the newest slot.""" + before_index = getattr(buffer, "working_index", None) + try: + buffer.history_forward() + except AttributeError: + return False + moved = getattr(buffer, "working_index", None) != before_index + if moved: + self._invalidate() + return moved + + def _completion_previous_or_cursor_up(self, buffer: Any) -> None: + """Navigate completion candidates, then history, then multiline cursor movement.""" + if self._completion_is_open(buffer): + buffer.complete_previous() + return + if self._cursor_at_first_input_line(buffer) and self._move_history_backward(buffer): + return + buffer.cursor_up() + + def _completion_next_or_cursor_down(self, buffer: Any) -> None: + """Navigate completion candidates, then history, then multiline cursor movement.""" + if self._completion_is_open(buffer): + buffer.complete_next() + return + if self._cursor_at_last_input_line(buffer) and self._move_history_forward(buffer): + return + buffer.cursor_down() + def _accept_or_start_completion(self, buffer: Any) -> None: - """Accept the selected completion or open/cycle completion candidates.""" + """Accept the selected completion or open completion candidates.""" complete_state = getattr(buffer, "complete_state", None) current_completion = getattr(complete_state, "current_completion", None) + if complete_state is not None and current_completion is None: + buffer.complete_next() + complete_state = getattr(buffer, "complete_state", None) + current_completion = getattr(complete_state, "current_completion", None) if current_completion is not None: buffer.apply_completion(current_completion) return if self._accept_auto_suggestion(buffer): return - buffer.complete_next() + buffer.start_completion(select_first=True) def _move_right_or_accept_suggestion(self, buffer: Any) -> None: """Keep normal Right-arrow movement while accepting visible suggestions at EOL.""" @@ -396,7 +471,23 @@ def _build_application(self) -> Application[Any]: wrap_lines=False, style="class:status-bar", ) - layout = Layout(HSplit([spinner, status_bar, self._input_area])) + root = HSplit([spinner, status_bar, self._input_area]) + layout = Layout( + FloatContainer( + content=root, + floats=[ + Float( + xcursor=True, + ycursor=True, + content=CompletionsMenu( + max_height=12, + scroll_offset=1, + display_arrows=True, + ), + ) + ], + ) + ) cursor_kwargs: dict[str, Any] = {} try: @@ -425,14 +516,33 @@ def _build_keybindings(self) -> KeyBindings: @kb.add(Keys.Enter) def _(event): - text = event.app.current_buffer.text.strip() + buffer = event.app.current_buffer + complete_state = getattr(buffer, "complete_state", None) + current_completion = getattr(complete_state, "current_completion", None) + if complete_state is not None and current_completion is None: + buffer.complete_next() + complete_state = getattr(buffer, "complete_state", None) + current_completion = getattr(complete_state, "current_completion", None) + if current_completion is not None: + before = buffer.text + buffer.apply_completion(current_completion) + if buffer.text != before: + return + text = buffer.text.strip() if not text: return has_compacted_paste = ref._paste_store.has_blocks ref.submit_text(text) - event.app.current_buffer.reset(append_to_history=not has_compacted_paste) + buffer.reset(append_to_history=not has_compacted_paste) ref._reset_paste_fragment_window() + @kb.add(Keys.Escape) + def _(event): + if ref._close_completion(event.current_buffer): + return + event.current_buffer.reset() + ref._clear_paste_state() + @kb.add(Keys.Escape, Keys.Enter) def _(event): event.current_buffer.insert_text("\n") @@ -441,6 +551,14 @@ def _(event): def _(event): ref._accept_or_start_completion(event.current_buffer) + @kb.add(Keys.Up) + def _(event): + ref._completion_previous_or_cursor_up(event.current_buffer) + + @kb.add(Keys.Down) + def _(event): + ref._completion_next_or_cursor_down(event.current_buffer) + @kb.add(Keys.Right) def _(event): ref._move_right_or_accept_suggestion(event.current_buffer) @@ -483,16 +601,21 @@ def _build_style(self) -> PTStyle: "prompt.recording": t.recording, "prompt.paused": t.prompt_paused, "prompt.executing": t.executing, - "status-bar": f"bg:{t.toolbar_bg} {t.toolbar_fg}", - "status-bar.strong": f"bg:{t.toolbar_bg} bold {t.accent}", - "status-bar.dim": f"bg:{t.toolbar_bg} {t.text_muted}", - "status-bar.good": f"bg:{t.toolbar_bg} {t.success}", + "status-bar": f"bg:{t.toolbar_bg} {t.statusbar_fg}", + "status-bar.strong": f"bg:{t.toolbar_bg} bold {t.statusbar_accent}", + "status-bar.dim": f"bg:{t.toolbar_bg} {t.statusbar_dim}", + "status-bar.good": f"bg:{t.toolbar_bg} {t.statusbar_good}", "status-bar.warn": f"bg:{t.toolbar_bg} {t.warning}", "status-bar.bad": f"bg:{t.toolbar_bg} {t.error}", "hint": t.text_dim, "auto-suggest": t.auto_suggest, "placeholder": t.input_placeholder, "selection": f"bg:{t.input_selection_bg} {t.input_selection_fg}", + "completion-menu": f"bg:{t.toolbar_bg} {t.input_text}", + "completion-menu.completion": f"bg:{t.toolbar_bg} {t.input_text}", + "completion-menu.completion.current": f"bg:{t.input_selection_bg} bold {t.input_selection_fg}", + "completion-menu.meta.completion": f"bg:{t.toolbar_bg} {t.text_muted}", + "completion-menu.meta.completion.current": f"bg:{t.input_selection_bg} {t.input_selection_fg}", }) # ── Fragment providers ─────────────────────────────────────────── diff --git a/src/leapflow/cli/tui_app/console.py b/src/leapflow/cli/tui_app/console.py index affcd14..230966b 100644 --- a/src/leapflow/cli/tui_app/console.py +++ b/src/leapflow/cli/tui_app/console.py @@ -51,6 +51,21 @@ def _build_rich_theme(theme: Theme | ResolvedTheme) -> RichTheme: "leap.recording": theme.recording, "leap.executing": theme.executing, "leap.panel_title": theme.panel_title, + "leap.answer_border": theme.statusbar_dim, + "leap.answer_title": f"bold {theme.statusbar_accent}", + "leap.tool": theme.text_muted, + "leap.tool_name": f"bold {theme.text_muted}", + "markdown.h1": f"bold {theme.text}", + "markdown.h2": f"bold {theme.accent_dim}", + "markdown.h3": f"bold {theme.text_dim}", + "markdown.h4": f"bold {theme.text_dim}", + "markdown.h5": theme.text_dim, + "markdown.h6": theme.text_dim, + "markdown.strong": f"bold {theme.text}", + "markdown.em": theme.text_dim, + "markdown.hr": theme.border_dim, + "markdown.item": theme.text, + "markdown.code": f"bold {theme.info}", "rule.line": theme.border, }) @@ -64,7 +79,7 @@ def __rich_console__(self, console, options): code, self.lexer_name, theme=self.theme, - word_wrap=True, + word_wrap=False, background_color="default", padding=1, ) @@ -259,6 +274,15 @@ def thinking(self, text: str) -> None: padding=(0, 1), )) + def answer_label(self) -> None: + """Print a clear warm boundary before the user-facing final answer.""" + title = Text(" LeapFlow ", style="leap.answer_title") + self._console.print(Rule( + title=title, + style="leap.answer_border", + align="left", + )) + def response_label(self, elapsed_s: float, *, tool_count: int = 0) -> None: """Print the response attribution line with elapsed time.""" from leapflow.cli.tui_app.stream import _format_elapsed diff --git a/src/leapflow/cli/tui_app/input.py b/src/leapflow/cli/tui_app/input.py index 4e2701e..a6338b6 100644 --- a/src/leapflow/cli/tui_app/input.py +++ b/src/leapflow/cli/tui_app/input.py @@ -1,9 +1,8 @@ """Slash-command completion for the TUI input area. -Provides tab-completion for REPL slash commands, consumed by the -TextArea widget in the Application layout. The completer runs in -a background thread (via ``ThreadedCompleter``) to keep the UI -responsive during completion. +Provides interactive slash-command suggestions for the TextArea widget. +The completer keeps command discovery read-only and lightweight: it only uses +the static command registry entries already loaded by the CLI layer. """ from __future__ import annotations @@ -13,45 +12,66 @@ from prompt_toolkit.completion import Completer, Completion, ThreadedCompleter if TYPE_CHECKING: + from collections.abc import Iterable + from prompt_toolkit.document import Document -class _SlashCompleter(Completer): - """Complete slash-style commands from a static command list. +_MAX_DESCRIPTION_WIDTH = 88 + + +def _truncate_meta(text: str, *, width: int = _MAX_DESCRIPTION_WIDTH) -> str: + normalized = " ".join(str(text).split()) + if len(normalized) <= width: + return normalized + return normalized[: max(0, width - 1)].rstrip() + "…" + - Matches both ``/help`` and bare ``help`` input styles so the user - can type either form. +class SlashCommandCompleter(Completer): + """Complete slash commands with display text and descriptions. + + The returned ``Completion`` objects are intentionally prompt_toolkit-native: + ``display`` renders the left command column and ``display_meta`` renders the + right description column used by ``CompletionsMenu``. This gives LeapFlow a + Hermes-style slash menu without owning a custom focus or scroll state. """ def __init__(self, commands: Sequence[tuple[str, str]]) -> None: - self._commands = commands + self._commands = tuple(commands) + + @property + def commands(self) -> tuple[tuple[str, str], ...]: + """Return the immutable command catalog used by this completer.""" + return self._commands - def get_completions(self, document: "Document", complete_event): + def get_completions( + self, + document: "Document", + complete_event, + ) -> "Iterable[Completion]": text = document.text_before_cursor.lstrip() if not text: return has_slash = text.startswith("/") - query = text.lstrip("/") - if not query: - for cmd, desc in self._commands: - yield Completion( - f"/{cmd}", - start_position=-len(text), - display_meta=desc, - ) + if not has_slash and " " in text: return - for cmd, desc in self._commands: - if cmd.startswith(query) and cmd != query: - completion = f"/{cmd}" if has_slash else cmd - yield Completion( - completion, - start_position=-len(text), - display_meta=desc, - ) + + query = text.lstrip("/").lower() + for command, description in self._commands: + command_lower = command.lower() + if query and not command_lower.startswith(query): + continue + completion = f"/{command}" if has_slash else command + yield Completion( + completion, + start_position=-len(text), + display=f"/{command}", + display_meta=_truncate_meta(description), + ) def build_completer( commands: Sequence[tuple[str, str]], ) -> ThreadedCompleter: """Create a threaded slash-command completer for the TextArea.""" - return ThreadedCompleter(_SlashCompleter(commands or [])) + return ThreadedCompleter(SlashCommandCompleter(commands or [])) diff --git a/src/leapflow/cli/tui_app/stream.py b/src/leapflow/cli/tui_app/stream.py index ad59717..dd39227 100644 --- a/src/leapflow/cli/tui_app/stream.py +++ b/src/leapflow/cli/tui_app/stream.py @@ -12,6 +12,9 @@ from __future__ import annotations +import json +import os +import re import time from typing import TYPE_CHECKING, Any @@ -19,7 +22,94 @@ _FINAL_RESPONSE_INDENT_SPACES = 4 _FINAL_RESPONSE_MARGIN_TOP = 1 -_TOOL_DETAIL_LIMIT = 180 +_TOOL_INPUT_LIMIT = 96 +_TOOL_OUTPUT_LIMIT = 96 +_TOOL_PATH_LIMIT = 72 +_TOOL_CONTEXT_TAG_LIMIT = 3 +_SYNTHETIC_THINKING_ROUND_RE = re.compile(r"round\s*\d+", re.IGNORECASE) +_FENCED_BLOCK_RE = re.compile(r"```(?P[\w+-]*)\s*\n(?P.*?)\n```", re.DOTALL) +_TOOL_AUDIT_LINE_RE = re.compile( + r"^\s*[·✓✗]\s+[A-Za-z_][\w.-]*(?:\s|$).*", + re.MULTILINE, +) +_JSON_DECODER = json.JSONDecoder() + + +def _is_tool_protocol_payload(value: Any) -> bool: + return ( + isinstance(value, dict) + and isinstance(value.get("name"), str) + and isinstance(value.get("arguments"), dict) + ) + + +def _strip_tool_protocol_fences(text: str) -> str: + def replace(match: re.Match[str]) -> str: + body = match.group("body").strip() + try: + payload = json.loads(body) + except json.JSONDecodeError: + return match.group(0) + return "" if _is_tool_protocol_payload(payload) else match.group(0) + + return _FENCED_BLOCK_RE.sub(replace, text) + + +def _strip_tool_protocol_json_objects(text: str) -> str: + pieces: list[str] = [] + cursor = 0 + while True: + start = text.find("{", cursor) + if start < 0: + pieces.append(text[cursor:]) + break + pieces.append(text[cursor:start]) + try: + payload, end = _JSON_DECODER.raw_decode(text[start:]) + except json.JSONDecodeError: + pieces.append(text[start:start + 1]) + cursor = start + 1 + continue + absolute_end = start + end + if _is_tool_protocol_payload(payload): + cursor = absolute_end + while cursor < len(text) and text[cursor] in " \t\r": + cursor += 1 + if cursor < len(text) and text[cursor] == "\n": + cursor += 1 + continue + pieces.append(text[start:absolute_end]) + cursor = absolute_end + return "".join(pieces) + + +def _collapse_blank_lines(text: str) -> str: + lines = text.splitlines() + collapsed: list[str] = [] + blank_count = 0 + for line in lines: + if line.strip(): + blank_count = 0 + collapsed.append(line.rstrip()) + continue + blank_count += 1 + if blank_count <= 1: + collapsed.append("") + return "\n".join(collapsed).strip() + + +def _sanitize_final_response(text: str) -> str: + """Remove leaked tool protocol artifacts from user-facing final answers.""" + without_fences = _strip_tool_protocol_fences(text) + without_objects = _strip_tool_protocol_json_objects(without_fences) + without_audit_lines = _TOOL_AUDIT_LINE_RE.sub("", without_objects) + return _collapse_blank_lines(without_audit_lines) + + +def _normalize_thinking_text(text: str) -> str: + without_round_markers = _SYNTHETIC_THINKING_ROUND_RE.sub(" ", text) + lines = [" ".join(line.split()) for line in without_round_markers.splitlines()] + return "\n".join(line for line in lines if line).strip() def _metadata_text(metadata: dict[str, Any] | None, key: str) -> str: @@ -29,72 +119,84 @@ def _metadata_text(metadata: dict[str, Any] | None, key: str) -> str: return value if isinstance(value, str) else ("" if value is None else str(value)) -def _truncate_detail(text: str, *, limit: int = _TOOL_DETAIL_LIMIT) -> str: +def _truncate_detail(text: str, *, limit: int = _TOOL_OUTPUT_LIMIT) -> str: compact = " ".join(text.split()) if len(compact) <= limit: return compact return compact[: limit - 1] + "…" +def _compact_path(path: str, *, limit: int = _TOOL_PATH_LIMIT) -> str: + expanded_home = os.path.expanduser("~") + compact = path.replace(expanded_home, "~", 1) if path.startswith(expanded_home) else path + if len(compact) <= limit: + return compact + parts = compact.split("/") + if len(parts) > 3: + compact = "…/" + "/".join(parts[-3:]) + if len(compact) <= limit: + return compact + return "…" + compact[-(limit - 1):] + + +def _looks_like_structured_blob(text: str) -> bool: + stripped = text.lstrip() + return stripped.startswith("{") or stripped.startswith("[") + + +def _context_tags(metadata: dict[str, Any] | None) -> list[str]: + if not metadata: + return [] + tags: list[str] = [] + posture = _metadata_text(metadata, "context_posture") + if posture and posture != "baseline": + tags.append(posture) + mode = _metadata_text(metadata, "mode") + if mode and mode not in {"raw", posture}: + tags.append(mode) + disclosure = _metadata_text(metadata, "disclosure_level") + if disclosure and disclosure not in {"selected_tools", "minimal"}: + tags.append(f"disclosure={disclosure}") + if metadata.get("tool_truncated"): + tags.append("truncated") + read_count = metadata.get("read_count") + if metadata.get("repeat_read") and read_count is not None: + tags.append(f"repeat×{read_count}") + elif metadata.get("context_evidence"): + tags.append("evidence") + if metadata.get("compression_stages"): + tags.append("compressed") + deduped: list[str] = [] + for tag in tags: + if tag not in deduped: + deduped.append(tag) + return deduped[:_TOOL_CONTEXT_TAG_LIMIT] + + def _tool_action_detail(metadata: dict[str, Any] | None) -> str: if not metadata: return "" command = _metadata_text(metadata, "command") or _metadata_text(metadata, "cmd") if command: - return f"$ {_truncate_detail(command)}" + return f"$ {_truncate_detail(command, limit=_TOOL_INPUT_LIMIT)}" path = _metadata_text(metadata, "path") pattern = _metadata_text(metadata, "pattern") if path and pattern: - return f"path={_truncate_detail(path)} pattern={_truncate_detail(pattern, limit=80)}" + return f"path={_compact_path(path)} pattern={_truncate_detail(pattern, limit=48)}" if path: - return f"path={_truncate_detail(path)}" + return f"path={_compact_path(path)}" query = _metadata_text(metadata, "query") if query: - return f"query={_truncate_detail(query)}" + return f"query={_truncate_detail(query, limit=_TOOL_INPUT_LIMIT)}" url = _metadata_text(metadata, "url") if url: - return f"url={_truncate_detail(url)}" - return _truncate_detail(_metadata_text(metadata, "args_summary")) + return f"url={_truncate_detail(url, limit=_TOOL_INPUT_LIMIT)}" + return _truncate_detail(_metadata_text(metadata, "args_summary"), limit=_TOOL_INPUT_LIMIT) def _tool_context_detail(metadata: dict[str, Any] | None) -> str: - if not metadata: - return "" - parts: list[str] = [] - mode = _metadata_text(metadata, "mode") - if mode and mode != "raw": - parts.append(f"mode={mode}") - if metadata.get("tool_truncated"): - parts.append("truncated") - stages = metadata.get("compression_stages") - if isinstance(stages, list) and stages: - stage_text = "+".join(str(stage) for stage in stages[:3]) - parts.append(f"compressed={stage_text}") - savings = metadata.get("compression_savings_ratio") - if isinstance(savings, (int, float)) and savings > 0: - parts.append(f"saved≈{int(savings * 100)}%") - reason = _metadata_text(metadata, "compression_reason") - if reason: - parts.append(reason) - posture = _metadata_text(metadata, "context_posture") - if posture and posture != "baseline": - parts.append(posture) - guidance = _metadata_text(metadata, "context_guidance") - if guidance: - parts.append(_truncate_detail(guidance, limit=72)) - disclosure = _metadata_text(metadata, "disclosure_level") - if disclosure: - parts.append(f"disclosure={disclosure}") - disclosure_reason = _metadata_text(metadata, "disclosure_reason") - if disclosure_reason: - parts.append(_truncate_detail(disclosure_reason, limit=72)) - read_count = metadata.get("read_count") - if metadata.get("repeat_read") and read_count is not None: - parts.append(f"repeat-read x{read_count}") - elif metadata.get("context_evidence"): - parts.append("evidence") - return ", ".join(parts) - + tags = _context_tags(metadata) + return f"[{' · '.join(tags)}]" if tags else "" def _tool_result_detail(metadata: dict[str, Any] | None) -> str: if not metadata: @@ -107,23 +209,21 @@ def _tool_result_detail(metadata: dict[str, Any] | None) -> str: or _metadata_text(metadata, "error_preview") or _metadata_text(metadata, "result_preview") ) - base = _truncate_detail(prefix + detail) if detail or prefix else "failed" + base = _truncate_detail(prefix + detail, limit=_TOOL_OUTPUT_LIMIT) if detail or prefix else "failed" context = _tool_context_detail(metadata) - return f"{base} ({context})" if context else base + return f"{base} {context}" if context else base detail = ( _metadata_text(metadata, "stdout_preview") or _metadata_text(metadata, "content_preview") or _metadata_text(metadata, "output_preview") or _metadata_text(metadata, "result_preview") ) - if detail: - base = _truncate_detail(detail) - elif metadata.get("path"): - base = f"path={_truncate_detail(str(metadata['path']))}" + if detail and not _looks_like_structured_blob(detail): + base = _truncate_detail(detail, limit=_TOOL_OUTPUT_LIMIT) else: - base = "ok (no output)" + base = "ok" context = _tool_context_detail(metadata) - return f"{base} ({context})" if context else base + return f"{base} {context}" if context else base if TYPE_CHECKING: @@ -196,8 +296,13 @@ def feed(self, chunk: str) -> None: self._buffer += chunk def feed_thinking(self, chunk: str) -> None: - """Append a thinking/reasoning chunk.""" - self._thinking_buffer += chunk + """Append meaningful thinking/reasoning text.""" + text = _normalize_thinking_text(chunk) + if not text: + return + if self._thinking_buffer and not self._thinking_buffer.endswith("\n"): + self._thinking_buffer += "\n" + self._thinking_buffer += text def tool_started(self, name: str, metadata: dict[str, Any] | None = None) -> str: """Mark a tool call as started. Returns spinner text for LeapApp.""" @@ -205,11 +310,11 @@ def tool_started(self, name: str, metadata: dict[str, Any] | None = None) -> str self._tool_start_time = time.monotonic() detail = _tool_action_detail(metadata) line = Text() - line.append(" ⚡ ", style="leap.accent") - line.append(name, style="bold") + line.append(" · ", style="leap.tool") + line.append(name, style="leap.tool_name") if detail: - line.append(" ", style="dim") - line.append(detail, style="leap.dim") + line.append(" ", style="leap.tool") + line.append(detail, style="leap.tool") self._console.print(line) return f"⚡ {name}" @@ -226,13 +331,15 @@ def tool_finished( self._tool_history.append((tool_name, duration)) ok = metadata.get("ok", True) if metadata else True line = Text() - line.append(" ✓ " if ok else " ✗ ", style="leap.success" if ok else "leap.error") - line.append(tool_name, style="bold") - line.append(f" {_format_elapsed(duration)}", style="dim") - detail = _tool_result_detail(metadata) or _truncate_detail(output) + status_style = "leap.tool" if ok else "leap.error" + name_style = "leap.tool_name" if ok else "leap.error" + line.append(" ✓ " if ok else " ✗ ", style=status_style) + line.append(tool_name, style=name_style) + line.append(f" {_format_elapsed(duration)}", style="leap.tool") + detail = _tool_result_detail(metadata) or _truncate_detail(output, limit=_TOOL_OUTPUT_LIMIT) if detail: - line.append(" ", style="dim") - line.append(detail, style="leap.dim" if ok else "leap.error") + line.append(" ", style="leap.tool") + line.append(detail, style="leap.tool" if ok else "leap.error") self._console.print(line) self._active_tool = "" self._tool_start_time = 0.0 @@ -242,9 +349,13 @@ def finish(self) -> None: if self._thinking_buffer.strip(): self._console.thinking(self._thinking_buffer) - if self._buffer.strip(): + answer = _sanitize_final_response(self._buffer) + if answer: + answer_label = getattr(self._console, "answer_label", None) + if callable(answer_label): + answer_label() self._console.markdown( - self._buffer, + answer, indent=_FINAL_RESPONSE_INDENT_SPACES, margin_top=_FINAL_RESPONSE_MARGIN_TOP, ) diff --git a/src/leapflow/cli/tui_app/theme.py b/src/leapflow/cli/tui_app/theme.py index ad6dc27..0f45008 100644 --- a/src/leapflow/cli/tui_app/theme.py +++ b/src/leapflow/cli/tui_app/theme.py @@ -41,43 +41,43 @@ "#808080", ) _PROMPT_CANDIDATES = ( - "#FFD700", - "#FACC15", - "#FFBF00", - "#CC6600", - "#996600", - "#8A4B00", - "#78350F", + "#E6DDC4", + "#F1EAD6", + "#CBD5E1", + "#94A3B8", + "#64748B", + "#475569", + "#334155", "#FFFFFF", "#111827", ) _BORDER_CANDIDATES = ( - "#CD7F32", - "#B45309", - "#996600", - "#8A4B00", - "#78350F", + "#D8D1BB", + "#C8C3B3", + "#A8B2AF", + "#94A3B8", "#64748B", "#475569", "#FFFFFF", "#111827", ) _TEXT_CANDIDATES = ( - "#FFF8DC", + "#F1EAD6", "#F8FAFC", "#E5E7EB", + "#CBD5E1", "#111827", "#1A1A1A", "#000000", ) _MUTED_TEXT_CANDIDATES = ( - "#B8860B", + "#C8C3B3", + "#A8B2AF", "#94A3B8", "#CBD5E1", "#64748B", "#475569", "#808080", - "#78350F", ) _ANSI_BG = { 0: "#000000", @@ -130,6 +130,10 @@ class Theme: input_disabled_text: str toolbar_bg: str toolbar_fg: str + statusbar_fg: str + statusbar_accent: str + statusbar_dim: str + statusbar_good: str prompt_paused: str auto_suggest: str @@ -144,65 +148,73 @@ class ResolvedTheme(Theme): _DARK = Theme( name="dark", - accent="#FFBF00", - accent_dim="#B8860B", - success="#87D687", - warning="#FFD700", - error="#FF6B6B", - info="#87CEEB", - text="#FFF8DC", - text_dim="#B8860B", - text_muted="#8B8682", - border="#CD7F32", - border_dim="#8B6914", - panel_title="bold #FFD700", - recording="bold #FF6B6B", - executing="bold #87D687", - code_bg="#1c1c1c", - prompt_char="bold #FFD700", - input_text="#FFFFFF", + accent="#E6DDC4", + accent_dim="#A8B2AF", + success="#A7C7A1", + warning="#D8C690", + error="#F08A8A", + info="#C8D3CF", + text="#F1EAD6", + text_dim="#C8C3B3", + text_muted="#9AA6A3", + border="#D8D1BB", + border_dim="#8FA09B", + panel_title="bold #E6DDC4", + recording="bold #F08A8A", + executing="bold #A7C7A1", + code_bg="#0B1F24", + prompt_char="bold #E6DDC4", + input_text="#F8FAFC", input_bg=_DARK_TERMINAL_BG, input_placeholder="#94A3B8", - input_border="#8B6914", - input_focus_border="#FFD700", + input_border="#8FA09B", + input_focus_border="#E6DDC4", input_selection_bg="#334155", - input_selection_fg="#FFFFFF", + input_selection_fg="#F8FAFC", input_disabled_text="#94A3B8", - toolbar_bg="#2a2418", - toolbar_fg="#B8860B", - prompt_paused="bold #FFD700", + toolbar_bg="#102A2F", + toolbar_fg="#A8B2AF", + statusbar_fg="#CD7F32", + statusbar_accent="#FFBF00", + statusbar_dim="#B8860B", + statusbar_good="#FFBF00", + prompt_paused="bold #D8C690", auto_suggest="#94A3B8", ) _LIGHT = Theme( name="light", - accent="#8A4B00", - accent_dim="#7A3E00", - success="#007700", - warning="#8A4B00", - error="#cc0000", - info="#0055cc", + accent="#334155", + accent_dim="#64748B", + success="#2F6F4E", + warning="#8A6D1D", + error="#B42318", + info="#475569", text="#111827", text_dim="#475569", text_muted="#64748B", - border="#8A4B00", - border_dim="#64748B", - panel_title="bold #8A4B00", - recording="bold #cc0000", - executing="bold #007700", - code_bg="#ededed", - prompt_char="bold #8A4B00", - input_text="#1A1A1A", + border="#64748B", + border_dim="#94A3B8", + panel_title="bold #334155", + recording="bold #B42318", + executing="bold #2F6F4E", + code_bg="#F8FAFC", + prompt_char="bold #334155", + input_text="#111827", input_bg=_LIGHT_TERMINAL_BG, input_placeholder="#64748B", - input_border="#8A4B00", - input_focus_border="#78350F", + input_border="#64748B", + input_focus_border="#334155", input_selection_bg="#D6E4FF", input_selection_fg="#111827", input_disabled_text="#64748B", toolbar_bg=_LIGHT_TERMINAL_BG, - toolbar_fg="#374151", - prompt_paused="bold #8A4B00", + toolbar_fg="#475569", + statusbar_fg="#8B5E34", + statusbar_accent="#B8860B", + statusbar_dim="#A16207", + statusbar_good="#B8860B", + prompt_paused="bold #8A6D1D", auto_suggest="#64748B", ) @@ -444,6 +456,41 @@ def resolve_theme( min_ratio=_SECONDARY_TEXT_MIN_CONTRAST, candidates=_MUTED_TEXT_CANDIDATES, ) + statusbar_fg = ensure_contrast( + base.statusbar_fg, + surface_bg, + min_ratio=_SECONDARY_TEXT_MIN_CONTRAST, + candidates=( + base.statusbar_accent, + base.statusbar_dim, + "#FFBF00", + "#CD7F32", + "#8B5E34", + ), + ) + statusbar_accent = ensure_contrast( + base.statusbar_accent, + surface_bg, + min_ratio=_SECONDARY_TEXT_MIN_CONTRAST, + candidates=("#FFBF00", "#D97706", "#B8860B", "#8B5E34"), + ) + statusbar_dim = ensure_contrast( + base.statusbar_dim, + surface_bg, + min_ratio=_SECONDARY_TEXT_MIN_CONTRAST, + candidates=(base.statusbar_fg, "#CD7F32", "#B8860B", "#8B5E34"), + ) + statusbar_good = ensure_contrast( + base.statusbar_good, + surface_bg, + min_ratio=_SECONDARY_TEXT_MIN_CONTRAST, + candidates=( + base.statusbar_accent, + "#FFBF00", + "#D97706", + "#B8860B", + ), + ) prompt_color = ensure_contrast( _style_color(base.prompt_char) or base.accent, background, @@ -497,6 +544,10 @@ def resolve_theme( input_disabled_text=input_disabled_text, toolbar_bg=surface_bg, toolbar_fg=toolbar_fg, + statusbar_fg=statusbar_fg, + statusbar_accent=statusbar_accent, + statusbar_dim=statusbar_dim, + statusbar_good=statusbar_good, prompt_paused=_with_style_color(base.prompt_paused, prompt_paused_color), auto_suggest=auto_suggest, terminal_bg=background, diff --git a/src/leapflow/config.py b/src/leapflow/config.py index c55a1ef..96208ef 100644 --- a/src/leapflow/config.py +++ b/src/leapflow/config.py @@ -139,6 +139,7 @@ class Settings: # ── Data Root & Profile ── data_dir: Path = Path("~/.leapflow") profile: str = "default" + workspace_root: Path = Path(".") # Audit audit_log_path: Path = Path("~/.leapflow/profiles/default/audit.jsonl") @@ -570,6 +571,9 @@ def _build_settings_from_env() -> Settings: data_dir = _expand_path(os.getenv("LEAPFLOW_DATA_DIR", "~/.leapflow").strip()) profile = _validate_profile_name(os.getenv("LEAPFLOW_PROFILE", "default")) + workspace_root = _expand_path( + os.getenv("LEAPFLOW_WORKSPACE_ROOT", str(Path.cwd())).strip() or str(Path.cwd()) + ).resolve() _profile_dir = data_dir / "profiles" / profile mock_host = os.getenv("LEAPFLOW_MOCK_HOST", "0").strip() in ("1", "true", "True", "yes") @@ -893,6 +897,7 @@ def _build_settings_from_env() -> Settings: memory_prefetch_limit=memory_prefetch_limit, data_dir=data_dir, profile=profile, + workspace_root=workspace_root, audit_log_path=_expand_path(audit_log_path), skills_dir=_expand_path(skills_dir), skill_view_max_chars=skill_view_max_chars, diff --git a/src/leapflow/engine/context_control.py b/src/leapflow/engine/context_control.py index f7d00fd..dfe964d 100644 --- a/src/leapflow/engine/context_control.py +++ b/src/leapflow/engine/context_control.py @@ -399,6 +399,15 @@ def __post_init__(self) -> None: self._tool_counts: dict[str, int] = {} self._evidence_count = 0 + def reset_turn_scope(self) -> None: + """Clear per-turn exploration state so posture never leaks across tasks.""" + self._reads.clear() + self._sources_seen.clear() + self._tool_counts.clear() + self._evidence_count = 0 + + reset_task_scope = reset_turn_scope + def compact_tool_result(self, tool_name: str, arguments: Dict[str, Any] | None, result: Any) -> Any: """Return evidence and update the session exploration ledger.""" self._tool_counts[tool_name] = self._tool_counts.get(tool_name, 0) + 1 @@ -460,7 +469,11 @@ def snapshot(self, *, context_ratio: float = 0.0, round_number: int = 0) -> Expl posture = _POSTURE_CONVERGING dominant_signal = "repeat-read" if repeated_reads > 0 else "long-exploration" convergence_reason = "repeat reads detected" if repeated_reads > 0 else "exploration round limit reached" - guidance = "deduplicate evidence and prefer targeted reads" + guidance = ( + "switch to complementary sources, outlines, symbols, or bounded ranges" + if repeated_reads > 0 else + "deduplicate evidence and prefer targeted reads" + ) elif sources_seen >= cfg.research_source_threshold or self._evidence_count >= cfg.research_evidence_threshold: posture = _POSTURE_RESEARCH dominant_signal = "multi-source" if sources_seen >= cfg.research_source_threshold else "evidence-volume" @@ -488,6 +501,13 @@ def convergence_notice(self, round_number: int) -> str: snapshot = self.snapshot(round_number=round_number) if not snapshot.should_converge: return "" + if snapshot.dominant_signal == "repeat-read": + return ( + "SYSTEM: Adaptive context governance detected repeated reads. " + "Do not reread the same raw source again. Pivot to complementary project evidence: " + "directory outline, symbols, bounded line ranges, adjacent modules, tests, docs, or synthesize " + "from the evidence already gathered if enough context exists." + ) reason = snapshot.convergence_reason or snapshot.dominant_signal or "context pressure" return ( "SYSTEM: Adaptive context governance is converging " diff --git a/src/leapflow/engine/context_disclosure.py b/src/leapflow/engine/context_disclosure.py index e54ae06..8422745 100644 --- a/src/leapflow/engine/context_disclosure.py +++ b/src/leapflow/engine/context_disclosure.py @@ -18,6 +18,7 @@ class DisclosureLevel(str, Enum): LIGHT = "light" INDEXED_CAPABILITIES = "indexed_capabilities" SELECTED_TOOLS = "selected_tools" + PROJECT_RESEARCH = "project_research" FULL = "full" @@ -165,6 +166,13 @@ def plan( ) selected = self._select_capabilities(user_text, manifests) + if _needs_project_research(user_text): + return self.project_research_plan( + user_text, + tool_definitions, + runtime, + "project research task needs scoped file/document evidence without full catalog", + ) if self._needs_full_context(user_text, selected, runtime): return self.full_plan(user_text, tool_definitions, runtime, "task requires broad execution context") @@ -199,6 +207,37 @@ def plan( max_prior_turns=2, ) + def project_research_plan( + self, + user_text: str, + tool_definitions: Sequence[Mapping[str, Any]], + runtime: DisclosureRuntimeState, + reason: str, + ) -> PromptAssemblyPlan: + """Build a scoped research plan for code/doc exploration tasks.""" + selected_defs: list[Mapping[str, Any]] = [] + selected_names: list[str] = [] + for tool_definition in tool_definitions: + manifest = CapabilityManifest.from_tool_definition(tool_definition) + if manifest.category in {"file", "memory", "system"} and not manifest.requires_approval: + selected_defs.append(tool_definition) + if manifest.name: + selected_names.append(manifest.name) + return PromptAssemblyPlan( + level=DisclosureLevel.PROJECT_RESEARCH, + tool_definitions=tuple(selected_defs), + catalog_definitions=tuple(selected_defs), + memory=MemoryDisclosure.TASK_RETRIEVAL, + history=HistoryDisclosure.RECENT, + reasoning=ReasoningDisclosure.AUTO if runtime.enable_thinking else ReasoningDisclosure.OFF, + native_tools=runtime.native_tools_enabled and bool(selected_defs), + stream_mode="tool_aware", + risk_level="read_only", + reason=reason, + selected_tool_names=tuple(sorted(set(selected_names))), + max_prior_turns=6, + ) + def full_plan( self, user_text: str, @@ -238,10 +277,16 @@ def _select_capabilities( if any(token and token in normalized for token in _name_tokens(manifest.name)): selected.append(manifest) continue - if manifest.category in {"hub", "gateway"} and any(token in normalized for token in ("hub", "gateway", "message", "send")): + if ( + manifest.category in {"hub", "gateway"} + and any(token in normalized for token in ("hub", "gateway", "message", "send")) + ): selected.append(manifest) continue - if manifest.category == "delegate" and any(token in normalized for token in ("delegate", "subagent", "parallel", "子任务")): + if ( + manifest.category == "delegate" + and any(token in normalized for token in ("delegate", "subagent", "parallel", "子任务")) + ): selected.append(manifest) continue if any(token in normalized for token in _description_tokens(haystack)): @@ -363,15 +408,52 @@ def _asks_about_capabilities(text: str) -> bool: def _needs_memory(text: str, manifests: Sequence[CapabilityManifest]) -> bool: normalized = _normalize(text) - return any(manifest.category == "memory" for manifest in manifests) or any(token in normalized for token in ("memory", "remember", "recall", "history", "记忆", "之前")) + return any(manifest.category == "memory" for manifest in manifests) or any( + token in normalized for token in ("memory", "remember", "recall", "history", "记忆", "之前") + ) + + +def _needs_project_research(text: str) -> bool: + normalized = _normalize(text) + architecture_signals = ( + "architecture", "diagram", "mermaid", "system design", "dependency map", + "架构", "框图", "架构图", "系统设计", "依赖图", + ) + project_scope_signals = ( + "project", "repo", "repository", "codebase", "docs", "documentation", + "项目", "仓库", "代码库", "文档", + ) + code_scope_signals = ( + "source", "src", "module", "package", "readme", "源码", "模块", + ) + strong_research_verbs = ( + "study", "research", "analyze", "inspect", "review", "summarize", "map", + "generate", "draw", "trace", "梳理", "研究", "分析", "总结", "生成", + ) + weak_research_verbs = ("read", "give", "读取", "给出") + has_explicit_file = bool(re.search(r"[\w./-]+\.[a-z0-9]{1,8}\b", normalized)) + has_architecture = any(signal in normalized for signal in architecture_signals) + has_project_scope = any(signal in normalized for signal in project_scope_signals) + has_code_scope = any(signal in normalized for signal in code_scope_signals) + has_strong_verb = any(verb in normalized for verb in strong_research_verbs) + has_weak_verb = any(verb in normalized for verb in weak_research_verbs) + + if has_explicit_file and not (has_architecture or has_project_scope): + return False + if has_architecture and (has_strong_verb or has_weak_verb or has_project_scope): + return True + if has_project_scope and (has_strong_verb or (has_weak_verb and has_code_scope)): + return True + return has_strong_verb and has_project_scope and has_code_scope def _has_complexity_signal(normalized_text: str) -> bool: return any( token in normalized_text for token in ( - "implement", "refactor", "debug", "root cause", "architecture", "design", "analyze", "test", - "实现", "重构", "调试", "根因", "架构", "设计", "深入分析", "执行", "代码", "测试", + "implement", "refactor", "debug", "root cause", "architecture", "design", + "analyze", "test", "实现", "重构", "调试", "根因", "架构", "设计", + "深入分析", "执行", "代码", "测试", ) ) diff --git a/src/leapflow/engine/engine.py b/src/leapflow/engine/engine.py index b66920e..0e549f2 100644 --- a/src/leapflow/engine/engine.py +++ b/src/leapflow/engine/engine.py @@ -10,6 +10,7 @@ import time from dataclasses import dataclass from datetime import datetime +from pathlib import Path from typing import Any, AsyncIterator, Dict, List, Literal, Optional, Union from leapflow.platform.protocol import HostRpc, Methods @@ -74,6 +75,7 @@ _TOOL_ARGS_PREVIEW_LIMIT = 160 _TOOL_RESULT_PREVIEW_LIMIT = 240 +_TASK_CONTRACT_HEADING = "## Task Contract" def _single_line_preview(value: Any, *, limit: int) -> str: @@ -308,6 +310,39 @@ class _PromptAssembly: prior_turns: List[Dict[str, Any]] +@dataclass(frozen=True) +class TaskContract: + """Stable per-turn task contract that survives compression and retrieval drift.""" + + task_id: str + original_request: str + workspace_root: str + allowed_roots: tuple[str, ...] + research_protocol: tuple[str, ...] = () + + def render(self) -> str: + """Render the contract as a compact system block.""" + lines = [ + "## Task Contract", + f"- Task ID: {self.task_id}", + f"- Original user request: {self.original_request}", + f"- Workspace root: {self.workspace_root}", + f"- Allowed roots: {', '.join(self.allowed_roots)}", + ( + "- Treat relative project paths as relative to the workspace root; never infer `.` " + "as the project root when a workspace root is provided." + ), + ( + "- Preserve this task contract across summarization, compression, " + "tool loops, and memory retrieval." + ), + ] + if self.research_protocol: + lines.append("- Research protocol:") + lines.extend(f" - {item}" for item in self.research_protocol) + return "\n".join(lines) + + class AgentEngine: """Coordinates perception memory, LLM reasoning, RPC execution, and skills.""" @@ -461,6 +496,7 @@ def __init__( ) self._last_context_snapshot: dict[str, Any] = {} self._last_disclosure_metadata: dict[str, Any] = {} + self._current_task_contract: TaskContract | None = None self._disclosure_planner = DisclosurePlanner() self._healer = MessageHealer() @@ -804,6 +840,111 @@ def _active_context_length(self) -> int: logger.debug("model capability lookup failed", exc_info=True) return max(1, int(self._settings.llm_context_length)) + def _begin_turn_context(self, user_text: str) -> None: + """Reset turn-scoped state and build the stable task contract.""" + self._memory_context_snapshot = None + self._last_context_snapshot = {} + self._last_disclosure_metadata = {} + self._context_governance_controller.reset_turn_scope() + self._current_task_contract = self._build_task_contract(user_text) + + def _build_task_contract(self, user_text: str) -> TaskContract: + workspace_root = ( + Path(getattr(self._settings, "workspace_root", Path.cwd())) + .expanduser() + .resolve() + ) + protocol = self._research_protocol_for(user_text) + return TaskContract( + task_id=f"turn-{self._session_turn_count}", + original_request=user_text.strip(), + workspace_root=str(workspace_root), + allowed_roots=(str(workspace_root),), + research_protocol=protocol, + ) + + @staticmethod + def _research_protocol_for(user_text: str) -> tuple[str, ...]: + normalized = user_text.lower() + architecture_tokens = ( + "architecture", "diagram", "design", "架构", "架构图", "系统设计", "框图", + ) + if not any(token in normalized for token in architecture_tokens): + return () + return ( + "Identify the active project root before reading files.", + "Start from README, AGENTS, docs index, and top-level source layout.", + "Use outlines, symbols, and bounded ranges before raw full-file reads.", + "Cross-check entrypoints, core orchestration, representative modules, and tests.", + "Produce a concise subsystem map and Mermaid architecture diagram grounded in evidence.", + ) + + def _task_scope_keywords(self, user_text: str) -> list[str]: + keywords = _keywords_from_query(user_text) + contract = self._current_task_contract + if contract: + workspace_name = Path(contract.workspace_root).name + if workspace_name: + keywords.append(workspace_name) + deduped: list[str] = [] + seen: set[str] = set() + for keyword in keywords: + key = keyword.lower() + if key and key not in seen: + seen.add(key) + deduped.append(keyword) + return deduped[:12] + + def _task_contract_block(self) -> str: + if not self._current_task_contract: + return "" + return self._current_task_contract.render() + + def _append_task_contract_to_system(self, system: str) -> str: + block = self._task_contract_block() + if not block: + return system + base = self._strip_task_contract_block(system) + return f"{base.rstrip()}\n\n{block}\n" if base.strip() else f"{block}\n" + + @staticmethod + def _strip_task_contract_block(content: str) -> str: + marker = f"\n{_TASK_CONTRACT_HEADING}" + if content.startswith(_TASK_CONTRACT_HEADING): + return "" + marker_index = content.find(marker) + if marker_index == -1: + return content + return content[:marker_index].rstrip() + + def _ensure_task_contract_message(self, messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + block = self._task_contract_block() + if not block: + return messages + prepared: list[Dict[str, Any]] = [] + inserted = False + for message in messages: + if message.get("role") != "system": + prepared.append(message) + continue + content = message.get("content", "") + if not isinstance(content, str): + prepared.append(message) + continue + base = self._strip_task_contract_block(content) + if not inserted: + updated = dict(message) + updated["content"] = f"{base.rstrip()}\n\n{block}\n" if base.strip() else f"{block}\n" + prepared.append(updated) + inserted = True + elif base.strip(): + updated = dict(message) + updated["content"] = base + prepared.append(updated) + if inserted: + return prepared + return [build_system_message(block), *prepared] + async def _assemble_unified_prompt( self, user_text: str, @@ -845,6 +986,7 @@ async def _assemble_unified_prompt( skill_section=skill_section, memory_context=memory_context, ) + system = self._append_task_contract_to_system(system) self._last_disclosure_metadata = plan.metadata() prior_turns = self._prior_turns_for_plan(plan) return _PromptAssembly(system=system, plan=plan, prior_turns=prior_turns) @@ -859,7 +1001,10 @@ def _build_session_summary_context(self, *, max_messages: int) -> str: continue content = message.get("content", "") if isinstance(content, list): - content = " ".join(str(part.get("text", part)) if isinstance(part, dict) else str(part) for part in content) + content = " ".join( + str(part.get("text", part)) if isinstance(part, dict) else str(part) + for part in content + ) elif not isinstance(content, str): content = str(content) preview = _single_line_preview(content, limit=180) @@ -915,17 +1060,20 @@ def _prepare_llm_messages( context_length = self._active_context_length() token_count = self._context_controller.estimator.estimate_messages(messages) prepared = self._compressor.compress(messages, token_count=token_count) + prepared = self._ensure_task_contract_message(prepared) compression_trace = self._compressor.last_trace.as_dict() prepared = self._compressor.preflight_check(prepared, context_length=context_length) + prepared = self._ensure_task_contract_message(prepared) if self._cache_strategy: prepared = self._cache_strategy.optimize(prepared) + prepared = self._ensure_task_contract_message(prepared) decision = self._context_controller.prepare( prepared, tools=tools, context_length=context_length, compressor=self._compressor, ) - prepared = decision.messages + prepared = self._ensure_task_contract_message(decision.messages) compression_trace = self._compressor.last_trace.as_dict() warning = self._context_controller.warning_notice( decision.snapshot, @@ -935,6 +1083,7 @@ def _prepare_llm_messages( for notice in (warning, convergence): if notice: prepared = [*prepared, build_user_message_text(notice)] + prepared = self._ensure_task_contract_message(prepared) snapshot = self._context_controller.estimator.snapshot( prepared, tools=tools, @@ -1028,7 +1177,7 @@ async def run(self, user_text: str, *, enable_thinking: bool = False) -> str: """Entrypoint: simplified routing with unified tool loop as default path.""" self._session_turn_count += 1 logger.info("audit.user_input chars=%s", len(user_text)) - self._memory_context_snapshot = None # reset per-turn for fresh prefetch + self._begin_turn_context(user_text) # 1. Slash command (skill injection — zero-ambiguity activation) if user_text.startswith("/") and self._skill_injector: @@ -1065,7 +1214,7 @@ async def run_stream( """ self._session_turn_count += 1 logger.info("audit.user_input chars=%s", len(user_text)) - self._memory_context_snapshot = None # reset per-turn for fresh prefetch + self._begin_turn_context(user_text) # 1. Slash command (skill injection) if user_text.startswith("/") and self._skill_injector: @@ -1536,7 +1685,6 @@ async def _unified_tool_loop( round_number=budget.used, ) - _show_progress("thinking", f"round {budget.used}") try: resp = await self._llm.achat( compressed, stream=False, enable_thinking=planned_enable_thinking, @@ -1864,8 +2012,6 @@ async def _unified_tool_loop_stream( round_number=budget.used, ) - yield StreamEvent(type="thinking", content=f"round {budget.used}") - content = "" if use_native_tools and tools_kwarg: @@ -2501,7 +2647,17 @@ async def _prefetch_and_freeze_memory(self, user_text: str) -> str: try: entries = await asyncio.wait_for( self._memory_manager.prefetch( - user_text, limit=self._settings.memory_prefetch_limit, + user_text, + limit=self._settings.memory_prefetch_limit, + workspace_root=( + self._current_task_contract.workspace_root + if self._current_task_contract else "" + ), + task_id=( + self._current_task_contract.task_id + if self._current_task_contract else "" + ), + scope_keywords=self._task_scope_keywords(user_text), ), timeout=self._settings.memory_prefetch_timeout_s, ) diff --git a/src/leapflow/memory/manager.py b/src/leapflow/memory/manager.py index 2e65fbc..c24d3a0 100644 --- a/src/leapflow/memory/manager.py +++ b/src/leapflow/memory/manager.py @@ -8,7 +8,7 @@ import json import logging import math -import time +from pathlib import Path from typing import Any, Dict, List, Optional from leapflow.memory.protocol import ( @@ -214,7 +214,6 @@ async def search_cross_domain( right after a clipboard event in domain CLIPBOARD" — cross-modal correlation. """ keywords = query.split()[:8] if query else [] - now = time.time() # Step 1: Gather entries from all providers (broad search) mq = MemoryQuery( @@ -263,10 +262,89 @@ async def search_cross_domain( unique.sort(key=lambda e: e.score, reverse=True) return unique[:limit] - async def prefetch(self, query_text: str, *, limit: int = 10) -> List[MemoryEntry]: - """Quick search for LLM context injection.""" - query = MemoryQuery(keywords=query_text.split()[:5], limit=limit) - return await self.search(query) + async def prefetch( + self, + query_text: str, + *, + limit: int = 10, + workspace_root: str = "", + task_id: str = "", + scope_keywords: List[str] | None = None, + ) -> List[MemoryEntry]: + """Quick search for LLM context injection with optional project/task scope.""" + keywords = query_text.split()[:5] + scope_terms = [term for term in (scope_keywords or []) if term] + query = MemoryQuery( + keywords=[*keywords, *scope_terms[:5]], + limit=max(limit, limit * 3), + workspace_root=workspace_root, + task_id=task_id, + scope_keywords=scope_terms, + ) + entries = await self.search(query) + if workspace_root or scope_terms or task_id: + entries = self._scope_entries( + entries, + workspace_root=workspace_root, + task_id=task_id, + scope_keywords=scope_terms, + ) + return entries[:limit] + + def _scope_entries( + self, + entries: List[MemoryEntry], + *, + workspace_root: str = "", + task_id: str = "", + scope_keywords: List[str] | None = None, + ) -> List[MemoryEntry]: + """Filter retrieved memories to the active project/task when scope is known.""" + if not entries: + return [] + root = str(Path(workspace_root).expanduser().resolve()) if workspace_root else "" + root_name = Path(root).name.lower() if root else "" + terms = {term.lower() for term in (scope_keywords or []) if len(term) >= 2} + if root_name: + terms.add(root_name) + if task_id: + terms.add(str(task_id).lower()) + scoped: list[MemoryEntry] = [] + for entry in entries: + if self._entry_matches_scope(entry, workspace_root=root, scope_terms=terms): + scoped.append(entry) + return scoped + + @staticmethod + def _entry_matches_scope(entry: MemoryEntry, *, workspace_root: str, scope_terms: set[str]) -> bool: + metadata = entry.metadata or {} + path_values = [ + metadata.get("path"), + metadata.get("file_path"), + metadata.get("workspace_root"), + metadata.get("project_root"), + metadata.get("cwd"), + ] + has_path_scope = False + for value in path_values: + if not value: + continue + has_path_scope = True + try: + candidate = str(Path(str(value)).expanduser().resolve()) + except (OSError, RuntimeError, ValueError): + candidate = str(value) + if workspace_root and ( + candidate == workspace_root or candidate.startswith(workspace_root + "/") + ): + return True + if workspace_root and has_path_scope: + return False + haystack = " ".join([ + entry.content, + json.dumps(metadata, ensure_ascii=False, default=str), + ]).lower() + return bool(scope_terms and any(term in haystack for term in scope_terms)) async def sync_turn(self, messages: List[Dict[str, Any]]) -> None: """Background sync of conversation turn (fire-and-forget safe).""" diff --git a/src/leapflow/memory/protocol.py b/src/leapflow/memory/protocol.py index 640ca74..d9d8e67 100644 --- a/src/leapflow/memory/protocol.py +++ b/src/leapflow/memory/protocol.py @@ -75,6 +75,9 @@ class MemoryQuery: min_score: float = 0.0 include_expired: bool = False cross_domain: bool = False # Enable cross-domain correlation + workspace_root: str = "" + task_id: str = "" + scope_keywords: List[str] = field(default_factory=list) @dataclass diff --git a/src/leapflow/prompts/templates.py b/src/leapflow/prompts/templates.py index fdc3885..bc82516 100644 --- a/src/leapflow/prompts/templates.py +++ b/src/leapflow/prompts/templates.py @@ -90,7 +90,15 @@ def build_react_system(language: str = "en", skill_catalog: str = "") -> str: 5. **Summarize results naturally**: After tool execution, synthesize the results into a helpful answer rather than dumping raw output. 6. **Stay conversational**: Maintain a natural, helpful tone. Acknowledge context from earlier in the conversation. -When finished with all tool calls, respond normally without a JSON block. +## Presentation Style +1. **Polished Markdown only**: Format user-facing answers with clean Markdown headings, short paragraphs, and concise bullets. Use tables only when they improve comparison or scanning. +2. **Terminal-friendly layout**: Keep lines readable in a TUI; avoid dense walls of text, deeply nested lists, oversized ASCII art, or heavy visual blocks. +3. **Elegant emphasis**: Use bold text sparingly for key terms and conclusions. Avoid excessive emojis, decorative symbols, repeated separators, or visual noise. +4. **Theme-safe colors**: Do not emit ANSI escape codes, HTML color tags, Rich markup, or hardcoded color names. Rely on the TUI theme to render Markdown professionally. +5. **No leaked tool protocol**: Never show tool-call JSON, internal schemas, raw observations, tool result payloads, or hidden reasoning in the final answer unless the user explicitly asks for raw/debug output. Treat any prior `{{"name": ..., "arguments": ...}}` blocks and `Tool result (...)` messages as internal execution context only. +6. **Professional closure**: End with a concise conclusion or next step when helpful; avoid rambling after the useful answer is complete. + +When finished with all tool calls, respond normally without a JSON block, tool-call transcript, or process log. {memory_context} """ diff --git a/tests/test_agent_execution.py b/tests/test_agent_execution.py index 83223d4..cdfdd24 100644 --- a/tests/test_agent_execution.py +++ b/tests/test_agent_execution.py @@ -286,6 +286,14 @@ async def achat_stream(self, messages, *, enable_thinking=False, **kwargs): assert "tools" not in llm.kwargs assert llm.enable_thinking is False assert "file_read" not in str(llm.messages[0].get("content", "")) + system_prompt = str(llm.messages[0].get("content", "")) + assert "## Presentation Style" in system_prompt + assert "No leaked tool protocol" in system_prompt + assert "Theme-safe colors" in system_prompt + assert "## Task Contract" in system_prompt + assert "Original user request: hello" in system_prompt + assert "Workspace root:" in system_prompt + assert "never infer `.` as the project root" in system_prompt snapshot = engine.context_budget_snapshot assert snapshot["disclosure_level"] == "light" assert snapshot["disclosure"]["native_tools"] is False @@ -293,6 +301,46 @@ async def achat_stream(self, messages, *, enable_thinking=False, **kwargs): lt.close() +def test_task_contract_replaces_stale_contract_block() -> None: + """Compression recovery should keep exactly one current task contract.""" + from leapflow.platform.mock import MockBridge + + with tempfile.TemporaryDirectory() as td: + settings = make_settings(td) + rpc = MockBridge() + llm = StubLLM(["ok"]) + wm = WorkingMemoryProvider(max_tokens=1024) + lt = SemanticMemoryProvider(source=settings.duckdb_path) + imm = EpisodicMemoryProvider() + try: + reg = build_default_registry(rpc, llm, wm, lt) + classifier = _FixedClassifier("chat") + engine = AgentEngine(settings, rpc, llm, wm, lt, imm, reg, classifier) + + engine._session_turn_count = 1 + engine._begin_turn_context("first request") + stale_contract = engine._task_contract_block() + engine._session_turn_count = 2 + engine._begin_turn_context("second request") + + prepared = engine._ensure_task_contract_message([ + {"role": "system", "content": f"base system\n\n{stale_contract}\n"}, + {"role": "system", "content": stale_contract}, + {"role": "user", "content": "second request"}, + ]) + system_text = "\n".join( + str(message.get("content", "")) + for message in prepared + if message.get("role") == "system" + ) + + assert system_text.count("## Task Contract") == 1 + assert "Original user request: second request" in system_text + assert "Original user request: first request" not in system_text + finally: + lt.close() + + @pytest.mark.asyncio async def test_progressive_disclosure_file_query_selects_file_schemas() -> None: """File-oriented requests should disclose file schemas without the full catalog.""" diff --git a/tests/test_context_disclosure.py b/tests/test_context_disclosure.py index 1169eab..91b52b7 100644 --- a/tests/test_context_disclosure.py +++ b/tests/test_context_disclosure.py @@ -48,6 +48,33 @@ def test_disclosure_planner_selects_file_tools_without_full_catalog() -> None: assert "file_list" in names assert "shell_run" not in names + package_plan = planner.plan( + "Read package.json and summarize dependencies", + TOOL_DEFINITIONS, + DisclosureRuntimeState(native_tools_enabled=True), + ) + assert package_plan.level == DisclosureLevel.SELECTED_TOOLS + + +def test_disclosure_planner_selects_project_research_without_full_catalog() -> None: + planner = DisclosurePlanner() + + plan = planner.plan( + "Read and study this codebase, then generate a system architecture diagram", + TOOL_DEFINITIONS, + DisclosureRuntimeState(native_tools_enabled=True, enable_thinking=True), + ) + + names = _tool_names(plan) + assert plan.level == DisclosureLevel.PROJECT_RESEARCH + assert plan.native_tools is True + assert plan.memory.value == "task_retrieval" + assert plan.reasoning.value == "auto" + assert "file_read" in names + assert "file_list" in names + assert "shell_run" not in names + assert not any(name.startswith("hub") for name in names) + def test_disclosure_planner_uses_index_for_capability_questions() -> None: planner = DisclosurePlanner() diff --git a/tests/test_context_governance.py b/tests/test_context_governance.py index 02a95a3..9efb74c 100644 --- a/tests/test_context_governance.py +++ b/tests/test_context_governance.py @@ -116,7 +116,32 @@ def test_long_task_controller_reports_repeated_reads() -> None: assert metadata["context_evidence"] is True assert metadata["read_count"] == 2 assert metadata["repeat_read"] is True - assert "synthesize" in controller.convergence_notice(2) + notice = controller.convergence_notice(2) + assert "repeated reads" in notice + assert "complementary project evidence" in notice + assert "synthesize" in notice + + +def test_context_governance_reset_clears_turn_scope() -> None: + controller = LongTaskContextController( + evidence_builder=ToolEvidenceBuilder(max_content_chars=240), + repeated_read_limit=1, + convergence_round=20, + ) + args = {"path": "/tmp/repeated.py"} + result = {"ok": True, "path": "/tmp/repeated.py", "content": "print(1)", "mode": "raw"} + + controller.compact_tool_result("file_read", args, result) + controller.compact_tool_result("file_read", args, result) + assert controller.snapshot().repeated_reads == 1 + + controller.reset_turn_scope() + + snapshot = controller.snapshot() + assert snapshot.repeated_reads == 0 + assert snapshot.sources_seen == 0 + assert snapshot.evidence_count == 0 + assert controller.convergence_notice(1) == "" def test_long_task_metadata_avoids_noise_for_uncompacted_tools() -> None: diff --git a/tests/test_memory_and_storage.py b/tests/test_memory_and_storage.py index cfbc1eb..906120b 100644 --- a/tests/test_memory_and_storage.py +++ b/tests/test_memory_and_storage.py @@ -19,6 +19,8 @@ from leapflow.memory import ( EpisodicMemoryProvider, SemanticMemoryProvider, WorkingMemoryProvider, ) +from leapflow.memory.manager import MemoryManager +from leapflow.memory.protocol import MemoryEntry, MemoryKind, SignalDomain from leapflow.platform.event_bus import EventBus from leapflow.platform.protocol import EventTypes from leapflow.storage.skill_library import StoredSkill @@ -162,6 +164,38 @@ def test_long_term_metadata_preserved(long_term_memory: SemanticMemoryProvider) assert by_id.metadata == {"topic": "planning", "priority": "high"} +def test_memory_manager_scope_gate_filters_other_workspaces(tmp_path: Path) -> None: + manager = MemoryManager() + workspace = tmp_path / "active_project" + workspace.mkdir() + active = MemoryEntry( + kind=MemoryKind.CONVERSATION, + domain=SignalDomain.SYSTEM, + content="Architecture notes for active_project", + metadata={"path": str(workspace / "README.md")}, + ) + other = MemoryEntry( + kind=MemoryKind.CONVERSATION, + domain=SignalDomain.SYSTEM, + content="Architecture notes for active_project but from another workspace", + metadata={"path": str(tmp_path / "other_workspace" / "paper.pdf")}, + ) + global_note = MemoryEntry( + kind=MemoryKind.CONVERSATION, + domain=SignalDomain.SYSTEM, + content="Architecture notes for active_project without path metadata", + metadata={}, + ) + + scoped = manager._scope_entries( + [active, other, global_note], + workspace_root=str(workspace), + scope_keywords=["architecture"], + ) + + assert scoped == [active, global_note] + + # ── Immediate memory ─────────────────────────────────────────────── diff --git a/tests/test_pure_algorithms.py b/tests/test_pure_algorithms.py index 540ee58..caedd53 100644 --- a/tests/test_pure_algorithms.py +++ b/tests/test_pure_algorithms.py @@ -18,6 +18,7 @@ _token_jaccard, ) from leapflow.memory import decay_weight +from leapflow.prompts.templates import UNIFIED_SYSTEM_TEMPLATE from leapflow.world_model._json_utils import extract_json_object @@ -134,6 +135,17 @@ def test_causal_graph_connected_components() -> None: assert frozenset({c.id, d.id}) in comp_sets +def test_unified_system_template_escapes_literal_tool_protocol_json() -> None: + rendered = UNIFIED_SYSTEM_TEMPLATE.format( + tool_catalog="- **skills_list**(): List installed skills", + skill_section="", + memory_context="", + ) + + assert '{"name": "tool_name", "arguments": {"key": "value"}}' in rendered + assert '{"name": ..., "arguments": ...}' in rendered + + def test_json_extraction_variants() -> None: assert extract_json_object('{"key": "value", "n": 42}') == {"key": "value", "n": 42} diff --git a/tests/test_tui_command_queue.py b/tests/test_tui_command_queue.py index abccad8..5d1d32b 100644 --- a/tests/test_tui_command_queue.py +++ b/tests/test_tui_command_queue.py @@ -8,9 +8,12 @@ import leapflow.cli.tui_app.app as app_module from prompt_toolkit.auto_suggest import Suggestion from prompt_toolkit.completion import Completion +from prompt_toolkit.document import Document +from prompt_toolkit.formatted_text import to_plain_text from leapflow.cli.tui_app.app import LeapApp from leapflow.cli.tui_app.console import LeapConsole from leapflow.cli.tui_app.command import TuiCommand, TuiCommandStatus +from leapflow.cli.tui_app.input import SlashCommandCompleter from leapflow.cli.tui_app.stream import StreamRenderer from leapflow.cli.tui_app.theme import _LIGHT, resolve_theme @@ -51,13 +54,18 @@ async def _wait_for(condition, *, timeout: float = 1.0) -> None: raise AssertionError("condition was not met before timeout") -def _make_app(on_input=None) -> tuple[LeapApp, _FakeConsole, _FakeStatus]: +def _make_app( + on_input=None, + *, + commands: tuple[tuple[str, str], ...] = (), +) -> tuple[LeapApp, _FakeConsole, _FakeStatus]: console = _FakeConsole() status = _FakeStatus() app = LeapApp( console=console, theme=resolve_theme(_LIGHT, terminal_bg="#FFFFFF"), status=status, + commands=commands, on_input=on_input, ) return app, console, status @@ -88,9 +96,43 @@ def test_submit_text_rejects_empty_commands() -> None: assert status.counts == [] +def test_slash_completer_shows_commands_and_descriptions() -> None: + completer = SlashCommandCompleter(( + ("help", "Show available commands"), + ("teach start", "Start teaching mode"), + )) + + completions = list(completer.get_completions(Document("/", 1), None)) + + assert [completion.text for completion in completions] == ["/help", "/teach start"] + assert [to_plain_text(completion.display) for completion in completions] == ["/help", "/teach start"] + assert to_plain_text(completions[0].display_meta) == "Show available commands" + + +def test_slash_completer_filters_multi_word_commands() -> None: + completer = SlashCommandCompleter(( + ("teach start", "Start teaching mode"), + ("teach stop", "Stop and distill skill"), + ("tools", "List available tools"), + )) + + completions = list(completer.get_completions(Document("/teach s", len("/teach s")), None)) + + assert [completion.text for completion in completions] == ["/teach start", "/teach stop"] + assert all(completion.start_position == -len("/teach s") for completion in completions) + + +def test_slash_completer_does_not_pollute_natural_language() -> None: + completer = SlashCommandCompleter((("help", "Show available commands"),)) + + assert list(completer.get_completions(Document("please help", len("please help")), None)) == [] + + def test_tab_accepts_selected_completion() -> None: app, _console, _status = _make_app() buffer = app._input_area.buffer + buffer.completer = None + buffer.auto_suggest = None buffer.text = "/he" buffer.cursor_position = len(buffer.text) completion = Completion("/help", start_position=-len(buffer.text)) @@ -102,6 +144,89 @@ def test_tab_accepts_selected_completion() -> None: assert buffer.cursor_position == len("/help") +def test_escape_closes_visible_completion_menu() -> None: + app, _console, _status = _make_app() + buffer = app._input_area.buffer + buffer.completer = None + buffer.auto_suggest = None + buffer.text = "/h" + buffer.cursor_position = len(buffer.text) + buffer._set_completions([Completion("/help", start_position=-len(buffer.text))]) + + assert app._close_completion(buffer) is True + + assert buffer.complete_state is None + + +def test_down_arrow_navigates_visible_completion_menu() -> None: + app, _console, _status = _make_app() + buffer = app._input_area.buffer + buffer.completer = None + buffer.auto_suggest = None + buffer.text = "/h" + buffer.cursor_position = len(buffer.text) + buffer._set_completions([ + Completion("/help", start_position=-len(buffer.text)), + Completion("/host", start_position=-len(buffer.text)), + ]) + + app._completion_next_or_cursor_down(buffer) + first = buffer.complete_state.current_completion + app._completion_next_or_cursor_down(buffer) + second = buffer.complete_state.current_completion + + assert first is not None and first.text == "/help" + assert second is not None and second.text == "/host" + + +def test_history_navigation_restores_unsubmitted_draft() -> None: + app, _console, _status = _make_app() + buffer = app._input_area.buffer + buffer.completer = None + buffer.auto_suggest = None + buffer.text = "你是谁" + buffer.cursor_position = len(buffer.text) + buffer._working_lines.appendleft("上一条消息") + buffer.working_index += 1 + + app._completion_previous_or_cursor_up(buffer) + + assert buffer.text == "上一条消息" + assert buffer.cursor_position == len("上一条消息") + + app._completion_next_or_cursor_down(buffer) + + assert buffer.text == "你是谁" + assert buffer.cursor_position == len("你是谁") + + +def test_up_arrow_keeps_multiline_cursor_navigation_before_history() -> None: + app, _console, _status = _make_app() + buffer = app._input_area.buffer + buffer.completer = None + buffer.auto_suggest = None + buffer.text = "第一行\n第二行" + buffer._working_lines.appendleft("上一条消息") + buffer.working_index += 1 + buffer.cursor_position = len(buffer.text) + working_index = buffer.working_index + + app._completion_previous_or_cursor_up(buffer) + + assert buffer.text == "第一行\n第二行" + assert buffer.working_index == working_index + assert buffer.document.cursor_position_row == 0 + + +def test_leap_app_exposes_completion_menu_styles() -> None: + app, _console, _status = _make_app(commands=(("help", "Show available commands"),)) + style = app._build_style() + + assert style.get_attrs_for_style_str("class:completion-menu.completion") is not None + assert style.get_attrs_for_style_str("class:completion-menu.meta.completion.current") is not None + assert app._input_area.buffer.completer is not None + + @pytest.mark.asyncio async def test_tab_accepts_visible_auto_suggestion() -> None: app, _console, _status = _make_app() @@ -265,9 +390,10 @@ def newline(self) -> None: assert any( "file_read" in line - and "mode=symbols" in line + and "symbols" in line and "truncated" in line - and "repeat-read x3" in line + and "repeat×3" in line + and "repeat-read" not in line for line in console.lines ) @@ -314,17 +440,60 @@ def newline(self) -> None: ) assert any( - "compressed=trim+summarize" in line - and "saved≈42%" in line - and "threshold-triggered" in line - and "research" in line - and "maintain research ledger" in line - and "disclosure=selected_tools" in line - and "selected capabilities matched" in line + "passed" in line + and "[research · evidence · compressed]" in line + and "saved≈" not in line + and "threshold-triggered" not in line + and "maintain research ledger" not in line + and "selected capabilities matched" not in line for line in console.lines ) +def test_stream_renderer_suppresses_structured_tool_blobs_and_compacts_paths() -> None: + class CaptureConsole: + def __init__(self) -> None: + self.lines: list[str] = [] + + def print(self, renderable) -> None: + self.lines.append(getattr(renderable, "plain", str(renderable))) + + def thinking(self, text: str) -> None: + pass + + def markdown(self, text: str, *, indent: int = 0, margin_top: int = 0) -> None: + pass + + def response_label(self, elapsed_s: float, *, tool_count: int = 0) -> None: + pass + + def newline(self) -> None: + pass + + console = CaptureConsole() + renderer = StreamRenderer(console) # type: ignore[arg-type] + renderer.start() + long_path = "/very/long/path/that/should/not/dominate/the/tool/log/with/many/nested/segments/src/leapflow" + + renderer.tool_started("file_list", metadata={"path": long_path}) + renderer.tool_finished( + "file_list", + metadata={ + "ok": True, + "path": long_path, + "result_preview": '{"ok": true, "entries": [{"name": "a"}, {"name": "b"}]}', + "context_evidence": True, + "disclosure_level": "full", + "disclosure_reason": "task requires broad execution context", + }, + ) + + assert any("path=…/segments/src/leapflow" in line for line in console.lines) + assert not any(long_path in line for line in console.lines) + assert any("file_list" in line and "ok [disclosure=full · evidence]" in line for line in console.lines) + assert not any("entries" in line or "task requires broad" in line for line in console.lines) + + @pytest.mark.asyncio async def test_process_loop_marks_failed_commands_and_recovers_counts() -> None: async def on_input(text: str) -> None: diff --git a/tests/test_tui_session_summary.py b/tests/test_tui_session_summary.py index f2f5cee..0641a76 100644 --- a/tests/test_tui_session_summary.py +++ b/tests/test_tui_session_summary.py @@ -23,6 +23,7 @@ def __init__(self) -> None: self.markdown_calls: list[dict[str, object]] = [] self.thinking_calls: list[str] = [] self.labels: list[tuple[float, int]] = [] + self.answer_labels = 0 self.lines = 0 def markdown( @@ -43,6 +44,9 @@ def markdown( def thinking(self, text: str) -> None: self.thinking_calls.append(text) + def answer_label(self) -> None: + self.answer_labels += 1 + def response_label(self, elapsed_s: float, *, tool_count: int = 0) -> None: self.labels.append((elapsed_s, tool_count)) @@ -159,9 +163,89 @@ def test_stream_renderer_spaces_and_indents_final_response_only() -> None: "margin_bottom": 0, }] assert len(console.labels) == 1 + assert console.answer_labels == 1 assert console.lines == 1 +def test_stream_renderer_suppresses_synthetic_round_thinking() -> None: + console = _Console() + renderer = StreamRenderer(console) + renderer.start() + + renderer.feed_thinking("round 1") + renderer.feed_thinking("round 2round 3") + assert renderer.has_output is False + + renderer.feed("final answer") + renderer.finish() + + assert console.thinking_calls == [] + assert console.markdown_calls[0]["text"] == "final answer" + + +def test_stream_renderer_keeps_meaningful_thinking_lines() -> None: + console = _Console() + renderer = StreamRenderer(console) + renderer.start() + + renderer.feed_thinking("round 1") + renderer.feed_thinking("Reading relevant files") + renderer.feed_thinking("round 2\nSynthesizing findings") + renderer.feed("final answer") + renderer.finish() + + assert console.thinking_calls == ["Reading relevant files\nSynthesizing findings"] + + +def test_stream_renderer_sanitizes_tool_protocol_from_final_answer() -> None: + console = _Console() + renderer = StreamRenderer(console) + renderer.start() + + renderer.feed(""" + · skills_list {} + ✓ skills_list 4ms ok [disclosure=indexed_capabilities] + +```json +{"name": "skills_list", "arguments": {}} +``` + +目前可用能力如下: + +{"name": "skills_list", "arguments": {"query": "", "source": "local"}} + +- 文件操作 +- Shell 命令执行 +""") + renderer.finish() + + text = str(console.markdown_calls[0]["text"]) + assert "skills_list" not in text + assert "arguments" not in text + assert "目前可用能力如下" in text + assert "文件操作" in text + assert console.answer_labels == 1 + + +def test_stream_renderer_keeps_regular_json_examples() -> None: + console = _Console() + renderer = StreamRenderer(console) + renderer.start() + + renderer.feed(""" +配置示例: + +```json +{"theme": "dark", "enabled": true} +``` +""") + renderer.finish() + + text = str(console.markdown_calls[0]["text"]) + assert '"theme": "dark"' in text + assert '"enabled": true' in text + + def test_global_resume_routes_to_interactive(monkeypatch) -> None: from leapflow.cli import cli diff --git a/tests/test_tui_theme.py b/tests/test_tui_theme.py index a27574e..c180215 100644 --- a/tests/test_tui_theme.py +++ b/tests/test_tui_theme.py @@ -3,10 +3,11 @@ from os import terminal_size from prompt_toolkit.styles import Style as PTStyle +from rich.rule import Rule -from leapflow.cli.banner import display_rich_banner +from leapflow.cli.banner import _BannerPalette, display_rich_banner from leapflow.cli.tui_app.app import LeapApp -from leapflow.cli.tui_app.console import _TerminalBackgroundCodeBlock, _TerminalBackgroundMarkdown +from leapflow.cli.tui_app.console import LeapConsole, _TerminalBackgroundCodeBlock, _TerminalBackgroundMarkdown from leapflow.cli.tui_app.status import StatusBar from leapflow.cli.tui_app.theme import ( _DARK, @@ -36,10 +37,10 @@ def _style_for(theme): "prompt.recording": theme.recording, "prompt.paused": theme.prompt_paused, "prompt.executing": theme.executing, - "status-bar": f"bg:{theme.toolbar_bg} {theme.toolbar_fg}", - "status-bar.strong": f"bg:{theme.toolbar_bg} bold {theme.accent}", - "status-bar.dim": f"bg:{theme.toolbar_bg} {theme.text_muted}", - "status-bar.good": f"bg:{theme.toolbar_bg} {theme.success}", + "status-bar": f"bg:{theme.toolbar_bg} {theme.statusbar_fg}", + "status-bar.strong": f"bg:{theme.toolbar_bg} bold {theme.statusbar_accent}", + "status-bar.dim": f"bg:{theme.toolbar_bg} {theme.statusbar_dim}", + "status-bar.good": f"bg:{theme.toolbar_bg} {theme.statusbar_good}", "status-bar.warn": f"bg:{theme.toolbar_bg} {theme.warning}", "status-bar.bad": f"bg:{theme.toolbar_bg} {theme.error}", "hint": theme.text_dim, @@ -152,6 +153,33 @@ def test_rich_banner_accepts_resolved_theme(capsys) -> None: assert "#FFF8DC" not in output +def test_rich_banner_keeps_warm_brand_palette() -> None: + theme = resolve_theme(_LIGHT, terminal_bg="#FFFFFF") + palette = _BannerPalette(theme) + + assert theme.accent == "#334155" + assert palette.accent == "#FFBF00" + assert palette.accent_dim == "#B8860B" + assert palette.border == "#CD7F32" + assert palette.text == "#FFF8DC" + + +def test_status_bar_uses_warm_brand_palette() -> None: + dark = resolve_theme(_DARK, terminal_bg="#0B1F24") + light = resolve_theme(_LIGHT, terminal_bg="#FFFFFF") + + assert dark.statusbar_fg == "#CD7F32" + assert dark.statusbar_accent == "#FFBF00" + assert dark.statusbar_dim == "#B8860B" + assert dark.statusbar_good == "#FFBF00" + assert light.statusbar_fg == "#8B5E34" + assert light.statusbar_dim == "#A16207" + assert light.statusbar_accent in {"#8B5E34", "#B8860B"} + assert light.statusbar_good in {"#8B5E34", "#B8860B"} + assert contrast_ratio(dark.statusbar_fg, dark.toolbar_bg) >= 4.5 + assert contrast_ratio(light.statusbar_fg, light.toolbar_bg) >= 4.5 + + def test_markdown_code_blocks_use_terminal_background() -> None: from rich.console import Console @@ -162,6 +190,72 @@ def test_markdown_code_blocks_use_terminal_background() -> None: syntax = next(block.__rich_console__(Console(), Console().options)) assert syntax.background_color == "default" + assert syntax.word_wrap is False + + +def test_markdown_headings_use_professional_palette() -> None: + console = LeapConsole(resolve_theme(_DARK, terminal_bg="#0B1F24")).raw + + h2 = console.get_style("markdown.h2") + h1 = console.get_style("markdown.h1") + + assert h2.color is not None + assert h1.color is not None + assert str(h2.color).lower() != "magenta" + assert h2.color.triplet is not None + assert h2.color.triplet.hex.lower() != "ff00ff" + assert h1.bgcolor is None + assert h2.bgcolor is None + + +def test_dark_theme_uses_low_saturation_terminal_palette() -> None: + theme = resolve_theme(_DARK, terminal_bg="#0B1F24") + + assert theme.accent == "#E6DDC4" + assert theme.text == "#F1EAD6" + assert theme.text_muted == "#9AA6A3" + assert theme.border == "#D8D1BB" + assert "#FF00FF" not in {theme.accent, theme.text, theme.text_dim, theme.border} + + +def test_inline_markdown_code_uses_terminal_background_style() -> None: + console = LeapConsole(resolve_theme(_LIGHT, terminal_bg="#FFFFFF")).raw + + style = console.get_style("markdown.code") + + assert style.bgcolor is None + assert style.color is not None + assert style.bold is True + + +def test_answer_label_uses_warm_left_aligned_boundary(monkeypatch) -> None: + class CaptureConsole: + def __init__(self) -> None: + self.rendered = [] + + def print(self, renderable) -> None: + self.rendered.append(renderable) + + capture = CaptureConsole() + theme = resolve_theme(_DARK, terminal_bg="#0B1F24") + leap_console = LeapConsole(theme) + rich_console = leap_console.raw + monkeypatch.setattr(leap_console, "_console", capture) + + leap_console.answer_label() + + rule = capture.rendered[0] + answer_border = rich_console.get_style("leap.answer_border") + answer_title = rich_console.get_style("leap.answer_title") + assert isinstance(rule, Rule) + assert rule.align == "left" + assert rule.style == "leap.answer_border" + assert str(rule.title) == " LeapFlow " + assert rule.title.style == "leap.answer_title" + assert answer_border.color is not None + assert answer_title.color is not None + assert answer_border.color.triplet.hex == theme.statusbar_dim.lower() + assert answer_title.color.triplet.hex == theme.statusbar_accent.lower() def test_status_bar_compacts_on_narrow_terminal(monkeypatch) -> None: From 0eaf0eb297b4a8ca51a28fb4907412c7432204a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8F=AD=E6=89=AC?= Date: Sat, 11 Jul 2026 01:57:40 +0800 Subject: [PATCH 12/17] update --- leapflow-architecture.html | 457 ------------------------------------- 1 file changed, 457 deletions(-) delete mode 100644 leapflow-architecture.html diff --git a/leapflow-architecture.html b/leapflow-architecture.html deleted file mode 100644 index 8beeb16..0000000 --- a/leapflow-architecture.html +++ /dev/null @@ -1,457 +0,0 @@ - - - - - - LeapFlow System Architecture - - - - -
-
-
-
-

LeapFlow System Architecture

-
-

Signal-Driven Self-Evolving Agent Framework — Learning and Evolving from Actual Practice

-
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - signal feedback - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - INTELLIGENCE CORE (Python) - - - - COGNITIVE PIPELINE - - - - - - - CLI / TUI - prompt_toolkit + rich - Primary User Entry - - - - - Gateway - Multi-Platform Router - Telegram / Discord / Slack / ... - - - - - - Engine — ReAct Loop - Intent Classification · Planning · Tool Dispatch · Budget Control - context_compressor · error_classifier · turn_recovery · prompt_cache - - - - - - - Copilot — Workflow Prediction - L0 Hash → L1 Markov → L2 Embed → L3 LLM · Idle Detection · Proactive Suggestions - - - - - World Model — State Prediction - Trajectory Grading · Curiosity-Driven Learning · Prediction-Error Updates - Experience Store · Embedding Index · Replay - - - - - Skill Synthesis — Hypothesis → Verified → Production - Progressive Trust: STEP → CONFIRM → NOTIFY → AUTO - Registry · Evolution · Sandbox · Action Policy · UI Selector - - - - - Causal Engine — Why Things Happen - Causal Inference · Pattern Mining · Rule/Heuristic/VLM Verification - Intent Inference · Episode Dedup · Segmenter · Synthesis - - - - - Perception — Multi-Channel Signal Fusion (7ch) - Vision · AX Tree · Filesystem · Clipboard · Input · App Focus · Video - Filter (SNR) → Compress (intent-preserving) → Store (multi-layer) - - - - - - Platform Adaptation Layer - MCP Protocol (stdio/WebSocket/gRPC) · Event Normalizer - Reorder Buffer · Capability Negotiation · EventBus - - - - - - Execution Layer (Pluggable Backends) - cua-driver (macOS native, default) · Mock Host (testing) - Screen Capture · Input Injection · Accessibility Tree - - - - - - - Memory System - • Working (token-bounded) - • Episodic (TTL-managed) - • Semantic (vector-indexed) - • Narrative (conversation) - • Evolution (skill history) - Manager · Priority Routing - Protocol-based Providers - - - - - Learning & Evolution - • Active Learning · Skill Merger - • Pattern Miner · Feedback - • Cold Start · Distiller - • Codegen · Doc Generator - Learnability · Similarity - - - - - Analysis Pipeline - • Imitation Learning Pipeline - • Segmenter · Denoiser - • Abstractor · Consensus - • Pattern Pass · FS Pattern - Episode Dedup · Synthesis - - - - - Recording & Attention - • Perceptual Field · Attention - • Video Recorder · Segmenter - • Timeline · Health Monitor - - - - - - - LLM Provider Chain - OpenAI-compatible · DashScope - Provider Chain (fallback) - Model Capabilities · Message Builder - - - - - Security & Privacy - • Approval Workflow (HITL) - • Risk Assessment · Policy - • Redaction · Threat Patterns - Grants · Orchestrator - - - - - Skills Hub - ModelScope · GitHub · HuggingFace - Sync · Signature Verification - Serializer · Security - - - - - Scheduler - Cron · Interval · Event · Condition - Local + Cloud (ModelScope Studio) - Coordinator · Worker Packager - - - - - Storage — DuckDB - • Trajectory Store - • Skill Library · Conversation - • Session · Evolution Store - Schema · Migrate · Write Buffer - - - - - - - Observers (Signal Sources) - App Focus · Clipboard · FS Watcher - Input Tap · Daemon - watchdog · Accessibility API - - - - - - - - - Daemon (leapd) - RPC Server · Lifecycle · Lease · Service - Background Service · Multi-Client - - - - Legend - - - User Entry - - - Intelligence - - - Platform - - - Storage/Memory - - - Security - - - External/LLM - - - Feedback Loop - - - Auth/Security Flow - - -
- - -
-
-
-
-

Core Design Principles

-
-
    -
  • • Signal-Driven Intelligence — learn from real-world signals
  • -
  • • Context Pipeline — Signal → Filter → Compress → Store → Retrieve → Decide
  • -
  • • Progressive Trust — STEP → CONFIRM → NOTIFY → AUTO
  • -
  • • Prediction-Error Learning — world model learns from deltas
  • -
  • • Event-Driven — typed EventBus, Protocol over ABC
  • -
  • • Dependency Inversion — core depends on abstractions
  • -
-
- -
-
-
-

Three-Layer Architecture

-
-
    -
  • • Intelligence Core (Python) — Engine, Copilot, World Model, Skills
  • -
  • • Platform Adaptation — MCP protocol, event normalization, capability negotiation
  • -
  • • Execution Layer — cua-driver (macOS), Mock Host, pluggable backends
  • -
  • • 7-Channel Perception — Vision, AX, FS, Clipboard, Input, App, Video
  • -
  • • DuckDB persistence — single source of truth for all state
  • -
-
- -
-
-
-

Key Modules & Stats

-
-
    -
  • • 18 packages · 180+ Python modules
  • -
  • • Engine: ReAct loop, graph planner, context compressor
  • -
  • • Memory: 5 providers (Working, Episodic, Semantic, Narrative, Evolution)
  • -
  • • Copilot: 4-level predictors (Hash → Markov → Embed → LLM)
  • -
  • • Security: approval workflow, risk assessment, redaction
  • -
  • • Hub: ModelScope / GitHub / HuggingFace backends
  • -
-
-
- - -
- - From 96cbaa0e9357a5f974f729174c4312966f00e0d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8F=AD=E6=89=AC?= Date: Sat, 11 Jul 2026 02:17:39 +0800 Subject: [PATCH 13/17] feat(tui): streamline tool audit logs --- src/leapflow/cli/tui_app/stream.py | 67 ++++++++++++++++++---------- tests/test_tui_command_queue.py | 70 ++++++++++++++++++++---------- 2 files changed, 89 insertions(+), 48 deletions(-) diff --git a/src/leapflow/cli/tui_app/stream.py b/src/leapflow/cli/tui_app/stream.py index dd39227..17d3446 100644 --- a/src/leapflow/cli/tui_app/stream.py +++ b/src/leapflow/cli/tui_app/stream.py @@ -29,7 +29,8 @@ _SYNTHETIC_THINKING_ROUND_RE = re.compile(r"round\s*\d+", re.IGNORECASE) _FENCED_BLOCK_RE = re.compile(r"```(?P[\w+-]*)\s*\n(?P.*?)\n```", re.DOTALL) _TOOL_AUDIT_LINE_RE = re.compile( - r"^\s*[·✓✗]\s+[A-Za-z_][\w.-]*(?:\s|$).*", + r"^\s*(?:·|✓|✗|📁|📄|✍️?|🧠|🧭|💻|🌐|🧩|🔧|❌)\s+" + r"[A-Za-z_][\w.-]*(?:\s|$).*", re.MULTILINE, ) _JSON_DECODER = json.JSONDecoder() @@ -198,6 +199,29 @@ def _tool_context_detail(metadata: dict[str, Any] | None) -> str: tags = _context_tags(metadata) return f"[{' · '.join(tags)}]" if tags else "" + +def _tool_icon(name: str, *, ok: bool = True) -> str: + if not ok: + return "❌" + if name.startswith("file_list"): + return "📁" + if name.startswith("file_read"): + return "📄" + if name.startswith("file_write") or name.startswith("file_edit"): + return "✍️" + if name.startswith("memory"): + return "🧠" + if name.startswith("env"): + return "🧭" + if name.startswith("shell") or name.startswith("bash"): + return "💻" + if name.startswith("web") or name.startswith("browser"): + return "🌐" + if name.startswith("skill"): + return "🧩" + return "🔧" + + def _tool_result_detail(metadata: dict[str, Any] | None) -> str: if not metadata: return "" @@ -209,9 +233,7 @@ def _tool_result_detail(metadata: dict[str, Any] | None) -> str: or _metadata_text(metadata, "error_preview") or _metadata_text(metadata, "result_preview") ) - base = _truncate_detail(prefix + detail, limit=_TOOL_OUTPUT_LIMIT) if detail or prefix else "failed" - context = _tool_context_detail(metadata) - return f"{base} {context}" if context else base + return _truncate_detail(prefix + detail, limit=_TOOL_OUTPUT_LIMIT) if detail or prefix else "failed" detail = ( _metadata_text(metadata, "stdout_preview") or _metadata_text(metadata, "content_preview") @@ -219,11 +241,8 @@ def _tool_result_detail(metadata: dict[str, Any] | None) -> str: or _metadata_text(metadata, "result_preview") ) if detail and not _looks_like_structured_blob(detail): - base = _truncate_detail(detail, limit=_TOOL_OUTPUT_LIMIT) - else: - base = "ok" - context = _tool_context_detail(metadata) - return f"{base} {context}" if context else base + return _truncate_detail(detail, limit=_TOOL_OUTPUT_LIMIT) + return "" if TYPE_CHECKING: @@ -262,6 +281,7 @@ def __init__(self, console: "LeapConsole") -> None: self._start_time: float = 0.0 self._tool_start_time: float = 0.0 self._active_tool: str = "" + self._active_tool_detail: str = "" self._tool_history: list[tuple[str, float]] = [] @property @@ -287,6 +307,7 @@ def start(self) -> None: self._buffer = "" self._thinking_buffer = "" self._active_tool = "" + self._active_tool_detail = "" self._tool_history = [] self._start_time = time.monotonic() self._tool_start_time = 0.0 @@ -307,16 +328,9 @@ def feed_thinking(self, chunk: str) -> None: def tool_started(self, name: str, metadata: dict[str, Any] | None = None) -> str: """Mark a tool call as started. Returns spinner text for LeapApp.""" self._active_tool = name + self._active_tool_detail = _tool_action_detail(metadata) self._tool_start_time = time.monotonic() - detail = _tool_action_detail(metadata) - line = Text() - line.append(" · ", style="leap.tool") - line.append(name, style="leap.tool_name") - if detail: - line.append(" ", style="leap.tool") - line.append(detail, style="leap.tool") - self._console.print(line) - return f"⚡ {name}" + return f"{_tool_icon(name)} {name}" def tool_finished( self, @@ -324,24 +338,29 @@ def tool_finished( output: str = "", metadata: dict[str, Any] | None = None, ) -> None: - """Mark a tool call as finished; print completion line immediately.""" + """Mark a tool call as finished; print one compact audit line.""" tool_name = name or self._active_tool if tool_name and self._tool_start_time > 0: duration = time.monotonic() - self._tool_start_time self._tool_history.append((tool_name, duration)) ok = metadata.get("ok", True) if metadata else True + action_detail = self._active_tool_detail or _tool_action_detail(metadata) + result_detail = _tool_result_detail(metadata) or _truncate_detail(output, limit=_TOOL_OUTPUT_LIMIT) line = Text() status_style = "leap.tool" if ok else "leap.error" name_style = "leap.tool_name" if ok else "leap.error" - line.append(" ✓ " if ok else " ✗ ", style=status_style) + line.append(f" {_tool_icon(tool_name, ok=ok)} ", style=status_style) line.append(tool_name, style=name_style) - line.append(f" {_format_elapsed(duration)}", style="leap.tool") - detail = _tool_result_detail(metadata) or _truncate_detail(output, limit=_TOOL_OUTPUT_LIMIT) - if detail: + if action_detail: line.append(" ", style="leap.tool") - line.append(detail, style="leap.tool" if ok else "leap.error") + line.append(action_detail, style="leap.tool") + if result_detail: + line.append(" → ", style="leap.tool" if ok else "leap.error") + line.append(result_detail, style="leap.tool" if ok else "leap.error") + line.append(f" | {_format_elapsed(duration)}", style="leap.tool") self._console.print(line) self._active_tool = "" + self._active_tool_detail = "" self._tool_start_time = 0.0 def finish(self) -> None: diff --git a/tests/test_tui_command_queue.py b/tests/test_tui_command_queue.py index 5d1d32b..e19ae00 100644 --- a/tests/test_tui_command_queue.py +++ b/tests/test_tui_command_queue.py @@ -313,8 +313,12 @@ def newline(self) -> None: renderer.tool_started("shell_run", metadata={"command": "python -V"}) renderer.tool_finished("shell_run", metadata={"ok": True, "stdout_preview": "Python 3.13.0"}) - assert any("shell_run" in line and "$ python -V" in line for line in console.lines) - assert any("shell_run" in line and "Python 3.13.0" in line for line in console.lines) + assert len(console.lines) == 1 + line = console.lines[0] + assert "💻 shell_run" in line + assert "$ python -V" in line + assert "→ Python 3.13.0" in line + assert " | " in line def test_stream_renderer_prints_tool_failure_exit_and_stderr() -> None: @@ -347,7 +351,12 @@ def newline(self) -> None: metadata={"ok": False, "exit_code": 1, "stderr_preview": "permission denied"}, ) - assert any("✗" in line and "exit=1 permission denied" in line for line in console.lines) + assert len(console.lines) == 1 + line = console.lines[0] + assert "❌ shell_run" in line + assert "$ false" in line + assert "→ exit=1 permission denied" in line + assert " | " in line def test_stream_renderer_prints_context_evidence_metadata() -> None: @@ -388,14 +397,16 @@ def newline(self) -> None: }, ) - assert any( - "file_read" in line - and "symbols" in line - and "truncated" in line - and "repeat×3" in line - and "repeat-read" not in line - for line in console.lines - ) + assert len(console.lines) == 1 + line = console.lines[0] + assert "📄 file_read" in line + assert "path=/tmp/sample.py" in line + assert " | " in line + assert "symbols" not in line + assert "truncated" not in line + assert "repeat×3" not in line + assert "evidence" not in line + assert "repeat-read" not in line def test_stream_renderer_prints_compression_and_posture_metadata() -> None: @@ -439,15 +450,19 @@ def newline(self) -> None: }, ) - assert any( - "passed" in line - and "[research · evidence · compressed]" in line - and "saved≈" not in line - and "threshold-triggered" not in line - and "maintain research ledger" not in line - and "selected capabilities matched" not in line - for line in console.lines - ) + assert len(console.lines) == 1 + line = console.lines[0] + assert "💻 shell_run" in line + assert "$ pytest" in line + assert "→ passed" in line + assert " | " in line + assert "research" not in line + assert "evidence" not in line + assert "compressed" not in line + assert "saved≈" not in line + assert "threshold-triggered" not in line + assert "maintain research ledger" not in line + assert "selected capabilities matched" not in line def test_stream_renderer_suppresses_structured_tool_blobs_and_compacts_paths() -> None: @@ -488,10 +503,17 @@ def newline(self) -> None: }, ) - assert any("path=…/segments/src/leapflow" in line for line in console.lines) - assert not any(long_path in line for line in console.lines) - assert any("file_list" in line and "ok [disclosure=full · evidence]" in line for line in console.lines) - assert not any("entries" in line or "task requires broad" in line for line in console.lines) + assert len(console.lines) == 1 + line = console.lines[0] + assert "📁 file_list" in line + assert "path=…/segments/src/leapflow" in line + assert " | " in line + assert long_path not in line + assert "ok" not in line + assert "disclosure" not in line + assert "evidence" not in line + assert "entries" not in line + assert "task requires broad" not in line @pytest.mark.asyncio From 60189e299501211063d7861f59d7f01eaf76bdbb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8F=AD=E6=89=AC?= Date: Sat, 11 Jul 2026 23:04:02 +0800 Subject: [PATCH 14/17] feat(prompt): discourage duplicate tool calls --- src/leapflow/prompts/templates.py | 11 ++++++----- tests/test_agent_execution.py | 3 +++ tests/test_pure_algorithms.py | 3 +++ 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/leapflow/prompts/templates.py b/src/leapflow/prompts/templates.py index bc82516..c8cd9d2 100644 --- a/src/leapflow/prompts/templates.py +++ b/src/leapflow/prompts/templates.py @@ -84,11 +84,12 @@ def build_react_system(language: str = "en", skill_catalog: str = "") -> str: ## Guidelines 1. **Direct answers first**: If you already know the answer, respond directly without tools. -2. **Use tools proactively**: When the user asks about files, time, system state, or needs actions performed, use the appropriate tool. -3. **Chain tools when needed**: You can call multiple tools in sequence (e.g., list files → read file → summarize). -4. **Handle failures gracefully**: If a tool fails, explain what went wrong and suggest alternatives. -5. **Summarize results naturally**: After tool execution, synthesize the results into a helpful answer rather than dumping raw output. -6. **Stay conversational**: Maintain a natural, helpful tone. Acknowledge context from earlier in the conversation. +2. **Avoid redundant tool calls**: Do not call the same tool with the same arguments more than once in the same user turn. When an existing tool result already answers the user's request, stop calling tools and answer directly. +3. **Use tools proactively**: When the user asks about files, time, system state, or needs actions performed, use the appropriate tool. +4. **Chain tools when needed**: You can call multiple tools in sequence (e.g., list files → read file → summarize). +5. **Handle failures gracefully**: If a tool fails, explain what went wrong and suggest alternatives. +6. **Summarize results naturally**: After tool execution, synthesize the results into a helpful answer rather than dumping raw output. +7. **Stay conversational**: Maintain a natural, helpful tone. Acknowledge context from earlier in the conversation. ## Presentation Style 1. **Polished Markdown only**: Format user-facing answers with clean Markdown headings, short paragraphs, and concise bullets. Use tables only when they improve comparison or scanning. diff --git a/tests/test_agent_execution.py b/tests/test_agent_execution.py index cdfdd24..cb64367 100644 --- a/tests/test_agent_execution.py +++ b/tests/test_agent_execution.py @@ -288,6 +288,9 @@ async def achat_stream(self, messages, *, enable_thinking=False, **kwargs): assert "file_read" not in str(llm.messages[0].get("content", "")) system_prompt = str(llm.messages[0].get("content", "")) assert "## Presentation Style" in system_prompt + assert "Avoid redundant tool calls" in system_prompt + assert "same tool with the same arguments" in system_prompt + assert "existing tool result already answers" in system_prompt assert "No leaked tool protocol" in system_prompt assert "Theme-safe colors" in system_prompt assert "## Task Contract" in system_prompt diff --git a/tests/test_pure_algorithms.py b/tests/test_pure_algorithms.py index caedd53..b636ee8 100644 --- a/tests/test_pure_algorithms.py +++ b/tests/test_pure_algorithms.py @@ -144,6 +144,9 @@ def test_unified_system_template_escapes_literal_tool_protocol_json() -> None: assert '{"name": "tool_name", "arguments": {"key": "value"}}' in rendered assert '{"name": ..., "arguments": ...}' in rendered + assert "Avoid redundant tool calls" in rendered + assert "same tool with the same arguments" in rendered + assert "existing tool result already answers" in rendered def test_json_extraction_variants() -> None: From cc276df37758d231c65d7283182c8d95911ab9b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8F=AD=E6=89=AC?= Date: Sun, 12 Jul 2026 00:39:27 +0800 Subject: [PATCH 15/17] feat(tui): add task control commands --- AGENTS.md | 2 + src/leapflow/cli/commands/interactive.py | 192 +++++++++++++-- src/leapflow/cli/commands/registry.py | 12 +- src/leapflow/cli/tui_app/app.py | 289 ++++++++++++++++++++++- src/leapflow/cli/tui_app/command.py | 20 ++ src/leapflow/cli/tui_app/console.py | 4 + src/leapflow/cli/tui_app/theme.py | 5 +- src/leapflow/daemon/client.py | 5 + tests/test_daemon_rpc.py | 26 ++ tests/test_tui_command_queue.py | 232 +++++++++++++++++- tests/test_tui_theme.py | 13 +- 11 files changed, 760 insertions(+), 40 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5858828..adf6984 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,6 +32,8 @@ This document is the LeapFlow engineering collaboration contract. It is not only - **System Boundary Awareness**: LeapFlow is a multi-entry, multi-module runtime. Changes must account for the affected path across CLI/TUI, leapd, engine, skills/tools, LLM, storage, memory, gateway, hub, and platform adapters. - **TUI as the Primary User Entry**: The interactive TUI is the default product surface. Preserve streaming feedback, command queue behavior, approval prompts, status bar accuracy, long-input robustness, history, and session continuity. +- **TUI Command Clarity**: Global task-control commands stay short and unambiguous (`/cancel`, `/skip`, `/pause`, `/resume`, `/queue`, `/drop`); teach-mode controls must use the `/teach ...` namespace and should not keep bare compatibility aliases during early iteration. +- **TUI Prompt Ownership**: Input prompt and placeholder rendering must have a single owner. Avoid duplicate prompt sources; placeholder text stays visually subordinate, offset after the prompt, and disappears as soon as the user types. - **leapd Runtime Consistency**: Daemon-backed behavior must preserve lifecycle correctness: start, stop, restart, status, RPC streaming, cancellation, pending approvals, runtime config reload, multi-client state, and version consistency. - **Progressive Context Disclosure (PCD)**: Keep one unified execution loop, but never default every turn to full disclosure. Each LLM call must use the smallest sufficient PromptAssemblyPlan for tools, memory, history, reasoning, streaming, and risk; upgrade progressively only when observable signals require it. - **Dependency Inversion**: Core logic depends on Protocol abstractions, never on concrete implementations diff --git a/src/leapflow/cli/commands/interactive.py b/src/leapflow/cli/commands/interactive.py index bba9320..c82843a 100644 --- a/src/leapflow/cli/commands/interactive.py +++ b/src/leapflow/cli/commands/interactive.py @@ -135,6 +135,15 @@ def _host_action(args: str) -> str: return action +def _format_queue_elapsed(seconds: float) -> str: + if seconds < 1.0: + return f"{seconds * 1000:.0f}ms" + if seconds < 60.0: + return f"{seconds:.1f}s" + minutes = int(seconds // 60) + return f"{minutes}m{seconds - minutes * 60:.0f}s" + + class _DaemonRuntimeBridge: """Reconnectable bridge for daemon-backed TUI runtime calls.""" @@ -554,7 +563,7 @@ async def handle_input(text: str) -> None: handle_model(ctx, console, cmd_args) return - if canonical.startswith("teach") or canonical in ("annotate", "skip"): + if canonical.startswith("teach") or canonical == "annotate": if await _handle_teach(ctx, console, cmd_text, _learning): await _after_dispatch(text) return @@ -657,6 +666,76 @@ async def _after_dispatch(text: str) -> None: else: _last_hint = None + def _render_queue_state() -> None: + active = app.active_command + queued = app.queued_commands() + if active is None and not queued: + console.system("Queue is empty.") + return + if active is not None: + console.system(f"{active.label} {active.status.value} {_format_queue_elapsed(active.elapsed_s)} {active.summary()}") + for command in queued: + console.system(f"{command.label} {command.status.value} {command.summary()}") + if app.queue_paused: + console.system("Queue is paused — use /resume to continue.") + + def _handle_task_control(text: str) -> bool: + invocation = command_router.parse(text) + if invocation is None: + return False + canonical = invocation.command.name + args = invocation.args.strip() + if canonical not in {"cancel", "skip", "pause", "resume", "queue", "drop"}: + return False + if canonical == "cancel": + cancelled = app.request_cancel_active("cancelled by user") + if cancelled is None: + console.system("No running task to cancel.") + else: + engine = getattr(ctx, "engine", None) + if engine is not None and hasattr(engine, "cancel"): + engine.cancel() + console.warning(f"Cancelled {cancelled.label}. Continuing queued work.") + return True + if canonical == "skip": + skipped = app.request_skip_active("skipped by user") + if skipped is None: + console.system("No running task to skip.") + else: + engine = getattr(ctx, "engine", None) + if engine is not None and hasattr(engine, "cancel"): + engine.cancel() + console.warning(f"Skipped {skipped.label}. Continuing queued work.") + return True + if canonical == "pause": + changed = app.pause_queue() + console.system("Queue paused. Current running task continues; new queued tasks will wait." if changed else "Queue is already paused.") + return True + if canonical == "resume": + changed = app.resume_queue() + console.system("Queue resumed." if changed else "Queue is not paused.") + return True + if canonical == "queue": + if args.lower() == "clear": + dropped = app.clear_queued_commands("cleared by user") + console.system(f"Cleared {len(dropped)} queued task(s).") + else: + _render_queue_state() + return True + if canonical == "drop": + try: + command_id = int(args) + except ValueError: + console.warning("Usage: /drop ") + return True + dropped = app.drop_queued_command(command_id, "dropped by user") + if dropped is None: + console.warning(f"No queued task #{command_id}.") + else: + console.system(f"Dropped {dropped.label}.") + return True + return False + # ── Create and run the Application ─────────────────────────────── app = LeapApp( @@ -670,6 +749,7 @@ async def _after_dispatch(text: str) -> None: else None ), on_input=handle_input, + on_control=_handle_task_control, ) # Auto-connect previously configured gateway platforms @@ -1089,6 +1169,83 @@ def _print_exit_summary() -> None: ): console.print(line) + def _render_queue_state() -> None: + active = app.active_command + queued = app.queued_commands() + if active is None and not queued: + console.system("Queue is empty.") + return + if active is not None: + console.system(f"{active.label} {active.status.value} {_format_queue_elapsed(active.elapsed_s)} {active.summary()}") + for command in queued: + console.system(f"{command.label} {command.status.value} {command.summary()}") + if app.queue_paused: + console.system("Queue is paused — use /resume to continue.") + + def _request_daemon_cancel() -> None: + async def cancel_remote() -> None: + try: + await bridge.call( + lambda current_client: current_client.engine_cancel(), + description="engine cancel", + ) + except Exception as exc: + console.warning(f"Daemon cancel failed: {exc}") + asyncio.create_task(cancel_remote()) + + def _handle_task_control(text: str) -> bool: + invocation = command_router.parse(text) + if invocation is None: + return False + canonical = invocation.command.name + args = invocation.args.strip() + if canonical not in {"cancel", "skip", "pause", "resume", "queue", "drop"}: + return False + if canonical == "cancel": + cancelled = app.request_cancel_active("cancelled by user") + if cancelled is None: + console.system("No running task to cancel.") + else: + _request_daemon_cancel() + console.warning(f"Cancelled {cancelled.label}. Continuing queued work.") + return True + if canonical == "skip": + skipped = app.request_skip_active("skipped by user") + if skipped is None: + console.system("No running task to skip.") + else: + _request_daemon_cancel() + console.warning(f"Skipped {skipped.label}. Continuing queued work.") + return True + if canonical == "pause": + changed = app.pause_queue() + console.system("Queue paused. Current running task continues; new queued tasks will wait." if changed else "Queue is already paused.") + return True + if canonical == "resume": + changed = app.resume_queue() + console.system("Queue resumed." if changed else "Queue is not paused.") + return True + if canonical == "queue": + if args.lower() == "clear": + dropped = app.clear_queued_commands("cleared by user") + console.system(f"Cleared {len(dropped)} queued task(s).") + else: + _render_queue_state() + return True + if canonical == "drop": + try: + command_id = int(args) + except ValueError: + console.warning("Usage: /drop ") + return True + dropped = app.drop_queued_command(command_id, "dropped by user") + if dropped is None: + console.warning(f"No queued task #{command_id}.") + else: + console.system(f"Dropped {dropped.label}.") + return True + return False + app = LeapApp( console=console, theme=theme, @@ -1096,6 +1253,7 @@ def _print_exit_summary() -> None: commands=completion_entries(), data_dir=getattr(settings, "data_dir", None), on_input=handle_input, + on_control=_handle_task_control, ) if resume_id: @@ -1146,7 +1304,7 @@ async def _handle_teach( or line == "teach" ): if ctx.session and ctx.session.mode == SessionMode.LEARNING: - console.warning("Already in teaching mode. Say 'teach stop' to end.") + console.warning("Already in teaching mode. Say '/teach stop' to end.") return True goal = "" if line.startswith("teach start "): @@ -1159,13 +1317,14 @@ async def _handle_teach( if goal: console.system(f"Goal: {goal}") console.system( - "Commands: stop │ discard │ pause │ resume │ annotate │ skip [n]" + "Commands: /teach stop │ /teach discard │ /teach pause │ " + "/teach resume │ /teach skip [n] │ /annotate " ) except Exception as e: console.error(str(e)) return True - if line in ("teach stop", "stop", "done", "教学结束", "结束"): + if line == "teach stop": if not ctx.session or ctx.session.mode != SessionMode.LEARNING: if learning: ctx.imitation.end_control_input() @@ -1229,14 +1388,7 @@ async def _handle_teach( console.error(str(e)) return True - if line in ( - "teach quit", - "teach discard", - "quit", - "discard", - "退出教学", - "放弃", - ): + if line in ("teach quit", "teach discard"): if not ctx.session or ctx.session.mode != SessionMode.LEARNING: console.warning("Not in teaching mode.") return True @@ -1247,7 +1399,7 @@ async def _handle_teach( console.error(str(e)) return True - if line in ("teach save", "save", "保存"): + if line == "teach save": if not ctx.session or ctx.session.mode != SessionMode.LEARNING: console.warning("Not in teaching mode.") return True @@ -1257,32 +1409,32 @@ async def _handle_teach( console.success("Session saved for later resume.") if learning_session: console.system( - f" Resume with: teach resume {learning_session.session_id}" + f" Resume with: /teach resume {learning_session.session_id}" ) except Exception as e: console.error(str(e)) return True - if line in ("teach pause", "pause", "暂停"): + if line == "teach pause": if ctx.session: ctx.session.pause_learning() console.system("Recording paused.") return True - if line in ("teach resume", "resume", "继续"): + if line == "teach resume": from leapflow.engine.session import SessionMode as SM if ctx.session and ctx.session.mode == SM.LEARNING: ctx.session.resume_learning() console.system("Recording resumed.") else: - console.warning("Not in teaching mode. Use 'teach resume '.") + console.warning("Not in teaching mode. Use '/teach resume '.") return True if line.startswith("teach resume "): resume_id = line[len("teach resume "):].strip() if not resume_id: - console.warning("Usage: teach resume ") + console.warning("Usage: /teach resume ") return True if ctx.session and ctx.session.mode == SessionMode.LEARNING: console.warning("Already in teaching mode. Stop first.") @@ -1308,9 +1460,9 @@ async def _handle_teach( console.system("Annotation added.") return True - if line.startswith("skip") or line.startswith("跳过"): + if line.startswith("teach skip"): parts = line.split() - n = int(parts[1]) if len(parts) > 1 and parts[1].isdigit() else 1 + n = int(parts[2]) if len(parts) > 2 and parts[2].isdigit() else 1 if ctx.session: count = ctx.session.mark_skip(n) console.system(f"Marked {count} step(s) as noise.") diff --git a/src/leapflow/cli/commands/registry.py b/src/leapflow/cli/commands/registry.py index fd4209a..2d5245a 100644 --- a/src/leapflow/cli/commands/registry.py +++ b/src/leapflow/cli/commands/registry.py @@ -80,18 +80,26 @@ def supports_runtime(self, runtime: CommandRuntime) -> bool: ), CommandDef("exit", "Quit LeapFlow", "Session", aliases=("quit", "q", "退出"), supports_daemon=True), + # Task Control + CommandDef("cancel", "Cancel the currently running task", "Task Control", aliases=("abort",), supports_daemon=True, effect=CommandEffect.SESSION, execution=CommandExecution.SHORT_OPERATION), + CommandDef("skip", "Skip the current running task and continue the queue", "Task Control", supports_daemon=True, effect=CommandEffect.SESSION, execution=CommandExecution.SHORT_OPERATION), + CommandDef("pause", "Pause starting queued tasks", "Task Control", supports_daemon=True, effect=CommandEffect.SESSION, execution=CommandExecution.INSTANT), + CommandDef("resume", "Resume queued task execution", "Task Control", supports_daemon=True, effect=CommandEffect.SESSION, execution=CommandExecution.INSTANT), + CommandDef("queue", "Show or clear queued tasks", "Task Control", args_hint="[clear]", supports_daemon=True, effect=CommandEffect.READ_ONLY), + CommandDef("drop", "Remove a queued task by id", "Task Control", args_hint="", supports_daemon=True, effect=CommandEffect.SESSION), + # Chat CommandDef("model", "Show or switch active model", "Chat", args_hint="[model_name]", supports_daemon=True, requires_llm=True), CommandDef("usage", "Show token usage for current session", "Chat", supports_daemon=True, requires_llm=True), # Teaching CommandDef("teach start", "Start teaching mode", "Teaching", aliases=("teach",), args_hint="[goal]"), - CommandDef("teach stop", "Stop and distill skill", "Teaching", aliases=("stop", "done")), + CommandDef("teach stop", "Stop and distill skill", "Teaching"), CommandDef("teach pause", "Pause recording", "Teaching"), CommandDef("teach resume", "Resume recording", "Teaching"), CommandDef("teach discard", "Discard current recording", "Teaching"), + CommandDef("teach skip", "Mark last n steps as noise", "Teaching", args_hint="[n]"), CommandDef("annotate", "Add annotation during teaching", "Teaching", args_hint=""), - CommandDef("skip", "Mark last n steps as noise", "Teaching", args_hint="[n]"), # Skills & Tools CommandDef("skills", "List all skills", "Skills & Tools", aliases=("skills list",)), diff --git a/src/leapflow/cli/tui_app/app.py b/src/leapflow/cli/tui_app/app.py index ec21b6b..95ddeae 100644 --- a/src/leapflow/cli/tui_app/app.py +++ b/src/leapflow/cli/tui_app/app.py @@ -24,6 +24,7 @@ import asyncio import os import time +from collections import deque from pathlib import Path from typing import ( TYPE_CHECKING, @@ -37,6 +38,7 @@ from prompt_toolkit import Application from prompt_toolkit.auto_suggest import AutoSuggestFromHistory +from prompt_toolkit.formatted_text.utils import fragment_list_len from prompt_toolkit.history import FileHistory from prompt_toolkit.key_binding import KeyBindings from prompt_toolkit.keys import Keys @@ -45,11 +47,12 @@ from prompt_toolkit.layout.controls import FormattedTextControl from prompt_toolkit.layout.dimension import Dimension from prompt_toolkit.layout.menus import CompletionsMenu +from prompt_toolkit.layout.processors import Processor, Transformation from prompt_toolkit.patch_stdout import patch_stdout from prompt_toolkit.styles import Style as PTStyle from prompt_toolkit.widgets import TextArea -from leapflow.cli.tui_app.command import TuiCommand +from leapflow.cli.tui_app.command import TuiCommand, TuiCommandStatus from leapflow.cli.tui_app.input import build_completer from leapflow.cli.tui_app.paste import ( PASTE_FRAGMENT_WINDOW_S, @@ -65,8 +68,92 @@ _HISTORY_FILENAME = "history" _REFRESH_INTERVAL_S = 0.5 _PASTE_COMPACTOR_ATTR = "_leapflow_paste_compactor_installed" +_PLACEHOLDER_CURSOR_SPACER = " " InputHandler = Callable[[str], Union[Awaitable[None], None]] +ControlHandler = Callable[[str], bool] + + +def _format_inline_duration(seconds: float) -> str: + """Format short elapsed text for inline TUI guidance.""" + if seconds < 1.0: + return f"{seconds * 1000:.0f}ms" + if seconds < 60.0: + return f"{seconds:.1f}s" + minutes = int(seconds // 60) + return f"{minutes}m{seconds - minutes * 60:.0f}s" + + +class _DynamicPlaceholderProcessor(Processor): + """Render the input prompt and contextual placeholder text.""" + + def __init__( + self, + provider: Callable[[], str], + prompt_provider: Callable[[], list[tuple[str, str]]], + ) -> None: + self._provider = provider + self._prompt_provider = prompt_provider + + def apply_transformation(self, transformation_input: Any) -> Transformation: + document = getattr(transformation_input, "document", None) + is_first_line = getattr(transformation_input, "lineno", 0) == 0 + has_text = bool(getattr(document, "text", "")) if document is not None else False + if not is_first_line: + return Transformation(transformation_input.fragments) + prompt_fragments = self._prompt_provider() + if has_text: + prefix = prompt_fragments + fragments = [*prefix, *transformation_input.fragments] + else: + placeholder = self._provider() + placeholder_fragments = [ + ("class:placeholder", _PLACEHOLDER_CURSOR_SPACER), + ("class:placeholder", placeholder), + ] if placeholder else [] + prefix = [*prompt_fragments, *placeholder_fragments] + fragments = [*prefix, *transformation_input.fragments] + shift_position = fragment_list_len(prefix) + return Transformation( + fragments, + source_to_display=lambda index: index + shift_position, + display_to_source=lambda index: index - shift_position, + ) + + +class _CommandQueue: + """Small observable async queue for TUI command scheduling.""" + + def __init__(self) -> None: + self._items: deque[TuiCommand] = deque() + self._ready = asyncio.Event() + + def put_nowait(self, command: TuiCommand) -> None: + self._items.append(command) + self._ready.set() + + async def get(self) -> TuiCommand: + while not self._items: + self._ready.clear() + await self._ready.wait() + command = self._items.popleft() + if not self._items: + self._ready.clear() + return command + + def get_nowait(self) -> TuiCommand: + if not self._items: + raise asyncio.QueueEmpty + command = self._items.popleft() + if not self._items: + self._ready.clear() + return command + + def qsize(self) -> int: + return len(self._items) + + def snapshot(self) -> list[TuiCommand]: + return list(self._items) class LeapApp: @@ -89,11 +176,13 @@ def __init__( commands: Sequence[tuple[str, str]] = (), data_dir: Optional[Path] = None, on_input: Optional[InputHandler] = None, + on_control: Optional[ControlHandler] = None, ) -> None: self._console = console self._theme = theme self._status = status self._on_input = on_input + self._on_control = on_control self._should_exit = False self._agent_running = False @@ -101,6 +190,11 @@ def __init__( self._spinner_text = "" self._tool_start_time: float = 0.0 self._next_command_id = 1 + self._queue_paused = False + self._active_dispatch_task: Optional[asyncio.Task[Any]] = None + self._active_terminal_status: Optional[TuiCommandStatus] = None + self._active_terminal_reason = "" + self._last_control_c_at = 0.0 self._paste_heuristics = PasteHeuristics() self._paste_store = PasteStore(self._paste_heuristics) self._paste_blocks = self._paste_store.blocks @@ -109,7 +203,7 @@ def __init__( self._paste_fragment_last_at = 0.0 self._active_fragment_marker: Optional[str] = None self._active_command: Optional[TuiCommand] = None - self._pending_input: asyncio.Queue[TuiCommand] = asyncio.Queue() + self._pending_input = _CommandQueue() data_dir = data_dir or Path( os.environ.get("LEAPFLOW_DATA_DIR", "~/.leapflow") @@ -155,15 +249,134 @@ def submit_text(self, text: str) -> TuiCommand: normalized = self._resolve_paste_blocks(text).strip() if not normalized: raise ValueError("Cannot submit an empty TUI command") + if self._dispatch_control_text(normalized): + return TuiCommand.create(command_id=0, text=normalized).mark_done() command = TuiCommand.create(command_id=self._next_command_id, text=normalized) self._next_command_id += 1 self._pending_input.put_nowait(command) self._sync_task_counts() - if self._active_command is not None or self._pending_input.qsize() > 1: + if self._active_command is not None or self._pending_input.qsize() > 1 or self._queue_paused: self._console.command_card(command) self._invalidate() return command + @property + def queue_paused(self) -> bool: + """Return whether queued commands are currently held.""" + return self._queue_paused + + @property + def active_command(self) -> Optional[TuiCommand]: + """Return the currently running command, if any.""" + return self._active_command + + def queued_commands(self) -> list[TuiCommand]: + """Return a snapshot of pending commands in queue order.""" + return self._pending_input.snapshot() + + def pause_queue(self) -> bool: + """Pause starting future queued commands; current work continues.""" + if self._queue_paused: + return False + self._queue_paused = True + self._sync_task_counts() + self._invalidate() + return True + + def resume_queue(self) -> bool: + """Resume starting queued commands.""" + if not self._queue_paused: + return False + self._queue_paused = False + self._sync_task_counts() + self._invalidate() + return True + + def clear_queued_commands(self, reason: str = "cleared by user") -> list[TuiCommand]: + """Drop every pending command and render them as skipped.""" + dropped: list[TuiCommand] = [] + while True: + try: + command = self._pending_input.get_nowait() + except asyncio.QueueEmpty: + break + skipped = command.mark_skipped(reason) + dropped.append(skipped) + self._console.command_card(skipped) + self._sync_task_counts() + self._invalidate() + return dropped + + def drop_queued_command(self, command_id: int, reason: str = "dropped by user") -> Optional[TuiCommand]: + """Drop one queued command by id while preserving queue order.""" + kept: list[TuiCommand] = [] + dropped: Optional[TuiCommand] = None + while True: + try: + command = self._pending_input.get_nowait() + except asyncio.QueueEmpty: + break + if command.id == command_id and dropped is None: + dropped = command.mark_skipped(reason) + self._console.command_card(dropped) + else: + kept.append(command) + for command in kept: + self._pending_input.put_nowait(command) + self._sync_task_counts() + self._invalidate() + return dropped + + def request_cancel_active(self, reason: str = "cancelled by user") -> Optional[TuiCommand]: + """Mark current work as cancelled and cancel its dispatch task.""" + return self._request_finish_active(TuiCommandStatus.CANCELLED, reason) + + def request_skip_active(self, reason: str = "skipped by user") -> Optional[TuiCommand]: + """Mark current work as skipped and cancel its dispatch task.""" + return self._request_finish_active(TuiCommandStatus.SKIPPED, reason) + + def _request_finish_active(self, status: TuiCommandStatus, reason: str) -> Optional[TuiCommand]: + if self._active_command is None: + return None + self._active_terminal_status = status + self._active_terminal_reason = reason + terminal = self._terminal_command(self._active_command, status, reason) + self._active_command = terminal + self._console.command_card(terminal) + task = self._active_dispatch_task + if task is not None and not task.done(): + task.cancel() + self._spinner_text = "" + self._tool_start_time = 0.0 + self._sync_task_counts() + self._invalidate() + return terminal + + def _terminal_command( + self, + command: TuiCommand, + status: TuiCommandStatus, + reason: str, + ) -> TuiCommand: + if status == TuiCommandStatus.CANCELLED: + return command.mark_cancelled(reason) + if status == TuiCommandStatus.SKIPPED: + return command.mark_skipped(reason) + return command.mark_failed(reason) + + def _dispatch_control_text(self, text: str) -> bool: + handler = self._on_control + if handler is None: + return False + try: + handled = handler(text) + except Exception as exc: + self._console.error(f"Task control failed: {exc}") + return True + if handled: + self._invalidate() + return handled + def _should_compact_paste(self, text: str) -> bool: """Return True when rendering pasted text directly would hurt TUI responsiveness.""" return self._paste_heuristics.should_compact_block(text) @@ -394,6 +607,9 @@ def exit(self) -> None: async def _process_loop(self) -> None: """Drain pending_input and dispatch to on_input handler.""" while not self._should_exit: + if self._queue_paused: + await asyncio.sleep(0.1) + continue try: command = await asyncio.wait_for( self._pending_input.get(), timeout=0.1 @@ -408,6 +624,8 @@ async def _process_loop(self) -> None: continue self._active_command = command.mark_running() + self._active_terminal_status = None + self._active_terminal_reason = "" self._agent_running = True self._sync_task_counts() self._console.command_card(self._active_command) @@ -415,10 +633,23 @@ async def _process_loop(self) -> None: if self._on_input is not None: result = self._on_input(command.text) if asyncio.iscoroutine(result): - await result - if self._active_command is not None: + self._active_dispatch_task = asyncio.create_task(result) + await self._active_dispatch_task + if self._active_command is not None and self._active_terminal_status is None: finished = self._active_command.mark_done() self._console.command_card(finished) + except asyncio.CancelledError: + if self._active_command is not None and self._active_terminal_status is not None: + terminal = self._terminal_command( + self._active_command, + self._active_terminal_status, + self._active_terminal_reason or self._active_terminal_status.value, + ) + if terminal.status != self._active_command.status: + self._console.command_card(terminal) + elif self._active_command is not None: + cancelled = self._active_command.mark_cancelled("cancelled by user") + self._console.command_card(cancelled) except Exception as exc: if self._active_command is not None: failed = self._active_command.mark_failed(f"{type(exc).__name__}: {exc}") @@ -428,6 +659,9 @@ async def _process_loop(self) -> None: self._tool_start_time = 0.0 finally: self._active_command = None + self._active_dispatch_task = None + self._active_terminal_status = None + self._active_terminal_reason = "" self._agent_running = False self._sync_task_counts() self._invalidate() @@ -440,12 +674,9 @@ def _build_input_area( completer = build_completer(commands) ref = self - def get_prompt(): - return ref._prompt_fragments() - area = TextArea( height=Dimension(min=1, max=4, preferred=1), - prompt=get_prompt, + prompt="", style="class:input-area", multiline=True, wrap_lines=True, @@ -454,6 +685,10 @@ def get_prompt(): completer=completer, complete_while_typing=True, auto_suggest=AutoSuggestFromHistory(), + input_processors=[_DynamicPlaceholderProcessor( + ref._placeholder_text, + ref._prompt_fragments, + )], ) area.buffer.tempfile_suffix = ".md" self._install_paste_compactor(area.buffer) @@ -579,9 +814,14 @@ def _(event): ref._clear_paste_state() return if ref._agent_running: + now = time.monotonic() + if now - ref._last_control_c_at <= 2.0 and ref._dispatch_control_text("/cancel"): + ref._last_control_c_at = 0.0 + return + ref._last_control_c_at = now ref._console.system( - "Task is running. Keep typing to queue the next instruction; " - "use /exit after it finishes to leave." + "Task is running. Type /cancel to stop, /skip to continue the queue, " + "or press Ctrl+C again to cancel." ) return ref._should_exit = True @@ -609,7 +849,7 @@ def _build_style(self) -> PTStyle: "status-bar.bad": f"bg:{t.toolbar_bg} {t.error}", "hint": t.text_dim, "auto-suggest": t.auto_suggest, - "placeholder": t.input_placeholder, + "placeholder": f"{t.input_placeholder} nobold", "selection": f"bg:{t.input_selection_bg} {t.input_selection_fg}", "completion-menu": f"bg:{t.toolbar_bg} {t.input_text}", "completion-menu.completion": f"bg:{t.toolbar_bg} {t.input_text}", @@ -621,6 +861,11 @@ def _build_style(self) -> PTStyle: # ── Fragment providers ─────────────────────────────────────────── def _prompt_fragments(self) -> list[tuple[str, str]]: + if self._queue_paused: + return [ + ("class:prompt.paused", "⏸ "), + ("class:prompt", "❯ "), + ] if self._agent_running: return [("class:prompt.working", "⚕ ")] _mode_prompts = { @@ -636,6 +881,21 @@ def _prompt_fragments(self) -> list[tuple[str, str]]: } return _mode_prompts.get(self._prompt_mode, [("class:prompt", "❯ ")]) + def _placeholder_text(self) -> str: + """Return contextual input guidance for an empty buffer.""" + if self._queue_paused: + return "Queue paused · /resume continue · /drop remove · /queue view" + if self._active_command is not None: + command = self._active_command + elapsed = _format_inline_duration(command.elapsed_s) + return ( + f"Running {command.label} {elapsed} · type to queue next · " + "/cancel stop · /skip next · /queue view" + ) + if self._pending_input.qsize() > 0: + return f"{self._pending_input.qsize()} queued · /pause hold · /queue view · /drop remove" + return "Ask LeapFlow… /help commands · /status runtime · /queue tasks" + def _spinner_fragments(self) -> list[tuple[str, str]]: if not self._spinner_text: return [] @@ -647,7 +907,10 @@ def _spinner_fragments(self) -> list[tuple[str, str]]: elapsed = f" ({m:02d}m{s:02d}s)" else: elapsed = f" ({dt:.1f}s)" - return [("class:hint", f" {self._spinner_text}{elapsed}")] + text = f" {self._spinner_text}{elapsed}" + if self._active_command is not None and self._active_command.elapsed_s >= 30: + text += " · /cancel stop · /skip next · /queue view" + return [("class:hint", text)] def _spinner_height(self) -> int: return 1 if self._spinner_text else 0 diff --git a/src/leapflow/cli/tui_app/command.py b/src/leapflow/cli/tui_app/command.py index 222ba14..69c0ee7 100644 --- a/src/leapflow/cli/tui_app/command.py +++ b/src/leapflow/cli/tui_app/command.py @@ -32,6 +32,8 @@ class TuiCommandStatus(str, Enum): RUNNING = "running" DONE = "done" FAILED = "failed" + CANCELLED = "cancelled" + SKIPPED = "skipped" @dataclass(frozen=True) @@ -99,3 +101,21 @@ def mark_failed(self, error: str) -> "TuiCommand": finished_at=time.monotonic(), error=_truncate(_single_line(error), _MAX_ERROR_LENGTH), ) + + def mark_cancelled(self, reason: str = "cancelled by user") -> "TuiCommand": + """Return a copy marked as cancelled with a concise reason.""" + return replace( + self, + status=TuiCommandStatus.CANCELLED, + finished_at=time.monotonic(), + error=_truncate(_single_line(reason), _MAX_ERROR_LENGTH), + ) + + def mark_skipped(self, reason: str = "skipped by user") -> "TuiCommand": + """Return a copy marked as skipped with a concise reason.""" + return replace( + self, + status=TuiCommandStatus.SKIPPED, + finished_at=time.monotonic(), + error=_truncate(_single_line(reason), _MAX_ERROR_LENGTH), + ) diff --git a/src/leapflow/cli/tui_app/console.py b/src/leapflow/cli/tui_app/console.py index 230966b..d899386 100644 --- a/src/leapflow/cli/tui_app/console.py +++ b/src/leapflow/cli/tui_app/console.py @@ -138,12 +138,16 @@ def command_card(self, command: TuiCommand) -> None: TuiCommandStatus.RUNNING: "leap.accent", TuiCommandStatus.DONE: "leap.success", TuiCommandStatus.FAILED: "leap.error", + TuiCommandStatus.CANCELLED: "leap.warning", + TuiCommandStatus.SKIPPED: "leap.warning", } title_styles = { TuiCommandStatus.QUEUED: "leap.muted", TuiCommandStatus.RUNNING: "leap.accent", TuiCommandStatus.DONE: "leap.success", TuiCommandStatus.FAILED: "leap.error", + TuiCommandStatus.CANCELLED: "leap.warning", + TuiCommandStatus.SKIPPED: "leap.warning", } summary_limit = max(_COMMAND_CARD_MIN_SUMMARY, self.width - _COMMAND_CARD_PADDING) body = Text(command.summary(limit=summary_limit), style="leap.muted") diff --git a/src/leapflow/cli/tui_app/theme.py b/src/leapflow/cli/tui_app/theme.py index 0f45008..05d0e45 100644 --- a/src/leapflow/cli/tui_app/theme.py +++ b/src/leapflow/cli/tui_app/theme.py @@ -16,6 +16,7 @@ _HEX_RE = re.compile(r"^#[0-9a-fA-F]{6}$") _INPUT_TEXT_MIN_CONTRAST = 7.0 _SECONDARY_TEXT_MIN_CONTRAST = 4.5 +_PLACEHOLDER_MIN_CONTRAST = 3.0 _PROMPT_MIN_CONTRAST = 5.0 _DARK_TERMINAL_BG = "#0B1F24" @@ -166,7 +167,7 @@ class ResolvedTheme(Theme): prompt_char="bold #E6DDC4", input_text="#F8FAFC", input_bg=_DARK_TERMINAL_BG, - input_placeholder="#94A3B8", + input_placeholder="#64748B", input_border="#8FA09B", input_focus_border="#E6DDC4", input_selection_bg="#334155", @@ -387,7 +388,7 @@ def resolve_theme( input_placeholder = ensure_contrast( base.input_placeholder, background, - min_ratio=_SECONDARY_TEXT_MIN_CONTRAST, + min_ratio=_PLACEHOLDER_MIN_CONTRAST, candidates=_PLACEHOLDER_CANDIDATES, ) auto_suggest = ensure_contrast( diff --git a/src/leapflow/daemon/client.py b/src/leapflow/daemon/client.py index 0a4791f..afcc6eb 100644 --- a/src/leapflow/daemon/client.py +++ b/src/leapflow/daemon/client.py @@ -87,6 +87,11 @@ async def engine_chat( finally: await _close_writer(writer) + async def engine_cancel(self) -> bool: + """Request cancellation of the daemon-owned active engine turn.""" + result = await self.request("engine.cancel") + return bool(result) + async def session_resume(self, session_id: str) -> dict[str, Any]: """Ask the daemon to load an existing conversation session.""" result = await self.request("session.resume", {"session_id": session_id}) diff --git a/tests/test_daemon_rpc.py b/tests/test_daemon_rpc.py index aa3eae7..31931fb 100644 --- a/tests/test_daemon_rpc.py +++ b/tests/test_daemon_rpc.py @@ -17,6 +17,7 @@ class _FakeService: def __init__(self) -> None: self._client_count = lambda: 0 + self.cancelled = False def set_client_count_provider(self, provider) -> None: self._client_count = provider @@ -33,6 +34,10 @@ async def engine_chat(self, message: str, **kwargs: Any) -> AsyncIterator[Stream async def status(self) -> dict[str, Any]: return {"pid": 123, "active_clients": self._client_count(), "profile": "test"} + async def engine_cancel(self) -> bool: + self.cancelled = True + return True + class _FailingStreamService(_FakeService): async def engine_chat(self, message: str, **kwargs: Any) -> AsyncIterator[StreamChunk]: @@ -86,6 +91,27 @@ async def test_daemon_client_receives_stream_events() -> None: assert events[0].metadata == {"session_id": "sess-1"} +@pytest.mark.asyncio +async def test_daemon_client_can_cancel_engine_turn() -> None: + with tempfile.TemporaryDirectory(prefix="lfd-", dir="/tmp") as root: + service = _FakeService() + server, task, run_dir = await _start_server(Path(root) / "run", service=service) + client = DaemonClient(run_dir / "leapd.sock") + + try: + cancelled = await client.engine_cancel() + finally: + task.cancel() + await server.stop() + try: + await task + except asyncio.CancelledError: + pass + + assert cancelled is True + assert service.cancelled is True + + @pytest.mark.asyncio async def test_daemon_client_stream_heartbeat_prevents_idle_timeout() -> None: with tempfile.TemporaryDirectory(prefix="lfd-", dir="/tmp") as root: diff --git a/tests/test_tui_command_queue.py b/tests/test_tui_command_queue.py index e19ae00..a22a1fc 100644 --- a/tests/test_tui_command_queue.py +++ b/tests/test_tui_command_queue.py @@ -2,6 +2,7 @@ import asyncio from contextlib import suppress +from types import SimpleNamespace import pytest @@ -10,7 +11,7 @@ from prompt_toolkit.completion import Completion from prompt_toolkit.document import Document from prompt_toolkit.formatted_text import to_plain_text -from leapflow.cli.tui_app.app import LeapApp +from leapflow.cli.tui_app.app import LeapApp, _DynamicPlaceholderProcessor from leapflow.cli.tui_app.console import LeapConsole from leapflow.cli.tui_app.command import TuiCommand, TuiCommandStatus from leapflow.cli.tui_app.input import SlashCommandCompleter @@ -23,6 +24,7 @@ def __init__(self) -> None: self.cards: list[TuiCommand] = [] self.errors: list[str] = [] self.systems: list[str] = [] + self.warnings: list[str] = [] def command_card(self, command: TuiCommand) -> None: self.cards.append(command) @@ -33,6 +35,9 @@ def error(self, message: str) -> None: def system(self, message: str) -> None: self.systems.append(message) + def warning(self, message: str) -> None: + self.warnings.append(message) + class _FakeStatus: def __init__(self) -> None: @@ -58,6 +63,7 @@ def _make_app( on_input=None, *, commands: tuple[tuple[str, str], ...] = (), + on_control=None, ) -> tuple[LeapApp, _FakeConsole, _FakeStatus]: console = _FakeConsole() status = _FakeStatus() @@ -67,6 +73,7 @@ def _make_app( status=status, commands=commands, on_input=on_input, + on_control=on_control, ) return app, console, status @@ -554,6 +561,229 @@ def test_failed_command_error_is_single_line_and_truncated() -> None: assert failed.error.endswith("…") +def test_cancelled_and_skipped_commands_are_terminal() -> None: + command = TuiCommand.create(command_id=1, text="long task").mark_running() + + cancelled = command.mark_cancelled("user pressed cancel") + skipped = command.mark_skipped("user skipped") + + assert cancelled.status is TuiCommandStatus.CANCELLED + assert cancelled.error == "user pressed cancel" + assert cancelled.finished_at > 0 + assert skipped.status is TuiCommandStatus.SKIPPED + assert skipped.error == "user skipped" + assert skipped.finished_at > 0 + + +def test_placeholder_processor_indents_hint_after_prompt_space() -> None: + processor = _DynamicPlaceholderProcessor( + lambda: "Queue paused · /resume continue", + lambda: [("class:prompt", "❯ ")], + ) + empty_input = SimpleNamespace( + document=SimpleNamespace(text=""), + lineno=0, + fragments=[], + ) + + transformed = processor.apply_transformation(empty_input) + + assert transformed.fragments == [ + ("class:prompt", "❯ "), + ("class:placeholder", " "), + ("class:placeholder", "Queue paused · /resume continue"), + ] + assert sum(text.count("❯") for _style, text in transformed.fragments) == 1 + + +def test_prompt_prefix_is_preserved_with_placeholder_hint() -> None: + app, _console, _status = _make_app() + processor = _DynamicPlaceholderProcessor( + app._placeholder_text, + app._prompt_fragments, + ) + empty_input = SimpleNamespace( + document=SimpleNamespace(text=""), + lineno=0, + fragments=[], + ) + + transformed = processor.apply_transformation(empty_input) + + assert transformed.fragments == [ + ("class:prompt", "❯ "), + ("class:placeholder", " "), + ("class:placeholder", app._placeholder_text()), + ] + assert sum(text.count("❯") for _style, text in transformed.fragments) == 1 + + +def test_placeholder_processor_hides_hint_after_user_input() -> None: + processor = _DynamicPlaceholderProcessor( + lambda: "Ask LeapFlow…", + lambda: [("class:prompt", "❯ ")], + ) + typed_input = SimpleNamespace( + document=SimpleNamespace(text="hello"), + lineno=0, + fragments=[("", "hello")], + ) + + transformed = processor.apply_transformation(typed_input) + + assert transformed.fragments == [("class:prompt", "❯ "), ("", "hello")] + + +def test_control_commands_are_handled_without_queueing() -> None: + handled: list[str] = [] + + def on_control(text: str) -> bool: + handled.append(text) + return text == "/cancel" + + app, _console, status = _make_app(on_control=on_control) + + command = app.submit_text("/cancel") + + assert command.id == 0 + assert command.status is TuiCommandStatus.DONE + assert handled == ["/cancel"] + assert app._pending_input.qsize() == 0 + assert status.counts == [] + + +@pytest.mark.asyncio +async def test_pause_queue_holds_and_resume_runs_pending_command() -> None: + processed: list[str] = [] + + async def on_input(text: str) -> None: + processed.append(text) + + app, console, status = _make_app(on_input=on_input) + app.pause_queue() + app.submit_text("held command") + worker = asyncio.create_task(app._process_loop()) + try: + await asyncio.sleep(0.05) + assert processed == [] + assert app._pending_input.qsize() == 1 + assert app.queue_paused is True + + app.resume_queue() + await _wait_for(lambda: processed == ["held command"]) + finally: + app._should_exit = True + worker.cancel() + with suppress(asyncio.CancelledError): + await worker + + assert [card.status for card in console.cards] == [ + TuiCommandStatus.QUEUED, + TuiCommandStatus.RUNNING, + TuiCommandStatus.DONE, + ] + assert status.counts[-1] == (0, 0) + + +@pytest.mark.asyncio +async def test_cancel_active_command_continues_queued_work() -> None: + started = asyncio.Event() + processed: list[str] = [] + + async def on_input(text: str) -> None: + processed.append(text) + if text == "first command": + started.set() + await asyncio.sleep(10) + + app, console, status = _make_app(on_input=on_input) + app.submit_text("first command") + app.submit_text("second command") + worker = asyncio.create_task(app._process_loop()) + try: + await _wait_for(lambda: started.is_set()) + cancelled = app.request_cancel_active("cancelled in test") + assert cancelled is not None + await _wait_for(lambda: processed == ["first command", "second command"]) + finally: + app._should_exit = True + worker.cancel() + with suppress(asyncio.CancelledError): + await worker + + rendered = [(card.id, card.status) for card in console.cards] + assert rendered == [ + (2, TuiCommandStatus.QUEUED), + (1, TuiCommandStatus.RUNNING), + (1, TuiCommandStatus.CANCELLED), + (2, TuiCommandStatus.RUNNING), + (2, TuiCommandStatus.DONE), + ] + assert console.cards[2].error == "cancelled in test" + assert status.counts[-1] == (0, 0) + + +def test_queue_drop_and_clear_render_skipped_commands() -> None: + app, console, status = _make_app() + app.submit_text("first") + app.submit_text("second") + app.submit_text("third") + + dropped = app.drop_queued_command(2, "not needed") + cleared = app.clear_queued_commands("clear rest") + + assert dropped is not None + assert dropped.id == 2 + assert [command.id for command in cleared] == [1, 3] + assert app._pending_input.qsize() == 0 + assert [card.status for card in console.cards] == [ + TuiCommandStatus.QUEUED, + TuiCommandStatus.QUEUED, + TuiCommandStatus.SKIPPED, + TuiCommandStatus.SKIPPED, + TuiCommandStatus.SKIPPED, + ] + assert status.counts[-1] == (0, 0) + + +def test_task_control_commands_are_registered_for_completion() -> None: + from leapflow.cli.commands.registry import completion_entries, resolve_command + + entries = dict(completion_entries()) + + assert resolve_command("cancel") is not None + assert resolve_command("skip") is not None + assert resolve_command("teach skip") is not None + assert resolve_command("stop") is None + assert entries["cancel"] == "Cancel the currently running task" + assert entries["queue"] == "Show or clear queued tasks" + + +@pytest.mark.asyncio +async def test_teach_skip_command_marks_noise_steps() -> None: + from leapflow.cli.commands.interactive import _handle_teach + + class FakeSession: + def __init__(self) -> None: + self.skipped = 0 + + def mark_skip(self, count: int) -> int: + self.skipped = count + return count + + session = FakeSession() + ctx = SimpleNamespace(session=session) + console = _FakeConsole() + + handled = await _handle_teach(ctx, console, "teach skip 3", learning=False) + legacy_handled = await _handle_teach(ctx, console, "skip 2", learning=True) + + assert handled is True + assert legacy_handled is False + assert session.skipped == 3 + assert console.systems == ["Marked 3 step(s) as noise."] + + class _FakeBuffer: def __init__(self) -> None: self.text = "" diff --git a/tests/test_tui_theme.py b/tests/test_tui_theme.py index c180215..675872e 100644 --- a/tests/test_tui_theme.py +++ b/tests/test_tui_theme.py @@ -45,7 +45,7 @@ def _style_for(theme): "status-bar.bad": f"bg:{theme.toolbar_bg} {theme.error}", "hint": theme.text_dim, "auto-suggest": theme.auto_suggest, - "placeholder": theme.input_placeholder, + "placeholder": f"{theme.input_placeholder} nobold", "selection": f"bg:{theme.input_selection_bg} {theme.input_selection_fg}", }) @@ -62,6 +62,15 @@ def test_dark_background_resolves_readable_input_text() -> None: assert theme.input_text in {"#FFFFFF", "#F8FAFC", "#E5E7EB", "#D1D5DB"} assert contrast_ratio(theme.input_text, theme.input_bg) >= 7.0 assert contrast_ratio(theme.auto_suggest, theme.input_bg) >= 4.5 + assert contrast_ratio(theme.input_placeholder, theme.input_bg) >= 3.0 + + +def test_placeholder_is_visually_subordinate_on_dark_theme() -> None: + theme = resolve_theme(_DARK, terminal_bg="#0B1F24") + + assert theme.input_placeholder == "#64748B" + assert contrast_ratio(theme.input_placeholder, theme.input_bg) >= 3.0 + assert contrast_ratio(theme.input_placeholder, theme.input_bg) < contrast_ratio(theme.input_text, theme.input_bg) def test_light_background_resolves_readable_input_text() -> None: @@ -69,7 +78,7 @@ def test_light_background_resolves_readable_input_text() -> None: assert theme.input_text in {"#111827", "#1A1A1A", "#000000", "#374151"} assert contrast_ratio(theme.input_text, theme.input_bg) >= 7.0 - assert contrast_ratio(theme.input_placeholder, theme.input_bg) >= 4.5 + assert contrast_ratio(theme.input_placeholder, theme.input_bg) >= 3.0 assert contrast_ratio(theme.border, theme.input_bg) >= 4.5 assert contrast_ratio(theme.input_border, theme.input_bg) >= 4.5 assert contrast_ratio(theme.input_focus_border, theme.input_bg) >= 5.0 From c3d2100f61271a38176075e045e5fe53e80d84b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8F=AD=E6=89=AC?= Date: Sun, 12 Jul 2026 03:40:29 +0800 Subject: [PATCH 16/17] feat: harden daemon lifecycle and TUI flows --- src/leapflow/cli/approval_view.py | 62 +++- src/leapflow/cli/cli.py | 6 +- src/leapflow/cli/commands/daemon.py | 63 ++-- src/leapflow/cli/commands/interactive.py | 30 +- src/leapflow/cli/context.py | 24 +- src/leapflow/cli/tui_app/app.py | 129 ++++++- src/leapflow/cli/tui_app/approval_modal.py | 270 +++++++++++++++ src/leapflow/cli/tui_app/console.py | 15 +- src/leapflow/cli/tui_app/stream.py | 18 +- src/leapflow/daemon/client.py | 16 +- src/leapflow/daemon/lifecycle.py | 80 +++++ src/leapflow/engine/engine.py | 296 +++++++++++++--- src/leapflow/gateway/adapters/__init__.py | 15 + src/leapflow/gateway/adapters/api_server.py | 136 ++++++++ src/leapflow/gateway/adapters/common.py | 314 +++++++++++++++++ src/leapflow/gateway/adapters/dingtalk.py | 196 +++++++++++ src/leapflow/gateway/adapters/feishu.py | 176 ++++++++++ src/leapflow/gateway/adapters/telegram.py | 153 +++++++++ src/leapflow/gateway/adapters/webhook.py | 117 +++++++ .../gateway/manifests/api_server.yaml | 2 +- src/leapflow/gateway/manifests/dingtalk.yaml | 37 +- src/leapflow/gateway/manifests/feishu.yaml | 28 +- src/leapflow/gateway/manifests/telegram.yaml | 2 +- src/leapflow/gateway/manifests/webhook.yaml | 2 +- src/leapflow/tools/name_resolver.py | 324 ++++++++++++++++++ src/leapflow/tools/registry_bootstrap.py | 10 +- tests/test_agent_execution.py | 169 ++++++++- tests/test_approval_layer.py | 23 ++ tests/test_cli_entrypoint.py | 24 +- tests/test_daemon_rpc.py | 100 +++++- tests/test_gateway_adapters.py | 234 +++++++++++++ tests/test_gateway_tool_e2e.py | 123 +++++++ tests/test_tui_command_queue.py | 126 ++++++- tests/test_tui_session_summary.py | 2 +- tests/test_tui_theme.py | 41 +++ 35 files changed, 3193 insertions(+), 170 deletions(-) create mode 100644 src/leapflow/cli/tui_app/approval_modal.py create mode 100644 src/leapflow/gateway/adapters/__init__.py create mode 100644 src/leapflow/gateway/adapters/api_server.py create mode 100644 src/leapflow/gateway/adapters/common.py create mode 100644 src/leapflow/gateway/adapters/dingtalk.py create mode 100644 src/leapflow/gateway/adapters/feishu.py create mode 100644 src/leapflow/gateway/adapters/telegram.py create mode 100644 src/leapflow/gateway/adapters/webhook.py create mode 100644 src/leapflow/tools/name_resolver.py create mode 100644 tests/test_gateway_adapters.py create mode 100644 tests/test_gateway_tool_e2e.py diff --git a/src/leapflow/cli/approval_view.py b/src/leapflow/cli/approval_view.py index 2297aed..b380168 100644 --- a/src/leapflow/cli/approval_view.py +++ b/src/leapflow/cli/approval_view.py @@ -43,7 +43,7 @@ async def prompt_approval(request: ApprovalRequest) -> ApprovalDecision: if not sys.stdin.isatty(): return ApprovalDecision.DENY - choices = _build_choices(request) + choices = build_approval_choices(request) show_details = False while True: if _is_expired(request): @@ -52,16 +52,16 @@ async def prompt_approval(request: ApprovalRequest) -> ApprovalDecision: try: answer = await asyncio.wait_for( asyncio.get_running_loop().run_in_executor( - None, lambda: input(" → ").strip().lower(), + None, lambda: input("Select approval choice: ").strip().lower(), ), - timeout=_remaining_seconds(request), + timeout=remaining_seconds(request), ) except TimeoutError: return ApprovalDecision.DENY except (EOFError, KeyboardInterrupt): return ApprovalDecision.DENY - selected = _resolve_choice(answer, choices) + selected = resolve_approval_choice(answer, choices) if selected is None: return ApprovalDecision.DENY if selected.key == "show_details": @@ -70,7 +70,8 @@ async def prompt_approval(request: ApprovalRequest) -> ApprovalDecision: return selected.decision or ApprovalDecision.DENY -def _build_choices(request: ApprovalRequest) -> list[ApprovalChoice]: +def build_approval_choices(request: ApprovalRequest) -> list[ApprovalChoice]: + """Build selectable approval choices for a request.""" keys = list(request.choices or ("allow_once", "allow_session", "deny")) if "deny" not in keys: keys.append("deny") @@ -84,7 +85,8 @@ def _build_choices(request: ApprovalRequest) -> list[ApprovalChoice]: return choices -def _resolve_choice(answer: str, choices: list[ApprovalChoice]) -> ApprovalChoice | None: +def resolve_approval_choice(answer: str, choices: list[ApprovalChoice]) -> ApprovalChoice | None: + """Resolve a typed approval answer to a selectable choice.""" if not answer: return next((choice for choice in choices if choice.key == "deny"), None) aliases = { @@ -113,12 +115,12 @@ def _resolve_choice(answer: str, choices: list[ApprovalChoice]) -> ApprovalChoic def _render(request: ApprovalRequest, choices: list[ApprovalChoice], *, show_details: bool) -> None: - title = str(request.display.get("title") or _title_for(request)) + title = str(request.display.get("title") or title_for_approval(request)) summary = str(request.display.get("summary") or request.category) - reason = str(request.display.get("reason") or _risk_reason(request)) + reason = str(request.display.get("reason") or risk_reason(request)) detail = redact_sensitive_text(request.detail, force=True) if not show_details: - detail = _truncate_detail(detail) + detail = truncate_detail(detail) try: from rich.console import Console @@ -135,7 +137,7 @@ def _render(request: ApprovalRequest, choices: list[ApprovalChoice], *, show_det for line in textwrap.wrap(reason, width=72) or [reason]: body.append(f"- {line}\n", style="dim") body.append("\n") - remaining = _remaining_seconds(request) + remaining = remaining_seconds(request) if remaining is not None: body.append(f"Defaults to Deny in {int(remaining)}s.\n\n", style="dim") for idx, choice in enumerate(choices, start=1): @@ -150,7 +152,7 @@ def _render(request: ApprovalRequest, choices: list[ApprovalChoice], *, show_det sys.stderr.write(f"⚠ {title}\n\n{summary}\n\n{detail}\n\n") if reason: sys.stderr.write(f"Why approval is needed: {reason}\n\n") - remaining = _remaining_seconds(request) + remaining = remaining_seconds(request) if remaining is not None: sys.stderr.write(f"Defaults to Deny in {int(remaining)}s.\n\n") for idx, choice in enumerate(choices, start=1): @@ -158,7 +160,8 @@ def _render(request: ApprovalRequest, choices: list[ApprovalChoice], *, show_det sys.stderr.flush() -def _title_for(request: ApprovalRequest) -> str: +def title_for_approval(request: ApprovalRequest) -> str: + """Return the display title for an approval request.""" if request.risk is not None: if request.risk.level.value == "high": return "High Risk Action" @@ -167,7 +170,8 @@ def _title_for(request: ApprovalRequest) -> str: return "Action Approval" -def _risk_reason(request: ApprovalRequest) -> str: +def risk_reason(request: ApprovalRequest) -> str: + """Return the human-readable risk reason for an approval request.""" if request.risk is None: return "" if request.risk.explanation: @@ -175,18 +179,20 @@ def _risk_reason(request: ApprovalRequest) -> str: return ", ".join(request.risk.reasons) -def _remaining_seconds(request: ApprovalRequest) -> float | None: +def remaining_seconds(request: ApprovalRequest) -> float | None: + """Return seconds before approval expiry, if the request has a deadline.""" if request.expires_at is None: return None return max(0.0, float(request.expires_at) - time.time()) def _is_expired(request: ApprovalRequest) -> bool: - remaining = _remaining_seconds(request) + remaining = remaining_seconds(request) return remaining is not None and remaining <= 0.0 -def _truncate_detail(text: str, *, max_lines: int = 6, width: int = 88) -> str: +def truncate_detail(text: str, *, max_lines: int = 6, width: int = 88) -> str: + """Truncate approval detail for compact rendering.""" wrapped: list[str] = [] for line in text.splitlines() or [text]: wrapped.extend(textwrap.wrap(line, width=width, replace_whitespace=False) or [""]) @@ -195,5 +201,29 @@ def _truncate_detail(text: str, *, max_lines: int = 6, width: int = 88) -> str: return "\n".join(wrapped[: max_lines - 1] + ["… (choose Show full details)"]) +def _build_choices(request: ApprovalRequest) -> list[ApprovalChoice]: + return build_approval_choices(request) + + +def _resolve_choice(answer: str, choices: list[ApprovalChoice]) -> ApprovalChoice | None: + return resolve_approval_choice(answer, choices) + + +def _title_for(request: ApprovalRequest) -> str: + return title_for_approval(request) + + +def _risk_reason(request: ApprovalRequest) -> str: + return risk_reason(request) + + +def _remaining_seconds(request: ApprovalRequest) -> float | None: + return remaining_seconds(request) + + +def _truncate_detail(text: str, *, max_lines: int = 6, width: int = 88) -> str: + return truncate_detail(text, max_lines=max_lines, width=width) + + def _indent(text: str) -> str: return "\n".join(f" {line}" for line in text.splitlines()) diff --git a/src/leapflow/cli/cli.py b/src/leapflow/cli/cli.py index ec33e6a..c3e9865 100644 --- a/src/leapflow/cli/cli.py +++ b/src/leapflow/cli/cli.py @@ -248,8 +248,10 @@ def main(argv: list[str] | None = None) -> int: daemon_sub = daemon_parser.add_subparsers(dest="daemon_action") daemon_sub.add_parser("status", help="Show daemon status") daemon_sub.add_parser("start", help="Start daemon for the active profile") - daemon_sub.add_parser("stop", help="Stop running daemon") - daemon_sub.add_parser("restart", help="Restart daemon for the active profile") + stop_parser = daemon_sub.add_parser("stop", help="Stop running daemon") + stop_parser.add_argument("--force", action="store_true", help="Escalate to SIGKILL if graceful stop times out") + restart_parser = daemon_sub.add_parser("restart", help="Restart daemon for the active profile") + restart_parser.add_argument("--force", action="store_true", help="Force old daemon shutdown before restart") serve_parser = daemon_sub.add_parser("serve", help=argparse.SUPPRESS) serve_parser.add_argument("--internal", action="store_true", help=argparse.SUPPRESS) diff --git a/src/leapflow/cli/commands/daemon.py b/src/leapflow/cli/commands/daemon.py index 587fa83..c47bb59 100644 --- a/src/leapflow/cli/commands/daemon.py +++ b/src/leapflow/cli/commands/daemon.py @@ -2,15 +2,13 @@ ``leap daemon status`` — show whether leapd is running ``leap daemon start`` — start leapd for the active profile -``leap daemon stop`` — send SIGTERM to a running leapd +``leap daemon stop`` — stop running daemon and verify shutdown ``leap daemon restart`` — restart leapd so code/config changes take effect """ from __future__ import annotations import asyncio -import signal import sys -import time from argparse import Namespace from pathlib import Path @@ -29,9 +27,9 @@ def cmd_daemon(args: Namespace) -> int: if action == "start": return _start(settings, getattr(args, "mock_host", False)) if action == "stop": - return _stop(run_dir) + return _stop(run_dir, force=getattr(args, "force", False)) if action == "restart": - return _restart(settings, getattr(args, "mock_host", False)) + return _restart(settings, getattr(args, "mock_host", False), force=getattr(args, "force", False)) if action == "serve": if not getattr(args, "internal", False): sys.stderr.write("'leap daemon serve' is an internal command. Use 'leap daemon start'.\n") @@ -138,8 +136,8 @@ async def _run() -> int: return asyncio.run(_run()) -def _stop(run_dir: Path) -> int: - from leapflow.daemon.lifecycle import DaemonInfo, send_signal, cleanup_stale +def _stop(run_dir: Path, *, force: bool = False, timeout_s: float = 10.0) -> int: + from leapflow.daemon.lifecycle import DaemonInfo, cleanup_stale, stop_daemon info = DaemonInfo.discover(run_dir) if not info.is_running: @@ -150,36 +148,51 @@ def _stop(run_dir: Path) -> int: print("leapd is not running.") return 0 - if send_signal(run_dir, signal.SIGTERM): - print(f"Sent SIGTERM to leapd (pid={info.pid}).") + graceful_requested = False + if info.is_healthy and info.sock_path is not None: + graceful_requested = _request_shutdown(info.sock_path) + print(f"Stopping leapd (pid={info.pid})...") + result = stop_daemon( + run_dir, + timeout_s=timeout_s, + force=force, + grace_timeout_s=2.0 if graceful_requested else 0.0, + ) + if result.stopped: + suffix = " with force" if result.forced else "" + print(f"leapd stopped{suffix}.") return 0 - sys.stderr.write("Failed to stop leapd.\n") + sys.stderr.write( + f"Timed out waiting for leapd to stop (pid={result.pid}). " + "Run 'leap daemon stop --force' or inspect the process manually.\n" + ) return 1 -def _restart(settings: object, mock_host: bool) -> int: +def _request_shutdown(sock_path: Path) -> bool: + from leapflow.daemon.client import DaemonClient, DaemonUnavailableError + + async def _run() -> bool: + try: + await DaemonClient(sock_path, timeout_s=2.0).shutdown() + return True + except DaemonUnavailableError: + return False + + return asyncio.run(_run()) + + +def _restart(settings: object, mock_host: bool, *, force: bool = False) -> int: run_dir = settings.profile_dir / "run" print("Restarting leapd...") - stop_code = _stop(run_dir) + stop_code = _stop(run_dir, force=force, timeout_s=10.0) if stop_code != 0: + sys.stderr.write("Restart aborted because old leapd did not stop cleanly.\n") return stop_code - if not _wait_stopped(run_dir): - sys.stderr.write("Timed out waiting for leapd to stop.\n") - return 1 return _start(settings, mock_host) -def _wait_stopped(run_dir: Path, *, timeout_s: float = 10.0) -> bool: - from leapflow.daemon.lifecycle import DaemonInfo - - deadline = time.time() + timeout_s - while time.time() < deadline: - if not DaemonInfo.discover(run_dir).is_running: - return True - time.sleep(0.1) - return not DaemonInfo.discover(run_dir).is_running - async def _serve(settings: object, mock_host: bool) -> int: from leapflow.daemon.server import serve_daemon diff --git a/src/leapflow/cli/commands/interactive.py b/src/leapflow/cli/commands/interactive.py index c82843a..f9b0813 100644 --- a/src/leapflow/cli/commands/interactive.py +++ b/src/leapflow/cli/commands/interactive.py @@ -10,7 +10,6 @@ import asyncio import logging import os -import signal import sys import time from typing import TYPE_CHECKING, Any, Optional @@ -64,13 +63,29 @@ async def _prompt_stop_daemon_on_exit( console.system("leapd kept running. Use `leap daemon restart` when needed.") return - from leapflow.daemon.lifecycle import send_signal + from leapflow.daemon.lifecycle import stop_daemon run_dir = settings.profile_dir / "run" - if send_signal(run_dir, signal.SIGTERM): - console.system(f"Sent SIGTERM to leapd (pid={pid}).") + console.system(f"Stopping leapd (pid={pid})...") + graceful_requested = False + try: + await asyncio.wait_for(client.shutdown(), timeout=2.0) + graceful_requested = True + except Exception: + graceful_requested = False + result = await asyncio.to_thread( + stop_daemon, + run_dir, + timeout_s=5.0, + grace_timeout_s=1.0 if graceful_requested else 0.0, + ) + if result.stopped: + console.system("leapd stopped.") else: - console.warning("Could not stop leapd; run `leap daemon stop` manually.") + console.warning( + f"leapd did not stop within the exit window (pid={result.pid}). " + "Run `leap daemon stop --force` if it remains unhealthy." + ) async def _ask_yes_no_default_yes(prompt: str) -> bool: @@ -751,6 +766,7 @@ def _handle_task_control(text: str) -> bool: on_input=handle_input, on_control=_handle_task_control, ) + ctx.set_approval_handler(app.request_approval) # Auto-connect previously configured gateway platforms gw = getattr(ctx, "gateway_server", None) @@ -775,6 +791,7 @@ def _handle_task_control(text: str) -> bool: try: exit_code = await app.run() finally: + ctx.set_approval_handler(None) _print_exit_summary() return exit_code @@ -949,7 +966,6 @@ async def _print_daemon_status() -> None: _print_host_status(console, host) async def _handle_daemon_approval(event: StreamEvent) -> None: - from leapflow.cli.approval_view import prompt_approval from leapflow.security.approval import ApprovalDecision, ApprovalRequest metadata = event.metadata or {} @@ -961,7 +977,7 @@ async def _handle_daemon_approval(event: StreamEvent) -> None: return app.spinner_text = "Waiting for approval…" request = ApprovalRequest.from_dict(payload) - decision = await prompt_approval(request) + decision = await app.request_approval(request) value = decision.value if isinstance(decision, ApprovalDecision) else str(decision) await bridge.call( lambda current_client: current_client.approval_resolve(pending_id, value), diff --git a/src/leapflow/cli/context.py b/src/leapflow/cli/context.py index 1564452..1ac92e3 100644 --- a/src/leapflow/cli/context.py +++ b/src/leapflow/cli/context.py @@ -74,16 +74,14 @@ class _TUIApprovalGate: - """Rich-styled approval gate for the interactive TUI. + """Approval gate that delegates to the active TUI surface when available.""" - Displays a styled panel with the action details and accepts: - - ``y``/``yes`` → allow this one time - - ``a``/``always`` → allow and skip future prompts for this category - - ``n``/``no``/Enter → deny + def __init__(self) -> None: + self._handler: Optional[Callable[["ApprovalRequest"], Any]] = None - Implements both ``ApprovalGate`` (unified) and ``CommandApprovalGate`` - (backward-compatible with ``shell_tools.py``). - """ + def set_handler(self, handler: Optional[Callable[["ApprovalRequest"], Any]]) -> None: + """Set the active TUI approval handler.""" + self._handler = handler _CATEGORY_LABELS = { "shell_dangerous": ("Shell Command", "yellow"), @@ -94,6 +92,12 @@ class _TUIApprovalGate: async def request_approval( self, request: "ApprovalRequest", ) -> "ApprovalDecision": + handler = self._handler + if handler is not None: + result = handler(request) + if hasattr(result, "__await__"): + return await result + return result from leapflow.cli.approval_view import prompt_approval return await prompt_approval(request) @@ -475,6 +479,10 @@ def __init__(self, settings: Settings, mock_host: bool) -> None: audit=ApprovalAuditLog(approval_dir / "audit.jsonl"), ) + def set_approval_handler(self, handler: Optional[Callable[["ApprovalRequest"], Any]]) -> None: + """Bind the current interactive surface as the approval renderer.""" + self._tui_approval.set_handler(handler) + def _configure_llm_clients(self, settings: Settings) -> None: """Build LLM/VLM clients from a settings snapshot.""" provider_configs = parse_provider_configs( diff --git a/src/leapflow/cli/tui_app/app.py b/src/leapflow/cli/tui_app/app.py index 95ddeae..84cae33 100644 --- a/src/leapflow/cli/tui_app/app.py +++ b/src/leapflow/cli/tui_app/app.py @@ -38,12 +38,13 @@ from prompt_toolkit import Application from prompt_toolkit.auto_suggest import AutoSuggestFromHistory +from prompt_toolkit.filters import Condition from prompt_toolkit.formatted_text.utils import fragment_list_len from prompt_toolkit.history import FileHistory from prompt_toolkit.key_binding import KeyBindings from prompt_toolkit.keys import Keys from prompt_toolkit.layout import HSplit, Layout, Window -from prompt_toolkit.layout.containers import Float, FloatContainer +from prompt_toolkit.layout.containers import ConditionalContainer, Float, FloatContainer from prompt_toolkit.layout.controls import FormattedTextControl from prompt_toolkit.layout.dimension import Dimension from prompt_toolkit.layout.menus import CompletionsMenu @@ -52,6 +53,7 @@ from prompt_toolkit.styles import Style as PTStyle from prompt_toolkit.widgets import TextArea +from leapflow.cli.tui_app.approval_modal import ApprovalModal, request_is_expired from leapflow.cli.tui_app.command import TuiCommand, TuiCommandStatus from leapflow.cli.tui_app.input import build_completer from leapflow.cli.tui_app.paste import ( @@ -61,6 +63,7 @@ ) from leapflow.cli.tui_app.status import StatusBar from leapflow.cli.tui_app.theme import Theme +from leapflow.security.approval import ApprovalDecision, ApprovalRequest if TYPE_CHECKING: from leapflow.cli.tui_app.console import LeapConsole @@ -68,7 +71,7 @@ _HISTORY_FILENAME = "history" _REFRESH_INTERVAL_S = 0.5 _PASTE_COMPACTOR_ATTR = "_leapflow_paste_compactor_installed" -_PLACEHOLDER_CURSOR_SPACER = " " +_PLACEHOLDER_CURSOR_SPACER = " " InputHandler = Callable[[str], Union[Awaitable[None], None]] ControlHandler = Callable[[str], bool] @@ -105,19 +108,23 @@ def apply_transformation(self, transformation_input: Any) -> Transformation: if has_text: prefix = prompt_fragments fragments = [*prefix, *transformation_input.fragments] - else: - placeholder = self._provider() - placeholder_fragments = [ - ("class:placeholder", _PLACEHOLDER_CURSOR_SPACER), - ("class:placeholder", placeholder), - ] if placeholder else [] - prefix = [*prompt_fragments, *placeholder_fragments] - fragments = [*prefix, *transformation_input.fragments] - shift_position = fragment_list_len(prefix) + shift_position = fragment_list_len(prefix) + return Transformation( + fragments, + source_to_display=lambda index: index + shift_position, + display_to_source=lambda index: index - shift_position, + ) + placeholder = self._provider() + placeholder_fragments = [ + ("class:placeholder", _PLACEHOLDER_CURSOR_SPACER), + ("class:placeholder", placeholder), + ] if placeholder else [] + fragments = [*prompt_fragments, *placeholder_fragments, *transformation_input.fragments] + shift_position = fragment_list_len(prompt_fragments) return Transformation( fragments, source_to_display=lambda index: index + shift_position, - display_to_source=lambda index: index - shift_position, + display_to_source=lambda _index: 0, ) @@ -204,6 +211,7 @@ def __init__( self._active_fragment_marker: Optional[str] = None self._active_command: Optional[TuiCommand] = None self._pending_input = _CommandQueue() + self._approval_modal: Optional[ApprovalModal] = None data_dir = data_dir or Path( os.environ.get("LEAPFLOW_DATA_DIR", "~/.leapflow") @@ -244,6 +252,30 @@ def spinner_text(self, value: str) -> None: self._tool_start_time = time.monotonic() if value else 0.0 self._invalidate() + async def request_approval(self, request: ApprovalRequest) -> ApprovalDecision: + """Show a native TUI approval modal and return the selected decision.""" + if request_is_expired(request): + return ApprovalDecision.DENY + if self._approval_modal is not None: + return ApprovalDecision.DENY + from leapflow.cli.approval_view import remaining_seconds + + modal = ApprovalModal.create(request) + self._approval_modal = modal + self._input_area.buffer.reset() + self._clear_paste_state() + self._invalidate() + try: + timeout = remaining_seconds(request) + return await asyncio.wait_for(asyncio.shield(modal.future), timeout=timeout) + except asyncio.TimeoutError: + modal.deny() + return ApprovalDecision.DENY + finally: + if self._approval_modal is modal: + self._approval_modal = None + self._invalidate() + def submit_text(self, text: str) -> TuiCommand: """Submit user text into the serial TUI command queue.""" normalized = self._resolve_paste_blocks(text).strip() @@ -700,13 +732,27 @@ def _build_application(self) -> Application[Any]: height=self._spinner_height, wrap_lines=True, ) + status_gap = Window( + content=FormattedTextControl(lambda: []), + height=1, + style="class:status-gap", + ) status_bar = Window( content=FormattedTextControl(self._status), height=1, wrap_lines=False, style="class:status-bar", ) - root = HSplit([spinner, status_bar, self._input_area]) + root = HSplit([spinner, status_gap, status_bar, self._input_area]) + approval_overlay = ConditionalContainer( + Window( + content=FormattedTextControl(self._approval_fragments), + height=Dimension(min=10, max=24, preferred=24), + wrap_lines=False, + style="class:approval.modal", + ), + filter=Condition(lambda: self._approval_modal is not None), + ) layout = Layout( FloatContainer( content=root, @@ -719,7 +765,13 @@ def _build_application(self) -> Application[Any]: scroll_offset=1, display_arrows=True, ), - ) + ), + Float( + top=1, + left=2, + right=2, + content=approval_overlay, + ), ], ) ) @@ -748,9 +800,26 @@ def _build_application(self) -> Application[Any]: def _build_keybindings(self) -> KeyBindings: kb = KeyBindings() ref = self + approval_filter = Condition(lambda: ref._approval_modal is not None) + + def choose_approval_text(text: str) -> None: + modal = ref._approval_modal + if modal is None: + return + if modal.choose_text(text): + ref._invalidate() + + for key in ("1", "2", "3", "4", "5", "6", "7", "8", "9", "y", "o", "s", "a", "n", "d", "v"): + @kb.add(key, filter=approval_filter) + def _(event, key=key): + choose_approval_text(key) @kb.add(Keys.Enter) def _(event): + if ref._approval_modal is not None: + ref._approval_modal.choose_selected() + ref._invalidate() + return buffer = event.app.current_buffer complete_state = getattr(buffer, "complete_state", None) current_completion = getattr(complete_state, "current_completion", None) @@ -773,6 +842,10 @@ def _(event): @kb.add(Keys.Escape) def _(event): + if ref._approval_modal is not None: + ref._approval_modal.deny() + ref._invalidate() + return if ref._close_completion(event.current_buffer): return event.current_buffer.reset() @@ -788,10 +861,18 @@ def _(event): @kb.add(Keys.Up) def _(event): + if ref._approval_modal is not None: + ref._approval_modal.move(-1) + ref._invalidate() + return ref._completion_previous_or_cursor_up(event.current_buffer) @kb.add(Keys.Down) def _(event): + if ref._approval_modal is not None: + ref._approval_modal.move(1) + ref._invalidate() + return ref._completion_next_or_cursor_down(event.current_buffer) @kb.add(Keys.Right) @@ -809,6 +890,10 @@ def _(event): @kb.add(Keys.ControlC) def _(event): + if ref._approval_modal is not None: + ref._approval_modal.deny() + ref._invalidate() + return if event.current_buffer.text: event.current_buffer.reset() ref._clear_paste_state() @@ -841,6 +926,7 @@ def _build_style(self) -> PTStyle: "prompt.recording": t.recording, "prompt.paused": t.prompt_paused, "prompt.executing": t.executing, + "status-gap": "", "status-bar": f"bg:{t.toolbar_bg} {t.statusbar_fg}", "status-bar.strong": f"bg:{t.toolbar_bg} bold {t.statusbar_accent}", "status-bar.dim": f"bg:{t.toolbar_bg} {t.statusbar_dim}", @@ -856,6 +942,15 @@ def _build_style(self) -> PTStyle: "completion-menu.completion.current": f"bg:{t.input_selection_bg} bold {t.input_selection_fg}", "completion-menu.meta.completion": f"bg:{t.toolbar_bg} {t.text_muted}", "completion-menu.meta.completion.current": f"bg:{t.input_selection_bg} {t.input_selection_fg}", + "approval.modal": f"bg:{t.input_bg} {t.text}", + "approval.border": t.warning, + "approval.title": f"bold {t.warning}", + "approval.summary": f"bold {t.text}", + "approval.label": t.text_dim, + "approval.detail": t.warning, + "approval.dim": t.text_muted, + "approval.option": t.text, + "approval.selected": f"bg:{t.input_selection_bg} bold {t.input_selection_fg}", }) # ── Fragment providers ─────────────────────────────────────────── @@ -896,6 +991,12 @@ def _placeholder_text(self) -> str: return f"{self._pending_input.qsize()} queued · /pause hold · /queue view · /drop remove" return "Ask LeapFlow… /help commands · /status runtime · /queue tasks" + def _approval_fragments(self) -> list[tuple[str, str]]: + modal = self._approval_modal + if modal is None: + return [] + return modal.fragments() + def _spinner_fragments(self) -> list[tuple[str, str]]: if not self._spinner_text: return [] diff --git a/src/leapflow/cli/tui_app/approval_modal.py b/src/leapflow/cli/tui_app/approval_modal.py new file mode 100644 index 0000000..ac977c5 --- /dev/null +++ b/src/leapflow/cli/tui_app/approval_modal.py @@ -0,0 +1,270 @@ +"""Prompt-toolkit native approval modal for LeapFlow TUI.""" +from __future__ import annotations + +import asyncio +import shutil +import textwrap +from dataclasses import dataclass + +from leapflow.cli.approval_view import ( + ApprovalChoice, + build_approval_choices, + remaining_seconds, + resolve_approval_choice, + risk_reason, + title_for_approval, + truncate_detail, +) +from leapflow.security.approval import ApprovalDecision, ApprovalRequest +from leapflow.security.redact import redact_sensitive_text + +Fragment = tuple[str, str] + +_MIN_WIDTH = 58 +_MAX_WIDTH = 104 +_MIN_HEIGHT = 10 +_MAX_HEIGHT = 24 +_MAX_DETAIL_LINES = 3 +_MAX_REASON_LINES = 2 + + +@dataclass +class ApprovalModal: + """Stateful approval modal rendered inside the prompt-toolkit layout.""" + + request: ApprovalRequest + choices: list[ApprovalChoice] + selected_index: int + show_details: bool + future: asyncio.Future[ApprovalDecision] + + @classmethod + def create(cls, request: ApprovalRequest) -> "ApprovalModal": + choices = build_approval_choices(request) + selected_index = _default_index(choices, request.default_choice) + future = asyncio.get_running_loop().create_future() + return cls( + request=request, + choices=choices, + selected_index=selected_index, + show_details=False, + future=future, + ) + + @property + def done(self) -> bool: + """Return whether the user has already made a decision.""" + return self.future.done() + + def move(self, delta: int) -> None: + """Move the selected option by ``delta`` rows.""" + if not self.choices: + return + self.selected_index = (self.selected_index + delta) % len(self.choices) + + def choose_selected(self) -> None: + """Accept the currently selected option.""" + if not self.choices: + self.resolve(ApprovalDecision.DENY) + return + self._choose(self.choices[self.selected_index]) + + def choose_text(self, text: str) -> bool: + """Resolve a typed shortcut or numeric choice.""" + choice = resolve_approval_choice(text.strip().lower(), self.choices) + if choice is None: + return False + self._choose(choice) + return True + + def deny(self) -> None: + """Fail closed when the user cancels or the request expires.""" + self.resolve(ApprovalDecision.DENY) + + def resolve(self, decision: ApprovalDecision) -> None: + """Resolve the modal future once.""" + if not self.future.done(): + self.future.set_result(decision) + + def fragments(self, *, max_height: int | None = None) -> list[Fragment]: + """Render the modal as a complete bordered component.""" + width = _modal_width() + height = _modal_height(max_height) + inner_width = width - 4 + title = f"⚠ {title_for_approval(self.request)}" + lines: list[list[Fragment]] = [] + lines.append(_border_top(width, title)) + + summary = str(self.request.display.get("summary") or self.request.category) + lines.extend(_limited_content_lines( + _wrap(summary, inner_width), + inner_width, + "class:approval.summary", + limit=1, + )) + + reason = str(self.request.display.get("reason") or risk_reason(self.request)) + remaining = remaining_seconds(self.request) + fixed_count = ( + 1 # top border + + 1 # summary + + 1 # action detail label + + (1 if reason else 0) # reason label + + (1 if remaining is not None else 0) + + 1 # keyboard hint + + len(self.choices) + + 1 # bottom border + ) + content_budget = max(2, height - fixed_count) + detail_budget = min(_MAX_DETAIL_LINES, max(1, content_budget // 2)) + reason_budget = min(_MAX_REASON_LINES, max(0, content_budget - detail_budget)) + + lines.append(_content_line("Action detail:", inner_width, "class:approval.label")) + detail = redact_sensitive_text(self.request.detail, force=True) + if not self.show_details: + detail = truncate_detail(detail, max_lines=detail_budget, width=inner_width - 2) + detail_lines: list[str] = [] + for raw_line in detail.splitlines() or [""]: + detail_lines.extend(_wrap(raw_line, inner_width - 2)) + lines.extend(_limited_content_lines( + [f" {line}" for line in detail_lines], + inner_width, + "class:approval.detail", + limit=detail_budget, + )) + + if reason: + lines.append(_content_line("Why approval is needed:", inner_width, "class:approval.label")) + reason_lines = [f"- {line}" for line in _wrap(reason, inner_width - 2)] + lines.extend(_limited_content_lines( + reason_lines, + inner_width, + "class:approval.dim", + limit=reason_budget, + )) + + if remaining is not None: + lines.append(_content_line( + f"Defaults to Deny in {int(remaining)}s.", + inner_width, + "class:approval.dim", + )) + + lines.append(_content_line("↑/↓ choose · Enter confirm · Esc deny", inner_width, "class:approval.dim")) + for idx, choice in enumerate(self.choices, start=1): + selected = idx - 1 == self.selected_index + marker = "▶" if selected else " " + text = f" {marker} {idx}. {choice.label}" + style = "class:approval.selected" if selected else "class:approval.option" + lines.append(_content_line(text, inner_width, style)) + + lines.append(_border_bottom(width)) + if len(lines) > height: + lines = _preserve_frame_and_choices(lines, height, len(self.choices)) + fragments: list[Fragment] = [] + for line in lines: + fragments.extend(line) + fragments.append(("", "\n")) + return fragments + + def _choose(self, choice: ApprovalChoice) -> None: + if choice.key == "show_details": + self.show_details = True + return + self.resolve(choice.decision or ApprovalDecision.DENY) + + +def _default_index(choices: list[ApprovalChoice], default_choice: str) -> int: + for index, choice in enumerate(choices): + if choice.key == default_choice: + return index + return 0 + + +def _modal_width() -> int: + columns = shutil.get_terminal_size((100, 24)).columns + return min(_MAX_WIDTH, max(_MIN_WIDTH, columns - 6)) + + +def _modal_height(max_height: int | None = None) -> int: + if max_height is not None: + return max(_MIN_HEIGHT, max_height) + rows = shutil.get_terminal_size((100, 24)).lines + return min(_MAX_HEIGHT, max(_MIN_HEIGHT, rows - 5)) + + +def _wrap(text: str, width: int) -> list[str]: + return textwrap.wrap( + text, + width=max(20, width), + replace_whitespace=False, + drop_whitespace=False, + ) or [""] + + +def _limited_content_lines( + lines: list[str], + width: int, + style: str, + *, + limit: int, +) -> list[list[Fragment]]: + if limit <= 0: + return [] + visible = list(lines[:limit]) or [""] + if len(lines) > limit: + visible[-1] = _with_ellipsis(visible[-1], width) + return [_content_line(line, width, style) for line in visible] + + +def _with_ellipsis(text: str, width: int) -> str: + if width <= 1: + return "…" + if len(text) >= width: + return text[: width - 1] + "…" + return f"{text} …" + + +def _preserve_frame_and_choices( + lines: list[list[Fragment]], + height: int, + choice_count: int, +) -> list[list[Fragment]]: + if len(lines) <= height: + return lines + tail_count = choice_count + 1 + head_budget = max(1, height - tail_count) + return [*lines[:head_budget], *lines[-tail_count:]] + + +def _border_top(width: int, title: str) -> list[Fragment]: + label = f" {title} " + available = max(0, width - 2 - len(label)) + left = available // 2 + right = available - left + return [ + ("class:approval.border", "╭" + "─" * left), + ("class:approval.title", label[: max(0, width - 2)]), + ("class:approval.border", "─" * right + "╮"), + ] + + +def _border_bottom(width: int) -> list[Fragment]: + return [("class:approval.border", "╰" + "─" * (width - 2) + "╯")] + + +def _content_line(text: str, width: int, style: str = "") -> list[Fragment]: + clipped = text[:width] + padding = " " * max(0, width - len(clipped)) + return [ + ("class:approval.border", "│ "), + (style, clipped), + ("", padding), + ("class:approval.border", " │"), + ] + + +def request_is_expired(request: ApprovalRequest) -> bool: + """Return True when an approval request is past its deadline.""" + remaining = remaining_seconds(request) + return remaining is not None and remaining <= 0.0 diff --git a/src/leapflow/cli/tui_app/console.py b/src/leapflow/cli/tui_app/console.py index d899386..110167f 100644 --- a/src/leapflow/cli/tui_app/console.py +++ b/src/leapflow/cli/tui_app/console.py @@ -204,9 +204,20 @@ def code(self, source: str, language: str = "python", *, title: str = "") -> Non else: self._console.print(syntax) - def system(self, message: str, *, style: str = "leap.dim") -> None: - """Print a system/info message in muted style.""" + def system( + self, + message: str, + *, + style: str = "leap.dim", + margin_top: int = 0, + margin_bottom: int = 0, + ) -> None: + """Print a system/info message in muted style with optional spacing.""" + for _ in range(max(0, margin_top)): + self._console.print() self._console.print(f" {message}", style=style) + for _ in range(max(0, margin_bottom)): + self._console.print() def success(self, message: str) -> None: self._console.print(f" ✓ {message}", style="leap.success") diff --git a/src/leapflow/cli/tui_app/stream.py b/src/leapflow/cli/tui_app/stream.py index 17d3446..82ad984 100644 --- a/src/leapflow/cli/tui_app/stream.py +++ b/src/leapflow/cli/tui_app/stream.py @@ -22,6 +22,7 @@ _FINAL_RESPONSE_INDENT_SPACES = 4 _FINAL_RESPONSE_MARGIN_TOP = 1 +_FINAL_RESPONSE_MARGIN_BOTTOM = 1 _TOOL_INPUT_LIMIT = 96 _TOOL_OUTPUT_LIMIT = 96 _TOOL_PATH_LIMIT = 72 @@ -327,10 +328,12 @@ def feed_thinking(self, chunk: str) -> None: def tool_started(self, name: str, metadata: dict[str, Any] | None = None) -> str: """Mark a tool call as started. Returns spinner text for LeapApp.""" - self._active_tool = name + metadata = metadata or {} + tool_name = _metadata_text(metadata, "normalized_tool_name") or name + self._active_tool = tool_name self._active_tool_detail = _tool_action_detail(metadata) self._tool_start_time = time.monotonic() - return f"{_tool_icon(name)} {name}" + return f"{_tool_icon(tool_name)} {tool_name}" def tool_finished( self, @@ -339,11 +342,14 @@ def tool_finished( metadata: dict[str, Any] | None = None, ) -> None: """Mark a tool call as finished; print one compact audit line.""" - tool_name = name or self._active_tool + metadata = metadata or {} + tool_name = _metadata_text(metadata, "normalized_tool_name") or name or self._active_tool + original_tool_name = _metadata_text(metadata, "original_tool_name") + alias_detail = original_tool_name if original_tool_name and original_tool_name != tool_name else "" if tool_name and self._tool_start_time > 0: duration = time.monotonic() - self._tool_start_time self._tool_history.append((tool_name, duration)) - ok = metadata.get("ok", True) if metadata else True + ok = metadata.get("ok", True) action_detail = self._active_tool_detail or _tool_action_detail(metadata) result_detail = _tool_result_detail(metadata) or _truncate_detail(output, limit=_TOOL_OUTPUT_LIMIT) line = Text() @@ -354,6 +360,9 @@ def tool_finished( if action_detail: line.append(" ", style="leap.tool") line.append(action_detail, style="leap.tool") + if alias_detail: + line.append(" ", style="leap.tool") + line.append(f"alias={alias_detail}", style="leap.tool") if result_detail: line.append(" → ", style="leap.tool" if ok else "leap.error") line.append(result_detail, style="leap.tool" if ok else "leap.error") @@ -377,6 +386,7 @@ def finish(self) -> None: answer, indent=_FINAL_RESPONSE_INDENT_SPACES, margin_top=_FINAL_RESPONSE_MARGIN_TOP, + margin_bottom=_FINAL_RESPONSE_MARGIN_BOTTOM, ) self._console.response_label(self.elapsed, tool_count=self.tool_count) diff --git a/src/leapflow/daemon/client.py b/src/leapflow/daemon/client.py index afcc6eb..990f17a 100644 --- a/src/leapflow/daemon/client.py +++ b/src/leapflow/daemon/client.py @@ -13,8 +13,8 @@ DaemonInfo, DaemonLock, cleanup_stale, - send_signal, spawn_daemon, + stop_daemon, wait_ready, ) from leapflow.daemon.protocol import RpcRequest @@ -256,11 +256,9 @@ async def recover_daemon_client( if not info.is_running: raise _emit(status_callback, f"Restarting unhealthy leapd (pid={info.pid})...") - if not send_signal(run_dir): + result = await asyncio.to_thread(stop_daemon, run_dir, timeout_s=10.0) + if not result.stopped: raise exc - if not await _wait_stopped(run_dir): - raise exc - cleanup_stale(run_dir) return await ensure_daemon_client( settings, mock_host=mock_host, @@ -268,14 +266,6 @@ async def recover_daemon_client( ) -async def _wait_stopped(run_dir: Path, *, timeout_s: float = 10.0) -> bool: - deadline = asyncio.get_running_loop().time() + timeout_s - while asyncio.get_running_loop().time() < deadline: - if not DaemonInfo.discover(run_dir).is_running: - return True - await asyncio.sleep(0.1) - return not DaemonInfo.discover(run_dir).is_running - def _event_from_params(params: dict[str, Any]) -> StreamEvent: event_type = str(params.get("event_type") or "chunk") diff --git a/src/leapflow/daemon/lifecycle.py b/src/leapflow/daemon/lifecycle.py index 8100e9a..4f7be74 100644 --- a/src/leapflow/daemon/lifecycle.py +++ b/src/leapflow/daemon/lifecycle.py @@ -81,6 +81,19 @@ def format_status(self) -> str: return "leapd not running" +@dataclass(frozen=True) +class StopDaemonResult: + """Outcome of a bounded daemon stop transaction.""" + + pid: Optional[int] + stopped: bool + signal_sent: bool = False + forced: bool = False + stale_cleaned: bool = False + timed_out: bool = False + error: str = "" + + class DaemonLock: """Advisory file lock for daemon leader election. @@ -179,6 +192,65 @@ def send_signal(run_dir: Path, sig: int = signal.SIGTERM) -> bool: return False +def stop_daemon( + run_dir: Path, + *, + timeout_s: float = 10.0, + force: bool = False, + grace_timeout_s: float = 0.0, + poll_interval_s: float = 0.1, + force_timeout_s: float = 2.0, +) -> StopDaemonResult: + """Stop leapd as a bounded transaction and verify final state.""" + info = DaemonInfo.discover(run_dir) + pid = info.pid + if not info.is_running: + stale_cleaned = cleanup_stale(run_dir) if pid is not None else False + return StopDaemonResult(pid=pid, stopped=True, stale_cleaned=stale_cleaned) + + deadline = time.time() + max(0.1, timeout_s) + interval = max(0.01, poll_interval_s) + if grace_timeout_s > 0: + grace_deadline = min(deadline, time.time() + grace_timeout_s) + if _wait_until_stopped(run_dir, deadline=grace_deadline, interval_s=interval): + stale_cleaned = cleanup_stale(run_dir) + return StopDaemonResult(pid=pid, stopped=True, stale_cleaned=stale_cleaned) + + signal_sent = send_signal(run_dir, signal.SIGTERM) + if not signal_sent and not DaemonInfo.discover(run_dir).is_running: + stale_cleaned = cleanup_stale(run_dir) + return StopDaemonResult(pid=pid, stopped=True, stale_cleaned=stale_cleaned) + if not signal_sent: + return StopDaemonResult(pid=pid, stopped=False, error="failed to send SIGTERM") + + if _wait_until_stopped(run_dir, deadline=deadline, interval_s=interval): + stale_cleaned = cleanup_stale(run_dir) + return StopDaemonResult(pid=pid, stopped=True, signal_sent=True, stale_cleaned=stale_cleaned) + + forced = False + if force: + forced = send_signal(run_dir, signal.SIGKILL) + kill_deadline = time.time() + max(0.1, force_timeout_s) + if forced and _wait_until_stopped(run_dir, deadline=kill_deadline, interval_s=interval): + stale_cleaned = cleanup_stale(run_dir) + return StopDaemonResult( + pid=pid, + stopped=True, + signal_sent=True, + forced=True, + stale_cleaned=stale_cleaned, + ) + + return StopDaemonResult( + pid=pid, + stopped=False, + signal_sent=True, + forced=forced, + timed_out=True, + error="timed out waiting for leapd to stop", + ) + + def spawn_daemon(settings: object, *, mock_host: bool = False) -> subprocess.Popen[bytes]: """Spawn a detached leapd process for the active environment.""" run_dir = getattr(settings, "profile_dir") / "run" @@ -216,6 +288,14 @@ def wait_ready(run_dir: Path, *, timeout_s: float = 30.0, interval_s: float = 0. return last +def _wait_until_stopped(run_dir: Path, *, deadline: float, interval_s: float) -> bool: + while time.time() < deadline: + if not DaemonInfo.discover(run_dir).is_running: + return True + time.sleep(interval_s) + return not DaemonInfo.discover(run_dir).is_running + + # ── Internal helpers ── def _read_pid(path: Path) -> Optional[int]: diff --git a/src/leapflow/engine/engine.py b/src/leapflow/engine/engine.py index 0e549f2..b50ff2d 100644 --- a/src/leapflow/engine/engine.py +++ b/src/leapflow/engine/engine.py @@ -70,6 +70,7 @@ from leapflow.skills.builtin import app_launcher, clipboard_manager, file_organizer from leapflow.storage.skill_library import SkillLibraryStore from leapflow.skills.registry import Skill, SkillRegistry +from leapflow.tools.name_resolver import ToolRegistry, ToolResolution logger = logging.getLogger(__name__) @@ -78,6 +79,37 @@ _TASK_CONTRACT_HEADING = "## Task Contract" +def _default_tool_registry() -> ToolRegistry: + """Return the canonical runtime tool registry.""" + from leapflow.tools.registry_bootstrap import TOOL_REGISTRY + + return TOOL_REGISTRY + + +def _resolve_tool_name(tool_name: str, arguments: Dict[str, Any] | None = None) -> ToolResolution: + """Resolve a tool name through the runtime registry.""" + return _default_tool_registry().resolve(tool_name, arguments or {}) + + +def _normalize_tool_name(tool_name: str) -> str: + """Return the canonical executable tool name when resolution is safe.""" + return _default_tool_registry().normalize_name(tool_name) + + +def _normalize_tool_call(tool_call: Dict[str, Any]) -> Dict[str, Any]: + """Return a resolved tool call while preserving the original tool name.""" + original_name = str(tool_call.get("name", "")) + arguments = tool_call.get("arguments") or {} + resolution = _resolve_tool_name(original_name, arguments) + if not resolution.auto_executable or resolution.normalized_name is None: + return {**tool_call, **resolution.to_metadata()} + return { + **tool_call, + "name": resolution.normalized_name, + **resolution.to_metadata(), + } + + def _single_line_preview(value: Any, *, limit: int) -> str: """Return a compact single-line preview for UI metadata.""" if value is None: @@ -89,13 +121,27 @@ def _single_line_preview(value: Any, *, limit: int) -> str: return compact[: limit - 1] + "…" -def _tool_args_metadata(tool_name: str, arguments: Dict[str, Any] | None) -> Dict[str, Any]: +def _tool_args_metadata( + tool_name: str, + arguments: Dict[str, Any] | None, + *, + original_tool_name: str | None = None, +) -> Dict[str, Any]: """Build safe, compact tool-start metadata for streaming UIs.""" args = dict(arguments or {}) + original_name = original_tool_name or tool_name metadata: Dict[str, Any] = { "tool_name": tool_name, + "original_tool_name": original_name, + "normalized_tool_name": tool_name, "args_summary": _single_line_preview(args, limit=_TOOL_ARGS_PREVIEW_LIMIT), } + resolution = _resolve_tool_name(original_name, args) + metadata.update(resolution.to_metadata()) + metadata["tool_name"] = tool_name + metadata["normalized_tool_name"] = tool_name + if original_name != tool_name: + metadata["alias"] = original_name for key in ("command", "cmd", "path", "pattern", "query", "url"): value = args.get(key) if value: @@ -107,15 +153,24 @@ def _tool_result_metadata( tool_name: str, arguments: Dict[str, Any] | None, result: Any, + *, + original_tool_name: str | None = None, ) -> Dict[str, Any]: """Build safe, compact tool-completion metadata for streaming UIs.""" - metadata = _tool_args_metadata(tool_name, arguments) + metadata = _tool_args_metadata(tool_name, arguments, original_tool_name=original_tool_name) metadata["ok"] = True if isinstance(result, dict): metadata["ok"] = bool(result.get("ok", True)) for key in ("exit_code", "path", "lines", "truncated", "bytes_written"): if key in result: metadata[key] = result[key] + for key in ("error_type", "retryable", "resolution_status", "resolution_confidence"): + if key in result: + metadata[key] = result[key] + for key in ("suggestions", "available_tools"): + value = result.get(key) + if value: + metadata[key] = value for key in ("stdout", "stderr", "content", "output", "error"): value = result.get(key) if value: @@ -136,6 +191,30 @@ def _tool_result_metadata( return metadata +def _is_retryable_unknown_tool_result(result: Any) -> bool: + """Return whether a tool result can drive a one-shot name correction retry.""" + return isinstance(result, dict) and result.get("error_type") == "unknown_tool" and bool( + result.get("retryable", False) + ) + + +def _unknown_tool_retry_prompt(result: Dict[str, Any]) -> str: + """Build a compact structured correction prompt for a bad tool name.""" + suggestions = result.get("suggestions") or [] + available = result.get("available_tools") or [] + suggestions_text = ", ".join(str(item) for item in suggestions[:5]) or "none" + available_text = ", ".join(str(item) for item in available[:12]) + return ( + "SYSTEM: The previous tool call used an unavailable tool name. " + f"Original tool: {result.get('original_tool_name', '')}. " + f"Resolution: {result.get('resolution_status', 'unknown')} " + f"({result.get('resolution_reason', 'no match')}). " + f"Suggested canonical tools: {suggestions_text}. " + f"Available tools include: {available_text}. " + "Retry once using an exact canonical tool name and valid arguments, or answer without a tool if no tool fits." + ) + + def _estimate_text_tokens(text: str) -> int: """Approximate token count for status display when provider usage is absent.""" if not text: @@ -1658,6 +1737,7 @@ async def _unified_tool_loop( recovery = TurnRecoveryState() use_native_tools = assembly.plan.native_tools result_budget = self._effective_tool_result_budget() + unknown_tool_retry_used = False self._usage_tracker.reset() tools_kwarg: Dict[str, Any] = self._planned_tools_kwarg(assembly.plan) @@ -1750,10 +1830,23 @@ async def _unified_tool_loop( messages.append(assistant_msg) self._persist_message(session_id, "assistant", content, tool_calls=assistant_msg.get("tool_calls")) - await self._execute_tools_concurrent( + results = await self._execute_tools_concurrent( native_calls, TOOL_HANDLERS, trace=trace, messages=messages, ) + retryable_unknown = next( + ( + item.get("result") + for item in results + if _is_retryable_unknown_tool_result(item.get("result")) + ), + None, + ) + if retryable_unknown and not unknown_tool_retry_used: + unknown_tool_retry_used = True + messages.append(build_user_message_text(_unknown_tool_retry_prompt(retryable_unknown))) + continue + failures = self._count_consecutive_tool_failures(messages) recovery.consecutive_tool_failures = failures if failures >= self._settings.max_consecutive_tool_failures: @@ -1783,31 +1876,40 @@ async def _unified_tool_loop( break messages.append(build_assistant_message(content)) - _show_progress("executing", tool_call['name']) - result = await self._execute_general_tool(tool_call, TOOL_HANDLERS) + normalized_tool_call = _normalize_tool_call(tool_call) + tool_name = str(normalized_tool_call["name"]) + tool_arguments = normalized_tool_call.get("arguments") + _show_progress("executing", tool_name) + result = await self._execute_general_tool(normalized_tool_call, TOOL_HANDLERS) _clear_indicator() - _print_tool_result(tool_call['name'], result, enabled=self._settings.verbose_progress) + _print_tool_result(tool_name, result, enabled=self._settings.verbose_progress) trace.record( ExecutionMode.ACTING, - action=tool_call, + action=normalized_tool_call, observation=result if isinstance(result, dict) else {"result": str(result)}, ) is_error = isinstance(result, dict) and not result.get("ok", True) if is_error: recovery.record_tool_failure() - if recovery.consecutive_tool_failures >= self._settings.max_consecutive_tool_failures: - logger.warning("unified_loop: %d consecutive tool failures, stopping", - recovery.consecutive_tool_failures) - break else: recovery.record_tool_success() - result_payload = self._compact_tool_result(tool_call['name'], tool_call.get("arguments"), result) + result_payload = self._compact_tool_result(tool_name, tool_arguments, result) result_text = json.dumps(result_payload, default=str, ensure_ascii=False)[:result_budget] messages.append(build_user_message_text( - f"Tool result ({tool_call['name']}):\n{result_text}" + f"Tool result ({tool_name}):\n{result_text}" )) - self._persist_message(session_id, "tool", result_text, tool_name=tool_call['name']) + self._persist_message(session_id, "tool", result_text, tool_name=tool_name) + + if _is_retryable_unknown_tool_result(result) and not unknown_tool_retry_used: + unknown_tool_retry_used = True + messages.append(build_user_message_text(_unknown_tool_retry_prompt(result))) + continue + + if is_error and recovery.consecutive_tool_failures >= self._settings.max_consecutive_tool_failures: + logger.warning("unified_loop: %d consecutive tool failures, stopping", + recovery.consecutive_tool_failures) + break if self._check_guardrail(messages) == "halt": break @@ -1985,6 +2087,7 @@ async def _unified_tool_loop_stream( turn_recovery = TurnRecoveryState() use_native_tools = assembly.plan.native_tools result_budget = self._effective_tool_result_budget() + unknown_tool_retry_used = False self._usage_tracker.reset() tools_kwarg: Dict[str, Any] = self._planned_tools_kwarg(assembly.plan) @@ -2080,28 +2183,54 @@ async def _unified_tool_loop_stream( ) for tc in native_calls: + resolved_call = _normalize_tool_call({"name": tc.name, "arguments": tc.arguments}) + normalized_name = str(resolved_call["name"]) + original_name = str(resolved_call.get("original_tool_name") or tc.name) yield StreamEvent( type="tool_start", - content=tc.name, - metadata=_tool_args_metadata(tc.name, tc.arguments), + content=normalized_name, + metadata=_tool_args_metadata( + normalized_name, + tc.arguments, + original_tool_name=original_name, + ), ) results = await self._execute_tools_concurrent( native_calls, TOOL_HANDLERS, trace=trace, messages=messages ) result_by_id = {str(item.get("id")): item for item in results} + retryable_unknown = next( + ( + item.get("result") + for item in results + if _is_retryable_unknown_tool_result(item.get("result")) + ), + None, + ) for tc in native_calls: item = result_by_id.get(str(tc.id), {}) + normalized_name = str(item.get("name") or _normalize_tool_name(tc.name)) + original_name = str(item.get("original_tool_name") or tc.name) yield StreamEvent( type="tool_complete", - content=tc.name, + content=normalized_name, metadata={ - **_tool_result_metadata(tc.name, tc.arguments, item.get("result")), - **self._tool_context_metadata(tc.name, tc.arguments, item.get("result")), + **_tool_result_metadata( + normalized_name, + tc.arguments, + item.get("result"), + original_tool_name=original_name, + ), + **self._tool_context_metadata(normalized_name, tc.arguments, item.get("result")), }, ) failures = self._count_consecutive_tool_failures(messages) turn_recovery.consecutive_tool_failures = failures + if retryable_unknown and not unknown_tool_retry_used: + unknown_tool_retry_used = True + messages.append(build_user_message_text(_unknown_tool_retry_prompt(retryable_unknown))) + continue if failures >= self._settings.max_consecutive_tool_failures: logger.warning("unified_loop_stream: %d consecutive tool failures, stopping", failures) break @@ -2216,52 +2345,67 @@ async def _unified_tool_loop_stream( return messages.append(build_assistant_message(content)) + normalized_tool_call = _normalize_tool_call(tool_call) + tool_name = str(normalized_tool_call["name"]) + original_tool_name = str(normalized_tool_call.get("original_tool_name", tool_name)) + tool_arguments = normalized_tool_call.get("arguments") yield StreamEvent( type="tool_start", - content=tool_call['name'], - metadata=_tool_args_metadata(tool_call['name'], tool_call.get("arguments")), + content=tool_name, + metadata=_tool_args_metadata( + tool_name, + tool_arguments, + original_tool_name=original_tool_name, + ), ) - result = await self._execute_general_tool(tool_call, TOOL_HANDLERS) + result = await self._execute_general_tool(normalized_tool_call, TOOL_HANDLERS) _clear_indicator() yield StreamEvent( type="tool_complete", - content=tool_call['name'], + content=tool_name, metadata={ **_tool_result_metadata( - tool_call['name'], - tool_call.get("arguments"), + tool_name, + tool_arguments, result, + original_tool_name=original_tool_name, ), **self._tool_context_metadata( - tool_call['name'], - tool_call.get("arguments"), + tool_name, + tool_arguments, result, ), }, ) - _print_tool_result(tool_call['name'], result, enabled=self._settings.verbose_progress) + _print_tool_result(tool_name, result, enabled=self._settings.verbose_progress) trace.record( ExecutionMode.ACTING, - action=tool_call, + action=normalized_tool_call, observation=result if isinstance(result, dict) else {"result": str(result)}, ) is_error = isinstance(result, dict) and not result.get("ok", True) if is_error: turn_recovery.record_tool_failure() - if turn_recovery.consecutive_tool_failures >= self._settings.max_consecutive_tool_failures: - logger.warning("unified_loop_stream: %d consecutive tool failures, stopping", - turn_recovery.consecutive_tool_failures) - break else: turn_recovery.record_tool_success() - result_payload = self._compact_tool_result(tool_call['name'], tool_call.get("arguments"), result) + result_payload = self._compact_tool_result(tool_name, tool_arguments, result) result_text = json.dumps(result_payload, default=str, ensure_ascii=False)[:result_budget] messages.append(build_user_message_text( - f"Tool result ({tool_call['name']}):\n{result_text}" + f"Tool result ({tool_name}):\n{result_text}" )) - self._persist_message(session_id, "tool", result_text, tool_name=tool_call['name']) + self._persist_message(session_id, "tool", result_text, tool_name=tool_name) + + if _is_retryable_unknown_tool_result(result) and not unknown_tool_retry_used: + unknown_tool_retry_used = True + messages.append(build_user_message_text(_unknown_tool_retry_prompt(result))) + continue + + if is_error and turn_recovery.consecutive_tool_failures >= self._settings.max_consecutive_tool_failures: + logger.warning("unified_loop_stream: %d consecutive tool failures, stopping", + turn_recovery.consecutive_tool_failures) + break if self._check_guardrail(messages) == "halt": break @@ -2331,27 +2475,40 @@ async def _execute_tools_concurrent( """ result_budget = self._effective_tool_result_budget() executed: list[Dict[str, Any]] = [] + original_names_by_id = {str(tc.id): str(tc.name) for tc in native_calls} tc_wrappers = [ - ConcurrentToolCall(id=tc.id, name=tc.name, arguments=tc.arguments) + ConcurrentToolCall( + id=tc.id, + name=str(_normalize_tool_call({"name": tc.name, "arguments": tc.arguments})["name"]), + arguments=tc.arguments, + ) for tc in native_calls ] if not self._concurrency_policy or len(tc_wrappers) <= 1: for i, tc in enumerate(native_calls): - _show_progress("executing", tc.name, step=i + 1, total=len(native_calls)) - tool_call_dict = {"name": tc.name, "arguments": tc.arguments} + original_name = str(tc.name) + tool_call_dict = _normalize_tool_call({"name": original_name, "arguments": tc.arguments}) + normalized_name = str(tool_call_dict["name"]) + _show_progress("executing", normalized_name, step=i + 1, total=len(native_calls)) result = await self._execute_general_tool(tool_call_dict, handlers) _clear_indicator() - _print_tool_result(tc.name, result, enabled=self._settings.verbose_progress) + _print_tool_result(normalized_name, result, enabled=self._settings.verbose_progress) trace.record( ExecutionMode.ACTING, action=tool_call_dict, observation=result if isinstance(result, dict) else {"result": str(result)}, ) - result_payload = self._compact_tool_result(tc.name, tc.arguments, result) + result_payload = self._compact_tool_result(normalized_name, tc.arguments, result) result_text = json.dumps(result_payload, default=str, ensure_ascii=False)[:result_budget] messages.append({"role": "tool", "tool_call_id": tc.id, "content": result_text}) - executed.append({"id": tc.id, "name": tc.name, "arguments": tc.arguments, "result": result}) + executed.append({ + "id": tc.id, + "name": normalized_name, + "original_tool_name": str(tool_call_dict.get("original_tool_name") or original_name), + "arguments": tc.arguments, + "result": result, + }) return executed concurrent, sequential = self._concurrency_policy.partition(tc_wrappers) @@ -2364,7 +2521,13 @@ async def _execute_tools_concurrent( # Execute concurrent group via asyncio.gather if concurrent: async def _run_one(ctc: ConcurrentToolCall) -> Dict[str, Any]: - tool_call_dict = {"name": ctc.name, "arguments": ctc.arguments} + original_name = original_names_by_id.get(str(ctc.id), ctc.name) + tool_call_dict = { + "name": ctc.name, + "arguments": ctc.arguments, + "original_tool_name": original_name, + "normalized_tool_name": ctc.name, + } return await self._execute_general_tool(tool_call_dict, handlers) gather_results = await asyncio.gather( @@ -2372,7 +2535,13 @@ async def _run_one(ctc: ConcurrentToolCall) -> Dict[str, Any]: return_exceptions=True, ) for ctc, result in zip(concurrent, gather_results): - tool_call_dict = {"name": ctc.name, "arguments": ctc.arguments} + original_name = original_names_by_id.get(str(ctc.id), ctc.name) + tool_call_dict = { + "name": ctc.name, + "arguments": ctc.arguments, + "original_tool_name": original_name, + "normalized_tool_name": ctc.name, + } if isinstance(result, Exception): error_result: Dict[str, Any] = { "ok": False, @@ -2399,13 +2568,20 @@ async def _run_one(ctc: ConcurrentToolCall) -> Dict[str, Any]: executed.append({ "id": ctc.id, "name": ctc.name, + "original_tool_name": original_name, "arguments": ctc.arguments, "result": error_result if isinstance(result, Exception) else result, }) for i, ctc in enumerate(sequential): + original_name = original_names_by_id.get(str(ctc.id), ctc.name) _show_progress("executing", ctc.name, step=i + 1, total=len(sequential)) - tool_call_dict = {"name": ctc.name, "arguments": ctc.arguments} + tool_call_dict = { + "name": ctc.name, + "arguments": ctc.arguments, + "original_tool_name": original_name, + "normalized_tool_name": ctc.name, + } result = await self._execute_general_tool(tool_call_dict, handlers) _clear_indicator() _print_tool_result(ctc.name, result, enabled=self._settings.verbose_progress) @@ -2417,7 +2593,13 @@ async def _run_one(ctc: ConcurrentToolCall) -> Dict[str, Any]: result_payload = self._compact_tool_result(ctc.name, ctc.arguments, result) result_text = json.dumps(result_payload, default=str, ensure_ascii=False)[:result_budget] messages.append({"role": "tool", "tool_call_id": ctc.id, "content": result_text}) - executed.append({"id": ctc.id, "name": ctc.name, "arguments": ctc.arguments, "result": result}) + executed.append({ + "id": ctc.id, + "name": ctc.name, + "original_tool_name": original_name, + "arguments": ctc.arguments, + "result": result, + }) return executed async def _execute_general_tool( @@ -2436,8 +2618,25 @@ async def _execute_general_tool( from leapflow.skills.tool_executor import ToolCall as TC from leapflow.security.redact import redact_sensitive_text - name = tool_call.get("name", "") + original_name = str(tool_call.get("original_tool_name") or tool_call.get("name", "")) + proposed_name = str(tool_call.get("name", "")) args = tool_call.get("arguments", {}) + registry = _default_tool_registry() + resolution = registry.resolve(proposed_name, args) + if not resolution.auto_executable or resolution.normalized_name is None: + return registry.unknown_result( + ToolResolution( + original_name=original_name, + normalized_name=resolution.normalized_name, + status=resolution.status, + confidence=resolution.confidence, + reason=resolution.reason, + suggestions=resolution.suggestions, + auto_executable=False, + risk_level=resolution.risk_level, + ) + ) + name = resolution.normalized_name result: Dict[str, Any] @@ -2470,7 +2669,8 @@ async def _execute_general_tool( # Fallback: direct handler dispatch handler = handlers.get(name) if handler is None: - return {"ok": False, "error": f"Unknown tool: {name}"} + missing_resolution = registry.resolve(original_name, args) + return registry.unknown_result(missing_resolution) result = await asyncio.wait_for(handler(args), timeout=timeout) except asyncio.TimeoutError: diff --git a/src/leapflow/gateway/adapters/__init__.py b/src/leapflow/gateway/adapters/__init__.py new file mode 100644 index 0000000..7fa9ec0 --- /dev/null +++ b/src/leapflow/gateway/adapters/__init__.py @@ -0,0 +1,15 @@ +"""Built-in gateway platform adapters.""" + +from leapflow.gateway.adapters.api_server import APIServerAdapter +from leapflow.gateway.adapters.dingtalk import DingTalkAdapter +from leapflow.gateway.adapters.feishu import FeishuAdapter +from leapflow.gateway.adapters.telegram import TelegramAdapter +from leapflow.gateway.adapters.webhook import WebhookAdapter + +__all__ = [ + "APIServerAdapter", + "DingTalkAdapter", + "FeishuAdapter", + "TelegramAdapter", + "WebhookAdapter", +] diff --git a/src/leapflow/gateway/adapters/api_server.py b/src/leapflow/gateway/adapters/api_server.py new file mode 100644 index 0000000..79908c9 --- /dev/null +++ b/src/leapflow/gateway/adapters/api_server.py @@ -0,0 +1,136 @@ +"""OpenAI-compatible API server gateway adapter.""" +from __future__ import annotations + +from typing import Any, Mapping + +from leapflow.gateway.adapters.common import ( + AdapterLifecycle, + HttpRequest, + HttpResponse, + TinyJsonHttpServer, + parse_bind_port, + parse_json_object, + stable_message_id, +) +from leapflow.gateway.protocol import InboundMessage, OutboundContent, SendResult, SendTarget + + +class APIServerAdapter(AdapterLifecycle): + """Expose a local OpenAI-compatible chat completions ingress endpoint.""" + + platform_id = "api_server" + supports_async_delivery = False + max_message_length = 0 + + def __init__( + self, + api_key: str, + host: str = "127.0.0.1", + port: str | int = "8080", + profile: str = "default", + **_: Any, + ) -> None: + super().__init__(profile=profile) + self._api_key = api_key + self._host = host or "127.0.0.1" + self._port = parse_bind_port(port, 8080) + self._server: TinyJsonHttpServer | None = None + + @property + def local_url(self) -> str: + if self._server is None: + return f"http://{self._host}:{self._port}" + return self._server.url_base + + async def connect(self, *, is_reconnect: bool = False) -> None: + if len(self._api_key) < 16: + raise ValueError("API key must be at least 16 characters") + if self._server is None: + self._server = TinyJsonHttpServer(self._host, self._port, self._handle_request) + await self._server.start() + await super().connect(is_reconnect=is_reconnect) + + async def disconnect(self) -> None: + if self._server is not None: + await self._server.stop() + self._server = None + await super().disconnect() + + async def send(self, target: SendTarget, content: OutboundContent) -> SendResult: + return SendResult(ok=False, error="api_server ingress does not support outbound delivery") + + async def _handle_request(self, request: HttpRequest) -> HttpResponse: + path = request.path.split("?", 1)[0] + if path == "/health": + return HttpResponse(200, {"ok": True, "platform": self.platform_id}) + if path != "/v1/chat/completions": + return HttpResponse(404, {"ok": False, "error": "not found"}) + if request.method != "POST": + return HttpResponse(405, {"ok": False, "error": "method not allowed"}) + if not self._authorized(request.headers): + return HttpResponse(401, {"error": {"message": "unauthorized", "type": "auth_error"}}) + payload = parse_json_object(request.body) + message = self.message_from_payload(payload) + await self._emit(message) + return HttpResponse(200, self._accepted_response(payload, message.message_id)) + + def _authorized(self, headers: Mapping[str, str]) -> bool: + auth = headers.get("authorization", "") + if auth == f"Bearer {self._api_key}": + return True + return headers.get("x-api-key", "") == self._api_key + + def message_from_payload(self, payload: Mapping[str, Any]) -> InboundMessage: + messages = payload.get("messages") + text = "" + if isinstance(messages, list): + for item in reversed(messages): + if not isinstance(item, dict): + continue + if item.get("role") == "user": + content = item.get("content", "") + text = self._content_to_text(content) + break + if not text: + text = str(payload.get("prompt") or payload.get("input") or "") + chat_id = str(payload.get("user") or payload.get("session_id") or "api") + message_id = stable_message_id("api") + return InboundMessage( + source=self._source(chat_id=chat_id, chat_type="api", user_id=chat_id), + text=text, + message_id=message_id, + metadata={"model": str(payload.get("model", ""))}, + ) + + @staticmethod + def _content_to_text(content: Any) -> str: + if isinstance(content, str): + return content + if isinstance(content, list): + parts: list[str] = [] + for block in content: + if isinstance(block, dict): + value = block.get("text") or block.get("content") + if value: + parts.append(str(value)) + return "\n".join(parts) + return str(content or "") + + @staticmethod + def _accepted_response(payload: Mapping[str, Any], message_id: str) -> dict[str, Any]: + return { + "id": message_id, + "object": "chat.completion", + "created": 0, + "model": str(payload.get("model") or "leapflow-gateway"), + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "Request accepted by LeapFlow gateway.", + }, + "finish_reason": "stop", + }, + ], + } diff --git a/src/leapflow/gateway/adapters/common.py b/src/leapflow/gateway/adapters/common.py new file mode 100644 index 0000000..2f91f78 --- /dev/null +++ b/src/leapflow/gateway/adapters/common.py @@ -0,0 +1,314 @@ +"""Common helpers for built-in gateway adapters. + +The helpers here intentionally stay small: they provide shared lifecycle, +JSON-over-HTTP, and tiny local HTTP server primitives without becoming a +Hermes-style base class that owns platform behaviour. +""" +from __future__ import annotations + +import asyncio +import json +import logging +import uuid +from dataclasses import dataclass, field +from typing import Any, Awaitable, Callable, Dict, Mapping, Optional, Protocol +from urllib import error, request + +from leapflow.gateway.mixin import PlatformAdapterMixin +from leapflow.gateway.protocol import InboundMessage, MessageHandler, MessageSource + +logger = logging.getLogger(__name__) + +JsonBody = Dict[str, Any] +HttpHeaders = Dict[str, str] + + +class JsonHttpClient(Protocol): + """Minimal async JSON HTTP client contract used by adapters.""" + + async def request_json( + self, + method: str, + url: str, + *, + json_body: Mapping[str, Any] | None = None, + headers: Mapping[str, str] | None = None, + timeout_s: float = 10.0, + ) -> tuple[int, JsonBody]: + """Send a JSON request and return ``(status_code, parsed_json)``.""" + ... + + +class UrlLibJsonHttpClient: + """Small stdlib-backed JSON HTTP client with timeout support.""" + + async def request_json( + self, + method: str, + url: str, + *, + json_body: Mapping[str, Any] | None = None, + headers: Mapping[str, str] | None = None, + timeout_s: float = 10.0, + ) -> tuple[int, JsonBody]: + return await asyncio.to_thread( + self._request_json_sync, + method, + url, + dict(json_body or {}), + dict(headers or {}), + timeout_s, + ) + + @staticmethod + def _request_json_sync( + method: str, + url: str, + json_body: Mapping[str, Any], + headers: Mapping[str, str], + timeout_s: float, + ) -> tuple[int, JsonBody]: + payload = json.dumps(json_body).encode("utf-8") if json_body else None + request_headers = {"Accept": "application/json", **headers} + if payload is not None: + request_headers.setdefault("Content-Type", "application/json") + req = request.Request( + url, + data=payload, + headers=request_headers, + method=method.upper(), + ) + try: + with request.urlopen(req, timeout=timeout_s) as resp: + status = int(getattr(resp, "status", 200)) + raw = resp.read().decode("utf-8") + except error.HTTPError as exc: + status = int(exc.code) + raw = exc.read().decode("utf-8", errors="replace") + data = parse_json_object(raw) + return status, data + + +def parse_json_object(raw: str | bytes) -> JsonBody: + """Parse a JSON object; non-object payloads are wrapped as text.""" + if isinstance(raw, bytes): + raw = raw.decode("utf-8", errors="replace") + if not raw: + return {} + try: + value = json.loads(raw) + except json.JSONDecodeError: + return {"text": raw} + return value if isinstance(value, dict) else {"value": value} + + +def parse_bind_port(value: str | int | None, default: int) -> int: + """Parse a bind port while preserving explicit ``0`` for ephemeral ports.""" + if value is None or value == "": + return default + return int(value) + + +def stable_message_id(prefix: str) -> str: + """Return a compact unique message ID for synthetic gateway events.""" + return f"{prefix}-{uuid.uuid4().hex[:12]}" + + +def bool_option(value: Any, *, default: bool = True) -> bool: + """Parse common boolean option representations.""" + if value is None: + return default + if isinstance(value, bool): + return value + return str(value).strip().lower() not in {"0", "false", "no", "off"} + + +def chunk_text(text: str, limit: int) -> list[str]: + """Split text into non-empty chunks without truncating content.""" + if limit <= 0 or len(text) <= limit: + return [text] + return [text[index:index + limit] for index in range(0, len(text), limit)] + + +class AdapterLifecycle(PlatformAdapterMixin): + """Tiny lifecycle helper shared by built-in adapters.""" + + platform_id = "" + supports_async_delivery = True + splits_long_messages = False + max_message_length = 4000 + + def __init__(self, *, profile: str = "default") -> None: + self._profile = profile or "default" + self._on_message: Optional[MessageHandler] = None + self._connected = False + + @property + def on_message(self) -> Optional[MessageHandler]: + return self._on_message + + @on_message.setter + def on_message(self, handler: MessageHandler) -> None: + self._on_message = handler + + @property + def connected(self) -> bool: + return self._connected + + async def connect(self, *, is_reconnect: bool = False) -> None: + self._connected = True + + async def disconnect(self) -> None: + self._connected = False + + def _source( + self, + *, + chat_id: str, + chat_type: str = "dm", + user_id: str = "", + user_name: str = "", + thread_id: str = "", + scope_id: str = "", + ) -> MessageSource: + return MessageSource( + platform=self.platform_id, + chat_id=str(chat_id or self.platform_id), + chat_type=chat_type or "dm", + user_id=str(user_id or ""), + user_name=str(user_name or ""), + thread_id=str(thread_id or ""), + scope_id=str(scope_id or ""), + profile=self._profile, + ) + + async def _emit(self, message: InboundMessage) -> None: + if self._on_message is None: + logger.warning("No gateway message handler set for %s", self.platform_id) + return + result = self._on_message(message) + if asyncio.iscoroutine(result): + await result + + +@dataclass(frozen=True) +class HttpRequest: + """Parsed HTTP request passed to local gateway endpoints.""" + + method: str + path: str + headers: Mapping[str, str] + body: bytes = b"" + + +@dataclass(frozen=True) +class HttpResponse: + """Minimal HTTP response returned by local gateway endpoints.""" + + status: int + body: Mapping[str, Any] = field(default_factory=dict) + headers: Mapping[str, str] = field(default_factory=dict) + + +HttpHandler = Callable[[HttpRequest], Awaitable[HttpResponse]] + + +class TinyJsonHttpServer: + """Small asyncio JSON HTTP server for local gateway adapters.""" + + def __init__(self, host: str, port: int, handler: HttpHandler) -> None: + self._host = host + self._port = port + self._handler = handler + self._server: asyncio.AbstractServer | None = None + + @property + def host(self) -> str: + return self._host + + @property + def port(self) -> int: + if self._server is None: + return self._port + sockets = self._server.sockets or [] + if not sockets: + return self._port + return int(sockets[0].getsockname()[1]) + + @property + def url_base(self) -> str: + return f"http://{self.host}:{self.port}" + + async def start(self) -> None: + self._server = await asyncio.start_server( + self._handle_client, + host=self._host, + port=self._port, + ) + + async def stop(self) -> None: + if self._server is None: + return + self._server.close() + await self._server.wait_closed() + self._server = None + + async def _handle_client( + self, + reader: asyncio.StreamReader, + writer: asyncio.StreamWriter, + ) -> None: + try: + request_obj = await self._read_request(reader) + response = await self._handler(request_obj) + except Exception as exc: + logger.debug("gateway.http.error", exc_info=True) + response = HttpResponse(500, {"ok": False, "error": type(exc).__name__}) + self._write_response(writer, response) + await writer.drain() + writer.close() + await writer.wait_closed() + + @staticmethod + async def _read_request(reader: asyncio.StreamReader) -> HttpRequest: + header_bytes = await reader.readuntil(b"\r\n\r\n") + header_text = header_bytes.decode("iso-8859-1") + lines = header_text.split("\r\n") + method, path, _version = lines[0].split(" ", 2) + headers: Dict[str, str] = {} + for line in lines[1:]: + if not line or ":" not in line: + continue + key, value = line.split(":", 1) + headers[key.strip().lower()] = value.strip() + content_length = int(headers.get("content-length", "0") or "0") + body = await reader.readexactly(content_length) if content_length else b"" + return HttpRequest(method=method.upper(), path=path, headers=headers, body=body) + + @staticmethod + def _write_response(writer: asyncio.StreamWriter, response: HttpResponse) -> None: + reason = { + 200: "OK", + 202: "Accepted", + 400: "Bad Request", + 401: "Unauthorized", + 404: "Not Found", + 405: "Method Not Allowed", + 500: "Internal Server Error", + }.get(response.status, "OK") + body = json.dumps(dict(response.body), ensure_ascii=False).encode("utf-8") + headers = { + "Content-Type": "application/json; charset=utf-8", + "Content-Length": str(len(body)), + "Connection": "close", + **dict(response.headers), + } + header_lines = [f"HTTP/1.1 {response.status} {reason}"] + header_lines.extend(f"{key}: {value}" for key, value in headers.items()) + writer.write("\r\n".join(header_lines).encode("ascii") + b"\r\n\r\n" + body) + + +async def post_json_for_test(url: str, payload: Mapping[str, Any]) -> tuple[int, JsonBody]: + """Helper used by tests and smoke checks to POST JSON to local adapters.""" + client = UrlLibJsonHttpClient() + return await client.request_json("POST", url, json_body=payload, timeout_s=5) diff --git a/src/leapflow/gateway/adapters/dingtalk.py b/src/leapflow/gateway/adapters/dingtalk.py new file mode 100644 index 0000000..e5b5831 --- /dev/null +++ b/src/leapflow/gateway/adapters/dingtalk.py @@ -0,0 +1,196 @@ +"""DingTalk gateway adapter.""" +from __future__ import annotations + +from typing import Any, Mapping + +from leapflow.gateway.adapters.common import ( + AdapterLifecycle, + HttpRequest, + HttpResponse, + JsonHttpClient, + TinyJsonHttpServer, + UrlLibJsonHttpClient, + parse_bind_port, + parse_json_object, + stable_message_id, +) +from leapflow.gateway.protocol import InboundMessage, OutboundContent, SendResult, SendTarget + + +class DingTalkAdapter(AdapterLifecycle): + """DingTalk adapter with access token refresh and text message sending.""" + + platform_id = "dingtalk" + supports_async_delivery = True + max_message_length = 5000 + + def __init__( + self, + app_key: str, + app_secret: str, + robot_code: str = "", + agent_id: str = "", + connection_mode: str = "webhook", + profile: str = "default", + api_base: str = "https://oapi.dingtalk.com", + auth_base: str = "https://api.dingtalk.com", + host: str = "127.0.0.1", + port: str | int = "9092", + path: str = "/dingtalk/events", + http_client: JsonHttpClient | None = None, + **_: Any, + ) -> None: + super().__init__(profile=profile) + self._app_key = app_key + self._app_secret = app_secret + self._robot_code = robot_code + self._agent_id = agent_id + self._connection_mode = connection_mode or "webhook" + self._api_base = api_base.rstrip("/") + self._auth_base = auth_base.rstrip("/") + self._host = host or "127.0.0.1" + self._port = parse_bind_port(port, 9092) + self._path = path if str(path).startswith("/") else f"/{path}" + self._http = http_client or UrlLibJsonHttpClient() + self._access_token = "" + self._server: TinyJsonHttpServer | None = None + + @property + def local_url(self) -> str: + if self._server is None: + return f"http://{self._host}:{self._port}{self._path}" + return f"{self._server.url_base}{self._path}" + + async def connect(self, *, is_reconnect: bool = False) -> None: + self._access_token = await self._fetch_access_token() + if self._connection_mode != "webhook": + raise NotImplementedError( + "DingTalk built-in adapter currently supports connection_mode='webhook'. " + "dingtalk-stream mode is planned as a later adapter enhancement.", + ) + if self._server is None: + self._server = TinyJsonHttpServer(self._host, self._port, self._handle_request) + await self._server.start() + await super().connect(is_reconnect=is_reconnect) + + async def disconnect(self) -> None: + if self._server is not None: + await self._server.stop() + self._server = None + await super().disconnect() + + async def send(self, target: SendTarget, content: OutboundContent) -> SendResult: + token = self._access_token or await self._fetch_access_token() + if self._robot_code: + return await self._send_robot_message(token, target, content) + if self._agent_id: + return await self._send_corp_message(token, target, content) + return SendResult( + ok=False, + error="DingTalk send requires robot_code or agent_id option", + ) + + async def handle_event(self, payload: Mapping[str, Any]) -> InboundMessage | None: + """Normalise a DingTalk stream/callback payload and emit it.""" + message = self.message_from_event(payload) + if message is None: + return None + await self._emit(message) + return message + + async def _handle_request(self, request: HttpRequest) -> HttpResponse: + if request.path.split("?", 1)[0] != self._path: + return HttpResponse(404, {"ok": False, "error": "not found"}) + if request.method != "POST": + return HttpResponse(405, {"ok": False, "error": "method not allowed"}) + payload = parse_json_object(request.body) + message = await self.handle_event(payload) + if message is None: + return HttpResponse(202, {"ok": True, "ignored": True}) + return HttpResponse(202, {"ok": True, "message_id": message.message_id}) + + def message_from_event(self, payload: Mapping[str, Any]) -> InboundMessage | None: + text = self._extract_text(payload) + if not text: + return None + chat_id = str( + payload.get("conversationId") + or payload.get("conversation_id") + or payload.get("chat_id") + or "dingtalk" + ) + chat_type = "group" if payload.get("conversationType") == "2" else "dm" + user_id = str(payload.get("senderStaffId") or payload.get("senderId") or "") + user_name = str(payload.get("senderNick") or payload.get("senderName") or "") + message_id = str(payload.get("msgId") or payload.get("message_id") or stable_message_id("dingtalk")) + return InboundMessage( + source=self._source( + chat_id=chat_id, + chat_type=chat_type, + user_id=user_id, + user_name=user_name, + ), + text=text, + message_id=message_id, + metadata={"robot_code": self._robot_code}, + ) + + async def _fetch_access_token(self) -> str: + status, data = await self._http.request_json( + "POST", + f"{self._auth_base}/v1.0/oauth2/accessToken", + json_body={"appKey": self._app_key, "appSecret": self._app_secret}, + timeout_s=10, + ) + token = data.get("accessToken") or data.get("access_token", "") + if status >= 400 or data.get("errcode", 0) != 0 or not token: + raise RuntimeError(str(data.get("errmsg") or "DingTalk token request failed")) + return str(token) + + async def _send_robot_message( + self, + token: str, + target: SendTarget, + content: OutboundContent, + ) -> SendResult: + url = f"{self._api_base}/topapi/robot/send?access_token={token}" + payload = { + "robotCode": self._robot_code, + "conversationId": target.chat_id, + "msgKey": "sampleText", + "msgParam": {"content": content.text[:self.max_message_length]}, + } + status, data = await self._http.request_json("POST", url, json_body=payload, timeout_s=10) + if status >= 400 or data.get("errcode", 0) != 0: + return SendResult(ok=False, error=str(data.get("errmsg") or data)) + return SendResult(ok=True, message_id=str(data.get("task_id") or data.get("request_id") or "")) + + async def _send_corp_message( + self, + token: str, + target: SendTarget, + content: OutboundContent, + ) -> SendResult: + url = f"{self._api_base}/topapi/message/corpconversation/asyncsend_v2?access_token={token}" + payload = { + "agent_id": self._agent_id, + "userid_list": target.chat_id, + "msg": {"msgtype": "text", "text": {"content": content.text[:self.max_message_length]}}, + } + status, data = await self._http.request_json("POST", url, json_body=payload, timeout_s=10) + if status >= 400 or data.get("errcode", 0) != 0: + return SendResult(ok=False, error=str(data.get("errmsg") or data)) + task_id = data.get("task_id") or data.get("result", {}).get("task_id", "") + return SendResult(ok=True, message_id=str(task_id)) + + @staticmethod + def _extract_text(payload: Mapping[str, Any]) -> str: + text = payload.get("text") + if isinstance(text, dict): + return str(text.get("content") or "") + if text: + return str(text) + content = payload.get("content") + if isinstance(content, dict): + return str(content.get("text") or content.get("content") or "") + return str(payload.get("msgContent") or "") diff --git a/src/leapflow/gateway/adapters/feishu.py b/src/leapflow/gateway/adapters/feishu.py new file mode 100644 index 0000000..35bcdf5 --- /dev/null +++ b/src/leapflow/gateway/adapters/feishu.py @@ -0,0 +1,176 @@ +"""Feishu/Lark gateway adapter.""" +from __future__ import annotations + +import json +from typing import Any, Mapping + +from leapflow.gateway.adapters.common import ( + AdapterLifecycle, + HttpRequest, + HttpResponse, + JsonHttpClient, + TinyJsonHttpServer, + UrlLibJsonHttpClient, + parse_bind_port, + parse_json_object, + stable_message_id, +) +from leapflow.gateway.protocol import InboundMessage, OutboundContent, SendResult, SendTarget + + +class FeishuAdapter(AdapterLifecycle): + """Feishu adapter with token refresh, outbound text, and event normalisation.""" + + platform_id = "feishu" + supports_async_delivery = True + max_message_length = 8000 + + def __init__( + self, + app_id: str, + app_secret: str, + connection_mode: str = "webhook", + profile: str = "default", + api_base: str = "https://open.feishu.cn", + host: str = "127.0.0.1", + port: str | int = "9091", + path: str = "/feishu/events", + http_client: JsonHttpClient | None = None, + **_: Any, + ) -> None: + super().__init__(profile=profile) + self._app_id = app_id + self._app_secret = app_secret + self._connection_mode = connection_mode or "webhook" + self._api_base = api_base.rstrip("/") + self._host = host or "127.0.0.1" + self._port = parse_bind_port(port, 9091) + self._path = path if str(path).startswith("/") else f"/{path}" + self._http = http_client or UrlLibJsonHttpClient() + self._tenant_access_token = "" + self._server: TinyJsonHttpServer | None = None + + @property + def local_url(self) -> str: + if self._server is None: + return f"http://{self._host}:{self._port}{self._path}" + return f"{self._server.url_base}{self._path}" + + async def connect(self, *, is_reconnect: bool = False) -> None: + self._tenant_access_token = await self._fetch_tenant_access_token() + if self._connection_mode != "webhook": + raise NotImplementedError( + "Feishu built-in adapter currently supports connection_mode='webhook'. " + "WebSocket SDK mode is planned as a later adapter enhancement.", + ) + if self._server is None: + self._server = TinyJsonHttpServer(self._host, self._port, self._handle_request) + await self._server.start() + await super().connect(is_reconnect=is_reconnect) + + async def disconnect(self) -> None: + if self._server is not None: + await self._server.stop() + self._server = None + await super().disconnect() + + async def send(self, target: SendTarget, content: OutboundContent) -> SendResult: + token = self._tenant_access_token or await self._fetch_tenant_access_token() + url = f"{self._api_base}/open-apis/im/v1/messages?receive_id_type=chat_id" + payload = { + "receive_id": target.chat_id, + "msg_type": "text", + "content": json.dumps({"text": content.text[:self.max_message_length]}), + } + status, data = await self._http.request_json( + "POST", + url, + json_body=payload, + headers={"Authorization": f"Bearer {token}"}, + timeout_s=10, + ) + if status >= 400 or data.get("code", 0) != 0: + return SendResult(ok=False, error=str(data.get("msg") or data)) + message_id = data.get("data", {}).get("message_id", "") + return SendResult(ok=True, message_id=str(message_id)) + + async def handle_event(self, payload: Mapping[str, Any]) -> InboundMessage | None: + """Normalise a Feishu event callback payload and emit it.""" + message = self.message_from_event(payload) + if message is None: + return None + await self._emit(message) + return message + + async def _handle_request(self, request: HttpRequest) -> HttpResponse: + if request.path.split("?", 1)[0] != self._path: + return HttpResponse(404, {"ok": False, "error": "not found"}) + if request.method != "POST": + return HttpResponse(405, {"ok": False, "error": "method not allowed"}) + payload = parse_json_object(request.body) + if payload.get("type") == "url_verification" and payload.get("challenge"): + return HttpResponse(200, {"challenge": payload["challenge"]}) + message = await self.handle_event(payload) + if message is None: + return HttpResponse(202, {"ok": True, "ignored": True}) + return HttpResponse(202, {"ok": True, "message_id": message.message_id}) + + def message_from_event(self, payload: Mapping[str, Any]) -> InboundMessage | None: + event = payload.get("event") if isinstance(payload.get("event"), dict) else payload + if not isinstance(event, Mapping): + return None + raw_message = event.get("message") if isinstance(event.get("message"), dict) else {} + raw_sender = event.get("sender") if isinstance(event.get("sender"), dict) else {} + content = raw_message.get("content", "") + text = self._parse_text_content(content) + if not text: + return None + chat_id = str(raw_message.get("chat_id") or event.get("chat_id") or "feishu") + chat_type = str(raw_message.get("chat_type") or "group") + user_id = self._sender_id(raw_sender) + user_name = str(raw_sender.get("sender_type") or "") + message_id = str(raw_message.get("message_id") or stable_message_id("feishu")) + return InboundMessage( + source=self._source( + chat_id=chat_id, + chat_type=chat_type, + user_id=user_id, + user_name=user_name, + ), + text=text, + message_id=message_id, + metadata={"connection_mode": self._connection_mode}, + ) + + async def _fetch_tenant_access_token(self) -> str: + status, data = await self._http.request_json( + "POST", + f"{self._api_base}/open-apis/auth/v3/tenant_access_token/internal", + json_body={"app_id": self._app_id, "app_secret": self._app_secret}, + timeout_s=10, + ) + token = data.get("tenant_access_token", "") + if status >= 400 or data.get("code", 0) != 0 or not token: + raise RuntimeError(str(data.get("msg") or "Feishu token request failed")) + return str(token) + + @staticmethod + def _sender_id(sender: Mapping[str, Any]) -> str: + raw_id = sender.get("sender_id") if isinstance(sender.get("sender_id"), dict) else {} + if isinstance(raw_id, dict): + return str(raw_id.get("open_id") or raw_id.get("union_id") or raw_id.get("user_id") or "") + return "" + + @staticmethod + def _parse_text_content(content: Any) -> str: + if isinstance(content, str): + try: + parsed = json.loads(content) + except json.JSONDecodeError: + return content + if isinstance(parsed, dict): + return str(parsed.get("text") or parsed.get("content") or "") + return str(parsed) + if isinstance(content, dict): + return str(content.get("text") or content.get("content") or "") + return str(content or "") diff --git a/src/leapflow/gateway/adapters/telegram.py b/src/leapflow/gateway/adapters/telegram.py new file mode 100644 index 0000000..48adf2a --- /dev/null +++ b/src/leapflow/gateway/adapters/telegram.py @@ -0,0 +1,153 @@ +"""Telegram Bot gateway adapter.""" +from __future__ import annotations + +import asyncio +from typing import Any, Mapping + +from leapflow.gateway.adapters.common import ( + AdapterLifecycle, + JsonHttpClient, + UrlLibJsonHttpClient, + bool_option, + chunk_text, + stable_message_id, +) +from leapflow.gateway.protocol import InboundMessage, OutboundContent, SendResult, SendTarget + + +class TelegramAdapter(AdapterLifecycle): + """Telegram bot adapter supporting polling and outbound text messages.""" + + platform_id = "telegram" + supports_async_delivery = True + max_message_length = 4096 + + def __init__( + self, + bot_token: str, + transport: str = "polling", + webhook_url: str = "", + profile: str = "default", + api_base: str = "https://api.telegram.org", + poll_interval_s: float = 1.0, + auto_poll: bool | str = True, + http_client: JsonHttpClient | None = None, + **_: Any, + ) -> None: + super().__init__(profile=profile) + self._bot_token = bot_token + self._transport = transport or "polling" + self._webhook_url = webhook_url + self._api_base = api_base.rstrip("/") + self._poll_interval_s = float(poll_interval_s) + self._auto_poll = bool_option(auto_poll, default=True) + self._http = http_client or UrlLibJsonHttpClient() + self._poll_task: asyncio.Task[None] | None = None + self._offset = 0 + + async def connect(self, *, is_reconnect: bool = False) -> None: + await super().connect(is_reconnect=is_reconnect) + if self._transport == "webhook" and self._webhook_url: + await self._api("setWebhook", {"url": self._webhook_url}) + if self._transport == "polling" and self._auto_poll and self._poll_task is None: + self._poll_task = asyncio.create_task(self._poll_loop()) + + async def disconnect(self) -> None: + task = self._poll_task + self._poll_task = None + if task is not None: + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + await super().disconnect() + + async def send(self, target: SendTarget, content: OutboundContent) -> SendResult: + message_ids: list[str] = [] + for chunk in chunk_text(content.text, self.max_message_length): + payload = { + "chat_id": target.chat_id, + "text": chunk, + } + if target.reply_to_id and not message_ids: + payload["reply_to_message_id"] = target.reply_to_id + status, data = await self._api("sendMessage", payload) + if status >= 400 or not data.get("ok", False): + return SendResult(ok=False, error=str(data.get("description") or data)) + message_id = data.get("result", {}).get("message_id", "") + if message_id: + message_ids.append(str(message_id)) + return SendResult(ok=True, message_id=",".join(message_ids)) + + async def handle_update(self, update: Mapping[str, Any]) -> InboundMessage | None: + """Normalise a Telegram update and emit it when it contains text.""" + message = self.message_from_update(update) + if message is None: + return None + await self._emit(message) + return message + + def message_from_update(self, update: Mapping[str, Any]) -> InboundMessage | None: + raw_message = update.get("message") or update.get("edited_message") + if not isinstance(raw_message, dict): + return None + text = raw_message.get("text") or raw_message.get("caption") or "" + if not text: + return None + chat = raw_message.get("chat") if isinstance(raw_message.get("chat"), dict) else {} + user = raw_message.get("from") if isinstance(raw_message.get("from"), dict) else {} + chat_id = str(chat.get("id") or raw_message.get("chat_id") or "telegram") + chat_type = str(chat.get("type") or "dm") + if chat_type == "private": + chat_type = "dm" + user_id = str(user.get("id") or "") + user_name = str(user.get("username") or user.get("first_name") or "") + message_id = str(raw_message.get("message_id") or stable_message_id("telegram")) + return InboundMessage( + source=self._source( + chat_id=chat_id, + chat_type=chat_type, + user_id=user_id, + user_name=user_name, + ), + text=str(text), + message_id=message_id, + metadata={"update_id": str(update.get("update_id", ""))}, + ) + + async def _poll_loop(self) -> None: + while self.connected: + try: + status, data = await self._api( + "getUpdates", + {"offset": self._offset, "timeout": 25}, + timeout_s=30, + ) + if status < 400 and data.get("ok"): + for update in data.get("result", []) or []: + if not isinstance(update, dict): + continue + update_id = int(update.get("update_id", self._offset)) + self._offset = max(self._offset, update_id + 1) + await self.handle_update(update) + except asyncio.CancelledError: + raise + except Exception: + await asyncio.sleep(self._poll_interval_s) + await asyncio.sleep(self._poll_interval_s) + + async def _api( + self, + method: str, + payload: Mapping[str, Any], + *, + timeout_s: float = 10.0, + ) -> tuple[int, dict[str, Any]]: + url = f"{self._api_base}/bot{self._bot_token}/{method}" + return await self._http.request_json( + "POST", + url, + json_body=payload, + timeout_s=timeout_s, + ) diff --git a/src/leapflow/gateway/adapters/webhook.py b/src/leapflow/gateway/adapters/webhook.py new file mode 100644 index 0000000..32e911c --- /dev/null +++ b/src/leapflow/gateway/adapters/webhook.py @@ -0,0 +1,117 @@ +"""Generic HTTP webhook gateway adapter.""" +from __future__ import annotations + +import hashlib +import hmac +from typing import Any, Mapping + +from leapflow.gateway.adapters.common import ( + AdapterLifecycle, + HttpRequest, + HttpResponse, + TinyJsonHttpServer, + parse_bind_port, + parse_json_object, + stable_message_id, +) +from leapflow.gateway.protocol import InboundMessage, OutboundContent, SendResult, SendTarget + + +class WebhookAdapter(AdapterLifecycle): + """Receive generic JSON webhook events and normalise them to gateway messages.""" + + platform_id = "webhook" + supports_async_delivery = False + max_message_length = 0 + + def __init__( + self, + webhook_secret: str = "", + host: str = "127.0.0.1", + port: str | int = "9090", + path: str = "/webhook", + profile: str = "default", + **_: Any, + ) -> None: + super().__init__(profile=profile) + self._secret = webhook_secret or "" + self._host = host or "127.0.0.1" + self._port = parse_bind_port(port, 9090) + self._path = path if str(path).startswith("/") else f"/{path}" + self._server: TinyJsonHttpServer | None = None + + @property + def local_url(self) -> str: + if self._server is None: + return f"http://{self._host}:{self._port}{self._path}" + return f"{self._server.url_base}{self._path}" + + async def connect(self, *, is_reconnect: bool = False) -> None: + if self._server is None: + self._server = TinyJsonHttpServer(self._host, self._port, self._handle_request) + await self._server.start() + await super().connect(is_reconnect=is_reconnect) + + async def disconnect(self) -> None: + if self._server is not None: + await self._server.stop() + self._server = None + await super().disconnect() + + async def send(self, target: SendTarget, content: OutboundContent) -> SendResult: + return SendResult(ok=False, error="webhook receiver does not support outbound delivery") + + async def _handle_request(self, request: HttpRequest) -> HttpResponse: + if request.path.split("?", 1)[0] != self._path: + return HttpResponse(404, {"ok": False, "error": "not found"}) + if request.method != "POST": + return HttpResponse(405, {"ok": False, "error": "method not allowed"}) + if self._secret and not self._valid_signature(request.headers, request.body): + return HttpResponse(401, {"ok": False, "error": "invalid signature"}) + payload = parse_json_object(request.body) + message = self.message_from_payload(payload) + await self._emit(message) + return HttpResponse(202, {"ok": True, "message_id": message.message_id}) + + def _valid_signature(self, headers: Mapping[str, str], body: bytes) -> bool: + expected = hmac.new(self._secret.encode("utf-8"), body, hashlib.sha256).hexdigest() + candidates = [ + headers.get("x-leapflow-signature", ""), + headers.get("x-hub-signature-256", ""), + headers.get("x-signature", ""), + ] + for candidate in candidates: + if candidate.startswith("sha256="): + candidate = candidate[len("sha256="):] + if candidate and hmac.compare_digest(candidate, expected): + return True + return False + + def message_from_payload(self, payload: Mapping[str, Any]) -> InboundMessage: + source_raw = payload.get("source") if isinstance(payload.get("source"), dict) else {} + source = source_raw if isinstance(source_raw, dict) else {} + text = str( + payload.get("text") + or payload.get("message") + or payload.get("content") + or payload.get("prompt") + or "" + ) + chat_id = str(payload.get("chat_id") or source.get("chat_id") or "webhook") + user_id = str(payload.get("user_id") or source.get("user_id") or "") + user_name = str(payload.get("user_name") or source.get("user_name") or "") + chat_type = str(payload.get("chat_type") or source.get("chat_type") or "dm") + thread_id = str(payload.get("thread_id") or source.get("thread_id") or "") + message_id = str(payload.get("message_id") or stable_message_id("webhook")) + return InboundMessage( + source=self._source( + chat_id=chat_id, + chat_type=chat_type, + user_id=user_id, + user_name=user_name, + thread_id=thread_id, + ), + text=text, + message_id=message_id, + metadata={"payload_keys": tuple(sorted(str(key) for key in payload.keys()))}, + ) diff --git a/src/leapflow/gateway/manifests/api_server.yaml b/src/leapflow/gateway/manifests/api_server.yaml index a4d75e9..3c7ad64 100644 --- a/src/leapflow/gateway/manifests/api_server.yaml +++ b/src/leapflow/gateway/manifests/api_server.yaml @@ -40,4 +40,4 @@ validation: adapter: module: leapflow.gateway.adapters.api_server class: APIServerAdapter - dependencies: [aiohttp] + dependencies: [] diff --git a/src/leapflow/gateway/manifests/dingtalk.yaml b/src/leapflow/gateway/manifests/dingtalk.yaml index c0ad180..3602f95 100644 --- a/src/leapflow/gateway/manifests/dingtalk.yaml +++ b/src/leapflow/gateway/manifests/dingtalk.yaml @@ -18,12 +18,43 @@ credentials: help_en: "Same page → AppSecret" options: + - key: connection_mode + label: 连接模式 + type: choice + choices: [webhook] + default: webhook + help_zh: "当前内置适配器使用本地 HTTP Webhook 接收事件;dingtalk-stream 模式将作为后续增强" + help_en: "The built-in adapter currently receives events through a local HTTP webhook; dingtalk-stream mode is a later enhancement" - key: robot_code label: 机器人编码 type: string required: false - help_zh: "如使用自定义机器人,请提供 robot_code(可选)" - help_en: "Provide robot_code if using a custom bot (optional)" + help_zh: "如使用自定义机器人发送消息,请提供 robot_code(可选)" + help_en: "Provide robot_code when sending through a custom bot (optional)" + - key: agent_id + label: Agent ID + type: string + required: false + help_zh: "如使用企业会话消息发送,请提供 agent_id(可选)" + help_en: "Provide agent_id when sending corporate conversation messages (optional)" + - key: host + label: 绑定地址 + type: string + default: "127.0.0.1" + help_zh: "Webhook 服务绑定地址(默认仅本机访问)" + help_en: "Webhook server bind address (default: localhost only)" + - key: port + label: 端口 + type: string + default: "9092" + help_zh: "Webhook 服务端口" + help_en: "Webhook server port" + - key: path + label: 路径 + type: string + default: "/dingtalk/events" + help_zh: "钉钉事件回调路径" + help_en: "DingTalk event callback path" setup_guide: summary_zh: | @@ -46,4 +77,4 @@ validation: adapter: module: leapflow.gateway.adapters.dingtalk class: DingTalkAdapter - dependencies: [dingtalk-stream] + dependencies: [] diff --git a/src/leapflow/gateway/manifests/feishu.yaml b/src/leapflow/gateway/manifests/feishu.yaml index 56c9a99..51e66f5 100644 --- a/src/leapflow/gateway/manifests/feishu.yaml +++ b/src/leapflow/gateway/manifests/feishu.yaml @@ -21,10 +21,28 @@ options: - key: connection_mode label: 连接模式 type: choice - choices: [websocket, webhook] - default: websocket - help_zh: "WebSocket 无需公网 IP(推荐),Webhook 需要公网回调地址" - help_en: "WebSocket needs no public IP (recommended), Webhook requires public callback URL" + choices: [webhook] + default: webhook + help_zh: "当前内置适配器使用本地 HTTP Webhook 接收事件;WebSocket SDK 模式将作为后续增强" + help_en: "The built-in adapter currently receives events through a local HTTP webhook; SDK WebSocket mode is a later enhancement" + - key: host + label: 绑定地址 + type: string + default: "127.0.0.1" + help_zh: "Webhook 服务绑定地址(默认仅本机访问)" + help_en: "Webhook server bind address (default: localhost only)" + - key: port + label: 端口 + type: string + default: "9091" + help_zh: "Webhook 服务端口" + help_en: "Webhook server port" + - key: path + label: 路径 + type: string + default: "/feishu/events" + help_zh: "飞书事件回调路径" + help_en: "Feishu event callback path" setup_guide: summary_zh: | @@ -47,4 +65,4 @@ validation: adapter: module: leapflow.gateway.adapters.feishu class: FeishuAdapter - dependencies: [lark-oapi] + dependencies: [] diff --git a/src/leapflow/gateway/manifests/telegram.yaml b/src/leapflow/gateway/manifests/telegram.yaml index 76c0bd9..3cf97e2 100644 --- a/src/leapflow/gateway/manifests/telegram.yaml +++ b/src/leapflow/gateway/manifests/telegram.yaml @@ -49,4 +49,4 @@ validation: adapter: module: leapflow.gateway.adapters.telegram class: TelegramAdapter - dependencies: [aiohttp] + dependencies: [] diff --git a/src/leapflow/gateway/manifests/webhook.yaml b/src/leapflow/gateway/manifests/webhook.yaml index 4c0ee78..9854e79 100644 --- a/src/leapflow/gateway/manifests/webhook.yaml +++ b/src/leapflow/gateway/manifests/webhook.yaml @@ -47,4 +47,4 @@ validation: adapter: module: leapflow.gateway.adapters.webhook class: WebhookAdapter - dependencies: [aiohttp] + dependencies: [] diff --git a/src/leapflow/tools/name_resolver.py b/src/leapflow/tools/name_resolver.py new file mode 100644 index 0000000..e18f68e --- /dev/null +++ b/src/leapflow/tools/name_resolver.py @@ -0,0 +1,324 @@ +"""Tool registry and name-resolution primitives. + +This module centralizes tool identity, safe aliases, risk metadata, and +structured unknown-tool feedback. It is intentionally execution-free: callers +resolve a tool name here, then execute through their existing dispatch path. +""" + +from __future__ import annotations + +import difflib +from dataclasses import dataclass, field +from typing import Any, Dict, Literal, Mapping, Sequence + +ResolutionStatus = Literal["exact", "alias", "parameter_match", "unknown", "ambiguous"] +ResolutionConfidence = Literal["high", "medium", "low"] +RiskLevel = Literal["read_only", "mutating", "external"] + +_READ_ONLY_TOOLS = { + "file_list", + "file_read", + "time_get", + "env_info", + "text_search", + "skills_list", + "skill_view", + "memory_search", +} +_MUTATING_NAME_SIGNALS = ( + "write", + "replace", + "delete", + "move", + "copy", + "create", + "add", + "send", + "post", + "run", + "shell", + "delegate", +) + +_DEFAULT_ALIASES: Mapping[str, str] = { + "list_dir": "file_list", + "list_directory": "file_list", + "list_files": "file_list", + "directory_list": "file_list", + "file_ls": "file_list", + "ls": "file_list", + "dir": "file_list", + "read_file": "file_read", + "open_file": "file_read", + "cat_file": "file_read", + "view_file": "file_read", + "write_file": "file_write", + "save_file": "file_write", + "search_text": "text_search", + "grep_text": "text_search", + "replace_text": "text_replace", + "execute_command": "shell_run", + "execute_shell": "shell_run", + "execute_shell_command": "shell_run", + "run_command": "shell_run", + "run_shell": "shell_run", + "shell": "shell_run", + "bash": "shell_run", + "terminal_command": "shell_run", +} + + +def tool_alias_key(tool_name: str) -> str: + """Return a stable lookup key for tool-name matching.""" + return tool_name.strip().lower().replace("-", "_").replace(" ", "_") + + +@dataclass(frozen=True) +class ToolSpec: + """Canonical metadata for a registered tool.""" + + name: str + description: str = "" + parameters: frozenset[str] = field(default_factory=frozenset) + required: frozenset[str] = field(default_factory=frozenset) + risk_level: RiskLevel = "read_only" + + +@dataclass(frozen=True) +class ToolResolution: + """Result of resolving an LLM-proposed tool name.""" + + original_name: str + normalized_name: str | None + status: ResolutionStatus + confidence: ResolutionConfidence + reason: str + suggestions: tuple[str, ...] = () + auto_executable: bool = False + risk_level: RiskLevel = "read_only" + + @property + def is_resolved(self) -> bool: + """Return whether the resolution has a canonical target.""" + return self.normalized_name is not None and self.status not in {"unknown", "ambiguous"} + + def to_metadata(self) -> Dict[str, Any]: + """Return compact metadata suitable for TUI logs and trace events.""" + metadata: Dict[str, Any] = { + "original_tool_name": self.original_name, + "tool_resolution_status": self.status, + "tool_resolution_confidence": self.confidence, + "tool_resolution_reason": self.reason, + "tool_risk_level": self.risk_level, + "tool_auto_executable": self.auto_executable, + } + if self.normalized_name is not None: + metadata["normalized_tool_name"] = self.normalized_name + if self.normalized_name != self.original_name: + metadata["alias"] = self.original_name + if self.suggestions: + metadata["tool_suggestions"] = list(self.suggestions) + return metadata + + +@dataclass(frozen=True) +class ToolRegistry: + """Single source of truth for tool identity and resolution metadata.""" + + specs: Mapping[str, ToolSpec] + aliases: Mapping[str, str] = field(default_factory=dict) + + @classmethod + def from_definitions( + cls, + tool_definitions: Sequence[Mapping[str, Any]], + handlers: Mapping[str, Any], + *, + bridge_tools: Sequence[Mapping[str, Any]] = (), + aliases: Mapping[str, str] | None = None, + ) -> "ToolRegistry": + """Build a registry from OpenAI schemas, dispatch handlers, and bridge metadata.""" + bridge_mutates = { + str(tool.get("name", "")).removeprefix("gp_"): bool(tool.get("mutates_state", False)) + for tool in bridge_tools + } + specs: dict[str, ToolSpec] = {} + for definition in tool_definitions: + function = definition.get("function", {}) + name = str(function.get("name") or definition.get("name") or "") + if not name: + continue + parameters_schema = function.get("parameters", {}) or {} + properties = parameters_schema.get("properties", {}) or {} + required = parameters_schema.get("required", []) or [] + specs[name] = ToolSpec( + name=name, + description=str(function.get("description") or definition.get("description") or ""), + parameters=frozenset(str(key) for key in properties.keys()), + required=frozenset(str(key) for key in required), + risk_level=_infer_risk_level(name, bridge_mutates.get(name, False)), + ) + for name in handlers.keys(): + canonical = str(name).removeprefix("gp_") + if canonical and canonical not in specs: + specs[canonical] = ToolSpec( + name=canonical, + risk_level=_infer_risk_level(canonical, bridge_mutates.get(canonical, False)), + ) + alias_source = aliases or _DEFAULT_ALIASES + normalized_aliases = { + tool_alias_key(alias): canonical + for alias, canonical in alias_source.items() + if canonical in specs + } + return cls(specs=specs, aliases=normalized_aliases) + + @property + def tool_names(self) -> tuple[str, ...]: + """Return all canonical tool names sorted for stable feedback.""" + return tuple(sorted(self.specs.keys())) + + def normalize_name(self, tool_name: str, arguments: Mapping[str, Any] | None = None) -> str: + """Return the canonical name when resolution is safely executable.""" + resolution = self.resolve(tool_name, arguments or {}) + return resolution.normalized_name if resolution.auto_executable and resolution.normalized_name else tool_name + + def resolve(self, tool_name: str, arguments: Mapping[str, Any] | None = None) -> ToolResolution: + """Resolve a proposed tool call against canonical tool metadata.""" + original_name = str(tool_name or "") + args = dict(arguments or {}) + key = tool_alias_key(original_name) + if original_name in self.specs: + return self._resolution(original_name, original_name, "exact", "high", "canonical tool name") + if key in self.specs: + canonical = key + return self._resolution(original_name, canonical, "alias", "high", "case or separator normalization") + if key.startswith("gp_") and key[3:] in self.specs: + canonical = key[3:] + return self._resolution(original_name, canonical, "alias", "high", "gp-prefixed bridge alias") + alias_target = self.aliases.get(key) + if alias_target: + return self._resolution(original_name, alias_target, "alias", "high", "declared tool alias") + + parameter_candidates = self._parameter_candidates(original_name, args) + if len(parameter_candidates) == 1: + canonical, confidence, reason = parameter_candidates[0] + spec = self.specs[canonical] + auto = spec.risk_level == "read_only" and confidence in {"high", "medium"} + return ToolResolution( + original_name=original_name, + normalized_name=canonical, + status="parameter_match", + confidence=confidence, + reason=reason, + suggestions=(canonical,), + auto_executable=auto, + risk_level=spec.risk_level, + ) + if len(parameter_candidates) > 1: + suggestions = tuple(candidate[0] for candidate in parameter_candidates[:5]) + return ToolResolution( + original_name=original_name, + normalized_name=None, + status="ambiguous", + confidence="low", + reason="arguments match multiple tool shapes", + suggestions=suggestions, + auto_executable=False, + risk_level="read_only", + ) + + suggestions = self._suggestions(original_name, args) + return ToolResolution( + original_name=original_name, + normalized_name=None, + status="unknown", + confidence="low", + reason="no canonical tool, alias, or safe parameter match", + suggestions=suggestions, + auto_executable=False, + risk_level="read_only", + ) + + def unknown_result(self, resolution: ToolResolution) -> Dict[str, Any]: + """Build structured feedback for unknown or ambiguous tool names.""" + available = self.tool_names[:16] + suggestions = resolution.suggestions or available[:5] + return { + "ok": False, + "error_type": "unknown_tool", + "error": f"Unknown tool: {resolution.original_name}", + "original_tool_name": resolution.original_name, + "normalized_tool_name": resolution.normalized_name, + "resolution_status": resolution.status, + "resolution_confidence": resolution.confidence, + "resolution_reason": resolution.reason, + "suggestions": list(suggestions), + "available_tools": list(available), + "retryable": True, + } + + def _resolution( + self, + original_name: str, + canonical: str, + status: ResolutionStatus, + confidence: ResolutionConfidence, + reason: str, + ) -> ToolResolution: + spec = self.specs[canonical] + auto = confidence == "high" or spec.risk_level == "read_only" + return ToolResolution( + original_name=original_name, + normalized_name=canonical, + status=status, + confidence=confidence, + reason=reason, + suggestions=(canonical,) if canonical != original_name else (), + auto_executable=auto, + risk_level=spec.risk_level, + ) + + def _parameter_candidates( + self, tool_name: str, arguments: Mapping[str, Any]) -> list[tuple[str, ResolutionConfidence, str]]: + keys = {str(key) for key in arguments.keys()} + name_key = tool_alias_key(tool_name) + candidates: list[tuple[str, ResolutionConfidence, str]] = [] + if {"text", "old", "new"}.issubset(keys) and "text_replace" in self.specs: + candidates.append(("text_replace", "high", "text replacement argument shape")) + if {"text", "pattern"}.issubset(keys) and "text_search" in self.specs: + candidates.append(("text_search", "high", "text search argument shape")) + if "command" in keys and "shell_run" in self.specs: + confidence: ResolutionConfidence = "high" if any(token in name_key for token in ("shell", "command", "exec", "bash", "terminal")) else "medium" + candidates.append(("shell_run", confidence, "shell command argument shape")) + if "path" in keys: + if any(token in name_key for token in ("list", "dir", "directory", "ls", "files", "scan")) and "file_list" in self.specs: + candidates.append(("file_list", "high", "directory listing argument shape")) + if any(token in name_key for token in ("read", "open", "view", "cat")) and "file_read" in self.specs: + candidates.append(("file_read", "high", "file read argument shape")) + if "content" in keys and any(token in name_key for token in ("write", "save", "create")) and "file_write" in self.specs: + candidates.append(("file_write", "high", "file write argument shape")) + return candidates + + def _suggestions(self, tool_name: str, arguments: Mapping[str, Any]) -> tuple[str, ...]: + names = list(self.tool_names) + close = difflib.get_close_matches(tool_alias_key(tool_name), names, n=5, cutoff=0.55) + if close: + return tuple(close) + keys = {str(key) for key in arguments.keys()} + shape_matches = [ + spec.name + for spec in self.specs.values() + if keys and keys.issubset(spec.parameters | spec.required) + ] + return tuple(shape_matches[:5]) + + +def _infer_risk_level(name: str, bridge_mutates: bool) -> RiskLevel: + if name.startswith("gateway_") or name.startswith("hub_"): + return "external" + if name in _READ_ONLY_TOOLS and not bridge_mutates: + return "read_only" + if bridge_mutates or any(signal in name for signal in _MUTATING_NAME_SIGNALS): + return "mutating" + return "read_only" diff --git a/src/leapflow/tools/registry_bootstrap.py b/src/leapflow/tools/registry_bootstrap.py index 625ee19..815ea77 100644 --- a/src/leapflow/tools/registry_bootstrap.py +++ b/src/leapflow/tools/registry_bootstrap.py @@ -22,8 +22,9 @@ GATEWAY_BRIDGE_TOOLS, GATEWAY_TOOL_DEFINITIONS, GATEWAY_TOOL_HANDLERS, - set_gateway_server, + set_gateway_server as set_gateway_server, ) +from leapflow.tools.name_resolver import ToolRegistry # ───────────────────────────────────────────────────────────────────── @@ -444,6 +445,13 @@ async def _delegate_task_handler(params: Dict[str, Any]) -> Dict[str, Any]: TOOL_HANDLERS["gp_delegate_task"] = _delegate_task_handler +TOOL_REGISTRY = ToolRegistry.from_definitions( + TOOL_DEFINITIONS, + TOOL_HANDLERS, + bridge_tools=_BRIDGE_TOOLS, +) + + # ───────────────────────────────────────────────────────────────────── # File write approval gate (Protocol-based, injectable) # ───────────────────────────────────────────────────────────────────── diff --git a/tests/test_agent_execution.py b/tests/test_agent_execution.py index cb64367..d5c0a2d 100644 --- a/tests/test_agent_execution.py +++ b/tests/test_agent_execution.py @@ -9,7 +9,13 @@ import pytest from conftest import StubLLM, make_settings -from leapflow.engine.engine import AgentEngine, build_default_registry +from leapflow.engine.engine import ( + AgentEngine, + _normalize_tool_name, + _resolve_tool_name, + _tool_args_metadata, + build_default_registry, +) from leapflow.engine.graph_planner import GraphPlanner from leapflow.engine.intent_classifier import Intent from leapflow.engine.scheduler import TaskScheduler @@ -115,6 +121,167 @@ async def test_react_loop_tool_then_answer() -> None: lt.close() +@pytest.mark.asyncio +async def test_tool_aliases_are_normalized_before_execution() -> None: + """Common LLM tool-name drift should route through canonical tool handlers.""" + with tempfile.TemporaryDirectory() as td: + settings = make_settings(td) + from leapflow.platform.mock import MockBridge + + rpc = MockBridge() + llm = StubLLM([]) + wm = WorkingMemoryProvider(max_tokens=1024) + lt = SemanticMemoryProvider(source=settings.duckdb_path) + imm = EpisodicMemoryProvider() + captured: dict[str, object] = {} + + async def file_list_handler(args): + captured["args"] = args + return {"ok": True, "path": args.get("path", ""), "entries": []} + + try: + reg = build_default_registry(rpc, llm, wm, lt) + classifier = _FixedClassifier("complex") + engine = AgentEngine(settings, rpc, llm, wm, lt, imm, reg, classifier) + engine._tool_bridge = None + + result = await engine._execute_general_tool( + {"name": "list_directory", "arguments": {"path": "."}}, + {"file_list": file_list_handler}, + ) + metadata = _tool_args_metadata( + "file_list", + {"path": "."}, + original_tool_name="list_directory", + ) + + assert result["ok"] is True + assert captured["args"] == {"path": "."} + assert _normalize_tool_name("list_directory") == "file_list" + assert _normalize_tool_name("List-Directory") == "file_list" + assert _normalize_tool_name("list files") == "file_list" + assert _normalize_tool_name("read_file") == "file_read" + assert _normalize_tool_name("write_file") == "file_write" + assert _normalize_tool_name("execute_command") == "shell_run" + assert _normalize_tool_name("execute shell command") == "shell_run" + assert _normalize_tool_name("terminal-command") == "shell_run" + directory_resolution = _resolve_tool_name("directory_scan", {"path": "."}) + risky_resolution = _resolve_tool_name("please_do", {"command": "ls -la"}) + assert directory_resolution.normalized_name == "file_list" + assert directory_resolution.status == "parameter_match" + assert directory_resolution.auto_executable is True + assert risky_resolution.normalized_name == "shell_run" + assert risky_resolution.status == "parameter_match" + assert risky_resolution.auto_executable is False + assert metadata["original_tool_name"] == "list_directory" + assert metadata["normalized_tool_name"] == "file_list" + assert metadata["alias"] == "list_directory" + finally: + lt.close() + + +@pytest.mark.asyncio +async def test_unknown_tool_returns_structured_retry_feedback() -> None: + """Unknown tools should produce structured feedback instead of a bare string.""" + with tempfile.TemporaryDirectory() as td: + settings = make_settings(td) + from leapflow.platform.mock import MockBridge + + rpc = MockBridge() + llm = StubLLM([]) + wm = WorkingMemoryProvider(max_tokens=1024) + lt = SemanticMemoryProvider(source=settings.duckdb_path) + imm = EpisodicMemoryProvider() + try: + reg = build_default_registry(rpc, llm, wm, lt) + classifier = _FixedClassifier("complex") + engine = AgentEngine(settings, rpc, llm, wm, lt, imm, reg, classifier) + + result = await engine._execute_general_tool( + {"name": "missing_magic_tool", "arguments": {"foo": "bar"}}, + {}, + ) + + assert result["ok"] is False + assert result["error_type"] == "unknown_tool" + assert result["original_tool_name"] == "missing_magic_tool" + assert result["retryable"] is True + assert "available_tools" in result + assert "suggestions" in result + finally: + lt.close() + + +@pytest.mark.asyncio +async def test_unknown_tool_triggers_single_self_healing_retry() -> None: + """The loop should give the LLM one structured chance to retry an unknown tool.""" + class CaptureLLM(StubLLM): + def __init__(self) -> None: + super().__init__([ + '{"name": "missing_magic_tool", "arguments": {"foo": "bar"}}', + "recovered answer", + ]) + self.seen_messages: list[list[dict[str, object]]] = [] + + async def achat(self, messages, *, stream=True, enable_thinking=False, **kwargs): + self.seen_messages.append(list(messages)) + return await super().achat(messages, stream=stream, enable_thinking=enable_thinking, **kwargs) + + with tempfile.TemporaryDirectory() as td: + settings = make_settings(td) + from leapflow.platform.mock import MockBridge + + rpc = MockBridge() + llm = CaptureLLM() + wm = WorkingMemoryProvider(max_tokens=1024) + lt = SemanticMemoryProvider(source=settings.duckdb_path) + imm = EpisodicMemoryProvider() + try: + reg = build_default_registry(rpc, llm, wm, lt) + classifier = _FixedClassifier("complex") + engine = AgentEngine(settings, rpc, llm, wm, lt, imm, reg, classifier) + + out = await engine.run("Use a missing tool then recover") + + assert out == "recovered answer" + assert llm.call_count == 2 + second_call_messages = "\n".join(str(message.get("content", "")) for message in llm.seen_messages[1]) + assert "unavailable tool name" in second_call_messages + assert "missing_magic_tool" in second_call_messages + assert "Available tools include" in second_call_messages + finally: + lt.close() + +@pytest.mark.asyncio +async def test_text_tool_alias_is_normalized_in_stream_events() -> None: + """Text-mode tool calls should not leak common alias names as unknown tools.""" + tool_reply = '{"name": "list_directory", "arguments": {"path": "."}}' + with tempfile.TemporaryDirectory() as td: + settings = make_settings(td) + from leapflow.platform.mock import MockBridge + + rpc = MockBridge() + llm = StubLLM([tool_reply, "directory checked"]) + wm = WorkingMemoryProvider(max_tokens=1024) + lt = SemanticMemoryProvider(source=settings.duckdb_path) + imm = EpisodicMemoryProvider() + try: + reg = build_default_registry(rpc, llm, wm, lt) + classifier = _FixedClassifier("complex") + engine = AgentEngine(settings, rpc, llm, wm, lt, imm, reg, classifier) + + events = [event async for event in engine.run_stream("List current directory")] + + tool_events = [event for event in events if event.type in {"tool_start", "tool_complete"}] + assert [event.content for event in tool_events] == ["file_list", "file_list"] + assert tool_events[0].metadata["original_tool_name"] == "list_directory" + assert tool_events[0].metadata["normalized_tool_name"] == "file_list" + assert tool_events[1].metadata["ok"] is True + assert "Unknown tool" not in str(tool_events[1].metadata) + finally: + lt.close() + + @pytest.mark.asyncio async def test_dag_execution_end_to_end() -> None: """DAG planner/scheduler: direct invocation produces graph summary.""" diff --git a/tests/test_approval_layer.py b/tests/test_approval_layer.py index 30b52a7..2a653e1 100644 --- a/tests/test_approval_layer.py +++ b/tests/test_approval_layer.py @@ -1,5 +1,6 @@ from __future__ import annotations +import builtins from pathlib import Path import sys import time @@ -117,6 +118,28 @@ async def test_prompt_approval_expired_request_denies(monkeypatch) -> None: assert await prompt_approval(request) == ApprovalDecision.DENY +@pytest.mark.asyncio +async def test_prompt_approval_uses_plain_fallback_prompt(monkeypatch) -> None: + from leapflow.cli import approval_view + from leapflow.cli.approval_view import prompt_approval + from leapflow.security.approval import ApprovalRequest + + prompts: list[str] = [] + monkeypatch.setattr(sys.stdin, "isatty", lambda: True) + monkeypatch.setattr(approval_view, "_render", lambda *_args, **_kwargs: None) + + def fake_input(prompt: str) -> str: + prompts.append(prompt) + return "n" + + monkeypatch.setattr(builtins, "input", fake_input) + + request = ApprovalRequest(category="shell.command", detail="echo hello") + + assert await prompt_approval(request) == ApprovalDecision.DENY + assert prompts == ["Select approval choice: "] + + @pytest.mark.asyncio async def test_orchestrator_persists_deny_always_as_session_grant(tmp_path: Path) -> None: gate = _Gate(ApprovalDecision.DENY_ALWAYS) diff --git a/tests/test_cli_entrypoint.py b/tests/test_cli_entrypoint.py index 790be36..b6eaeaf 100644 --- a/tests/test_cli_entrypoint.py +++ b/tests/test_cli_entrypoint.py @@ -703,6 +703,9 @@ class Client: async def status(self): return {"pid": 1234} + async def shutdown(self): + calls.append("shutdown") + class Console: def __init__(self) -> None: self.systems: list[str] = [] @@ -720,19 +723,20 @@ class Settings: async def yes(prompt: str) -> bool: return True - def record_signal(run_dir, sig): - calls.append((run_dir, sig)) - return True + def record_stop(run_dir, **kwargs): + calls.append((run_dir, kwargs)) + return lifecycle_module.StopDaemonResult(pid=1234, stopped=True) calls = [] monkeypatch.setattr(interactive_module, "_ask_yes_no_default_yes", yes) - monkeypatch.setattr(lifecycle_module, "send_signal", record_signal) + monkeypatch.setattr(lifecycle_module, "stop_daemon", record_stop) console = Console() await interactive_module._prompt_stop_daemon_on_exit(Client(), Settings(), console) - assert calls - assert "Sent SIGTERM" in console.systems[-1] + assert "shutdown" in calls + assert any(isinstance(call, tuple) and call[0] == tmp_path / "run" for call in calls) + assert "leapd stopped" in console.systems[-1] @pytest.mark.asyncio @@ -758,14 +762,14 @@ def warning(self, message: str) -> None: class Settings: profile_dir = tmp_path - def fail_send_signal(*args, **kwargs): + def fail_stop(*args, **kwargs): raise AssertionError("daemon should be kept running") async def no(prompt: str) -> bool: return False monkeypatch.setattr(interactive_module, "_ask_yes_no_default_yes", no) - monkeypatch.setattr(lifecycle_module, "send_signal", fail_send_signal) + monkeypatch.setattr(lifecycle_module, "stop_daemon", fail_stop) console = Console() await interactive_module._prompt_stop_daemon_on_exit(Client(), Settings(), console) @@ -805,11 +809,11 @@ async def default_no(prompt: str) -> bool: prompts.append(prompt) return False - def fail_send_signal(*args, **kwargs): + def fail_stop(*args, **kwargs): raise AssertionError("daemon should be kept running while other clients exist") monkeypatch.setattr(interactive_module, "_ask_yes_no_default_no", default_no) - monkeypatch.setattr(lifecycle_module, "send_signal", fail_send_signal) + monkeypatch.setattr(lifecycle_module, "stop_daemon", fail_stop) console = Console() await interactive_module._prompt_stop_daemon_on_exit(Client(), Settings(), console) diff --git a/tests/test_daemon_rpc.py b/tests/test_daemon_rpc.py index 31931fb..ca0c5e0 100644 --- a/tests/test_daemon_rpc.py +++ b/tests/test_daemon_rpc.py @@ -802,7 +802,94 @@ def test_daemon_runtime_status_prints_diagnostics(capsys) -> None: assert "host_capability: test-cap" in output -def test_daemon_restart_stops_waits_and_starts(monkeypatch, tmp_path) -> None: +def test_stop_daemon_sends_sigterm_waits_and_cleans(monkeypatch, tmp_path) -> None: + import leapflow.daemon.lifecycle as lifecycle_module + + running = lifecycle_module.DaemonInfo( + pid=1234, + sock_path=tmp_path / "run" / "leapd.sock", + start_time=None, + is_running=True, + is_healthy=True, + ) + stopped = lifecycle_module.DaemonInfo( + pid=1234, + sock_path=None, + start_time=None, + is_running=False, + is_healthy=False, + ) + states = [running, stopped] + signals: list[int] = [] + + def discover(run_dir): + return states.pop(0) if states else stopped + + def send_signal(run_dir, sig): + signals.append(sig) + return True + + monkeypatch.setattr(lifecycle_module.DaemonInfo, "discover", staticmethod(discover)) + monkeypatch.setattr(lifecycle_module, "send_signal", send_signal) + monkeypatch.setattr(lifecycle_module, "cleanup_stale", lambda run_dir: True) + + result = lifecycle_module.stop_daemon(tmp_path / "run", timeout_s=1.0) + + assert result.stopped is True + assert result.signal_sent is True + assert result.stale_cleaned is True + assert signals == [lifecycle_module.signal.SIGTERM] + + +def test_stop_daemon_force_escalates_after_timeout(monkeypatch, tmp_path) -> None: + import leapflow.daemon.lifecycle as lifecycle_module + + running = lifecycle_module.DaemonInfo( + pid=1234, + sock_path=tmp_path / "run" / "leapd.sock", + start_time=None, + is_running=True, + is_healthy=False, + ) + signals: list[int] = [] + + monkeypatch.setattr(lifecycle_module.DaemonInfo, "discover", staticmethod(lambda run_dir: running)) + monkeypatch.setattr(lifecycle_module, "send_signal", lambda run_dir, sig: signals.append(sig) or True) + + result = lifecycle_module.stop_daemon( + tmp_path / "run", + timeout_s=0.05, + force=True, + force_timeout_s=0.05, + poll_interval_s=0.01, + ) + + assert result.stopped is False + assert result.timed_out is True + assert result.forced is True + assert signals == [lifecycle_module.signal.SIGTERM, lifecycle_module.signal.SIGKILL] + + +def test_daemon_restart_stops_and_starts(monkeypatch, tmp_path) -> None: + from leapflow.cli.commands import daemon as daemon_module + + calls: list[str] = [] + + class Settings: + profile_dir = tmp_path + + monkeypatch.setattr( + daemon_module, + "_stop", + lambda run_dir, **kwargs: calls.append(f"stop:{kwargs.get('force')}") or 0, + ) + monkeypatch.setattr(daemon_module, "_start", lambda settings, mock_host: calls.append("start") or 0) + + assert daemon_module._restart(Settings(), mock_host=True, force=True) == 0 + assert calls == ["stop:True", "start"] + + +def test_daemon_restart_aborts_when_stop_fails(monkeypatch, tmp_path) -> None: from leapflow.cli.commands import daemon as daemon_module calls: list[str] = [] @@ -810,12 +897,15 @@ def test_daemon_restart_stops_waits_and_starts(monkeypatch, tmp_path) -> None: class Settings: profile_dir = tmp_path - monkeypatch.setattr(daemon_module, "_stop", lambda run_dir: calls.append("stop") or 0) - monkeypatch.setattr(daemon_module, "_wait_stopped", lambda run_dir: calls.append("wait") or True) + monkeypatch.setattr( + daemon_module, + "_stop", + lambda run_dir, **kwargs: calls.append("stop") or 1, + ) monkeypatch.setattr(daemon_module, "_start", lambda settings, mock_host: calls.append("start") or 0) - assert daemon_module._restart(Settings(), mock_host=True) == 0 - assert calls == ["stop", "wait", "start"] + assert daemon_module._restart(Settings(), mock_host=True) == 1 + assert calls == ["stop"] def test_stream_chunk_notification_preserves_event_shape() -> None: diff --git a/tests/test_gateway_adapters.py b/tests/test_gateway_adapters.py new file mode 100644 index 0000000..9acfe56 --- /dev/null +++ b/tests/test_gateway_adapters.py @@ -0,0 +1,234 @@ +from __future__ import annotations + +from typing import Any, Mapping + +import pytest + +from leapflow.gateway.adapters.api_server import APIServerAdapter +from leapflow.gateway.adapters.common import JsonBody, UrlLibJsonHttpClient, post_json_for_test +from leapflow.gateway.adapters.dingtalk import DingTalkAdapter +from leapflow.gateway.adapters.feishu import FeishuAdapter +from leapflow.gateway.adapters.telegram import TelegramAdapter +from leapflow.gateway.adapters.webhook import WebhookAdapter +from leapflow.gateway.manifest import ManifestLoader +from leapflow.gateway.protocol import InboundMessage, OutboundContent, SendTarget +from leapflow.gateway.server import GatewayServer + + +class FakeJsonHttpClient: + def __init__(self, responses: Mapping[str, tuple[int, JsonBody]]) -> None: + self.responses = dict(responses) + self.requests: list[dict[str, Any]] = [] + + async def request_json( + self, + method: str, + url: str, + *, + json_body: Mapping[str, Any] | None = None, + headers: Mapping[str, str] | None = None, + timeout_s: float = 10.0, + ) -> tuple[int, JsonBody]: + self.requests.append({ + "method": method, + "url": url, + "json_body": dict(json_body or {}), + "headers": dict(headers or {}), + "timeout_s": timeout_s, + }) + for marker, response in self.responses.items(): + if marker in url: + return response + return 200, {"ok": True} + + +def test_builtin_manifest_adapter_modules_are_importable() -> None: + manifests = ManifestLoader().discover() + credentials = { + "api_server": {"api_key": "0123456789abcdef"}, + "webhook": {"webhook_secret": ""}, + "telegram": {"bot_token": "token"}, + "feishu": {"app_id": "app", "app_secret": "secret"}, + "dingtalk": {"app_key": "key", "app_secret": "secret"}, + } + + for platform_id in {"api_server", "webhook", "telegram", "feishu", "dingtalk"}: + manifest = manifests[platform_id] + adapter = GatewayServer._instantiate_adapter( + manifest, + credentials[platform_id], + {"auto_poll": False, "port": 0}, + ) + assert adapter.platform_id == platform_id + + +@pytest.mark.asyncio +async def test_webhook_adapter_http_post_emits_message() -> None: + received: list[InboundMessage] = [] + adapter = WebhookAdapter(port=0) + adapter.on_message = received.append + + await adapter.connect() + try: + status, data = await post_json_for_test(adapter.local_url, { + "text": "hello webhook", + "chat_id": "chat-1", + "user_id": "user-1", + "chat_type": "group", + }) + finally: + await adapter.disconnect() + + assert status == 202 + assert data["ok"] is True + assert received[0].text == "hello webhook" + assert received[0].source.platform == "webhook" + assert received[0].source.chat_id == "chat-1" + assert received[0].source.user_id == "user-1" + + +@pytest.mark.asyncio +async def test_api_server_adapter_accepts_openai_chat_completion() -> None: + received: list[InboundMessage] = [] + adapter = APIServerAdapter(api_key="0123456789abcdef", port=0) + adapter.on_message = received.append + client = UrlLibJsonHttpClient() + + await adapter.connect() + try: + status, data = await client.request_json( + "POST", + f"{adapter.local_url}/v1/chat/completions", + json_body={ + "model": "leapflow-test", + "user": "api-user", + "messages": [ + {"role": "system", "content": "ignore"}, + {"role": "user", "content": "hello api"}, + ], + }, + headers={"Authorization": "Bearer 0123456789abcdef"}, + ) + finally: + await adapter.disconnect() + + assert status == 200 + assert data["object"] == "chat.completion" + assert received[0].text == "hello api" + assert received[0].source.platform == "api_server" + assert received[0].source.chat_type == "api" + + +@pytest.mark.asyncio +async def test_telegram_adapter_send_and_update_normalization() -> None: + fake_http = FakeJsonHttpClient({"sendMessage": (200, {"ok": True, "result": {"message_id": 42}})}) + received: list[InboundMessage] = [] + adapter = TelegramAdapter(bot_token="token", auto_poll=False, http_client=fake_http) + adapter.on_message = received.append + + await adapter.connect() + result = await adapter.send( + SendTarget(platform="telegram", chat_id="100", reply_to_id="7"), + OutboundContent(text="x" * 5000), + ) + await adapter.handle_update({ + "update_id": 7, + "message": { + "message_id": 8, + "text": "incoming", + "chat": {"id": 100, "type": "private"}, + "from": {"id": 200, "username": "alice"}, + }, + }) + await adapter.disconnect() + + assert result.ok is True + assert result.message_id == "42,42" + assert len(fake_http.requests) == 2 + assert len(fake_http.requests[0]["json_body"]["text"]) == adapter.max_message_length + assert len(fake_http.requests[1]["json_body"]["text"]) == 5000 - adapter.max_message_length + assert fake_http.requests[0]["json_body"]["reply_to_message_id"] == "7" + assert "reply_to_message_id" not in fake_http.requests[1]["json_body"] + assert received[0].source.chat_type == "dm" + assert received[0].text == "incoming" + + +@pytest.mark.asyncio +async def test_feishu_adapter_connect_send_and_event_normalization() -> None: + fake_http = FakeJsonHttpClient({ + "tenant_access_token": (200, {"code": 0, "tenant_access_token": "tenant-token"}), + "/im/v1/messages": (200, {"code": 0, "data": {"message_id": "om_1"}}), + }) + received: list[InboundMessage] = [] + adapter = FeishuAdapter(app_id="app", app_secret="secret", port=0, http_client=fake_http) + adapter.on_message = received.append + + await adapter.connect() + try: + result = await adapter.send( + SendTarget(platform="feishu", chat_id="oc_1"), + OutboundContent(text="hello feishu"), + ) + status, data = await post_json_for_test(adapter.local_url, { + "event": { + "message": { + "message_id": "om_in", + "chat_id": "oc_1", + "chat_type": "group", + "content": "{\"text\": \"incoming feishu\"}", + }, + "sender": {"sender_id": {"open_id": "ou_1"}, "sender_type": "user"}, + }, + }) + finally: + await adapter.disconnect() + + assert result.ok is True + assert status == 202 + assert data["ok"] is True + assert result.message_id == "om_1" + assert fake_http.requests[1]["headers"]["Authorization"] == "Bearer tenant-token" + assert received[0].text == "incoming feishu" + assert received[0].source.user_id == "ou_1" + + +@pytest.mark.asyncio +async def test_dingtalk_adapter_connect_send_and_event_normalization() -> None: + fake_http = FakeJsonHttpClient({ + "/v1.0/oauth2/accessToken": (200, {"errcode": 0, "accessToken": "access-token"}), + "/topapi/robot/send": (200, {"errcode": 0, "task_id": "task-1"}), + }) + received: list[InboundMessage] = [] + adapter = DingTalkAdapter( + app_key="key", + app_secret="secret", + robot_code="robot", + port=0, + http_client=fake_http, + ) + adapter.on_message = received.append + + await adapter.connect() + try: + result = await adapter.send( + SendTarget(platform="dingtalk", chat_id="cid-1"), + OutboundContent(text="hello dingtalk"), + ) + status, data = await post_json_for_test(adapter.local_url, { + "conversationId": "cid-1", + "conversationType": "2", + "senderStaffId": "staff-1", + "senderNick": "Bob", + "msgId": "msg-1", + "text": {"content": "incoming dingtalk"}, + }) + finally: + await adapter.disconnect() + + assert result.ok is True + assert status == 202 + assert data["ok"] is True + assert result.message_id == "task-1" + assert fake_http.requests[1]["json_body"]["robotCode"] == "robot" + assert received[0].source.chat_type == "group" + assert received[0].text == "incoming dingtalk" diff --git a/tests/test_gateway_tool_e2e.py b/tests/test_gateway_tool_e2e.py new file mode 100644 index 0000000..7456294 --- /dev/null +++ b/tests/test_gateway_tool_e2e.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +import pytest + +from leapflow.gateway.protocol import OutboundContent, SendResult, SendTarget +from leapflow.gateway.server import GatewayServer +from leapflow.tools.gateway_tool import ( + gateway_connect_handler, + gateway_send_handler, + set_gateway_approval_gate, + set_gateway_server, +) + + +@pytest.mark.asyncio +async def test_gateway_connect_tool_can_connect_builtin_webhook(tmp_path) -> None: + server = GatewayServer(tmp_path) + server.discover_manifests() + set_gateway_server(server) + + try: + listed = await gateway_connect_handler({"action": "list"}) + assert listed["ok"] is True + assert {entry["id"] for entry in listed["platforms"]} >= {"webhook", "api_server"} + + guide = await gateway_connect_handler({"action": "guide", "platform": "webhook"}) + assert guide["ok"] is True + assert guide["platform"] == "Webhook (Generic)" + assert "setup_form" in guide + + connected = await gateway_connect_handler({ + "action": "connect", + "platform": "webhook", + "credentials": {"webhook_secret": ""}, + "options": {"host": "127.0.0.1", "port": 0, "path": "/webhook"}, + }) + assert connected["ok"] is True + assert connected["status"] == "connected" + + status = await gateway_connect_handler({"action": "status", "platform": "webhook"}) + assert status["ok"] is True + assert status["connected"] is True + finally: + await server.stop() + set_gateway_approval_gate(None) + set_gateway_server(None) + + +class FakeSendAdapter: + platform_id = "fake" + supports_async_delivery = True + splits_long_messages = False + max_message_length = 0 + + def __init__(self) -> None: + self.sent: list[tuple[SendTarget, OutboundContent]] = [] + + async def connect(self, *, is_reconnect: bool = False) -> None: + pass + + async def disconnect(self) -> None: + pass + + async def send(self, target: SendTarget, content: OutboundContent) -> SendResult: + self.sent.append((target, content)) + return SendResult(ok=True, message_id="fake-1") + + +class DenyGate: + async def evaluate(self, action): + class Result: + approved = False + denial_message = "denied for test" + + return Result() + + +@pytest.mark.asyncio +async def test_gateway_send_tool_dispatches_to_connected_adapter(tmp_path) -> None: + server = GatewayServer(tmp_path) + adapter = FakeSendAdapter() + server._adapters["fake"] = adapter + set_gateway_server(server) + set_gateway_approval_gate(None) + + try: + result = await gateway_send_handler({ + "platform": "fake", + "chat_id": "chat-1", + "text": "hello outbound", + "thread_id": "thread-1", + }) + finally: + set_gateway_approval_gate(None) + set_gateway_server(None) + + assert result == {"ok": True, "message_id": "fake-1"} + target, content = adapter.sent[0] + assert target.chat_id == "chat-1" + assert target.thread_id == "thread-1" + assert content.text == "hello outbound" + + +@pytest.mark.asyncio +async def test_gateway_send_tool_honors_approval_denial(tmp_path) -> None: + server = GatewayServer(tmp_path) + adapter = FakeSendAdapter() + server._adapters["fake"] = adapter + set_gateway_server(server) + set_gateway_approval_gate(DenyGate()) + + try: + result = await gateway_send_handler({ + "platform": "fake", + "chat_id": "chat-1", + "text": "blocked outbound", + }) + finally: + set_gateway_approval_gate(None) + set_gateway_server(None) + + assert result == {"ok": False, "error": "denied for test"} + assert adapter.sent == [] diff --git a/tests/test_tui_command_queue.py b/tests/test_tui_command_queue.py index a22a1fc..4e949c9 100644 --- a/tests/test_tui_command_queue.py +++ b/tests/test_tui_command_queue.py @@ -7,6 +7,7 @@ import pytest import leapflow.cli.tui_app.app as app_module +from leapflow.security.approval import ApprovalDecision, ApprovalRequest from prompt_toolkit.auto_suggest import Suggestion from prompt_toolkit.completion import Completion from prompt_toolkit.document import Document @@ -78,6 +79,81 @@ def _make_app( return app, console, status +@pytest.mark.asyncio +async def test_tui_approval_modal_is_keyboard_selectable() -> None: + app, _console, _status = _make_app() + request = ApprovalRequest( + category="shell.command", + detail="python -m pip install aiohttp", + default_choice="allow_once", + ) + + task = asyncio.create_task(app.request_approval(request)) + await _wait_for(lambda: app._approval_modal is not None) + modal = app._approval_modal + assert modal is not None + assert modal.selected_index == 0 + + modal.move(1) + assert modal.selected_index == 1 + fragments = modal.fragments() + rendered = "".join(text for _style, text in fragments) + assert "→" not in rendered + assert "╭" in rendered and "╮" in rendered + assert "╰" in rendered and "╯" in rendered + assert "▶ 2. Allow for this session" in rendered + + modal.choose_selected() + assert await task == ApprovalDecision.ALLOW_SESSION + assert app._approval_modal is None + + +@pytest.mark.asyncio +async def test_tui_approval_modal_preserves_frame_and_choices_in_small_height() -> None: + request = ApprovalRequest( + category="shell.command", + detail="python3 -m pip install aiohttp requests --user --quiet 2>&1\n" * 5, + choices=("allow_once", "allow_session", "allow_always", "deny"), + display={ + "summary": "Run shell command", + "reason": "This shell command has side effects or reaches external systems.", + }, + ) + modal = app_module.ApprovalModal.create(request) + + rendered = "".join(text for _style, text in modal.fragments(max_height=14)) + lines = [line for line in rendered.splitlines() if line] + + assert len(lines) <= 14 + assert lines[0].startswith("╭") and lines[0].endswith("╮") + assert lines[-1].startswith("╰") and lines[-1].endswith("╯") + assert "1. Allow once" in rendered + assert "2. Allow for this session" in rendered + assert "3. Add to permanent allowlist" in rendered + assert "4. Deny" in rendered + + +@pytest.mark.asyncio +async def test_tui_approval_modal_shortcuts_and_details() -> None: + app, _console, _status = _make_app() + request = ApprovalRequest( + category="gateway_send", + detail="send external message\n" * 8, + choices=("allow_once", "show_details", "deny"), + ) + + task = asyncio.create_task(app.request_approval(request)) + await _wait_for(lambda: app._approval_modal is not None) + modal = app._approval_modal + assert modal is not None + + assert modal.choose_text("2") is True + assert modal.show_details is True + assert task.done() is False + assert modal.choose_text("n") is True + assert await task == ApprovalDecision.DENY + + def test_submit_text_assigns_ids_and_keeps_input_editable() -> None: app, console, status = _make_app() @@ -328,6 +404,47 @@ def newline(self) -> None: assert " | " in line +def test_stream_renderer_prints_normalized_tool_name_with_alias_hint() -> None: + class CaptureConsole: + def __init__(self) -> None: + self.lines: list[str] = [] + + def print(self, renderable) -> None: + self.lines.append(getattr(renderable, "plain", str(renderable))) + + def thinking(self, text: str) -> None: + pass + + def markdown(self, text: str, *, indent: int = 0, margin_top: int = 0) -> None: + pass + + def response_label(self, elapsed_s: float, *, tool_count: int = 0) -> None: + pass + + def newline(self) -> None: + pass + + console = CaptureConsole() + renderer = StreamRenderer(console) # type: ignore[arg-type] + renderer.start() + metadata = { + "ok": True, + "path": "/tmp/demo", + "original_tool_name": "list_directory", + "normalized_tool_name": "file_list", + } + + spinner = renderer.tool_started("list_directory", metadata=metadata) + renderer.tool_finished("list_directory", metadata=metadata) + + assert spinner == "📁 file_list" + assert len(console.lines) == 1 + line = console.lines[0] + assert "📁 file_list" in line + assert "alias=list_directory" in line + assert "list_directory path=" not in line + + def test_stream_renderer_prints_tool_failure_exit_and_stderr() -> None: class CaptureConsole: def __init__(self) -> None: @@ -590,10 +707,12 @@ def test_placeholder_processor_indents_hint_after_prompt_space() -> None: assert transformed.fragments == [ ("class:prompt", "❯ "), - ("class:placeholder", " "), + ("class:placeholder", " "), ("class:placeholder", "Queue paused · /resume continue"), ] assert sum(text.count("❯") for _style, text in transformed.fragments) == 1 + assert transformed.source_to_display(0) == len("❯ ") + assert transformed.display_to_source(len("❯ Queue paused · /resume continue")) == 0 def test_prompt_prefix_is_preserved_with_placeholder_hint() -> None: @@ -612,10 +731,11 @@ def test_prompt_prefix_is_preserved_with_placeholder_hint() -> None: assert transformed.fragments == [ ("class:prompt", "❯ "), - ("class:placeholder", " "), + ("class:placeholder", " "), ("class:placeholder", app._placeholder_text()), ] assert sum(text.count("❯") for _style, text in transformed.fragments) == 1 + assert transformed.source_to_display(0) == len("❯ ") def test_placeholder_processor_hides_hint_after_user_input() -> None: @@ -632,6 +752,8 @@ def test_placeholder_processor_hides_hint_after_user_input() -> None: transformed = processor.apply_transformation(typed_input) assert transformed.fragments == [("class:prompt", "❯ "), ("", "hello")] + assert transformed.source_to_display(0) == len("❯ ") + assert transformed.display_to_source(len("❯ ") + 1) == 1 def test_control_commands_are_handled_without_queueing() -> None: diff --git a/tests/test_tui_session_summary.py b/tests/test_tui_session_summary.py index 0641a76..bd480d1 100644 --- a/tests/test_tui_session_summary.py +++ b/tests/test_tui_session_summary.py @@ -160,7 +160,7 @@ def test_stream_renderer_spaces_and_indents_final_response_only() -> None: "text": "final **answer**", "indent": 4, "margin_top": 1, - "margin_bottom": 0, + "margin_bottom": 1, }] assert len(console.labels) == 1 assert console.answer_labels == 1 diff --git a/tests/test_tui_theme.py b/tests/test_tui_theme.py index 675872e..88b4ed7 100644 --- a/tests/test_tui_theme.py +++ b/tests/test_tui_theme.py @@ -144,6 +144,47 @@ def test_leap_app_style_builder_accepts_resolved_theme(tmp_path) -> None: assert app._input_area.window.dont_extend_height() is True +def test_leap_app_layout_keeps_status_breathing_gap(tmp_path) -> None: + theme = resolve_theme(_DARK, terminal_bg="#102A2E") + app = LeapApp( + console=None, + theme=theme, + status=lambda: [], + data_dir=tmp_path, + on_input=None, + ) + + root = app._app.layout.container.content + children = root.children + + assert len(children) == 4 + assert children[1].style == "class:status-gap" + assert children[1].height == 1 + assert app._build_style().get_attrs_for_style_str("class:status-gap").bgcolor == "" + assert children[2].style == "class:status-bar" + assert children[3] is app._input_area.window + + +def test_console_system_supports_visual_spacing() -> None: + class CaptureConsole: + def __init__(self) -> None: + self.calls: list[tuple[tuple[object, ...], dict[str, object]]] = [] + + def print(self, *args, **kwargs) -> None: + self.calls.append((args, kwargs)) + + console = LeapConsole(resolve_theme(_LIGHT, terminal_bg="#FFFFFF")) + capture = CaptureConsole() + console._console = capture # type: ignore[assignment] + + console.system("After reinstalling LeapFlow, use `leap daemon restart`.", margin_bottom=1) + + assert capture.calls == [ + ((" After reinstalling LeapFlow, use `leap daemon restart`.",), {"style": "leap.dim"}), + ((), {}), + ] + + def test_rich_banner_accepts_resolved_theme(capsys) -> None: theme = resolve_theme(_LIGHT, terminal_bg="#FFFFFF") From e6e7839fc8036094d383cd749df6fffc39fd843a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8F=AD=E6=89=AC?= Date: Mon, 13 Jul 2026 11:04:54 +0800 Subject: [PATCH 17/17] fix(tui): approval modal display truncated to 3-4 lines Root cause: the modal's height budget calculation went negative when there were 6 choices (fixed_count=13 > min_height=10), causing _preserve_frame_and_choices to slice out all middle content. Fix: - Remove complex height-based truncation from ApprovalModal.fragments() - Use static content caps (_DETAIL_LINES=3, _REASON_LINES=2) instead - Let the prompt_toolkit Window handle clipping via dont_extend_height - Add line_count() for dynamic Window height sizing - Add bottom=2 to Float constraint to prevent status bar overlap - All content sections now always render: summary, detail, reason, keyboard hints, and all choice options Co-authored-by: Cursor --- src/leapflow/cli/tui_app/app.py | 12 +- src/leapflow/cli/tui_app/approval_modal.py | 201 +++++++++------------ tests/test_tui_command_queue.py | 5 +- 3 files changed, 96 insertions(+), 122 deletions(-) diff --git a/src/leapflow/cli/tui_app/app.py b/src/leapflow/cli/tui_app/app.py index 84cae33..d3bfc43 100644 --- a/src/leapflow/cli/tui_app/app.py +++ b/src/leapflow/cli/tui_app/app.py @@ -747,8 +747,9 @@ def _build_application(self) -> Application[Any]: approval_overlay = ConditionalContainer( Window( content=FormattedTextControl(self._approval_fragments), - height=Dimension(min=10, max=24, preferred=24), + height=self._approval_height, wrap_lines=False, + dont_extend_height=True, style="class:approval.modal", ), filter=Condition(lambda: self._approval_modal is not None), @@ -767,7 +768,8 @@ def _build_application(self) -> Application[Any]: ), ), Float( - top=1, + top=0, + bottom=2, left=2, right=2, content=approval_overlay, @@ -997,6 +999,12 @@ def _approval_fragments(self) -> list[tuple[str, str]]: return [] return modal.fragments() + def _approval_height(self) -> int: + modal = self._approval_modal + if modal is None: + return 0 + return modal.line_count() + def _spinner_fragments(self) -> list[tuple[str, str]]: if not self._spinner_text: return [] diff --git a/src/leapflow/cli/tui_app/approval_modal.py b/src/leapflow/cli/tui_app/approval_modal.py index ac977c5..f8ec5a9 100644 --- a/src/leapflow/cli/tui_app/approval_modal.py +++ b/src/leapflow/cli/tui_app/approval_modal.py @@ -1,4 +1,9 @@ -"""Prompt-toolkit native approval modal for LeapFlow TUI.""" +"""Prompt-toolkit native approval modal for LeapFlow TUI. + +Renders a bordered panel with action summary, detail, risk reason, +and selectable choices — fully within the prompt_toolkit layout. +Keyboard: ↑/↓ navigate, Enter confirm, Esc deny, or type shortcut keys. +""" from __future__ import annotations import asyncio @@ -22,10 +27,8 @@ _MIN_WIDTH = 58 _MAX_WIDTH = 104 -_MIN_HEIGHT = 10 -_MAX_HEIGHT = 24 -_MAX_DETAIL_LINES = 3 -_MAX_REASON_LINES = 2 +_DETAIL_LINES = 3 +_REASON_LINES = 2 @dataclass @@ -39,7 +42,7 @@ class ApprovalModal: future: asyncio.Future[ApprovalDecision] @classmethod - def create(cls, request: ApprovalRequest) -> "ApprovalModal": + def create(cls, request: ApprovalRequest) -> ApprovalModal: choices = build_approval_choices(request) selected_index = _default_index(choices, request.default_choice) future = asyncio.get_running_loop().create_future() @@ -53,24 +56,20 @@ def create(cls, request: ApprovalRequest) -> "ApprovalModal": @property def done(self) -> bool: - """Return whether the user has already made a decision.""" return self.future.done() def move(self, delta: int) -> None: - """Move the selected option by ``delta`` rows.""" if not self.choices: return self.selected_index = (self.selected_index + delta) % len(self.choices) def choose_selected(self) -> None: - """Accept the currently selected option.""" if not self.choices: self.resolve(ApprovalDecision.DENY) return self._choose(self.choices[self.selected_index]) def choose_text(self, text: str) -> bool: - """Resolve a typed shortcut or numeric choice.""" choice = resolve_approval_choice(text.strip().lower(), self.choices) if choice is None: return False @@ -78,94 +77,105 @@ def choose_text(self, text: str) -> bool: return True def deny(self) -> None: - """Fail closed when the user cancels or the request expires.""" self.resolve(ApprovalDecision.DENY) def resolve(self, decision: ApprovalDecision) -> None: - """Resolve the modal future once.""" if not self.future.done(): self.future.set_result(decision) - def fragments(self, *, max_height: int | None = None) -> list[Fragment]: - """Render the modal as a complete bordered component.""" + def fragments(self) -> list[Fragment]: + """Build all fragments for the modal — no height truncation. + + The prompt_toolkit Window handles clipping/scrolling. + Content lines are limited by static caps (_DETAIL_LINES, + _REASON_LINES) to keep the panel concise. + """ width = _modal_width() - height = _modal_height(max_height) - inner_width = width - 4 + inner = width - 4 title = f"⚠ {title_for_approval(self.request)}" + lines: list[list[Fragment]] = [] + + # ── Top border ── lines.append(_border_top(width, title)) + # ── Summary ── summary = str(self.request.display.get("summary") or self.request.category) - lines.extend(_limited_content_lines( - _wrap(summary, inner_width), - inner_width, - "class:approval.summary", - limit=1, - )) + for text in _wrap(summary, inner)[:2]: + lines.append(_content_line(text, inner, "class:approval.summary")) - reason = str(self.request.display.get("reason") or risk_reason(self.request)) - remaining = remaining_seconds(self.request) - fixed_count = ( - 1 # top border - + 1 # summary - + 1 # action detail label - + (1 if reason else 0) # reason label - + (1 if remaining is not None else 0) - + 1 # keyboard hint - + len(self.choices) - + 1 # bottom border - ) - content_budget = max(2, height - fixed_count) - detail_budget = min(_MAX_DETAIL_LINES, max(1, content_budget // 2)) - reason_budget = min(_MAX_REASON_LINES, max(0, content_budget - detail_budget)) + # ── Blank separator ── + lines.append(_content_line("", inner, "")) - lines.append(_content_line("Action detail:", inner_width, "class:approval.label")) - detail = redact_sensitive_text(self.request.detail, force=True) + # ── Detail ── + detail_raw = redact_sensitive_text(self.request.detail, force=True) if not self.show_details: - detail = truncate_detail(detail, max_lines=detail_budget, width=inner_width - 2) - detail_lines: list[str] = [] - for raw_line in detail.splitlines() or [""]: - detail_lines.extend(_wrap(raw_line, inner_width - 2)) - lines.extend(_limited_content_lines( - [f" {line}" for line in detail_lines], - inner_width, - "class:approval.detail", - limit=detail_budget, - )) - + detail_raw = truncate_detail( + detail_raw, max_lines=_DETAIL_LINES, width=inner - 4, + ) + detail_wrapped: list[str] = [] + for raw_line in detail_raw.splitlines() or [""]: + detail_wrapped.extend(_wrap(raw_line, inner - 4)) + detail_limit = 50 if self.show_details else _DETAIL_LINES + for text in detail_wrapped[:detail_limit]: + lines.append(_content_line(f" {text}", inner, "class:approval.detail")) + + # ── Risk reason ── + reason = str(self.request.display.get("reason") or risk_reason(self.request)) if reason: - lines.append(_content_line("Why approval is needed:", inner_width, "class:approval.label")) - reason_lines = [f"- {line}" for line in _wrap(reason, inner_width - 2)] - lines.extend(_limited_content_lines( - reason_lines, - inner_width, - "class:approval.dim", - limit=reason_budget, + lines.append(_content_line("", inner, "")) + lines.append(_content_line( + "Why approval is needed:", inner, "class:approval.label", )) + for text in _wrap(reason, inner - 4)[:_REASON_LINES]: + lines.append(_content_line(f" {text}", inner, "class:approval.dim")) + # ── Timeout ── + remaining = remaining_seconds(self.request) if remaining is not None: lines.append(_content_line( - f"Defaults to Deny in {int(remaining)}s.", - inner_width, + f" Auto-deny in {int(remaining)}s", + inner, "class:approval.dim", )) - lines.append(_content_line("↑/↓ choose · Enter confirm · Esc deny", inner_width, "class:approval.dim")) - for idx, choice in enumerate(self.choices, start=1): - selected = idx - 1 == self.selected_index - marker = "▶" if selected else " " - text = f" {marker} {idx}. {choice.label}" + # ── Separator + keyboard hint ── + lines.append(_content_line("", inner, "")) + lines.append(_content_line( + " ↑↓ navigate · Enter confirm · Esc deny", + inner, + "class:approval.dim", + )) + + # ── Choices ── + for idx, choice in enumerate(self.choices): + selected = idx == self.selected_index + marker = "▸" if selected else " " + label = f" {marker} {idx + 1}. {choice.label}" style = "class:approval.selected" if selected else "class:approval.option" - lines.append(_content_line(text, inner_width, style)) + lines.append(_content_line(label, inner, style)) + # ── Bottom border ── lines.append(_border_bottom(width)) - if len(lines) > height: - lines = _preserve_frame_and_choices(lines, height, len(self.choices)) - fragments: list[Fragment] = [] + + # ── Flatten to fragment list ── + result: list[Fragment] = [] for line in lines: - fragments.extend(line) - fragments.append(("", "\n")) - return fragments + result.extend(line) + result.append(("", "\n")) + return result + + def line_count(self) -> int: + """Return the number of content lines (for Window height sizing).""" + count = 1 + 2 + 1 # top border, summary(+blank), blank + count += min(_DETAIL_LINES, 3) + reason = str(self.request.display.get("reason") or risk_reason(self.request)) + if reason: + count += 1 + 1 + min(_REASON_LINES, 2) + if self.request.expires_at is not None: + count += 1 + count += 1 + 1 + len(self.choices) + 1 # blank, hint, choices, bottom + return count def _choose(self, choice: ApprovalChoice) -> None: if choice.key == "show_details": @@ -186,13 +196,6 @@ def _modal_width() -> int: return min(_MAX_WIDTH, max(_MIN_WIDTH, columns - 6)) -def _modal_height(max_height: int | None = None) -> int: - if max_height is not None: - return max(_MIN_HEIGHT, max_height) - rows = shutil.get_terminal_size((100, 24)).lines - return min(_MAX_HEIGHT, max(_MIN_HEIGHT, rows - 5)) - - def _wrap(text: str, width: int) -> list[str]: return textwrap.wrap( text, @@ -202,49 +205,15 @@ def _wrap(text: str, width: int) -> list[str]: ) or [""] -def _limited_content_lines( - lines: list[str], - width: int, - style: str, - *, - limit: int, -) -> list[list[Fragment]]: - if limit <= 0: - return [] - visible = list(lines[:limit]) or [""] - if len(lines) > limit: - visible[-1] = _with_ellipsis(visible[-1], width) - return [_content_line(line, width, style) for line in visible] - - -def _with_ellipsis(text: str, width: int) -> str: - if width <= 1: - return "…" - if len(text) >= width: - return text[: width - 1] + "…" - return f"{text} …" - - -def _preserve_frame_and_choices( - lines: list[list[Fragment]], - height: int, - choice_count: int, -) -> list[list[Fragment]]: - if len(lines) <= height: - return lines - tail_count = choice_count + 1 - head_budget = max(1, height - tail_count) - return [*lines[:head_budget], *lines[-tail_count:]] - - def _border_top(width: int, title: str) -> list[Fragment]: label = f" {title} " + label = label[: max(0, width - 4)] available = max(0, width - 2 - len(label)) left = available // 2 right = available - left return [ ("class:approval.border", "╭" + "─" * left), - ("class:approval.title", label[: max(0, width - 2)]), + ("class:approval.title", label), ("class:approval.border", "─" * right + "╮"), ] @@ -258,13 +227,11 @@ def _content_line(text: str, width: int, style: str = "") -> list[Fragment]: padding = " " * max(0, width - len(clipped)) return [ ("class:approval.border", "│ "), - (style, clipped), - ("", padding), + (style, clipped + padding), ("class:approval.border", " │"), ] def request_is_expired(request: ApprovalRequest) -> bool: - """Return True when an approval request is past its deadline.""" remaining = remaining_seconds(request) return remaining is not None and remaining <= 0.0 diff --git a/tests/test_tui_command_queue.py b/tests/test_tui_command_queue.py index 4e949c9..9cfa684 100644 --- a/tests/test_tui_command_queue.py +++ b/tests/test_tui_command_queue.py @@ -101,7 +101,7 @@ async def test_tui_approval_modal_is_keyboard_selectable() -> None: assert "→" not in rendered assert "╭" in rendered and "╮" in rendered assert "╰" in rendered and "╯" in rendered - assert "▶ 2. Allow for this session" in rendered + assert "▸ 2. Allow for this session" in rendered modal.choose_selected() assert await task == ApprovalDecision.ALLOW_SESSION @@ -121,10 +121,9 @@ async def test_tui_approval_modal_preserves_frame_and_choices_in_small_height() ) modal = app_module.ApprovalModal.create(request) - rendered = "".join(text for _style, text in modal.fragments(max_height=14)) + rendered = "".join(text for _style, text in modal.fragments()) lines = [line for line in rendered.splitlines() if line] - assert len(lines) <= 14 assert lines[0].startswith("╭") and lines[0].endswith("╮") assert lines[-1].startswith("╰") and lines[-1].endswith("╯") assert "1. Allow once" in rendered