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/AGENTS.md b/AGENTS.md index 539c69c..adf6984 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,57 +14,83 @@ 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. +- **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 - **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. +- 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 - 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 - 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/approval_view.py b/src/leapflow/cli/approval_view.py new file mode 100644 index 0000000..b380168 --- /dev/null +++ b/src/leapflow/cli/approval_view.py @@ -0,0 +1,229 @@ +"""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_approval_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("Select approval choice: ").strip().lower(), + ), + timeout=remaining_seconds(request), + ) + except TimeoutError: + return ApprovalDecision.DENY + except (EOFError, KeyboardInterrupt): + return ApprovalDecision.DENY + + selected = resolve_approval_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_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") + 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_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 = { + "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_approval(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_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" + if request.risk.level.value == "critical": + return "Critical Action" + return "Action Approval" + + +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: + return request.risk.explanation + return ", ".join(request.risk.reasons) + + +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) + return remaining is not None and remaining <= 0.0 + + +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 [""]) + if len(wrapped) <= max_lines: + return "\n".join(wrapped) + 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/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/cli.py b/src/leapflow/cli/cli.py index 720d7d2..c3e9865 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" @@ -120,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 @@ -210,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/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/daemon.py b/src/leapflow/cli/commands/daemon.py index a1c4070..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") @@ -66,10 +64,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( @@ -91,6 +94,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: @@ -117,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: @@ -129,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/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/commands/interactive.py b/src/leapflow/cli/commands/interactive.py index 5119594..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 @@ -47,18 +46,46 @@ 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 - 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: @@ -75,6 +102,133 @@ 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" + + +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 + + +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.""" + + 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) @@ -90,7 +244,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, @@ -108,6 +263,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)) @@ -158,9 +314,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, @@ -170,6 +332,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 @@ -277,9 +440,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: @@ -340,11 +506,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: @@ -359,13 +530,33 @@ async def handle_input(text: str) -> None: return if canonical == "help": - _show_help(console) + _show_help(console, runtime="in_process") return if canonical == "status": 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() @@ -387,7 +578,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 @@ -423,10 +614,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 @@ -494,6 +681,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( @@ -507,7 +764,9 @@ async def _after_dispatch(text: str) -> None: else None ), 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) @@ -532,6 +791,7 @@ async def _after_dispatch(text: str) -> None: try: exit_code = await app.run() finally: + ctx.set_approval_handler(None) _print_exit_summary() return exit_code @@ -541,10 +801,17 @@ 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 - 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, @@ -554,22 +821,31 @@ 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() 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", "")) 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( + 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 - nonlocal runtime_context_used, runtime_daemon_pid + 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"): @@ -588,16 +864,40 @@ 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) + + 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", 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, context_max=runtime_context_length, + context_state=runtime_context_state, ) app.prompt_mode = "daemon" @@ -606,7 +906,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, @@ -620,7 +920,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 @@ -631,6 +934,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") @@ -657,70 +961,206 @@ 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 _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 _handle_daemon_approval(event: StreamEvent) -> None: + 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 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), + description="approval resolve", + ) + app.spinner_text = "Thinking…" + + 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) 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: renderer.feed(event.content) 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": - console.system(event.content) + 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) - 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_args) + try: + if action == "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 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 bridge.call( + lambda current_client: current_client.host_stop(), + description="host stop", + ) + elif action == "restart": + console.system("Restarting CuaDriver OS control…") + result = await bridge.call( + lambda current_client: current_client.host_restart(), + description="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 - 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. " @@ -745,6 +1185,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, @@ -752,10 +1269,14 @@ 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: - 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}") @@ -764,7 +1285,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}") @@ -772,10 +1296,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 @@ -794,7 +1320,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 "): @@ -807,13 +1333,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() @@ -877,14 +1404,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 @@ -895,7 +1415,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 @@ -905,32 +1425,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.") @@ -956,9 +1476,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.") @@ -1051,48 +1571,11 @@ 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) -> 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(): @@ -1110,8 +1593,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 9edc1e8..2d5245a 100644 --- a/src/leapflow/cli/commands/registry.py +++ b/src/leapflow/cli/commands/registry.py @@ -11,8 +11,32 @@ from __future__ import annotations -from dataclasses import dataclass, field -from typing import Dict, List, Optional, Sequence, Tuple +from dataclasses import dataclass +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,50 +48,74 @@ 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("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), + + # 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]"), - 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]"), - 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",)), 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=""), 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"), + CommandDef("gateway", "Show connected platforms and gateway status", "Gateway", effect=CommandEffect.EXTERNAL), # Scheduler CommandDef("arm", "Schedule a skill for timed execution", "Scheduler", args_hint=" "), @@ -102,10 +150,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 @@ -114,6 +168,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/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/context.py b/src/leapflow/cli/context.py index f100a61..1ac92e3 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 @@ -61,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 @@ -76,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"), @@ -96,62 +92,34 @@ class _TUIApprovalGate: async def request_approval( self, request: "ApprovalRequest", ) -> "ApprovalDecision": - from leapflow.security.approval import ApprovalDecision - - 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() + 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 - 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"]: @@ -440,7 +408,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: @@ -459,7 +430,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 @@ -471,6 +441,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 @@ -493,9 +467,21 @@ 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 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.""" @@ -733,6 +719,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.""" @@ -776,6 +911,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: @@ -792,6 +930,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]): @@ -806,6 +945,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", @@ -1410,7 +1554,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 +1661,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) @@ -1533,7 +1677,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, @@ -1627,69 +1770,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) @@ -2232,7 +2356,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/cli/tui_app/app.py b/src/leapflow/cli/tui_app/app.py index 0c222d1..d3bfc43 100644 --- a/src/leapflow/cli/tui_app/app.py +++ b/src/leapflow/cli/tui_app/app.py @@ -24,7 +24,7 @@ import asyncio import os import time -from dataclasses import dataclass +from collections import deque from pathlib import Path from typing import ( TYPE_CHECKING, @@ -38,39 +38,129 @@ 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 ConditionalContainer, 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.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.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 ( + PASTE_FRAGMENT_WINDOW_S, + PasteHeuristics, + PasteStore, +) 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 _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" +_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] + 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: 0, + ) + + +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 -@dataclass(frozen=True) -class _PasteBlock: - """Full pasted content stored outside the prompt buffer.""" + def qsize(self) -> int: + return len(self._items) - marker: str - text: str + def snapshot(self) -> list[TuiCommand]: + return list(self._items) class LeapApp: @@ -93,11 +183,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 @@ -105,10 +197,21 @@ 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._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 + 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() + self._pending_input = _CommandQueue() + self._approval_modal: Optional[ApprovalModal] = None data_dir = data_dir or Path( os.environ.get("LEAPFLOW_DATA_DIR", "~/.leapflow") @@ -149,55 +252,207 @@ 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() 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 - 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: + @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 - return text.count("\n") + 1 >= _LARGE_PASTE_LINE_THRESHOLD + if handled: + self._invalidate() + return handled - 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" + 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) 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 +460,155 @@ 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 _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 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.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.""" + 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: @@ -256,6 +639,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 @@ -270,6 +656,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) @@ -277,10 +665,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}") @@ -290,6 +691,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() @@ -302,12 +706,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, @@ -316,6 +717,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) @@ -327,13 +732,51 @@ 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", ) - layout = Layout(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=self._approval_height, + wrap_lines=False, + dont_extend_height=True, + style="class:approval.modal", + ), + filter=Condition(lambda: self._approval_modal is not None), + ) + layout = Layout( + FloatContainer( + content=root, + floats=[ + Float( + xcursor=True, + ycursor=True, + content=CompletionsMenu( + max_height=12, + scroll_offset=1, + display_arrows=True, + ), + ), + Float( + top=0, + bottom=2, + left=2, + right=2, + content=approval_overlay, + ), + ], + ) + ) cursor_kwargs: dict[str, Any] = {} try: @@ -359,20 +802,85 @@ 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): - text = event.app.current_buffer.text.strip() + 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) + 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 = 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) + buffer.reset(append_to_history=not has_compacted_paste) + ref._reset_paste_fragment_window() + + @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() + ref._clear_paste_state() @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.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) + 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) @@ -384,14 +892,23 @@ 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._paste_blocks.clear() + 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 @@ -411,21 +928,41 @@ 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-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}", + "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, + "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}", + "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 ─────────────────────────────────────────── 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 = { @@ -441,6 +978,33 @@ 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 _approval_fragments(self) -> list[tuple[str, str]]: + modal = self._approval_modal + if modal is None: + 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 [] @@ -452,7 +1016,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/approval_modal.py b/src/leapflow/cli/tui_app/approval_modal.py new file mode 100644 index 0000000..f8ec5a9 --- /dev/null +++ b/src/leapflow/cli/tui_app/approval_modal.py @@ -0,0 +1,237 @@ +"""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 +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 +_DETAIL_LINES = 3 +_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 self.future.done() + + def move(self, delta: int) -> None: + if not self.choices: + return + self.selected_index = (self.selected_index + delta) % len(self.choices) + + def choose_selected(self) -> None: + if not self.choices: + self.resolve(ApprovalDecision.DENY) + return + self._choose(self.choices[self.selected_index]) + + def choose_text(self, text: str) -> bool: + choice = resolve_approval_choice(text.strip().lower(), self.choices) + if choice is None: + return False + self._choose(choice) + return True + + def deny(self) -> None: + self.resolve(ApprovalDecision.DENY) + + def resolve(self, decision: ApprovalDecision) -> None: + if not self.future.done(): + self.future.set_result(decision) + + 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() + 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) + for text in _wrap(summary, inner)[:2]: + lines.append(_content_line(text, inner, "class:approval.summary")) + + # ── Blank separator ── + lines.append(_content_line("", inner, "")) + + # ── Detail ── + detail_raw = redact_sensitive_text(self.request.detail, force=True) + if not self.show_details: + 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("", 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" Auto-deny in {int(remaining)}s", + inner, + "class:approval.dim", + )) + + # ── 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(label, inner, style)) + + # ── Bottom border ── + lines.append(_border_bottom(width)) + + # ── Flatten to fragment list ── + result: list[Fragment] = [] + for line in lines: + 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": + 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 _wrap(text: str, width: int) -> list[str]: + return textwrap.wrap( + text, + width=max(20, width), + replace_whitespace=False, + drop_whitespace=False, + ) or [""] + + +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), + ("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: + remaining = remaining_seconds(request) + return remaining is not None and remaining <= 0.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 254f29d..110167f 100644 --- a/src/leapflow/cli/tui_app/console.py +++ b/src/leapflow/cli/tui_app/console.py @@ -12,7 +12,8 @@ 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 from rich.syntax import Syntax @@ -26,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({ @@ -40,10 +51,50 @@ 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, }) +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=False, + 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``. @@ -87,24 +138,27 @@ 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") 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, @@ -113,15 +167,27 @@ def command_card(self, command: TuiCommand) -> None: padding=(0, 1), )) - def markdown(self, text: str, *, code_theme: str = "monokai") -> 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( + md = _TerminalBackgroundMarkdown( text, code_theme=code_theme if self._theme.name == "dark" else "default", ) - self._console.print(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: """Render a standalone code block with syntax highlighting.""" @@ -130,6 +196,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: @@ -137,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") @@ -186,7 +264,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, @@ -206,6 +289,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/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/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 588ece2..82ad984 100644 --- a/src/leapflow/cli/tui_app/stream.py +++ b/src/leapflow/cli/tui_app/stream.py @@ -12,11 +12,240 @@ from __future__ import annotations +import json +import os +import re 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 +_FINAL_RESPONSE_MARGIN_BOTTOM = 1 +_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+" + r"[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: + 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_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, limit=_TOOL_INPUT_LIMIT)}" + path = _metadata_text(metadata, "path") + pattern = _metadata_text(metadata, "pattern") + if path and pattern: + return f"path={_compact_path(path)} pattern={_truncate_detail(pattern, limit=48)}" + if path: + return f"path={_compact_path(path)}" + query = _metadata_text(metadata, "query") + if query: + return f"query={_truncate_detail(query, limit=_TOOL_INPUT_LIMIT)}" + url = _metadata_text(metadata, "url") + if url: + 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: + 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 "" + 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, limit=_TOOL_OUTPUT_LIMIT) 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 and not _looks_like_structured_blob(detail): + return _truncate_detail(detail, limit=_TOOL_OUTPUT_LIMIT) + return "" + + if TYPE_CHECKING: from leapflow.cli.tui_app.console import LeapConsole @@ -53,6 +282,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 @@ -78,6 +308,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 @@ -87,27 +318,58 @@ 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) -> 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 + 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"⚡ {name}" + return f"{_tool_icon(tool_name)} {tool_name}" - def tool_finished(self, name: str = "", output: str = "") -> None: - """Mark a tool call as finished; print completion line immediately.""" - tool_name = name or self._active_tool + def tool_finished( + self, + name: str = "", + output: str = "", + metadata: dict[str, Any] | None = None, + ) -> None: + """Mark a tool call as finished; print one compact audit line.""" + 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) + 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() - line.append(" ✓ ", style="leap.success") - line.append(tool_name, style="bold") - line.append(f" {_format_elapsed(duration)}", style="dim") + status_style = "leap.tool" if ok else "leap.error" + name_style = "leap.tool_name" if ok else "leap.error" + line.append(f" {_tool_icon(tool_name, ok=ok)} ", style=status_style) + line.append(tool_name, style=name_style) + 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") + 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: @@ -115,8 +377,17 @@ def finish(self) -> None: if self._thinking_buffer.strip(): self._console.thinking(self._thinking_buffer) - if self._buffer.strip(): - self._console.markdown(self._buffer) + 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( + 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) self._console.newline() diff --git a/src/leapflow/cli/tui_app/theme.py b/src/leapflow/cli/tui_app/theme.py index ad6dc27..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" @@ -41,43 +42,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 +131,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 +149,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_placeholder="#64748B", + 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", ) @@ -375,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( @@ -444,6 +457,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 +545,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 a9d0563..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") @@ -340,6 +341,17 @@ 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 + 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 @@ -559,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") @@ -763,6 +778,17 @@ 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")) + 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")) @@ -871,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, @@ -1011,6 +1038,17 @@ 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, + 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/client.py b/src/leapflow/daemon/client.py index c3ce69b..990f17a 100644 --- a/src/leapflow/daemon/client.py +++ b/src/leapflow/daemon/client.py @@ -14,6 +14,7 @@ DaemonLock, cleanup_stale, spawn_daemon, + stop_daemon, wait_ready, ) from leapflow.daemon.protocol import RpcRequest @@ -86,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}) @@ -96,6 +102,69 @@ 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 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") + 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") @@ -168,6 +237,36 @@ 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})...") + result = await asyncio.to_thread(stop_daemon, run_dir, timeout_s=10.0) + if not result.stopped: + raise exc + return await ensure_daemon_client( + settings, + mock_host=mock_host, + status_callback=status_callback, + ) + + + def _event_from_params(params: dict[str, Any]) -> StreamEvent: event_type = str(params.get("event_type") or "chunk") if event_type not in { @@ -178,6 +277,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/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/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/daemon/protocol.py b/src/leapflow/daemon/protocol.py index e9f42e5..2a0d482 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,51 @@ 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 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.""" + ... + + 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 +282,16 @@ 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", + "tools.list": "tools_list", + "usage.summary": "usage_summary", + "model.info": "model_info", + "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/server.py b/src/leapflow/daemon/server.py index 663daa4..b2f39a1 100644 --- a/src/leapflow/daemon/server.py +++ b/src/leapflow/daemon/server.py @@ -4,28 +4,66 @@ import asyncio import json import logging +import os import signal from collections.abc import Callable 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 logger = logging.getLogger(__name__) +_DEFAULT_STREAM_HEARTBEAT_S = 10.0 +_DEFAULT_IDLE_TIMEOUT_S = 600.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 + + +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.""" - 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, + 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"): 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: @@ -127,6 +165,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, @@ -135,8 +175,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 +198,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 +222,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.""" @@ -170,14 +244,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) @@ -185,9 +264,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 @@ -199,6 +290,34 @@ def _request_stop() -> None: return 0 +async def _watch_idle_shutdown( + server: UnixRpcServer, + 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 = 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(): + 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") + 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 6764b72..fec0fee 100644 --- a/src/leapflow/daemon/service.py +++ b/src/leapflow/daemon/service.py @@ -6,9 +6,11 @@ import os import sys import time +import uuid 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 @@ -26,11 +28,18 @@ 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 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: @@ -39,6 +48,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 @@ -89,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) @@ -99,9 +108,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 @@ -126,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"), @@ -136,15 +152,102 @@ 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, + "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(), "runtime_executable": sys.executable, "runtime_version": self._runtime_version(), + "pending_approvals": len(self._approval_pending), + "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 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()} + + 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 @@ -159,6 +262,64 @@ 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"])) + 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", + "disclosure_level", + "disclosure_reason", + "disclosure", + ): + if safe_snapshot.get(key) is not None: + metadata[key] = safe_snapshot[key] + 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"} + 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 @@ -211,6 +372,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 @@ -224,7 +518,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, @@ -232,3 +526,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/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 new file mode 100644 index 0000000..dfe964d --- /dev/null +++ b/src/leapflow/engine/context_control.py @@ -0,0 +1,519 @@ +"""Adaptive context governance primitives for every agent interaction. + +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, field +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 +_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 +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(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 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 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 + 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 adaptive context handling.""" + metadata: Dict[str, Any] = {} + 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 "") + 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") + 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 = ( + "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" + 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.""" + 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 " + 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/context_disclosure.py b/src/leapflow/engine/context_disclosure.py new file mode 100644 index 0000000..8422745 --- /dev/null +++ b/src/leapflow/engine/context_disclosure.py @@ -0,0 +1,463 @@ +"""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" + PROJECT_RESEARCH = "project_research" + 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 _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") + + 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 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, + 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 _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", "实现", "重构", "调试", "根因", "架构", "设计", + "深入分析", "执行", "代码", "测试", + ) + ) + + +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 fc20f6d..b50ff2d 100644 --- a/src/leapflow/engine/engine.py +++ b/src/leapflow/engine/engine.py @@ -10,12 +10,27 @@ 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 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, + ContextGovernanceController, + ContextPostureConfig, + ContextWindowController, + ToolEvidenceBuilder, +) +from leapflow.engine.context_disclosure import ( + DisclosureLevel, + DisclosurePlanner, + DisclosureRuntimeState, + MemoryDisclosure, + PromptAssemblyPlan, +) from leapflow.engine.error_classifier import ( ErrorCategory, ErrorClassifier, @@ -35,7 +50,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 @@ -56,9 +70,150 @@ 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__) +_TOOL_ARGS_PREVIEW_LIMIT = 160 +_TOOL_RESULT_PREVIEW_LIMIT = 240 +_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: + 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, + *, + 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: + 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, + *, + 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, 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: + 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 _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.""" @@ -199,12 +354,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 @@ -223,6 +380,48 @@ 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]] + + +@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.""" @@ -244,7 +443,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, @@ -262,7 +460,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( @@ -351,10 +548,35 @@ 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._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._last_disclosure_metadata: dict[str, Any] = {} + self._current_task_contract: TaskContract | None = None + self._disclosure_planner = DisclosurePlanner() self._healer = MessageHealer() # B2: Prompt cache optimization (None = disabled) @@ -373,6 +595,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, *, @@ -386,6 +635,32 @@ 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._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, llm=llm, @@ -627,24 +902,363 @@ 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 _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, + *, + 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, + ) + 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) + + 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]], + *, + 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._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 = self._ensure_task_contract_message(decision.messages) + compression_trace = self._compressor.last_trace.as_dict() + warning = self._context_controller.warning_notice( + decision.snapshot, + round_number=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)] + prepared = self._ensure_task_contract_message(prepared) + snapshot = self._context_controller.estimator.snapshot( + prepared, + 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, + "tool_schema_tokens": snapshot.tool_schema_tokens, + "total_tokens": snapshot.total_tokens, + "context_length": snapshot.context_length, + "ratio": snapshot.ratio, + "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", ""), + "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() + 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, + "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._context_governance_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 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) + 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")) + 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.""" 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 - - # 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 + self._begin_turn_context(user_text) - # 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)) @@ -654,11 +1268,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) @@ -672,25 +1286,16 @@ 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). """ 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 - - # 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 + self._begin_turn_context(user_text) - # 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)) @@ -702,13 +1307,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) @@ -1109,63 +1714,33 @@ 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() + unknown_tool_retry_used = False 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()] @@ -1184,16 +1759,15 @@ 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: 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() @@ -1205,9 +1779,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) @@ -1258,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: @@ -1291,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_text = json.dumps(result, default=str, ensure_ascii=False)[:result_budget] + 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 @@ -1470,58 +2064,33 @@ 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() + unknown_tool_retry_used = False 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) @@ -1540,20 +2109,18 @@ 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) - - yield StreamEvent(type="thinking", content=f"round {budget.used}") + compressed = self._prepare_llm_messages( + healed, + tools=tools_kwarg.get("tools") if use_native_tools else None, + round_number=budget.used, + ) content = "" 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() @@ -1565,7 +2132,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) @@ -1616,15 +2183,54 @@ async def _unified_tool_loop_stream( ) for tc in native_calls: - yield StreamEvent(type="tool_start", content=tc.name) - await self._execute_tools_concurrent( + 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=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: - yield StreamEvent(type="tool_complete", content=tc.name) + 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=normalized_name, + metadata={ + **_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 @@ -1647,7 +2253,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, @@ -1689,7 +2295,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 {} @@ -1739,32 +2345,67 @@ async def _unified_tool_loop_stream( return messages.append(build_assistant_message(content)) - yield StreamEvent(type="tool_start", content=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"]) + 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_name, + metadata=_tool_args_metadata( + tool_name, + tool_arguments, + original_tool_name=original_tool_name, + ), + ) + result = await self._execute_general_tool(normalized_tool_call, TOOL_HANDLERS) _clear_indicator() - yield StreamEvent(type="tool_complete", content=tool_call['name']) - _print_tool_result(tool_call['name'], result, enabled=self._settings.verbose_progress) + yield StreamEvent( + type="tool_complete", + content=tool_name, + metadata={ + **_tool_result_metadata( + tool_name, + tool_arguments, + result, + original_tool_name=original_tool_name, + ), + **self._tool_context_metadata( + tool_name, + tool_arguments, + result, + ), + }, + ) + _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_text = json.dumps(result, default=str, ensure_ascii=False)[:result_budget] + 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 @@ -1825,33 +2466,50 @@ 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]] = [] + 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_text = json.dumps(result, default=str, ensure_ascii=False)[:result_budget] + 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}) - return + 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) logger.info( @@ -1863,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( @@ -1871,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, @@ -1883,7 +2553,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( @@ -1891,12 +2562,26 @@ 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, + "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) @@ -1905,8 +2590,17 @@ 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, + "original_tool_name": original_name, + "arguments": ctc.arguments, + "result": result, + }) + return executed async def _execute_general_tool( self, tool_call: Dict[str, Any], handlers: Dict[str, Any] @@ -1924,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] @@ -1958,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: @@ -2135,7 +2847,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/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/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/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/platform/cua_client.py b/src/leapflow/platform/cua_client.py index 23a46c6..271e140 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, @@ -604,12 +633,25 @@ 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: """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 +670,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/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/src/leapflow/prompts/templates.py b/src/leapflow/prompts/templates.py index fdc3885..c8cd9d2 100644 --- a/src/leapflow/prompts/templates.py +++ b/src/leapflow/prompts/templates.py @@ -84,13 +84,22 @@ 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. - -When finished with all tool calls, respond normally without a JSON block. +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. +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/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..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)} @@ -184,9 +277,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/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 fd6359c..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 # ───────────────────────────────────────────────────────────────────── @@ -49,12 +50,23 @@ "type": "function", "function": { "name": "file_read", - "description": "Read the content of a text file.", + "description": ( + "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/max_lines 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)"}, + "max_chars": {"type": "integer", "description": "Max characters to read before line filtering (default bounded by runtime guard)"}, + "mode": { + "type": "string", + "enum": ["raw", "outline", "symbols"], + "description": "raw=exact lines, outline=headings/structure, symbols=class/function signatures", + }, }, "required": ["path"], }, @@ -241,10 +253,16 @@ }, { "name": "gp_file_read", - "description": "Read the content of a text file.", + "description": ( + "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, }, @@ -427,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/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_agent_execution.py b/tests/test_agent_execution.py index f4875a8..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.""" @@ -237,6 +404,161 @@ 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", "")) + 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 + 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 + finally: + 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.""" + 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_approval_layer.py b/tests/test_approval_layer.py new file mode 100644 index 0000000..2a653e1 --- /dev/null +++ b/tests/test_approval_layer.py @@ -0,0 +1,203 @@ +from __future__ import annotations + +import builtins +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_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) + 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 0512f88..b6eaeaf 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 @@ -27,7 +36,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() @@ -368,6 +378,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 @@ -433,6 +507,193 @@ 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), + ] + + +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 @@ -442,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] = [] @@ -459,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 @@ -497,18 +762,64 @@ 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) + + 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_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, "stop_daemon", fail_stop) 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) diff --git a/tests/test_context_disclosure.py b/tests/test_context_disclosure.py new file mode 100644 index 0000000..91b52b7 --- /dev/null +++ b/tests/test_context_disclosure.py @@ -0,0 +1,134 @@ +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 + + 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() + + 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_context_governance.py b/tests/test_context_governance.py new file mode 100644 index 0000000..9efb74c --- /dev/null +++ b/tests/test_context_governance.py @@ -0,0 +1,238 @@ +from __future__ import annotations + +from typing import Any + +import pytest + +from leapflow.engine.context_compressor import CompressorConfig, ContextCompressor +from leapflow.engine.context_control import ( + ContextBudgetEstimator, + ContextGovernanceController, + ContextPostureConfig, + 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_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 = { + "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 + 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: + controller = LongTaskContextController( + evidence_builder=ToolEvidenceBuilder(max_content_chars=240), + ) + + metadata = controller.tool_metadata("time_get", {}, {"ok": True, "result": "now"}) + + 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" + 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 dcce213..ca0c5e0 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]: @@ -40,11 +45,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 +91,443 @@ 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: + 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_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 + 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_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_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_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 + 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): + 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: @@ -220,6 +670,20 @@ 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, + "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" async def run_stream(self, message: str, *, enable_thinking: bool = False): @@ -253,9 +717,21 @@ 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["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 @@ -293,6 +769,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, @@ -304,17 +781,96 @@ 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 - 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 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_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 -def test_daemon_restart_stops_waits_and_starts(monkeypatch, tmp_path) -> None: + 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] = [] @@ -322,12 +878,34 @@ 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(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] = [] + + class Settings: + profile_dir = tmp_path + + 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_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..b636ee8 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,20 @@ 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 + 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: assert extract_json_object('{"key": "value", "n": 42}') == {"key": "value", "n": 42} 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 4adb809..9cfa684 100644 --- a/tests/test_tui_command_queue.py +++ b/tests/test_tui_command_queue.py @@ -2,11 +2,21 @@ import asyncio from contextlib import suppress +from types import SimpleNamespace import pytest -from leapflow.cli.tui_app.app import LeapApp +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 +from prompt_toolkit.formatted_text import to_plain_text +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 +from leapflow.cli.tui_app.stream import StreamRenderer from leapflow.cli.tui_app.theme import _LIGHT, resolve_theme @@ -15,6 +25,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) @@ -25,6 +36,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: @@ -46,18 +60,99 @@ 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], ...] = (), + on_control=None, +) -> 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, + on_control=on_control, ) 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()) + lines = [line for line in rendered.splitlines() if line] + + 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() @@ -83,6 +178,467 @@ 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)) + buffer._set_completions([completion]) + + app._accept_or_start_completion(buffer) + + assert buffer.text == "/help" + 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() + 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") + + +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 + + +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 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_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: + 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 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: + 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 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: + 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", + "disclosure_level": "selected_tools", + "disclosure_reason": "selected capabilities matched observable task signals", + }, + ) + + 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: + 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 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 async def test_process_loop_marks_failed_commands_and_recovers_counts() -> None: async def on_input(text: str) -> None: @@ -121,6 +677,234 @@ 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 + 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: + 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 + assert transformed.source_to_display(0) == len("❯ ") + + +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")] + assert transformed.source_to_display(0) == len("❯ ") + assert transformed.display_to_source(len("❯ ") + 1) == 1 + + +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 = "" @@ -177,6 +961,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() diff --git a/tests/test_tui_session_summary.py b/tests/test_tui_session_summary.py index 74fe5e3..bd480d1 100644 --- a/tests/test_tui_session_summary.py +++ b/tests/test_tui_session_summary.py @@ -20,17 +20,33 @@ class _Message: class _Console: def __init__(self) -> None: - self.markdown_calls: list[str] = [] + 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(self, text: str) -> None: - self.markdown_calls.append(text) + 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) + 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)) @@ -130,6 +146,106 @@ def test_stream_renderer_exposes_output_without_private_access() -> None: assert renderer.has_output is True +def test_stream_renderer_spaces_and_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 == [{ + "text": "final **answer**", + "indent": 4, + "margin_top": 1, + "margin_bottom": 1, + }] + 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 4cfc448..88b4ed7 100644 --- a/tests/test_tui_theme.py +++ b/tests/test_tui_theme.py @@ -3,9 +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 LeapConsole, _TerminalBackgroundCodeBlock, _TerminalBackgroundMarkdown from leapflow.cli.tui_app.status import StatusBar from leapflow.cli.tui_app.theme import ( _DARK, @@ -35,15 +37,15 @@ 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, "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}", }) @@ -60,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: @@ -67,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 @@ -133,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") @@ -151,6 +203,111 @@ 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 + + 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" + 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: monkeypatch.setattr( "leapflow.cli.tui_app.status.shutil.get_terminal_size", @@ -184,4 +341,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