diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 4d2e45e..6309198 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1,43 +1,43 @@ -#name: CI -# -#on: -# push: -# branches: [main] -# pull_request: -# branches: [main] -# -#concurrency: -# group: ${{ github.workflow }}-${{ github.ref }} -# cancel-in-progress: true -# -#jobs: -# test: -# runs-on: ${{ matrix.os }} -# strategy: -# fail-fast: false -# matrix: -# os: [ubuntu-latest, macos-latest] -# python-version: ['3.11', '3.12', '3.13'] -# -# steps: -# - uses: actions/checkout@v4 -# -# - name: Set up Python ${{ matrix.python-version }} -# uses: actions/setup-python@v5 -# with: -# python-version: ${{ matrix.python-version }} -# -# - name: Install uv -# uses: astral-sh/setup-uv@v4 -# -# - name: Install dependencies -# run: uv sync --all-extras -# -# - name: Lint -# run: uv run ruff check src/leapflow/ tests/ -# -# - name: Run tests -# run: uv run pytest tests/ -q --tb=short -# env: -# LEAPFLOW_MOCK_HOST: '1' -# LEAPFLOW_LLM_API_KEY: 'test-key-ci' +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + python-version: ['3.11', '3.12', '3.13'] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install uv + uses: astral-sh/setup-uv@v4 + + - name: Install dependencies + run: uv sync --all-extras + + - name: Lint + run: uv run ruff check src/leapflow/ tests/ + + - name: Run tests + run: uv run pytest tests/ -q --tb=short + env: + LEAPFLOW_MOCK_HOST: '1' + LEAPFLOW_LLM_API_KEY: 'test-key-ci' diff --git a/AGENTS.md b/AGENTS.md index e3248f3..20a444b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,7 +39,7 @@ This document is the LeapFlow engineering collaboration contract. It is not only - **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. - **Gateway as Signal Boundary**: External IM/platform integrations are not just messaging features; they extend LeapFlow's Observe/Orient boundary into collaboration environments. Inbound platform events must enter as structured signals (`BackendEvent` → normalized domain event/message), pass SNR filtering and privacy/safety gates, then feed memory, decision, and action paths according to their classification. - **Transport-Lifecycle Separation**: Short-lived actions (`ExecutionBackend`/`CliBackend`) and long-lived observations (`BackendEventSource`) are separate responsibilities. Do not implement streaming subscribers, webhooks, polling loops, or CLI NDJSON consumers inside one-shot action execution code. -- **Platform-Neutral Gateway Core**: Gateway core owns protocols, lifecycle, routing, session isolation, approval, audit, and memory integration. Platform adapters own authentication, send semantics, event-source configuration, and schema normalization. Core modules must not import platform SDKs directly. +- **Platform-Neutral Gateway Core**: Gateway core owns protocols, lifecycle, routing, session isolation, approval, audit, and memory integration. Platform adapters own authentication, send semantics, event-source configuration, and schema normalization. Core modules must not import platform SDKs directly. Per-vendor code — including credential validators — lives in a platform sub-package (`adapters/`, `normalizers/`, `action_packs/`, `validators/.py`), never in a core module; core keeps only the neutral registry and contracts. - **Platform vs App Business Boundary**: Platform layers may define stable contracts and governance primitives (`ActionSpec`, `ActionFailure`, `ActionAuthSpec`, `CapabilityHealthLedger`, approval/feasibility gates, audit, and metadata propagation). Third-party app or vendor specifics — SDK/CLI wire formats, scope names, auth commands, console URLs, error JSON shapes, resource naming, and recovery playbooks — must live in that app's action pack, adapter, backend, or normalizer, never in gateway core. - **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 @@ -50,7 +50,8 @@ This document is the LeapFlow engineering collaboration contract. It is not only - **Single Source of Truth**: DuckDB for persistence, EventBus for communication, Settings for configuration - **Inbound Signal Classification**: Platform events must be classified before they activate the agent. Message/callback events may enter Decide; signal/lifecycle events should be stored or routed without triggering LLM by default; ignored events must be explicit (e.g. self-message, duplicate, blocked scope). - **Single Recovery Decision Point**: All agent loop errors (LLM, tool, system, security) enter one `RecoveryCoordinator`. No parallel decision paths, no scattered if/break logic. The pipeline is always: `FailureEnvelope` → `RecoveryDecision` → `StrategyOutcome` feedback. -- **Side-Effect Gating**: Recovery actions are gated by `SideEffectState`. Committed or partial side effects block automatic retry; only user-mediated or checkpoint-based resumption is permitted after state mutation. +- **Side-Effect Gating**: Recovery is gated by `SideEffectState` at two levels. Within a tool batch, a failed side-effecting call stops the remaining calls in that batch, decided by the declared `execution_policy` rather than any tool-name list. Within `RecoveryCoordinator`, any state other than `NONE` blocks replaying actions (retry, transform-and-retry, failover) and yields a checkpointed halt carrying an `InteractionRequest`; only user-mediated or checkpoint-based resumption is permitted. `UNKNOWN` blocks like `COMMITTED` and `PARTIAL` do: it is the classifier's fallback and the state assigned to `external_side_effect` (outbound sends, external API calls), so exempting it would leave the highest-risk case ungated. +- **Uncertain Effects Are Reported, Not Retried Blindly**: a failed call whose effect may already have landed (`external_side_effect`, `mutating_once`) must carry that verdict in its result so the next turn verifies before repeating it. An error is not proof that nothing happened. Idempotent mutations are exempt — re-applying them converges, so flagging them would only stall safe retries. - **Budget-Constrained Recovery**: Turn-level deadlines, per-category limits, and a global recovery budget prevent infinite retry loops. Every recovery action has an explicit cost; exhaustion triggers a clean halt or user escalation. - **Recovery Strategy as Protocol**: Recovery strategies implement a `RecoveryStrategy` Protocol (`can_apply` + `decide`), registered by priority, composable, and extensible without modifying the coordinator. @@ -88,13 +89,17 @@ This document is the LeapFlow engineering collaboration contract. It is not only - ANSI output must check `sys.stdout.isatty()` before emitting escape codes - For error recovery, route all failures through the `RecoveryCoordinator` — classify into a `FailureEnvelope`, receive a `RecoveryDecision` with an explainable `reason` and `strategy_key`, then feed the outcome back. Never handle errors with ad-hoc if/break in the loop body. - Recovery strategies are standalone Protocol implementations with `can_apply()` + `decide()`. Add new strategies by registration, never by modifying the coordinator's decision logic. -- When automatic recovery exhausts its budget or encounters non-recoverable failures, emit a structured `InteractionRequest` (typed action, severity, suggested actions, timeout behavior, resumption key) — not raw text appended to conversation. +- When automatic recovery exhausts its budget or encounters non-recoverable failures, emit a structured `InteractionRequest` (typed action, severity, suggested actions, timeout behavior, resumption key) — not raw text appended to conversation. A terminal decision that carries one must surface it: render its title, description, and suggested actions for the user, and pass the structured payload to the client so it can prompt and resume by `resumption_key`. Dropping it back to `decision.reason` tells the user a turn stopped without saying what to do. ## 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. - **Human confirmation for TUI changes**: Any TUI layout or interaction-logic change requires a second human confirmation before it is considered ready. -- **Human confirmation for slash-command paths**: Any change that adds, removes, renames, reroutes, or alters the behavior of a slash command (`/...`) — across the registry, router, in-process REPL, daemon REPL, `command_execute`, completion, and rendering — requires a second human confirmation before it is considered ready. This applies especially to user-experience-facing behavior (dispatch, prompts, confirmations, output, browser/dashboard launches, and error/recovery messaging), which must never be shipped on a single pass. +- **Human confirmation for slash-command paths (MANDATORY, no exceptions)**: Any change to what a slash command (`/...`) *does* requires a second human confirmation before it is considered ready — never ship it on a single pass. This covers the whole surface: registry, router, in-process REPL, daemon REPL, `command_execute` (including its RPC signature and parameter plumbing), completion, and rendering. + - **Functional changes count even when the command surface is unchanged.** The name, arguments, and help text staying identical does NOT waive confirmation. Altering what the command observes, targets, arms, schedules, sends, opens, or persists — or which session/workspace/profile it resolves against — is a functional change and must be confirmed. + - Equally in scope: dispatch and routing, prompts and confirmations, emitted output, browser/dashboard launches, background work the command triggers (watches, schedules, re-entries), and error/recovery messaging. + - Passing tests and a clean lint run are NOT a substitute for confirmation. Slash commands are the primary user-facing control plane; correctness of the visible behavior is only established by a human check. + - State the pending confirmation explicitly in the handoff, and name the behavior a human should exercise to verify it. - **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. diff --git a/pyproject.toml b/pyproject.toml index 8268ba7..a03f9c6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -68,3 +68,8 @@ testpaths = ["tests"] [tool.ruff] line-length = 100 target-version = "py311" + +[tool.ruff.lint] +# Pinned explicitly so a ruff upgrade cannot silently change the enforced set. +# Matches the previously implicit default (pycodestyle errors + pyflakes). +select = ["E4", "E7", "E9", "F"] diff --git a/src/leapflow/causal/adapter.py b/src/leapflow/causal/adapter.py index e455519..403da6e 100644 --- a/src/leapflow/causal/adapter.py +++ b/src/leapflow/causal/adapter.py @@ -44,7 +44,7 @@ def graph_to_pair_context( This adapter enables the existing ContextEnrichedVLMExtractor to consume causal chain data without modification. """ - from leapflow.perception.types import InteractionSignal, PairContext + from leapflow.perception.types import PairContext chains = graph.chains_in_window(t0, t1) signals: List[InteractionSignal] = [] diff --git a/src/leapflow/causal/components.py b/src/leapflow/causal/components.py index edb9278..55a3921 100644 --- a/src/leapflow/causal/components.py +++ b/src/leapflow/causal/components.py @@ -13,18 +13,15 @@ import logging import math -import time from collections import defaultdict from dataclasses import dataclass, field -from typing import Any, Dict, List, Optional, Sequence, Tuple +from typing import Any, Dict, List, Optional, Tuple from leapflow.causal.channel import AggregationPolicy, ChannelRegistry from leapflow.causal.types import ( CausalChain, CausalEvent, - EventSource, EventType, - FrameRef, ) logger = logging.getLogger(__name__) diff --git a/src/leapflow/causal/inference.py b/src/leapflow/causal/inference.py index 3a1fa83..23d0a05 100644 --- a/src/leapflow/causal/inference.py +++ b/src/leapflow/causal/inference.py @@ -15,7 +15,7 @@ import warnings from dataclasses import dataclass, field from pathlib import Path -from typing import Any, Dict, List, Optional, Sequence, Tuple +from typing import Any, Dict, List, Optional, Tuple import yaml diff --git a/src/leapflow/causal/pipeline.py b/src/leapflow/causal/pipeline.py index d392c4d..bba0bc5 100644 --- a/src/leapflow/causal/pipeline.py +++ b/src/leapflow/causal/pipeline.py @@ -15,7 +15,7 @@ import logging import time from dataclasses import dataclass, field -from typing import Any, Dict, List, Optional, Sequence, TYPE_CHECKING, Union +from typing import Any, Dict, List, Optional, Sequence, TYPE_CHECKING from leapflow.causal.channel import ChannelRegistry, build_default_registry from leapflow.causal.components import ( diff --git a/src/leapflow/causal/types.py b/src/leapflow/causal/types.py index fa55646..7bb3878 100644 --- a/src/leapflow/causal/types.py +++ b/src/leapflow/causal/types.py @@ -12,7 +12,7 @@ from collections import defaultdict, deque from dataclasses import dataclass, field from enum import Enum -from typing import Any, Dict, FrozenSet, Iterator, List, Optional, Set, Tuple +from typing import Any, Dict, Iterator, List, Optional, Set, Tuple class EventType(str, Enum): diff --git a/src/leapflow/cli/cli.py b/src/leapflow/cli/cli.py index f3d216b..86e9572 100644 --- a/src/leapflow/cli/cli.py +++ b/src/leapflow/cli/cli.py @@ -31,6 +31,8 @@ async def _async_main(args: argparse.Namespace) -> int: settings = load_config() + from leapflow.logging_setup import init_cli_logging + init_cli_logging(settings) mock_host = getattr(args, "mock_host", False) sys.stderr.write("\033[2m→ Initializing LeapFlow...\033[0m\n") sys.stderr.flush() @@ -128,6 +130,8 @@ async def _async_daemon_main(args: argparse.Namespace) -> int: from leapflow.daemon.client import DaemonUnavailableError, recover_daemon_client settings = load_config() + from leapflow.logging_setup import init_cli_logging + init_cli_logging(settings) mock_host = getattr(args, "mock_host", False) def _status(message: str) -> None: diff --git a/src/leapflow/cli/commands/daemon.py b/src/leapflow/cli/commands/daemon.py index 94f3125..c1d9bbb 100644 --- a/src/leapflow/cli/commands/daemon.py +++ b/src/leapflow/cli/commands/daemon.py @@ -249,5 +249,11 @@ def _restart(settings: object, mock_host: bool, *, force: bool = False) -> int: async def _serve(settings: object, mock_host: bool) -> int: from leapflow.daemon.server import serve_daemon + from leapflow.logging_setup import init_daemon_logging + # The daemon writes stdout/stderr to leapd.log; without an explicit logging + # setup Python's lastResort handler only emits WARNING+, hiding the INFO + # field evidence (deferred init progress, turn usage). daemon.log_level is + # independent from runtime.log_level and requires a daemon restart. + init_daemon_logging(settings) return await serve_daemon(settings, mock_host=mock_host) diff --git a/src/leapflow/cli/commands/dashboard.py b/src/leapflow/cli/commands/dashboard.py index de21901..3839704 100644 --- a/src/leapflow/cli/commands/dashboard.py +++ b/src/leapflow/cli/commands/dashboard.py @@ -39,7 +39,11 @@ def cmd_dashboard(args: argparse.Namespace) -> int: def _serve(args: argparse.Namespace, settings: object) -> int: from leapflow.dashboard.server import run_server + from leapflow.logging_setup import init_logging + # Long-lived server process: capture INFO evidence in its log output, + # mirroring the leapd daemon surface. + init_logging("INFO") token = getattr(args, "token", "") or launcher.generate_token() bind = getattr(args, "bind", "") or settings.dashboard_bind port = getattr(args, "port", 0) or settings.dashboard_port diff --git a/src/leapflow/cli/commands/host.py b/src/leapflow/cli/commands/host.py index 758c811..7cb76ee 100644 --- a/src/leapflow/cli/commands/host.py +++ b/src/leapflow/cli/commands/host.py @@ -242,9 +242,9 @@ async def _cmd_start() -> int: # Module-based runner for the daemon process daemon_script = ( - "import asyncio, logging, signal, sys; " - "logging.basicConfig(level=logging.INFO, " - "format='%(asctime)s %(name)s %(levelname)s %(message)s'); " + "import asyncio, signal, sys; " + "from leapflow.logging_setup import init_logging; " + "init_logging('INFO'); " "from leapflow.platform.event_bus import EventBus; " "from leapflow.platform.observers import ObservationDaemon, ObserverConfig; " "from leapflow.memory.providers.episodic import EpisodicMemoryProvider; " diff --git a/src/leapflow/cli/commands/hub.py b/src/leapflow/cli/commands/hub.py index 4704f72..a953f03 100644 --- a/src/leapflow/cli/commands/hub.py +++ b/src/leapflow/cli/commands/hub.py @@ -7,7 +7,6 @@ from __future__ import annotations import logging -import sys from typing import TYPE_CHECKING, List logger = logging.getLogger(__name__) @@ -233,7 +232,7 @@ async def _hub_push(ctx: "Context", args: List[str]) -> int: client = _build_hub_client(ctx) repo_id = client._build_repo_id(bundle.manifest.name) - print(f"\n Push Summary:") + print("\n Push Summary:") print(f" Skill: {bundle.manifest.name}") print(f" Version: {bundle.manifest.version}") print(f" Visibility: {visibility.value}") @@ -286,7 +285,7 @@ async def _hub_push(ctx: "Context", args: List[str]) -> int: print(" Push aborted.") return 0 - print(f"\n Pushed successfully!") + print("\n Pushed successfully!") print(f" Repo: {result.repo_id}") print(f" Version: {result.version}") print(f" URL: {result.url}") @@ -343,7 +342,7 @@ async def _hub_pull(ctx: "Context", args: List[str]) -> int: return 0 # Step 4: Show summary - print(f"\n Pull Summary:") + print("\n Pull Summary:") print(f" Skill: {bundle.manifest.name}") print(f" Version: {bundle.manifest.version}") print(f" Source: {repo_id} ({client.hub_type})") @@ -458,7 +457,7 @@ async def _hub_sync(ctx: "Context", args: List[str]) -> int: return 0 # Display plan - print(f"\n Sync Plan:") + print("\n Sync Plan:") if plan.to_push and not pull_only: print(f" Push ({len(plan.to_push)}):") for m in plan.to_push: diff --git a/src/leapflow/cli/commands/interactive.py b/src/leapflow/cli/commands/interactive.py index 5f215bb..015e2fe 100644 --- a/src/leapflow/cli/commands/interactive.py +++ b/src/leapflow/cli/commands/interactive.py @@ -349,7 +349,6 @@ async def cmd_interactive(ctx: "Context", *, resume_id: Optional[str] = None) -> handle_usage, handle_model, handle_config, - handle_clear, handle_gateway, handle_app, render_command_payload, @@ -666,7 +665,7 @@ async def handle_input(text: str) -> None: return if canonical == "clear": - handle_clear(ctx, console, cmd_args) + app.clear_screen() _render_banner() return @@ -1285,6 +1284,7 @@ async def handle_input(text: str) -> None: _show_help(console, runtime="daemon") return if canonical == "clear": + app.clear_screen() _render_banner() return # Task control commands are handled by the existing _handle_task_control @@ -1294,7 +1294,9 @@ async def handle_input(text: str) -> None: # Engine-routed commands: dispatch through daemon RPC try: payload = await bridge.call( - lambda current_client: current_client.command_execute(canonical, cmd_args), + lambda current_client: current_client.command_execute( + canonical, cmd_args, session_id=active_session_id, + ), description=f"/{canonical}", ) except Exception as exc: diff --git a/src/leapflow/cli/commands/run.py b/src/leapflow/cli/commands/run.py index cd80207..ac1ce14 100644 --- a/src/leapflow/cli/commands/run.py +++ b/src/leapflow/cli/commands/run.py @@ -8,7 +8,7 @@ from typing import TYPE_CHECKING, Any, Dict, Optional from leapflow.cli.helpers import require_initialized -from leapflow.engine.situational_assessor import Assessment, AssessmentVerdict +from leapflow.engine.situational_assessor import AssessmentVerdict if TYPE_CHECKING: from leapflow.cli.context import Context diff --git a/src/leapflow/cli/commands/slash_handlers.py b/src/leapflow/cli/commands/slash_handlers.py index cae2894..e7f529b 100644 --- a/src/leapflow/cli/commands/slash_handlers.py +++ b/src/leapflow/cli/commands/slash_handlers.py @@ -1071,9 +1071,10 @@ async def handle_app(ctx: "Context", console: "LeapConsole", args: str) -> None: render_app_payload(console, await build_app_payload(ctx, args)) -def handle_clear(ctx: "Context", console: "LeapConsole", args: str) -> None: - """Clear the terminal screen.""" - os.system("cls" if os.name == "nt" else "clear") +# NOTE: screen clearing is owned by ``LeapApp.clear_screen()``. It must go +# through the prompt_toolkit renderer that owns the TTY — a shell ``clear`` +# leaves the renderer's cursor cache stale and misplaces the next redraw — so +# there is deliberately no clear handler here. # ══════════════════════════════════════════════════════════════════════ @@ -1117,12 +1118,15 @@ def build_orient_payload(ctx: "Context") -> dict[str, Any]: } -async def command_execute(ctx: "Context", name: str, args: str = "") -> dict[str, Any]: +async def command_execute( + ctx: "Context", name: str, args: str = "", session_id: str = "", +) -> dict[str, Any]: """Execute a slash command and return a serializable result payload. This is the unified entry point for daemon-mode command execution. Returns a dict with at minimum ``ok`` and ``message`` keys, plus - optional structured data for rich TUI rendering. + optional structured data for rich TUI rendering. ``session_id`` identifies + the calling client's session for commands that observe it (e.g. ``/board``). """ if name == "status": return build_status_payload(ctx) @@ -1160,24 +1164,29 @@ async def command_execute(ctx: "Context", name: str, args: str = "") -> dict[str if name == "task": return _execute_scheduler_task(ctx) if name == "board" or name.startswith("board "): - return await _execute_dashboard(ctx, name, args) + return await _execute_dashboard(ctx, name, args, session_id=session_id) return {"ok": False, "message": f"Unknown command: /{name}"} -async def _ensure_session_watch_refresh(ctx: "Context", monitors: Any) -> str: +async def _ensure_session_watch_refresh( + ctx: "Context", monitors: Any, session_id: str = "", +) -> str: """Ensure an active session watch exists and trigger one analysis cycle. The analysis producer is LLM-backed and can take tens of seconds, so it is scheduled in the background: arming the watch and opening the board must be instant and must never block the command RPC past its timeout. The board receives the resulting finding over WebSocket when the cycle completes. + ``session_id`` binds the watch to the caller's session so the analysis reads + that conversation rather than whichever session was last active. Returns the session watch id. """ from leapflow.monitor.session_producer import ensure_session_watch, session_watch_params - watch_id = await ensure_session_watch( - monitors, params=session_watch_params(getattr(ctx, "settings", None)) - ) + params = session_watch_params(getattr(ctx, "settings", None)) + if session_id: + params["session_id"] = session_id + watch_id = await ensure_session_watch(monitors, params=params) monitors.schedule_watch_once(watch_id, force=True) return watch_id @@ -1190,7 +1199,9 @@ async def _ensure_session_watch_refresh(ctx: "Context", monitors: Any) -> str: ) -async def _execute_dashboard(ctx: "Context", name: str, args: str = "") -> dict[str, Any]: +async def _execute_dashboard( + ctx: "Context", name: str, args: str = "", session_id: str = "", +) -> dict[str, Any]: """Analyze the current session and render it through a template. LeapBoard has a single analysis target — the current session. ``/board`` @@ -1217,11 +1228,11 @@ async def _execute_dashboard(ctx: "Context", name: str, args: str = "") -> dict[ ctx, monitors, verb, target=rest_tokens[0] if rest_tokens else "", ) if not verb: - return await _execute_board_open(ctx, monitors, template="") + return await _execute_board_open(ctx, monitors, template="", session_id=session_id) # A non-empty, non-verb token must name a known template lens; otherwise it # is an unknown command and is rejected rather than coerced to generic. if verb in set(_template_library(ctx).names()): - return await _execute_board_open(ctx, monitors, template=verb) + return await _execute_board_open(ctx, monitors, template=verb, session_id=session_id) return { "ok": False, "message": ( @@ -1232,7 +1243,9 @@ async def _execute_dashboard(ctx: "Context", name: str, args: str = "") -> dict[ } -async def _execute_board_open(ctx: "Context", monitors: Any, *, template: str) -> dict[str, Any]: +async def _execute_board_open( + ctx: "Context", monitors: Any, *, template: str, session_id: str = "", +) -> dict[str, Any]: """Open the current-session board rendered with the requested template. ``template`` is guaranteed to be empty (default) or a known lens by the @@ -1241,7 +1254,7 @@ async def _execute_board_open(ctx: "Context", monitors: Any, *, template: str) - session_watch_id = "" if monitors is not None: try: - session_watch_id = await _ensure_session_watch_refresh(ctx, monitors) + session_watch_id = await _ensure_session_watch_refresh(ctx, monitors, session_id) except Exception: logger.debug("dashboard: session watch refresh failed", exc_info=True) # The client owns the dashboard server lifecycle (it spawns/validates it off diff --git a/src/leapflow/cli/commands/teach.py b/src/leapflow/cli/commands/teach.py index c4a326c..1914f7a 100644 --- a/src/leapflow/cli/commands/teach.py +++ b/src/leapflow/cli/commands/teach.py @@ -4,7 +4,7 @@ import asyncio import sys -from typing import TYPE_CHECKING, List, Optional +from typing import TYPE_CHECKING, Any, List, Optional from leapflow.cli.helpers import ( blink_recording, @@ -17,6 +17,7 @@ if TYPE_CHECKING: from leapflow.cli.context import Context + from leapflow.recording.health import RecordingHealthMonitor _VALID_LEVELS = frozenset({"full", "structural", "opaque", "deny"}) diff --git a/src/leapflow/cli/context.py b/src/leapflow/cli/context.py index 95ab633..320ba57 100644 --- a/src/leapflow/cli/context.py +++ b/src/leapflow/cli/context.py @@ -146,21 +146,6 @@ def _emit_status(msg: str) -> None: sys.stderr.flush() -def configure_logging(level: str) -> None: - log_level = getattr(logging, level.upper(), logging.INFO) - - # Install RedactingFormatter to prevent secret leakage in logs - try: - from leapflow.security.redact import RedactingFormatter - formatter = RedactingFormatter("%(asctime)s %(levelname)s %(name)s: %(message)s") - except ImportError: - formatter = logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s") - - handler = logging.StreamHandler() - handler.setFormatter(formatter) - logging.basicConfig(level=log_level, handlers=[handler]) - - def _build_visual_components( settings: Settings, rpc: Any, ) -> Optional[Any]: @@ -1621,13 +1606,16 @@ async def _archive_to_semantic(messages: List[Dict[str, Any]]) -> None: # ── Seamless ripgrep provisioning for code_search (best-effort, background) ── # code_search always works via the pure-Python fallback; this just tries to # provision the faster ripgrep backend without blocking startup or searches. + # The attempt is persisted in the profile cache so daemon restarts never + # re-trigger the installer's process storm after a failed install. try: if getattr(settings, "tools_ripgrep_autoinstall", True): import threading from leapflow.tools.file_operations import ensure_ripgrep_available + provision_marker = settings.profile_layout.cache.profile_dir / "ripgrep_provision.json" threading.Thread( target=ensure_ripgrep_available, - kwargs={"autoinstall": True}, + kwargs={"autoinstall": True, "marker_path": provision_marker}, daemon=True, ).start() except Exception: diff --git a/src/leapflow/cli/tui_app/app.py b/src/leapflow/cli/tui_app/app.py index ca40637..1d98f18 100644 --- a/src/leapflow/cli/tui_app/app.py +++ b/src/leapflow/cli/tui_app/app.py @@ -720,6 +720,20 @@ def exit(self) -> None: if self._app.is_running: self._app.exit() + def clear_screen(self) -> None: + """Clear the terminal and reset the renderer's position state. + + Must go through prompt_toolkit's renderer rather than a shell ``clear``: + the Application owns the TTY (``full_screen=False`` + ``patch_stdout``) + and caches where it last drew. Clearing behind its back leaves that + cache stale, so the next redraw lands at the wrong offset. Renderer + ``clear()`` erases the screen *and* re-requests the absolute cursor + position, keeping the layout consistent afterwards. + """ + if not self._app.is_running: + return + self._app.renderer.clear() + # ── Async worker ───────────────────────────────────────────────── async def _process_loop(self) -> None: diff --git a/src/leapflow/config.py b/src/leapflow/config.py index 3e48e8f..a2fb380 100644 --- a/src/leapflow/config.py +++ b/src/leapflow/config.py @@ -423,6 +423,11 @@ class Settings: daemon_max_concurrent_turns: int = 3 daemon_max_live_sessions: int = 16 daemon_session_idle_ttl_s: float = 1800.0 + # File-log verbosity for the leapd process (stdout/stderr -> leapd.log). + # Independent from runtime.log_level so field diagnostics (deferred init + # progress, turn_usage, empty-response warnings) are captured without + # making the interactive CLI/TUI noisy. Requires `leap daemon restart`. + daemon_log_level: str = "INFO" circuit_breaker_threshold: int = 5 # Consecutive failures before circuit opens circuit_breaker_cooldown_s: float = 60.0 # Circuit breaker cooldown period @@ -603,7 +608,10 @@ def _build_settings_from_env( mock_host = os.getenv("LEAPFLOW_MOCK_HOST", "0").strip() in ("1", "true", "True", "yes") duckdb = os.getenv("LEAPFLOW_DUCKDB_PATH", str(profile_layout.duckdb_path)).strip() - log_level = os.getenv("LEAPFLOW_LOG_LEVEL", "INFO").strip() + # Interactive CLI/TUI surface verbosity. WARNING matches the historical + # visible behavior (quiet interactive surface); verbose field diagnostics + # belong to the daemon file log (daemon.log_level, default INFO). + log_level = os.getenv("LEAPFLOW_LOG_LEVEL", "WARNING").strip() # Memory Providers memory_working_max_tokens = int(os.getenv("LEAPFLOW_MEMORY_WORKING_MAX_TOKENS", "8192")) @@ -889,6 +897,7 @@ def _build_settings_from_env( daemon_max_concurrent_turns = int(os.getenv("LEAPFLOW_DAEMON_MAX_CONCURRENT_TURNS", "3")) daemon_max_live_sessions = int(os.getenv("LEAPFLOW_DAEMON_MAX_LIVE_SESSIONS", "16")) daemon_session_idle_ttl_s = float(os.getenv("LEAPFLOW_DAEMON_SESSION_IDLE_TTL_S", "1800.0")) + daemon_log_level = os.getenv("LEAPFLOW_DAEMON_LOG_LEVEL", "INFO").strip() or "INFO" circuit_breaker_threshold = int(os.getenv("LEAPFLOW_CIRCUIT_BREAKER_THRESHOLD", "5")) circuit_breaker_cooldown_s = float(os.getenv("LEAPFLOW_CIRCUIT_BREAKER_COOLDOWN_S", "60.0")) @@ -1210,6 +1219,7 @@ def _build_settings_from_env( daemon_max_concurrent_turns=daemon_max_concurrent_turns, daemon_max_live_sessions=daemon_max_live_sessions, daemon_session_idle_ttl_s=daemon_session_idle_ttl_s, + daemon_log_level=daemon_log_level, circuit_breaker_threshold=circuit_breaker_threshold, circuit_breaker_cooldown_s=circuit_breaker_cooldown_s, # Signal Fusion diff --git a/src/leapflow/config_service.py b/src/leapflow/config_service.py index 7aaa6e5..d03492b 100644 --- a/src/leapflow/config_service.py +++ b/src/leapflow/config_service.py @@ -147,8 +147,11 @@ class ConfigSnapshot: "tools.verify_edits": "After edit_file/file_write, run an advisory syntax check on the written file (Python via AST) and attach syntax_ok/syntax_error to the result. Advisory only — it never blocks the write; the model sees a broken edit immediately and can fix it.", "agent.validate_tool_args": "Validate a tool call's required arguments before execution; a missing required parameter returns a structured invalid_arguments result (with the accepted schema) for in-turn self-repair instead of an opaque handler error. Does not count as a failure and never trips the batch-stop gate.", "daemon.max_concurrent_turns": "Maximum agent turns the daemon runs concurrently across sessions (Stage 3). 3 (default) lets several fresh TUI sessions run in parallel on isolated per-session engines; set to 1 for strict serialized fallback. Changes require `leap daemon restart`.", - "daemon.max_live_sessions": "Maximum per-session execution contexts the daemon keeps live (bounds memory); the least-recently-active non-primary session is evicted beyond this.", - "daemon.session_idle_ttl_s": "Idle seconds after which a non-primary session execution context is evicted (0 disables idle eviction).", + "daemon.log_level": "File-log verbosity for the leapd process (written to leapd.log). Independent from runtime.log_level so daemon field diagnostics (deferred init progress, turn usage, empty-response warnings) are captured without making the CLI/TUI noisy. Changes require `leap daemon restart`.", + "daemon.max_live_sessions": "Maximum per-session execution contexts the daemon keeps live (bounds memory); the least-recently-active non-primary session is evicted beyond this. Changes require `leap daemon restart`.", + "daemon.session_idle_ttl_s": "Idle seconds after which a non-primary session execution context is evicted (0 disables idle eviction). Changes require `leap daemon restart`.", + "daemon.request_ledger_max_entries": "Maximum RPC request ids the daemon remembers for idempotency (bounds memory); the oldest entries are dropped beyond this. Changes require `leap daemon restart`.", + "daemon.request_ledger_ttl_s": "Seconds a completed RPC request id stays in the idempotency ledger, so a client retry is deduplicated instead of re-running the turn. Changes require `leap daemon restart`.", "agent.cost_ceiling_context_multiple": "Optional cumulative effective-cost ceiling as a multiple of context length (0 disables; a soft finalize nudge, the iteration cap stays the hard bound).", "agent.subagent_max_depth": "Maximum delegation depth for subagents (governs recursive task decomposition).", "agent.max_parallel_tools": "Maximum tool calls executed in parallel within a single LLM response's batch (metadata-classified read-only / non-overlapping idempotent tools). Bounds the asyncio.gather fan-out so a large batch does not overwhelm IO; 1 forces sequential execution.", @@ -210,6 +213,7 @@ class ConfigSnapshot: _VALUE_HINTS = { "runtime.log_level": "DEBUG|INFO|WARNING|ERROR", + "daemon.log_level": "DEBUG|INFO|WARNING|ERROR", "recording.mode": "video|default|vision_only", "signal.channels": "all or comma-separated channel names", } diff --git a/src/leapflow/copilot/engine.py b/src/leapflow/copilot/engine.py index 1b8fbba..b690546 100644 --- a/src/leapflow/copilot/engine.py +++ b/src/leapflow/copilot/engine.py @@ -67,7 +67,7 @@ def __init__( degradation: Optional["DegradationPolicy"] = None, ) -> None: self._layers: List[PredictorLayer] = sorted( - layers, key=lambda l: l.priority + layers, key=lambda item: item.priority ) self._config = config self._degradation = degradation @@ -144,10 +144,10 @@ def register_layer(self, layer: PredictorLayer) -> None: """ # Remove existing with same id (if any) self._layers = [ - l for l in self._layers if l.layer_id != layer.layer_id + item for item in self._layers if item.layer_id != layer.layer_id ] self._layers.append(layer) - self._layers.sort(key=lambda l: l.priority) + self._layers.sort(key=lambda item: item.priority) logger.info( "Registered layer %s (priority=%d)", layer.layer_id, layer.priority ) @@ -158,7 +158,7 @@ def unregister_layer(self, layer_id: str) -> None: No-op if the layer is not found. """ before = len(self._layers) - self._layers = [l for l in self._layers if l.layer_id != layer_id] + self._layers = [item for item in self._layers if item.layer_id != layer_id] if len(self._layers) < before: logger.info("Unregistered layer %s", layer_id) diff --git a/src/leapflow/copilot/pipeline.py b/src/leapflow/copilot/pipeline.py index 98f1ab8..d2280ac 100644 --- a/src/leapflow/copilot/pipeline.py +++ b/src/leapflow/copilot/pipeline.py @@ -24,7 +24,7 @@ import logging import time from collections import OrderedDict -from dataclasses import dataclass, field +from dataclasses import dataclass from typing import Dict, List, Optional from leapflow.copilot.config import CopilotConfig diff --git a/src/leapflow/copilot/predictors/l0_hash.py b/src/leapflow/copilot/predictors/l0_hash.py index d3fa831..a08af4c 100644 --- a/src/leapflow/copilot/predictors/l0_hash.py +++ b/src/leapflow/copilot/predictors/l0_hash.py @@ -13,7 +13,7 @@ from __future__ import annotations import logging -from dataclasses import dataclass, field +from dataclasses import dataclass from typing import Dict, List, Optional, Protocol, Tuple from leapflow.copilot.types import ( diff --git a/src/leapflow/copilot/predictors/l1_markov.py b/src/leapflow/copilot/predictors/l1_markov.py index 6dc1052..6045b2e 100644 --- a/src/leapflow/copilot/predictors/l1_markov.py +++ b/src/leapflow/copilot/predictors/l1_markov.py @@ -14,7 +14,7 @@ from __future__ import annotations import logging -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, Dict, List from leapflow.copilot.types import ( ContextState, diff --git a/src/leapflow/daemon/_service_helpers.py b/src/leapflow/daemon/_service_helpers.py index 1ff2c15..07c3a48 100644 --- a/src/leapflow/daemon/_service_helpers.py +++ b/src/leapflow/daemon/_service_helpers.py @@ -8,7 +8,7 @@ from leapflow.memory.protocol import MemoryEntry if TYPE_CHECKING: - from pathlib import Path + pass logger = logging.getLogger(__name__) @@ -183,8 +183,8 @@ class ProducerServices: def __init__(self, service: Any) -> None: self._service = service - async def session_history(self) -> dict[str, Any]: - return await self._service.session_history() + async def session_history(self, session_id: str = "") -> dict[str, Any]: + return await self._service.session_history(session_id=session_id) async def analyze_session( self, diff --git a/src/leapflow/daemon/client.py b/src/leapflow/daemon/client.py index 100e8c2..e916afe 100644 --- a/src/leapflow/daemon/client.py +++ b/src/leapflow/daemon/client.py @@ -183,9 +183,16 @@ async def app_command(self, args: str = "") -> dict[str, Any]: result = await self.request("app.command", {"args": args}) return dict(result or {}) - async def command_execute(self, name: str, args: str = "") -> dict[str, Any]: - """Execute any engine-routed slash command via daemon.""" - result = await self.request("command.execute", {"name": name, "args": args}) + async def command_execute(self, name: str, args: str = "", session_id: str = "") -> dict[str, Any]: + """Execute any engine-routed slash command via daemon. + + ``session_id`` tells the daemon which client session the command belongs + to, so session-scoped commands (e.g. ``/board``) observe the caller's + conversation instead of whichever session was last active. + """ + result = await self.request( + "command.execute", {"name": name, "args": args, "session_id": session_id}, + ) return dict(result or {}) async def approval_status(self) -> dict[str, Any]: @@ -257,9 +264,11 @@ async def watch_findings( "watch.findings", {"watch_id": watch_id, "limit": limit, "offset": offset} ) or []) - async def session_history(self, *, limit: int = 200) -> dict[str, Any]: - """Return the current conversation transcript and counts.""" - return dict(await self.request("session.history", {"limit": limit}) or {}) + async def session_history(self, *, limit: int = 200, session_id: str = "") -> dict[str, Any]: + """Return a session's transcript and counts (empty id = current session).""" + return dict(await self.request( + "session.history", {"limit": limit, "session_id": session_id}, + ) or {}) async def session_analyze(self) -> dict[str, Any]: """Ensure a session-analysis watch and run one analysis cycle now.""" diff --git a/src/leapflow/daemon/protocol.py b/src/leapflow/daemon/protocol.py index daf56e2..73bb473 100644 --- a/src/leapflow/daemon/protocol.py +++ b/src/leapflow/daemon/protocol.py @@ -227,8 +227,8 @@ async def watch_findings( """Return persisted findings, newest first.""" ... - async def session_history(self, limit: int = 200) -> Dict[str, Any]: - """Return the current conversation transcript and turn/token counts.""" + async def session_history(self, limit: int = 200, session_id: str = "") -> Dict[str, Any]: + """Return a session's transcript and counts (empty id = current session).""" ... async def session_analyze(self) -> Dict[str, Any]: @@ -267,7 +267,7 @@ async def app_command(self, args: str = "") -> Dict[str, Any]: """Return an App Connector slash-command payload.""" ... - async def command_execute(self, name: str, args: str = "") -> Dict[str, Any]: + async def command_execute(self, name: str, args: str = "", session_id: str = "") -> Dict[str, Any]: """Execute any engine-routed slash command and return structured result.""" ... diff --git a/src/leapflow/daemon/service.py b/src/leapflow/daemon/service.py index c1fce56..e0e6178 100644 --- a/src/leapflow/daemon/service.py +++ b/src/leapflow/daemon/service.py @@ -216,11 +216,29 @@ async def engine_chat(self, message: str, **kwargs: Any) -> AsyncIterator[Stream "Deferred init still in progress after %.0fs; serving turn " "in critical-only mode", self._DEFERRED_WAIT_TIMEOUT_S, ) + yield StreamChunk( + event_type="status", + content=( + "Runtime is still warming up; answering with core " + "capabilities only. Full capabilities return shortly." + ), + request_id=request_id, + metadata={"degraded": "warmup"}, + ) except Exception: logger.warning( "Deferred init failed; serving turn in critical-only mode", exc_info=True, ) + yield StreamChunk( + event_type="status", + content=( + "Some runtime components failed to initialize; answering " + "with core capabilities only. See daemon logs for details." + ), + request_id=request_id, + metadata={"degraded": "deferred_init_failed"}, + ) # Busy-feedback for clients when all slots occupied if self._turn_admission.locked(): @@ -451,8 +469,10 @@ async def session_create(self, **kwargs: Any) -> dict[str, Any]: async def session_resume(self, session_id: str) -> dict[str, Any]: return await self._session_coordinator.resume(self.context, session_id) - async def session_history(self, limit: int = 200) -> dict[str, Any]: - return await self._session_coordinator.get_history(self._ctx, self._settings, limit=limit) + async def session_history(self, limit: int = 200, session_id: str = "") -> dict[str, Any]: + return await self._session_coordinator.get_history( + self._ctx, self._settings, limit=limit, session_id=session_id, + ) async def session_analyze(self) -> dict[str, Any]: return await self._session_coordinator.analyze(self._monitors, self._ctx, self._settings) @@ -600,9 +620,9 @@ async def app_command(self, args: str = "") -> dict[str, Any]: from leapflow.cli.commands.slash_handlers import build_app_payload return await build_app_payload(self.context, args) - async def command_execute(self, name: str, args: str = "") -> dict[str, Any]: + async def command_execute(self, name: str, args: str = "", session_id: str = "") -> dict[str, Any]: from leapflow.cli.commands.slash_handlers import command_execute - return await command_execute(self.context, name, args) + return await command_execute(self.context, name, args, session_id=session_id) # ── Delegate: gateway (stubs) ──────────────────────────────────── diff --git a/src/leapflow/daemon/session_coordinator.py b/src/leapflow/daemon/session_coordinator.py index 78afa39..98368ca 100644 --- a/src/leapflow/daemon/session_coordinator.py +++ b/src/leapflow/daemon/session_coordinator.py @@ -104,12 +104,41 @@ async def resume(self, ctx: Any, session_id: str) -> dict[str, Any]: # ── History ─────────────────────────────────────────────────────────── - async def get_history(self, ctx: Any, settings: Any, limit: int = 200) -> dict[str, Any]: - """Get session message history with token stats.""" + def resolve_session_engine(self, ctx: Any, session_id: str = "") -> tuple[Any, str]: + """Return the engine holding a session's live state, plus its id. + + Conversation state lives on per-session engines from the registry, never + on ``ctx.engine`` (the base engine is only a template for building them). + Reading the base engine therefore observes an empty conversation — the + cause of an empty LeapBoard. Resolution order: + + 1. the requested ``session_id``, when that session is live; + 2. otherwise the most recently active session ("the current session"); + 3. finally the base engine, for in-process mode where it *is* the engine. + """ + base = getattr(ctx, "engine", None) if ctx is not None else None + registry = self._session_registry + if registry is not None: + session_ctx = registry.get(session_id) if session_id else None + if session_ctx is None and not session_id: + session_ctx = registry.most_recent() + if session_ctx is not None: + return session_ctx.engine, str(session_ctx.session_id or "") + if base is None: + return None, "" + return base, str(getattr(base, "_current_session_id", "") or "") + + async def get_history( + self, ctx: Any, settings: Any, limit: int = 200, session_id: str = "", + ) -> dict[str, Any]: + """Get session message history with token stats. + + ``session_id`` selects which live session to read; empty means "the + current session" (most recently active). + """ if ctx is None: return {"session_id": "", "turn_count": 0, "token_count": 0, "messages": [], "artifacts": []} - engine = getattr(ctx, "engine", None) - session_id = getattr(engine, "_current_session_id", "") if engine else "" + engine, session_id = self.resolve_session_engine(ctx, session_id) messages: list[dict[str, Any]] = [] if engine is not None: wm = getattr(engine, "_wm", None) diff --git a/src/leapflow/daemon/session_registry.py b/src/leapflow/daemon/session_registry.py index e59f36a..5a68eaf 100644 --- a/src/leapflow/daemon/session_registry.py +++ b/src/leapflow/daemon/session_registry.py @@ -6,9 +6,12 @@ (isolated substrate), while turns *within* a session serialize on the session lock. A daemon-wide semaphore (wired in P3-2b/P3-4) bounds total concurrency. -The first session to arrive reuses the daemon's existing base engine, so a -single-session daemon (the common case) is byte-for-byte unchanged; only -additional concurrent sessions get isolated per-session engines. +Every session — including the first — gets its own engine built via +``build_session_engine``, so all sessions follow one homogeneous path and the +daemon's base engine is never mutated by conversation state. Consumers that +need a session's live state (e.g. session-analysis watches feeding LeapBoard) +must therefore resolve it through this registry rather than reading the base +engine, which carries no conversation. This module is pure infrastructure: it does not import daemon internals and is unit-tested in isolation. Wiring into ``engine_chat`` (session-id routing) is @@ -147,3 +150,22 @@ def active_count(self) -> int: def session_ids(self) -> List[str]: return list(self._contexts.keys()) + + def get(self, session_id: str) -> Optional[SessionExecutionContext]: + """Return an existing session context without creating one. + + Read-only lookup for consumers that observe a session (e.g. the + session-analysis watch) and must never materialize an engine. + """ + return self._contexts.get(str(session_id or "")) + + def most_recent(self) -> Optional[SessionExecutionContext]: + """Return the most recently active session context, if any. + + Defines "the current session" for consumers that have no session id of + their own. Since the base engine never holds conversation state, this is + the only meaningful fallback for observing live activity. + """ + if not self._contexts: + return None + return max(self._contexts.values(), key=lambda ctx: ctx.last_active) diff --git a/src/leapflow/engine/engine.py b/src/leapflow/engine/engine.py index b7fbd94..74313ba 100644 --- a/src/leapflow/engine/engine.py +++ b/src/leapflow/engine/engine.py @@ -62,7 +62,11 @@ ToolCall as ConcurrentToolCall, ToolConcurrencyPolicy, ) -from leapflow.engine.tool_execution import ToolExecutionLedger, execution_policy_for +from leapflow.engine.tool_execution import ( + ToolExecutionLedger, + effect_is_uncertain_on_failure, + execution_policy_for, +) from leapflow.engine.graph_planner import GraphPlanner from leapflow.engine.scheduler import TaskScheduler from leapflow.engine.session import SessionController, SessionMode @@ -204,6 +208,9 @@ def _tool_result_metadata( "counts_as_failure", "counts_as_tool_attempt", "ui_hidden", "skipped_reason", "blocked_by_tool", "blocked_by_error", "execution_id", "idempotency_key", "execution_status", "execution_policy", "tool_call_id", + # Must reach the model: a failed side effect whose fate is unknown + # needs verification, not a blind retry. + "side_effect_uncertain", "retry_guidance", ): if key in result: metadata[key] = result[key] @@ -283,6 +290,22 @@ def _unknown_tool_retry_prompt(result: Dict[str, Any]) -> str: ) +# Empty-response hardening: an LLM call that "succeeds" with empty content is a +# failure signal, never a valid answer. It gets one bounded retry with an +# explicit nudge; a second empty response produces a transparent degraded +# message instead of a fake-success filler. +_EMPTY_RESPONSE_RETRY_PROMPT = ( + "SYSTEM: Your previous reply was empty. Respond to the user's request now " + "with substantive content. If you cannot help, say so explicitly." +) + +_EMPTY_RESPONSE_DEGRADED_MESSAGE = ( + "The model returned an empty response twice, so no answer was produced for " + "this turn. This is usually transient (e.g., provider or runtime warm-up " + "right after startup) \u2014 please resend your message." +) + + def _is_permission_failure_payload(payload: Dict[str, Any]) -> bool: """Return whether a tool-result payload represents an unresolved permission failure.""" return is_permission_failure_payload(payload) @@ -319,6 +342,92 @@ def _tool_failure_text(payload: Dict[str, Any]) -> str: return "unknown error" +def _terminal_failure_text(decision: Any) -> str: + """Render a terminal recovery decision for the user. + + When the decision carries an ``InteractionRequest``, its title, description, + and suggested actions are what the user needs in order to act; the raw + ``reason`` is written for the audit log. Falling back to ``reason`` alone + (the previous behavior) told the user a turn had stopped without saying what + to do about it. + """ + interaction = getattr(decision, "interaction", None) + if interaction is None: + return str(getattr(decision, "reason", "") or "") + + lines = [str(interaction.title or "Input needed to continue")] + if interaction.description: + lines.append(str(interaction.description)) + for action in interaction.suggested_actions or (): + label = str(getattr(action, "label", "") or "") + command = str(getattr(action, "command", "") or "") + entry = f" - {label}" if label else " -" + if command: + entry += f": {command}" + lines.append(entry) + return "\n".join(line for line in lines if line.strip()) + + +def _interaction_metadata(decision: Any) -> Dict[str, Any]: + """Return the structured InteractionRequest payload, or ``{}``. + + Carried on the stream event so the TUI/gateway can render a typed prompt and + resume via ``resumption_key`` instead of parsing the message text. + """ + interaction = getattr(decision, "interaction", None) + if interaction is None: + return {} + return { + "interaction": { + "request_id": interaction.request_id, + "interaction_type": getattr(interaction.interaction_type, "value", str(interaction.interaction_type)), + "severity": getattr(interaction.severity, "value", str(interaction.severity)), + "title": interaction.title, + "description": interaction.description, + "suggested_actions": [ + { + "label": str(getattr(action, "label", "") or ""), + "command": str(getattr(action, "command", "") or ""), + "description": str(getattr(action, "description", "") or ""), + "is_default": bool(getattr(action, "is_default", False)), + } + for action in interaction.suggested_actions or () + ], + "resumption_key": interaction.resumption_key, + "timeout_behavior": getattr( + interaction.timeout_behavior, "value", str(interaction.timeout_behavior) + ), + "context": interaction.context_dict, + } + } + + +def _annotate_uncertain_effect(payload: Dict[str, Any], policy: str) -> Dict[str, Any]: + """Mark a failed side-effecting result whose effect may already have landed. + + A timeout or transport error on an outbound send does not mean the message + was not delivered, so the model must verify before resending. Without this + the failure reads as a plain "did not happen" and the natural next step is a + blind retry that duplicates the effect. Batch-level protection already stops + the rest of the batch (see ``_should_stop_after_tool_result``); this carries + the same knowledge across turns, where the model decides what to do next. + + Advisory by design: only the tool's own state can settle whether the effect + landed, so a hard block would also reject legitimate retries (e.g. resending + after fixing an argument). + """ + if not _tool_result_counts_as_failure(payload): + return payload + if not effect_is_uncertain_on_failure(policy): + return payload + payload["side_effect_uncertain"] = True + payload["retry_guidance"] = ( + "This operation may already have taken effect despite the error. " + "Verify the current state before retrying; do not simply repeat the call." + ) + return payload + + def _should_stop_after_tool_result(tool_name: str, payload: Dict[str, Any]) -> bool: """Return whether a failed side-effect result must stop the current tool batch. @@ -1405,6 +1514,54 @@ def _evaluate_tool_failures( )) return None + def _save_halt_checkpoint( + self, + decision: Any, + envelope: Any, + messages: List[Dict[str, Any]], + *, + budget_used: int, + tools_kwarg: Optional[Dict[str, Any]] = None, + use_native_tools: bool = False, + ) -> None: + """Persist a resumable checkpoint for a ``HALT_WITH_CHECKPOINT`` decision. + + Shared by every terminal dispatch site (streaming included): the gate + that withholds a replay after a side effect emits this action from any + path, and a halt that skips the save leaves the decision's + ``resumption_key`` pointing at nothing. The envelope id and the + interaction's request id are recorded so the client can find this + checkpoint again from the ``InteractionRequest`` it was shown + (``resumption_key`` == envelope id; lookup via ``list_pending``). + """ + interaction = getattr(decision, "interaction", None) + try: + checkpoint = RecoveryCheckpoint( + session_id=getattr(self, '_current_session_id', '') or '', + turn_id=budget_used, + failure_envelope_data={ + "envelope_id": envelope.envelope_id, + "message": envelope.message, + "category": envelope.category, + "failure_code": envelope.failure_code, + "source": envelope.source.value, + "side_effect_state": envelope.side_effect_state.value, + }, + interaction_request_id=( + interaction.request_id if interaction is not None else "" + ), + messages_snapshot=list(messages), + context_data={ + "resumption_key": getattr(interaction, "resumption_key", "") or "", + "tools_kwarg_keys": list((tools_kwarg or {}).keys()), + "use_native_tools": use_native_tools, + "budget_used": budget_used, + }, + ) + self._checkpoint_store.save(checkpoint) + except Exception: # noqa: BLE001 - a failed save must not mask the halt itself + logger.warning("recovery: failed to persist halt checkpoint", exc_info=True) + def set_tool_timeouts(self, timeouts: Dict[str, float]) -> None: """Set per-tool execution timeout overrides (seconds).""" self._tool_timeouts = dict(timeouts) @@ -2829,24 +2986,13 @@ async def _run_agent_loop(self, frame: AgentLoopFrame) -> str: elif decision.action in (RecoveryAction.HALT_CLEAN, RecoveryAction.HALT_WITH_CHECKPOINT): if decision.action == RecoveryAction.HALT_WITH_CHECKPOINT: - checkpoint = RecoveryCheckpoint( - session_id=getattr(self, '_current_session_id', '') or '', - turn_id=budget.used, - failure_envelope_data={ - "message": envelope.message, - "category": envelope.category, - "failure_code": envelope.failure_code, - "source": envelope.source.value, - }, - messages_snapshot=list(messages), - context_data={ - "tools_kwarg_keys": list(tools_kwarg.keys()), - "use_native_tools": use_native_tools, - "budget_used": budget.used, - }, + self._save_halt_checkpoint( + decision, envelope, messages, + budget_used=budget.used, + tools_kwarg=tools_kwarg, + use_native_tools=use_native_tools, ) - self._checkpoint_store.save(checkpoint) - fatal_error = decision.reason + fatal_error = _terminal_failure_text(decision) self._audit_sink.update_outcome( decision.decision_id, "failure", reason="Terminal halt", @@ -2854,8 +3000,14 @@ async def _run_agent_loop(self, frame: AgentLoopFrame) -> str: break else: - # ASK_USER, SKIP_AND_CONTINUE, or unknown - fatal_error = decision.reason + # ASK_USER, SKIP_AND_CONTINUE, or unknown. ASK_USER carries an + # InteractionRequest describing what the user must decide; + # surfacing only decision.reason would drop it. + if decision.action == RecoveryAction.HALT_WITH_CHECKPOINT: + self._save_halt_checkpoint( + decision, envelope, messages, budget_used=budget.used, + ) + fatal_error = _terminal_failure_text(decision) break _clear_indicator() @@ -3234,6 +3386,7 @@ async def _unified_tool_loop_stream( use_native_tools = assembly.plan.native_tools result_budget = self._effective_tool_result_budget() unknown_tool_retry_used = False + empty_response_retry_used = False self._usage_tracker.reset() tools_kwarg: Dict[str, Any] = self._planned_tools_kwarg(assembly.plan) @@ -3338,8 +3491,19 @@ async def _unified_tool_loop_stream( continue else: # Terminal: HALT_CLEAN, HALT_WITH_CHECKPOINT, ASK_USER - fatal_error = decision.reason - yield StreamEvent(type="error", content=decision.reason) + if decision.action == RecoveryAction.HALT_WITH_CHECKPOINT: + self._save_halt_checkpoint( + decision, envelope, messages, + budget_used=budget.used, + tools_kwarg=tools_kwarg, + use_native_tools=use_native_tools, + ) + fatal_error = _terminal_failure_text(decision) + yield StreamEvent( + type="error", + content=fatal_error, + metadata=_interaction_metadata(decision), + ) break _clear_indicator() @@ -3528,9 +3692,17 @@ async def _unified_tool_loop_stream( coordinator.on_strategy_outcome(decision.decision_id, True) continue else: - fatal_error = decision.reason + if decision.action == RecoveryAction.HALT_WITH_CHECKPOINT: + self._save_halt_checkpoint( + decision, envelope, messages, budget_used=budget.used, + ) + fatal_error = _terminal_failure_text(decision) logger.error("unified_loop_stream: unrecoverable %s: %s", envelope.category, exc) - yield StreamEvent(type="error", content=decision.reason) + yield StreamEvent( + type="error", + content=fatal_error, + metadata=_interaction_metadata(decision), + ) break content = "".join(content_parts).strip() @@ -3587,9 +3759,17 @@ async def _unified_tool_loop_stream( coordinator.on_strategy_outcome(decision.decision_id, True) continue else: - fatal_error = decision.reason + if decision.action == RecoveryAction.HALT_WITH_CHECKPOINT: + self._save_halt_checkpoint( + decision, envelope, messages, budget_used=budget.used, + ) + fatal_error = _terminal_failure_text(decision) logger.error("unified_loop_stream: unrecoverable %s: %s", envelope.category, exc) - yield StreamEvent(type="error", content=decision.reason) + yield StreamEvent( + type="error", + content=fatal_error, + metadata=_interaction_metadata(decision), + ) break _clear_indicator() content = (resp.content or "").strip() @@ -3607,11 +3787,31 @@ async def _unified_tool_loop_stream( tool_call = self._parse_tool_call_from_content(content) if tool_call is None: + if not content and not empty_response_retry_used: + # Empty successful response: treat as a transient failure and + # retry once with an explicit nudge (mirrors the bounded + # unknown-tool retry). WARNING-level so the field log always + # captures the occurrence for diagnosis. + empty_response_retry_used = True + logger.warning( + "unified_loop_stream: empty LLM response " + "(model=%s provider=%s stream=%s); retrying once", + getattr(self._llm, "model", ""), + getattr(self._llm, "active_provider_name", "") or getattr(self._llm, "provider", ""), + self._settings.stream_output, + ) + messages.append(build_user_message_text(_EMPTY_RESPONSE_RETRY_PROMPT)) + continue self._wm.remember_chat(build_assistant_message(content)) trace.record(ExecutionMode.COMPLETE) if not content: + logger.warning( + "unified_loop_stream: empty LLM response persisted after retry " + "(model=%s); emitting transparent degraded message", + getattr(self._llm, "model", ""), + ) fallback = _app_onboarding_recovery_message(messages) - final_text = fallback or "I processed your request but have no additional output." + final_text = fallback or _EMPTY_RESPONSE_DEGRADED_MESSAGE self._emit_chat_event("response", {"content": final_text[:500]}) yield StreamEvent(type="final", content=final_text) else: @@ -4117,6 +4317,7 @@ async def _execute_tool_with_ledger( "execution_policy": policy, "tool_call_id": tool_call_id, } + _annotate_uncertain_effect(failed_result, policy) self._tool_execution_ledger.complete(record, failed_result) raise if isinstance(result, dict): @@ -4136,6 +4337,9 @@ async def _execute_tool_with_ledger( "execution_policy": policy, "tool_call_id": tool_call_id, } + # Annotated before the ledger completes so the recorded result and the + # copy the model sees carry the same verdict. + _annotate_uncertain_effect(result_for_ledger, policy) completed = self._tool_execution_ledger.complete(record, result_for_ledger) result_for_ledger["execution_status"] = completed.status return result_for_ledger @@ -4372,7 +4576,7 @@ def _tool_execution_metadata(result: Any) -> Dict[str, Any]: "already_executed", "duplicate_suppressed", "execution_reused", "execution_skipped", "counts_as_failure", "counts_as_tool_attempt", "ui_hidden", "skipped_reason", "blocked_by_tool", "blocked_by_error", "tool_call_id", "path", "file_path", - "bytes_written", + "bytes_written", "side_effect_uncertain", ): if key in result: metadata[key] = result[key] diff --git a/src/leapflow/engine/failure_envelope.py b/src/leapflow/engine/failure_envelope.py index 4e49cfd..cba57f0 100644 --- a/src/leapflow/engine/failure_envelope.py +++ b/src/leapflow/engine/failure_envelope.py @@ -8,7 +8,7 @@ import time import uuid -from dataclasses import dataclass, field +from dataclasses import dataclass from enum import Enum from typing import Any diff --git a/src/leapflow/engine/recovery_audit.py b/src/leapflow/engine/recovery_audit.py index 898f2e1..4f4a1c7 100644 --- a/src/leapflow/engine/recovery_audit.py +++ b/src/leapflow/engine/recovery_audit.py @@ -13,6 +13,12 @@ from pathlib import Path from typing import Any, Protocol +# Imported at runtime (no import cycle): the audit entry's annotations must stay +# resolvable for typing.get_type_hints() introspection, not just static checks. +from leapflow.engine.failure_envelope import FailureEnvelope +from leapflow.engine.recovery_budget import RecoveryBudget +from leapflow.engine.recovery_decision import RecoveryDecision + logger = logging.getLogger(__name__) @@ -149,9 +155,6 @@ def create_audit_entry( Handles attribute access safely for enum values and budget internals. """ - from leapflow.engine.failure_envelope import FailureEnvelope # noqa: F811 - from leapflow.engine.recovery_decision import RecoveryDecision # noqa: F811 - from leapflow.engine.recovery_budget import RecoveryBudget # noqa: F811 return RecoveryAuditEntry( timestamp=time.time(), diff --git a/src/leapflow/engine/recovery_coordinator.py b/src/leapflow/engine/recovery_coordinator.py index 5858bf9..cb0d440 100644 --- a/src/leapflow/engine/recovery_coordinator.py +++ b/src/leapflow/engine/recovery_coordinator.py @@ -8,11 +8,17 @@ import logging import time -import uuid -from dataclasses import dataclass, field +from dataclasses import dataclass from typing import Any, Protocol, runtime_checkable -from leapflow.engine.failure_envelope import FailureEnvelope, Recoverability +from leapflow.engine.failure_envelope import FailureEnvelope, Recoverability, SideEffectState +from leapflow.engine.interaction_request import ( + InteractionRequest, + InteractionType, + Severity, + SuggestedAction, + TimeoutBehavior, +) from leapflow.engine.oneshot_guard import OneShotGuard from leapflow.engine.recovery_budget import RecoveryBudget from leapflow.engine.recovery_decision import ( @@ -23,6 +29,14 @@ logger = logging.getLogger(__name__) +# Actions that re-run work already attempted. Safe only when the failed attempt +# left no trace; after a mutation they can duplicate the effect. +_REPLAYING_ACTIONS = frozenset({ + RecoveryAction.RETRY_WITH_BACKOFF, + RecoveryAction.TRANSFORM_AND_RETRY, + RecoveryAction.FAILOVER, +}) + @dataclass class RecoveryState: @@ -153,6 +167,7 @@ def evaluate(self, envelope: FailureEnvelope) -> RecoveryDecision: return decision # Strategy evaluation + blocked_by_side_effect: list[str] = [] for strategy in self._strategies: if not self._matches_source(strategy, envelope): continue @@ -166,6 +181,16 @@ def evaluate(self, envelope: FailureEnvelope) -> RecoveryDecision: # Budget pre-check for the strategy's expected cost decision = strategy.decide(envelope, self._state) + + # Side-effect gating: once the attempt may have mutated state, a + # replaying action can duplicate it. Reject the candidate (rolling + # back whatever decide() staged) and keep looking for a strategy + # that does not replay; if none exists, halt with a checkpoint below. + if self._replay_blocked_by_side_effect(envelope, decision): + self._rollback_state_changes(decision, strategy) + blocked_by_side_effect.append(strategy.key) + continue + if decision.budget_cost > 0: if not self._budget.can_afford(decision.budget_cost, envelope.category): # Rollback any state mutations from decide() @@ -190,11 +215,83 @@ def evaluate(self, envelope: FailureEnvelope) -> RecoveryDecision: self._record_audit(decision, strategy_key=strategy.key) return decision + if blocked_by_side_effect: + decision = self._side_effect_halt(envelope, blocked_by_side_effect) + self._record_audit(decision, strategy_key="") + return decision + # No strategy matched decision = self.terminal_decision(envelope) self._record_audit(decision, strategy_key="") return decision + @staticmethod + def _replay_blocked_by_side_effect( + envelope: FailureEnvelope, decision: RecoveryDecision, + ) -> bool: + """Return whether the side-effect state forbids this replaying action. + + Every state other than ``NONE`` blocks: ``COMMITTED`` and ``PARTIAL`` + mean state changed, and ``UNKNOWN`` means it may have. ``UNKNOWN`` is + deliberately included rather than treated as safe — it is both the + classifier's fallback and the state it assigns to ``external_side_effect`` + (an outbound send, an external API call), so exempting it would leave the + highest-risk case ungated. + """ + if envelope.side_effect_state is SideEffectState.NONE: + return False + return decision.action in _REPLAYING_ACTIONS + + def _side_effect_halt( + self, envelope: FailureEnvelope, blocked_strategies: list[str], + ) -> RecoveryDecision: + """Halt with a checkpoint because replaying could duplicate an effect. + + Resumption is user-mediated on purpose: only the user (or a read-back of + the target's state) can settle whether the effect landed. + """ + tool_name = envelope.context.tool_name or "the operation" + state_label = envelope.side_effect_state.value + reason = ( + f"Automatic retry is blocked: {tool_name} failed with side-effect state " + f"'{state_label}', so replaying it could duplicate the effect. " + f"Strategies withheld: {', '.join(blocked_strategies)}." + ) + interaction = InteractionRequest.create( + interaction_type=InteractionType.RETRY_CHOICE, + severity=Severity.WARNING, + title=f"{tool_name} may already have taken effect", + description=( + f"It failed with '{envelope.failure_code or envelope.category}', but its " + f"side-effect state is '{state_label}', so retrying automatically could " + "apply it twice. Check whether it took effect, then decide how to proceed." + ), + suggested_actions=( + SuggestedAction( + label="Verify the target state, then retry if it did not apply", + is_default=True, + ), + SuggestedAction(label="Abandon this step and continue"), + ), + context={ + "tool_name": tool_name, + "side_effect_state": state_label, + "failure_code": envelope.failure_code, + "category": envelope.category, + }, + resumption_key=envelope.envelope_id, + timeout_behavior=TimeoutBehavior.PERSIST, + ) + return RecoveryDecision.create( + envelope=envelope, + action=RecoveryAction.HALT_WITH_CHECKPOINT, + reason=reason, + strategy_key="", + retry_semantics=RetrySemantics(consumes_retry_budget=False), + budget_cost=0, + interaction=interaction, + ) + def on_strategy_outcome(self, decision_id: str, success: bool) -> None: """Record the outcome of executing a recovery decision. diff --git a/src/leapflow/engine/situational_assessor.py b/src/leapflow/engine/situational_assessor.py index d6d838f..de8a6a1 100644 --- a/src/leapflow/engine/situational_assessor.py +++ b/src/leapflow/engine/situational_assessor.py @@ -17,7 +17,7 @@ from dataclasses import dataclass, field from enum import Enum from pathlib import Path -from typing import Any, Dict, List, Optional, Protocol, runtime_checkable +from typing import Any, Dict, List, Protocol, runtime_checkable from leapflow.skills.registry import Skill diff --git a/src/leapflow/engine/tool_execution.py b/src/leapflow/engine/tool_execution.py index 39db700..98b287e 100644 --- a/src/leapflow/engine/tool_execution.py +++ b/src/leapflow/engine/tool_execution.py @@ -12,6 +12,17 @@ ExecutionPolicy = Literal["read_only", "mutating_idempotent", "mutating_once", "external_side_effect"] ExecutionStatus = Literal["reserved", "running", "completed", "failed_retryable", "failed_final"] +# Policies whose failure leaves the effect's fate unknown: the call may already +# have landed (a delivered message, a committed write) even though it reported an +# error, so a blind retry can duplicate it. ``mutating_idempotent`` is absent on +# purpose — re-applying it converges, so flagging it would stall safe retries. +UNCERTAIN_EFFECT_POLICIES: frozenset[str] = frozenset({"external_side_effect", "mutating_once"}) + + +def effect_is_uncertain_on_failure(policy: str) -> bool: + """Return whether a failed call under ``policy`` may still have taken effect.""" + return str(policy or "") in UNCERTAIN_EFFECT_POLICIES + _EXTERNAL_TOOLS = frozenset({ "shell_run", "scm_sync", @@ -236,6 +247,14 @@ def duplicate_result(record: ToolExecutionRecord) -> dict[str, Any]: } if not completed: payload["error"] = "An identical side-effect attempt is already recorded. Review the original result before retrying." + # Preserve the original attempt's uncertainty verdict: if that failure may + # already have taken effect, the suppressed duplicate must say so too, or + # the model loses exactly the warning that told it to verify first. + original = record.result if isinstance(record.result, Mapping) else {} + if original.get("side_effect_uncertain"): + payload["side_effect_uncertain"] = True + if original.get("retry_guidance"): + payload["retry_guidance"] = original["retry_guidance"] return payload def _get_durable(self, session_id: str, key: str) -> ToolExecutionRecord | None: diff --git a/src/leapflow/gateway/adapters/dingtalk.py b/src/leapflow/gateway/adapters/dingtalk.py index 6e36921..1b51ad3 100644 --- a/src/leapflow/gateway/adapters/dingtalk.py +++ b/src/leapflow/gateway/adapters/dingtalk.py @@ -8,7 +8,6 @@ HttpRequest, HttpResponse, JsonHttpClient, - TinyJsonHttpServer, UrlLibJsonHttpClient, parse_bind_port, parse_json_object, diff --git a/src/leapflow/gateway/backends/cli_backend.py b/src/leapflow/gateway/backends/cli_backend.py index 27cfd56..6182ab8 100644 --- a/src/leapflow/gateway/backends/cli_backend.py +++ b/src/leapflow/gateway/backends/cli_backend.py @@ -9,6 +9,7 @@ from leapflow.gateway.connectors.cli_discovery import CliDiscovery from leapflow.gateway.connectors.protocol import ( + ActionFailure, ActionPreview, ActionResult, ActionSpec, diff --git a/src/leapflow/gateway/connectors/cli_discovery.py b/src/leapflow/gateway/connectors/cli_discovery.py index b30e407..10ec40c 100644 --- a/src/leapflow/gateway/connectors/cli_discovery.py +++ b/src/leapflow/gateway/connectors/cli_discovery.py @@ -28,7 +28,7 @@ import re import time from dataclasses import dataclass, field -from typing import Any, Mapping, Sequence +from typing import Any, Sequence from leapflow.gateway.connectors.protocol import ActionSpec, BackendKind @@ -212,7 +212,6 @@ def _extract_arguments(self, lines: list[str]) -> list[HelpArgument]: continue match = _FLAG_RE.match(line) if match: - short = match.group(1) or "" long_flag = match.group(2) value_hint = match.group(3) or "" desc = match.group(4).strip() diff --git a/src/leapflow/gateway/connectors/lark_event_source.py b/src/leapflow/gateway/connectors/lark_event_source.py index 655cf1e..fbf6756 100644 --- a/src/leapflow/gateway/connectors/lark_event_source.py +++ b/src/leapflow/gateway/connectors/lark_event_source.py @@ -4,7 +4,7 @@ import asyncio import json import logging -from typing import Any, AsyncIterator, NamedTuple, Sequence +from typing import AsyncIterator, NamedTuple, Sequence from leapflow.gateway.connectors.composite_event_source import CompositeEventSource from leapflow.gateway.connectors.event_sources import CliEventSourceConfig, CliNdjsonEventSource diff --git a/src/leapflow/gateway/connectors/protocol.py b/src/leapflow/gateway/connectors/protocol.py index dedeb45..ed84513 100644 --- a/src/leapflow/gateway/connectors/protocol.py +++ b/src/leapflow/gateway/connectors/protocol.py @@ -13,7 +13,12 @@ import time from dataclasses import dataclass, field from enum import Enum -from typing import Any, AsyncIterator, Dict, Mapping, Protocol, Sequence, runtime_checkable +from typing import Any, AsyncIterator, Dict, Mapping, Protocol, Sequence, TYPE_CHECKING, runtime_checkable + +if TYPE_CHECKING: + # Annotation-only: keeps this protocol module free of a runtime edge to the + # gateway domain types. + from leapflow.gateway.protocol import InboundMessage, MessageSource class BackendKind(str, Enum): diff --git a/src/leapflow/gateway/event_bridge.py b/src/leapflow/gateway/event_bridge.py index 25b79c2..6298a32 100644 --- a/src/leapflow/gateway/event_bridge.py +++ b/src/leapflow/gateway/event_bridge.py @@ -7,7 +7,6 @@ """ from __future__ import annotations -import asyncio import logging import time from dataclasses import asdict diff --git a/src/leapflow/gateway/resource_provenance.py b/src/leapflow/gateway/resource_provenance.py index d73e175..bd954dc 100644 --- a/src/leapflow/gateway/resource_provenance.py +++ b/src/leapflow/gateway/resource_provenance.py @@ -15,7 +15,7 @@ from __future__ import annotations import logging -from dataclasses import dataclass, field +from dataclasses import dataclass from enum import Enum from typing import Any, Dict, Mapping, Set, Sequence diff --git a/src/leapflow/gateway/server.py b/src/leapflow/gateway/server.py index 881cf60..bbf917e 100644 --- a/src/leapflow/gateway/server.py +++ b/src/leapflow/gateway/server.py @@ -35,7 +35,6 @@ ActionSpec, BackendEvent, BackendEventSource, - EventClassification, EventKind, EventSourceStatus, InboundCallback, diff --git a/src/leapflow/gateway/trigger_policy.py b/src/leapflow/gateway/trigger_policy.py index 932edc0..820b269 100644 --- a/src/leapflow/gateway/trigger_policy.py +++ b/src/leapflow/gateway/trigger_policy.py @@ -7,9 +7,8 @@ import time from collections import defaultdict -from dataclasses import dataclass, field +from dataclasses import dataclass from enum import Enum -from typing import Any from leapflow.gateway.protocol import InboundMessage diff --git a/src/leapflow/gateway/validators.py b/src/leapflow/gateway/validators.py deleted file mode 100644 index e58d59a..0000000 --- a/src/leapflow/gateway/validators.py +++ /dev/null @@ -1,153 +0,0 @@ -"""Platform credential validation functions. - -Each validator is a simple async function: -``(credentials: Dict[str, str]) → (ok: bool, error_or_info: str)``. - -Validators are registered by name so YAML manifests can reference them -declaratively. New platforms add a validator + register call — zero -core changes. -""" -from __future__ import annotations - -import asyncio -import logging -from typing import Any, Awaitable, Callable, Dict, Optional, Tuple - -logger = logging.getLogger(__name__) - -ValidatorFn = Callable[[Dict[str, str]], Awaitable[Tuple[bool, str]]] - -_registry: Dict[str, ValidatorFn] = {} - - -# ═══════════════════════════════════════════════════════════════ -# Registry API -# ═══════════════════════════════════════════════════════════════ - -def register_validator(name: str, fn: ValidatorFn) -> None: - """Register a credential validator by name.""" - _registry[name] = fn - - -def get_validator(name: str) -> Optional[ValidatorFn]: - """Retrieve a registered validator (or ``None``).""" - return _registry.get(name) - - -async def validate_credentials( - method_name: str, - credentials: Dict[str, str], - *, - timeout_s: float = 10.0, -) -> Tuple[bool, str]: - """Run the named validator with a timeout. - - Returns ``(True, "")`` if no validator is registered (safe default). - Errors are redacted before returning. - """ - fn = _registry.get(method_name) - if fn is None: - return True, "" - - try: - ok, msg = await asyncio.wait_for(fn(credentials), timeout=timeout_s) - return ok, msg - except asyncio.TimeoutError: - return False, f"Validation timed out after {timeout_s}s" - except Exception as exc: - from leapflow.security.redact import redact_sensitive_text - - safe_error = redact_sensitive_text(str(exc), force=True) - return False, f"Validation error: {safe_error}" - - -# ═══════════════════════════════════════════════════════════════ -# Built-in validators (for bundled manifests) -# ═══════════════════════════════════════════════════════════════ - -def _make_client_timeout() -> Any: - """Create a strict per-request timeout for credential validation. - - Imported lazily to avoid top-level ``aiohttp`` dependency. - """ - import aiohttp - - return aiohttp.ClientTimeout(total=8, connect=5) - - -async def _feishu_token_check(credentials: Dict[str, str]) -> Tuple[bool, str]: - """Validate Feishu credentials by fetching ``tenant_access_token``.""" - import aiohttp - - app_id = credentials.get("app_id", "") - app_secret = credentials.get("app_secret", "") - if not app_id or not app_secret: - return False, "Missing app_id or app_secret" - - url = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal" - async with aiohttp.ClientSession( - timeout=_make_client_timeout(), - trace_configs=[], - ) as session: - async with session.post( - url, - json={"app_id": app_id, "app_secret": app_secret}, - ) as resp: - data = await resp.json() - if data.get("code") == 0 and data.get("tenant_access_token"): - return True, "" - return False, data.get("msg", "Unknown error from Feishu API") - - -async def _dingtalk_token_check(credentials: Dict[str, str]) -> Tuple[bool, str]: - """Validate DingTalk credentials by fetching ``access_token``.""" - import aiohttp - - app_key = credentials.get("app_key", "") - app_secret = credentials.get("app_secret", "") - if not app_key or not app_secret: - return False, "Missing app_key or app_secret" - - url = "https://oapi.dingtalk.com/gettoken" - params = {"appkey": app_key, "appsecret": app_secret} - async with aiohttp.ClientSession( - timeout=_make_client_timeout(), - trace_configs=[], - ) as session: - async with session.get(url, params=params) as resp: - data = await resp.json() - if data.get("errcode") == 0 and data.get("access_token"): - return True, "" - return False, data.get("errmsg", "Unknown error from DingTalk API") - - -async def _telegram_getme(credentials: Dict[str, str]) -> Tuple[bool, str]: - """Validate Telegram bot token via ``getMe`` API. - - The token is embedded in the URL path (Telegram API convention). - ``trace_configs=[]`` disables aiohttp request tracing to prevent - the token-containing URL from appearing in debug logs. - """ - import aiohttp - - token = credentials.get("bot_token", "") - if not token: - return False, "Missing bot_token" - - url = f"https://api.telegram.org/bot{token}/getMe" - async with aiohttp.ClientSession( - timeout=_make_client_timeout(), - trace_configs=[], - ) as session: - async with session.get(url) as resp: - data = await resp.json() - if data.get("ok"): - bot_name = data.get("result", {}).get("username", "unknown") - return True, f"Bot: @{bot_name}" - return False, data.get("description", "Invalid bot token") - - -# ── Auto-register built-in validators ──────────────────────── -register_validator("feishu_token_check", _feishu_token_check) -register_validator("dingtalk_token_check", _dingtalk_token_check) -register_validator("telegram_getme", _telegram_getme) diff --git a/src/leapflow/gateway/validators/__init__.py b/src/leapflow/gateway/validators/__init__.py new file mode 100644 index 0000000..cfb298d --- /dev/null +++ b/src/leapflow/gateway/validators/__init__.py @@ -0,0 +1,112 @@ +"""Platform credential validation: a neutral registry plus per-vendor modules. + +Each validator is a simple async function: +``(credentials: Dict[str, str]) → (ok: bool, error_or_info: str)``. + +Validators are registered by name so YAML manifests can reference them +declaratively (``validation.method``). A new platform adds a module here plus a +``register_validator`` call — no change to gateway core. + +Vendor implementations live in sibling modules (``dingtalk.py``, +``telegram.py``) rather than in this file, keeping platform endpoints and error +shapes out of gateway core, alongside how ``adapters/`` and ``normalizers/`` are +organized. They are imported eagerly at the bottom of this module because +``GatewayServer.configure_platform`` validates credentials *before* it builds +the adapter: a validator registered lazily from an adapter module would not +exist yet at that point. +""" +from __future__ import annotations + +import asyncio +import logging +from typing import Awaitable, Callable, Dict, Optional, Tuple + +logger = logging.getLogger(__name__) + +ValidatorFn = Callable[[Dict[str, str]], Awaitable[Tuple[bool, str]]] + +_registry: Dict[str, ValidatorFn] = {} + + +# ═══════════════════════════════════════════════════════════════ +# Registry API +# ═══════════════════════════════════════════════════════════════ + +def register_validator(name: str, fn: ValidatorFn) -> None: + """Register a credential validator by name.""" + _registry[name] = fn + + +def get_validator(name: str) -> Optional[ValidatorFn]: + """Retrieve a registered validator (or ``None``).""" + return _registry.get(name) + + +def registered_validators() -> tuple[str, ...]: + """Return the registered validator names (sorted, for diagnostics).""" + return tuple(sorted(_registry)) + + +async def validate_credentials( + method_name: str, + credentials: Dict[str, str], + *, + timeout_s: float = 10.0, +) -> Tuple[bool, str]: + """Run the named validator with a timeout. + + An empty ``method_name`` means the manifest opts out of validation, which is + a legitimate configuration and passes. A *named but unregistered* validator + is a configuration error, and is rejected rather than passed: silently + treating it as "valid" would store unverified credentials and surface the + problem much later as an opaque runtime failure. + + Errors are redacted before returning. + """ + if not method_name: + return True, "" + + fn = _registry.get(method_name) + if fn is None: + logger.warning( + "gateway: no credential validator registered for %r (registered: %s)", + method_name, ", ".join(registered_validators()) or "none", + ) + return False, ( + f"Credential validator {method_name!r} is not registered, so these " + "credentials cannot be verified." + ) + + try: + ok, msg = await asyncio.wait_for(fn(credentials), timeout=timeout_s) + except asyncio.TimeoutError: + return False, f"Validation timed out after {timeout_s}s" + except Exception as exc: # noqa: BLE001 - any vendor error becomes a safe message + from leapflow.security.redact import redact_sensitive_text + + safe_error = redact_sensitive_text(str(exc), force=True) + return False, f"Validation error: {safe_error}" + + if not ok and msg: + # Vendor error strings can echo request parameters back; never assume a + # third-party API keeps credentials out of its error messages. + from leapflow.security.redact import redact_sensitive_text + + msg = redact_sensitive_text(msg, force=True) + return ok, msg + + +# ── Register built-in validators (eager: see module docstring) ──────── +from leapflow.gateway.validators import dingtalk as _dingtalk # noqa: E402 +from leapflow.gateway.validators import telegram as _telegram # noqa: E402 + +register_validator("dingtalk_token_check", _dingtalk.token_check) +register_validator("telegram_getme", _telegram.getme) + +__all__ = [ + "ValidatorFn", + "get_validator", + "register_validator", + "registered_validators", + "validate_credentials", +] diff --git a/src/leapflow/gateway/validators/_http.py b/src/leapflow/gateway/validators/_http.py new file mode 100644 index 0000000..4c6fb5b --- /dev/null +++ b/src/leapflow/gateway/validators/_http.py @@ -0,0 +1,19 @@ +"""Shared HTTP helper for credential validators.""" + +from __future__ import annotations + +from typing import Any + + +def make_client_timeout() -> Any: + """Create a strict per-request timeout for credential validation. + + ``aiohttp`` is imported lazily so this package stays importable without the + optional dependency. + """ + import aiohttp + + return aiohttp.ClientTimeout(total=8, connect=5) + + +__all__ = ["make_client_timeout"] diff --git a/src/leapflow/gateway/validators/dingtalk.py b/src/leapflow/gateway/validators/dingtalk.py new file mode 100644 index 0000000..cd46727 --- /dev/null +++ b/src/leapflow/gateway/validators/dingtalk.py @@ -0,0 +1,37 @@ +"""DingTalk credential validator. + +Referenced declaratively by ``manifests/dingtalk.yaml`` as +``validation.method: dingtalk_token_check``. +""" + +from __future__ import annotations + +from typing import Dict, Tuple + +from leapflow.gateway.validators._http import make_client_timeout + + +async def token_check(credentials: Dict[str, str]) -> Tuple[bool, str]: + """Validate DingTalk credentials by fetching ``access_token``.""" + import aiohttp + + app_key = credentials.get("app_key", "") + app_secret = credentials.get("app_secret", "") + if not app_key or not app_secret: + return False, "Missing app_key or app_secret" + + url = "https://oapi.dingtalk.com/gettoken" + params = {"appkey": app_key, "appsecret": app_secret} + async with aiohttp.ClientSession( + timeout=make_client_timeout(), + trace_configs=[], + ) as session: + async with session.get(url, params=params) as resp: + data = await resp.json() + # DingTalk reports failure as errcode/errmsg. + if data.get("errcode") == 0 and data.get("access_token"): + return True, "" + return False, data.get("errmsg", "Unknown error from DingTalk API") + + +__all__ = ["token_check"] diff --git a/src/leapflow/gateway/validators/telegram.py b/src/leapflow/gateway/validators/telegram.py new file mode 100644 index 0000000..927cad8 --- /dev/null +++ b/src/leapflow/gateway/validators/telegram.py @@ -0,0 +1,41 @@ +"""Telegram credential validator. + +Referenced declaratively by ``manifests/telegram.yaml`` as +``validation.method: telegram_getme``. +""" + +from __future__ import annotations + +from typing import Dict, Tuple + +from leapflow.gateway.validators._http import make_client_timeout + + +async def getme(credentials: Dict[str, str]) -> Tuple[bool, str]: + """Validate a Telegram bot token via the ``getMe`` API. + + The token is embedded in the URL path (Telegram API convention), so + ``trace_configs=[]`` disables aiohttp request tracing to keep the + token-bearing URL out of debug logs. + """ + import aiohttp + + token = credentials.get("bot_token", "") + if not token: + return False, "Missing bot_token" + + url = f"https://api.telegram.org/bot{token}/getMe" + async with aiohttp.ClientSession( + timeout=make_client_timeout(), + trace_configs=[], + ) as session: + async with session.get(url) as resp: + data = await resp.json() + # Telegram reports failure as ok=false with a description. + if data.get("ok"): + bot_name = data.get("result", {}).get("username", "unknown") + return True, f"Bot: @{bot_name}" + return False, data.get("description", "Invalid bot token") + + +__all__ = ["getme"] diff --git a/src/leapflow/hub/backends/local.py b/src/leapflow/hub/backends/local.py index d79b79d..5754cf0 100644 --- a/src/leapflow/hub/backends/local.py +++ b/src/leapflow/hub/backends/local.py @@ -6,7 +6,6 @@ from __future__ import annotations -import asyncio import json import logging import os @@ -76,7 +75,7 @@ def _safe_repo_path(self, repo_id: str) -> Path: parts = PurePosixPath(repo_id).parts if not parts: - raise ValueError(f"Empty repo_id") + raise ValueError("Empty repo_id") if any(p == ".." for p in parts): raise ValueError(f"Path traversal detected in repo_id: '{repo_id}'") if PurePosixPath(repo_id).is_absolute(): diff --git a/src/leapflow/hub/client.py b/src/leapflow/hub/client.py index 40fb132..e7d3bca 100644 --- a/src/leapflow/hub/client.py +++ b/src/leapflow/hub/client.py @@ -8,7 +8,11 @@ import asyncio import logging -from typing import Callable, Dict, List, Optional, Tuple +from typing import Callable, Dict, List, Optional, Tuple, TYPE_CHECKING + +if TYPE_CHECKING: + # hub.sync imports HubClient, so this stays annotation-only to avoid a cycle. + from leapflow.hub.sync import SyncPlan from leapflow.hub.protocol import ( HubBackend, diff --git a/src/leapflow/hub/security.py b/src/leapflow/hub/security.py index b59fec0..da8bd73 100644 --- a/src/leapflow/hub/security.py +++ b/src/leapflow/hub/security.py @@ -9,7 +9,7 @@ import logging import re from dataclasses import dataclass -from typing import TYPE_CHECKING, ClassVar, List, Pattern +from typing import TYPE_CHECKING, ClassVar, List if TYPE_CHECKING: from leapflow.hub.protocol import SkillBundle diff --git a/src/leapflow/hub/sync.py b/src/leapflow/hub/sync.py index fdcae84..c2c20c3 100644 --- a/src/leapflow/hub/sync.py +++ b/src/leapflow/hub/sync.py @@ -384,7 +384,7 @@ async def _execute_pull(self, action: SyncAction) -> str: # Determine repo_id to pull from repo_id = action.repo_id or self._client._build_repo_id(action.skill_name) - bundle = await self._client.pull(repo_id) + await self._client.pull(repo_id) desc = ( f"Pulled '{action.skill_name}' v{action.remote_version} ← " diff --git a/src/leapflow/learning/active_learning.py b/src/leapflow/learning/active_learning.py index acb27c2..0eb793a 100644 --- a/src/leapflow/learning/active_learning.py +++ b/src/leapflow/learning/active_learning.py @@ -18,8 +18,10 @@ if TYPE_CHECKING: from leapflow.analysis.consensus import MultiTrajectoryDistiller + from leapflow.learning.document import ProvenanceEntry, SkillDocument from leapflow.skills.activator import SkillActivator from leapflow.learning.doc_generator import SkillDocGenerator + from leapflow.storage.bundle_writer import BundleFiles from leapflow.storage.skill_docs import SkillDocStore from leapflow.world_model.curiosity import CuriosityScore from leapflow.world_model.prediction import PredictionOutcome diff --git a/src/leapflow/learning/distiller.py b/src/leapflow/learning/distiller.py index d38558c..601436a 100644 --- a/src/leapflow/learning/distiller.py +++ b/src/leapflow/learning/distiller.py @@ -279,7 +279,6 @@ def _parse_llm_response( def _extract_variable_params(episode: "Episode") -> List[Dict[str, str]]: """Identify parameters that vary across actions (likely user-configurable).""" - import os.path params: List[Dict[str, str]] = [] seen_names: set[str] = set() diff --git a/src/leapflow/learning/doc_generator.py b/src/leapflow/learning/doc_generator.py index 5d666cd..043e807 100644 --- a/src/leapflow/learning/doc_generator.py +++ b/src/leapflow/learning/doc_generator.py @@ -12,7 +12,6 @@ import json import logging import os.path -import re import sys import textwrap from collections import Counter diff --git a/src/leapflow/learning/effectiveness.py b/src/leapflow/learning/effectiveness.py index 3db9e86..5b5f77f 100644 --- a/src/leapflow/learning/effectiveness.py +++ b/src/leapflow/learning/effectiveness.py @@ -12,7 +12,7 @@ import time import logging -from dataclasses import dataclass, field +from dataclasses import dataclass from typing import Any, Dict, List, Optional logger = logging.getLogger(__name__) diff --git a/src/leapflow/logging_setup.py b/src/leapflow/logging_setup.py new file mode 100644 index 0000000..b2cbc6d --- /dev/null +++ b/src/leapflow/logging_setup.py @@ -0,0 +1,92 @@ +"""Centralized process logging setup — the single owner of log configuration. + +Every LeapFlow process surface initializes logging through this module so that +format, secret redaction, and level policy stay consistent and are defined in +exactly one place: + +- CLI / TUI process -> ``init_cli_logging(settings)`` (runtime.log_level) +- leapd daemon process -> ``init_daemon_logging(settings)`` (daemon.log_level) +- auxiliary processes -> ``init_logging(level)`` (explicit level) + +Design rules: + +- **Single handler owner.** ``init_logging`` installs one stderr handler tagged + as LeapFlow-owned. Re-initialization is idempotent: it only updates levels, + never stacks duplicate handlers. +- **Redaction is not optional.** The handler always carries + ``RedactingFormatter`` so secrets never reach stdout/stderr or leapd.log. +- **No import-time side effects.** Nothing here runs at import; process entry + points call an init function explicitly. + +Scoped, temporary level changes on third-party loggers (e.g. muting a noisy +library during shutdown) are intentionally out of scope — they are local +concerns, not process configuration. +""" +from __future__ import annotations + +import logging +import sys +from typing import Any + +LOG_FORMAT = "%(asctime)s %(levelname)s %(name)s: %(message)s" + +# Attribute tag identifying the handler owned by this module. +_HANDLER_TAG = "_leapflow_log_handler" + + +def resolve_level(name: str, *, default: int = logging.WARNING) -> int: + """Map a level name to a logging constant, falling back to ``default``.""" + resolved = getattr(logging, str(name or "").strip().upper(), None) + return resolved if isinstance(resolved, int) else default + + +def _build_formatter() -> logging.Formatter: + try: + from leapflow.security.redact import RedactingFormatter + return RedactingFormatter(LOG_FORMAT) + except ImportError: # pragma: no cover - redact is a first-party module + return logging.Formatter(LOG_FORMAT) + + +def _owned_handler(root: logging.Logger) -> logging.Handler | None: + for handler in root.handlers: + if getattr(handler, _HANDLER_TAG, False): + return handler + return None + + +def init_logging(level: str, *, default: int = logging.WARNING) -> None: + """Initialize (or re-level) process logging. Idempotent. + + First call attaches one redacting stderr handler to the root logger and + sets the root level. Subsequent calls only adjust the level — handlers are + never duplicated, so this is safe to call from any entry point. + """ + resolved = resolve_level(level, default=default) + root = logging.getLogger() + handler = _owned_handler(root) + if handler is None: + handler = logging.StreamHandler(sys.stderr) + handler.setFormatter(_build_formatter()) + setattr(handler, _HANDLER_TAG, True) + root.addHandler(handler) + root.setLevel(resolved) + + +def init_cli_logging(settings: Any) -> None: + """Configure logging for the interactive CLI/TUI process. + + Driven by ``runtime.log_level`` (default WARNING: the interactive surface + stays quiet; verbose diagnostics belong to the daemon file log). + """ + init_logging(str(getattr(settings, "log_level", "") or "WARNING")) + + +def init_daemon_logging(settings: Any) -> None: + """Configure logging for the leapd daemon process. + + Driven by ``daemon.log_level`` (default INFO: stdout/stderr land in + leapd.log, so INFO field evidence — deferred init progress, turn usage, + empty-response warnings — is captured for diagnosis). + """ + init_logging(str(getattr(settings, "daemon_log_level", "") or "INFO"), default=logging.INFO) diff --git a/src/leapflow/memory/providers/evolution.py b/src/leapflow/memory/providers/evolution.py index 5806c93..4d29341 100644 --- a/src/leapflow/memory/providers/evolution.py +++ b/src/leapflow/memory/providers/evolution.py @@ -232,7 +232,8 @@ def handle_tool_call(self, tool_name: str, args: Dict[str, Any]) -> str: mq = MemoryQuery(keywords=keywords, limit=limit) # Synchronous wrapper for the async search try: - loop = asyncio.get_running_loop() + # Probe whether a loop is already running (RuntimeError = not). + asyncio.get_running_loop() # If we're inside an event loop, use run_until_complete workaround import concurrent.futures with concurrent.futures.ThreadPoolExecutor() as pool: diff --git a/src/leapflow/memory/providers/semantic.py b/src/leapflow/memory/providers/semantic.py index 3535796..6e5a2b0 100644 --- a/src/leapflow/memory/providers/semantic.py +++ b/src/leapflow/memory/providers/semantic.py @@ -642,7 +642,8 @@ def handle_tool_call(self, tool_name: str, args: Dict[str, Any]) -> str: session_scope=session_id if session_id else None, ) try: - loop = asyncio.get_running_loop() + # Probe whether a loop is already running (RuntimeError = not). + asyncio.get_running_loop() import concurrent.futures with concurrent.futures.ThreadPoolExecutor() as pool: results = pool.submit(asyncio.run, self.search(mq)).result() diff --git a/src/leapflow/monitor/session_producer.py b/src/leapflow/monitor/session_producer.py index 89ce189..afbe0d3 100644 --- a/src/leapflow/monitor/session_producer.py +++ b/src/leapflow/monitor/session_producer.py @@ -36,8 +36,12 @@ class SessionAnalysisServices(Protocol): engine/LLM internals directly. """ - async def session_history(self) -> dict[str, Any]: - """Return {session_id, turn_count, token_count, messages:[{role,content}]}.""" + async def session_history(self, session_id: str = "") -> dict[str, Any]: + """Return {session_id, turn_count, token_count, messages:[{role,content}]}. + + ``session_id`` selects which live session to read; empty means the + host's current session. + """ ... async def analyze_session( @@ -161,7 +165,16 @@ async def observe(self, ctx: ProducerContext) -> Sequence[Finding]: max_per_min = int(params.get("max_refresh_per_min", _DEFAULT_MAX_PER_MIN)) try: - history = await services.session_history() + # A session watch is bound to the session that armed it (the board is + # opened from one TUI). Without that binding the host would fall back + # to "most recently active", which is wrong once several TUIs share + # the daemon. + bound_session = str(params.get("session_id", "") or "") + try: + history = await services.session_history(bound_session) + except TypeError: + # Host predates the session-scoped signature. + history = await services.session_history() except Exception as exc: # noqa: BLE001 - degrade if history unavailable logger.debug("session producer: history unavailable: %s", exc) return [] diff --git a/src/leapflow/perception/config.py b/src/leapflow/perception/config.py index fc57d36..b936f15 100644 --- a/src/leapflow/perception/config.py +++ b/src/leapflow/perception/config.py @@ -4,7 +4,7 @@ from dataclasses import dataclass, field from pathlib import Path -from typing import Any, Dict, FrozenSet, Optional, TYPE_CHECKING +from typing import Dict, FrozenSet, TYPE_CHECKING if TYPE_CHECKING: from leapflow.config import Settings diff --git a/src/leapflow/perception/cv/phash.py b/src/leapflow/perception/cv/phash.py index c644a64..b645011 100644 --- a/src/leapflow/perception/cv/phash.py +++ b/src/leapflow/perception/cv/phash.py @@ -6,7 +6,7 @@ from __future__ import annotations -from typing import List, Optional, Tuple +from typing import List, Tuple try: from PIL import Image @@ -82,9 +82,8 @@ def phash_64(data: bytes) -> bytes: # 2D DCT, keep 8x8 low-frequency dct_low = _dct_2d(matrix, 8) - # Flatten and compute median (exclude DC component) + # Flatten and compute median (exclude DC component at index 0) flat = [dct_low[r][c] for r in range(8) for c in range(8)] - dc = flat[0] flat_no_dc = flat[1:] median = sorted(flat_no_dc)[len(flat_no_dc) // 2] diff --git a/src/leapflow/perception/cv/ui_detect.py b/src/leapflow/perception/cv/ui_detect.py index efadb3a..41debc5 100644 --- a/src/leapflow/perception/cv/ui_detect.py +++ b/src/leapflow/perception/cv/ui_detect.py @@ -11,7 +11,7 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import Any, Dict, List, Optional, Protocol, Tuple, runtime_checkable +from typing import List, Optional, Protocol, Tuple, runtime_checkable @dataclass(frozen=True) diff --git a/src/leapflow/perception/encoding/tiler.py b/src/leapflow/perception/encoding/tiler.py index 1615677..17447a1 100644 --- a/src/leapflow/perception/encoding/tiler.py +++ b/src/leapflow/perception/encoding/tiler.py @@ -9,14 +9,16 @@ from leapflow.perception.types import ComposedImage, TiledBatch if TYPE_CHECKING: - from leapflow.perception.types import InteractionSignal, PairContext + from leapflow.perception.types import PairContext logger = logging.getLogger(__name__) _MAX_SIGNALS_PER_PAIR_TILED = 4 try: - from PIL import Image, ImageDraw, ImageFont + # ImageFont participates in the availability probe: a partial PIL install + # missing it must also count as unavailable. + from PIL import Image, ImageDraw, ImageFont # noqa: F401 _HAS_PIL = True except ImportError: diff --git a/src/leapflow/perception/extraction/extractor.py b/src/leapflow/perception/extraction/extractor.py index 0f89c04..00ba6f7 100644 --- a/src/leapflow/perception/extraction/extractor.py +++ b/src/leapflow/perception/extraction/extractor.py @@ -5,12 +5,13 @@ import json import logging import time -from typing import Any, Dict, List, Optional, TYPE_CHECKING +from typing import List, Optional, TYPE_CHECKING from leapflow.perception.types import FramePair, InferenceLevel, PairContext, TiledBatch, VisualAction if TYPE_CHECKING: from leapflow.llm.base import LLMProvider + from leapflow.perception.types import InteractionSignal logger = logging.getLogger(__name__) @@ -121,7 +122,6 @@ async def extract_batch( levels: Optional[List[InferenceLevel]] = None, ) -> List[List[VisualAction]]: """Extract actions from multiple pairs (sequential for now).""" - import asyncio results = [] for i, pair in enumerate(pairs): diff --git a/src/leapflow/perception/extraction/pipeline.py b/src/leapflow/perception/extraction/pipeline.py index a030962..6e3ccab 100644 --- a/src/leapflow/perception/extraction/pipeline.py +++ b/src/leapflow/perception/extraction/pipeline.py @@ -24,6 +24,7 @@ if TYPE_CHECKING: from leapflow.llm.base import LLMProvider from leapflow.perception.config import PerceptionConfig + from leapflow.perception.types import TiledBatch logger = logging.getLogger(__name__) @@ -209,7 +210,7 @@ async def _do_one( _report("extract.vlm", completed_count, total) return actions - tasks = [_do_one(p, c, l) for _, p, c, l in candidate_pairs] + tasks = [_do_one(p, c, level) for _, p, c, level in candidate_pairs] results = await asyncio.gather(*tasks) for (_, pair, _, _), actions in zip(candidate_pairs, results): diff --git a/src/leapflow/perception/extraction/preprocessor.py b/src/leapflow/perception/extraction/preprocessor.py index d01fe44..7be1ae4 100644 --- a/src/leapflow/perception/extraction/preprocessor.py +++ b/src/leapflow/perception/extraction/preprocessor.py @@ -5,7 +5,7 @@ from typing import Any, Dict, List from leapflow.perception.cv.text_diff import TextDiffTracker -from leapflow.perception.types import FramePair, InteractionSignal, PairContext, TextChange +from leapflow.perception.types import FramePair, InteractionSignal, PairContext class SemanticPreprocessor: diff --git a/src/leapflow/perception/implicit_feedback.py b/src/leapflow/perception/implicit_feedback.py index ef02aa3..981e51c 100644 --- a/src/leapflow/perception/implicit_feedback.py +++ b/src/leapflow/perception/implicit_feedback.py @@ -143,7 +143,6 @@ def _is_undo_event(self, event_type: str, payload: Dict[str, Any]) -> bool: if sub_type != UIActionSubType.SHORTCUT: return False modifiers = payload.get("modifiers", []) - key_code = payload.get("key_code", 0) char = payload.get("char", "").lower() if char == "z" and any(m in modifiers for m in ("cmd", "meta", "ctrl", "control")): return True diff --git a/src/leapflow/perception/session.py b/src/leapflow/perception/session.py index a1342ec..dd3f3ee 100644 --- a/src/leapflow/perception/session.py +++ b/src/leapflow/perception/session.py @@ -26,6 +26,7 @@ if TYPE_CHECKING: from leapflow.causal import CausalGraph + from leapflow.causal.pipeline import CausalFusionPipeline from leapflow.domain.events import SystemEvent from leapflow.llm.base import LLMProvider from leapflow.platform.protocol import HostRpc diff --git a/src/leapflow/perception/storage/semantic_cache.py b/src/leapflow/perception/storage/semantic_cache.py index 7356d20..bfb7f91 100644 --- a/src/leapflow/perception/storage/semantic_cache.py +++ b/src/leapflow/perception/storage/semantic_cache.py @@ -7,10 +7,9 @@ from __future__ import annotations import hashlib -import json import logging import time -from dataclasses import dataclass, field +from dataclasses import dataclass from typing import Any, Dict, List, Optional from leapflow.perception.types import VisualAction diff --git a/src/leapflow/perception/types.py b/src/leapflow/perception/types.py index 8b51c8a..c7db622 100644 --- a/src/leapflow/perception/types.py +++ b/src/leapflow/perception/types.py @@ -5,7 +5,7 @@ from dataclasses import dataclass, field from enum import Enum from pathlib import Path -from typing import Any, Dict, List, Optional, Sequence, Tuple +from typing import Any, Dict, List, Optional, Tuple # ── Enums ── diff --git a/src/leapflow/perception/video/analyzer.py b/src/leapflow/perception/video/analyzer.py index 5a64ffa..04d692b 100644 --- a/src/leapflow/perception/video/analyzer.py +++ b/src/leapflow/perception/video/analyzer.py @@ -17,7 +17,6 @@ from leapflow.perception.types import ( MacroAnalysisResult, VideoAction, - VideoSegment, ) from leapflow.perception.video.prompts import ( AnalysisPromptStrategy, diff --git a/src/leapflow/platform/adapters/darwin.py b/src/leapflow/platform/adapters/darwin.py index 24de7df..2c8c5e8 100644 --- a/src/leapflow/platform/adapters/darwin.py +++ b/src/leapflow/platform/adapters/darwin.py @@ -9,12 +9,12 @@ from pathlib import Path from typing import Any, AsyncIterator, Dict, List, Optional -logger = logging.getLogger(__name__) - from leapflow.domain.events import SystemEvent, UINode from leapflow.domain.platform import Capability, PlatformManifest from leapflow.platform.protocol import HostRpc, Methods +logger = logging.getLogger(__name__) + class DarwinPerceptionAdapter: """PerceptionPort implementation backed by CuaDriver RPC.""" diff --git a/src/leapflow/platform/capabilities.py b/src/leapflow/platform/capabilities.py index 633bbcc..eb6002d 100644 --- a/src/leapflow/platform/capabilities.py +++ b/src/leapflow/platform/capabilities.py @@ -10,7 +10,7 @@ import logging import time from dataclasses import dataclass, field -from typing import Any, Dict, List, Optional, Tuple +from typing import Dict, List, Optional, Tuple from leapflow.domain.platform import Capability, PlatformManifest, capability_from_str from leapflow.platform.protocol import HostRpc, Methods diff --git a/src/leapflow/platform/facade.py b/src/leapflow/platform/facade.py index 5269dc5..6f05144 100644 --- a/src/leapflow/platform/facade.py +++ b/src/leapflow/platform/facade.py @@ -3,7 +3,10 @@ from __future__ import annotations import logging -from typing import Optional +from typing import Optional, TYPE_CHECKING + +if TYPE_CHECKING: + from leapflow.platform.cua_client import CuaDriverClient from leapflow.domain.platform import ( Capability, @@ -134,7 +137,6 @@ def _manifest_from_cua_tools(rpc: "CuaDriverClient") -> PlatformManifest: """Build a PlatformManifest from CuaDriverClient's discovered tools.""" import platform as _platform - from leapflow.platform.cua_client import CuaDriverClient session = rpc._session # noqa: SLF001 tools = session.available_tools diff --git a/src/leapflow/platform/mcp_manager.py b/src/leapflow/platform/mcp_manager.py index 42c2ad4..50f41e4 100644 --- a/src/leapflow/platform/mcp_manager.py +++ b/src/leapflow/platform/mcp_manager.py @@ -12,15 +12,12 @@ from __future__ import annotations import asyncio -import json import logging import os import threading -import time -from contextlib import asynccontextmanager from dataclasses import dataclass, field from pathlib import Path -from typing import Any, AsyncIterator, Dict, List, Optional, Protocol, runtime_checkable +from typing import Any, Dict, List, Optional, Protocol, runtime_checkable logger = logging.getLogger(__name__) diff --git a/src/leapflow/platform/observers/__init__.py b/src/leapflow/platform/observers/__init__.py index ff901e8..a874afc 100644 --- a/src/leapflow/platform/observers/__init__.py +++ b/src/leapflow/platform/observers/__init__.py @@ -8,7 +8,7 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import Dict, List, Optional, Protocol, runtime_checkable +from typing import Dict, List, Protocol, runtime_checkable @runtime_checkable diff --git a/src/leapflow/privacy/policy.py b/src/leapflow/privacy/policy.py index deef403..d2ccd34 100644 --- a/src/leapflow/privacy/policy.py +++ b/src/leapflow/privacy/policy.py @@ -11,8 +11,8 @@ import logging import re import time -from dataclasses import dataclass, field -from typing import Any, Dict, FrozenSet, List, Optional, Protocol, Set, runtime_checkable +from dataclasses import dataclass +from typing import Any, Dict, List, Protocol, runtime_checkable logger = logging.getLogger(__name__) diff --git a/src/leapflow/recording/field_policy_loader.py b/src/leapflow/recording/field_policy_loader.py index a052c30..725448b 100644 --- a/src/leapflow/recording/field_policy_loader.py +++ b/src/leapflow/recording/field_policy_loader.py @@ -12,9 +12,9 @@ import logging import re from pathlib import Path -from typing import Dict, List, Optional, Sequence, Tuple +from typing import Dict, List, Optional, Sequence -from leapflow.domain.perception import FieldRule, PerceptionLevel, sort_rules +from leapflow.domain.perception import FieldRule, PerceptionLevel from leapflow.recording.perceptual_field import FieldPolicy logger = logging.getLogger(__name__) diff --git a/src/leapflow/recording/health.py b/src/leapflow/recording/health.py index 6813c1d..6db9859 100644 --- a/src/leapflow/recording/health.py +++ b/src/leapflow/recording/health.py @@ -9,7 +9,7 @@ import time from dataclasses import dataclass, field -from typing import TYPE_CHECKING, Dict, List, Optional, Set +from typing import TYPE_CHECKING, List, Optional, Set if TYPE_CHECKING: from leapflow.perception.session import PerceptionSession diff --git a/src/leapflow/recording/perceptual_field.py b/src/leapflow/recording/perceptual_field.py index 11a154e..2cb2e2c 100644 --- a/src/leapflow/recording/perceptual_field.py +++ b/src/leapflow/recording/perceptual_field.py @@ -15,7 +15,7 @@ import sys import time from abc import ABC, abstractmethod -from dataclasses import dataclass, field +from dataclasses import dataclass from fnmatch import fnmatch from typing import TYPE_CHECKING, Any, Dict, List, Optional, Sequence, Set, Tuple diff --git a/src/leapflow/recording/recorder.py b/src/leapflow/recording/recorder.py index b92fab1..893fa96 100644 --- a/src/leapflow/recording/recorder.py +++ b/src/leapflow/recording/recorder.py @@ -9,7 +9,6 @@ from __future__ import annotations -import asyncio import hashlib import json import logging @@ -629,7 +628,6 @@ def retract_context(self, app_bundle_id: str, context_pattern: str) -> int: or state.focused_app (fallback). Returns the number of steps marked. """ - from fnmatch import fnmatch if not self._trajectory: return 0 diff --git a/src/leapflow/scheduler/triggers/condition.py b/src/leapflow/scheduler/triggers/condition.py index c897875..9680c37 100644 --- a/src/leapflow/scheduler/triggers/condition.py +++ b/src/leapflow/scheduler/triggers/condition.py @@ -13,7 +13,7 @@ import operator import re import time -from typing import Any, Callable, ClassVar, Dict, Optional +from typing import Any, Callable, Dict, Optional # Supported comparison operators @@ -49,8 +49,6 @@ class ConditionTrigger: Security: Does NOT use eval(). Implements a simple comparison parser. """ - trigger_type: ClassVar[str] = "condition" - def __init__( self, expression: str, @@ -79,7 +77,7 @@ def __init__( # ------------------------------------------------------------------ @property - def trigger_type(self) -> str: # type: ignore[override] + def trigger_type(self) -> str: return "condition" def is_due(self, now: float) -> bool: diff --git a/src/leapflow/scheduler/triggers/cron.py b/src/leapflow/scheduler/triggers/cron.py index e83129f..c444d11 100644 --- a/src/leapflow/scheduler/triggers/cron.py +++ b/src/leapflow/scheduler/triggers/cron.py @@ -8,7 +8,7 @@ import time from datetime import datetime, timezone -from typing import ClassVar, Optional +from typing import Optional try: from croniter import croniter as _croniter # type: ignore[import-untyped] @@ -26,8 +26,6 @@ class CronTrigger: only simple "HH:MM" daily schedules are supported. """ - trigger_type: ClassVar[str] = "cron" - def __init__(self, expression: str, *, next_due_at: float = 0.0) -> None: self._expression = expression.strip() self._next_due_at = next_due_at @@ -41,7 +39,7 @@ def __init__(self, expression: str, *, next_due_at: float = 0.0) -> None: # ------------------------------------------------------------------ @property - def trigger_type(self) -> str: # type: ignore[override] + def trigger_type(self) -> str: return "cron" def is_due(self, now: float) -> bool: diff --git a/src/leapflow/scheduler/triggers/event.py b/src/leapflow/scheduler/triggers/event.py index 2664623..cf22109 100644 --- a/src/leapflow/scheduler/triggers/event.py +++ b/src/leapflow/scheduler/triggers/event.py @@ -4,7 +4,6 @@ import fnmatch import time -from typing import ClassVar class EventTrigger: @@ -15,8 +14,6 @@ class EventTrigger: fnmatch-style glob patterns (e.g. "ci.passed", "fs.change:*.pdf"). """ - trigger_type: ClassVar[str] = "event" - def __init__( self, event_pattern: str, @@ -35,7 +32,7 @@ def __init__( # ------------------------------------------------------------------ @property - def trigger_type(self) -> str: # type: ignore[override] + def trigger_type(self) -> str: return "event" def is_due(self, now: float) -> bool: diff --git a/src/leapflow/scheduler/triggers/interval.py b/src/leapflow/scheduler/triggers/interval.py index 3f3e34d..12f3fe1 100644 --- a/src/leapflow/scheduler/triggers/interval.py +++ b/src/leapflow/scheduler/triggers/interval.py @@ -3,7 +3,7 @@ from __future__ import annotations import re -from typing import ClassVar, Dict +from typing import Dict _UNIT_MAP: Dict[str, float] = { @@ -57,8 +57,6 @@ class IntervalTrigger: Accepts human-readable specs like "30m", "2h", "1d", "every 5m". """ - trigger_type: ClassVar[str] = "interval" - def __init__(self, interval_seconds: float, *, next_due_at: float = 0.0) -> None: if interval_seconds <= 0: raise ValueError("interval_seconds must be positive") @@ -70,7 +68,7 @@ def __init__(self, interval_seconds: float, *, next_due_at: float = 0.0) -> None # ------------------------------------------------------------------ @property - def trigger_type(self) -> str: # type: ignore[override] + def trigger_type(self) -> str: return "interval" def is_due(self, now: float) -> bool: diff --git a/src/leapflow/signal_fusion/pipeline.py b/src/leapflow/signal_fusion/pipeline.py index 4be29b4..4016485 100644 --- a/src/leapflow/signal_fusion/pipeline.py +++ b/src/leapflow/signal_fusion/pipeline.py @@ -161,7 +161,7 @@ def to_domain_episodes(self, result: FusionResult) -> list: Bridges the signal_fusion output to the existing analysis pipeline's expected Episode format, enabling gradual adoption. """ - from leapflow.domain.trajectory import Episode, SemanticAction + from leapflow.domain.trajectory import Episode domain_episodes: list = [] for enriched in result.episodes: diff --git a/src/leapflow/signal_fusion/segment_agent.py b/src/leapflow/signal_fusion/segment_agent.py index 319ab28..0720227 100644 --- a/src/leapflow/signal_fusion/segment_agent.py +++ b/src/leapflow/signal_fusion/segment_agent.py @@ -12,7 +12,7 @@ import logging from abc import ABC, abstractmethod -from dataclasses import dataclass, field +from dataclasses import dataclass from typing import List, Optional, Sequence from leapflow.signal_fusion.protocol import FusionContext, FusionResult diff --git a/src/leapflow/signal_fusion/wait_classifier.py b/src/leapflow/signal_fusion/wait_classifier.py index a8eba82..54e2774 100644 --- a/src/leapflow/signal_fusion/wait_classifier.py +++ b/src/leapflow/signal_fusion/wait_classifier.py @@ -11,7 +11,7 @@ from __future__ import annotations import re -from dataclasses import dataclass, field +from dataclasses import dataclass from typing import ClassVar, Dict, Optional, Pattern from leapflow.signal_fusion.types import SilentPeriodClass diff --git a/src/leapflow/skills/action_policy.py b/src/leapflow/skills/action_policy.py index 3e21bf4..6ba316e 100644 --- a/src/leapflow/skills/action_policy.py +++ b/src/leapflow/skills/action_policy.py @@ -21,7 +21,7 @@ import re from dataclasses import dataclass, field from enum import Enum -from typing import Any, Dict, List, Optional, Protocol, runtime_checkable +from typing import List, Optional, Protocol, runtime_checkable from leapflow.skills.tool_executor import ToolCall diff --git a/src/leapflow/skills/activator.py b/src/leapflow/skills/activator.py index bdbcadd..46fec89 100644 --- a/src/leapflow/skills/activator.py +++ b/src/leapflow/skills/activator.py @@ -10,7 +10,7 @@ import json import logging import time -from typing import TYPE_CHECKING, Any, Dict, List, Optional +from typing import TYPE_CHECKING, Any, List, Optional from leapflow.skills.registry import Skill, SkillFn, SkillMetadata, SkillParameter diff --git a/src/leapflow/skills/conditions.py b/src/leapflow/skills/conditions.py index bab45c7..94f2703 100644 --- a/src/leapflow/skills/conditions.py +++ b/src/leapflow/skills/conditions.py @@ -12,9 +12,9 @@ import os import re from dataclasses import dataclass, field -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List -from leapflow.domain.platform import PlatformManifest, capability_from_str +from leapflow.domain.platform import capability_from_str from leapflow.platform.capabilities import EnvironmentProbe from leapflow.platform.protocol import HostRpc from leapflow.skills.registry import Skill, SkillResult diff --git a/src/leapflow/skills/index.py b/src/leapflow/skills/index.py index 5fe75b7..0408951 100644 --- a/src/leapflow/skills/index.py +++ b/src/leapflow/skills/index.py @@ -9,7 +9,7 @@ import json import logging import time -from dataclasses import dataclass, field +from dataclasses import dataclass from pathlib import Path from typing import Any, Dict, List, Optional, Set, Tuple diff --git a/src/leapflow/skills/registry.py b/src/leapflow/skills/registry.py index 2b9f40d..974e7b0 100644 --- a/src/leapflow/skills/registry.py +++ b/src/leapflow/skills/registry.py @@ -11,7 +11,6 @@ from leapflow.domain.skill_types import SkillMetadata, SkillParameter # noqa: F401 if TYPE_CHECKING: - from leapflow.utils.resilience import ResiliencePolicy from leapflow.skills.conditions import ConditionChecker logger = logging.getLogger(__name__) diff --git a/src/leapflow/skills/semantic_adapter.py b/src/leapflow/skills/semantic_adapter.py index 9806ceb..228372a 100644 --- a/src/leapflow/skills/semantic_adapter.py +++ b/src/leapflow/skills/semantic_adapter.py @@ -26,12 +26,9 @@ from leapflow.domain.events import UINode from leapflow.skills.ui_selector import ( - UISelector, - find_in_tree, - parse_selector, resolve_selector_string, ) -from leapflow.skills.ui_summarizer import UIElement, UITreeSummarizer, summarize_tree +from leapflow.skills.ui_summarizer import UIElement, UITreeSummarizer logger = logging.getLogger(__name__) diff --git a/src/leapflow/skills/ui_selector.py b/src/leapflow/skills/ui_selector.py index 4af9b18..54715ae 100644 --- a/src/leapflow/skills/ui_selector.py +++ b/src/leapflow/skills/ui_selector.py @@ -15,7 +15,7 @@ import re from dataclasses import dataclass, field -from typing import Any, Dict, List, Optional, Tuple +from typing import Dict, List, Optional, Tuple from leapflow.domain.events import UINode from leapflow.domain.skill_types import AnchorCandidate diff --git a/src/leapflow/skills/ui_summarizer.py b/src/leapflow/skills/ui_summarizer.py index 6ce5a73..a22289f 100644 --- a/src/leapflow/skills/ui_summarizer.py +++ b/src/leapflow/skills/ui_summarizer.py @@ -14,7 +14,7 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import Any, Dict, FrozenSet, List, Optional, Set +from typing import Any, Dict, FrozenSet, List, Optional from leapflow.domain.events import UINode from leapflow.domain.ui_vocabulary import INTERACTIVE_ROLES, LAYOUT_ROLES, STRUCTURAL_ROLES diff --git a/src/leapflow/storage/conversation_store.py b/src/leapflow/storage/conversation_store.py index 1532736..089c5c8 100644 --- a/src/leapflow/storage/conversation_store.py +++ b/src/leapflow/storage/conversation_store.py @@ -24,6 +24,7 @@ if TYPE_CHECKING: from leapflow.engine.tool_execution import ToolExecutionRecord + from leapflow.storage.connection import ConnectionHolder logger = logging.getLogger(__name__) @@ -102,7 +103,7 @@ class DuckDBConversationStore: """ def __init__(self, source: "Union[ConnectionHolder, Path, str]") -> None: - from leapflow.storage.connection import ConnectionHolder, LocalConnectionHolder + from leapflow.storage.connection import LocalConnectionHolder self._owns_holder = isinstance(source, (str, Path)) if self._owns_holder: source = LocalConnectionHolder(Path(source)) diff --git a/src/leapflow/storage/db_repair.py b/src/leapflow/storage/db_repair.py index 5fdc41a..bc33aed 100644 --- a/src/leapflow/storage/db_repair.py +++ b/src/leapflow/storage/db_repair.py @@ -40,7 +40,8 @@ def check_and_repair( try: conn = duckdb.connect(str(db_path), read_only=True) conn.execute("SELECT 1").fetchone() - tables = conn.execute("SHOW TABLES").fetchall() + # Probe that the catalog is readable too; the result itself is unused. + conn.execute("SHOW TABLES").fetchall() conn.close() return True except Exception as exc: diff --git a/src/leapflow/storage/skill_library.py b/src/leapflow/storage/skill_library.py index 42a203b..b8aa525 100644 --- a/src/leapflow/storage/skill_library.py +++ b/src/leapflow/storage/skill_library.py @@ -18,7 +18,6 @@ from pathlib import Path from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union -import duckdb from leapflow.domain.skill_types import ( AnchorCandidate, diff --git a/src/leapflow/tools/file_operations.py b/src/leapflow/tools/file_operations.py index 8229046..dd4ab75 100644 --- a/src/leapflow/tools/file_operations.py +++ b/src/leapflow/tools/file_operations.py @@ -20,6 +20,7 @@ import shutil import subprocess import sys +import time from pathlib import Path from typing import Any, Dict, Iterable, List, Tuple @@ -503,6 +504,27 @@ def ripgrep_path() -> str | None: return shutil.which("rg") +def _read_provision_marker(marker_path: Path) -> bool: + """Return True when a previous install attempt is recorded on disk.""" + try: + payload = json.loads(marker_path.read_text(encoding="utf-8")) + return bool(payload.get("attempted")) + except (OSError, ValueError): + return False + + +def _write_provision_marker(marker_path: Path, *, available: bool) -> None: + """Persist the install attempt so restarts never re-trigger the installer.""" + try: + marker_path.parent.mkdir(parents=True, exist_ok=True) + marker_path.write_text( + json.dumps({"attempted": True, "available": available, "ts": time.time()}), + encoding="utf-8", + ) + except OSError: + logger.debug("code_search: could not persist ripgrep provision marker", exc_info=True) + + def ripgrep_install_hint() -> str: """Platform-appropriate manual install command for ripgrep.""" if sys.platform == "darwin": @@ -518,7 +540,12 @@ def ripgrep_install_hint() -> str: return "install ripgrep — see https://github.com/BurntSushi/ripgrep#installation" -def ensure_ripgrep_available(*, autoinstall: bool = True, timeout: float = 180.0) -> bool: +def ensure_ripgrep_available( + *, + autoinstall: bool = True, + timeout: float = 180.0, + marker_path: Path | None = None, +) -> bool: """Best-effort, cached, non-fatal provision of ripgrep. Never raises. ripgrep is only an *accelerator*: ``code_search`` always works via the pure @@ -527,12 +554,21 @@ def ensure_ripgrep_available(*, autoinstall: bool = True, timeout: float = 180.0 no elevated privileges). Other platforms fall back to the Python search plus a manual-install hint. Intended to run once in the background at startup so it never blocks a search. Returns whether ripgrep is available afterward. + + ``marker_path`` (profile-scoped, from CacheLayout) persists the attempt + across daemon restarts: a failed install must not re-spawn the installer's + process storm (and its per-exec security assessments) on every startup. """ if _RG_PROVISION["done"]: return _RG_PROVISION["available"] if ripgrep_path() is not None: _RG_PROVISION.update(done=True, available=True) return True + if marker_path is not None and _read_provision_marker(marker_path): + # A previous run already attempted the install and rg is still absent: + # stay on the Python fallback instead of re-spawning the installer. + _RG_PROVISION.update(done=True, available=False) + return False available = False if autoinstall and sys.platform == "darwin" and shutil.which("brew"): try: @@ -544,6 +580,8 @@ def ensure_ripgrep_available(*, autoinstall: bool = True, timeout: float = 180.0 available = ripgrep_path() is not None except Exception: # noqa: BLE001 - best-effort, never fatal logger.debug("code_search: ripgrep auto-install failed; using Python fallback", exc_info=True) + if marker_path is not None: + _write_provision_marker(marker_path, available=available) _RG_PROVISION.update(done=True, available=available) return available @@ -657,10 +695,18 @@ def _enrich_context(matches: List[Dict[str, Any]], context_lines: int) -> None: async def code_search(params: Dict[str, Any]) -> Dict[str, Any]: """Search file *contents* by regex across a directory tree (ripgrep-backed, Python fallback). Read-only; VCS/dependency/build/cache dirs are skipped and - results are redacted.""" + results are redacted. Multiple patterns are OR-combined into one pass so a + batched query costs a single process spawn.""" pattern = str(params.get("pattern", "") or "") - if not pattern: + extra_raw = params.get("patterns") or [] + extra = [str(item) for item in extra_raw if str(item or "").strip()] if isinstance(extra_raw, (list, tuple)) else [] + all_patterns = [p for p in [pattern, *extra] if p] + if not all_patterns: return {"ok": False, "error": "Missing required parameter: pattern"} + if len(all_patterns) > 1: + pattern = "|".join(f"(?:{p})" for p in all_patterns) + else: + pattern = all_patterns[0] base = resolve_workspace_path(params.get("path", ".") or ".", default=".") scope_error = workspace_scope_error(base, operation="code_search") if scope_error: diff --git a/src/leapflow/tools/hub_tool.py b/src/leapflow/tools/hub_tool.py index b8ba1f8..5a85b86 100644 --- a/src/leapflow/tools/hub_tool.py +++ b/src/leapflow/tools/hub_tool.py @@ -10,7 +10,7 @@ from typing import TYPE_CHECKING, Any, Dict, List if TYPE_CHECKING: - from leapflow.cli.context import Context + pass logger = logging.getLogger(__name__) diff --git a/src/leapflow/tools/registry_bootstrap.py b/src/leapflow/tools/registry_bootstrap.py index 84f8a76..79e020c 100644 --- a/src/leapflow/tools/registry_bootstrap.py +++ b/src/leapflow/tools/registry_bootstrap.py @@ -121,13 +121,20 @@ "description": ( "Search file CONTENTS by regex across a directory tree (ripgrep-backed). " "Prefer this over shell_run grep: faster, skips VCS/dependency/build dirs, " - "and returns structured path:line:column matches. Use file_read for the " - "surrounding context of a hit." + "and returns structured path:line:column matches. Batch related lookups " + "into ONE call via `patterns` (OR-combined, single pass) instead of " + "issuing several separate searches. Use file_read for the surrounding " + "context of a hit." ), "parameters": { "type": "object", "properties": { "pattern": {"type": "string", "description": "Regex pattern to search for"}, + "patterns": { + "type": "array", + "items": {"type": "string"}, + "description": "Additional regex patterns OR-combined with pattern into one search pass", + }, "path": {"type": "string", "description": "Base directory (default: current dir)"}, "glob": {"type": "string", "description": "Filter files by glob, e.g. *.py"}, "ignore_case": {"type": "boolean", "description": "Case-insensitive match (default: false)"}, diff --git a/src/leapflow/world_model/curiosity.py b/src/leapflow/world_model/curiosity.py index e539c69..5c97a9d 100644 --- a/src/leapflow/world_model/curiosity.py +++ b/src/leapflow/world_model/curiosity.py @@ -11,7 +11,7 @@ import logging from collections import defaultdict from dataclasses import dataclass -from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple +from typing import TYPE_CHECKING, Any, Dict, Optional, Tuple if TYPE_CHECKING: from leapflow.world_model.experience_store import ExperienceStore diff --git a/src/leapflow/world_model/embedding.py b/src/leapflow/world_model/embedding.py index 9cb5a11..ca97bce 100644 --- a/src/leapflow/world_model/embedding.py +++ b/src/leapflow/world_model/embedding.py @@ -15,7 +15,7 @@ import logging import math from collections import Counter -from typing import Dict, List, Optional, Protocol, runtime_checkable +from typing import Any, Dict, List, Optional, Protocol, runtime_checkable logger = logging.getLogger(__name__) diff --git a/src/leapflow/world_model/prediction.py b/src/leapflow/world_model/prediction.py index 4d1cc71..ce0bf6f 100644 --- a/src/leapflow/world_model/prediction.py +++ b/src/leapflow/world_model/prediction.py @@ -142,7 +142,7 @@ def record_failure(self, action_desc: str, error: str) -> None: self._store.store( action_description=action_desc, app_context="", - predicted_effect=f"execute successfully", + predicted_effect="execute successfully", actual_effect=f"FAILED: {error}", delta=1.0, advantage=self._failure_advantage, diff --git a/src/leapflow/world_model/trajectory_grader.py b/src/leapflow/world_model/trajectory_grader.py index fc3a564..8f75eb5 100644 --- a/src/leapflow/world_model/trajectory_grader.py +++ b/src/leapflow/world_model/trajectory_grader.py @@ -111,7 +111,7 @@ async def _call_teacher( goal: str, ) -> List[ActionGrade]: """Single LLM call: teacher grades with full hindsight.""" - labels_str = ", ".join(f'"{l}"' for l in self._grade_labels) + labels_str = ", ".join(f'"{label}"' for label in self._grade_labels) prompt = _GRADE_PROMPT.format( goal=goal or "(not specified)", trajectory_text=trajectory_text, diff --git a/tests/conftest.py b/tests/conftest.py index 19fa2ac..e93e829 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,11 +2,9 @@ from __future__ import annotations -import tempfile import time -from dataclasses import dataclass from pathlib import Path -from typing import Any, AsyncIterator, Dict, List, Optional +from typing import Any, AsyncIterator, List, Optional import pytest @@ -14,13 +12,8 @@ from leapflow.layout import build_layout from leapflow.domain.events import SystemEvent from leapflow.domain.trajectory import ( - ActionType, Episode, - RawAction, SemanticAction, - StateSnapshot, - Trajectory, - TrajectoryStep, ) from leapflow.llm.base import LLMChatResponse, LLMProvider from leapflow.memory import ( diff --git a/tests/test_app_connector.py b/tests/test_app_connector.py index 7ecb05f..f6422fa 100644 --- a/tests/test_app_connector.py +++ b/tests/test_app_connector.py @@ -7,7 +7,6 @@ from leapflow.gateway.backends.lark_cli_errors import classify_lark_cli_failure from leapflow.gateway.connectors.action_registry import ( ActionRegistry, - ValidationResult, normalize_payload, summarize_action_result, validate_payload, diff --git a/tests/test_architecture_contracts.py b/tests/test_architecture_contracts.py new file mode 100644 index 0000000..54beebf --- /dev/null +++ b/tests/test_architecture_contracts.py @@ -0,0 +1,252 @@ +"""Executable guards for the architecture contracts in AGENTS.md. + +These contracts are the ones a code review is worst at catching, because a +violation looks locally reasonable: one vendor import in a core module, one +mutable domain type, one event-subscription method on a one-shot backend. Each +test below therefore asserts the *boundary* rather than any single call site, +so the guard keeps holding as the implementation moves. + +Covered contracts: +- Platform-Neutral Gateway Core (core must not import platform packages) +- Platform vs App Business Boundary (no vendor endpoints/error shapes in core) +- Transport-Lifecycle Separation (one-shot actions vs long-lived observations) +- Immutable Domain Types (frozen dataclasses for domain objects) +- Protocol over ABC (extension points are runtime_checkable Protocols) +- Standalone importability (no import-time side effects) +""" + +from __future__ import annotations + +import ast +import dataclasses +import importlib +import pathlib +import re +import typing + +import pytest + +GATEWAY_DIR = pathlib.Path(__file__).resolve().parents[1] / "src" / "leapflow" / "gateway" + +# Sub-packages that own platform/vendor specifics. Gateway core may define the +# contracts these implement, but must never depend on them. +_PLATFORM_PACKAGES = ("adapters", "normalizers", "action_packs", "backends", "manifests") + + +def _core_modules() -> list[pathlib.Path]: + """Return gateway core modules (top-level files, excluding sub-packages).""" + return sorted(p for p in GATEWAY_DIR.glob("*.py") if p.name != "__init__.py") + + +def _imported_modules(path: pathlib.Path) -> list[tuple[str, int]]: + """Return (module, lineno) for every import in a source file.""" + tree = ast.parse(path.read_text(encoding="utf-8")) + found: list[tuple[str, int]] = [] + for node in ast.walk(tree): + if isinstance(node, ast.ImportFrom): + found.append((node.module or "", node.lineno)) + elif isinstance(node, ast.Import): + found.extend((alias.name, node.lineno) for alias in node.names) + return found + + +# ── Platform-Neutral Gateway Core ──────────────────────────────────────── + + +def test_gateway_core_does_not_import_platform_packages() -> None: + """Core owns protocols, lifecycle, routing, approval, audit — not vendors. + + A core module importing an adapter/normalizer/action pack inverts the + dependency and makes every new platform a core change. + """ + violations: list[str] = [] + for path in _core_modules(): + for module, lineno in _imported_modules(path): + for package in _PLATFORM_PACKAGES: + if f"gateway.{package}" in module: + violations.append(f"{path.name}:{lineno} imports {module}") + + assert violations == [], ( + "gateway core must not depend on platform packages; move the " + "platform-specific part behind a protocol or into the adapter:\n " + + "\n ".join(violations) + ) + + +def test_gateway_core_does_not_import_vendor_sdks() -> None: + """Vendor SDKs belong to adapters/backends, never to core modules.""" + vendor_sdk_roots = ("lark_oapi", "telebot", "telegram", "slack_sdk", "dingtalk") + violations: list[str] = [] + for path in _core_modules(): + for module, lineno in _imported_modules(path): + root = module.split(".")[0] + if root in vendor_sdk_roots: + violations.append(f"{path.name}:{lineno} imports {module}") + + assert violations == [], "vendor SDK imported by gateway core:\n " + "\n ".join(violations) + + +def test_gateway_core_has_no_vendor_endpoints_or_error_shapes() -> None: + """Vendor wire formats must live in the app's pack/adapter, not in core. + + Vendor validators now sit in ``gateway/validators/.py``, mirroring + ``adapters/`` and ``normalizers/``; core keeps only the neutral registry. + """ + vendor_endpoint = re.compile( + r"https?://[^\s\"']*(feishu|larksuite|dingtalk|telegram|slack)", re.IGNORECASE + ) + violations: list[str] = [] + for path in _core_modules(): + for lineno, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1): + if vendor_endpoint.search(line): + violations.append(f"{path.name}:{lineno}") + + assert violations == [], "vendor endpoint hardcoded in gateway core: " + ", ".join(violations) + + +# ── Transport-Lifecycle Separation ─────────────────────────────────────── + + +def test_one_shot_action_backend_exposes_no_event_subscription() -> None: + """``ExecutionBackend`` runs bounded actions; it must not stream events. + + Merging the two lets a streaming subscriber, webhook, or polling loop be + implemented inside one-shot action execution, which is the exact coupling + the contract forbids. + """ + from leapflow.gateway.connectors.protocol import ExecutionBackend + + members = {name for name in dir(ExecutionBackend) if not name.startswith("_")} + assert "execute" in members, "ExecutionBackend must run actions" + assert members.isdisjoint({"events", "start", "stop"}), ( + "ExecutionBackend must not own long-lived observation methods; those " + f"belong to BackendEventSource. Found: {sorted(members)}" + ) + + +def test_long_lived_event_source_exposes_no_action_execution() -> None: + """``BackendEventSource`` observes; it must not execute actions.""" + from leapflow.gateway.connectors.protocol import BackendEventSource + + members = {name for name in dir(BackendEventSource) if not name.startswith("_")} + assert {"events", "start", "stop"} <= members, "event source must own its lifecycle" + assert members.isdisjoint({"execute", "preview"}), ( + f"BackendEventSource must not execute actions. Found: {sorted(members)}" + ) + + +# ── Immutable Domain Types ─────────────────────────────────────────────── + + +_DOMAIN_TYPES = [ + ("leapflow.gateway.protocol", "InboundMessage"), + ("leapflow.gateway.protocol", "OutboundContent"), + ("leapflow.gateway.protocol", "SendTarget"), + ("leapflow.gateway.protocol", "SendResult"), + ("leapflow.gateway.protocol", "MessageSource"), + ("leapflow.gateway.connectors.protocol", "ActionSpec"), + ("leapflow.gateway.connectors.protocol", "ActionResult"), + ("leapflow.gateway.connectors.protocol", "ActionFailure"), + ("leapflow.engine.failure_envelope", "FailureEnvelope"), + ("leapflow.engine.failure_envelope", "FailureContext"), + ("leapflow.engine.failure_envelope", "RecoveryHint"), + ("leapflow.engine.recovery_decision", "RecoveryDecision"), + ("leapflow.engine.recovery_decision", "BackoffConfig"), + ("leapflow.engine.recovery_decision", "RetrySemantics"), + ("leapflow.monitor.types", "Finding"), + ("leapflow.monitor.types", "WatchSpec"), +] + + +@pytest.mark.parametrize(("module_name", "type_name"), _DOMAIN_TYPES) +def test_domain_types_are_frozen(module_name: str, type_name: str) -> None: + """Domain objects crossing module boundaries must be immutable. + + These types are passed between engine, gateway, and storage; a mutable one + lets a downstream consumer edit shared state instead of deriving a new value. + """ + cls = getattr(importlib.import_module(module_name), type_name) + + assert dataclasses.is_dataclass(cls), f"{type_name} must be a dataclass" + assert cls.__dataclass_params__.frozen, ( + f"{type_name} is a shared domain type and must be frozen=True" + ) + + +def test_frozen_domain_type_rejects_mutation_at_runtime() -> None: + """The frozen flag must actually block writes (not just be declared).""" + from leapflow.engine.failure_envelope import FailureEnvelope, FailureSource, Recoverability + + envelope = FailureEnvelope.create( + source=FailureSource.TOOL, + category="tool_timeout", + failure_class="transient", + failure_code="timeout", + message="timed out", + recoverability=Recoverability.AUTO_RETRY, + ) + with pytest.raises(dataclasses.FrozenInstanceError): + envelope.message = "rewritten" # type: ignore[misc] + + +# ── Protocol over ABC ──────────────────────────────────────────────────── + + +_EXTENSION_POINTS = [ + ("leapflow.gateway.connectors.protocol", "ExecutionBackend"), + ("leapflow.gateway.connectors.protocol", "BackendEventSource"), + ("leapflow.engine.recovery_coordinator", "RecoveryStrategy"), + ("leapflow.monitor.types", "MonitorProducer"), + ("leapflow.dashboard.service", "DashboardDataProvider"), +] + + +@pytest.mark.parametrize(("module_name", "type_name"), _EXTENSION_POINTS) +def test_extension_points_are_runtime_checkable_protocols( + module_name: str, type_name: str, +) -> None: + """Extension points must be Protocols so implementations stay decoupled. + + ``runtime_checkable`` is part of the contract: registration and test code + verify conformance with ``isinstance`` rather than by subclassing. Missing + names fail rather than skip — a renamed extension point must be noticed. + """ + module = importlib.import_module(module_name) + cls = getattr(module, type_name, None) + + assert cls is not None, f"{module_name} must export the {type_name} extension point" + assert issubclass(cls, typing.Protocol), f"{type_name} must be a typing.Protocol" # type: ignore[arg-type] + assert getattr(cls, "_is_runtime_protocol", False), ( + f"{type_name} must be decorated with @runtime_checkable" + ) + + +# ── Standalone importability ───────────────────────────────────────────── + + +_STANDALONE_MODULES = [ + "leapflow.logging_setup", + "leapflow.layout", + "leapflow.config_service", + "leapflow.gateway.trigger_policy", + "leapflow.gateway.session_router", + "leapflow.gateway.validators", + "leapflow.engine.recovery_coordinator", + "leapflow.engine.recovery_strategies", + "leapflow.engine.failure_envelope", + "leapflow.monitor.types", + "leapflow.monitor.session_producer", + "leapflow.dashboard.service", + "leapflow.daemon.session_registry", + "leapflow.daemon.notifications", +] + + +@pytest.mark.parametrize("module_name", _STANDALONE_MODULES) +def test_module_imports_standalone(module_name: str) -> None: + """Every module must import without side effects or optional deps. + + Guards the graceful-degradation contract at import level: a module that + needs aiohttp/duckdb/an LLM at import time breaks unrelated entry points. + """ + assert importlib.import_module(module_name) is not None diff --git a/tests/test_board_session_binding.py b/tests/test_board_session_binding.py new file mode 100644 index 0000000..3348648 --- /dev/null +++ b/tests/test_board_session_binding.py @@ -0,0 +1,230 @@ +"""Regression tests: LeapBoard must observe the session that opened it. + +Root cause of "board opens, status bar shows watch, page stays empty": +conversation state lives on per-session engines from ``SessionRegistry``, but +``SessionCoordinator.get_history`` read ``ctx.engine`` — the *base* engine, +which is only a template and never carries a conversation. The session-analysis +watch therefore analyzed an empty transcript, produced no useful finding, and +the board had nothing to render. + +Validates: +- the registry exposes read-only lookup (``get``) and a "current session" + fallback (``most_recent``) without materializing engines +- history resolves the requested session's engine, then the most recently + active one, and only falls back to the base engine (in-process mode) +- ``/board`` binds the caller's session id into the watch params +- the session producer forwards that bound id when reading history +""" +from __future__ import annotations + +import asyncio +from types import SimpleNamespace +from typing import Any + +import pytest + +from leapflow.daemon.session_coordinator import SessionCoordinator +from leapflow.daemon.session_registry import SessionRegistry + + +class _Wm: + def __init__(self, messages: list[dict[str, Any]]) -> None: + self._messages = messages + + def as_chat_messages(self) -> list[dict[str, Any]]: + return list(self._messages) + + +class _Engine: + def __init__(self, session_id: str, messages: list[dict[str, Any]]) -> None: + self._current_session_id = session_id + self._wm = _Wm(messages) + self.turn_count = len(messages) + self.context_token_count = 100 * len(messages) + + +def _registry(base: Any) -> SessionRegistry: + return SessionRegistry( + base_engine=base, + build_engine=lambda b, sid, wm, root: _Engine(sid, [{"role": "user", "content": f"hi from {sid}"}]), + build_working_memory=lambda: None, + ) + + +# ── SessionRegistry read-only lookup ───────────────────────────────── + + +def test_registry_get_does_not_create_and_most_recent_tracks_activity() -> None: + base = _Engine("", []) + registry = _registry(base) + + assert registry.get("absent") is None + assert registry.most_recent() is None + assert registry.active_count() == 0 # lookups must not materialize engines + + first = asyncio.run(registry.acquire("s1", workspace_root="/tmp")) + second = asyncio.run(registry.acquire("s2", workspace_root="/tmp")) + + assert registry.get("s1") is first + # s2 was acquired last, so it is the current session. + assert registry.most_recent() is second + first.touch() + assert registry.most_recent() is first + + +# ── get_history resolves the right engine ──────────────────────────── + + +def test_history_reads_requested_session_not_base_engine() -> None: + """The regression: base engine carries no conversation.""" + base = _Engine("", []) # base is a template: empty transcript + coordinator = SessionCoordinator() + registry = _registry(base) + coordinator._session_registry = registry + asyncio.run(registry.acquire("tui-a", workspace_root="/tmp")) + asyncio.run(registry.acquire("tui-b", workspace_root="/tmp")) + + ctx = SimpleNamespace(engine=base, _conversation_store=None) + history = asyncio.run(coordinator.get_history(ctx, None, session_id="tui-a")) + + assert history["session_id"] == "tui-a" + assert [m["content"] for m in history["messages"]] == ["hi from tui-a"] + + +def test_history_falls_back_to_most_recent_session_when_id_absent() -> None: + base = _Engine("", []) + coordinator = SessionCoordinator() + registry = _registry(base) + coordinator._session_registry = registry + asyncio.run(registry.acquire("older", workspace_root="/tmp")) + asyncio.run(registry.acquire("newest", workspace_root="/tmp")) + + ctx = SimpleNamespace(engine=base, _conversation_store=None) + history = asyncio.run(coordinator.get_history(ctx, None)) + + assert history["session_id"] == "newest" + + +def test_history_uses_base_engine_without_registry() -> None: + """In-process mode: ctx.engine *is* the conversation engine.""" + engine = _Engine("in-proc", [{"role": "user", "content": "local"}]) + coordinator = SessionCoordinator() + ctx = SimpleNamespace(engine=engine, _conversation_store=None) + + history = asyncio.run(coordinator.get_history(ctx, None)) + + assert history["session_id"] == "in-proc" + assert [m["content"] for m in history["messages"]] == ["local"] + + +def test_resolve_session_engine_handles_missing_context() -> None: + coordinator = SessionCoordinator() + assert coordinator.resolve_session_engine(None) == (None, "") + + +# ── /board binds the caller's session into the watch ───────────────── + + +class _Monitors: + def __init__(self) -> None: + self.armed_params: dict[str, Any] = {} + self.scheduled: list[tuple[str, bool]] = [] + + def list_watches(self) -> list[Any]: + return [] + + async def arm_watch(self, spec: Any) -> Any: + self.armed_params = dict(spec.params or {}) + return SimpleNamespace(watch_id="w-session", name="Session", domain="session") + + def schedule_watch_once(self, watch_id: str, *, force: bool = False) -> None: + self.scheduled.append((watch_id, force)) + + +@pytest.mark.asyncio +async def test_board_open_binds_caller_session_id_into_watch_params() -> None: + from leapflow.cli.commands.slash_handlers import command_execute + + monitors = _Monitors() + ctx = SimpleNamespace(monitors=monitors, settings=None, engine=None) + + payload = await command_execute(ctx, "board", "", session_id="tui-caller") + + assert payload["ok"] is True and payload["view"] == "dashboard" + assert monitors.armed_params.get("session_id") == "tui-caller" + assert monitors.scheduled == [("w-session", True)] + + +# ── producer forwards the bound session id ─────────────────────────── + + +@pytest.mark.asyncio +async def test_session_producer_reads_the_bound_session() -> None: + from leapflow.monitor.session_producer import SessionAnalysisProducer + from leapflow.monitor.types import ProducerContext, WatchSpec + + requested: list[str] = [] + + class _Services: + async def session_history(self, session_id: str = "") -> dict[str, Any]: + requested.append(session_id) + return { + "session_id": session_id, + "turn_count": 3, + "token_count": 10, + "messages": [{"role": "user", "content": "q"}], + "artifacts": [], + } + + async def analyze_session(self, messages, *, prior=None, artifacts=None): + return {"story": "analyzed"} + + async def should_refresh(self, messages) -> bool: + return True + + producer = SessionAnalysisProducer() + spec = WatchSpec( + name="Session", domain="session", trigger_expr="2m", + params={"session_id": "tui-bound"}, watch_id="w1", + ) + findings = await producer.observe( + ProducerContext(spec=spec, now=1000.0, run_count=0, last_run_at=0.0, + services=_Services(), force=True) + ) + + assert requested == ["tui-bound"] + assert len(findings) == 1 + assert findings[0].payload["story"] == "analyzed" + + +@pytest.mark.asyncio +async def test_session_producer_tolerates_legacy_history_signature() -> None: + """A host predating the session-scoped signature must still work.""" + from leapflow.monitor.session_producer import SessionAnalysisProducer + from leapflow.monitor.types import ProducerContext, WatchSpec + + class _LegacyServices: + async def session_history(self) -> dict[str, Any]: # no session_id param + return { + "session_id": "legacy", "turn_count": 1, "token_count": 5, + "messages": [{"role": "user", "content": "q"}], "artifacts": [], + } + + async def analyze_session(self, messages, *, prior=None, artifacts=None): + return {"story": "legacy ok"} + + async def should_refresh(self, messages) -> bool: + return True + + producer = SessionAnalysisProducer() + spec = WatchSpec( + name="Session", domain="session", trigger_expr="2m", + params={"session_id": "ignored-by-legacy-host"}, watch_id="w2", + ) + findings = await producer.observe( + ProducerContext(spec=spec, now=1000.0, run_count=0, last_run_at=0.0, + services=_LegacyServices(), force=True) + ) + + assert len(findings) == 1 + assert findings[0].payload["story"] == "legacy ok" diff --git a/tests/test_cli_discovery.py b/tests/test_cli_discovery.py index b5f131a..0830544 100644 --- a/tests/test_cli_discovery.py +++ b/tests/test_cli_discovery.py @@ -1,8 +1,7 @@ """Tests for CLI help-based command discovery.""" from __future__ import annotations -import asyncio -from unittest.mock import AsyncMock, patch +from unittest.mock import patch import pytest @@ -17,7 +16,7 @@ _infer_effect, ) from leapflow.gateway.connectors.action_registry import ActionRegistry -from leapflow.gateway.connectors.protocol import ActionSpec, BackendKind +from leapflow.gateway.connectors.protocol import ActionSpec # ── Realistic help text fixtures ───────────────────────────────────── diff --git a/tests/test_cli_ndjson_event_source.py b/tests/test_cli_ndjson_event_source.py index d80c6b6..8049c88 100644 --- a/tests/test_cli_ndjson_event_source.py +++ b/tests/test_cli_ndjson_event_source.py @@ -1,7 +1,6 @@ """Tests for CliNdjsonEventSource — generic CLI NDJSON subprocess manager.""" from __future__ import annotations -import asyncio from typing import Any import pytest diff --git a/tests/test_code_tools.py b/tests/test_code_tools.py index 18f5be8..b454da2 100644 --- a/tests/test_code_tools.py +++ b/tests/test_code_tools.py @@ -83,6 +83,77 @@ def test_code_search_python_backend_matches(tmp_path, monkeypatch) -> None: assert result["match_count"] == 1 and result["matches"][0]["line"] == 2 +def test_code_search_multiple_patterns_or_combined_single_pass(tmp_path, monkeypatch) -> None: + """P1-B2: batched patterns run as ONE search (one potential process spawn).""" + monkeypatch.setattr(fo.shutil, "which", lambda _name: None) # force fallback + (tmp_path / "a.py").write_text("alpha_token\n") + (tmp_path / "b.py").write_text("beta_token\n") + (tmp_path / "c.py").write_text("unrelated\n") + + result = _run(code_search({ + "pattern": "alpha_token", + "patterns": ["beta_token"], + "path": str(tmp_path), + })) + + assert result["ok"] is True + assert result["match_count"] == 2 + hit_paths = {m["path"] for m in result["matches"]} + assert any(p.endswith("a.py") for p in hit_paths) + assert any(p.endswith("b.py") for p in hit_paths) + + +def test_code_search_patterns_only_without_primary_pattern(tmp_path, monkeypatch) -> None: + monkeypatch.setattr(fo.shutil, "which", lambda _name: None) + (tmp_path / "a.py").write_text("needle\n") + result = _run(code_search({"patterns": ["needle"], "path": str(tmp_path)})) + assert result["ok"] is True and result["match_count"] == 1 + + +# ── ripgrep provisioning persistence (P0-B1) ─────────────────────── + + +def test_ripgrep_provision_marker_blocks_reinstall_across_restarts(tmp_path, monkeypatch) -> None: + """A failed install attempt persisted on disk must stop later daemon starts + from re-spawning the installer's process storm.""" + marker = tmp_path / "cache" / "ripgrep_provision.json" + monkeypatch.setattr(fo.shutil, "which", lambda _name: "/usr/local/bin/brew" if _name == "brew" else None) + monkeypatch.setattr(fo.sys, "platform", "darwin") + + install_calls: list[list[str]] = [] + + def fake_run(args, **kwargs): + install_calls.append(list(args)) + class _R: # noqa: N801 - minimal stub + returncode = 1 + return _R() + + monkeypatch.setattr(fo.subprocess, "run", fake_run) + + # First process lifetime: attempts the install once and persists the marker. + monkeypatch.setitem(fo._RG_PROVISION, "done", False) + monkeypatch.setitem(fo._RG_PROVISION, "available", False) + assert fo.ensure_ripgrep_available(autoinstall=True, marker_path=marker) is False + assert len(install_calls) == 1 + assert marker.exists() + + # Simulated restart (fresh in-process cache): marker must block a re-attempt. + monkeypatch.setitem(fo._RG_PROVISION, "done", False) + monkeypatch.setitem(fo._RG_PROVISION, "available", False) + assert fo.ensure_ripgrep_available(autoinstall=True, marker_path=marker) is False + assert len(install_calls) == 1 # no second installer spawn + + +def test_ripgrep_provision_marker_ignored_when_rg_present(tmp_path, monkeypatch) -> None: + marker = tmp_path / "ripgrep_provision.json" + marker.write_text('{"attempted": true, "available": false}') + monkeypatch.setattr(fo.shutil, "which", lambda _name: "/opt/homebrew/bin/rg" if _name == "rg" else None) + monkeypatch.setitem(fo._RG_PROVISION, "done", False) + monkeypatch.setitem(fo._RG_PROVISION, "available", False) + + assert fo.ensure_ripgrep_available(autoinstall=True, marker_path=marker) is True + + # ── file_find ──────────────────────────────────────────────────────── def test_file_find_recursive_glob(tmp_path) -> None: diff --git a/tests/test_config_and_path_contracts.py b/tests/test_config_and_path_contracts.py new file mode 100644 index 0000000..a6f3f73 --- /dev/null +++ b/tests/test_config_and_path_contracts.py @@ -0,0 +1,338 @@ +"""End-to-end guards for the config control plane and the path tree contract. + +AGENTS.md devotes a whole section to these, but coverage was scattered: each +feature asserted its own key in its own test file (``agent.reentry_enabled`` in +one, ``daemon.log_level`` in another), so a new field could ship without a +description, without hot-reload semantics, or writing outside the profile, and +no test would notice. These tests assert the contract over *every* field and +*every* managed path instead of one at a time. + +Covered contracts: +- `leap config` is the user-facing control plane (discoverable + mutable) +- Config catalog is the discovery contract (complete per-field metadata) +- Secrets are refs, never durable plaintext +- Path tree is a product contract (layout-owned, profile-scoped) +- Graceful degradation (optional components may be absent) +""" + +from __future__ import annotations + +import dataclasses +import pathlib + +import pytest + +from leapflow.config_service import ( + _FIELD_DESCRIPTIONS, + ConfigService, + _build_field_specs, +) +from leapflow.layout import build_layout + +_VALID_HOT_RELOAD = frozenset({"yes", "partial", "restart-required"}) +_VALID_SCOPES = frozenset({"profile", "workspace", "global"}) + + +@pytest.fixture() +def layout(tmp_path: pathlib.Path): + """A real layout rooted in a temp dir (never the user's home).""" + return build_layout(tmp_path / "leap-home") + + +# ── Config catalog is the discovery contract ───────────────────────────── + + +def test_catalog_is_not_empty() -> None: + """A collapsed catalog would make every assertion below vacuous.""" + assert len(_build_field_specs()) > 100 + + +def test_every_writable_field_declares_valid_discovery_metadata() -> None: + """Catalog metadata must be well-formed for every field. + + This guards hand-written spec mistakes on newly added settings (an invalid + ``hot_reload`` string, an unknown scope, a key that is not dot.separated). + Fields with generated fallbacks are deliberately not asserted non-empty: + ``description`` falls back to "Configure ." and ``value_hint`` is empty + for plain numeric settings, so such checks would pass unconditionally. The + parts that actually need prose are covered by the two tests below. + """ + problems: list[str] = [] + for name, spec in _build_field_specs().items(): + if not spec.key or "." not in spec.key: + problems.append(f"{name}: key must be dot.separated, got {spec.key!r}") + if spec.value_type is None: + problems.append(f"{name}: missing value_type") + if not spec.scopes: + problems.append(f"{name}: must declare at least one scope") + if not _VALID_SCOPES.issuperset(spec.scopes): + problems.append(f"{name}: unknown scope in {spec.scopes}") + if spec.hot_reload not in _VALID_HOT_RELOAD: + problems.append(f"{name}: hot_reload={spec.hot_reload!r} not in {sorted(_VALID_HOT_RELOAD)}") + if not spec.category: + problems.append(f"{name}: missing category") + + assert problems == [], "config catalog contract violations:\n " + "\n ".join(problems) + + +def test_restart_required_fields_explain_the_restart() -> None: + """A setting that needs a restart must say so in hand-written prose. + + The generated fallback ("Configure daemon request ledger ttl s.") never + mentions the restart, so a user edits the value, sees no effect, and has no + way to find out why. + """ + problems: list[str] = [] + for spec in _build_field_specs().values(): + if spec.hot_reload != "restart-required": + continue + description = _FIELD_DESCRIPTIONS.get(spec.key, "") + if not description: + problems.append(f"{spec.key}: relies on the generated description") + elif "restart" not in description.lower(): + problems.append(f"{spec.key}: description does not mention the restart") + + assert problems == [], "restart-required fields must be explained:\n " + "\n ".join(problems) + + +def test_secret_fields_have_hand_written_descriptions() -> None: + """Credential fields must explain provenance, not just restate the key.""" + missing = [ + spec.key + for spec in _build_field_specs().values() + if spec.secret and not _FIELD_DESCRIPTIONS.get(spec.key) + ] + + assert missing == [], f"secret fields need an explicit description: {missing}" + + +def test_catalog_keys_are_unique() -> None: + """Two specs sharing a key would make `leap config set` ambiguous.""" + keys = [spec.key for spec in _build_field_specs().values()] + + duplicates = {key for key in keys if keys.count(key) > 1} + assert duplicates == set(), f"duplicate config keys: {sorted(duplicates)}" + + +def test_secret_fields_declare_a_vault_ref_and_are_profile_scoped() -> None: + """Credentials must resolve to a vault ref, never to a durable value. + + Also profile-scoped: a workspace-writable credential would leak into a + shared repo. + """ + problems: list[str] = [] + for name, spec in _build_field_specs().items(): + if not spec.secret: + continue + if not spec.ref_name: + problems.append(f"{name}: secret field must declare ref_name") + if tuple(spec.scopes) != ("profile",): + problems.append(f"{name}: secret must be profile-scoped, got {spec.scopes}") + + assert problems == [], "secret field contract violations:\n " + "\n ".join(problems) + + +def test_writable_keys_matches_the_catalog(monkeypatch, tmp_path) -> None: + """``leap config keys`` and ``leap config list`` must not drift apart.""" + monkeypatch.setenv("LEAPFLOW_HOME", str(tmp_path / "home")) + from leapflow.config import get_settings + + service = ConfigService(get_settings()) + + assert set(service.writable_keys()) == {s.key for s in _build_field_specs().values()} + + +def test_describe_exposes_every_field_the_contract_requires(monkeypatch, tmp_path) -> None: + """``leap config show `` must render the full discovery contract. + + AGENTS.md enumerates exactly what a writable field has to expose; assert the + view carries all of it so a trimmed-down renderer cannot pass. + """ + monkeypatch.setenv("LEAPFLOW_HOME", str(tmp_path / "home")) + from leapflow.config import get_settings + + view = ConfigService(get_settings()).describe("runtime.log_level") + + required = { + "key", "value", "value_type", "scopes", + "hot_reload", "category", "value_hint", "description", + } + present = {field.name for field in dataclasses.fields(view)} + assert required <= present, f"missing from the field view: {sorted(required - present)}" + + assert view.key == "runtime.log_level" + assert view.hot_reload in _VALID_HOT_RELOAD + assert view.description + # The hint is what makes an enum-like value self-discoverable in the TUI. + assert view.value_hint + + +def test_unknown_key_is_rejected_rather_than_silently_accepted(monkeypatch, tmp_path) -> None: + """Both read and write reject an unknown key loudly. + + Silently accepting a typo'd key would strand the user's setting in a file + nothing ever reads. + """ + monkeypatch.setenv("LEAPFLOW_HOME", str(tmp_path / "home")) + from leapflow.config import get_settings + + service = ConfigService(get_settings()) + + with pytest.raises(ValueError, match="Unknown config key"): + service.describe("definitely.not.a.real.key") + with pytest.raises(ValueError, match="Unsupported config key"): + service.set("definitely.not.a.real.key", "x") + + +# ── Path tree is a product contract ────────────────────────────────────── + + +def test_managed_paths_stay_under_the_layout_root(layout) -> None: + """Every layout-declared path must live under the layout root. + + Guards against an ad-hoc join escaping into ``~`` or the CWD. + """ + root = layout.root.resolve() + candidates = { + "user_config_path": layout.user_config_path, + "mcp_servers_path": layout.mcp_servers_path, + "policy_config_path": layout.policy_config_path, + "profiles_dir": layout.profiles_dir, + "logs_dir": layout.logs_dir, + "defaults_lock_path": layout.defaults_lock_path, + } + + for name, path in candidates.items(): + assert pathlib.Path(path).resolve().is_relative_to(root), ( + f"{name} ({path}) escapes the layout root {root}" + ) + + +def test_profile_owns_its_state_and_never_the_workspace(layout, tmp_path) -> None: + """Profile data must not be written into the user's workspace.""" + profile = layout.ensure(profile_id="default") + workspace = tmp_path / "some-workspace" + workspace.mkdir() + + profile_root = pathlib.Path(profile.config_dir).resolve().parent + owned = [ + profile.duckdb_path, profile.conversation_db_path, profile.audit_log_path, + profile.memory_dir, profile.config_dir, profile.gateway_config_path, + profile.llm_config_path, profile.approval_config_path, + ] + + for path in owned: + resolved = pathlib.Path(path).resolve() + assert resolved.is_relative_to(profile_root), f"{path} is not profile-owned" + assert not resolved.is_relative_to(workspace.resolve()), ( + f"{path} leaked into the workspace" + ) + + +def test_workspace_footprint_is_limited_to_declared_files(layout, tmp_path) -> None: + """Workspace-local files are limited to config + manifest, in .leapflow/.""" + workspace = tmp_path / "ws" + workspace.mkdir() + + config_path = pathlib.Path(layout.workspace_config_path(workspace)).resolve() + manifest_path = pathlib.Path(layout.workspace_manifest_path(workspace)).resolve() + + for path in (config_path, manifest_path): + assert path.is_relative_to(workspace.resolve()) + assert path.parent.name == ".leapflow", f"{path} must live in .leapflow/" + assert {config_path.name, manifest_path.name} == {"config.yaml", "workspace.yaml"} + + +def test_profiles_are_mutually_isolated(layout) -> None: + """Two profiles must not share DBs, memory, secrets, or audit logs.""" + first = layout.ensure(profile_id="default") + second = layout.ensure(profile_id="work") + + pairs = [ + (first.duckdb_path, second.duckdb_path), + (first.memory_dir, second.memory_dir), + (first.audit_log_path, second.audit_log_path), + (first.config_dir, second.config_dir), + (first.secrets.vault_path, second.secrets.vault_path), + ] + for left, right in pairs: + assert pathlib.Path(left).resolve() != pathlib.Path(right).resolve(), ( + f"profiles share {left}" + ) + + +def test_no_legacy_run_or_flat_cache_paths(layout) -> None: + """Retired layouts must not come back (``run/``, flat cache roots).""" + profile = layout.ensure(profile_id="default") + described = { + "duckdb": str(profile.duckdb_path), + "audit": str(profile.audit_log_path), + "cache": str(profile.cache.profile_dir), + } + + for name, path in described.items(): + assert "/run/" not in path, f"{name} uses the retired run/ path: {path}" + # Cache must be scoped under the profile, not a shared flat root. + assert pathlib.Path(profile.cache.profile_dir).resolve().is_relative_to( + pathlib.Path(profile.config_dir).resolve().parent + ) + + +def test_secret_vault_is_profile_scoped_and_separate_from_config(layout) -> None: + """Vault files must not sit inside the readable config directory.""" + profile = layout.ensure(profile_id="default") + + vault = pathlib.Path(profile.secrets.vault_path).resolve() + key = pathlib.Path(profile.secrets.key_path).resolve() + config_dir = pathlib.Path(profile.config_dir).resolve() + + assert not vault.is_relative_to(config_dir), "vault must not live in config/" + assert not key.is_relative_to(config_dir), "vault key must not live in config/" + assert vault != key, "key material must be separate from the vault payload" + + +# ── Graceful degradation ───────────────────────────────────────────────── + + +def test_config_service_works_without_any_existing_config(monkeypatch, tmp_path) -> None: + """First run: no files on disk yet, and the control plane still answers.""" + monkeypatch.setenv("LEAPFLOW_HOME", str(tmp_path / "pristine")) + from leapflow.config import get_settings + + service = ConfigService(get_settings()) + + assert service.writable_keys(), "catalog must be available before any file exists" + assert service.describe("runtime.log_level") is not None + + +def test_dashboard_view_builds_with_no_watches_or_findings() -> None: + """A dashboard with an empty backend must render, not raise. + + This is the degraded path a fresh board hits before the first analysis. + """ + import asyncio + + from leapflow.dashboard.intent import DashboardIntent + from leapflow.dashboard.service import DashboardViewBuilder + + class _EmptyProvider: + async def watches(self) -> list[dict]: + return [] + + async def findings(self, *, watch_id: str = "", limit: int = 50) -> list[dict]: + return [] + + spec = asyncio.run( + DashboardViewBuilder().build(DashboardIntent.from_params({}), _EmptyProvider()) + ) + + assert isinstance(spec, dict) + assert "root" in spec, "an empty board must still produce a renderable ViewSpec" + + +def test_field_spec_is_immutable() -> None: + """The catalog is shared read-only state; a mutable spec invites drift.""" + spec = next(iter(_build_field_specs().values())) + + with pytest.raises(dataclasses.FrozenInstanceError): + spec.description = "rewritten" # type: ignore[misc] diff --git a/tests/test_config_loader.py b/tests/test_config_loader.py index af3980b..a73f4d6 100644 --- a/tests/test_config_loader.py +++ b/tests/test_config_loader.py @@ -93,3 +93,118 @@ def test_mask_secret_reveals_only_a_short_suffix() -> None: assert _mask_secret("12345678") == "***" # Never leaks the full value. assert "1234567890" not in _mask_secret("sk-1234567890abc") + + +def test_daemon_log_level_config_chain(monkeypatch, tmp_path) -> None: + """daemon.log_level flows through all three layers: Settings default, + env override, and the discoverable config catalog (restart-required).""" + from conftest import make_settings + from leapflow.config import _build_settings_from_env + from leapflow.config_service import _build_field_specs + + # Layer 1: Settings default keeps daemon file logs at INFO. + settings = make_settings(str(tmp_path)) + assert settings.daemon_log_level == "INFO" + + # Layer 2: catalog exposes the key with restart-required semantics. + specs = _build_field_specs() + assert "daemon.log_level" in specs + spec = specs["daemon.log_level"] + assert spec.setting_name == "daemon_log_level" + + # Layer 3: LEAPFLOW_DAEMON_LOG_LEVEL overrides (the same channel the + # config loader uses when flattening daemon.yaml values). + monkeypatch.setenv("LEAPFLOW_DATA_DIR", str(tmp_path / "home")) + monkeypatch.setenv("LEAPFLOW_DAEMON_LOG_LEVEL", "DEBUG") + built = _build_settings_from_env() + assert built.daemon_log_level == "DEBUG" + + +def test_logging_setup_is_idempotent_and_redacting(monkeypatch) -> None: + """Centralized init: one tagged redacting handler on root, re-init only + adjusts the level and never stacks duplicate handlers.""" + import logging + + from leapflow import logging_setup + from leapflow.security.redact import RedactingFormatter + + root = logging.getLogger() + original_handlers = list(root.handlers) + original_level = root.level + try: + for handler in original_handlers: + root.removeHandler(handler) + + logging_setup.init_logging("INFO") + owned = [h for h in root.handlers if getattr(h, "_leapflow_log_handler", False)] + assert len(owned) == 1 + assert isinstance(owned[0].formatter, RedactingFormatter) + assert root.level == logging.INFO + + # Re-init: level changes, handler count does not. + logging_setup.init_logging("DEBUG") + owned_again = [h for h in root.handlers if getattr(h, "_leapflow_log_handler", False)] + assert owned_again == owned + assert root.level == logging.DEBUG + + # Unknown level falls back to the surface default, never raises. + logging_setup.init_logging("NOT-A-LEVEL") + assert root.level == logging.WARNING + finally: + for handler in list(root.handlers): + root.removeHandler(handler) + for handler in original_handlers: + root.addHandler(handler) + root.setLevel(original_level) + + +def test_surface_level_policy_cli_quiet_daemon_verbose(tmp_path) -> None: + """Level policy lives in one place: CLI/TUI defaults quiet (WARNING), + the daemon surface defaults verbose (INFO) for leapd.log evidence.""" + import logging + + from leapflow import logging_setup + + resolved: list[int] = [] + + class _Settings: + log_level = "" + daemon_log_level = "" + + original = logging_setup.init_logging + try: + logging_setup.init_logging = lambda level, *, default=logging.WARNING: resolved.append( + logging_setup.resolve_level(level, default=default) + ) + logging_setup.init_cli_logging(_Settings()) + logging_setup.init_daemon_logging(_Settings()) + finally: + logging_setup.init_logging = original + + assert resolved == [logging.WARNING, logging.INFO] + + +def test_daemon_serve_applies_daemon_log_level(monkeypatch, tmp_path) -> None: + """`leap daemon serve` must configure logging from daemon.log_level so + INFO field evidence lands in leapd.log (previously nothing configured + logging in the daemon process and only WARNING+ was emitted).""" + import asyncio + + from leapflow.cli.commands import daemon as daemon_module + + applied: list[object] = [] + + async def fake_serve(settings, *, mock_host=False): + return 0 + + monkeypatch.setattr( + "leapflow.logging_setup.init_daemon_logging", lambda settings: applied.append(settings) + ) + monkeypatch.setattr("leapflow.daemon.server.serve_daemon", fake_serve) + + class Settings: + daemon_log_level = "DEBUG" + + settings = Settings() + assert asyncio.run(daemon_module._serve(settings, mock_host=True)) == 0 + assert applied == [settings] diff --git a/tests/test_daemon_event_loop_blocking.py b/tests/test_daemon_event_loop_blocking.py index 1fa8685..531b89a 100644 --- a/tests/test_daemon_event_loop_blocking.py +++ b/tests/test_daemon_event_loop_blocking.py @@ -24,7 +24,6 @@ from __future__ import annotations import asyncio -import inspect import time from pathlib import Path from typing import Any, List, Optional @@ -237,32 +236,13 @@ async def _slow_init() -> None: class TestDeferredDbExecutor: - """Blocking DuckDB work must not freeze the event loop.""" - - def test_initialize_deferred_routes_heavy_ops_through_executor(self) -> None: - """Every known-heavy synchronous DB call site must go through the - dedicated executor helper instead of running on the loop thread.""" - source = inspect.getsource(Context.initialize_deferred) - assert source.count("_run_deferred_db") >= 6, ( - "initialize_deferred() must route its heavy synchronous DuckDB " - "operations through _run_deferred_db (dedicated executor)" - ) - for marker in ( - "load_and_activate_all", - "load_all_as_skills", - "_register_stored_skill_fallbacks", - "_load_evolution_store", - "_hydrate_l1_markov", - "load_all_active", - ): - # Use the LAST occurrence: helper definitions may precede the - # wrapped call site (e.g. the _load_evolution_store closure). - call_pos = source.rfind(marker) - assert call_pos != -1, f"expected {marker} in initialize_deferred" - window = source[max(0, call_pos - 400):call_pos] - assert "_run_deferred_db" in window, ( - f"{marker} must be wrapped by _run_deferred_db" - ) + """Blocking DuckDB work must not freeze the event loop. + + Asserted behaviorally below: scanning ``initialize_deferred``'s source for + ``_run_deferred_db`` occurrences would pin the current call-site layout + rather than the contract — that a blocking DB call keeps the loop + responsive and that ``status()`` still answers while the DB worker is busy. + """ @pytest.mark.asyncio async def test_event_loop_stays_responsive_during_blocking_db_op(self) -> None: diff --git a/tests/test_daemon_rpc.py b/tests/test_daemon_rpc.py index 2ac3219..3cbd01f 100644 --- a/tests/test_daemon_rpc.py +++ b/tests/test_daemon_rpc.py @@ -1332,13 +1332,14 @@ async def usage_summary(self) -> dict[str, Any]: "context_length": 100, } - async def command_execute(self, name: str, args: str = "") -> dict[str, Any]: + async def command_execute(self, name: str, args: str = "", session_id: str = "") -> dict[str, Any]: return { "ok": True, "view": name, "model": "test-model", "context_length": 100, "requested_model": args, + "seen_session_id": session_id, } async def app_command(self, args: str = "") -> dict[str, Any]: @@ -1355,7 +1356,7 @@ async def app_command(self, args: str = "") -> dict[str, Any]: try: tools = await client.tools_list() usage = await client.usage_summary() - model = await client.command_execute("model", "next-model") + model = await client.command_execute("model", "next-model", session_id="tui-1") app_payload = await client.app_command("list") finally: task.cancel() @@ -1368,6 +1369,9 @@ async def app_command(self, args: str = "") -> dict[str, Any]: assert tools["groups"] == {"core": ["chat"]} assert usage["total_tokens"] == 15 assert model["requested_model"] == "next-model" + # session_id must reach the daemon: /board binds it to the session watch, so a + # silently dropped id makes the board analyze the wrong conversation. + assert model["seen_session_id"] == "tui-1" assert app_payload["view"] == "list" assert app_payload["args"] == "list" diff --git a/tests/test_deferred_init_responsiveness.py b/tests/test_deferred_init_responsiveness.py index 3f4a1dc..9db118f 100644 --- a/tests/test_deferred_init_responsiveness.py +++ b/tests/test_deferred_init_responsiveness.py @@ -6,8 +6,7 @@ client's 30s readline timeout to fire on the first command. Validates: -- ``initialize_deferred()`` contains event-loop yield points so concurrent - tasks (heartbeats) can run while it executes +- concurrent tasks (heartbeats) can run while ``initialize_deferred()`` executes - ``engine_chat()`` yields an immediate ``status`` chunk before blocking on deferred initialization - ``_ensure_deferred()`` stops retrying after repeated failures (degraded @@ -16,7 +15,6 @@ from __future__ import annotations import asyncio -import inspect from typing import Any import pytest @@ -31,18 +29,13 @@ class TestDeferredInitYieldPoints: - """initialize_deferred() must cooperatively yield to the event loop.""" - - def test_initialize_deferred_contains_yield_points(self) -> None: - """The coroutine must contain multiple `await asyncio.sleep(0)` yield - points at phase boundaries so heartbeat callbacks stay responsive.""" - source = inspect.getsource(Context.initialize_deferred) - yield_points = source.count("await asyncio.sleep(0)") - assert yield_points >= 6, ( - f"initialize_deferred() has only {yield_points} event-loop yield " - "points; expected >= 6 (one per heavy phase) to keep the daemon " - "keepalive heartbeat alive during startup" - ) + """initialize_deferred() must cooperatively yield to the event loop. + + Asserted behaviorally: counting ``await asyncio.sleep(0)`` occurrences in + the source would freeze an implementation detail (how many yield points, + written which way) instead of the contract that matters — that a concurrent + task still gets scheduled while initialization runs. + """ @pytest.mark.asyncio async def test_concurrent_task_runs_during_deferred_wait(self) -> None: @@ -114,6 +107,35 @@ async def test_first_chunk_is_status_when_deferred_pending(self) -> None: # The status chunk must arrive BEFORE the blocking wait starts assert fake_ctx.ensure_called is False + @pytest.mark.asyncio + async def test_degraded_status_streamed_when_deferred_times_out(self) -> None: + """P1-A2: a warm-up timeout must be visible to the client as a status + chunk (transparent degradation), not only a daemon-side log line.""" + + class _NeverReady: + _deferred_initialized = False + + async def _ensure_deferred(self) -> None: + await asyncio.Event().wait() + + service = RuntimeLeapService.__new__(RuntimeLeapService) + service._ctx = _NeverReady() + service._DEFERRED_WAIT_TIMEOUT_S = 0.05 # type: ignore[misc] + service._turn_admission = type( + "_Admission", (), {"locked": staticmethod(lambda: False)} + )() + + stream = service.engine_chat("hello", request_id="req-degrade-status") + try: + first = await stream.__anext__() + assert "warming up" in first.content.lower() + second = await asyncio.wait_for(stream.__anext__(), timeout=2.0) + assert second.event_type == "status" + assert (second.metadata or {}).get("degraded") == "warmup" + assert "core" in second.content.lower() + finally: + await stream.aclose() + @pytest.mark.asyncio async def test_no_warmup_chunk_when_deferred_completed(self) -> None: service = RuntimeLeapService.__new__(RuntimeLeapService) diff --git a/tests/test_empty_response_hardening.py b/tests/test_empty_response_hardening.py new file mode 100644 index 0000000..f1b05e8 --- /dev/null +++ b/tests/test_empty_response_hardening.py @@ -0,0 +1,126 @@ +"""Regression tests for empty-LLM-response hardening (P0-A1). + +Root cause (observed as "I processed your request but have no additional +output." on the first TUI turn right after daemon startup): an LLM call that +succeeded with empty content was converted into a fake-success filler message +instead of being treated as a failure signal. + +Validates: +- an empty response gets exactly one bounded retry with an explicit nudge +- a recovered second response is returned as the final answer +- a second empty response yields a transparent degraded message, never the + old fake-success filler +""" +from __future__ import annotations + +import dataclasses +import tempfile +from typing import Any, List + +import pytest + +from conftest import make_settings +from leapflow.engine.engine import ( + _EMPTY_RESPONSE_DEGRADED_MESSAGE, + _EMPTY_RESPONSE_RETRY_PROMPT, + AgentEngine, + build_default_registry, +) +from leapflow.engine.intent_classifier import Intent +from leapflow.llm.base import LLMChatResponse, LLMProvider +from leapflow.memory import ( + EpisodicMemoryProvider, + SemanticMemoryProvider, + WorkingMemoryProvider, +) +from leapflow.platform.mock import MockBridge + + +class _FixedClassifier: + async def classify(self, user_text: str) -> Intent: + return Intent(label="complex", reason="test") + + +class _ScriptedLLM(LLMProvider): + """Returns scripted contents in order; records the messages it saw.""" + + def __init__(self, replies: List[str]) -> None: + self._replies = list(replies) + self.call_count = 0 + self.seen_messages: List[List[dict[str, Any]]] = [] + + async def achat( + self, + messages: List[dict[str, Any]], + *, + stream: bool = True, + enable_thinking: bool = False, + **kwargs: Any, + ) -> LLMChatResponse: + self.seen_messages.append(list(messages)) + text = self._replies[self.call_count] if self.call_count < len(self._replies) else "" + self.call_count += 1 + return LLMChatResponse(content=text) + + async def achat_stream( + self, + messages: List[dict[str, Any]], + *, + enable_thinking: bool = False, + **kwargs: Any, + ): + if False: # pragma: no cover + yield "" + + +def _build_engine(td: str, llm: LLMProvider) -> tuple[AgentEngine, SemanticMemoryProvider]: + settings = dataclasses.replace(make_settings(td), stream_output=False) + rpc = MockBridge() + wm = WorkingMemoryProvider(max_tokens=1024) + lt = SemanticMemoryProvider(source=settings.duckdb_path) + imm = EpisodicMemoryProvider() + reg = build_default_registry(rpc, llm, wm, lt) + engine = AgentEngine(settings, rpc, llm, wm, lt, imm, reg, _FixedClassifier()) + return engine, lt + + +@pytest.mark.asyncio +async def test_empty_response_retried_once_then_recovers() -> None: + with tempfile.TemporaryDirectory() as td: + llm = _ScriptedLLM(["", "Potatoes have about 77 kcal per 100g."]) + engine, lt = _build_engine(td, llm) + try: + events = [event async for event in engine.run_stream("calories of potatoes")] + finally: + lt.close() + + finals = [event for event in events if event.type == "final"] + assert finals, "expected a final event" + assert "77 kcal" in finals[-1].content + assert llm.call_count == 2 + # The retry carried the explicit nudge so the correction is model-visible. + retry_texts = [ + str(msg.get("content")) + for msg in llm.seen_messages[-1] + if msg.get("role") == "user" + ] + assert any(_EMPTY_RESPONSE_RETRY_PROMPT in text for text in retry_texts) + + +@pytest.mark.asyncio +async def test_double_empty_response_yields_transparent_degraded_message() -> None: + with tempfile.TemporaryDirectory() as td: + llm = _ScriptedLLM(["", ""]) + engine, lt = _build_engine(td, llm) + try: + events = [event async for event in engine.run_stream("hello")] + finally: + lt.close() + + finals = [event for event in events if event.type == "final"] + assert finals, "expected a final event" + final_text = finals[-1].content + assert final_text == _EMPTY_RESPONSE_DEGRADED_MESSAGE + # The old fake-success filler must never resurface. + assert "no additional output" not in final_text + assert llm.call_count == 2 # exactly one bounded retry, no loop diff --git a/tests/test_feishu_event_normalizer.py b/tests/test_feishu_event_normalizer.py index 460f019..c86bd70 100644 --- a/tests/test_feishu_event_normalizer.py +++ b/tests/test_feishu_event_normalizer.py @@ -1,7 +1,6 @@ """Tests for FeishuEventNormalizer — Feishu event classification and mapping.""" from __future__ import annotations -import pytest from leapflow.gateway.connectors.protocol import BackendEvent, EventKind from leapflow.gateway.normalizers.feishu import FeishuEventNormalizer diff --git a/tests/test_recovery_audit.py b/tests/test_recovery_audit.py index dc4933e..ab9cca8 100644 --- a/tests/test_recovery_audit.py +++ b/tests/test_recovery_audit.py @@ -23,7 +23,6 @@ from leapflow.engine.recovery_decision import ( RecoveryAction, RecoveryDecision, - RetrySemantics, ) diff --git a/tests/test_recovery_checkpoint.py b/tests/test_recovery_checkpoint.py index df3bbd5..79ca6f3 100644 --- a/tests/test_recovery_checkpoint.py +++ b/tests/test_recovery_checkpoint.py @@ -2,7 +2,6 @@ from __future__ import annotations import time -from unittest.mock import patch import pytest diff --git a/tests/test_recovery_contract_e2e.py b/tests/test_recovery_contract_e2e.py new file mode 100644 index 0000000..5fe49ca --- /dev/null +++ b/tests/test_recovery_contract_e2e.py @@ -0,0 +1,303 @@ +"""End-to-end guards for the recovery contracts in AGENTS.md. + +The existing recovery tests are unit-level: they exercise one budget method or +one strategy's ``decide()`` in isolation. These tests instead drive the whole +documented pipeline — ``FailureEnvelope`` → ``RecoveryDecision`` → +``StrategyOutcome`` — through a real ``RecoveryCoordinator`` with the real +default strategy registry, which is where a contract actually holds or breaks. + +Covered contracts: +- Single Recovery Decision Point (one coordinator, explainable decisions) +- Budget-Constrained Recovery (per-category, global, deadline → clean halt) +- Side-Effect Gating (committed/partial/unknown must block automatic retry) +""" + +from __future__ import annotations + +import time + +import pytest + +from leapflow.engine.failure_envelope import ( + FailureContext, + FailureEnvelope, + FailureSource, + Recoverability, + SideEffectState, +) +from leapflow.engine.recovery_budget import RecoveryBudget +from leapflow.engine.recovery_coordinator import RecoveryCoordinator +from leapflow.engine.recovery_decision import RecoveryAction +from leapflow.engine.recovery_strategies import default_strategies + +# Actions that re-run work and can therefore duplicate an already-applied effect. +_AUTOMATIC_RETRY_ACTIONS = frozenset({ + RecoveryAction.RETRY_WITH_BACKOFF, + RecoveryAction.TRANSFORM_AND_RETRY, + RecoveryAction.FAILOVER, +}) + + +def _coordinator(**budget_kwargs) -> RecoveryCoordinator: + """Build a coordinator over the real default strategies.""" + budget = RecoveryBudget(**budget_kwargs) if budget_kwargs else RecoveryBudget() + coord = RecoveryCoordinator(strategies=default_strategies(), budget=budget) + coord.budget.start_deadline() + return coord + + +def _envelope( + *, + source: FailureSource = FailureSource.LLM, + category: str = "transient", + message: str = "connection reset by peer", + recoverability: Recoverability = Recoverability.AUTO_RETRY, + side_effect_state: SideEffectState = SideEffectState.NONE, + tool_name: str = "", +) -> FailureEnvelope: + return FailureEnvelope.create( + source=source, + category=category, + failure_class="transient", + failure_code="reset", + message=message, + recoverability=recoverability, + side_effect_state=side_effect_state, + context=FailureContext.from_dict_args(tool_name=tool_name), + ) + + +# ── Single Recovery Decision Point ─────────────────────────────────────── + + +def test_every_decision_is_explainable_and_attributed() -> None: + """A decision without a reason or owning strategy cannot be audited.""" + coord = _coordinator(total_recovery_actions=16) + + for source, category in ( + (FailureSource.LLM, "context_overflow"), + (FailureSource.LLM, "transient"), + (FailureSource.TOOL, "tool_timeout"), + (FailureSource.SYSTEM, "system_network"), + ): + decision = coord.evaluate(_envelope(source=source, category=category)) + + assert decision.reason, f"{source}/{category} produced an unexplained decision" + assert decision.strategy_key, f"{source}/{category} produced an unattributed decision" + assert decision.decision_id, "decisions must be identifiable for outcome feedback" + + +def test_decisions_are_audited_with_budget_accounting() -> None: + """Every evaluation appends one audit entry carrying its cost accounting.""" + coord = _coordinator(total_recovery_actions=8) + + coord.evaluate(_envelope()) + coord.evaluate(_envelope(category="context_overflow")) + + assert len(coord.audit_log) == 2 + for entry in coord.audit_log: + assert entry["event"] == "recovery_decision" + assert entry["strategy_key"] + assert entry["reason"] + # Cost accounting must be present so budget drift is diagnosable. + assert "budget_cost" in entry and "budget_remaining" in entry + + +def test_outcome_feedback_closes_the_loop() -> None: + """StrategyOutcome feedback is what lets the next decision differ. + + Without it the coordinator would evaluate every failure against a stale + state and keep picking the same strategy. + """ + coord = _coordinator() + decision = coord.evaluate(_envelope()) + + coord.on_strategy_outcome(decision.decision_id, success=False) + assert coord.state.consecutive_failures == 1 + + coord.on_strategy_outcome(decision.decision_id, success=True) + assert coord.state.consecutive_failures == 0, "success must reset the failure streak" + + +def test_non_recoverable_failure_never_reaches_a_strategy() -> None: + """Non-recoverable failures short-circuit to a terminal decision.""" + coord = _coordinator() + + decision = coord.evaluate(_envelope(recoverability=Recoverability.NON_RECOVERABLE)) + + assert decision.is_terminal + assert decision.action == RecoveryAction.HALT_CLEAN + assert "non-recoverable" in decision.reason.lower() + + +# ── Budget-Constrained Recovery ────────────────────────────────────────── + + +def test_per_category_exhaustion_halts_deterministically() -> None: + """A single failure category cannot consume unbounded recovery attempts.""" + coord = _coordinator(total_recovery_actions=10, max_retry_per_category=2) + + first = coord.evaluate(_envelope()) + second = coord.evaluate(_envelope()) + third = coord.evaluate(_envelope()) + + assert first.action in _AUTOMATIC_RETRY_ACTIONS + assert second.action in _AUTOMATIC_RETRY_ACTIONS + assert third.action == RecoveryAction.HALT_CLEAN, "category budget must stop retrying" + # A different category is still serviceable: the limit is per-category. + assert coord.evaluate(_envelope(category="tool_timeout", source=FailureSource.TOOL)).action \ + in _AUTOMATIC_RETRY_ACTIONS + + +def test_global_budget_exhaustion_halts_and_stays_halted() -> None: + """Exhaustion is terminal and idempotent — never a flapping decision.""" + coord = _coordinator(total_recovery_actions=2, max_retry_per_category=99) + + coord.evaluate(_envelope()) + coord.evaluate(_envelope()) + halted = [coord.evaluate(_envelope()) for _ in range(3)] + + assert all(d.action == RecoveryAction.HALT_CLEAN for d in halted) + assert all("exhausted" in d.reason.lower() for d in halted) + # Terminal decisions must not keep charging the budget. + assert coord.budget.remaining() == 0 + + +def test_deadline_exceeded_halts_before_any_strategy_runs() -> None: + """A turn that ran out of wall-clock time stops instead of retrying.""" + budget = RecoveryBudget(turn_deadline_s=0.01) + budget.start_deadline() + coord = RecoveryCoordinator(strategies=default_strategies(), budget=budget) + time.sleep(0.02) + + decision = coord.evaluate(_envelope()) + + assert decision.action == RecoveryAction.HALT_CLEAN + assert "deadline" in decision.reason.lower() + + +def test_exhausted_budget_reports_what_was_attempted() -> None: + """The halt reason must name the attempts, or the user learns nothing. + + Uses a budget-consuming category on purpose: transform strategies such as + ``context_compress`` cost 0, so they would never exhaust the budget. + """ + coord = _coordinator(total_recovery_actions=1) + + coord.evaluate(_envelope(category="transient")) + halt = coord.evaluate(_envelope(category="transient")) + + assert halt.action == RecoveryAction.HALT_CLEAN + assert "exhausted" in halt.reason.lower() + assert "jittered_retry" in halt.reason or "attempted" in halt.reason.lower() + + +def test_new_turn_restores_one_shot_strategies() -> None: + """One-shot strategies are per-turn, not per-process.""" + coord = _coordinator(total_recovery_actions=16) + + first = coord.evaluate(_envelope(category="billing")) + assert first.strategy_key == "provider_failover" + # Same turn: the one-shot failover is spent, so something else must answer. + second = coord.evaluate(_envelope(category="billing")) + assert second.strategy_key != "provider_failover" + + coord.new_turn(turn_id=2) + assert coord.evaluate(_envelope(category="billing")).strategy_key == "provider_failover" + + +# ── Side-Effect Gating ─────────────────────────────────────────────────── + + +def test_read_only_failure_is_freely_retryable() -> None: + """The baseline: with no side effect, automatic retry is correct.""" + coord = _coordinator(total_recovery_actions=16) + + decision = coord.evaluate(_envelope(side_effect_state=SideEffectState.NONE)) + + assert decision.action in _AUTOMATIC_RETRY_ACTIONS + + +@pytest.mark.parametrize( + "side_effect_state", + [SideEffectState.COMMITTED, SideEffectState.PARTIAL, SideEffectState.UNKNOWN], +) +def test_applied_side_effects_block_automatic_retry(side_effect_state) -> None: + """Contract: mutated state permits only user-mediated/checkpoint resumption. + + Retrying after an effect landed can send a message twice, write a file + twice, or re-charge an external API. ``UNKNOWN`` is included because it is + what ``external_side_effect`` maps to — exempting it would leave outbound + sends ungated. + """ + coord = _coordinator(total_recovery_actions=16) + + decision = coord.evaluate( + _envelope( + source=FailureSource.TOOL, + category="tool_timeout", + side_effect_state=side_effect_state, + tool_name="gateway_send", + ) + ) + + assert decision.action not in _AUTOMATIC_RETRY_ACTIONS, ( + f"{side_effect_state.name} side effects must not be retried automatically; " + f"got {decision.action.name} from {decision.strategy_key}" + ) + assert decision.action == RecoveryAction.HALT_WITH_CHECKPOINT + # A gated halt must tell the user what to check, not just stop. + assert decision.interaction is not None + assert decision.interaction.resumption_key + assert decision.interaction.suggested_actions + assert "gateway_send" in decision.reason + + +def test_side_effect_gate_does_not_consume_recovery_budget() -> None: + """Being blocked is not an attempt; it must not eat the turn's budget.""" + coord = _coordinator(total_recovery_actions=4) + before = coord.budget.remaining() + + decision = coord.evaluate( + _envelope(side_effect_state=SideEffectState.COMMITTED, tool_name="file_write") + ) + + assert decision.action == RecoveryAction.HALT_WITH_CHECKPOINT + assert coord.budget.remaining() == before + + +def test_side_effect_gate_is_audited() -> None: + """A withheld retry must be visible in the audit trail.""" + coord = _coordinator(total_recovery_actions=8) + + coord.evaluate(_envelope(side_effect_state=SideEffectState.PARTIAL, tool_name="scm_sync")) + + assert coord.audit_log[-1]["strategy_key"] == "" + + +def test_side_effect_state_survives_the_envelope_roundtrip() -> None: + """Whatever the gate ends up doing, the signal must reach the decision. + + Guards the input half of the gap above: the classifier's judgement has to be + observable on the envelope the coordinator receives. + """ + envelope = _envelope(side_effect_state=SideEffectState.COMMITTED) + + assert envelope.side_effect_state is SideEffectState.COMMITTED + + coord = _coordinator() + decision = coord.evaluate(envelope) + assert decision.envelope.side_effect_state is SideEffectState.COMMITTED + + +def test_classifier_maps_external_side_effect_to_a_gated_state() -> None: + """An outbound external call must never be classified as effect-free.""" + from leapflow.engine.unified_classifier import UnifiedErrorClassifier + + mapped = UnifiedErrorClassifier._side_effect_state_from_policy("external_side_effect") + idempotent = UnifiedErrorClassifier._side_effect_state_from_policy("mutating_idempotent") + read_only = UnifiedErrorClassifier._side_effect_state_from_policy("read_only") + + assert read_only is SideEffectState.NONE + assert mapped is not SideEffectState.NONE, "external side effects must be gated" + assert idempotent is not SideEffectState.NONE, "mutations must be gated" diff --git a/tests/test_recovery_strategies.py b/tests/test_recovery_strategies.py index 0348df8..1c19cb6 100644 --- a/tests/test_recovery_strategies.py +++ b/tests/test_recovery_strategies.py @@ -16,13 +16,10 @@ FailureEnvelope, FailureSource, Recoverability, - SideEffectState, ) from leapflow.engine.recovery_coordinator import RecoveryState, RecoveryStrategy from leapflow.engine.recovery_decision import ( - BackoffConfig, RecoveryAction, - RetrySemantics, ) from leapflow.engine.recovery_strategies import ( ContextCompressStrategy, @@ -86,10 +83,6 @@ def test_all_strategies_have_unique_keys(self) -> None: keys = [s.key for s in strategies] assert len(keys) == len(set(keys)), "Strategy keys must be unique" - def test_strategy_count(self) -> None: - strategies = default_strategies() - assert len(strategies) == 8 - # =========================================================================== # ContextCompressStrategy Tests @@ -97,18 +90,6 @@ def test_strategy_count(self) -> None: class TestContextCompressStrategy: - def test_priority(self) -> None: - s = ContextCompressStrategy() - assert s.priority == 10 - - def test_applicable_sources(self) -> None: - s = ContextCompressStrategy() - assert s.applicable_sources == frozenset({"llm"}) - - def test_applicable_categories(self) -> None: - s = ContextCompressStrategy() - assert s.applicable_categories == frozenset({"context_overflow", "payload_too_large"}) - def test_can_apply_fresh_state(self) -> None: s = ContextCompressStrategy() env = _make_envelope(category="context_overflow") @@ -160,9 +141,6 @@ def test_decide_does_not_consume_budget(self) -> None: class TestMultimodalStripStrategy: - def test_priority(self) -> None: - assert MultimodalStripStrategy().priority == 15 - def test_can_apply_with_image_message(self) -> None: s = MultimodalStripStrategy() env = _make_envelope(category="image_too_large", message="Image file too large to encode") @@ -188,14 +166,6 @@ def test_decide_action(self) -> None: class TestProviderFailoverStrategy: - def test_priority(self) -> None: - assert ProviderFailoverStrategy().priority == 20 - - def test_applicable_categories(self) -> None: - s = ProviderFailoverStrategy() - expected = frozenset({"billing", "auth_permanent", "overloaded", "model_not_found", "content_blocked"}) - assert s.applicable_categories == expected - def test_can_apply(self) -> None: s = ProviderFailoverStrategy() env = _make_envelope(category="billing") @@ -217,13 +187,6 @@ def test_decide_action(self) -> None: class TestCredentialRotateStrategy: - def test_priority(self) -> None: - assert CredentialRotateStrategy().priority == 25 - - def test_applicable_categories(self) -> None: - s = CredentialRotateStrategy() - assert s.applicable_categories == frozenset({"auth_error", "rate_limited", "billing"}) - def test_decide_action(self) -> None: s = CredentialRotateStrategy() env = _make_envelope(category="auth_error") @@ -239,12 +202,6 @@ def test_decide_action(self) -> None: class TestThinkingDisableStrategy: - def test_priority(self) -> None: - assert ThinkingDisableStrategy().priority == 30 - - def test_applicable_categories(self) -> None: - assert ThinkingDisableStrategy().applicable_categories == frozenset({"format_error"}) - def test_can_apply_always_true(self) -> None: s = ThinkingDisableStrategy() env = _make_envelope(category="format_error") @@ -265,9 +222,6 @@ def test_decide_action(self) -> None: class TestNativeToTextFallbackStrategy: - def test_priority(self) -> None: - assert NativeToTextFallbackStrategy().priority == 35 - def test_can_apply_with_tool_call_message(self) -> None: s = NativeToTextFallbackStrategy() env = _make_envelope(category="format_error", message="Failed to parse tool_call response") @@ -298,15 +252,6 @@ def test_decide_action(self) -> None: class TestToolSchemaExpandStrategy: - def test_priority(self) -> None: - assert ToolSchemaExpandStrategy().priority == 40 - - def test_applicable_sources(self) -> None: - assert ToolSchemaExpandStrategy().applicable_sources == frozenset({"tool"}) - - def test_applicable_categories(self) -> None: - assert ToolSchemaExpandStrategy().applicable_categories == frozenset({"tool_unknown"}) - def test_can_apply_auto_recover(self) -> None: s = ToolSchemaExpandStrategy() env = _make_envelope( @@ -346,16 +291,6 @@ def test_decide_action(self) -> None: class TestJitteredRetryStrategy: - def test_priority(self) -> None: - assert JitteredRetryStrategy().priority == 100 - - def test_applicable_sources(self) -> None: - assert JitteredRetryStrategy().applicable_sources == frozenset({"llm", "tool", "system"}) - - def test_applicable_categories(self) -> None: - # Empty frozenset = wildcard, matches all categories - assert JitteredRetryStrategy().applicable_categories == frozenset() - def test_can_apply_auto_retry(self) -> None: s = JitteredRetryStrategy() env = _make_envelope(category="transient", recoverability=Recoverability.AUTO_RETRY) @@ -434,27 +369,76 @@ def test_decide_system_network(self) -> None: # =========================================================================== -# Integration: Strategy Priority Ordering +# Integration: routing contract through the coordinator # =========================================================================== -class TestStrategyPriorityOrdering: - def test_priorities_are_in_expected_order(self) -> None: - strategies = default_strategies() - expected_keys = [ - "context_compress", - "multimodal_strip", - "provider_failover", - "credential_rotate", - "thinking_disable", - "native_to_text", - "tool_schema_expand", - "jittered_retry", - ] - actual_keys = [s.key for s in strategies] - assert actual_keys == expected_keys +class TestStrategyRoutingContract: + """What the registry must guarantee, asserted as behavior. + + Per-strategy ``priority``/``applicable_*`` assertions were removed: copying + implementation constants into the test freezes them without proving + anything, and any real regression shows up here instead — as the wrong + strategy winning for a given failure. + """ + + @pytest.mark.parametrize( + ("source", "category", "message", "recoverability", "expected_key"), + [ + # Context pressure is compressed before anything else is tried. + (FailureSource.LLM, "context_overflow", "too many tokens", + Recoverability.AUTO_RETRY, "context_compress"), + (FailureSource.LLM, "payload_too_large", "payload too large", + Recoverability.AUTO_RETRY, "context_compress"), + # Oversized images are stripped rather than compressed away. + (FailureSource.LLM, "image_too_large", "Image too large", + Recoverability.AUTO_RETRY, "multimodal_strip"), + # Permanent provider-side conditions fail over instead of retrying. + (FailureSource.LLM, "billing", "quota exhausted", + Recoverability.AUTO_RETRY, "provider_failover"), + (FailureSource.LLM, "model_not_found", "no such model", + Recoverability.AUTO_RETRY, "provider_failover"), + # Credential problems rotate before giving up. + (FailureSource.LLM, "auth_error", "invalid api key", + Recoverability.AUTO_RETRY, "credential_rotate"), + # Malformed output: drop thinking mode first… + (FailureSource.LLM, "format_error", "unparseable output", + Recoverability.AUTO_RETRY, "thinking_disable"), + # …unknown tools get their schema disclosed rather than retried blind. + (FailureSource.TOOL, "tool_unknown", "no such tool", + Recoverability.AUTO_RECOVER, "tool_schema_expand"), + # Anything transient falls through to backoff retry. + (FailureSource.LLM, "transient", "connection reset", + Recoverability.AUTO_RETRY, "jittered_retry"), + (FailureSource.SYSTEM, "system_network", "network down", + Recoverability.AUTO_RETRY, "jittered_retry"), + (FailureSource.TOOL, "tool_timeout", "timed out", + Recoverability.AUTO_RETRY, "jittered_retry"), + ], + ) + def test_failure_routes_to_expected_strategy( + self, source, category, message, recoverability, expected_key, + ) -> None: + from leapflow.engine.recovery_budget import RecoveryBudget + from leapflow.engine.recovery_coordinator import RecoveryCoordinator + + coord = RecoveryCoordinator( + strategies=default_strategies(), + budget=RecoveryBudget(total_recovery_actions=32), + ) + coord.budget.start_deadline() + envelope = _make_envelope( + source=source, category=category, message=message, + recoverability=recoverability, tool_name="some_tool", + ) + + decision = coord.evaluate(envelope) + + assert decision.strategy_key == expected_key + assert decision.reason, "every decision must carry an explainable reason" def test_priorities_are_strictly_increasing(self) -> None: + """Ties would make routing order depend on registration order.""" strategies = default_strategies() for i in range(len(strategies) - 1): assert strategies[i].priority < strategies[i + 1].priority, ( @@ -462,12 +446,13 @@ def test_priorities_are_strictly_increasing(self) -> None: f"lower than {strategies[i+1].key} (priority={strategies[i+1].priority})" ) - def test_repeatable_property_correctness(self) -> None: + def test_only_idempotent_strategies_are_repeatable(self) -> None: + """Repeatable strategies must be safe to re-apply within one turn. + + Compression advances through phases and jittered retry backs off, so + both converge. Every other strategy mutates provider/credential/mode + state and must fire at most once per turn. + """ strategies = default_strategies() - repeatable_keys = {s.key for s in strategies if s.repeatable} - non_repeatable_keys = {s.key for s in strategies if not s.repeatable} - assert repeatable_keys == {"context_compress", "jittered_retry"} - assert non_repeatable_keys == { - "multimodal_strip", "provider_failover", "credential_rotate", - "thinking_disable", "native_to_text", "tool_schema_expand", - } + repeatable = {s.key for s in strategies if s.repeatable} + assert repeatable == {"context_compress", "jittered_retry"} diff --git a/tests/test_teach_learn_lifecycle.py b/tests/test_teach_learn_lifecycle.py index 0bc7434..c935a96 100644 --- a/tests/test_teach_learn_lifecycle.py +++ b/tests/test_teach_learn_lifecycle.py @@ -15,7 +15,6 @@ ActionType, Episode, RawAction, - SemanticAction, StateSnapshot, Trajectory, TrajectoryStep, diff --git a/tests/test_tui_command_queue.py b/tests/test_tui_command_queue.py index 26b343f..a169fd3 100644 --- a/tests/test_tui_command_queue.py +++ b/tests/test_tui_command_queue.py @@ -1416,15 +1416,31 @@ async def test_buffer_insert_compacts_large_chinese_paste_with_ascii_marker() -> assert app.submit_text(visible).text == pasted.strip() +def _paste_fragments(app: LeapApp, monkeypatch, text: str, *, size: int) -> str: + """Feed ``text`` as same-paste fragments under a controlled clock. + + Fragment detection is time-based (``PASTE_FRAGMENT_WINDOW_S`` = 80ms between + inserts). Driving it off the real clock makes these tests load-sensitive: a + single scheduling stall over the window splits one paste into two, so the + compactor restarts and plaintext leaks into the visible buffer. A stepped + fake clock states the intent directly — "these fragments belong to one + paste" — and keeps the assertion about compaction, not machine speed. + """ + clock = [100.0] + monkeypatch.setattr(app_module.time, "monotonic", lambda: clock[0]) + for index in range(0, len(text), size): + app._input_area.buffer.insert_text(text[index:index + size]) + clock[0] += 0.001 # well inside the window: one continuous paste + return app._input_area.buffer.text + + @pytest.mark.asyncio -async def test_fragmented_chinese_paste_compacts_and_submits_full_text() -> None: +async def test_fragmented_chinese_paste_compacts_and_submits_full_text(monkeypatch) -> 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 = _paste_fragments(app, monkeypatch, pasted, size=18) - visible = app._input_area.buffer.text assert pasted not in visible assert "经济活动" not in visible assert visible.startswith("[pasted block #1:") @@ -1434,14 +1450,12 @@ async def test_fragmented_chinese_paste_compacts_and_submits_full_text() -> None @pytest.mark.asyncio -async def test_fragmented_english_single_line_paste_compacts() -> None: +async def test_fragmented_english_single_line_paste_compacts(monkeypatch) -> 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 = _paste_fragments(app, monkeypatch, pasted, size=16) - visible = app._input_area.buffer.text assert pasted not in visible assert visible.startswith("[pasted block #1:") assert visible.isascii() @@ -1541,3 +1555,50 @@ async def on_input(text: str) -> None: (2, TuiCommandStatus.DONE), ] assert status.counts[-1] == (0, 0) + + +# ── /clear screen clearing (renderer-owned) ────────────────────────── + + +def test_clear_screen_goes_through_prompt_toolkit_renderer() -> None: + """/clear must clear via the renderer that owns the TTY. + + Regression: the daemon REPL only redrew the banner (no clearing at all), + and the in-process REPL shelled out to `clear`, which leaves the + prompt_toolkit renderer's cursor cache stale. + """ + app, _console, _status = _make_app() + cleared: list[str] = [] + + class _Renderer: + def clear(self) -> None: + cleared.append("renderer.clear") + + app._app = SimpleNamespace(is_running=True, renderer=_Renderer()) + + app.clear_screen() + + assert cleared == ["renderer.clear"] + + +def test_clear_screen_is_a_noop_when_app_not_running() -> None: + app, _console, _status = _make_app() + + class _Renderer: + def clear(self) -> None: # pragma: no cover - must not be reached + raise AssertionError("renderer must not be touched while not running") + + app._app = SimpleNamespace(is_running=False, renderer=_Renderer()) + + app.clear_screen() # must not raise + + +def test_no_shell_clear_bypass_remains_in_slash_handlers() -> None: + """No handler may clear the screen behind the renderer's back.""" + import inspect + + import leapflow.cli.commands.slash_handlers as handlers + + source = inspect.getsource(handlers) + assert 'os.system("cls"' not in source + assert not hasattr(handlers, "handle_clear") diff --git a/tests/test_uncertain_effect_and_interaction.py b/tests/test_uncertain_effect_and_interaction.py new file mode 100644 index 0000000..90ffd56 --- /dev/null +++ b/tests/test_uncertain_effect_and_interaction.py @@ -0,0 +1,313 @@ +"""Guards for uncertain-effect reporting and InteractionRequest surfacing. + +Two contracts that only hold end-to-end: + +- A failed call whose effect may already have landed must say so in its result, + so the next turn verifies instead of blindly repeating it. An error is not + proof that nothing happened: an outbound send can time out after delivery. +- A terminal decision carrying an ``InteractionRequest`` must surface it. If the + engine falls back to ``decision.reason``, the user is told a turn stopped + without being told what to do about it. +""" + +from __future__ import annotations + +import pytest + +from leapflow.engine.engine import ( + _annotate_uncertain_effect, + _interaction_metadata, + _terminal_failure_text, +) +from leapflow.engine.failure_envelope import ( + FailureContext, + FailureEnvelope, + FailureSource, + Recoverability, + SideEffectState, +) +from leapflow.engine.interaction_request import ( + InteractionRequest, + InteractionType, + Severity, + SuggestedAction, +) +from leapflow.engine.recovery_budget import RecoveryBudget +from leapflow.engine.recovery_coordinator import RecoveryCoordinator +from leapflow.engine.recovery_decision import RecoveryAction, RecoveryDecision +from leapflow.engine.recovery_strategies import default_strategies +from leapflow.engine.tool_execution import effect_is_uncertain_on_failure + +# ── Uncertain-effect reporting ─────────────────────────────────────────── + + +@pytest.mark.parametrize("policy", ["external_side_effect", "mutating_once"]) +def test_failed_side_effect_is_reported_as_uncertain(policy: str) -> None: + """The model must be told the effect may have landed despite the error.""" + result = _annotate_uncertain_effect({"ok": False, "error": "timeout"}, policy) + + assert result["side_effect_uncertain"] is True + assert "retry" in result["retry_guidance"].lower() + + +@pytest.mark.parametrize("policy", ["read_only", "mutating_idempotent"]) +def test_safe_policies_are_not_flagged(policy: str) -> None: + """Read-only and idempotent failures stay freely retryable. + + Flagging an idempotent mutation would stall a retry that converges anyway. + """ + result = _annotate_uncertain_effect({"ok": False, "error": "timeout"}, policy) + + assert "side_effect_uncertain" not in result + assert "retry_guidance" not in result + + +def test_success_is_never_flagged() -> None: + """Only failures are ambiguous; a success already reported its outcome.""" + result = _annotate_uncertain_effect({"ok": True}, "external_side_effect") + + assert "side_effect_uncertain" not in result + + +def test_non_failures_are_not_flagged() -> None: + """``counts_as_failure=False`` results are not failed attempts.""" + result = _annotate_uncertain_effect( + {"ok": False, "counts_as_failure": False}, "external_side_effect" + ) + + assert "side_effect_uncertain" not in result + + +def test_uncertain_policy_set_matches_the_gating_helper() -> None: + """The helper and the policy set must not drift apart.""" + assert effect_is_uncertain_on_failure("external_side_effect") is True + assert effect_is_uncertain_on_failure("mutating_once") is True + assert effect_is_uncertain_on_failure("mutating_idempotent") is False + assert effect_is_uncertain_on_failure("read_only") is False + assert effect_is_uncertain_on_failure("") is False + + +def test_uncertainty_fields_survive_tool_metadata_extraction() -> None: + """The verdict must reach the transcript, not be filtered out. + + The metadata extractor is an allow-list, so a new field is dropped unless it + is listed; that would silently undo the annotation. + """ + from leapflow.engine.engine import AgentEngine + + metadata = AgentEngine._tool_execution_metadata({ + "ok": False, + "execution_policy": "external_side_effect", + "side_effect_uncertain": True, + }) + + assert metadata["side_effect_uncertain"] is True + + +# ── InteractionRequest surfacing ───────────────────────────────────────── + + +def _envelope() -> FailureEnvelope: + return FailureEnvelope.create( + source=FailureSource.TOOL, + category="tool_timeout", + failure_class="transient", + failure_code="timeout", + message="timed out", + recoverability=Recoverability.AUTO_RETRY, + side_effect_state=SideEffectState.UNKNOWN, + context=FailureContext.from_dict_args(tool_name="gateway_send"), + ) + + +def _decision_with_interaction() -> RecoveryDecision: + interaction = InteractionRequest.create( + interaction_type=InteractionType.RETRY_CHOICE, + severity=Severity.WARNING, + title="Delivery may already have happened", + description="The send timed out after the request was accepted.", + suggested_actions=( + SuggestedAction(label="Check the chat, then resend", command="/board", is_default=True), + SuggestedAction(label="Skip this step"), + ), + context={"tool_name": "gateway_send"}, + resumption_key="rk-1", + ) + return RecoveryDecision.create( + envelope=_envelope(), + action=RecoveryAction.ASK_USER, + reason="internal: blocked replay after uncertain effect", + strategy_key="test", + interaction=interaction, + ) + + +def test_terminal_text_renders_the_interaction_not_the_raw_reason() -> None: + """The user needs the title and options, not the audit-log sentence.""" + text = _terminal_failure_text(_decision_with_interaction()) + + assert "Delivery may already have happened" in text + assert "The send timed out after the request was accepted." in text + assert "Check the chat, then resend" in text + assert "Skip this step" in text + assert "internal: blocked replay" not in text + + +def test_terminal_text_falls_back_to_reason_without_an_interaction() -> None: + """Plain halts keep their existing message.""" + decision = RecoveryDecision.create( + envelope=_envelope(), + action=RecoveryAction.HALT_CLEAN, + reason="Recovery budget exhausted", + strategy_key="", + ) + + assert _terminal_failure_text(decision) == "Recovery budget exhausted" + + +def test_interaction_metadata_is_structured_for_the_client() -> None: + """The client must be able to prompt and resume without parsing prose.""" + payload = _interaction_metadata(_decision_with_interaction())["interaction"] + + assert payload["interaction_type"] == "retry_choice" + assert payload["severity"] == "warning" + assert payload["resumption_key"] == "rk-1" + assert payload["context"]["tool_name"] == "gateway_send" + labels = [action["label"] for action in payload["suggested_actions"]] + assert "Check the chat, then resend" in labels + assert payload["suggested_actions"][0]["is_default"] is True + + +def test_interaction_metadata_is_empty_without_an_interaction() -> None: + """No interaction means no metadata key to confuse the client.""" + decision = RecoveryDecision.create( + envelope=_envelope(), + action=RecoveryAction.HALT_CLEAN, + reason="halted", + strategy_key="", + ) + + assert _interaction_metadata(decision) == {} + + +def test_gated_halt_reaches_the_user_with_actionable_text() -> None: + """The gate's decision must render through the same surfacing path.""" + coord = RecoveryCoordinator( + strategies=default_strategies(), budget=RecoveryBudget(total_recovery_actions=8), + ) + coord.budget.start_deadline() + + decision = coord.evaluate(_envelope()) + text = _terminal_failure_text(decision) + + assert decision.action == RecoveryAction.HALT_WITH_CHECKPOINT + assert "gateway_send" in text + assert "Verify the target state" in text + assert _interaction_metadata(decision)["interaction"]["resumption_key"] + + +# ── Checkpoint persistence and resumption linkage ───────────────────── + + +def _engine_with_checkpoint_store(): + from leapflow.engine.engine import AgentEngine + from leapflow.engine.recovery_checkpoint import InMemoryCheckpointStore + + engine = AgentEngine.__new__(AgentEngine) + engine._checkpoint_store = InMemoryCheckpointStore() + engine._current_session_id = "sess-1" + return engine + + +def test_halt_checkpoint_links_back_to_the_interaction() -> None: + """The saved checkpoint must be findable from what the user was shown. + + The InteractionRequest carries ``resumption_key`` (the envelope id) while + the checkpoint gets a random id; without recording the envelope id, the + interaction request id, and the resumption key on the checkpoint, the + client has no way back from the prompt to the saved state. + """ + engine = _engine_with_checkpoint_store() + coord = RecoveryCoordinator( + strategies=default_strategies(), budget=RecoveryBudget(total_recovery_actions=8), + ) + coord.budget.start_deadline() + envelope = _envelope() + decision = coord.evaluate(envelope) + assert decision.action == RecoveryAction.HALT_WITH_CHECKPOINT + + engine._save_halt_checkpoint( + decision, envelope, [{"role": "user", "content": "send it"}], budget_used=2, + ) + + pending = engine._checkpoint_store.list_pending("sess-1") + assert len(pending) == 1 + saved = pending[0] + assert saved.failure_envelope_data["envelope_id"] == envelope.envelope_id + assert saved.failure_envelope_data["side_effect_state"] == "unknown" + assert saved.interaction_request_id == decision.interaction.request_id + # The key shown to the user resolves to this checkpoint. + assert saved.context_data["resumption_key"] == decision.interaction.resumption_key + assert saved.messages_snapshot == [{"role": "user", "content": "send it"}] + + +def test_halt_checkpoint_save_failure_does_not_mask_the_halt() -> None: + """A broken store must not turn a clean halt into a crash.""" + engine = _engine_with_checkpoint_store() + + class _BrokenStore: + def save(self, checkpoint) -> None: + raise OSError("disk full") + + engine._checkpoint_store = _BrokenStore() + coord = RecoveryCoordinator( + strategies=default_strategies(), budget=RecoveryBudget(total_recovery_actions=8), + ) + coord.budget.start_deadline() + envelope = _envelope() + decision = coord.evaluate(envelope) + + # Must not raise. + engine._save_halt_checkpoint(decision, envelope, [], budget_used=1) + + +def test_duplicate_result_preserves_the_uncertainty_verdict() -> None: + """A suppressed duplicate must keep the original failure's warning. + + The duplicate path returns a synthesized payload, not the annotated one; if + the flag is not carried over, the model loses exactly the signal that told + it to verify before retrying. + """ + from leapflow.engine.tool_execution import ToolExecutionLedger, ToolExecutionRecord + + record = ToolExecutionRecord( + execution_id="x1", session_id="s", turn_id="t", command_id="c", + tool_call_id="tc", tool_name="gateway_send", idempotency_key="k1", + arguments={}, policy="external_side_effect", status="failed_retryable", + result={ + "ok": False, "error": "timeout", + "side_effect_uncertain": True, + "retry_guidance": "Verify the current state before retrying.", + }, + ) + + duplicate = ToolExecutionLedger.duplicate_result(record) + + assert duplicate["side_effect_uncertain"] is True + assert "Verify" in duplicate["retry_guidance"] + + +def test_duplicate_result_stays_clean_for_certain_outcomes() -> None: + """No false alarm: a completed original adds no uncertainty flag.""" + from leapflow.engine.tool_execution import ToolExecutionLedger, ToolExecutionRecord + + record = ToolExecutionRecord( + execution_id="x2", session_id="s", turn_id="t", command_id="c", + tool_call_id="tc", tool_name="gateway_send", idempotency_key="k2", + arguments={}, policy="external_side_effect", status="completed", + result={"ok": True}, + ) + + duplicate = ToolExecutionLedger.duplicate_result(record) + + assert "side_effect_uncertain" not in duplicate diff --git a/tests/test_visual_pipeline.py b/tests/test_visual_pipeline.py index acedadf..5219ba9 100644 --- a/tests/test_visual_pipeline.py +++ b/tests/test_visual_pipeline.py @@ -7,7 +7,7 @@ import threading from pathlib import Path from typing import Any, Dict, List, Optional -from unittest.mock import AsyncMock, MagicMock, Mock, patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -22,15 +22,11 @@ ) from leapflow.perception.video.analyzer import VideoAnalyzer from leapflow.perception.video.prompts import ( - AnalysisPromptStrategy, DefaultAnalysisPrompts, - VLMMessageBuilder, ) from leapflow.perception.video.segmenter import AnalysisSegment, VideoSegmenter from leapflow.perception.video.timeline import ( SignalTimeline, - TimelineReader, - TimelineWriter, ) @@ -269,7 +265,7 @@ async def test_video_mode_analyze_routing(trajectory_store) -> None: # Create a minimal trajectory so analyze() has something to load from leapflow.domain.trajectory import ( - ActionType, Episode, RawAction, StateSnapshot, Trajectory, TrajectoryStep, + ActionType, RawAction, StateSnapshot, Trajectory, TrajectoryStep, ) import time as _time now = _time.time() diff --git a/tests/test_world_model.py b/tests/test_world_model.py index 8d13123..5e53829 100644 --- a/tests/test_world_model.py +++ b/tests/test_world_model.py @@ -5,7 +5,7 @@ import json import time from dataclasses import dataclass -from typing import Any, Dict, List, Optional +from typing import Any, List import pytest