diff --git a/README.md b/README.md index 995143f..2d980c7 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,57 @@ The cognitive pipeline built on top: The **Execution Layer** provides native OS interactions — screen capture, accessibility tree queries, and input injection. The default backend is `cua-driver` (macOS, MCP stdio transport), but the architecture is backend-agnostic via the Platform Adaptation Layer. **Perception** fuses raw signals into a causal timeline. The **Causal Engine** infers why things happened, not just what. The **World Model** builds an internal representation of the environment and learns from prediction errors. **Skill Synthesis** distills observations into parameterized, reusable skills with maturity tracking. The **Copilot** predicts your next workflow step and offers proactive suggestions — like GitHub Copilot, but for everything you do on your computer. +--- + +## Adaptive-Depth Execution (Toward Infinite OODA) + +LeapFlow's agent loop adapts its **depth** to each task's difficulty and can — under strict governance — extend beyond a single turn. Rather than a fixed iteration cap, the loop treats depth and autonomy as *signal-driven, bounded gradients*: a hard task earns a wider budget and a research posture while a simple one stays short; long tasks keep a persistent research ledger; recursive subagents run the *same* adaptive loop on isolated frames; and outcome data can self-calibrate the difficulty thresholds. "Infinite" capability comes from **composing bounded OODA frames**, never from removing a frame's bounds — so every step stays accountable, inspectable, and safe. + +Defaults benefit every session automatically (adaptive depth); the rest is **opt-in and off by default**: + +| Capability | Config key (`leap config set …`) | +|---|---| +| Full adaptive loop for delegated subagents | `agent.subagent_full_loop` | +| Event-driven re-entry (resume on time/event) | `agent.reentry_enabled` | +| Online difficulty/threshold self-calibration | `agent.calibration_enabled` (+ `agent.calibration_interval_turns`) | +| Governed proactive outbound (trust + approval) | `agent.reentry_send_enabled` | +| Cache-stable compression write-back | `agent.compression_writeback` | + +Inspect the agent's layered orientation and pending re-entries anytime with the read-only **`/orient`** command. Design methodology: [`docs/design/adaptive_depth_ooda.md`](docs/design/adaptive_depth_ooda.md). + +--- + +## Built-in Coding Tools + +LeapFlow ships a first-class coding toolset so the agent can *locate → read → edit → verify* code precisely instead of rewriting whole files or shelling out blindly. Every tool is registered with governance metadata (`x_leapflow`) so it flows through the existing idempotency, approval, redaction, path-sensitivity, and audit paths. + +| Tool | Purpose | Governance | +|---|---|---| +| `repo_map` | Compact project orientation: languages, detected test/lint commands, top-level structure, entry points, VCS branch | read-only | +| `code_search` | Regex search across a tree (ripgrep-backed, structured `path:line:col`, optional `context_lines`); skips VCS/dep/build dirs | read-only | +| `file_find` | Locate files by recursive glob (e.g. `**/test_*.py`) | read-only | +| `edit_file` | Targeted **anchored** search-replace (unique-anchor or `replace_all`, `dry_run`) or apply a unified **`diff`** — a missing/ambiguous anchor is rejected, never a partial write | mutating · approval + path gate | +| `code_intel` | Document symbols (outline): Python via exact **AST**, other languages via heuristic | read-only | +| `git_query` | Structured read-only git: `diff` / `log` / `status` / `branch` / `show` | read-only | +| `git_write` | Mutating git: `commit` / `branch` / `checkout` | mutating · approval | +| `test_run` | Run the test suite (auto-detect pytest/npm/go/cargo); structured pass/fail | verify (via governed shell) | +| `lint_check` | Run the linter (auto-detect ruff/eslint/go vet/clippy); structured issues | verify (via governed shell) | +| `terminal_session` | Persistent shell sessions (`open`/`send`/`read`/`close`/`list`) for REPLs/dev servers | **off by default** · opt-in · approval | + +**Notes** + +- **Seamless search:** `code_search` always works with zero install via a pure-Python fallback; when [ripgrep](https://github.com/BurntSushi/ripgrep) is present it is used for speed, and a best-effort background install (macOS/Homebrew, no sudo) is attempted otherwise. A manual-install hint is surfaced if it stays unavailable. +- **`test_run`/`lint_check` semantics:** `ok=true` means the *runner executed* — a failing suite is informative feedback (`success`/`clean`), not a tool error. +- **Persistent terminals** are long-lived resources kept separate from one-shot execution (Transport-Lifecycle Separation); enabling is the operator opt-in and sessions are bounded with process-group cleanup. + +Config keys (all via `leap config set …` / TUI `/config`): + +| Key | Default | Meaning | +|---|---|---| +| `tools.ripgrep_autoinstall` | `true` | Best-effort seamless ripgrep provisioning (fallback + manual hint regardless) | +| `tools.test_command` / `tools.lint_command` | *(auto-detect)* | Override the test/lint command | +| `tools.terminal_session_enabled` | `false` | Enable persistent terminal sessions (high-risk, opt-in) | + --- ## Prerequisites @@ -213,6 +264,27 @@ Inside the TUI, the same control plane is available as `/config`. It supports ho --- +## Multi-Profile + +LeapFlow supports multiple **profiles** — isolated runtime environments that can run in parallel. Each profile owns its own daemon process, databases, configuration, and credential vault. + +Select a profile via the `LEAPFLOW_PROFILE` environment variable (defaults to `default`): + +```bash +# Run with the default profile +leap + +# Run a separate instance under a different profile +LEAPFLOW_PROFILE=work leap + +# Check daemon status for a specific profile +LEAPFLOW_PROFILE=work leap daemon status +``` + +Profiles are stored under `~/.leapflow/profiles//`. Multiple profiles can run simultaneously without interference — useful for separating personal and work contexts, or running parallel experiments. + +--- + ## Quick Start — Use the TUI First LeapFlow's default experience is the interactive terminal UI. Start here for chat, tool execution, runtime status, session continuity, and progressively learning workflows from one surface. diff --git a/docs/design/adaptive_depth_ooda.md b/docs/design/adaptive_depth_ooda.md new file mode 100644 index 0000000..5302bfd --- /dev/null +++ b/docs/design/adaptive_depth_ooda.md @@ -0,0 +1,197 @@ +# Adaptive-Depth Execution: Toward an Infinite OODA Loop + +> A design-methodology document. It explains the *why*, the *how*, and the *when* of +> LeapFlow's adaptive-depth execution architecture. It is deliberately light on code: +> the goal is to convey the reasoning, the staged construction, and the usage model — +> not a line-by-line map. + +## 1. Problem Statement + +A capable agent must handle tasks whose intrinsic difficulty varies by orders of +magnitude — from a one-shot factual answer to a multi-hour investigation that spans +many tool calls, dead ends, and revisions. Yet most agent loops are built around a +*fixed* control budget: a constant iteration cap, a static context-disclosure policy, +and a single start→finish horizon. This mismatch produces two failure modes. + +**Under-provisioning.** A hard task is cut off prematurely because the loop exhausts a +budget calibrated for the average case. The agent "gives up" while still making progress. + +**Over-provisioning.** A trivial task carries the full apparatus — maximal tool +disclosure, aggressive context retention, many speculative iterations — inflating cost +and latency for no benefit. + +A deeper limitation is *temporal*: the classical loop ends when the turn ends. It cannot +maintain orientation across sessions, resume when the environment changes, or act +proactively under governance. Real work is rarely a single bounded turn; it is an +ongoing engagement with a changing world. + +The question this architecture answers is: **how can a single agent loop adapt its depth +to each task's difficulty, persist and refine its orientation over time, and — under +strict governance — extend into continuous, proactive operation, without ever becoming +unbounded or unsafe?** + +## 2. Methodology + +Five principles organize the design. + +**Signal-driven, not rule-driven.** Depth, posture, and commitment are derived from +*observed signals* (difficulty estimates, effective token cost, tool-evidence +saturation), not from hardcoded keyword rules. Behavior that cannot be grounded in a +signal is out of scope. + +**Boundedness by composition.** "Infinite" capability is achieved by *composing bounded +frames*, never by removing bounds from a single frame. Every unit of work — a turn, a +recursive subagent, a re-entry — is a frame with its own budget, deadline, and cost +ceiling. Unboundedness is an emergent property of chaining and nesting bounded frames, +which keeps every point in the system individually accountable. + +**Progressive trust.** Autonomy is *earned*, never assumed. A proactive action is +auto-approved only after repeated human approvals of similar actions have raised the +relevant scope's trust; otherwise it falls back to explicit approval. Destructive or +first-time actions are never implicit. + +**OODA as the organizing lens.** The loop is read as Observe → Orient → Decide → Act. +The design consistently invests in **Orient** — persistent findings, layered +orientation, learned calibration — because in OODA the quality of orientation cascades +into every downstream decision (see `ooda_framework.md`). + +**Default-off, zero-regression.** Every new capability is gated behind configuration and +defaults to off, byte-equivalent to prior behavior. Adoption is a deliberate, reversible +choice, and each increment is independently verifiable. + +These principles are realized as a **staged evolution S0 → S4**, where each stage adds a +capability while preserving the invariants of the ones below it. + +## 3. S0 — The Adaptive-Depth Frame + +The base stage makes a *single turn* elastic. Four coupled mechanisms: + +- **Difficulty as a first-class signal.** Each turn continuously estimates task + difficulty from context-governance evidence (tool-call breadth, evidence sources, + convergence). Difficulty drives an **elastic iteration budget** whose cap widens from a + safe floor toward a ceiling in proportion to observed hardness — a hard task earns a + wider horizon; a simple one stays near the floor and self-stops. + +- **Posture.** The turn adopts a research / expanding / finalizing posture, adjusting how + much context and tooling it discloses. Posture is a signal-driven gradient, not a + one-way ratchet. + +- **Prefix commitment and cacheable stability.** Once a turn commits to a stable working + prefix, that prefix (system instructions, tool schema, task contract) is held + byte-stable across rounds so that provider prefix-caches are reused; volatile content + (live signals, the research ledger) is appended at the tail, never woven into the + cacheable prefix. Context compression is *append-only*: each historical window is + summarized once and then frozen, which both preserves long-task state (signal-to-noise + first) and keeps the frozen region cache-stable. + +- **The research ledger.** A compact, durable record of findings, open questions, + decisions, and the next step accompanies the turn. It is the turn's working memory of + *intent and progress*, resistant to compression drift, and it supplies a reliable + sufficiency signal: a task with tracked open questions is never cut short by premature + convergence. + +Finally, S0 makes the loop **recursive**: a subagent runs the *same* adaptive loop on an +isolated child frame with its own fresh budget and subsystems. Recursion is depth-gated +and state-isolated, so a subagent can decompose a hard problem without contaminating the +parent's orientation. + +## 4. S1–S2 — Persistent Orientation and Event-Driven Re-entry + +S1 lifts orientation beyond a single turn: the research ledger is persisted across +sessions, so a long-running task's accumulated understanding survives restarts and +resumes where it left off. + +S2 breaks the start→finish horizon. A turn may register a **re-entry trigger** — a saved +orientation snapshot plus a firing condition (a delay, or an inbound environment event). +Later, that trigger fires *at most once* and seeds a fresh, isolated run from the saved +orientation. Crucially, re-entry is not a suspended coroutine held in memory; it is a +*finalize-then-reseed* pattern, which keeps the mechanism robust and the running system +uncontaminated. Inbound platform events enter as structured signals, are filtered and +classified, and only then may drive a governed re-entry — extending the agent's Observe +boundary into the collaboration environment. + +## 5. S3 — Learning Closure + +Orientation should improve with use. S3 closes a learning loop over the difficulty and +threshold machinery: each turn's *predicted* difficulty and posture are recorded +alongside its *actual* effort and outcome; offline analysis relates the two and proposes +a bounded adjustment to the difficulty-sensitivity weight and the finalize threshold; +and — when explicitly enabled — that adjustment is applied online, always derived from +the configured baseline (so it never compounds or drifts) and always reversible. The +difficulty signal thus migrates from hand-tuned toward learned, and the quality of +orientation rises monotonically with experience. + +## 6. Governed Proactive Action + +The most delicate capability is *acting outward* on the agent's own initiative — for +example, replying to the chat that originated a task once a background re-entry has +produced a result. The design refuses ungoverned autonomy. A proactive send passes a +pure decision kernel that combines a **send-scope trust ledger** (the progressive-trust +gradient), rate limits, idempotency, and a global budget. The verdict is one of: +auto-allow (only for non-destructive actions in a scope that has earned trust), queue for +asynchronous human approval (which, on approval, also accrues trust), or deny. Absent a +reachable approver, the default is to *not act*. External side effects are never silent. + +## 7. D1 → S4 — Layered Orientation and the Infinite Loop + +S4 is the north star: a **resident** agent that runs a continuous, resource-governed OODA +*tempo* — observing signals, maintaining a layered orientation, expanding bounded +subframes on demand, deciding through the trust-gated guidance described above, and +learning continuously — **with no hard horizon, only a governed cadence**. + +The first, observe-only step of S4 (D1) is a **multi-layer orientation** query that +unifies three layers with time decay: *immediate* (live signals), *working* (the current +task ledger), and *long-term* (durable cross-session findings and retrieved memory). +Recent salience dominates while durable facts persist quietly. This makes Orient a +first-class, inspectable object — a prerequisite for any autonomous decision, and useful +on its own for diagnosis. + +The remaining synthesis (a general implicit-guidance gate, a tempo governor with +backpressure, and the resident loop itself) is *designed but intentionally not enabled*: +a continuously autonomous, outward-acting loop is the highest-risk capability in the +system and is gated behind explicit authorization and review. + +## 8. Safety and Governance + +The architecture's safety rests on a small set of invariants that hold at every scale: + +- **Bounded frames everywhere.** Cost ceilings, budgets, and deadlines apply to each + turn, each recursive subframe, and each re-entry point. +- **Default-off and reversible.** Autonomy, re-entry, outbound delivery, online + calibration, and full-loop subagents each require an explicit opt-in; disabled, the + system is byte-equivalent to its prior behavior. +- **Approval, redaction, and audit on every act.** Outbound and other side-effecting + actions flow through the existing approval, redaction, and audit paths; proactive + action additionally requires progressive trust. +- **Isolation.** Recursive subagents run on fresh state with their own session, and never + pollute the parent's learning or conversation. + +## 9. Usage and Scenarios + +By default the agent already benefits from S0: hard tasks transparently earn more depth +and a research posture; simple tasks stay short. No configuration is required, and the +current orientation can be inspected at any time through a read-only orientation view. + +The remaining capabilities are opt-in and best adopted one at a time: + +- **Long, multi-session investigations** benefit from persistent orientation and, when a + follow-up is warranted, event-driven re-entry. +- **Decomposable problems** benefit from full-loop recursive subagents, which give each + sub-task the full adaptive apparatus under isolation. +- **Environments with accumulating outcome data** benefit from online calibration, which + tunes the difficulty and finalize thresholds to the observed workload. +- **Collaboration settings** may, under progressive trust and human approval, let a + completed background task deliver its result back to its originating conversation. + +Because every capability is bounded and reversible, the recommended path is to enable a +single feature, observe its behavior on a representative task, and expand adoption only as +confidence grows. + +## 10. Conclusion + +The design treats "depth" and "autonomy" not as switches but as *governed gradients* +driven by signals and earned through trust. By composing bounded OODA frames — adaptive +in depth, persistent in orientation, self-calibrating, and gated in action — the system +approaches the ideal of a continuous, infinite OODA loop while keeping every constituent +step accountable, inspectable, and safe. The infinite loop, in this view, is not the +removal of limits but their disciplined composition. diff --git a/src/leapflow/cli/cli.py b/src/leapflow/cli/cli.py index 3ff59b2..f3d216b 100644 --- a/src/leapflow/cli/cli.py +++ b/src/leapflow/cli/cli.py @@ -125,7 +125,7 @@ def __exit__(self, exc_type: object, exc: object, traceback: object) -> None: async def _async_daemon_main(args: argparse.Namespace) -> int: """Run chat/interactive through a shared leapd daemon.""" - from leapflow.daemon.client import DaemonUnavailableError, ensure_daemon_client + from leapflow.daemon.client import DaemonUnavailableError, recover_daemon_client settings = load_config() mock_host = getattr(args, "mock_host", False) @@ -136,7 +136,7 @@ def _status(message: str) -> None: try: with _StdinEchoGuard(): - client = await ensure_daemon_client( + client = await recover_daemon_client( settings, mock_host=mock_host, status_callback=_status, diff --git a/src/leapflow/cli/commands/chat.py b/src/leapflow/cli/commands/chat.py index be0baae..9bae402 100644 --- a/src/leapflow/cli/commands/chat.py +++ b/src/leapflow/cli/commands/chat.py @@ -2,6 +2,7 @@ from __future__ import annotations +from pathlib import Path from typing import TYPE_CHECKING, Any, AsyncIterator, Awaitable, Callable from leapflow.cli.helpers import require_initialized @@ -73,7 +74,11 @@ async def _handle_approval_event(event: Any, approval_resolver: ApprovalResolver async def cmd_chat_daemon(client: "DaemonClient", prompt: str, thinking: bool) -> int: """Single-turn conversational mode backed by leapd.""" return await render_chat_stream( - client.engine_chat(prompt, enable_thinking=thinking), + client.engine_chat( + prompt, + enable_thinking=thinking, + workspace_root=str(Path.cwd().resolve()), + ), lambda pending_id, decision: client.approval_resolve(pending_id, decision), ) diff --git a/src/leapflow/cli/commands/config.py b/src/leapflow/cli/commands/config.py index 1e29567..1f4dda8 100644 --- a/src/leapflow/cli/commands/config.py +++ b/src/leapflow/cli/commands/config.py @@ -49,11 +49,11 @@ def cmd_config(args: argparse.Namespace) -> int: return 0 if action == "set": result = service.set(str(args.key), args.value, scope=getattr(args, "scope", "profile")) - _print_result(result.message, result.changed_keys) + _print_result(result.message, result.changed_keys, result.warnings) return 0 if action == "unset": result = service.unset(str(args.key), scope=getattr(args, "scope", "profile")) - _print_result(result.message, result.changed_keys) + _print_result(result.message, result.changed_keys, result.warnings) return 0 if action == "llm": return _cmd_llm(service, args) @@ -84,11 +84,11 @@ def _cmd_llm(service: ConfigService, args: argparse.Namespace) -> int: max_retries=getattr(args, "max_retries", None), scope=getattr(args, "scope", "profile"), ) - _print_result(result.message, result.changed_keys) + _print_result(result.message, result.changed_keys, result.warnings) return 0 if result.ok else 1 if llm_action == "key": result = service.configure_llm(ask_api_key=True, scope=getattr(args, "scope", "profile")) - _print_result(result.message, result.changed_keys) + _print_result(result.message, result.changed_keys, result.warnings) return 0 print(f"Unknown llm config action: {llm_action}") return 2 @@ -172,7 +172,13 @@ def _field_to_dict(item: Any) -> dict[str, Any]: } -def _print_result(message: str, changed_keys: tuple[str, ...]) -> None: +def _print_result( + message: str, + changed_keys: tuple[str, ...], + warnings: tuple[str, ...] = (), +) -> None: print(message) for key in changed_keys: print(f" {key}") + for warning in warnings: + print(f"warning: {warning}") diff --git a/src/leapflow/cli/commands/daemon.py b/src/leapflow/cli/commands/daemon.py index b499926..94f3125 100644 --- a/src/leapflow/cli/commands/daemon.py +++ b/src/leapflow/cli/commands/daemon.py @@ -8,6 +8,7 @@ from __future__ import annotations import asyncio +import os import sys from argparse import Namespace from pathlib import Path @@ -51,7 +52,11 @@ def _status(runtime_dir: Path) -> int: try: details = asyncio.run(_runtime_status(info.sock_path)) except Exception as exc: - sys.stderr.write(f"Could not fetch daemon runtime details: {exc}\n") + sys.stderr.write( + f"Could not fetch daemon runtime details within {_runtime_status_timeout():g}s: {exc}\n" + "leapd accepted the socket connection but did not answer RPC; " + "run 'leap daemon restart --force' if this persists.\n" + ) else: _print_runtime_status(details) return 0 if info.is_healthy else 1 @@ -60,7 +65,15 @@ def _status(runtime_dir: Path) -> int: async def _runtime_status(sock_path: Path) -> dict: from leapflow.daemon.client import DaemonClient - return await DaemonClient(sock_path).status() + return await DaemonClient(sock_path, timeout_s=_runtime_status_timeout()).status() + + +def _runtime_status_timeout() -> float: + raw = os.getenv("LEAPFLOW_DAEMON_STATUS_TIMEOUT", "3").strip() + try: + return max(0.5, float(raw)) + except ValueError: + return 3.0 def _print_runtime_status(status: dict) -> None: @@ -80,8 +93,44 @@ def _print_runtime_status(status: dict) -> None: f"{status.get('model')} " f"context={status.get('context_used', 0)}/{status.get('llm_context_length', 0)}" ) + admission = status.get("turn_admission") + if isinstance(admission, dict): + print( + "turns: " + f"active={admission.get('active', 0)}/{admission.get('max_concurrent', 0)} " + f"available={admission.get('available', 0)} " + f"waiting={admission.get('waiting', 0)}" + ) + active_ids = [str(item) for item in admission.get("active_request_ids") or []] + if active_ids: + print(f"active_request_ids: {', '.join(active_ids)}") + deferred = status.get("deferred_init") + if isinstance(deferred, dict): + initialized = bool(deferred.get("initialized")) + running = bool(deferred.get("running")) + degraded = bool(deferred.get("degraded")) + state = "ready" if initialized else "running" if running else "degraded" if degraded else "pending" + attempts = int(deferred.get("attempts", 0) or 0) + max_attempts = int(deferred.get("max_attempts", 0) or 0) + print(f"deferred_init: state={state} attempts={attempts}/{max_attempts}") + if deferred.get("error"): + print(f"deferred_error: {deferred['error']}") if status.get("session_id"): print(f"session: {status['session_id']}") + clients = status.get("clients") + if isinstance(clients, list) and clients: + print("clients:") + for client in clients: + if not isinstance(client, dict): + continue + workspace = client.get("workspace_root") or "?" + session = client.get("session_id") or "-" + print( + f" - {client.get('kind', '?')}" + f" state={client.get('state', '?')}" + f" session={session}" + f" workspace={workspace}" + ) if status.get("runtime_version"): print(f"version: {status['runtime_version']}") if status.get("runtime_source"): diff --git a/src/leapflow/cli/commands/interactive.py b/src/leapflow/cli/commands/interactive.py index 54544f8..5f215bb 100644 --- a/src/leapflow/cli/commands/interactive.py +++ b/src/leapflow/cli/commands/interactive.py @@ -12,6 +12,8 @@ import os import sys import time +import uuid +from pathlib import Path from typing import TYPE_CHECKING, Any, Optional from leapflow.cli.commands.run import _print_execution_result @@ -965,7 +967,11 @@ async def cmd_interactive_daemon( status = StatusBar(theme) exit_stats = SessionExitStats() command_router = CommandRouter("daemon") - active_session_id = str(resume_id or "") + # A fresh TUI (no --resume) gets its own distinct session id so that two + # concurrent TUI clients route to isolated per-session engines on the daemon + # (bounded by daemon.max_concurrent_turns) instead of converging on one + # shared session and serializing. --resume keeps the requested id. + active_session_id = str(resume_id or "") or uuid.uuid4().hex[:16] turn_count = 0 runtime_model_name = str(getattr(settings, "llm_model", "")) runtime_context_length = int(getattr(settings, "llm_context_length", 0) or 0) @@ -973,6 +979,9 @@ async def cmd_interactive_daemon( runtime_context_state = "baseline" runtime_daemon_pid = "" runtime_host_online = False + runtime_turn_active = 0 + runtime_turn_max = 0 + runtime_turn_waiting = 0 client_lease = ClientLease( settings.runtime_dir, kind="tui", @@ -982,6 +991,7 @@ async def cmd_interactive_daemon( def _apply_daemon_runtime_metadata(metadata: dict[str, Any]) -> None: nonlocal active_session_id, runtime_model_name, runtime_context_length nonlocal runtime_context_used, runtime_context_state, runtime_daemon_pid, runtime_host_online + nonlocal runtime_turn_active, runtime_turn_max, runtime_turn_waiting if metadata.get("pid"): runtime_daemon_pid = str(metadata["pid"]) if metadata.get("session_id"): @@ -1008,6 +1018,14 @@ def _apply_daemon_runtime_metadata(metadata: dict[str, Any]) -> None: host = metadata.get("host_backend") if isinstance(host, dict): runtime_host_online = _host_started(host) + admission = metadata.get("turn_admission") + if isinstance(admission, dict): + try: + runtime_turn_active = max(0, int(admission.get("active", 0) or 0)) + runtime_turn_max = max(0, int(admission.get("max_concurrent", 0) or 0)) + runtime_turn_waiting = max(0, int(admission.get("waiting", 0) or 0)) + except (TypeError, ValueError): + pass def _set_active_session_id(session_id: str) -> None: nonlocal active_session_id @@ -1035,6 +1053,9 @@ def _update_status() -> None: context_used=runtime_context_used, context_max=runtime_context_length, context_state=runtime_context_state, + daemon_turn_active=runtime_turn_active, + daemon_turn_max=runtime_turn_max, + daemon_turn_waiting=runtime_turn_waiting, ) app.prompt_mode = effective_mode @@ -1074,6 +1095,23 @@ async def _print_daemon_status() -> None: f"connected={daemon_status.get('connected_clients', 0)} " f"volatile={daemon_status.get('volatile')}" ) + # Surface this TUI's own workspace so a user running several TUIs against + # one shared daemon can confirm at a glance which project this session is + # bound to (tools and memory are scoped to exactly this root). + console.system(f"Workspace: {Path.cwd().resolve()}") + admission = daemon_status.get("turn_admission") + if isinstance(admission, dict): + active = int(admission.get("active", 0) or 0) + cap = int(admission.get("max_concurrent", 0) or 0) + waiting = int(admission.get("waiting", 0) or 0) + available = int(admission.get("available", 0) or 0) + console.system( + f"Daemon turns: active={active}/{cap} " + f"available={available} waiting={waiting}" + ) + active_ids = [str(item) for item in admission.get("active_request_ids") or []] + if active_ids: + console.system(f"Active turn request ids: {', '.join(active_ids)}") db_path = daemon_status.get("db_path") if db_path: console.system(f"DB: {db_path}") @@ -1147,7 +1185,11 @@ async def _stream_response( saw_real_event = False turn_completed = False try: - async for event in bridge.client.engine_chat(prompt_text): + async for event in bridge.client.engine_chat( + prompt_text, + session_id=active_session_id, + workspace_root=str(Path.cwd().resolve()), + ): metadata = event.metadata or {} is_heartbeat = event.type == "status" and metadata.get("heartbeat") if not is_heartbeat: diff --git a/src/leapflow/cli/commands/registry.py b/src/leapflow/cli/commands/registry.py index ce6d287..ca1dcda 100644 --- a/src/leapflow/cli/commands/registry.py +++ b/src/leapflow/cli/commands/registry.py @@ -92,6 +92,7 @@ def supports_runtime(self, runtime: CommandRuntime) -> bool: CommandDef("model", "Show or switch active model", "Chat", args_hint="[model_name]", requires_llm=True), CommandDef("config", "View or update runtime configuration", "Chat", args_hint="[show|list|keys|sources|get|set|unset|llm|secret] ...", effect=CommandEffect.SESSION, execution=CommandExecution.SHORT_OPERATION), CommandDef("usage", "Show token usage for current session", "Chat", requires_llm=True), + CommandDef("orient", "Show the agent's unified orientation (layered) and pending re-entries", "Chat", effect=CommandEffect.READ_ONLY), # Teaching CommandDef("teach start", "Start teaching mode", "Teaching", aliases=("teach",), args_hint="[goal]"), diff --git a/src/leapflow/cli/commands/slash_handlers.py b/src/leapflow/cli/commands/slash_handlers.py index b06c802..cae2894 100644 --- a/src/leapflow/cli/commands/slash_handlers.py +++ b/src/leapflow/cli/commands/slash_handlers.py @@ -374,6 +374,9 @@ def render_config_payload(console: "LeapConsole", payload: dict[str, Any]) -> No console.success(str(payload.get("message") or "Config updated.")) for key in payload.get("changed_keys") or []: console.system(f" {key}") + warnings = payload.get("warnings") or [] + for warning in warnings: + console.warning(str(warning)) if payload.get("reloaded"): console.system("Configuration reloaded for this session.") return @@ -469,6 +472,7 @@ def _config_mutation_payload(ctx: "Context", service: Any, result: Any) -> dict[ "mode": "mutation", "message": result.message, "changed_keys": list(result.changed_keys), + "warnings": list(getattr(result, "warnings", ()) or ()), "reloaded": reloaded, "model": ctx.settings.llm_model, } @@ -1077,6 +1081,42 @@ def handle_clear(ctx: "Context", console: "LeapConsole", args: str) -> None: # ══════════════════════════════════════════════════════════════════════ +def build_orient_payload(ctx: "Context") -> dict[str, Any]: + """Read-only unified orientation view (S4-D1) + pending re-entry status. + + Surfaces what the agent is currently oriented on (immediate / working / + long-term layers, weight-ranked) plus any armed/due re-entry triggers. + Observe-only; changes no state. + """ + engine = ctx.engine + if engine is None: + return {"ok": False, "error": "No active engine — send a message first."} + view_fn = getattr(engine, "orientation_view", None) + if view_fn is None: + return {"ok": False, "error": "Orientation view not available."} + orientation = view_fn() + lines = ["Orientation (immediate / working / long-term):"] + items = orientation.top(12) + if not items: + lines.append(" (empty — no active findings or open questions yet)") + else: + lines.extend(f" - ({it.layer}) {it.text}" for it in items) + store = getattr(ctx, "_reentry_store", None) + if store is not None: + try: + import time as _time + armed = store.list_armed_events() + due = store.list_due(_time.time()) + lines.append(f"Re-entry: {len(armed)} armed event trigger(s), {len(due)} due now.") + except Exception: + pass + return { + "ok": True, + "message": "\n".join(lines), + "orientation": orientation.summary(), + } + + async def command_execute(ctx: "Context", name: str, args: str = "") -> dict[str, Any]: """Execute a slash command and return a serializable result payload. @@ -1086,6 +1126,8 @@ async def command_execute(ctx: "Context", name: str, args: str = "") -> dict[str """ if name == "status": return build_status_payload(ctx) + if name == "orient": + return build_orient_payload(ctx) if name == "tool": return build_tool_payload(ctx) if name == "usage": diff --git a/src/leapflow/cli/context.py b/src/leapflow/cli/context.py index dd98945..95ab633 100644 --- a/src/leapflow/cli/context.py +++ b/src/leapflow/cli/context.py @@ -2,12 +2,13 @@ from __future__ import annotations +import asyncio import logging import os import re import sys import time -from dataclasses import replace +from concurrent.futures import ThreadPoolExecutor from pathlib import Path from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional @@ -483,6 +484,31 @@ def __init__(self, settings: Settings, mock_host: bool) -> None: self._evolution_policy: Optional[EMAConfidencePolicy] = None self._pattern_miner: Optional[Any] = None + # Deferred initialization tracking + self._deferred_initialized: bool = False + self._deferred_lock: asyncio.Lock = asyncio.Lock() + self._deferred_attempts: int = 0 + # Single runner task executing initialize_deferred(); callers only + # wait on it (shielded), so a caller timeout never cancels the init. + self._deferred_task: Optional["asyncio.Task[None]"] = None + # Dedicated single-thread executor for the heavy synchronous DuckDB + # work inside initialize_deferred(): keeps the event loop responsive + # and serializes deferred DB operations among themselves. + self._deferred_db_executor: Optional[ThreadPoolExecutor] = None + + # Intermediate attributes shared between critical and deferred init + self._critical_codegen: Optional[Any] = None + self._critical_traj_store: Optional[Any] = None + self._critical_distiller: Optional[Any] = None + self._critical_intent_inferrer: Optional[Any] = None + self._critical_attention_filters: Optional[List[Any]] = None + self._critical_surprise_annotator: Optional[Any] = None + self._critical_scorer: Optional[Any] = None + self._critical_llm_scorer: Optional[Any] = None + self._critical_feedback_evaluator: Optional[Any] = None + self._critical_activator: Optional[Any] = None + self._critical_tool_bridge: Optional[Any] = None + # Unified approval gate is resource-free; create it in __init__ so all # initialize() wiring paths can safely reference the same session gate. from leapflow.security.approval import SessionAwareGate @@ -502,6 +528,73 @@ def set_approval_handler(self, handler: Optional[Callable[["ApprovalRequest"], A """Bind the current interactive surface as the approval renderer.""" self._tui_approval.set_handler(handler) + _DEFERRED_MAX_ATTEMPTS: int = 2 + + async def _ensure_deferred(self) -> None: + """Wait for deferred init, starting the runner task if none is active. + + The initialization itself always runs inside a dedicated runner task + (``_run_deferred_once``); callers only *wait* on it through + ``asyncio.shield``. This makes the wait cancellation-safe: when a + caller wraps this in ``asyncio.wait_for`` and times out, only the + wait is cancelled — the background initialization keeps running and + later callers pick up the completed state. + + Gives up after _DEFERRED_MAX_ATTEMPTS failed attempts: components stay + uninitialized and the engine keeps running in critical-only mode. + """ + if self._deferred_initialized: + return + task = getattr(self, "_deferred_task", None) + if task is None or task.done(): + task = asyncio.create_task(self._run_deferred_once()) + self._deferred_task = task + await asyncio.shield(task) + + async def _run_deferred_once(self) -> None: + """Single deferred-init attempt; only ever runs in the runner task.""" + async with self._deferred_lock: + if self._deferred_initialized: + return + if self._deferred_attempts >= self._DEFERRED_MAX_ATTEMPTS: + logger.error( + "Deferred initialization abandoned after %d failed attempts; " + "engine continues in critical-only degraded mode", + self._deferred_attempts, + ) + return + self._deferred_attempts += 1 + await self.initialize_deferred() + self._deferred_initialized = True + + async def _run_deferred_db(self, fn: Callable[[], Any]) -> Any: + """Run a blocking (DuckDB/file) operation off the event loop. + + Uses a dedicated single-thread executor so that: + - the event loop stays responsive during heavy deferred-init work + (RPCs such as daemon.status keep answering), and + - deferred DB operations are serialized among themselves and never + hit the shared DuckDB connection concurrently. + """ + if self._deferred_db_executor is None: + self._deferred_db_executor = ThreadPoolExecutor( + max_workers=1, thread_name_prefix="leap-deferred-db", + ) + loop = asyncio.get_running_loop() + return await loop.run_in_executor(self._deferred_db_executor, fn) + + async def _ensure_skill_system(self) -> None: + """Ensure skill registry is fully populated (deferred skills loaded).""" + if self._deferred_initialized: + return + await self._ensure_deferred() + + async def _ensure_world_model(self) -> None: + """Ensure world model components are ready.""" + if self.prediction_loop is not None: + return + await self._ensure_deferred() + def _configure_llm_clients(self, settings: Settings) -> None: """Build LLM/VLM clients from a settings snapshot.""" provider_configs = parse_provider_configs( @@ -950,12 +1043,16 @@ def storage_volatile(self) -> bool: return bool(getattr(self._db_holder, "is_volatile", False)) async def initialize(self) -> None: - """Async initialization: VSI handshake, pipeline assembly. + """Full initialization - used by CLI direct mode.""" + await self.initialize_critical() + await self.initialize_deferred() + self._deferred_initialized = True - Phases: - 1. Memory providers initialization - 2. CuaDriver connection / mock setup - 3. Platform adapter registration + async def initialize_critical(self) -> None: + """Critical-path initialization: platform, memory, engine core. + + Must complete before service.start() returns. Provides enough state + for the engine to handle basic chat requests. """ settings = self.settings @@ -1067,29 +1164,8 @@ async def initialize(self) -> None: perception_session = _build_visual_components(settings, self.rpc) self.perception_session = perception_session - # Video-mode components - video_recorder = None - video_analyzer = None - video_segmenter = None - signal_timeline = None - if settings.recording_mode.uses_video and settings.visual_track_enabled: - if settings.has_vlm_credentials: - video_recorder, video_analyzer, video_segmenter, signal_timeline = ( - _build_video_components(settings, self.rpc, self.vlm or self.llm) - ) - else: - message = ( - "Video analysis disabled: LEAPFLOW_VLM_API_KEY or " - "LEAPFLOW_LLM_API_KEY is required for visual recording mode." - ) - logger.warning(message) - _emit_status(message) - platform_hint = manifest.platform_id.value - from leapflow.analysis.abstractor import ActionAbstractor - abstractor = ActionAbstractor(platform_hint=platform_hint) - attention_filters = build_attention_filters( foreground_gate=settings.attention_foreground_gate, noise_patterns=settings.attention_noise_patterns, @@ -1112,52 +1188,15 @@ async def initialize(self) -> None: warmup_events=settings.surprise_warmup_events, )) - self.imitation = ImitationPipeline( - store=traj_store, distiller=distiller, codegen=codegen, - intent_inferrer=intent_inferrer, - abstractor=abstractor, - perception_session=perception_session, - goal_relevance_threshold=settings.attention_goal_relevance_threshold, - attention_filters=attention_filters, - surprise_annotator=surprise_annotator, - rpc=self.rpc, - event_bus=self.event_bus, - text_capture_enabled=settings.text_capture_enabled, - text_capture_exclude_apps=settings.text_capture_exclude_apps, - text_capture_secure_roles=settings.text_capture_secure_roles, - text_capture_max_length=settings.text_capture_max_length, - clipboard_max_length=settings.clipboard_max_length, - recording_mode=settings.recording_mode, - mhms_fusion_enabled=settings.mhms_fusion_enabled, - video_recorder=video_recorder, - video_analyzer=video_analyzer, - video_segmenter=video_segmenter, - signal_timeline=signal_timeline, - observation_daemon=self._observation_daemon, - recording_profile=_default_recording_profile(settings), - ) - self.event_bus.subscribe(self.imitation.recorder.on_event) - - # Wire perception session into EventBus with shared attention context - if perception_session: - perception_session._recording_context = self.imitation.recorder.attention_context - perception_session.set_recording_mode(settings.recording_mode) - self.event_bus.subscribe(perception_session.on_system_event) + # Store intermediates for deferred phase + self._critical_codegen = codegen + self._critical_traj_store = traj_store + self._critical_distiller = distiller + self._critical_intent_inferrer = intent_inferrer + self._critical_attention_filters = attention_filters + self._critical_surprise_annotator = surprise_annotator - # Wire signal timeline into EventBus for video mode - if settings.recording_mode.uses_video and signal_timeline is not None: - if self.event_bus is not None: - self.event_bus.subscribe(signal_timeline.record_event) - logger.info("EventBus -> SignalTimeline subscription established") - else: - logger.warning( - "EventBus is None — skipping SignalTimeline subscription" - ) - elif settings.recording_mode.uses_video and signal_timeline is None: - logger.warning( - "SignalTimeline is None — skipping EventBus subscription " - "(video mode active but timeline unavailable)" - ) + # NOTE: ImitationPipeline, Video components are assembled in initialize_deferred() self.skill_lib = SkillLibraryStore(self._db_holder, audit_logger=self.audit) scorer = HeuristicSimilarityScorer() @@ -1167,430 +1206,74 @@ async def initialize(self) -> None: ) self.registry = build_default_registry(self.rpc, self.llm, self.wm, self.lt) + + # Store scorers for deferred phase + self._critical_scorer = scorer + self._critical_llm_scorer = llm_scorer + self._critical_feedback_evaluator = feedback_evaluator + + # NOTE: World Model, SkillActivator, Learning Pipeline, Doc/Stored skills + # are assembled in initialize_deferred() + + graph_planner = GraphPlanner(self.llm, self.registry) if settings.has_llm_credentials else None + scheduler = TaskScheduler( + self.registry, self.rpc, graph_planner=graph_planner, + ) if graph_planner else None - # ── World Model assembly ── - if settings.prediction_enabled: - from leapflow.world_model import ( - LearningBudgetController, - ExperienceStore, - CuriosityConfig, - CuriositySignal, - PredictionLoop, - ExperienceReplayEngine, - TrajectoryGrader, - ) - from leapflow.perception.state_snapshot import StateSnapshotService - - self.learning_budget = LearningBudgetController( - prediction_budget=settings.prediction_budget, - comparison_budget=settings.comparison_budget, - replay_budget=settings.replay_budget, - grading_budget=settings.grading_budget, - distillation_budget=settings.distillation_budget, - discovery_baseline=settings.budget_discovery_baseline, - regression_baseline=settings.budget_regression_baseline, - ) - - embedding_provider = None - if settings.semantic_embedding_provider != "none": - from leapflow.world_model.embedding import ( - TFIDFEmbeddingProvider, - LLMEmbeddingProvider, - ) - if settings.semantic_embedding_provider == "llm": - embedding_provider = LLMEmbeddingProvider(self.llm) - else: - embedding_provider = TFIDFEmbeddingProvider() + # Build ToolBridge with general-purpose tools for unified execution + from leapflow.skills.bridge_factory import build_tool_bridge + from leapflow.tools import bootstrap_tools - self.experience_store = ExperienceStore( - self.lt, - embedding_provider=embedding_provider, - semantic_weight=settings.semantic_rerank_weight, - ) - self.snapshot_service = StateSnapshotService(self.rpc, self.imm) - self.curiosity = CuriositySignal( - CuriosityConfig( - alpha=settings.curiosity_alpha, - beta=settings.curiosity_beta, - gamma=settings.curiosity_gamma, - auto_balance=settings.curiosity_auto_balance, - ), - experience_store=self.experience_store, - ) - self.prediction_loop = PredictionLoop( - llm=self.llm, - snapshot_service=self.snapshot_service, - experience_store=self.experience_store, - budget=self.learning_budget, - enabled=settings.prediction_enabled, - delta_threshold=settings.prediction_delta_threshold, - structural_blend_weight=settings.prediction_structural_blend, - semantic_blend_weight=settings.prediction_semantic_blend, - semantic_compare_threshold=settings.prediction_semantic_threshold, - rag_advantage_floor=settings.prediction_rag_advantage_floor, - failure_advantage=settings.prediction_failure_advantage, - ) - insight_callback = self._build_insight_callback() - self.replay_engine = ExperienceReplayEngine( - llm=self.llm, - experience_store=self.experience_store, - budget=self.learning_budget, - on_insight=insight_callback, - regression_sample_size=settings.replay_regression_sample_size, - ) - self.trajectory_grader = TrajectoryGrader( - llm=self.llm, - experience_store=self.experience_store, - budget=self.learning_budget, - ) - self.registry.set_prediction_loop(self.prediction_loop) + tool_bridge = build_tool_bridge(execution_adapter, perception) + tool_count = bootstrap_tools(tool_bridge) + logger.info("Registered %d general-purpose tools", tool_count) - # Bridge CausalGraph → CuriositySignal (frequency data + graph reference) - if perception_session is not None: - self.curiosity.set_causal_graph(perception_session.causal_graph) - freq = perception_session.causal_graph.metadata.get("frequency_counter") - if freq: - self.curiosity.load_frequency_counter(freq) + # Initialize skill discovery (SkillIndex + SkillInjector) + skills_dir = Path(settings.skills_dir).expanduser() + skill_index = SkillIndex(skills_dir, min_quality=settings.skill_min_quality) + self.skill_index = skill_index + skill_injector = SkillInjector(skills_dir) + configure_skill_discovery( + skill_index, skill_injector, + registry=self.registry, + skill_view_max_chars=settings.skill_view_max_chars, + ) + logger.info("Skill discovery initialized: %s", skills_dir) - # Wire StateSnapshotService.update_focus() from EventBus - _ss = self.snapshot_service - def _on_focus_for_snapshot(event: Any) -> None: - if getattr(event, "event_type", "") == "app.focus_change": - bid = event.payload.get("bundle_id", "") - title = event.payload.get("window_title", "") - if bid: - _ss.update_focus(bid, title) - self.event_bus.subscribe(_on_focus_for_snapshot) + self.session_store = LearningSessionStore(self._db_holder) - logger.info("World model initialized (prediction + curiosity + replay + OPD grading)") + # NOTE: Learnability, SessionController are assembled in initialize_deferred() - activator = None - if perception and execution_adapter: - activator = SkillActivator( - self.registry, self.skill_lib, execution_adapter, perception, - codegen=codegen, - ) - n_activated = activator.load_and_activate_all() - if n_activated: - logger.info("Activated %d learned skills from library", n_activated) + classifier: IntentClassifier = ( + LLMIntentClassifier(self.llm) if settings.has_llm_credentials else FallbackClassifier() + ) + self.intent_classifier = classifier + if settings.has_llm_credentials: + self.assessor = LLMSituationalAssessor(self.llm) - from leapflow.analysis.consensus import MultiTrajectoryDistiller - consensus_distiller = MultiTrajectoryDistiller(self.imitation) + # NOTE: Copilot pipeline is assembled in initialize_deferred() + self.copilot_pipeline = None + self.copilot_idle = None + self.copilot_encoder = None + self.copilot_feedback = None + self.copilot_evolution = None + self.copilot_config = None - self.doc_store = SkillDocStore(settings.skills_dir) - doc_generator: Optional[CompositeSkillDocGenerator] = None - if settings.has_llm_credentials: - doc_generator = CompositeSkillDocGenerator( - llm_generator=LLMSkillDocGenerator(self.llm), - ) - else: - doc_generator = CompositeSkillDocGenerator() + # ── Wire memory tools into TOOL_HANDLERS (late binding) ── + from leapflow.tools.registry_bootstrap import set_memory_manager + set_memory_manager(self.memory) - self.active_observer = ActiveLearningObserver( - self.skill_lib, scorer, self.wm, - llm_scorer=llm_scorer, - feedback_evaluator=feedback_evaluator, - skill_activator=activator, - consensus_distiller=consensus_distiller, - doc_generator=doc_generator, - doc_store=self.doc_store, - skill_registry=self.registry, - llm=self.llm, - execution=execution_adapter, + # ── Gateway server (late-bound tool wiring) ── + from leapflow.gateway.server import GatewayServer + from leapflow.gateway.router import GatewayRouter + from leapflow.gateway.events import ( + GatewayMessageReceived, + GatewaySessionCreated, + GatewaySessionEnded, ) - observer = self.active_observer - self.imitation.set_on_candidates_ready(observer.on_candidates_ready) - - # Wire curiosity signal from world model → active learning + attention tuner - if self.prediction_loop is not None and self.curiosity is not None: - _es = self.experience_store - - from leapflow.recording.attention_tuner import AttentionTuner - pf_filter = None - for f in attention_filters: - if type(f).__name__ == "PerceptualFieldFilter": - pf_filter = f - break - self.attention_tuner = AttentionTuner( - self.imitation.recorder.attention_context, - perceptual_filter=pf_filter, - curiosity_expand_threshold=settings.attention_curiosity_expand_threshold, - accuracy_contract_threshold=settings.attention_accuracy_contract_threshold, - ) - _tuner = self.attention_tuner - - _ctx = self - - def _on_prediction_outcome(outcome: Any) -> None: - score = _ctx.curiosity.compute(outcome) - exp_id = getattr(outcome, "experience_id", "") - if exp_id and _es is not None: - _es.update_curiosity_score(exp_id, score.total) - _tuner.on_curiosity_signal(score, outcome) - observer.on_curiosity_signal(score, outcome) - - # Delta-driven skill evolution: high delta means prediction - # was inaccurate (failure); low delta means accurate (success) - delta = getattr(outcome, "delta", 0.0) - evo_policy = _ctx._evolution_policy - skill_lib = _ctx.skill_lib - if evo_policy is not None and skill_lib is not None: - action_desc = "" - pred = getattr(outcome, "prediction", None) - if pred is not None: - action_desc = getattr(pred, "action_description", "") - # Strip "skill:" / "bridge:" prefix for title lookup - skill_title = action_desc.split(":", 1)[-1] if ":" in action_desc else action_desc - if skill_title and (delta > 0.4 or delta < 0.15): - try: - stored = skill_lib.load_skill_by_title(skill_title) - if stored: - evo_outcome = evo_policy.on_execution_result( - stored.title, - success=(delta < 0.2), - duration_s=0.0, - current_confidence=stored.confidence, - current_version=stored.version, - ) - skill_lib.update_skill_confidence( - stored.title, evo_outcome.new_confidence - ) - if evo_outcome.tier_changed: - logger.info( - "Delta-driven evolution: '%s' confidence \u2192 %.3f", - stored.title, evo_outcome.new_confidence, - ) - except Exception: - logger.debug("delta-driven evolution update failed", exc_info=True) - - self.prediction_loop._on_outcome = _on_prediction_outcome - - n_doc_skills = 0 - for skill in self.doc_store.load_all_as_skills( - self.llm, execution=execution_adapter, perception=perception - ): - self.registry.register(skill) - n_doc_skills += 1 - if n_doc_skills: - logger.info("Registered %d SKILL.md skills", n_doc_skills) - - n_fallback = _register_stored_skill_fallbacks( - self.skill_lib, self.registry, self.llm, - ) - if n_fallback: - logger.info("Registered %d stored skills as fallback", n_fallback) - - graph_planner = GraphPlanner(self.llm, self.registry) if settings.has_llm_credentials else None - scheduler = TaskScheduler( - self.registry, self.rpc, graph_planner=graph_planner, - ) if graph_planner else None - - # Build ToolBridge with general-purpose tools for unified execution - from leapflow.skills.bridge_factory import build_tool_bridge - from leapflow.tools import bootstrap_tools - - tool_bridge = build_tool_bridge(execution_adapter, perception) - tool_count = bootstrap_tools(tool_bridge) - logger.info("Registered %d general-purpose tools", tool_count) - - # Initialize skill discovery (SkillIndex + SkillInjector) - skills_dir = Path(settings.skills_dir).expanduser() - skill_index = SkillIndex(skills_dir, min_quality=settings.skill_min_quality) - self.skill_index = skill_index - skill_injector = SkillInjector(skills_dir) - configure_skill_discovery( - skill_index, skill_injector, - registry=self.registry, - skill_view_max_chars=settings.skill_view_max_chars, - ) - logger.info("Skill discovery initialized: %s", skills_dir) - - from leapflow.engine.confirmation import ConfirmationHandler - confirmation = ConfirmationHandler(skill_store=self.skill_lib) - - self.session_store = LearningSessionStore(self._db_holder) - - # Learnability assessor - learnability_assessor = None - if settings.learnability_enabled: - from leapflow.learning.learnability import DefaultLearnabilityAssessor, LearnabilityConfig - learnability_config = LearnabilityConfig( - min_steps=settings.learnability_min_steps, - min_duration_s=settings.learnability_min_duration_s, - max_idle_ratio=settings.learnability_max_idle_ratio, - min_action_diversity=settings.learnability_min_action_diversity, - learn_threshold=settings.learnability_learn_threshold, - ask_threshold=settings.learnability_ask_threshold, - vlm_enabled=settings.learnability_vlm_enabled, - llm_enabled=settings.learnability_llm_enabled, - rule_weight=settings.learnability_rule_weight, - vlm_weight=settings.learnability_vlm_weight, - llm_weight=settings.learnability_llm_weight, - ) - learnability_assessor = DefaultLearnabilityAssessor( - llm=self.llm if settings.has_llm_credentials else None, - vlm=self.vlm, - config=learnability_config, - ) - - self._evolution_policy = EMAConfidencePolicy() - self.session = SessionController( - self.imitation, - self.registry, - idle_timeout=settings.learn_idle_timeout, - auto_learn=settings.learn_auto_distill, - confirmation=confirmation, - audit=self.audit, - storage_path=str(settings.duckdb_path), - audit_log_path=str(settings.audit_log_path), - active_learning_observer=observer, - session_store=self.session_store, - learnability_assessor=learnability_assessor, - evolution_policy=self._evolution_policy, - skill_store=self.skill_lib, - ) - - classifier: IntentClassifier = ( - LLMIntentClassifier(self.llm) if settings.has_llm_credentials else FallbackClassifier() - ) - self.intent_classifier = classifier - if settings.has_llm_credentials: - self.assessor = LLMSituationalAssessor(self.llm) - - # ── Workflow Copilot (proactive prediction pipeline) ── - if settings.copilot_enabled: - from leapflow.copilot import ( - CopilotConfig, - ContextEncoder, - CopilotEventSubscriber, - PredictionEngine, - SpeculativePipeline, - IdleDetector, - FeedbackCollector, - EvolutionLoop, - ) - from leapflow.copilot.predictors import ( - L0HashPredictor, - L1MarkovPredictor, - ) - - copilot_config = CopilotConfig( - enabled=True, - action_ring_size=settings.copilot_action_ring_size, - min_idle_ms=settings.copilot_min_idle_ms, - max_idle_ms=settings.copilot_max_idle_ms, - cache_ttl_seconds=settings.copilot_cache_ttl_s, - speculative_cache_size=settings.copilot_speculative_cache_size, - ) - - # Context encoder - copilot_encoder = ContextEncoder(copilot_config) - - # Predictors (L0 + L1 always; L2/L3 only if LLM available) - from leapflow.copilot.predictors.l0_hash import InMemoryContextHashStore - - l0_store = InMemoryContextHashStore() - # Use SemanticHashAdapter for persistent storage if semantic provider available - if hasattr(self, 'lt') and self.lt is not None: - from leapflow.copilot.adapters import SemanticHashAdapter - l0_store = SemanticHashAdapter(self.lt) - - l1_markov = L1MarkovPredictor() - self._l1_markov = l1_markov - self._hydrate_l1_markov(l1_markov) - - predictors = [ - L0HashPredictor(l0_store), - l1_markov, - ] - # L2/L3: wire Memory adapters when ExperienceStore is available - if hasattr(self, 'experience_store') and self.experience_store is not None: - from leapflow.copilot.adapters import ExperienceEmbedAdapter - from leapflow.copilot.predictors.l2_embed import L2EmbeddingPredictor - from leapflow.copilot.predictors.l3_llm import L3LLMPredictor - - l2_provider = ExperienceEmbedAdapter(self.experience_store) - predictors.append(L2EmbeddingPredictor(l2_provider)) - - if settings.has_llm_credentials: - from leapflow.copilot.adapters import MemoryRAGAdapter as _RAG - rag_provider = _RAG(self.wm, self.experience_store) - - class _CopilotLLMClient: - """Adapt OpenAIChat to L3's LLMClient protocol.""" - def __init__(self, llm): - self._llm = llm - async def complete(self, prompt: str) -> str: - from leapflow.llm.message_builder import build_user_message_text - resp = await self._llm.achat( - [build_user_message_text(prompt)], stream=False, - ) - return resp.content or "" - - predictors.append(L3LLMPredictor( - _CopilotLLMClient(self.llm), rag_provider=rag_provider, - )) - - # Prediction engine - copilot_engine = PredictionEngine(predictors, copilot_config) - - # Speculative pipeline - copilot_pipeline = SpeculativePipeline(copilot_engine, copilot_config) - - # Feedback - copilot_feedback = FeedbackCollector() - copilot_evolution = EvolutionLoop(copilot_config, predictors) - - # Idle detector (callback will be wired in Phase 2) - async def _copilot_on_idle(duration_ms: int) -> None: - pass # Phase 2 will implement ghost hint rendering here - - copilot_idle = IdleDetector(copilot_config, on_idle=_copilot_on_idle) - - # Event subscriber — register to EventBus - warmup_raw = getattr(settings, "copilot_warmup_event_types", "") - warmup_types = frozenset(k.strip() for k in warmup_raw.split(",") if k.strip()) if warmup_raw else None - copilot_subscriber = CopilotEventSubscriber( - copilot_encoder, - tracker=None, - working_memory=self.wm if hasattr(self, 'wm') else None, - pipeline=copilot_pipeline, - warmup_event_types=warmup_types, - ) - self.event_bus.subscribe(copilot_subscriber.on_system_event) - - # Store references on Context for Phase 2 access - self.copilot_pipeline = copilot_pipeline - self.copilot_idle = copilot_idle - self.copilot_encoder = copilot_encoder - self.copilot_feedback = copilot_feedback - self.copilot_evolution = copilot_evolution - self.copilot_config = copilot_config - - logger.info("Copilot initialized: L0+L1 predictors active, idle detection armed") - else: - self.copilot_pipeline = None - self.copilot_idle = None - self.copilot_encoder = None - self.copilot_feedback = None - self.copilot_evolution = None - self.copilot_config = None - - # ── Wire memory tools into TOOL_HANDLERS (late binding) ── - from leapflow.tools.registry_bootstrap import set_memory_manager - set_memory_manager(self.memory) - - # ── Gateway server (late-bound tool wiring) ── - from leapflow.gateway.server import GatewayServer - from leapflow.gateway.router import GatewayRouter - from leapflow.gateway.events import ( - GatewayMessageReceived, - GatewaySessionCreated, - GatewaySessionEnded, - ) - from leapflow.gateway.connectors.protocol import BackendEvent - from leapflow.tools.registry_bootstrap import set_gateway_server - from leapflow.tools.gateway_tool import set_gateway_approval_gate + from leapflow.gateway.connectors.protocol import BackendEvent + from leapflow.tools.registry_bootstrap import set_gateway_server + from leapflow.tools.gateway_tool import set_gateway_approval_gate async def _on_gateway_event(event: object) -> None: """Bridge gateway events to episodic memory and logging.""" @@ -1650,6 +1333,18 @@ async def _on_gateway_event(event: object) -> None: async def _on_gateway_event_with_bridge(event: object) -> None: await _on_gateway_event(event) await self._gateway_event_bridge.on_gateway_event(event) + # N4: observe inbound messages for re-entry EVENT-trigger matches + # (independent of agent activation; matcher is set by the daemon). + observer = getattr(self, "_reentry_event_observer", None) + if observer is not None and isinstance(event, GatewayMessageReceived): + try: + await observer( + platform=event.source.platform, + chat=event.source.chat_id, + text=event.text, + ) + except Exception: + logger.debug("reentry event observer failed", exc_info=True) self.gateway_server = GatewayServer( settings.profile_layout, @@ -1782,6 +1477,24 @@ async def _summarize_via_llm(prompt: str) -> str: except Exception: logger.warning("ConversationStore initialization failed", exc_info=True) + # ── Initialize ResearchLedgerStore (S1 durable Orient) ── + self._research_ledger_store = None + try: + from leapflow.storage.research_ledger_store import ResearchLedgerStore + self._research_ledger_store = ResearchLedgerStore(self._db_holder) + logger.info("ResearchLedgerStore initialized") + except Exception: + logger.warning("ResearchLedgerStore initialization failed", exc_info=True) + + # ── Initialize ReentryStore (S2 event-driven re-entry) ── + self._reentry_store = None + try: + from leapflow.storage.reentry_store import ReentryStore + self._reentry_store = ReentryStore(self._db_holder) + logger.info("ReentryStore initialized") + except Exception: + logger.warning("ReentryStore initialization failed", exc_info=True) + if self._conversation_store and hasattr(self, "_gateway_router"): self._gateway_router._persistence = self._conversation_store @@ -1796,17 +1509,19 @@ async def _summarize_via_llm(prompt: str) -> str: except Exception: logger.debug("Shell approval gate setup skipped", exc_info=True) + self._critical_tool_bridge = tool_bridge + self.engine = AgentEngine( settings, self.rpc, self.llm, self.wm, self.lt, self.imm, self.registry, classifier, - imitation=self.imitation, + imitation=None, # wired in initialize_deferred() skill_library=self.skill_lib, graph_planner=graph_planner, scheduler=scheduler, perception=perception, execution=execution_adapter, - skill_activator=activator, - session=self.session, + skill_activator=None, # wired in initialize_deferred() + session=None, # wired in initialize_deferred() vlm=self.vlm, memory_manager=self.memory, evolution=self._evolution, @@ -1841,6 +1556,14 @@ async def _archive_to_semantic(messages: List[Dict[str, Any]]) -> None: if self._conversation_store: self.engine.set_conversation_store(self._conversation_store) + # ── Wire ResearchLedgerStore into engine (S1 durable Orient) ── + if self._research_ledger_store: + self.engine.set_research_ledger_store(self._research_ledger_store) + + # ── Wire ReentryStore into engine (S2 event-driven re-entry) ── + if self._reentry_store: + self.engine.set_reentry_store(self._reentry_store) + # ── Wire SubagentManager + delegate_task tool ── try: from leapflow.engine.subagent import DefaultSubagentExecutor, SubagentManager @@ -1848,27 +1571,626 @@ async def _archive_to_semantic(messages: List[Dict[str, Any]]) -> None: TOOL_DEFINITIONS as _TD, TOOL_HANDLERS as _TH, set_subagent_manager, ) - sub_executor = DefaultSubagentExecutor( - llm=self.llm, - tool_handlers=_TH, - tool_definitions=_TD, - settings=settings, + if getattr(settings, "agent_subagent_full_loop", False): + # Opt-in: subagents run the engine's full adaptive loop on an + # isolated child frame (state-isolated via per-frame swap). + from leapflow.engine.subagent import EngineFrameSubagentExecutor + sub_executor = EngineFrameSubagentExecutor( + run_child=self.engine._run_subagent_goal, + tool_names=list(_TH.keys()), + settings=settings, + ) + else: + sub_executor = DefaultSubagentExecutor( + llm=self.llm, + tool_handlers=_TH, + tool_definitions=_TD, + settings=settings, + ) + self._subagent_manager = SubagentManager( + executor=sub_executor, + max_depth=settings.agent_subagent_max_depth, + max_concurrent=settings.agent_subagent_max_concurrent, + ) + set_subagent_manager(self._subagent_manager) + logger.info("SubagentManager wired with delegate_task tool") + except Exception: + self._subagent_manager = None + logger.debug("SubagentManager setup skipped", exc_info=True) + + # NOTE: EvolutionStore + calibration are in initialize_deferred() + self._evolution_store = None + + # ── Wire tool loop guardrails (progress-aware; thresholds from config) ── + try: + if getattr(settings, "guardrail_enabled", True): + from leapflow.engine.tool_guardrails import CompositeGuardrail + self.engine._guardrail = CompositeGuardrail( + max_repeats=settings.guardrail_max_repeats, + stagnation_window=settings.guardrail_stagnation_window, + min_success_rate=settings.guardrail_min_success_rate, + max_consecutive_same=settings.guardrail_max_consecutive_same, + ) + logger.debug("Tool loop guardrails enabled") + else: + self.engine._guardrail = None + logger.debug("Tool loop guardrails disabled by config") + except Exception: + logger.debug("Tool guardrails setup skipped", exc_info=True) + + # ── 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. + try: + if getattr(settings, "tools_ripgrep_autoinstall", True): + import threading + from leapflow.tools.file_operations import ensure_ripgrep_available + threading.Thread( + target=ensure_ripgrep_available, + kwargs={"autoinstall": True}, + daemon=True, + ).start() + except Exception: + logger.debug("ripgrep background provisioning skipped", exc_info=True) + + # ── Wire developer verification + terminal-session tool config ── + try: + from leapflow.tools.dev_tools import set_dev_commands + set_dev_commands( + test_command=getattr(settings, "tools_test_command", "") or "", + lint_command=getattr(settings, "tools_lint_command", "") or "", + ) + from leapflow.tools.terminal_session import set_terminal_sessions_enabled + set_terminal_sessions_enabled(bool(getattr(settings, "tools_terminal_session_enabled", False))) + from leapflow.tools.file_operations import set_edit_verification + set_edit_verification(bool(getattr(settings, "tools_verify_edits", True))) + except Exception: + logger.debug("dev/terminal tool config wiring skipped", exc_info=True) + + # ── Wire Smart Approval (auxiliary LLM for command risk) ── + if self.auxiliary is not None: + try: + aux = self.auxiliary + + class _SmartApprovalGate: + """LLM-assisted shell approval adapter that preserves policy authority.""" + + def __init__(self, delegate: Any) -> None: + self._delegate = delegate + + async def evaluate(self, action: Any) -> Any: + return await self._delegate.evaluate(action) + + async def check(self, command: str) -> bool: + try: + risk = await aux.classify_risk(command) + except Exception: + risk = 0.5 + if risk < 0.3: + logger.debug("smart_approval: low auxiliary risk hint (risk=%.2f)", risk) + return await self._delegate.check(command) + + from leapflow.tools.shell_tools import set_approval_gate + set_approval_gate(_SmartApprovalGate(self._approval_orchestrator)) + logger.debug("Smart approval gate enabled (auxiliary LLM)") + except Exception: + logger.debug("Smart approval setup skipped", exc_info=True) + + # ── Wire File Read Approval Gate ── + try: + from leapflow.security.actions import ActionDescriptor + from leapflow.tools.registry_bootstrap import set_file_read_gate + + approval_orchestrator = self._approval_orchestrator + + class _FileReadGate: + """File read approval via the action approval orchestrator.""" + + def __init__(self) -> None: + self.denial_message = "" + + async def check( + self, + path: str, + mode: str = "raw", + sensitivity_meta: dict | None = None, + ) -> bool: + meta = dict(sensitivity_meta or {}) + action = ActionDescriptor.file_read(path, mode=mode, metadata=meta) + result = await approval_orchestrator.evaluate(action) + self.denial_message = result.denial_message if not result.approved else "" + return result.approved + + set_file_read_gate(_FileReadGate()) + logger.debug("File read approval gate: action orchestrator") + except Exception: + logger.debug("File read gate setup skipped", exc_info=True) + + # ── Wire File Write Approval Gate ── + try: + from leapflow.security.actions import ActionDescriptor + from leapflow.tools.registry_bootstrap import set_file_write_gate + + approval_orchestrator = self._approval_orchestrator + + class _FileWriteGate: + """File write approval via the action approval orchestrator.""" + + def __init__(self) -> None: + self.denial_message = "" + + async def check( + self, + path: str, + content: str, + mode: str = "overwrite", + sensitivity_meta: dict | None = None, + ) -> bool: + meta = dict(sensitivity_meta or {}) + action = ActionDescriptor.file_write(path, content, mode=mode, metadata=meta) + result = await approval_orchestrator.evaluate(action) + self.denial_message = result.denial_message if not result.approved else "" + return result.approved + + set_file_write_gate(_FileWriteGate()) + logger.debug("File write approval gate: action orchestrator") + except Exception: + logger.debug("File write gate setup skipped", exc_info=True) + + # ── Token budget and model capability metadata ───────────────────── + if self.engine is not None: + self._sync_engine_runtime_budget(settings) + self.engine.set_stale_stream_timeout(settings.stale_stream_timeout_s) + self.engine.set_default_tool_timeout(settings.default_tool_timeout_s) + + # NOTE: evolution_store and experience_store wired in initialize_deferred() + + if hasattr(self, "doc_store") and self.doc_store is not None: + self.engine.set_doc_store(self.doc_store) + + self.engine.set_event_bus(self.event_bus) + + # ── Register session_search tool ── + if self._conversation_store: + try: + from leapflow.tools.registry_bootstrap import TOOL_DEFINITIONS, TOOL_HANDLERS + conv_store = self._conversation_store + + TOOL_DEFINITIONS.append({ + "type": "function", + "function": { + "name": "session_search", + "description": "Search past conversation sessions for relevant context.", + "parameters": { + "type": "object", + "properties": { + "query": {"type": "string", "description": "Search keywords"}, + "limit": {"type": "integer", "description": "Max results (default: 5)"}, + }, + "required": ["query"], + }, + }, + }) + + async def _session_search_handler(params: dict) -> dict: + query = params.get("query", "") + limit = int(params.get("limit", 5)) + if not query: + return {"ok": False, "error": "Missing query parameter"} + results = conv_store.search_messages(query, limit=limit) + if not results: + return {"ok": True, "result": "No matching sessions found."} + items = [ + { + "session": r.session_title or r.session_id[:8], + "role": r.role, + "content": r.content[:300], + "score": round(r.score, 3), + } + for r in results + ] + import json as _json_ss + return {"ok": True, "result": _json_ss.dumps(items, ensure_ascii=False)} + + TOOL_HANDLERS["session_search"] = _session_search_handler + TOOL_HANDLERS["gp_session_search"] = _session_search_handler + logger.debug("session_search tool registered") + except Exception: + logger.debug("session_search tool registration failed", exc_info=True) + + # NOTE: PipelineObserver, ObservationDaemon, ColdStart, PatternMiner, + # ImplicitFeedback are assembled in initialize_deferred() + + async def initialize_deferred(self) -> None: + """Deferred initialization: rich features that can run in background. + + Assembles: ImitationPipeline, World Model, Skill System full load, + Learning Pipeline, Copilot, Observers. Components auto-initialize + on first use via _ensure_deferred() if this hasn't completed. + """ + settings = self.settings + perception = self._platform_perception + execution_adapter = self._platform_execution + perception_session = self.perception_session + codegen = self._critical_codegen + traj_store = self._critical_traj_store + distiller = self._critical_distiller + intent_inferrer = self._critical_intent_inferrer + attention_filters = self._critical_attention_filters + surprise_annotator = self._critical_surprise_annotator + scorer = self._critical_scorer + llm_scorer = self._critical_llm_scorer + feedback_evaluator = self._critical_feedback_evaluator + + # Give leapd's control plane a scheduling point before deferred init + # begins constructing optional subsystems; this task is background work. + await asyncio.sleep(0) + + # ── Video-mode components ── + video_recorder = None + video_analyzer = None + video_segmenter = None + signal_timeline = None + if settings.recording_mode.uses_video and settings.visual_track_enabled: + if settings.has_vlm_credentials: + video_recorder, video_analyzer, video_segmenter, signal_timeline = ( + _build_video_components(settings, self.rpc, self.vlm or self.llm) + ) + else: + message = ( + "Video analysis disabled: LEAPFLOW_VLM_API_KEY or " + "LEAPFLOW_LLM_API_KEY is required for visual recording mode." + ) + logger.warning(message) + _emit_status(message) + + # ── ImitationPipeline full assembly ── + from leapflow.analysis.abstractor import ActionAbstractor + platform_hint = getattr(self._platform_manifest, 'platform_id', None) + platform_hint = platform_hint.value if platform_hint else "darwin" + abstractor = ActionAbstractor(platform_hint=platform_hint) + + self.imitation = ImitationPipeline( + store=traj_store, distiller=distiller, codegen=codegen, + intent_inferrer=intent_inferrer, + abstractor=abstractor, + perception_session=perception_session, + goal_relevance_threshold=settings.attention_goal_relevance_threshold, + attention_filters=attention_filters, + surprise_annotator=surprise_annotator, + rpc=self.rpc, + event_bus=self.event_bus, + text_capture_enabled=settings.text_capture_enabled, + text_capture_exclude_apps=settings.text_capture_exclude_apps, + text_capture_secure_roles=settings.text_capture_secure_roles, + text_capture_max_length=settings.text_capture_max_length, + clipboard_max_length=settings.clipboard_max_length, + recording_mode=settings.recording_mode, + mhms_fusion_enabled=settings.mhms_fusion_enabled, + video_recorder=video_recorder, + video_analyzer=video_analyzer, + video_segmenter=video_segmenter, + signal_timeline=signal_timeline, + observation_daemon=self._observation_daemon, + recording_profile=_default_recording_profile(settings), + ) + self.event_bus.subscribe(self.imitation.recorder.on_event) + + if perception_session: + perception_session._recording_context = self.imitation.recorder.attention_context + perception_session.set_recording_mode(settings.recording_mode) + self.event_bus.subscribe(perception_session.on_system_event) + + if settings.recording_mode.uses_video and signal_timeline is not None: + if self.event_bus is not None: + self.event_bus.subscribe(signal_timeline.record_event) + logger.info("EventBus -> SignalTimeline subscription established") + elif settings.recording_mode.uses_video and signal_timeline is None: + logger.warning( + "SignalTimeline is None — skipping EventBus subscription " + "(video mode active but timeline unavailable)" + ) + + # Yield to the event loop so heartbeats/RPC callbacks stay responsive + # during this long synchronous initialization (see daemon keepalive). + await asyncio.sleep(0) + + # ── World Model assembly ── + if settings.prediction_enabled: + from leapflow.world_model import ( + LearningBudgetController, + ExperienceStore, + CuriosityConfig, + CuriositySignal, + PredictionLoop, + ExperienceReplayEngine, + TrajectoryGrader, + ) + from leapflow.perception.state_snapshot import StateSnapshotService + + self.learning_budget = LearningBudgetController( + prediction_budget=settings.prediction_budget, + comparison_budget=settings.comparison_budget, + replay_budget=settings.replay_budget, + grading_budget=settings.grading_budget, + distillation_budget=settings.distillation_budget, + discovery_baseline=settings.budget_discovery_baseline, + regression_baseline=settings.budget_regression_baseline, + ) + + embedding_provider = None + if settings.semantic_embedding_provider != "none": + from leapflow.world_model.embedding import ( + TFIDFEmbeddingProvider, + LLMEmbeddingProvider, + ) + if settings.semantic_embedding_provider == "llm": + embedding_provider = LLMEmbeddingProvider(self.llm) + else: + embedding_provider = TFIDFEmbeddingProvider() + + self.experience_store = ExperienceStore( + self.lt, + embedding_provider=embedding_provider, + semantic_weight=settings.semantic_rerank_weight, + ) + self.snapshot_service = StateSnapshotService(self.rpc, self.imm) + self.curiosity = CuriositySignal( + CuriosityConfig( + alpha=settings.curiosity_alpha, + beta=settings.curiosity_beta, + gamma=settings.curiosity_gamma, + auto_balance=settings.curiosity_auto_balance, + ), + experience_store=self.experience_store, + ) + self.prediction_loop = PredictionLoop( + llm=self.llm, + snapshot_service=self.snapshot_service, + experience_store=self.experience_store, + budget=self.learning_budget, + enabled=settings.prediction_enabled, + delta_threshold=settings.prediction_delta_threshold, + structural_blend_weight=settings.prediction_structural_blend, + semantic_blend_weight=settings.prediction_semantic_blend, + semantic_compare_threshold=settings.prediction_semantic_threshold, + rag_advantage_floor=settings.prediction_rag_advantage_floor, + failure_advantage=settings.prediction_failure_advantage, + ) + insight_callback = self._build_insight_callback() + self.replay_engine = ExperienceReplayEngine( + llm=self.llm, + experience_store=self.experience_store, + budget=self.learning_budget, + on_insight=insight_callback, + regression_sample_size=settings.replay_regression_sample_size, + ) + self.trajectory_grader = TrajectoryGrader( + llm=self.llm, + experience_store=self.experience_store, + budget=self.learning_budget, + ) + self.registry.set_prediction_loop(self.prediction_loop) + + if perception_session is not None: + self.curiosity.set_causal_graph(perception_session.causal_graph) + freq = perception_session.causal_graph.metadata.get("frequency_counter") + if freq: + self.curiosity.load_frequency_counter(freq) + + _ss = self.snapshot_service + def _on_focus_for_snapshot(event: Any) -> None: + if getattr(event, "event_type", "") == "app.focus_change": + bid = event.payload.get("bundle_id", "") + title = event.payload.get("window_title", "") + if bid: + _ss.update_focus(bid, title) + self.event_bus.subscribe(_on_focus_for_snapshot) + + logger.info("World model initialized (prediction + curiosity + replay + OPD grading)") + + # Event-loop yield point after world model assembly + await asyncio.sleep(0) + + # ── SkillActivator ── + activator = None + if perception and execution_adapter: + activator = SkillActivator( + self.registry, self.skill_lib, execution_adapter, perception, + codegen=codegen, + ) + # Heavy DuckDB skill-library load: run off the event loop + n_activated = await self._run_deferred_db(activator.load_and_activate_all) + if n_activated: + logger.info("Activated %d learned skills from library", n_activated) + self._critical_activator = activator + + # Event-loop yield point after heavy skill-library DuckDB loading + await asyncio.sleep(0) + + # ── Learning Pipeline ── + from leapflow.analysis.consensus import MultiTrajectoryDistiller + consensus_distiller = MultiTrajectoryDistiller(self.imitation) + + self.doc_store = SkillDocStore(settings.skills_dir) + doc_generator: Optional[CompositeSkillDocGenerator] = None + if settings.has_llm_credentials: + doc_generator = CompositeSkillDocGenerator( + llm_generator=LLMSkillDocGenerator(self.llm), + ) + else: + doc_generator = CompositeSkillDocGenerator() + + self.active_observer = ActiveLearningObserver( + self.skill_lib, scorer, self.wm, + llm_scorer=llm_scorer, + feedback_evaluator=feedback_evaluator, + skill_activator=activator, + consensus_distiller=consensus_distiller, + doc_generator=doc_generator, + doc_store=self.doc_store, + skill_registry=self.registry, + llm=self.llm, + execution=execution_adapter, + ) + observer = self.active_observer + self.imitation.set_on_candidates_ready(observer.on_candidates_ready) + + # Wire curiosity signal from world model → active learning + attention tuner + if self.prediction_loop is not None and self.curiosity is not None: + _es = self.experience_store + + from leapflow.recording.attention_tuner import AttentionTuner + pf_filter = None + for f in attention_filters: + if type(f).__name__ == "PerceptualFieldFilter": + pf_filter = f + break + self.attention_tuner = AttentionTuner( + self.imitation.recorder.attention_context, + perceptual_filter=pf_filter, + curiosity_expand_threshold=settings.attention_curiosity_expand_threshold, + accuracy_contract_threshold=settings.attention_accuracy_contract_threshold, + ) + _tuner = self.attention_tuner + + _ctx = self + + def _on_prediction_outcome(outcome: Any) -> None: + score = _ctx.curiosity.compute(outcome) + exp_id = getattr(outcome, "experience_id", "") + if exp_id and _es is not None: + _es.update_curiosity_score(exp_id, score.total) + _tuner.on_curiosity_signal(score, outcome) + observer.on_curiosity_signal(score, outcome) + + delta = getattr(outcome, "delta", 0.0) + evo_policy = _ctx._evolution_policy + skill_lib = _ctx.skill_lib + if evo_policy is not None and skill_lib is not None: + action_desc = "" + pred = getattr(outcome, "prediction", None) + if pred is not None: + action_desc = getattr(pred, "action_description", "") + skill_title = action_desc.split(":", 1)[-1] if ":" in action_desc else action_desc + if skill_title and (delta > 0.4 or delta < 0.15): + try: + stored = skill_lib.load_skill_by_title(skill_title) + if stored: + evo_outcome = evo_policy.on_execution_result( + stored.title, + success=(delta < 0.2), + duration_s=0.0, + current_confidence=stored.confidence, + current_version=stored.version, + ) + skill_lib.update_skill_confidence( + stored.title, evo_outcome.new_confidence + ) + if evo_outcome.tier_changed: + logger.info( + "Delta-driven evolution: '%s' confidence → %.3f", + stored.title, evo_outcome.new_confidence, + ) + except Exception: + logger.debug("delta-driven evolution update failed", exc_info=True) + + self.prediction_loop._on_outcome = _on_prediction_outcome + + # ── Doc skills + stored fallbacks ── + n_doc_skills = 0 + # SKILL.md loading is blocking file/DB IO: run off the event loop + doc_skills = await self._run_deferred_db( + lambda: list(self.doc_store.load_all_as_skills( + self.llm, execution=execution_adapter, perception=perception, + )) + ) + for skill in doc_skills: + self.registry.register(skill) + n_doc_skills += 1 + if n_doc_skills: + logger.info("Registered %d SKILL.md skills", n_doc_skills) + + n_fallback = await self._run_deferred_db( + lambda: _register_stored_skill_fallbacks( + self.skill_lib, self.registry, self.llm, + ) + ) + if n_fallback: + logger.info("Registered %d stored skills as fallback", n_fallback) + + # Event-loop yield point after doc-skill/fallback registration + await asyncio.sleep(0) + + # ── Learnability + SessionController ── + from leapflow.engine.confirmation import ConfirmationHandler + confirmation = ConfirmationHandler(skill_store=self.skill_lib) + + learnability_assessor = None + if settings.learnability_enabled: + from leapflow.learning.learnability import DefaultLearnabilityAssessor, LearnabilityConfig + learnability_config = LearnabilityConfig( + min_steps=settings.learnability_min_steps, + min_duration_s=settings.learnability_min_duration_s, + max_idle_ratio=settings.learnability_max_idle_ratio, + min_action_diversity=settings.learnability_min_action_diversity, + learn_threshold=settings.learnability_learn_threshold, + ask_threshold=settings.learnability_ask_threshold, + vlm_enabled=settings.learnability_vlm_enabled, + llm_enabled=settings.learnability_llm_enabled, + rule_weight=settings.learnability_rule_weight, + vlm_weight=settings.learnability_vlm_weight, + llm_weight=settings.learnability_llm_weight, + ) + learnability_assessor = DefaultLearnabilityAssessor( + llm=self.llm if settings.has_llm_credentials else None, + vlm=self.vlm, + config=learnability_config, ) - self._subagent_manager = SubagentManager(executor=sub_executor) - set_subagent_manager(self._subagent_manager) - logger.info("SubagentManager wired with delegate_task tool") - except Exception: - self._subagent_manager = None - logger.debug("SubagentManager setup skipped", exc_info=True) - # ── Wire EvolutionStore (DuckDB persistence for skill episodes) ── - self._evolution_store = None + self._evolution_policy = EMAConfidencePolicy() + self.session = SessionController( + self.imitation, + self.registry, + idle_timeout=settings.learn_idle_timeout, + auto_learn=settings.learn_auto_distill, + confirmation=confirmation, + audit=self.audit, + storage_path=str(settings.duckdb_path), + audit_log_path=str(settings.audit_log_path), + active_learning_observer=observer, + session_store=self.session_store, + learnability_assessor=learnability_assessor, + evolution_policy=self._evolution_policy, + skill_store=self.skill_lib, + ) + + # ── Wire deferred components to engine ── + if self.engine is not None: + self.engine._imitation = self.imitation + self.engine._session = self.session + if activator: + self.engine._skill_activator = activator + if self.experience_store is not None: + self.engine.set_experience_store(self.experience_store) + + # Event-loop yield point after SessionController/engine wiring + await asyncio.sleep(0) + + # ── EvolutionStore (DuckDB persistence for skill episodes) ── try: from leapflow.storage.evolution_store import DuckDBEvolutionStore - self._evolution_store = DuckDBEvolutionStore(self._db_holder) - # Hydrate in-memory provider from persisted episodes - persisted = self._evolution_store.load_recent_episodes( - limit=settings.memory_evolution_max_episodes, + + def _load_evolution_store() -> "tuple[Any, list[dict[str, Any]]]": + # Construction runs schema init; both are blocking DuckDB work + store = DuckDBEvolutionStore(self._db_holder) + episodes = store.load_recent_episodes( + limit=settings.memory_evolution_max_episodes, + ) + return store, episodes + + self._evolution_store, persisted = await self._run_deferred_db( + _load_evolution_store ) for ep in persisted: self._evolution.record_episode( @@ -1886,168 +2208,129 @@ async def _archive_to_semantic(messages: List[Dict[str, Any]]) -> None: except Exception: logger.debug("EvolutionStore initialization skipped", exc_info=True) - # ── Wire tool loop guardrails ── + # Calibration try: - from leapflow.engine.tool_guardrails import CompositeGuardrail - self.engine._guardrail = CompositeGuardrail() - logger.debug("Tool loop guardrails enabled") + if getattr(settings, "agent_calibration_enabled", False) and self._evolution_store is not None: + self.engine.set_calibration_store(self._evolution_store) + diff_result = await self._run_deferred_db( + lambda: self.engine.recalibrate_difficulty(self._evolution_store) + ) + if getattr(diff_result, "applied", False): + logger.info("Difficulty calibration applied: %s", getattr(diff_result, "reason", "")) + thr_result = await self._run_deferred_db( + lambda: self.engine.recalibrate_thresholds(self._evolution_store) + ) + if getattr(thr_result, "applied", False): + logger.info("Threshold calibration applied: %s", getattr(thr_result, "reason", "")) except Exception: - logger.debug("Tool guardrails setup skipped", exc_info=True) - - # ── Wire Smart Approval (auxiliary LLM for command risk) ── - if self.auxiliary is not None: - try: - aux = self.auxiliary - - class _SmartApprovalGate: - """LLM-assisted shell approval adapter that preserves policy authority.""" - - def __init__(self, delegate: Any) -> None: - self._delegate = delegate - - async def evaluate(self, action: Any) -> Any: - return await self._delegate.evaluate(action) - - async def check(self, command: str) -> bool: - try: - risk = await aux.classify_risk(command) - except Exception: - risk = 0.5 - if risk < 0.3: - logger.debug("smart_approval: low auxiliary risk hint (risk=%.2f)", risk) - return await self._delegate.check(command) - - from leapflow.tools.shell_tools import set_approval_gate - set_approval_gate(_SmartApprovalGate(self._approval_orchestrator)) - logger.debug("Smart approval gate enabled (auxiliary LLM)") - except Exception: - logger.debug("Smart approval setup skipped", exc_info=True) - - # ── Wire File Read Approval Gate ── - try: - from leapflow.security.actions import ActionDescriptor - from leapflow.tools.registry_bootstrap import set_file_read_gate - - approval_orchestrator = self._approval_orchestrator + logger.debug("Difficulty/threshold calibration skipped", exc_info=True) - class _FileReadGate: - """File read approval via the action approval orchestrator.""" - - def __init__(self) -> None: - self.denial_message = "" + if self._evolution_store and self.engine is not None: + self.engine.set_evolution_store(self._evolution_store) - async def check( - self, - path: str, - mode: str = "raw", - sensitivity_meta: dict | None = None, - ) -> bool: - meta = dict(sensitivity_meta or {}) - action = ActionDescriptor.file_read(path, mode=mode, metadata=meta) - result = await approval_orchestrator.evaluate(action) - self.denial_message = result.denial_message if not result.approved else "" - return result.approved + # Event-loop yield point after EvolutionStore hydration/calibration + await asyncio.sleep(0) - set_file_read_gate(_FileReadGate()) - logger.debug("File read approval gate: action orchestrator") - except Exception: - logger.debug("File read gate setup skipped", exc_info=True) + # ── Copilot pipeline ── + if settings.copilot_enabled: + from leapflow.copilot import ( + CopilotConfig, + ContextEncoder, + CopilotEventSubscriber, + PredictionEngine, + SpeculativePipeline, + IdleDetector, + FeedbackCollector, + EvolutionLoop, + ) + from leapflow.copilot.predictors import ( + L0HashPredictor, + L1MarkovPredictor, + ) - # ── Wire File Write Approval Gate ── - try: - from leapflow.security.actions import ActionDescriptor - from leapflow.tools.registry_bootstrap import set_file_write_gate + copilot_config = CopilotConfig( + enabled=True, + action_ring_size=settings.copilot_action_ring_size, + min_idle_ms=settings.copilot_min_idle_ms, + max_idle_ms=settings.copilot_max_idle_ms, + cache_ttl_seconds=settings.copilot_cache_ttl_s, + speculative_cache_size=settings.copilot_speculative_cache_size, + ) - approval_orchestrator = self._approval_orchestrator + copilot_encoder = ContextEncoder(copilot_config) + from leapflow.copilot.predictors.l0_hash import InMemoryContextHashStore - class _FileWriteGate: - """File write approval via the action approval orchestrator.""" + l0_store = InMemoryContextHashStore() + if hasattr(self, 'lt') and self.lt is not None: + from leapflow.copilot.adapters import SemanticHashAdapter + l0_store = SemanticHashAdapter(self.lt) - def __init__(self) -> None: - self.denial_message = "" + l1_markov = L1MarkovPredictor() + self._l1_markov = l1_markov + # Semantic-memory DuckDB read: run off the event loop + await self._run_deferred_db(lambda: self._hydrate_l1_markov(l1_markov)) - async def check( - self, - path: str, - content: str, - mode: str = "overwrite", - sensitivity_meta: dict | None = None, - ) -> bool: - meta = dict(sensitivity_meta or {}) - action = ActionDescriptor.file_write(path, content, mode=mode, metadata=meta) - result = await approval_orchestrator.evaluate(action) - self.denial_message = result.denial_message if not result.approved else "" - return result.approved + predictors = [ + L0HashPredictor(l0_store), + l1_markov, + ] + if hasattr(self, 'experience_store') and self.experience_store is not None: + from leapflow.copilot.adapters import ExperienceEmbedAdapter + from leapflow.copilot.predictors.l2_embed import L2EmbeddingPredictor + from leapflow.copilot.predictors.l3_llm import L3LLMPredictor - set_file_write_gate(_FileWriteGate()) - logger.debug("File write approval gate: action orchestrator") - except Exception: - logger.debug("File write gate setup skipped", exc_info=True) + l2_provider = ExperienceEmbedAdapter(self.experience_store) + predictors.append(L2EmbeddingPredictor(l2_provider)) - # ── Token budget and model capability metadata ───────────────────── - if self.engine is not None: - self._sync_engine_runtime_budget(settings) - self.engine.set_stale_stream_timeout(settings.stale_stream_timeout_s) - self.engine.set_default_tool_timeout(settings.default_tool_timeout_s) + if settings.has_llm_credentials: + from leapflow.copilot.adapters import MemoryRAGAdapter as _RAG + rag_provider = _RAG(self.wm, self.experience_store) - if self._evolution_store is not None: - self.engine.set_evolution_store(self._evolution_store) + class _CopilotLLMClient: + def __init__(self, llm): + self._llm = llm + async def complete(self, prompt: str) -> str: + from leapflow.llm.message_builder import build_user_message_text + resp = await self._llm.achat( + [build_user_message_text(prompt)], stream=False, + ) + return resp.content or "" - if hasattr(self, "doc_store") and self.doc_store is not None: - self.engine.set_doc_store(self.doc_store) + predictors.append(L3LLMPredictor( + _CopilotLLMClient(self.llm), rag_provider=rag_provider, + )) - self.engine.set_event_bus(self.event_bus) + copilot_engine = PredictionEngine(predictors, copilot_config) + copilot_pipeline = SpeculativePipeline(copilot_engine, copilot_config) + copilot_feedback = FeedbackCollector() + copilot_evolution = EvolutionLoop(copilot_config, predictors) - if hasattr(self, "experience_store") and self.experience_store is not None: - self.engine.set_experience_store(self.experience_store) + async def _copilot_on_idle(duration_ms: int) -> None: + pass - # ── Register session_search tool ── - if self._conversation_store: - try: - from leapflow.tools.registry_bootstrap import TOOL_DEFINITIONS, TOOL_HANDLERS - conv_store = self._conversation_store + copilot_idle = IdleDetector(copilot_config, on_idle=_copilot_on_idle) - TOOL_DEFINITIONS.append({ - "type": "function", - "function": { - "name": "session_search", - "description": "Search past conversation sessions for relevant context.", - "parameters": { - "type": "object", - "properties": { - "query": {"type": "string", "description": "Search keywords"}, - "limit": {"type": "integer", "description": "Max results (default: 5)"}, - }, - "required": ["query"], - }, - }, - }) + warmup_raw = getattr(settings, "copilot_warmup_event_types", "") + warmup_types = frozenset(k.strip() for k in warmup_raw.split(",") if k.strip()) if warmup_raw else None + copilot_subscriber = CopilotEventSubscriber( + copilot_encoder, + tracker=None, + working_memory=self.wm if hasattr(self, 'wm') else None, + pipeline=copilot_pipeline, + warmup_event_types=warmup_types, + ) + self.event_bus.subscribe(copilot_subscriber.on_system_event) - async def _session_search_handler(params: dict) -> dict: - query = params.get("query", "") - limit = int(params.get("limit", 5)) - if not query: - return {"ok": False, "error": "Missing query parameter"} - results = conv_store.search_messages(query, limit=limit) - if not results: - return {"ok": True, "result": "No matching sessions found."} - items = [ - { - "session": r.session_title or r.session_id[:8], - "role": r.role, - "content": r.content[:300], - "score": round(r.score, 3), - } - for r in results - ] - import json as _json_ss - return {"ok": True, "result": _json_ss.dumps(items, ensure_ascii=False)} + self.copilot_pipeline = copilot_pipeline + self.copilot_idle = copilot_idle + self.copilot_encoder = copilot_encoder + self.copilot_feedback = copilot_feedback + self.copilot_evolution = copilot_evolution + self.copilot_config = copilot_config + logger.info("Copilot initialized: L0+L1 predictors active") - TOOL_HANDLERS["session_search"] = _session_search_handler - TOOL_HANDLERS["gp_session_search"] = _session_search_handler - logger.debug("session_search tool registered") - except Exception: - logger.debug("session_search tool registration failed", exc_info=True) + # Event-loop yield point after copilot assembly (L1 Markov hydration) + await asyncio.sleep(0) # Pipeline Observer (A6: learning pipeline observability) from leapflow.engine.pipeline_observer import StructuredPipelineLogger @@ -2071,13 +2354,19 @@ async def _session_search_handler(params: dict) -> dict: # ColdStartManager: adaptive threshold management from leapflow.learning.cold_start import ColdStartManager, ColdStartConfig self._cold_start = ColdStartManager(ColdStartConfig(mode="prompt")) - initial_skills = len(self.skill_lib.load_all_active()) if self.skill_lib else 0 + initial_skills = ( + await self._run_deferred_db(lambda: len(self.skill_lib.load_all_active())) + if self.skill_lib else 0 + ) self._cold_start.update_stats(skills_count=initial_skills) # LearningEffectivenessTracker: metrics observability from leapflow.learning.effectiveness import LearningEffectivenessTracker self._effectiveness_tracker = LearningEffectivenessTracker() + # Event-loop yield point before the observer/miner tail phase + await asyncio.sleep(0) + # PatternMiner → ActiveLearningObserver bridge (closed loop) if settings.observer_auto_start and settings.has_llm_credentials: try: @@ -2219,6 +2508,11 @@ async def _on_session_end_learning(self) -> None: 8. VLM Tier 3 verification (if enabled) """ observer = self._pipeline_observer + if observer is None: + # Deferred init never completed (degraded/critical-only mode): + # no learning components were assembled, nothing to flush. + logger.debug("Session-end learning skipped: pipeline observer not initialized") + return pipeline_start = time.perf_counter() phases_ok = 0 phases_failed = 0 @@ -2504,6 +2798,13 @@ def _to_frozenset(val: Any) -> frozenset[str]: gw.register_trigger_policy(platform_id, policy) async def cleanup(self) -> None: + # Drain the deferred-DB executor first so no worker thread touches the + # shared DuckDB connection while stores below persist/close it. + db_executor = getattr(self, "_deferred_db_executor", None) + if db_executor is not None: + db_executor.shutdown(wait=True, cancel_futures=True) + self._deferred_db_executor = None + # Persist evolution episodes to DuckDB before shutdown evo_store = getattr(self, "_evolution_store", None) if evo_store is not None and self._evolution is not None: diff --git a/src/leapflow/cli/tui_app/app.py b/src/leapflow/cli/tui_app/app.py index cb2ff50..ca40637 100644 --- a/src/leapflow/cli/tui_app/app.py +++ b/src/leapflow/cli/tui_app/app.py @@ -39,7 +39,7 @@ from prompt_toolkit import Application from prompt_toolkit.auto_suggest import AutoSuggestFromHistory from prompt_toolkit.filters import Condition -from prompt_toolkit.formatted_text.utils import fragment_list_len +from prompt_toolkit.formatted_text.utils import fragment_list_len, fragment_list_width from prompt_toolkit.history import FileHistory from prompt_toolkit.key_binding import KeyBindings from prompt_toolkit.keys import Keys @@ -51,6 +51,7 @@ from prompt_toolkit.layout.processors import Processor, Transformation from prompt_toolkit.patch_stdout import patch_stdout from prompt_toolkit.styles import Style as PTStyle +from prompt_toolkit.utils import get_cwidth from prompt_toolkit.widgets import TextArea from leapflow.cli.tui_app.approval_modal import ApprovalModal, request_is_expired @@ -87,6 +88,51 @@ def _format_inline_duration(seconds: float) -> str: return f"{minutes}m{seconds - minutes * 60:.0f}s" +def _compute_input_cap(rows: int) -> int: + """Max visible input rows: grow with content up to ~45% of the terminal, + leaving room for the status chrome and some transcript; never below 4.""" + return max(4, min(int(rows * 0.45), rows - 6)) + + +def _wrapped_display_rows(line: str, width: int) -> int: + """Return display rows needed for one logical line at a given cell width.""" + width = max(1, int(width)) + if not line: + return 1 + rows = 1 + used = 0 + for char in line: + char_width = max(0, get_cwidth(char)) + if char_width == 0: + continue + if used and used + char_width > width: + rows += 1 + used = 0 + if char_width > width: + rows += 1 if used else 0 + used = 0 + continue + used += char_width + return rows + + +def _estimate_input_rows(text: str, columns: int, *, prompt_width: int = 0) -> int: + """Estimate wrapped display rows for the input buffer. + + The first logical line loses cells to the rendered prompt prefix. Later + logical lines use the full input width. Width is display-cell based, so CJK + wide characters count as two cells. + """ + columns = max(1, int(columns)) + if not text: + return 1 + total = 0 + for index, line in enumerate(str(text).split("\n")): + line_width = columns if index else max(1, columns - max(0, prompt_width)) + total += _wrapped_display_rows(line, line_width) + return max(1, total) + + class _DynamicPlaceholderProcessor(Processor): """Render the input prompt and contextual placeholder text.""" @@ -740,6 +786,66 @@ async def _process_loop(self) -> None: # ── Layout construction ────────────────────────────────────────── + def _current_input_text(self) -> str: + input_area = getattr(self, "_input_area", None) + buffer = getattr(input_area, "buffer", None) + return str(getattr(buffer, "text", "") or "") + + def _input_visual_rows(self, columns: int) -> int: + prompt_width = fragment_list_width(self._prompt_fragments()) + return _estimate_input_rows( + self._current_input_text(), + columns, + prompt_width=prompt_width, + ) + + def _effective_input_cap(self, lines: int) -> int: + """Max visible input rows for the current run-state. + + While a task streams output, hold a small, stable cap: a dynamic or large + reserved region reshapes the scroll region under ``patch_stdout`` on + every redraw and can crash the terminal (macOS Terminal in particular). + Adaptive, content-sized sizing resumes once the task finishes (idle), + when the input area is the sole owner of the TTY. + """ + if self._agent_running: + return 4 + return _compute_input_cap(lines) + + def _input_hidden_rows(self) -> int: + terminal = shutil.get_terminal_size((80, 24)) + cap = self._effective_input_cap(terminal.lines) + return max(0, self._input_visual_rows(terminal.columns) - cap) + + def _input_overflow_hint(self) -> list[tuple[str, str]]: + hidden_rows = self._input_hidden_rows() + if hidden_rows <= 0: + return [] + suffix = "row" if hidden_rows == 1 else "rows" + # The external editor is disabled while a task streams (single TTY owner), + # so don't advertise it then. + action = "full view resumes when idle" if self._agent_running else "Ctrl+X Ctrl+E edit full draft" + return [( + "class:hint", + f"↑ {hidden_rows} more input {suffix} hidden · {action}", + )] + + def _input_height(self) -> Dimension: + """Content-sized input height, capped to the terminal size when idle. + + While a task streams output the height is pinned to a small, stable + region (min=1, max=4, preferred=1) so the reserved bottom area does not + thrash the scroll region under ``patch_stdout`` mid-stream — a cause of + terminal crashes. When idle the height tracks wrapped content up to an + adaptive cap; a one-line draft still renders as a single row. + """ + terminal = shutil.get_terminal_size((80, 24)) + cap = self._effective_input_cap(terminal.lines) + if self._agent_running: + return Dimension(min=1, max=cap, preferred=1) + preferred = min(cap, max(1, self._input_visual_rows(terminal.columns))) + return Dimension(min=1, max=cap, preferred=preferred) + def _build_input_area( self, commands: Sequence[tuple[str, str]], config_fields: Sequence[object] ) -> TextArea: @@ -747,7 +853,7 @@ def _build_input_area( ref = self area = TextArea( - height=Dimension(min=1, max=4, preferred=1), + height=self._input_height, prompt="", style="class:input-area", multiline=True, @@ -796,11 +902,19 @@ def _build_application(self) -> Application[Any]: ), filter=has_approval, ) + input_overflows = Condition(lambda: self._input_hidden_rows() > 0) + + input_hint = Window( + content=FormattedTextControl(self._input_overflow_hint), + height=1, + style="class:hint", + ) root = HSplit([ ConditionalContainer(spinner, filter=no_approval), approval_panel, ConditionalContainer(status_gap, filter=no_approval), status_bar, + ConditionalContainer(input_hint, filter=no_approval & input_overflows), ConditionalContainer(self._input_area, filter=no_approval), ]) layout = Layout( @@ -923,6 +1037,17 @@ def _(event): def _(event): ref._move_right_or_accept_suggestion(event.current_buffer) + @kb.add(Keys.ControlX, Keys.ControlE) + def _(event): + # Compose long input in $EDITOR only when idle. Spawning an external + # editor (alternate screen + raw mode) while a task streams output + # through patch_stdout makes two owners contend for the TTY and can + # crash the terminal; the editor becomes available again the moment + # the task finishes. (buffer round-trips via tempfile_suffix.) + if ref._approval_modal is not None or ref._agent_running: + return + event.current_buffer.open_in_editor(validate_and_handle=False) + @kb.add(Keys.BracketedPaste) def _(event): ref._insert_paste_text(event.current_buffer, event.data) diff --git a/src/leapflow/cli/tui_app/status.py b/src/leapflow/cli/tui_app/status.py index bd26e69..6ca1786 100644 --- a/src/leapflow/cli/tui_app/status.py +++ b/src/leapflow/cli/tui_app/status.py @@ -98,6 +98,9 @@ def __init__(self, theme: Optional[Theme | ResolvedTheme] = None) -> None: self.context_state: str = "baseline" self.running_tasks: int = 0 self.queued_tasks: int = 0 + self.daemon_turn_active: int = 0 + self.daemon_turn_max: int = 0 + self.daemon_turn_waiting: int = 0 self.watch_count: int = 0 self.alert_count: int = 0 self.last_turn_elapsed: float = 0.0 @@ -137,10 +140,28 @@ def __call__(self) -> list[tuple[str, str]]: if narrow: task_text = f"r{self.running_tasks} q{self.queued_tasks} " else: - task_text = f"running:{self.running_tasks} queued:{self.queued_tasks} " + task_text = f"local:{self.running_tasks}/{self.running_tasks + self.queued_tasks} " parts.append(("class:status-bar.strong", task_text)) parts.append(("class:status-bar.dim", "│ ")) + if self.daemon_turn_max > 0: + active = max(0, self.daemon_turn_active) + cap = max(1, self.daemon_turn_max) + waiting = max(0, self.daemon_turn_waiting) + daemon_cls = "class:status-bar.warn" if waiting or active >= cap else "class:status-bar.strong" + if narrow: + daemon_text = f"d{active}/{cap}" + if waiting: + daemon_text += f"w{waiting}" + daemon_text += " " + else: + daemon_text = f"daemon:{active}/{cap}" + if waiting: + daemon_text += f" waiting:{waiting}" + daemon_text += " " + parts.append((daemon_cls, daemon_text)) + parts.append(("class:status-bar.dim", "│ ")) + if self.watch_count or self.alert_count: if narrow: mon_text = f"◉{self.watch_count}" @@ -237,6 +258,9 @@ def update( context_state: Optional[str] = None, running_tasks: Optional[int] = None, queued_tasks: Optional[int] = None, + daemon_turn_active: Optional[int] = None, + daemon_turn_max: Optional[int] = None, + daemon_turn_waiting: Optional[int] = None, ) -> None: """Selectively update status fields.""" if mode is not None: @@ -259,6 +283,12 @@ def update( self.running_tasks = running_tasks if queued_tasks is not None: self.queued_tasks = queued_tasks + if daemon_turn_active is not None: + self.daemon_turn_active = max(0, daemon_turn_active) + if daemon_turn_max is not None: + self.daemon_turn_max = max(0, daemon_turn_max) + if daemon_turn_waiting is not None: + self.daemon_turn_waiting = max(0, daemon_turn_waiting) def update_task_counts(self, *, running: int, queued: int) -> None: """Update task counters shown in the status bar.""" diff --git a/src/leapflow/config.py b/src/leapflow/config.py index b85ce25..3e48e8f 100644 --- a/src/leapflow/config.py +++ b/src/leapflow/config.py @@ -308,6 +308,36 @@ class Settings: react_max_iterations: int = 20 react_soft_limit: int = 14 react_warning_threshold: int = 10 + # Adaptive-depth elastic budget: baseline floor -> difficulty-scaled ceiling. + agent_iter_floor: int = 12 + agent_iter_ceiling: int = 200 + agent_budget_scale_k: float = 1.0 + # Progress-gated continuation: when a task is productively unfinished (open + # ledger questions + still surfacing progress) and within resource limits, + # the effective cap extends past the elastic ceiling toward this hard cap in + # steps of ``agent_iter_extension_step``. Stall (no progress for + # ``agent_stall_rounds`` rounds) stops extension. The hard cap is the true + # backstop so a long task is bounded by progress/resources, not a fixed count. + agent_iter_hard_cap: int = 500 + agent_iter_extension_step: int = 25 + agent_stall_rounds: int = 6 + agent_cost_ceiling_context_multiple: float = 0.0 + agent_subagent_max_depth: int = 2 + agent_subagent_max_concurrent: int = 3 + agent_subagent_max_iterations: int = 15 + agent_max_parallel_tools: int = 8 # Max tool calls run in parallel within one response's batch + agent_subagent_full_loop: bool = False + agent_calibration_enabled: bool = False + agent_calibration_min_confidence: float = 0.3 + agent_calibration_interval_turns: int = 0 + agent_compression_writeback: bool = False + agent_reentry_enabled: bool = False + agent_reentry_tick_seconds: float = 30.0 + agent_reentry_global_budget: int = 100 + agent_reentry_send_enabled: bool = False + agent_reentry_send_rate_per_hour: int = 4 + agent_reentry_send_global_budget: int = 50 + agent_reentry_send_verified_at: int = 3 tool_max_iterations: int = 30 native_tool_calling_enabled: bool = True # Use native OpenAI tool_calls when available stream_output: bool = True # Enable LLM streaming in interactive mode @@ -316,13 +346,31 @@ class Settings: # Context Compression compress_threshold: int = 16 compress_keep_tail: int = 4 - max_tool_output_chars: int = 2000 + max_tool_output_chars: int = 3000 max_tool_result_chars: int = 3000 # Per-tool result truncation for LLM context + # code_search: best-effort seamless ripgrep auto-install (macOS/Homebrew, no + # sudo) when missing; always falls back to the pure-Python search + a manual + # install hint, so search works with zero install regardless. + tools_ripgrep_autoinstall: bool = True + tools_test_command: str = "" # empty => auto-detect (pytest/npm/go/cargo) + tools_lint_command: str = "" # empty => auto-detect (ruff/eslint/go vet/clippy) + tools_terminal_session_enabled: bool = False # persistent shell sessions (opt-in, high risk) + tools_verify_edits: bool = True # post-edit syntax check (advisory) for edit_file/file_write + agent_validate_tool_args: bool = True # pre-execution required-argument validation + self-repair context_hard_limit_ratio: float = 0.92 context_warning_ratio: float = 0.75 tool_evidence_max_chars: int = 1200 repeated_read_limit: int = 2 long_task_convergence_round: int = 12 + # Adaptive convergence ceiling: on high-difficulty tasks the effective + # convergence round scales up (convergence_round * (1 + difficulty * + # convergence_scale)), bounded by this ceiling so genuinely stuck tasks + # still eventually converge. + convergence_round_ceiling: int = 40 + convergence_scale: float = 2.0 + # Shell safety ceiling: the internal shell-run process timeout is clamped + # here so individual shell calls can safely run beyond 2 minutes. + max_shell_timeout_s: float = 300.0 context_expanded_ratio: float = 0.60 context_finalizing_ratio: float = 0.90 context_expanded_evidence_threshold: int = 2 @@ -334,6 +382,24 @@ class Settings: error_transient_max_retries: int = 3 error_rate_limit_base_delay: float = 5.0 max_consecutive_tool_failures: int = 3 + # Per-turn recovery budget (bounds recovery attempts within one agent turn). + # A non-positive deadline means unlimited wall-clock time so a long-running + # task is never denied recovery for a late transient error; the action-count + # budget remains the real bound and scales for long tasks. + recovery_turn_deadline_s: float = 0.0 + recovery_total_actions: int = 24 + recovery_max_retry_per_category: int = 4 + + # ── Tool-loop Guardrails ── + # Progress-aware loop guards (repetition / stagnation / single-tool + # domination). Halts and finalize nudges are suppressed while the task is + # still making progress, so legitimate batch/sequential work on a long task + # is not cut short. Thresholds are configurable; the guard can be disabled. + guardrail_enabled: bool = True + guardrail_max_repeats: int = 3 + guardrail_max_consecutive_same: int = 8 + guardrail_stagnation_window: int = 10 + guardrail_min_success_rate: float = 0.2 # ── Session Persistence ── session_persistence_enabled: bool = True @@ -351,6 +417,12 @@ class Settings: default_tool_timeout_s: float = 120.0 # Default per-tool execution timeout daemon_request_ledger_ttl_s: float = 600.0 # Replay cache retention for completed engine requests daemon_request_ledger_max_entries: int = 128 # Maximum completed engine requests kept for replay + # Concurrent turn execution (Stage 3). N=3 lets several fresh TUI sessions + # run concurrently by default on isolated per-session engines (turns within + # one session stay serialized). Set to 1 for strict serialized fallback. + daemon_max_concurrent_turns: int = 3 + daemon_max_live_sessions: int = 16 + daemon_session_idle_ttl_s: float = 1800.0 circuit_breaker_threshold: int = 5 # Consecutive failures before circuit opens circuit_breaker_cooldown_s: float = 60.0 # Circuit breaker cooldown period @@ -731,6 +803,29 @@ def _build_settings_from_env( react_max_iterations = int(os.getenv("LEAPFLOW_REACT_MAX_ITERATIONS", "20")) react_soft_limit = int(os.getenv("LEAPFLOW_REACT_SOFT_LIMIT", "14")) react_warning_threshold = int(os.getenv("LEAPFLOW_REACT_WARNING_THRESHOLD", "10")) + agent_iter_floor = int(os.getenv("LEAPFLOW_AGENT_ITER_FLOOR", "12")) + agent_iter_ceiling = int(os.getenv("LEAPFLOW_AGENT_ITER_CEILING", "200")) + agent_budget_scale_k = float(os.getenv("LEAPFLOW_AGENT_BUDGET_SCALE_K", "1.0")) + agent_iter_hard_cap = int(os.getenv("LEAPFLOW_AGENT_ITER_HARD_CAP", "500")) + agent_iter_extension_step = int(os.getenv("LEAPFLOW_AGENT_ITER_EXTENSION_STEP", "25")) + agent_stall_rounds = int(os.getenv("LEAPFLOW_AGENT_STALL_ROUNDS", "6")) + agent_cost_ceiling_context_multiple = float(os.getenv("LEAPFLOW_AGENT_COST_CEILING_CONTEXT_MULTIPLE", "0.0")) + agent_subagent_max_depth = int(os.getenv("LEAPFLOW_AGENT_SUBAGENT_MAX_DEPTH", "2")) + agent_subagent_max_concurrent = int(os.getenv("LEAPFLOW_AGENT_SUBAGENT_MAX_CONCURRENT", "3")) + agent_subagent_max_iterations = int(os.getenv("LEAPFLOW_AGENT_SUBAGENT_MAX_ITERATIONS", "15")) + agent_max_parallel_tools = int(os.getenv("LEAPFLOW_AGENT_MAX_PARALLEL_TOOLS", "8")) + agent_subagent_full_loop = os.getenv("LEAPFLOW_AGENT_SUBAGENT_FULL_LOOP", "0").strip().lower() in ("1", "true", "yes") + agent_calibration_enabled = os.getenv("LEAPFLOW_AGENT_CALIBRATION_ENABLED", "0").strip().lower() in ("1", "true", "yes") + agent_calibration_min_confidence = float(os.getenv("LEAPFLOW_AGENT_CALIBRATION_MIN_CONFIDENCE", "0.3")) + agent_calibration_interval_turns = int(os.getenv("LEAPFLOW_AGENT_CALIBRATION_INTERVAL_TURNS", "0")) + agent_compression_writeback = os.getenv("LEAPFLOW_AGENT_COMPRESSION_WRITEBACK", "0").strip().lower() in ("1", "true", "yes") + agent_reentry_enabled = os.getenv("LEAPFLOW_AGENT_REENTRY_ENABLED", "0").strip().lower() in ("1", "true", "yes") + agent_reentry_tick_seconds = float(os.getenv("LEAPFLOW_AGENT_REENTRY_TICK_SECONDS", "30")) + agent_reentry_global_budget = int(os.getenv("LEAPFLOW_AGENT_REENTRY_GLOBAL_BUDGET", "100")) + agent_reentry_send_enabled = os.getenv("LEAPFLOW_AGENT_REENTRY_SEND_ENABLED", "0").strip().lower() in ("1", "true", "yes") + agent_reentry_send_rate_per_hour = int(os.getenv("LEAPFLOW_AGENT_REENTRY_SEND_RATE_PER_HOUR", "4")) + agent_reentry_send_global_budget = int(os.getenv("LEAPFLOW_AGENT_REENTRY_SEND_GLOBAL_BUDGET", "50")) + agent_reentry_send_verified_at = int(os.getenv("LEAPFLOW_AGENT_REENTRY_SEND_VERIFIED_AT", "3")) tool_max_iterations = int(os.getenv("LEAPFLOW_TOOL_MAX_ITERATIONS", "30")) native_tool_calling_enabled = os.getenv("LEAPFLOW_NATIVE_TOOL_CALLING_ENABLED", "1").strip().lower() in ("1", "true", "yes") stream_output = os.getenv("LEAPFLOW_STREAM_OUTPUT", "1").strip().lower() in ("1", "true", "yes") @@ -739,13 +834,22 @@ def _build_settings_from_env( # Context Compression compress_threshold = int(os.getenv("LEAPFLOW_COMPRESS_THRESHOLD", "16")) compress_keep_tail = int(os.getenv("LEAPFLOW_COMPRESS_KEEP_TAIL", "4")) - max_tool_output_chars = int(os.getenv("LEAPFLOW_MAX_TOOL_OUTPUT_CHARS", "2000")) + max_tool_output_chars = int(os.getenv("LEAPFLOW_MAX_TOOL_OUTPUT_CHARS", "3000")) max_tool_result_chars = int(os.getenv("LEAPFLOW_MAX_TOOL_RESULT_CHARS", "3000")) + tools_ripgrep_autoinstall = os.getenv("LEAPFLOW_TOOLS_RIPGREP_AUTOINSTALL", "1").strip().lower() in ("1", "true", "yes") + tools_test_command = os.getenv("LEAPFLOW_TOOLS_TEST_COMMAND", "").strip() + tools_lint_command = os.getenv("LEAPFLOW_TOOLS_LINT_COMMAND", "").strip() + tools_terminal_session_enabled = os.getenv("LEAPFLOW_TOOLS_TERMINAL_SESSION_ENABLED", "0").strip().lower() in ("1", "true", "yes") + tools_verify_edits = os.getenv("LEAPFLOW_TOOLS_VERIFY_EDITS", "1").strip().lower() in ("1", "true", "yes") + agent_validate_tool_args = os.getenv("LEAPFLOW_AGENT_VALIDATE_TOOL_ARGS", "1").strip().lower() in ("1", "true", "yes") context_hard_limit_ratio = float(os.getenv("LEAPFLOW_CONTEXT_HARD_LIMIT_RATIO", "0.92")) context_warning_ratio = float(os.getenv("LEAPFLOW_CONTEXT_WARNING_RATIO", "0.75")) tool_evidence_max_chars = int(os.getenv("LEAPFLOW_TOOL_EVIDENCE_MAX_CHARS", "1200")) repeated_read_limit = int(os.getenv("LEAPFLOW_REPEATED_READ_LIMIT", "2")) long_task_convergence_round = int(os.getenv("LEAPFLOW_LONG_TASK_CONVERGENCE_ROUND", "12")) + convergence_round_ceiling = int(os.getenv("LEAPFLOW_CONVERGENCE_ROUND_CEILING", "40")) + convergence_scale = float(os.getenv("LEAPFLOW_CONVERGENCE_SCALE", "2.0")) + max_shell_timeout_s = float(os.getenv("LEAPFLOW_MAX_SHELL_TIMEOUT_S", "300.0")) context_expanded_ratio = float(os.getenv("LEAPFLOW_CONTEXT_EXPANDED_RATIO", "0.60")) context_finalizing_ratio = float(os.getenv("LEAPFLOW_CONTEXT_FINALIZING_RATIO", "0.90")) context_expanded_evidence_threshold = int(os.getenv("LEAPFLOW_CONTEXT_EXPANDED_EVIDENCE_THRESHOLD", "2")) @@ -757,6 +861,14 @@ def _build_settings_from_env( error_transient_max_retries = int(os.getenv("LEAPFLOW_ERROR_TRANSIENT_MAX_RETRIES", "3")) error_rate_limit_base_delay = float(os.getenv("LEAPFLOW_ERROR_RATE_LIMIT_BASE_DELAY", "5.0")) max_consecutive_tool_failures = int(os.getenv("LEAPFLOW_MAX_CONSECUTIVE_TOOL_FAILURES", "3")) + recovery_turn_deadline_s = float(os.getenv("LEAPFLOW_RECOVERY_TURN_DEADLINE_S", "0")) + recovery_total_actions = int(os.getenv("LEAPFLOW_RECOVERY_TOTAL_ACTIONS", "24")) + recovery_max_retry_per_category = int(os.getenv("LEAPFLOW_RECOVERY_MAX_RETRY_PER_CATEGORY", "4")) + guardrail_enabled = os.getenv("LEAPFLOW_GUARDRAIL_ENABLED", "1").strip().lower() in ("1", "true", "yes") + guardrail_max_repeats = int(os.getenv("LEAPFLOW_GUARDRAIL_MAX_REPEATS", "3")) + guardrail_max_consecutive_same = int(os.getenv("LEAPFLOW_GUARDRAIL_MAX_CONSECUTIVE_SAME", "8")) + guardrail_stagnation_window = int(os.getenv("LEAPFLOW_GUARDRAIL_STAGNATION_WINDOW", "10")) + guardrail_min_success_rate = float(os.getenv("LEAPFLOW_GUARDRAIL_MIN_SUCCESS_RATE", "0.2")) # Session Persistence session_persistence_enabled = _bool("LEAPFLOW_SESSION_PERSISTENCE_ENABLED", "true") @@ -774,6 +886,9 @@ def _build_settings_from_env( default_tool_timeout_s = float(os.getenv("LEAPFLOW_DEFAULT_TOOL_TIMEOUT_S", "120.0")) daemon_request_ledger_ttl_s = float(os.getenv("LEAPFLOW_DAEMON_REQUEST_LEDGER_TTL_S", "600.0")) daemon_request_ledger_max_entries = int(os.getenv("LEAPFLOW_DAEMON_REQUEST_LEDGER_MAX_ENTRIES", "128")) + 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")) 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")) @@ -1014,6 +1129,29 @@ def _build_settings_from_env( react_max_iterations=react_max_iterations, react_soft_limit=react_soft_limit, react_warning_threshold=react_warning_threshold, + agent_iter_floor=agent_iter_floor, + agent_iter_ceiling=agent_iter_ceiling, + agent_budget_scale_k=agent_budget_scale_k, + agent_iter_hard_cap=agent_iter_hard_cap, + agent_iter_extension_step=agent_iter_extension_step, + agent_stall_rounds=agent_stall_rounds, + agent_cost_ceiling_context_multiple=agent_cost_ceiling_context_multiple, + agent_subagent_max_depth=agent_subagent_max_depth, + agent_subagent_max_concurrent=agent_subagent_max_concurrent, + agent_subagent_max_iterations=agent_subagent_max_iterations, + agent_max_parallel_tools=agent_max_parallel_tools, + agent_subagent_full_loop=agent_subagent_full_loop, + agent_calibration_enabled=agent_calibration_enabled, + agent_calibration_min_confidence=agent_calibration_min_confidence, + agent_calibration_interval_turns=agent_calibration_interval_turns, + agent_compression_writeback=agent_compression_writeback, + agent_reentry_enabled=agent_reentry_enabled, + agent_reentry_tick_seconds=agent_reentry_tick_seconds, + agent_reentry_global_budget=agent_reentry_global_budget, + agent_reentry_send_enabled=agent_reentry_send_enabled, + agent_reentry_send_rate_per_hour=agent_reentry_send_rate_per_hour, + agent_reentry_send_global_budget=agent_reentry_send_global_budget, + agent_reentry_send_verified_at=agent_reentry_send_verified_at, tool_max_iterations=tool_max_iterations, native_tool_calling_enabled=native_tool_calling_enabled, stream_output=stream_output, @@ -1023,11 +1161,20 @@ def _build_settings_from_env( compress_keep_tail=compress_keep_tail, max_tool_output_chars=max_tool_output_chars, max_tool_result_chars=max_tool_result_chars, + tools_ripgrep_autoinstall=tools_ripgrep_autoinstall, + tools_test_command=tools_test_command, + tools_lint_command=tools_lint_command, + tools_terminal_session_enabled=tools_terminal_session_enabled, + tools_verify_edits=tools_verify_edits, + agent_validate_tool_args=agent_validate_tool_args, context_hard_limit_ratio=context_hard_limit_ratio, context_warning_ratio=context_warning_ratio, tool_evidence_max_chars=tool_evidence_max_chars, repeated_read_limit=repeated_read_limit, long_task_convergence_round=long_task_convergence_round, + convergence_round_ceiling=convergence_round_ceiling, + convergence_scale=convergence_scale, + max_shell_timeout_s=max_shell_timeout_s, context_expanded_ratio=context_expanded_ratio, context_finalizing_ratio=context_finalizing_ratio, context_expanded_evidence_threshold=context_expanded_evidence_threshold, @@ -1038,6 +1185,14 @@ def _build_settings_from_env( error_transient_max_retries=error_transient_max_retries, error_rate_limit_base_delay=error_rate_limit_base_delay, max_consecutive_tool_failures=max_consecutive_tool_failures, + recovery_turn_deadline_s=recovery_turn_deadline_s, + recovery_total_actions=recovery_total_actions, + recovery_max_retry_per_category=recovery_max_retry_per_category, + guardrail_enabled=guardrail_enabled, + guardrail_max_repeats=guardrail_max_repeats, + guardrail_max_consecutive_same=guardrail_max_consecutive_same, + guardrail_stagnation_window=guardrail_stagnation_window, + guardrail_min_success_rate=guardrail_min_success_rate, # Session Persistence session_persistence_enabled=session_persistence_enabled, # Multi-Provider LLM @@ -1052,6 +1207,9 @@ def _build_settings_from_env( default_tool_timeout_s=default_tool_timeout_s, daemon_request_ledger_ttl_s=daemon_request_ledger_ttl_s, daemon_request_ledger_max_entries=daemon_request_ledger_max_entries, + 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, 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 e0ef072..7aaa6e5 100644 --- a/src/leapflow/config_service.py +++ b/src/leapflow/config_service.py @@ -126,6 +126,46 @@ class ConfigSnapshot: "dashboard.token_ref": "Secret ref for the local dashboard access token.", "stream.output": "Stream assistant tokens and progress in interactive sessions.", "verbose.progress": "Show inline execution progress for tools and runtime steps.", + "agent.iter_floor": "Baseline iteration cap per task; the adaptive loop starts here and widens with difficulty.", + "agent.iter_ceiling": "Maximum iterations a hard task can earn via adaptive-depth (difficulty-scaled) budget widening, before any progress-gated extension.", + "agent.budget_scale_k": "Slope mapping task difficulty [0,1] to iterations between the floor and ceiling.", + "agent.iter_hard_cap": "Absolute iteration backstop. A productively-unfinished task (open ledger questions + ongoing progress, within resource limits) extends past the elastic ceiling toward this cap, so long tasks are bounded by progress/resources rather than a fixed count.", + "agent.iter_extension_step": "Iterations granted per progress-gated extension when the elastic ceiling is reached and the task is still productively unfinished.", + "agent.stall_rounds": "Consecutive no-progress rounds (no new ledger findings/questions or evidence) after which budget extension stops and the loop is allowed to converge.", + "recovery.turn_deadline_s": "Wall-clock deadline (seconds) for recovery attempts within one agent turn; 0 = unlimited so a long-running task is never denied recovery for a late transient error (the action-count budget remains the bound).", + "recovery.total_actions": "Maximum total recovery actions (retries/transforms/failovers) within one agent turn before recovery halts.", + "recovery.max_retry_per_category": "Maximum recovery retries per error category within one agent turn.", + "guardrail.enabled": "Enable tool-loop guardrails (repetition / stagnation / single-tool domination). Progress-aware: halts and finalize nudges are suppressed while the task is still making progress, so long productive tasks are not cut short.", + "guardrail.max_repeats": "Consecutive identical tool calls (same name + arguments) that trigger a loop halt — only when the task is also stalled.", + "guardrail.max_consecutive_same": "Consecutive uses of the same tool that trigger a diversify nudge (suppressed while progressing, so batch/sequential work is not penalized).", + "guardrail.stagnation_window": "Window of recent genuine tool results over which the low-success-rate stagnation warning is computed.", + "guardrail.min_success_rate": "Minimum tool success rate within the stagnation window before a stagnation warning is emitted.", + "tools.ripgrep_autoinstall": "Best-effort seamless ripgrep auto-install for code_search when missing (macOS/Homebrew, no sudo, background, non-fatal). code_search always works via the pure-Python fallback regardless; disabling this just skips the accelerator install and shows a manual hint.", + "tools.test_command": "Explicit command for the test_run tool (empty => auto-detect pytest/npm/go/cargo from project markers).", + "tools.lint_command": "Explicit command for the lint_check tool (empty => auto-detect ruff/eslint/go vet/clippy from project markers).", + "tools.terminal_session_enabled": "Enable persistent terminal sessions (terminal_open/send/read/close/list). Off by default: a persistent shell runs arbitrary interactive input; enabling is the operator opt-in. Sessions are bounded (max count, idle TTL) with process-group cleanup.", + "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).", + "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.", + "agent.subagent_max_concurrent": "Maximum concurrent child subagents per delegation batch.", + "agent.subagent_max_iterations": "Iteration budget for each delegated subagent's tool loop.", + "agent.subagent_full_loop": "Run delegated subagents through the engine's full adaptive OODA loop on an isolated child frame (progressive disclosure, compression, recovery, research ledger) instead of the lightweight loop; state-isolated and depth-gated (default off).", + "agent.calibration_enabled": "Enable S3-L3 online difficulty calibration: apply the offline S3-L2 report's bounded suggested weight scale to the difficulty->budget sensitivity (scale_k), derived from the baseline and clamped/reversible (default off).", + "agent.calibration_min_confidence": "Minimum calibration-report confidence required before an online difficulty-weight adjustment is applied (guards against acting on thin data).", + "agent.calibration_interval_turns": "Re-run online difficulty/threshold calibration every N root turns as outcome data accumulates (0 = one-shot at startup only; requires calibration enabled).", + "agent.compression_writeback": "Persist structural context compression back into the loop's message history so append-only frozen segments stay byte-stable across rounds (continuous prefix-cache reuse). Opt-in; the recent raw tail is preserved (default off).", + "agent.reentry_enabled": "Enable event-driven re-entry: allow tasks to register a resume trigger (schedule_reentry) that seeds a future run from the saved orientation (default off).", + "agent.reentry_tick_seconds": "How often (seconds) the daemon dispatches due re-entry triggers as isolated subagents (only when reentry is enabled).", + "agent.reentry_global_budget": "Lifetime cap on total autonomous re-entries per daemon (backstops runaway loops across all triggers; 0 = unlimited).", + "agent.reentry_send_enabled": "Enable governed autonomous outbound delivery from re-entry: allow a completed re-entry to reply to its originating chat, gated by send-scope Progressive Trust + ApprovalGate (default off; deny-by-default without trust or an approver).", + "agent.reentry_send_rate_per_hour": "Max autonomous outbound sends per originating chat per hour (0 = unlimited); backstops send storms.", + "agent.reentry_send_global_budget": "Lifetime cap on total autonomous outbound sends per daemon (0 = unlimited).", + "agent.reentry_send_verified_at": "Number of human approvals in a send scope before it reaches VERIFIED trust and may be auto-approved (non-destructive replies only).", } _SECTION_CATEGORIES = { @@ -157,6 +197,7 @@ class ConfigSnapshot: "react": "Execution Loop", "tool": "Execution Loop", "context": "Execution Loop", + "agent": "Execution Loop", "stream": "Interactive UX", "verbose": "Interactive UX", "signal": "Signal Fusion", @@ -174,6 +215,7 @@ class ConfigSnapshot: } _PARTIAL_RELOAD_SECTIONS = frozenset({"runtime", "mock", "gateway", "hub", "scheduler", "observer", "cua", "use", "dashboard"}) +_RESTART_REQUIRED_SECTIONS = frozenset({"daemon"}) _PROFILE_FILE_BY_SECTION = { "llm": "llm.yaml", @@ -360,7 +402,13 @@ def set(self, key: str, value: object, *, scope: ConfigScope = "profile") -> Con section[spec.name] = coerced data[spec.section] = section _write_yaml_atomic(path, data) - return ConfigMutationResult(True, f"Updated {normalized}", (normalized,), path) + return ConfigMutationResult( + True, + f"Updated {normalized}", + (normalized,), + path, + _restart_warnings(spec), + ) def unset(self, key: str, *, scope: ConfigScope = "profile") -> ConfigMutationResult: normalized = _normalize_key(key) @@ -375,7 +423,13 @@ def unset(self, key: str, *, scope: ConfigScope = "profile") -> ConfigMutationRe section.pop(spec.name, None) data[spec.section] = section _write_yaml_atomic(path, data) - return ConfigMutationResult(True, f"Unset {normalized}", (normalized,), path) + return ConfigMutationResult( + True, + f"Unset {normalized}", + (normalized,), + path, + _restart_warnings(spec), + ) def configure_llm( self, @@ -484,16 +538,31 @@ def _field_view(self, spec: ConfigFieldSpec) -> ConfigFieldView: def _with_metadata(spec: ConfigFieldSpec) -> ConfigFieldSpec: + if spec.section in _RESTART_REQUIRED_SECTIONS: + reload_semantics = "restart-required" + elif spec.section in _PARTIAL_RELOAD_SECTIONS: + reload_semantics = "partial" + else: + reload_semantics = "yes" return replace( spec, category=_category_for_spec(spec), description=_FIELD_DESCRIPTIONS.get(spec.key, _default_description(spec.key)), value_hint=_VALUE_HINTS.get(spec.key, _default_value_hint(spec)), - hot_reload="partial" if spec.section in _PARTIAL_RELOAD_SECTIONS else "yes", + hot_reload=reload_semantics, examples=_examples_for_key(spec.key), ) +def _restart_warnings(spec: ConfigFieldSpec) -> tuple[str, ...]: + if spec.hot_reload != "restart-required": + return () + return ( + f"{spec.key} requires `leap daemon restart` to take effect; " + "the running leapd keeps its previous value until restarted.", + ) + + def _category_for_spec(spec: ConfigFieldSpec) -> str: if spec.key.startswith("runtime."): return "Runtime" diff --git a/src/leapflow/daemon/_service_helpers.py b/src/leapflow/daemon/_service_helpers.py new file mode 100644 index 0000000..1ff2c15 --- /dev/null +++ b/src/leapflow/daemon/_service_helpers.py @@ -0,0 +1,203 @@ +"""Pure utility functions extracted from service.py to keep the orchestrator slim.""" +from __future__ import annotations + +import logging +from typing import Any, TYPE_CHECKING + +from leapflow.engine import StreamEvent +from leapflow.memory.protocol import MemoryEntry + +if TYPE_CHECKING: + from pathlib import Path + +logger = logging.getLogger(__name__) + + +# ── Stream event helpers ───────────────────────────────────────────── + +def normalize_stream_event(event: object) -> StreamEvent: + """Coerce an arbitrary engine event into a StreamEvent.""" + if isinstance(event, StreamEvent): + return event + return StreamEvent(type="chunk", content=str(event), metadata=None) + + +def memory_entry_to_dict(entry: MemoryEntry) -> dict[str, Any]: + """Serialize a MemoryEntry to a JSON-friendly dict.""" + return { + "entry_id": entry.entry_id, + "kind": entry.kind.value, + "domain": entry.domain.value, + "content": entry.content, + "timestamp": entry.timestamp, + "score": entry.score, + "metadata": dict(entry.metadata), + } + + +# ── Engine / context metadata ──────────────────────────────────────── + +def engine_context_metadata(engine: Any | None, settings: Any) -> dict[str, Any]: + """Return safe context-budget metadata for daemon status and stream events.""" + context_length = max(0, int(getattr(settings, "llm_context_length", 0) or 0)) + metadata: dict[str, Any] = { + "llm_context_length": context_length, + "context_used": 0, + } + if engine is None: + return metadata + metadata["context_used"] = max(0, int(getattr(engine, "context_token_count", 0) or 0)) + snapshot = getattr(engine, "context_budget_snapshot", {}) + if callable(snapshot): + snapshot = snapshot() + if isinstance(snapshot, dict) and snapshot: + safe_snapshot = dict(snapshot) + if safe_snapshot.get("context_length"): + metadata["llm_context_length"] = max(1, int(safe_snapshot["context_length"])) + if safe_snapshot.get("total_tokens") is not None: + metadata["context_used"] = max(0, int(safe_snapshot["total_tokens"])) + posture = safe_snapshot.get("context_posture") + if posture: + metadata["context_posture"] = str(posture) + signal = safe_snapshot.get("context_signal") + if signal: + metadata["context_signal"] = str(signal) + guidance = safe_snapshot.get("context_guidance") + if guidance: + metadata["context_guidance"] = str(guidance) + for key in ( + "compression_reason", + "compression_savings_ratio", + "compression_saved_tokens", + "disclosure_level", + "disclosure_reason", + "disclosure", + ): + if safe_snapshot.get(key) is not None: + metadata[key] = safe_snapshot[key] + metadata["context_budget_snapshot"] = safe_snapshot + return metadata + + +def host_backend_status(ctx: Any | None) -> dict[str, Any]: + """Inspect daemon host-backend state for status reporting.""" + if ctx is None: + return {"backend": "none", "started": False, "reason": "runtime_not_initialized"} + rpc = getattr(ctx, "rpc", None) + snapshot = getattr(rpc, "status_snapshot", None) + if callable(snapshot): + try: + return dict(snapshot()) + except Exception as exc: + return {"backend": type(rpc).__name__, "started": False, "last_error": str(exc)} + return { + "backend": type(rpc).__name__ if rpc is not None else "none", + "started": rpc is not None, + "pid": None, + "pid_source": "unavailable", + } + + +def persisted_session_workspace(engine: Any, session_id: str) -> str: + """Return the workspace a session was first created in, if persisted.""" + store = getattr(engine, "_conversation_store", None) + if store is None: + return "" + try: + session = store.get_session(session_id) + except Exception: + return "" + return str(getattr(session, "cwd", "") or "") if session is not None else "" + + +def checkpoint_open_connection(ctx: Any) -> None: + """Issue a DuckDB CHECKPOINT before daemon shutdown.""" + holder = getattr(ctx, "_db_holder", None) + conn = getattr(holder, "_conn", None) + if conn is None: + return + try: + conn.execute("CHECKPOINT") + except Exception: + logger.debug("daemon: DuckDB checkpoint skipped", exc_info=True) + + +def runtime_source() -> str: + import leapflow + return str(getattr(leapflow, "__file__", "")) + + +def runtime_version() -> str: + try: + from leapflow.version import __version__ + except ImportError: + return "unknown" + return str(__version__) + + +# ── Notification wiring ────────────────────────────────────────────── + +def install_learn_notifications(ctx: Any, bus: Any) -> None: + """Wire session learn-progress/completion callbacks to a NotificationBus.""" + def _on_progress(stage: str, current: int, total: int) -> None: + bus.emit_event( + "teach.progress", + phase=stage, + current=current, + total=total, + progress=current / total if total > 0 else 0.0, + ) + + def _on_complete(result: Any) -> None: + payload: dict[str, Any] = {"phase": "done"} + if result: + payload["step_count"] = getattr(result, "step_count", 0) + payload["duration"] = getattr(result, "duration", 0.0) + candidates = getattr(result, "candidates", None) or [] + payload["candidate_count"] = len(candidates) + activated = getattr(result, "activated_skill_names", None) or set() + payload["activated_skills"] = list(activated) + new = getattr(result, "new_skills", None) or [] + payload["new_skills"] = list(new) + bus.emit_event("teach.complete", **payload) + + if ctx.session: + ctx.session.set_on_learn_progress(_on_progress) + if hasattr(ctx.session, "set_on_learn_complete"): + ctx.session.set_on_learn_complete(_on_complete) + + original_on_idle = ctx.session._on_idle_timeout + + def _on_idle_with_notification() -> None: + bus.emit_event("teach.stopped", reason="idle_timeout") + original_on_idle() + + ctx.session._on_idle_timeout = _on_idle_with_notification + + +# ── ProducerServices facade (used by monitor_coordinator) ──────────── + +class ProducerServices: + """Facade exposing daemon capabilities to monitor producers (session, etc.).""" + + 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 analyze_session( + self, + messages: list[dict[str, Any]], + *, + prior: dict[str, Any] | None = None, + artifacts: list[dict[str, Any]] | None = None, + ) -> dict[str, Any]: + return await self._service._session_coordinator.analyze_llm( + self._service._ctx, messages, artifacts=artifacts + ) + + async def should_refresh(self, messages: list[dict[str, Any]]) -> bool: + return await self._service._session_coordinator.should_refresh( + self._service._ctx, messages + ) diff --git a/src/leapflow/daemon/approval_coordinator.py b/src/leapflow/daemon/approval_coordinator.py new file mode 100644 index 0000000..0880af6 --- /dev/null +++ b/src/leapflow/daemon/approval_coordinator.py @@ -0,0 +1,226 @@ +"""Approval lifecycle coordinator extracted from RuntimeLeapService.""" +from __future__ import annotations + +import asyncio +import logging +import time +import uuid +from typing import Any + +from leapflow.daemon.protocol import StreamChunk + +logger = logging.getLogger(__name__) + + +class ApprovalCoordinator: + """Manages daemon approval lifecycle: pending queue, resolution, TTL cleanup.""" + + def __init__(self, ttl_s: float = 1800.0) -> None: + self._approval_pending: dict[str, dict[str, Any]] = {} + self._ttl_s = ttl_s + + def install_gate(self, ctx: Any, service: Any) -> None: + """Install the daemon-mode approval gate on ctx. + + *service* is the owning RuntimeLeapService (needed by _DaemonApprovalGate + to route approval requests back through the coordinator). + """ + try: + from leapflow.security.approval import SessionAwareGate + from leapflow.security.actions import ActionDescriptor + from leapflow.security.orchestrator import ApprovalOrchestrator + from leapflow.tools.gateway_tool import set_gateway_approval_gate + from leapflow.tools.registry_bootstrap import set_file_read_gate, set_file_write_gate + from leapflow.tools.shell_tools import set_approval_gate + + existing = getattr(ctx, "_approval_orchestrator", None) + gate = SessionAwareGate(_DaemonApprovalGate(self)) + orchestrator = ApprovalOrchestrator( + gate, + grants=getattr(existing, "grants", None), + audit=getattr(existing, "audit", None), + ) + ctx._approval_gate = gate + ctx._approval_orchestrator = orchestrator + set_approval_gate(orchestrator) + set_gateway_approval_gate(orchestrator) + + class _FileReadGate: + def __init__(self) -> None: + self.denial_message = "" + + async def check( + self, + path: str, + mode: str = "raw", + sensitivity_meta: dict | None = None, + ) -> bool: + result = await orchestrator.evaluate( + ActionDescriptor.file_read(path, mode=mode, metadata=dict(sensitivity_meta or {})) + ) + self.denial_message = result.denial_message if not result.approved else "" + return result.approved + + class _FileWriteGate: + def __init__(self) -> None: + self.denial_message = "" + + async def check( + self, + path: str, + content: str, + mode: str = "overwrite", + sensitivity_meta: dict | None = None, + ) -> bool: + result = await orchestrator.evaluate( + ActionDescriptor.file_write(path, content, mode=mode, metadata=dict(sensitivity_meta or {})) + ) + self.denial_message = result.denial_message if not result.approved else "" + return result.approved + + set_file_read_gate(_FileReadGate()) + set_file_write_gate(_FileWriteGate()) + logger.debug("daemon approval gate installed") + except Exception: + logger.debug("daemon approval gate installation skipped", exc_info=True) + + async def request_approval(self, request: Any, route: "tuple[asyncio.Queue[StreamChunk], str] | None") -> str: + """Block until approval decision; called from tool execution. + + *route* is the per-turn (queue, request_id) tuple from the ContextVar. + """ + if route is None: + return "deny" + queue, active_request_id = route + pending_id = str(getattr(request, "request_id", "") or uuid.uuid4().hex) + request_id = active_request_id or pending_id + payload = request.to_dict() + payload["pending_id"] = pending_id + payload["request_id"] = request_id + future: asyncio.Future[dict[str, Any]] = asyncio.get_running_loop().create_future() + self._approval_pending[pending_id] = { + "request": payload, + "future": future, + "queue": queue, + "created_at": time.time(), + } + await queue.put(StreamChunk( + request_id=request_id, + content="Approval required", + event_type="approval_request", + metadata={"approval": payload, "request_id": request_id}, + )) + timeout_s = 120.0 + if getattr(request, "expires_at", None): + timeout_s = max(1.0, float(request.expires_at) - time.time()) + try: + result = await asyncio.wait_for(future, timeout=timeout_s) + return str(result.get("decision") or "deny") + except TimeoutError: + return "deny" + finally: + self._approval_pending.pop(pending_id, None) + + async def resolve(self, pending_id: str, decision: str, reason: str = "") -> dict[str, Any]: + """Resolve a pending approval.""" + pending = self._approval_pending.get(pending_id) + if pending is None: + return {"ok": False, "error": f"Unknown approval request: {pending_id}"} + future = pending.get("future") + if not isinstance(future, asyncio.Future) or future.done(): + return {"ok": False, "error": f"Approval request is no longer pending: {pending_id}"} + future.set_result({"decision": self._normalize_decision(decision), "reason": reason}) + return {"ok": True, "pending_id": pending_id, "decision": self._normalize_decision(decision)} + + async def cancel(self, pending_id: str, reason: str = "cancelled") -> dict[str, Any]: + """Cancel a pending approval.""" + return await self.resolve(pending_id, "deny", reason=reason) + + def get_status(self) -> dict[str, Any]: + """Return current approval queue status.""" + return {"pending": self._pending_payloads()} + + def pending_count(self) -> int: + """Return the number of currently pending approvals.""" + return len(self._approval_pending) + + def deny_for_queue(self, queue: "asyncio.Queue[StreamChunk]", reason: str = "stream_closed") -> None: + """Deny all pending approvals bound to a specific queue.""" + for pending_id, pending in list(self._approval_pending.items()): + if pending.get("queue") is not queue: + continue + future = pending.get("future") + if isinstance(future, asyncio.Future) and not future.done(): + future.set_result({"decision": "deny", "reason": reason}) + self._approval_pending.pop(pending_id, None) + + def deny_for_request(self, request_id: str, reason: str = "turn_ended") -> None: + """Deny all pending approvals bound to a specific request. + + Called when a turn ends (normally or exceptionally) to prevent + orphaned approval futures from leaking memory indefinitely. + """ + for pending_id, pending in list(self._approval_pending.items()): + payload = pending.get("request") or {} + if payload.get("request_id") != request_id: + continue + future = pending.get("future") + if isinstance(future, asyncio.Future) and not future.done(): + future.set_result({"decision": "deny", "reason": reason}) + self._approval_pending.pop(pending_id, None) + + def prune_stale(self, ttl_s: float | None = None) -> int: + """Remove approvals older than TTL. Returns count removed.""" + if ttl_s is None: + ttl_s = self._ttl_s + now = time.time() + pruned = 0 + for pending_id, pending in list(self._approval_pending.items()): + created_at = pending.get("created_at", now) + if (now - created_at) < ttl_s: + continue + future = pending.get("future") + if isinstance(future, asyncio.Future) and not future.done(): + future.set_result({"decision": "deny", "reason": "timeout"}) + self._approval_pending.pop(pending_id, None) + pruned += 1 + if pruned: + logger.info("daemon: pruned %d stale approval(s)", pruned) + return pruned + + def _pending_payloads(self) -> list[dict[str, Any]]: + return [dict(item.get("request") or {}) for item in self._approval_pending.values()] + + @staticmethod + def _normalize_decision(decision: str) -> str: + allowed = { + "allow", + "allow_once", + "allow_session", + "allow_always", + "deny", + "deny_always", + "cancel_workflow", + } + value = str(decision or "deny").strip().lower() + return value if value in allowed else "deny" + + +class _DaemonApprovalGate: + """Approval gate that bridges daemon-side actions to thin clients.""" + + def __init__(self, coordinator: ApprovalCoordinator) -> None: + self._coordinator = coordinator + + async def request_approval(self, request: Any) -> Any: + from leapflow.security.approval import ApprovalDecision + + # Import ContextVar from shared module (avoids circular dep with service). + from leapflow.daemon.approval_route import approval_route as _approval_route + + route = _approval_route.get() + decision = await self._coordinator.request_approval(request, route) + try: + return ApprovalDecision(decision) + except ValueError: + return ApprovalDecision.DENY diff --git a/src/leapflow/daemon/approval_route.py b/src/leapflow/daemon/approval_route.py new file mode 100644 index 0000000..0498a50 --- /dev/null +++ b/src/leapflow/daemon/approval_route.py @@ -0,0 +1,15 @@ +"""Shared ContextVar for per-turn approval routing. + +Extracted to its own module to avoid circular dependency between +service.py and approval_coordinator.py. +""" +from __future__ import annotations + +import asyncio +import contextvars +from typing import Any + +# Per-turn approval routing: (queue, active_request_id) or None. +approval_route: contextvars.ContextVar[ + "tuple[asyncio.Queue[Any], str] | None" +] = contextvars.ContextVar("leapd_approval_route", default=None) diff --git a/src/leapflow/daemon/client.py b/src/leapflow/daemon/client.py index 7d17f99..100e8c2 100644 --- a/src/leapflow/daemon/client.py +++ b/src/leapflow/daemon/client.py @@ -62,11 +62,27 @@ async def engine_chat( message: str, *, enable_thinking: bool = False, + session_id: str = "", + workspace_root: str = "", ) -> AsyncIterator[StreamEvent]: - """Stream chat events from the daemon-owned AgentEngine.""" + """Stream chat events from the daemon-owned AgentEngine. + + ``session_id`` routes the turn to that session's engine, so distinct + sessions (e.g. two TUI clients) run concurrently and isolated when the + daemon admits concurrency (daemon.max_concurrent_turns > 1). Empty means + the daemon's current session (single-session behavior unchanged). + ``workspace_root`` is the client process' active workspace/cwd. It is + routed to the daemon so each TUI session gets its own project context + instead of inheriting the shared daemon process cwd. + """ + params: dict[str, Any] = {"message": message, "enable_thinking": enable_thinking} + if session_id: + params["session_id"] = session_id + if workspace_root: + params["workspace_root"] = workspace_root request = RpcRequest( method="engine.chat", - params={"message": message, "enable_thinking": enable_thinking}, + params=params, ) reader, writer = await self._open() try: @@ -113,9 +129,13 @@ async def subscribe_notifications(self) -> AsyncIterator[dict[str, Any]]: finally: await _close_writer(writer) - async def engine_cancel(self) -> bool: - """Request cancellation of the daemon-owned active engine turn.""" - result = await self.request("engine.cancel") + async def engine_cancel(self, request_id: str = "") -> bool: + """Request cancellation of the daemon-owned active engine turn. + + With ``request_id`` the daemon targets that specific turn; without one it + cancels the active turn(s) (at N=1 the single running turn). + """ + result = await self.request("engine.cancel", {"request_id": request_id} if request_id else None) return bool(result) async def session_resume(self, session_id: str) -> dict[str, Any]: @@ -323,29 +343,44 @@ async def recover_daemon_client( mock_host: bool = False, status_callback: StatusCallback | None = None, ) -> DaemonClient: - """Return a usable daemon client, restarting an unhealthy daemon once.""" + """Return a usable daemon client, restarting an unresponsive daemon once.""" + runtime_dir = settings.runtime_dir try: - return await ensure_daemon_client( + client = await ensure_daemon_client( settings, mock_host=mock_host, status_callback=status_callback, ) + await _probe_daemon_status(client, timeout_s=_daemon_recovery_probe_timeout()) + return client except DaemonUnavailableError as exc: - runtime_dir = settings.runtime_dir info = DaemonInfo.discover(runtime_dir) if not info.is_running: raise - _emit(status_callback, f"Restarting unhealthy leapd (pid={info.pid})...") - result = await asyncio.to_thread(stop_daemon, runtime_dir, timeout_s=10.0) + _emit(status_callback, f"Restarting unresponsive leapd (pid={info.pid})...") + result = await asyncio.to_thread( + stop_daemon, + runtime_dir, + timeout_s=10.0, + force=True, + force_timeout_s=5.0, + on_progress=status_callback, + ) if not result.stopped: raise exc - return await ensure_daemon_client( + client = await ensure_daemon_client( settings, mock_host=mock_host, status_callback=status_callback, ) + await _probe_daemon_status(client, timeout_s=_daemon_recovery_probe_timeout()) + return client +async def _probe_daemon_status(client: DaemonClient, *, timeout_s: float) -> None: + """Verify that the daemon RPC loop responds, not just that its socket accepts.""" + await DaemonClient(client.sock_path, timeout_s=timeout_s).status() + def _event_from_params(params: dict[str, Any]) -> StreamEvent: event_type = str(params.get("event_type") or "chunk") @@ -390,6 +425,14 @@ def _daemon_start_timeout() -> float: return 30.0 +def _daemon_recovery_probe_timeout() -> float: + raw = os.getenv("LEAPFLOW_DAEMON_RECOVERY_PROBE_TIMEOUT", "3").strip() + try: + return max(0.5, float(raw)) + except ValueError: + return 3.0 + + async def _send(writer: asyncio.StreamWriter, text: str) -> None: writer.write(text.encode("utf-8") + b"\n") await writer.drain() diff --git a/src/leapflow/daemon/lease.py b/src/leapflow/daemon/lease.py index 1f69455..2b393a7 100644 --- a/src/leapflow/daemon/lease.py +++ b/src/leapflow/daemon/lease.py @@ -26,6 +26,7 @@ class ClientLeaseSnapshot: started_at: float last_seen_at: float path: Path + cwd: str = "" def default_lease_ttl_s() -> float: @@ -167,6 +168,7 @@ def _read_lease(path: Path) -> ClientLeaseSnapshot | None: started_at=float(payload.get("started_at") or 0.0), last_seen_at=float(payload.get("last_seen_at") or 0.0), path=path, + cwd=str(payload.get("cwd") or ""), ) except (OSError, json.JSONDecodeError, KeyError, TypeError, ValueError): return None diff --git a/src/leapflow/daemon/monitor_coordinator.py b/src/leapflow/daemon/monitor_coordinator.py new file mode 100644 index 0000000..3c8ce06 --- /dev/null +++ b/src/leapflow/daemon/monitor_coordinator.py @@ -0,0 +1,184 @@ +"""Manages the daemon-hosted monitor runtime (watches, findings, tickers). + +Extracted from service.py (Phase 2.2) to keep RuntimeLeapService focused on +orchestration while MonitorCoordinator owns all monitor lifecycle and RPC logic. +""" +from __future__ import annotations + +import logging +from typing import Any + +logger = logging.getLogger(__name__) + + +class MonitorCoordinator: + """Manages the daemon-hosted monitor runtime (watches, findings, tickers).""" + + def __init__(self) -> None: + self._monitors: Any | None = None + + # ── Lifecycle ───────────────────────────────────────────────────────── + + async def start(self, ctx: Any, notification_bus: Any, settings: Any) -> None: + """Build and start the monitor runtime if scheduler is enabled.""" + if not getattr(settings, "scheduler_enabled", True): + return + try: + from leapflow.monitor import MonitorManager, SessionAnalysisProducer + + bus = notification_bus + self._monitors = MonitorManager( + holder=ctx._db_holder, + emit=lambda event_type, payload: bus.emit_event(event_type, **payload), + services=self._build_services_proxy(ctx, settings), + tick_seconds=int(getattr(settings, "scheduler_tick_seconds", 120)), + grace_seconds=float(getattr(settings, "scheduler_grace_seconds", 120.0)), + ) + self._monitors.producers.register(SessionAnalysisProducer()) + setattr(ctx, "monitors", self._monitors) + await self._monitors.start() + # A fresh daemon lifetime owns no interactive clients yet, so any + # persisted client-coupled watch (e.g. a session-analysis watch left + # over from a prior run or an unclean client exit) is stale. Drop it + # so the status bar and keep-alive only reflect real active monitors. + try: + swept = self._monitors.sweep_client_coupled_watches() + if swept: + logger.info("daemon: swept %d stale client-coupled watch(es) on startup", swept) + except Exception: + logger.debug("daemon: client-coupled watch sweep failed", exc_info=True) + logger.debug("daemon: monitor runtime started") + except Exception: + logger.debug("daemon: monitor runtime start skipped", exc_info=True) + self._monitors = None + setattr(ctx, "monitors", None) + + def _build_services_proxy(self, ctx: Any, settings: Any) -> Any: + """Build the _ProducerServices proxy. + + This is deferred to the service layer via a back-reference injected + before start() is called. When no back-reference is available (e.g. + tests that set _monitors directly), returns None. + """ + # The proxy is built by the service layer and passed via + # _set_service_ref(). This method is a placeholder; the actual + # _ProducerServices is built in service.py and passed to start(). + return None + + async def stop(self) -> None: + """Stop the monitor runtime.""" + if self._monitors is not None: + try: + await self._monitors.stop() + except Exception: + logger.debug("daemon: monitor stop failed", exc_info=True) + self._monitors = None + + # ── Watch RPC operations ────────────────────────────────────────────── + + def _require_monitors(self) -> Any: + if self._monitors is None: + raise RuntimeError("monitor runtime is not available (scheduler disabled)") + return self._monitors + + async def arm(self, spec: dict[str, Any]) -> dict[str, Any]: + """Register a new watch from a spec dict.""" + from leapflow.monitor import WatchSpec + + view = await self._require_monitors().arm_watch(WatchSpec.from_dict(spec or {})) + return view.to_dict() + + async def list_watches(self) -> list[dict[str, Any]]: + """List all registered watches.""" + if self._monitors is None: + return [] + return [view.to_dict() for view in self._monitors.list_watches()] + + async def get_watch(self, watch_id: str) -> dict[str, Any]: + """Get a single watch by id.""" + view = self._require_monitors().get_watch(watch_id) + return view.to_dict() if view else {} + + async def pause(self, watch_id: str) -> dict[str, Any]: + """Pause an active watch.""" + view = self._require_monitors().pause_watch(watch_id) + return view.to_dict() if view else {} + + async def resume(self, watch_id: str) -> dict[str, Any]: + """Resume a paused watch.""" + view = self._require_monitors().resume_watch(watch_id) + return view.to_dict() if view else {} + + async def stop_watch(self, watch_id: str) -> dict[str, Any]: + """Stop a watch permanently.""" + view = self._require_monitors().stop_watch(watch_id) + return view.to_dict() if view else {} + + async def mute(self, watch_id: str, muted: bool = True) -> dict[str, Any]: + """Mute or unmute a watch.""" + view = self._require_monitors().set_muted(watch_id, bool(muted)) + return view.to_dict() if view else {} + + async def refresh(self, watch_id: str) -> dict[str, Any]: + """Manually trigger a watch run.""" + return await self._require_monitors().run_watch_once(watch_id) + + async def findings( + self, watch_id: str = "", limit: int = 50, offset: int = 0 + ) -> list[dict[str, Any]]: + """Get findings, optionally filtered by watch_id.""" + if self._monitors is None: + return [] + results = self._monitors.list_findings( + watch_id=watch_id or None, limit=int(limit), offset=int(offset) + ) + return [finding.to_dict() for finding in results] + + # ── Status / queries ────────────────────────────────────────────────── + + def has_active_watches(self) -> bool: + """Return True when any hosted watch is armed/watching (idle keep-alive).""" + monitors = self._monitors + if monitors is None: + return False + try: + return bool(monitors.has_active_watches()) + except Exception: + return False + + def get_summary(self) -> dict[str, Any]: + """Runtime summary for daemon.status().""" + monitors = self._monitors + if monitors is None: + return { + "total": 0, + "active": 0, + "standalone_active": 0, + "client_coupled_active": 0, + "active_samples": [], + } + try: + watches = [view.to_dict() for view in monitors.list_watches()] + except Exception: + logger.debug("daemon: watch summary unavailable", exc_info=True) + watches = [] + active_states = {"armed", "watching", "due", "confirming", "executing"} + active = [watch for watch in watches if str(watch.get("state", "")) in active_states] + standalone = [watch for watch in active if not bool(watch.get("client_coupled", False))] + coupled = [watch for watch in active if bool(watch.get("client_coupled", False))] + return { + "total": len(watches), + "active": len(active), + "standalone_active": len(standalone), + "client_coupled_active": len(coupled), + "active_samples": [ + { + "watch_id": str(watch.get("watch_id", "")), + "name": str(watch.get("name", "")), + "domain": str(watch.get("domain", "")), + "state": str(watch.get("state", "")), + "client_coupled": bool(watch.get("client_coupled", False)), + } + for watch in active[:5] + ], + } diff --git a/src/leapflow/daemon/protocol.py b/src/leapflow/daemon/protocol.py index 15ee5a1..daf56e2 100644 --- a/src/leapflow/daemon/protocol.py +++ b/src/leapflow/daemon/protocol.py @@ -151,12 +151,16 @@ async def signal_record(self, signal_data: Dict[str, Any]) -> Dict[str, Any]: """Record a signal (observation, action, event).""" ... - async def memory_search(self, query: str, *, limit: int = 10) -> List[Dict[str, Any]]: - """Search memory across all providers.""" + async def memory_search( + self, query: str, *, limit: int = 10, workspace_root: str = "" + ) -> List[Dict[str, Any]]: + """Search memory across all providers, scoped to the caller's workspace.""" ... - async def memory_insert(self, content: str, kind: str = "fact", **kwargs: Any) -> str: - """Insert a memory entry. Returns entry_id.""" + async def memory_insert( + self, content: str, kind: str = "fact", *, workspace_root: str = "", **kwargs: Any + ) -> str: + """Insert a memory entry tagged with the caller's workspace. Returns entry_id.""" ... async def session_create(self, **kwargs: Any) -> Dict[str, Any]: @@ -171,8 +175,8 @@ async def engine_chat(self, message: str, **kwargs: Any) -> AsyncIterator[Stream """Chat with the engine (streaming). Yields StreamChunks.""" ... - async def engine_cancel(self) -> bool: - """Cancel the currently running engine task.""" + async def engine_cancel(self, request_id: str = "") -> bool: + """Cancel the currently running engine task (optionally a specific request).""" ... async def skill_execute(self, skill_name: str, params: Dict[str, Any]) -> Dict[str, Any]: diff --git a/src/leapflow/daemon/reentry_coordinator.py b/src/leapflow/daemon/reentry_coordinator.py new file mode 100644 index 0000000..be2427b --- /dev/null +++ b/src/leapflow/daemon/reentry_coordinator.py @@ -0,0 +1,154 @@ +"""Manages the reentry driver lifecycle: background tick loop, gateway observation. + +Extracted from service.py (Phase 2.4) to keep RuntimeLeapService focused on +orchestration while ReentryCoordinator owns the reentry service lifecycle. + +Phase 3 enhancements: +- Conditional start: gated by ``agent_reentry_enabled`` setting. +- Idle-aware ticker: escalates to ``idle_interval`` when consecutive ticks + dispatch zero work; reverts to ``base_interval`` on first dispatch. +""" +from __future__ import annotations + +import asyncio +import logging +from typing import Any, Callable + +logger = logging.getLogger(__name__) + +# Consecutive zero-dispatch ticks before escalating to idle interval. +_IDLE_THRESHOLD: int = 3 + + +class ReentryCoordinator: + """Manages the reentry driver lifecycle: background tick loop, gateway observation.""" + + def __init__(self) -> None: + self._reentry_task: asyncio.Task[Any] | None = None + self._reentry_stop: asyncio.Event | None = None + self._reentry_service: Any | None = None + + async def start( + self, + ctx: Any, + settings: Any, + turn_admission: Any, + notification_bus: Any, + request_approval: Callable[..., Any] | None = None, + ) -> None: + """Start the background re-entry service (S2 N3b + N4 + N5). + + Dispatches due TIME triggers periodically and matches inbound gateway + EVENT triggers, always as *isolated subagents* (fresh context -> no + interactive-engine / working-memory / session pollution), serialized via + the turn-admission gate (exclusive with all turns). Gated by + ``agent_reentry_enabled`` (default off) plus a + global-budget backstop. Best-effort: never blocks startup. + """ + try: + store = getattr(ctx, "_reentry_store", None) + manager = getattr(ctx, "_subagent_manager", None) + if store is None or manager is None: + return + from leapflow.scheduler.reentry_service import ReentryService + + # SO3: governed proactive delivery (wired only when enabled; default off). + send_governor = None + send_fn = None + resolved_approval = None + if getattr(settings, "agent_reentry_send_enabled", False): + from leapflow.scheduler.reentry_send import SendGovernor, SendRateLimiter + from leapflow.security.send_trust import SendTrustLedger + send_governor = SendGovernor( + trust=SendTrustLedger( + verified_at=int(getattr(settings, "agent_reentry_send_verified_at", 3)), + ), + rate=SendRateLimiter( + per_hour=int(getattr(settings, "agent_reentry_send_rate_per_hour", 4)), + ), + enabled=True, + global_budget=int(getattr(settings, "agent_reentry_send_global_budget", 50)), + ) + gw = getattr(ctx, "gateway_server", None) + send_fn = getattr(gw, "send_message", None) if gw is not None else None + resolved_approval = request_approval + + service = ReentryService( + store=store, + manager=manager, + settings=settings, + engine_lock=turn_admission.exclusive_gate(), + notify=lambda event_type, **kw: notification_bus.emit_event(event_type, **kw), + global_budget=int(getattr(settings, "agent_reentry_global_budget", 100) or 0), + send_governor=send_governor, + send_fn=send_fn, + request_approval=resolved_approval, + ) + self._reentry_service = service + # N4: observe inbound gateway messages for EVENT-trigger matches. + try: + setattr(ctx, "_reentry_event_observer", service.on_gateway_message) + except Exception: + logger.debug("daemon: reentry event observer wiring failed", exc_info=True) + + base_interval = max(5.0, float(getattr(settings, "agent_reentry_tick_seconds", 30.0) or 30.0)) + idle_interval = max( + base_interval, + float(getattr(settings, "agent_reentry_idle_tick_seconds", base_interval * 4) or base_interval * 4), + ) + self._reentry_stop = asyncio.Event() + + async def _loop() -> None: + stop = self._reentry_stop + assert stop is not None + idle_count = 0 + while not stop.is_set(): + # Adaptive interval: use longer sleep when idle + interval = idle_interval if idle_count >= _IDLE_THRESHOLD else base_interval + try: + await asyncio.wait_for(stop.wait(), timeout=interval) + break # stop signalled + except (asyncio.TimeoutError, TimeoutError): + pass + if stop.is_set(): + break + try: + dispatched = await service.tick() + if dispatched: + idle_count = 0 + else: + idle_count += 1 + except Exception: + logger.debug("reentry service tick failed", exc_info=True) + idle_count += 1 + + self._reentry_task = asyncio.create_task(_loop(), name="leapd-reentry-driver") + logger.debug( + "daemon: re-entry service started (base=%.0fs, idle=%.0fs)", + base_interval, idle_interval, + ) + except Exception: + logger.debug("daemon: re-entry service start skipped", exc_info=True) + + async def stop(self) -> None: + """Stop the reentry driver and wait for task completion.""" + if self._reentry_stop is not None: + self._reentry_stop.set() + if self._reentry_task is not None: + try: + await asyncio.wait_for(self._reentry_task, timeout=5.0) + except (asyncio.TimeoutError, TimeoutError): + self._reentry_task.cancel() + except Exception: + logger.debug("daemon: reentry task stop failed", exc_info=True) + self._reentry_task = None + self._reentry_stop = None + + def is_running(self) -> bool: + """Check if reentry driver is active.""" + return self._reentry_task is not None and not self._reentry_task.done() + + @property + def service(self) -> Any: + """Access the underlying ReentryService (may be None).""" + return self._reentry_service diff --git a/src/leapflow/daemon/server.py b/src/leapflow/daemon/server.py index 0c2973e..52f910a 100644 --- a/src/leapflow/daemon/server.py +++ b/src/leapflow/daemon/server.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio +import contextvars import json import logging import os @@ -193,9 +194,21 @@ async def _dispatch_stream( ) -> None: stream = None pending: asyncio.Task | None = None + # Pin every per-chunk task driving this one stream to a single, shared + # Context. asyncio.create_task() defaults to copying the *current* + # context on each call, so re-creating ``pending`` per chunk (needed + # below to interleave heartbeats via asyncio.wait(..., timeout=...)) + # would otherwise hand the streamed generator a fresh Context every + # time it resumes. A ContextVar.set()/reset() pair inside that + # generator (e.g. the daemon's per-turn approval routing) binds its + # token to the Context active at set()-time; resetting it from a + # different Context object raises "was created in a different + # Context". Sharing one Context across all chunks keeps such + # set()/reset() pairs valid for the whole life of the stream. + ctx = contextvars.copy_context() try: stream = method(**params) - pending = asyncio.create_task(anext(stream)) + pending = asyncio.create_task(anext(stream), context=ctx) while True: done, _ = await asyncio.wait({pending}, timeout=self._stream_heartbeat_s) if not done: @@ -214,7 +227,24 @@ async def _dispatch_stream( metadata=chunk.metadata, ).to_notification() await _write_json(writer, notification.to_json()) - pending = asyncio.create_task(anext(stream)) + pending = asyncio.create_task(anext(stream), context=ctx) + except (ConnectionResetError, BrokenPipeError) as exc: + # Client vanished mid-stream (e.g. TUI closed while a heartbeat + # or chunk write was in flight). This is routine churn — log a + # single debug line, no traceback, and skip the error response + # since the pipe is already gone. + if pending is not None and not pending.done(): + pending.cancel() + if stream is not None and hasattr(stream, "aclose"): + try: + await stream.aclose() + except Exception: + logger.debug("daemon: failed to close stream after disconnect", exc_info=True) + logger.debug( + "daemon: client disconnected during stream method=%s (%s)", + request.method, type(exc).__name__, + ) + return except Exception as exc: if pending is not None and not pending.done(): pending.cancel() diff --git a/src/leapflow/daemon/service.py b/src/leapflow/daemon/service.py index c907340..c1fce56 100644 --- a/src/leapflow/daemon/service.py +++ b/src/leapflow/daemon/service.py @@ -1,12 +1,15 @@ -"""Runtime-backed LeapService implementation for leapd.""" +"""Runtime-backed LeapService implementation for leapd. + +This module is the lightweight orchestrator: it assembles coordinators, manages +lifecycle, and delegates domain work. Pure utility logic lives in +``_service_helpers``. +""" from __future__ import annotations import asyncio import inspect -import json import logging import os -import re import sys import time import uuid @@ -14,34 +17,63 @@ from pathlib import Path from typing import Any +from leapflow.daemon._service_helpers import ( + ProducerServices as _ProducerServices, + checkpoint_open_connection, + engine_context_metadata, + host_backend_status, + install_learn_notifications, + memory_entry_to_dict, + normalize_stream_event, + persisted_session_workspace, + runtime_source, + runtime_version, +) +from leapflow.daemon.approval_coordinator import ApprovalCoordinator +from leapflow.daemon.approval_route import approval_route as _approval_route from leapflow.daemon.lease import ClientLeaseSnapshot +from leapflow.daemon.monitor_coordinator import MonitorCoordinator from leapflow.daemon.protocol import StreamChunk +from leapflow.daemon.reentry_coordinator import ReentryCoordinator +from leapflow.daemon.session_coordinator import SessionCoordinator +from leapflow.daemon.turn_admission import TurnAdmission from leapflow.engine import StreamEvent -from leapflow.memory.protocol import MemoryEntry, MemoryQuery +from leapflow.memory.protocol import MemoryQuery logger = logging.getLogger(__name__) -_MAX_SESSION_ARTIFACTS = 5 -_MAX_SESSION_ARTIFACT_CHARS = 6000 -_MAX_SESSION_ARTIFACT_TOTAL_CHARS = 16000 -_PATH_RE = re.compile(r'(?Ppath|file_path)["\'=:\s]+(?P[^"\'\n|]+)') +# Per-turn approval routing ContextVar — imported from shared module to avoid +# circular dependency with approval_coordinator. See approval_route.py. class RuntimeLeapService: """LeapService implementation backed by a single initialized Context.""" + # Max time a turn waits for deferred init before degrading to + # critical-only mode (the background init keeps running). + _DEFERRED_WAIT_TIMEOUT_S: float = 15.0 + + # ── Construction ───────────────────────────────────────────────── + def __init__(self, settings: Any, *, mock_host: bool = False) -> None: self._settings = settings self._mock_host = mock_host self._ctx: Any | None = None - self._monitors: Any | None = None - self._engine_lock = asyncio.Lock() + self._monitor_coordinator = MonitorCoordinator() + self._reentry_coordinator = ReentryCoordinator() + self._deferred_init_task: "asyncio.Task[Any] | None" = None + self._turn_admission = TurnAdmission( + int(getattr(settings, "daemon_max_concurrent_turns", 3) or 3) + ) + self._session_coordinator = SessionCoordinator() self._started_at = time.time() self._client_count: Callable[[], int] = lambda: 0 self._client_leases: Callable[[], list[ClientLeaseSnapshot]] = lambda: [] - self._approval_pending: dict[str, dict[str, Any]] = {} - self._approval_event_queue: asyncio.Queue[StreamChunk] | None = None + self._approval_coordinator = ApprovalCoordinator( + ttl_s=float(getattr(settings, "daemon_approval_ttl_s", 1800.0) or 1800.0) + ) self._active_engine_request_id: str = "" + self._active_engines: dict[str, Any] = {} self._engine_request_ledger: dict[str, dict[str, Any]] = {} self._request_ledger_ttl_s = max(1.0, float(getattr(settings, "daemon_request_ledger_ttl_s", 600.0) or 600.0)) self._request_ledger_max_entries = max(1, int(getattr(settings, "daemon_request_ledger_max_entries", 128) or 128)) @@ -50,13 +82,13 @@ def __init__(self, settings: Any, *, mock_host: bool = False) -> None: self.notification_bus = NotificationBus() def set_client_count_provider(self, provider: Callable[[], int]) -> None: - """Set a lightweight callback used by status reporting.""" self._client_count = provider def set_client_lease_provider(self, provider: Callable[[], list[ClientLeaseSnapshot]]) -> None: - """Set a callback used to report live client leases.""" self._client_leases = provider + # ── Lifecycle ──────────────────────────────────────────────────── + async def start(self) -> None: """Initialize the daemon-owned runtime once.""" if self._ctx is not None: @@ -64,92 +96,64 @@ async def start(self) -> None: from leapflow.cli.context import Context ctx = Context(self._settings, self._mock_host) - await ctx.initialize() - self._install_daemon_approval(ctx) - self._install_learn_notifications(ctx) + await ctx.initialize_critical() + self._approval_coordinator.install_gate(ctx, self) + install_learn_notifications(ctx, self.notification_bus) self._ctx = ctx - await self._start_monitors(ctx) - - async def _start_monitors(self, ctx: Any) -> None: - """Build and start the daemon-hosted monitor runtime (watches).""" + self._deferred_init_task = asyncio.create_task(self._run_deferred_init(ctx)) + # Monitor: start only when scheduler is enabled (coordinator checks internally) settings = getattr(ctx, "settings", self._settings) - if not getattr(settings, "scheduler_enabled", True): - return - try: - from leapflow.monitor import MonitorManager, SessionAnalysisProducer - - bus = self.notification_bus - self._monitors = MonitorManager( - holder=ctx._db_holder, - emit=lambda event_type, payload: bus.emit_event(event_type, **payload), - services=_ProducerServices(self), - tick_seconds=int(getattr(settings, "scheduler_tick_seconds", 60)), - grace_seconds=float(getattr(settings, "scheduler_grace_seconds", 120.0)), + self._monitor_coordinator._build_services_proxy = lambda c, s: _ProducerServices(self) + if getattr(settings, "scheduler_enabled", True): + await self._monitor_coordinator.start(ctx, self.notification_bus, settings) + + # Reentry: start only when explicitly enabled + if getattr(settings, "agent_reentry_enabled", False): + await self._reentry_coordinator.start( + ctx, self._settings, self._turn_admission, self.notification_bus, + request_approval=self._request_approval, ) - self._monitors.producers.register(SessionAnalysisProducer()) - setattr(ctx, "monitors", self._monitors) - await self._monitors.start() - # A fresh daemon lifetime owns no interactive clients yet, so any - # persisted client-coupled watch (e.g. a session-analysis watch left - # over from a prior run or an unclean client exit) is stale. Drop it - # so the status bar and keep-alive only reflect real active monitors. + + async def shutdown(self) -> None: + if self._ctx is None: + return + ctx = self._ctx + self._ctx = None + # Stop background deferred init first: its yield points allow cleanup + # to interleave with a half-initialized context otherwise. + task = self._deferred_init_task + if task is not None and not task.done(): + task.cancel() try: - swept = self._monitors.sweep_client_coupled_watches() - if swept: - logger.info("daemon: swept %d stale client-coupled watch(es) on startup", swept) - except Exception: - logger.debug("daemon: client-coupled watch sweep failed", exc_info=True) - logger.debug("daemon: monitor runtime started") - except Exception: - logger.debug("daemon: monitor runtime start skipped", exc_info=True) - self._monitors = None - setattr(ctx, "monitors", None) + await task + except (asyncio.CancelledError, Exception): + pass + self._deferred_init_task = None + # The service-level task above only *waits* (shielded) on the + # context's runner task; cancel the runner itself so deferred init + # actually stops before cleanup proceeds. + runner = getattr(ctx, "_deferred_task", None) + if runner is not None and not runner.done(): + runner.cancel() + try: + await runner + except (asyncio.CancelledError, Exception): + pass + await self._reentry_coordinator.stop() + await self._monitor_coordinator.stop() + checkpoint_open_connection(ctx) + await ctx.cleanup() - def has_active_watches(self) -> bool: - """Return True when any hosted watch is armed/watching (idle keep-alive).""" - monitors = self._monitors - if monitors is None: - return False - try: - return bool(monitors.has_active_watches()) - except Exception: - return False - - def _watch_runtime_summary(self) -> dict[str, Any]: - monitors = self._monitors - if monitors is None: - return { - "total": 0, - "active": 0, - "standalone_active": 0, - "client_coupled_active": 0, - "active_samples": [], - } + async def _run_deferred_init(self, ctx: Any) -> None: + """Background non-critical initialization.""" try: - watches = [view.to_dict() for view in monitors.list_watches()] + # This task is created before the RPC server task in serve_daemon(). + # Yield once so the control-plane socket can start accepting status + # and recovery RPCs before the heavier deferred phases begin. + await asyncio.sleep(0) + await ctx._ensure_deferred() except Exception: - logger.debug("daemon: watch summary unavailable", exc_info=True) - watches = [] - active_states = {"armed", "watching", "due", "confirming", "executing"} - active = [watch for watch in watches if str(watch.get("state", "")) in active_states] - standalone = [watch for watch in active if not bool(watch.get("client_coupled", False))] - coupled = [watch for watch in active if bool(watch.get("client_coupled", False))] - return { - "total": len(watches), - "active": len(active), - "standalone_active": len(standalone), - "client_coupled_active": len(coupled), - "active_samples": [ - { - "watch_id": str(watch.get("watch_id", "")), - "name": str(watch.get("name", "")), - "domain": str(watch.get("domain", "")), - "state": str(watch.get("state", "")), - "client_coupled": bool(watch.get("client_coupled", False)), - } - for watch in active[:5] - ], - } + logger.warning("Deferred initialization failed; components will init on first use", exc_info=True) @property def context(self) -> Any: @@ -158,40 +162,89 @@ def context(self) -> Any: raise RuntimeError("leapd runtime is not initialized") return self._ctx - async def signal_record(self, signal_data: dict[str, Any]) -> dict[str, Any]: - ctx = self.context - event_type = str(signal_data.get("type") or "daemon.signal") - payload = dict(signal_data.get("payload") or {}) - await ctx.event_bus.handle_event(event_type, payload) - return {"ok": True} + # ── Backward-compat properties ─────────────────────────────────── - async def memory_search(self, query: str, *, limit: int = 10) -> list[dict[str, Any]]: - ctx = self.context - memory_query = MemoryQuery(keywords=query.split()[:8], limit=limit) - results = await ctx.memory.search(memory_query) - return [self._memory_entry_to_dict(item) for item in results] + @property + def _monitors(self) -> Any | None: + """Backward-compat: used by test fixtures that inject MonitorManager directly. - async def memory_insert(self, content: str, kind: str = "fact", **kwargs: Any) -> str: - ctx = self.context - metadata = dict(kwargs.get("metadata") or {}) - entry_id = ctx.lt.ingest(kind, content, metadata=metadata) - return str(entry_id) + Prefer MonitorCoordinator API for new code. + """ + return self._monitor_coordinator._monitors - async def session_create(self, **kwargs: Any) -> dict[str, Any]: - ctx = self.context - session_id = getattr(ctx.session, "session_id", "") if ctx.session else "" - return {"session_id": str(session_id), "created": bool(session_id), **kwargs} + @_monitors.setter + def _monitors(self, value: Any) -> None: + self._monitor_coordinator._monitors = value - async def session_resume(self, session_id: str) -> dict[str, Any]: - ctx = self.context - engine = getattr(ctx, "engine", None) - found = bool(engine and engine.load_session(session_id)) - current = getattr(engine, "_current_session_id", "") if engine else "" - return {"found": found, "session_id": str(current or session_id)} + @property + def _session_registry(self) -> Any: + """Backward-compat: used by test fixtures that inject SessionRegistry directly. + + Prefer SessionCoordinator API for new code. + """ + return self._session_coordinator._session_registry + + @_session_registry.setter + def _session_registry(self, value: Any) -> None: + self._session_coordinator._session_registry = value + + # ── Core execution: engine_chat ────────────────────────────────── async def engine_chat(self, message: str, **kwargs: Any) -> AsyncIterator[StreamChunk]: request_id = str(kwargs.get("request_id") or uuid.uuid4().hex[:12]) - async with self._engine_lock: + workspace_arg = str(kwargs.get("workspace_root") or "").strip() + + # Ensure deferred init completed. Emit a status chunk first so the + # server dispatch loop receives an immediate first chunk (keepalive + # heartbeats start right away) before the potentially long wait. + ctx = self._ctx + if ctx is not None and not getattr(ctx, '_deferred_initialized', True): + yield StreamChunk( + event_type="status", + content="Warming up runtime components...", + request_id=request_id, + ) + try: + # Bounded wait: _ensure_deferred() shields the background + # runner task, so a timeout here cancels only this wait — + # deferred init keeps running in the background. + await asyncio.wait_for( + ctx._ensure_deferred(), timeout=self._DEFERRED_WAIT_TIMEOUT_S, + ) + except asyncio.TimeoutError: + logger.info( + "Deferred init still in progress after %.0fs; serving turn " + "in critical-only mode", self._DEFERRED_WAIT_TIMEOUT_S, + ) + except Exception: + logger.warning( + "Deferred init failed; serving turn in critical-only mode", + exc_info=True, + ) + + # Busy-feedback for clients when all slots occupied + if self._turn_admission.locked(): + admission = self._turn_admission_status(queued_delta=1) + active = int(admission.get("active", 0) or 0) + cap = int(admission.get("max_concurrent", 1) or 1) + waiting = int(admission.get("waiting", 0) or 0) + yield StreamChunk( + request_id=request_id, + content=( + f"leapd turn capacity is full ({active}/{cap}); " + f"your request is waiting for a daemon slot (waiting:{waiting}). " + "To raise the cap, run `leap config set daemon.max_concurrent_turns N` " + "then `leap daemon restart`. Use `/cancel` to interrupt running work." + ), + event_type="status", + metadata={ + "request_id": request_id, "queued": True, + "active_request_id": self._active_engine_request_id or "", + "turn_admission": admission, + }, + ) + + async with self._turn_admission.turn_slot(): self._prune_engine_request_ledger() existing = self._engine_request_ledger.get(request_id) if existing and existing.get("status") == "completed": @@ -200,10 +253,8 @@ async def engine_chat(self, message: str, **kwargs: Any) -> AsyncIterator[Stream metadata = dict(chunk.metadata or {}) metadata["replayed_request"] = True yield StreamChunk( - request_id=request_id, - content=chunk.content, - done=chunk.done, - event_type=chunk.event_type, + request_id=request_id, content=chunk.content, + done=chunk.done, event_type=chunk.event_type, metadata=metadata, ) return @@ -216,11 +267,7 @@ async def engine_chat(self, message: str, **kwargs: Any) -> AsyncIterator[Stream ) return - request_record: dict[str, Any] = { - "status": "running", - "chunks": [], - "created_at": time.time(), - } + request_record: dict[str, Any] = {"status": "running", "chunks": [], "created_at": time.time()} self._engine_request_ledger[request_id] = request_record ctx = self.context try: @@ -231,448 +278,258 @@ async def engine_chat(self, message: str, **kwargs: Any) -> AsyncIterator[Stream content="Configuration reloaded in leapd.", event_type="status", metadata={ - **self._engine_context_metadata(getattr(ctx, "engine", None), ctx.settings), + **engine_context_metadata(getattr(ctx, "engine", None), ctx.settings), "llm_model": getattr(ctx.settings, "llm_model", ""), "request_id": request_id, }, ) request_record["chunks"].append(chunk) yield chunk + engine = getattr(ctx, "engine", None) if engine is None: raise RuntimeError("leapd engine is not initialized") + # Route turn to session engine (isolated per-session engines prevent + # cross-contamination; primary session reuses base engine). + session_id = str(kwargs.get("session_id") or "") + workspace_root = workspace_arg or str(self._workspace_root()) + if workspace_arg and not session_id: + session_id = request_id + + session_lock: asyncio.Lock | None = None + if session_id: + from leapflow.daemon.session_registry import WorkspaceMismatchError + try: + if workspace_arg: + persisted_root = persisted_session_workspace(engine, session_id) + if persisted_root: + expected = Path(persisted_root).expanduser().resolve() + requested = Path(workspace_arg).expanduser().resolve() + if expected != requested: + raise WorkspaceMismatchError(session_id, expected, requested) + exec_ctx = await self._ensure_session_registry(engine).acquire( + session_id, workspace_root=workspace_root, + ) + except WorkspaceMismatchError as exc: + chunk = StreamChunk( + request_id=request_id, content=str(exc), event_type="error", + metadata={ + "request_id": request_id, "workspace_mismatch": True, + "session_id": exc.session_id, + "expected_workspace_root": str(exc.expected), + "requested_workspace_root": str(exc.requested), + }, + ) + request_record["chunks"].append(chunk) + request_record["status"] = "failed" + request_record["completed_at"] = time.time() + yield chunk + return + engine = exec_ctx.engine + session_lock = exec_ctx.lock + if getattr(engine, "_current_session_id", None) != session_id: + engine._current_session_id = session_id + + # Serialize turns within one session + if session_lock is not None: + await session_lock.acquire() + enable_thinking = bool(kwargs.get("enable_thinking", False)) approval_queue: asyncio.Queue[StreamChunk] = asyncio.Queue() - previous_queue = self._approval_event_queue previous_request_id = self._active_engine_request_id - self._approval_event_queue = approval_queue + route_token = _approval_route.set((approval_queue, request_id)) self._active_engine_request_id = request_id + self._active_engines[request_id] = engine try: sig = inspect.signature(engine.run_stream) if "request_id" in sig.parameters: - stream = engine.run_stream( - message, - enable_thinking=enable_thinking, - request_id=request_id, - ) + stream = engine.run_stream(message, enable_thinking=enable_thinking, request_id=request_id) else: - stream = engine.run_stream( - message, - enable_thinking=enable_thinking, - ) - async for chunk in self._stream_engine_events( - stream, approval_queue, request_id=request_id, - ): + stream = engine.run_stream(message, enable_thinking=enable_thinking) + async for chunk in self._stream_engine_events(stream, approval_queue, request_id=request_id): request_record["chunks"].append(chunk) yield chunk request_record["status"] = "completed" request_record["completed_at"] = time.time() self._prune_engine_request_ledger() finally: - self._approval_event_queue = previous_queue + _approval_route.reset(route_token) self._active_engine_request_id = previous_request_id + self._active_engines.pop(request_id, None) + self._approval_coordinator.deny_for_request(request_id, reason="turn_ended") + if session_lock is not None: + session_lock.release() except Exception: request_record["status"] = "failed" request_record["completed_at"] = time.time() self._prune_engine_request_ledger() raise - async def engine_cancel(self) -> bool: + async def engine_cancel(self, request_id: str = "") -> bool: + targets: list[Any] = [] + if request_id: + eng = self._active_engines.get(request_id) + if eng is not None: + targets = [eng] + else: + targets = list(self._active_engines.values()) + if not targets: + ctx = self.context + eng = getattr(ctx, "engine", None) + if eng is not None: + targets = [eng] + cancelled = False + for eng in targets: + if eng is not None and hasattr(eng, "cancel"): + result = eng.cancel() + if hasattr(result, "__await__"): + await result + cancelled = True + return cancelled + + # ── Stream event fusion ────────────────────────────────────────── + + async def _stream_engine_events( + self, + stream: AsyncIterator[object], + approval_queue: asyncio.Queue[StreamChunk], + *, + request_id: str = "", + ) -> AsyncIterator[StreamChunk]: + engine_task: asyncio.Task[Any] | None = asyncio.create_task(anext(stream)) + approval_task: asyncio.Task[StreamChunk] | None = asyncio.create_task(approval_queue.get()) + try: + while engine_task is not None: + wait_set = {task for task in (engine_task, approval_task) if task is not None} + done, _ = await asyncio.wait(wait_set, return_when=asyncio.FIRST_COMPLETED) + if approval_task is not None and approval_task in done: + yield approval_task.result() + approval_task = asyncio.create_task(approval_queue.get()) + continue + if engine_task in done: + try: + event = engine_task.result() + except StopAsyncIteration: + engine_task = None + break + stream_event = normalize_stream_event(event) + yield self._chunk_from_event(stream_event, request_id=request_id) + engine_task = asyncio.create_task(anext(stream)) + finally: + for task in (engine_task, approval_task): + if task is not None and not task.done(): + task.cancel() + self._approval_coordinator.deny_for_queue(approval_queue, reason="stream_closed") + if hasattr(stream, "aclose"): + try: + await stream.aclose() + except Exception: + logger.debug("daemon: failed to close engine stream", exc_info=True) + + def _chunk_from_event(self, event: StreamEvent, *, request_id: str = "") -> StreamChunk: ctx = self.context engine = getattr(ctx, "engine", None) - if engine is not None and hasattr(engine, "cancel"): - result = engine.cancel() - if hasattr(result, "__await__"): - await result - return True - return False + metadata = dict(event.metadata or {}) + session_id = getattr(engine, "_current_session_id", "") if engine else "" + if request_id: + metadata.setdefault("request_id", request_id) + if session_id: + metadata.setdefault("session_id", str(session_id)) + if engine is not None: + metadata.update(engine_context_metadata(engine, getattr(ctx, "settings", self._settings))) + return StreamChunk( + request_id=request_id, content=event.content, + done=False, event_type=event.type, metadata=metadata, + ) - async def skill_execute(self, skill_name: str, params: dict[str, Any]) -> dict[str, Any]: - raise NotImplementedError("skill.execute is not available in this daemon phase") + # ── Delegate: session ──────────────────────────────────────────── - async def scheduler_arm(self, task_config: dict[str, Any]) -> str: - raise NotImplementedError("scheduler.arm is not available in this daemon phase") + async def session_create(self, **kwargs: Any) -> dict[str, Any]: + return await self._session_coordinator.create(self.context, **kwargs) - # ── Watch runtime (monitor subsystem) ─────────────────────────────── + async def session_resume(self, session_id: str) -> dict[str, Any]: + return await self._session_coordinator.resume(self.context, session_id) - def _require_monitors(self) -> Any: - if self._monitors is None: - raise RuntimeError("monitor runtime is not available (scheduler disabled)") - return self._monitors + 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 watch_arm(self, spec: dict[str, Any]) -> dict[str, Any]: - from leapflow.monitor import WatchSpec + async def session_analyze(self) -> dict[str, Any]: + return await self._session_coordinator.analyze(self._monitors, self._ctx, self._settings) - view = await self._require_monitors().arm_watch(WatchSpec.from_dict(spec or {})) - return view.to_dict() + def _ensure_session_registry(self, base_engine: Any) -> Any: + return self._session_coordinator.ensure_registry(base_engine, self._settings) + + # ── Delegate: watch (monitor subsystem) ────────────────────────── + + def has_active_watches(self) -> bool: + return self._monitor_coordinator.has_active_watches() + + def _watch_runtime_summary(self) -> dict[str, Any]: # backward-compat + return self._monitor_coordinator.get_summary() + + async def watch_arm(self, spec: dict[str, Any]) -> dict[str, Any]: + return await self._monitor_coordinator.arm(spec) async def watch_list(self) -> list[dict[str, Any]]: - if self._monitors is None: - return [] - return [view.to_dict() for view in self._monitors.list_watches()] + return await self._monitor_coordinator.list_watches() async def watch_get(self, watch_id: str) -> dict[str, Any]: - view = self._require_monitors().get_watch(watch_id) - return view.to_dict() if view else {} + return await self._monitor_coordinator.get_watch(watch_id) async def watch_pause(self, watch_id: str) -> dict[str, Any]: - view = self._require_monitors().pause_watch(watch_id) - return view.to_dict() if view else {} + return await self._monitor_coordinator.pause(watch_id) async def watch_resume(self, watch_id: str) -> dict[str, Any]: - view = self._require_monitors().resume_watch(watch_id) - return view.to_dict() if view else {} + return await self._monitor_coordinator.resume(watch_id) async def watch_stop(self, watch_id: str) -> dict[str, Any]: - view = self._require_monitors().stop_watch(watch_id) - return view.to_dict() if view else {} + return await self._monitor_coordinator.stop_watch(watch_id) async def watch_mute(self, watch_id: str, muted: bool = True) -> dict[str, Any]: - view = self._require_monitors().set_muted(watch_id, bool(muted)) - return view.to_dict() if view else {} + return await self._monitor_coordinator.mute(watch_id, muted) async def watch_refresh(self, watch_id: str) -> dict[str, Any]: - return await self._require_monitors().run_watch_once(watch_id) + return await self._monitor_coordinator.refresh(watch_id) - async def watch_findings( - self, watch_id: str = "", limit: int = 50, offset: int = 0 - ) -> list[dict[str, Any]]: - if self._monitors is None: - return [] - findings = self._monitors.list_findings( - watch_id=watch_id or None, limit=int(limit), offset=int(offset) - ) - return [finding.to_dict() for finding in findings] + async def watch_findings(self, watch_id: str = "", limit: int = 50, offset: int = 0) -> list[dict[str, Any]]: + return await self._monitor_coordinator.findings(watch_id, limit, offset) - # ── Session analysis (domain=session watch) ─────────────────────── + # ── Delegate: approval ─────────────────────────────────────────── - async def session_history(self, limit: int = 200) -> dict[str, Any]: - ctx = self._ctx - 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 "" - messages: list[dict[str, Any]] = [] - if engine is not None: - wm = getattr(engine, "_wm", None) - if wm is not None and hasattr(wm, "as_chat_messages"): - try: - messages = [dict(m) for m in wm.as_chat_messages() if isinstance(m, dict)] - except Exception: - messages = [] - store_messages = self._session_store_messages(session_id, limit=int(limit)) - if store_messages: - if not messages: - messages = store_messages - else: - messages.extend(m for m in store_messages if m.get("role") == "tool") - normalized = [ - { - "role": str(m.get("role", "")), - "content": str(m.get("content", "")), - "tool_name": str(m.get("tool_name", "") or ""), - "created_at": float(m.get("created_at", 0.0) or 0.0), - } - for m in messages - ][-int(limit):] - artifacts = self._collect_session_artifacts(session_id, store_messages or normalized) - return { - "session_id": session_id, - "turn_count": int(getattr(engine, "turn_count", 0)) if engine else 0, - "token_count": int(getattr(engine, "context_token_count", 0)) if engine else 0, - "messages": normalized, - "artifacts": artifacts, - } - - def _session_store_messages(self, session_id: str, *, limit: int = 200) -> list[dict[str, Any]]: - ctx = self._ctx - store = getattr(ctx, "_conversation_store", None) if ctx is not None else None - if store is None or not session_id: - return [] - try: - rows = store.get_messages(session_id, limit=int(limit)) - except Exception: - logger.debug("daemon: session store messages unavailable", exc_info=True) - return [] - return [self._conversation_message_to_dict(row) for row in rows] - - @staticmethod - def _conversation_message_to_dict(message: Any) -> dict[str, Any]: - if isinstance(message, dict): - return dict(message) - return { - "role": str(getattr(message, "role", "")), - "content": str(getattr(message, "content", "")), - "tool_name": str(getattr(message, "tool_name", "") or ""), - "tool_call_id": str(getattr(message, "tool_call_id", "") or ""), - "created_at": float(getattr(message, "created_at", 0.0) or 0.0), - "metadata": dict(getattr(message, "metadata", {}) or {}), - } - - def _collect_session_artifacts(self, session_id: str, messages: list[dict[str, Any]]) -> list[dict[str, Any]]: - if not session_id: - return [] - workspace = self._workspace_root() - candidates: list[tuple[str, dict[str, Any]]] = [] - for message in messages: - if str(message.get("role", "")) != "tool": - continue - tool_name = str(message.get("tool_name", "") or "") - if tool_name and tool_name not in {"file_write", "write_file"}: - continue - for path in self._extract_artifact_paths(message): - candidates.append((path, message)) - seen: set[str] = set() - artifacts: list[dict[str, Any]] = [] - total_chars = 0 - for raw_path, message in reversed(candidates): - if len(artifacts) >= _MAX_SESSION_ARTIFACTS: - break - artifact = self._read_session_artifact(raw_path, workspace, message) - key = str(artifact.get("path") or raw_path) - if key in seen: - continue - seen.add(key) - if artifact.get("status") == "included": - content = str(artifact.get("content_excerpt", "")) - remaining = max(0, _MAX_SESSION_ARTIFACT_TOTAL_CHARS - total_chars) - if len(content) > remaining: - artifact["content_excerpt"] = content[:remaining] - artifact["truncated"] = True - artifact["reason"] = "artifact context budget reached" - total_chars += len(str(artifact.get("content_excerpt", ""))) - artifacts.append(artifact) - artifacts.reverse() - return artifacts - - @staticmethod - def _extract_artifact_paths(message: dict[str, Any]) -> list[str]: - paths: list[str] = [] - payloads = [message.get("content", ""), message.get("metadata", {})] - for payload in payloads: - if isinstance(payload, dict): - for key in ("path", "file_path"): - if payload.get(key): - paths.append(str(payload[key])) - continue - text = str(payload or "") - try: - data = json.loads(text) - if isinstance(data, dict): - for key in ("path", "file_path"): - if data.get(key): - paths.append(str(data[key])) - except Exception: - pass - for match in _PATH_RE.finditer(text): - value = match.group("value").strip().strip(",}") - if value: - paths.append(value) - return paths - - def _workspace_root(self) -> Path: - ctx = self._ctx - settings = getattr(ctx, "settings", self._settings) if ctx is not None else self._settings - return Path(str(getattr(settings, "workspace_root", os.getcwd()))).expanduser().resolve() + async def approval_status(self) -> dict[str, Any]: + return self._approval_coordinator.get_status() - def _read_session_artifact(self, raw_path: str, workspace: Path, message: dict[str, Any]) -> dict[str, Any]: - target = Path(raw_path).expanduser() - if not target.is_absolute(): - target = workspace / target - try: - target = target.resolve() - except OSError: - target = target.absolute() - base = { - "path": str(target), - "name": target.name, - "source": "file_write", - "tool_call_id": str(message.get("tool_call_id", "") or ""), - "status": "skipped", - } - try: - target.relative_to(workspace) - except ValueError: - return {**base, "reason": "outside workspace boundary"} - try: - from leapflow.security.path_sensitivity import classify_path_sensitivity - sensitivity = classify_path_sensitivity(target) - except Exception: - sensitivity = None - if sensitivity is not None: - base.update({"sensitivity": sensitivity.category, "sensitivity_level": sensitivity.level}) - if not sensitivity.readable or sensitivity.requires_approval or sensitivity.redact_on_read: - return {**base, "reason": f"sensitive path ({sensitivity.category}) not read in background"} - if not target.exists() or not target.is_file(): - return {**base, "reason": "file no longer exists"} - try: - stat = target.stat() - content = target.read_text(encoding="utf-8", errors="replace") - except OSError as exc: - return {**base, "reason": f"read failed: {exc}"} - truncated = len(content) > _MAX_SESSION_ARTIFACT_CHARS - excerpt = content[:_MAX_SESSION_ARTIFACT_CHARS] - try: - from leapflow.security.redact import redact_sensitive_text - excerpt = redact_sensitive_text(excerpt, file_read=bool(getattr(sensitivity, "redact_on_read", False))) - except Exception: - pass - return { - **base, - "status": "included", - "size": int(stat.st_size), - "mtime": float(stat.st_mtime), - "content_excerpt": excerpt, - "truncated": truncated, - } + async def approval_resolve(self, pending_id: str, decision: str, reason: str = "") -> dict[str, Any]: + return await self._approval_coordinator.resolve(pending_id, decision, reason) - async def session_analyze(self) -> dict[str, Any]: - if self._monitors is None: - return {"ok": False, "error": "monitor runtime unavailable"} - watch_id = await self._ensure_session_watch() - result = await self._monitors.run_watch_once(watch_id, force=True) - return {"ok": bool(result.get("ok", True)), "watch_id": watch_id, "result": result} + async def approval_cancel(self, pending_id: str, reason: str = "cancelled") -> dict[str, Any]: + return await self._approval_coordinator.cancel(pending_id, reason) - async def _ensure_session_watch(self) -> str: - from leapflow.monitor.session_producer import ensure_session_watch, session_watch_params + async def _request_approval(self, request: Any) -> str: + """Route approval through the ContextVar-based turn routing.""" + route = _approval_route.get() + return await self._approval_coordinator.request_approval(request, route) - monitors = self._require_monitors() - settings = getattr(self._ctx, "settings", self._settings) - return await ensure_session_watch(monitors, params=session_watch_params(settings)) + def _deny_pending_for_request(self, request_id: str, reason: str = "turn_ended") -> None: + """Backward-compat delegate (used by tests).""" + self._approval_coordinator.deny_for_request(request_id, reason) - async def _analyze_session_llm( - self, - messages: list[dict[str, Any]], - *, - artifacts: list[dict[str, Any]] | None = None, - ) -> dict[str, Any]: - base: dict[str, Any] = { - "story": "", "insights": [], "decisions": [], "action_items": [], - "open_questions": [], "entities": [], "next_prompts": [], - "process_notes": [], "series_intents": [], "usage": {}, - } - ctx = self._ctx - llm = getattr(ctx, "llm", None) if ctx is not None else None - if llm is None or not messages: - return base - transcript = "\n".join( - f"{m.get('role', '')}: {str(m.get('content', ''))[:500]}" for m in messages[-40:] - )[:12000] - artifact_block = self._format_artifact_context(artifacts or []) - user_content = transcript if not artifact_block else f"{transcript}\n\n## Session file artifacts\n{artifact_block}" - prompt = [ - {"role": "system", "content": _SESSION_ANALYSIS_SYSTEM}, - {"role": "user", "content": user_content[:18000]}, - ] - try: - response = await llm.achat(prompt, stream=False) - data = _parse_session_json(getattr(response, "content", "")) - except Exception: - logger.debug("daemon: session analysis LLM call failed", exc_info=True) - return base - if isinstance(data, dict): - for key in base: - if key != "usage" and key in data: - base[key] = data[key] - return base - - @staticmethod - def _format_artifact_context(artifacts: list[dict[str, Any]]) -> str: - lines: list[str] = [] - for artifact in artifacts: - status = str(artifact.get("status", "")) - path = str(artifact.get("path", "")) - if status != "included": - lines.append(f"- SKIPPED {path}: {artifact.get('reason', 'not included')}") - continue - excerpt = str(artifact.get("content_excerpt", ""))[:_MAX_SESSION_ARTIFACT_CHARS] - truncated = " (truncated)" if artifact.get("truncated") else "" - lines.append(f"- FILE {path}{truncated}\n```text\n{excerpt}\n```") - return "\n".join(lines) + def _prune_stale_approvals(self, ttl_s: float | None = None) -> int: + """Backward-compat delegate (used by tests).""" + return self._approval_coordinator.prune_stale(ttl_s) - async def _session_should_refresh(self, messages: list[dict[str, Any]]) -> bool: - ctx = self._ctx - llm = getattr(ctx, "llm", None) if ctx is not None else None - if llm is None or not messages: - return False - tail = "\n".join( - f"{m.get('role', '')}: {str(m.get('content', ''))[:200]}" for m in messages[-6:] - )[:2000] - prompt = [ - {"role": "system", "content": _SESSION_SALIENCE_SYSTEM}, - {"role": "user", "content": tail}, - ] - try: - response = await llm.achat(prompt, stream=False) - return str(getattr(response, "content", "")).strip().upper().startswith("Y") - except Exception: - return False - - async def status(self) -> dict[str, Any]: - ctx = self._ctx - settings = getattr(ctx, "settings", self._settings) if ctx is not None else self._settings - engine = getattr(ctx, "engine", None) if ctx is not None else None - db_holder = getattr(ctx, "_db_holder", None) if ctx is not None else None - layout = settings.layout - profile_layout = settings.profile_layout - workspace_root = Path(str(getattr(settings, "workspace_root", os.getcwd()))) - workspace_config_path = layout.workspace_config_path(workspace_root) - workspace_manifest_path = layout.workspace_manifest_path(workspace_root) - context_metadata = self._engine_context_metadata(engine, settings) - watch_summary = self._watch_runtime_summary() - return { - "pid": os.getpid(), - "profile": getattr(settings, "profile", "default"), - "profile_dir": str(settings.profile_dir), - "profile_manifest_path": str(profile_layout.manifest_path), - "profile_config_dir": str(profile_layout.config_dir), - "user_config_path": str(layout.user_config_path), - "mcp_servers_path": str(layout.mcp_servers_path), - "workspace_config_path": str(workspace_config_path), - "workspace_manifest_path": str(workspace_manifest_path), - "config_sources": list(getattr(settings, "config_sources", ())), - "config_warnings": list(getattr(settings, "config_warnings", ())), - "watched_config_paths": [str(path) for path in getattr(settings, "watched_config_paths", ())], - "runtime_dir": str(getattr(settings, "runtime_dir", "")), - "tui_history_path": str(profile_layout.tui_history_path), - "cache_index_path": str(profile_layout.cache.index_path), - "secrets_scope": str(getattr(getattr(settings, "profile_manifest", None), "secrets_scope", "profile")), - "db_path": str(getattr(db_holder, "db_path", settings.duckdb_path)), - "volatile": bool(getattr(ctx, "storage_volatile", False)) if ctx is not None else False, - "uptime_s": max(0.0, time.time() - self._started_at), - "active_clients": max(0, self._client_count()), - "active_connections": max(0, self._client_count()), - "connected_clients": len(self._client_leases()), - "model": getattr(settings, "llm_model", ""), - "llm_context_length": context_metadata.get("llm_context_length", getattr(settings, "llm_context_length", 0)), - "context_used": context_metadata.get("context_used", 0), - "context_posture": context_metadata.get("context_posture", "baseline"), - "context_signal": context_metadata.get("context_signal", ""), - "context_guidance": context_metadata.get("context_guidance", ""), - "compression_reason": context_metadata.get("compression_reason", ""), - "compression_savings_ratio": context_metadata.get("compression_savings_ratio", 0.0), - "context_budget_snapshot": context_metadata.get("context_budget_snapshot", {}), - "session_id": str(getattr(engine, "_current_session_id", "") or ""), - "runtime_source": self._runtime_source(), - "runtime_executable": sys.executable, - "runtime_version": self._runtime_version(), - "pending_approvals": len(self._approval_pending), - "watch_summary": watch_summary, - "host_backend": self._host_backend_status(ctx), - } + # ── Delegate: host backend ─────────────────────────────────────── async def host_status(self) -> dict[str, Any]: - """Return daemon-owned host backend status.""" ctx = self.context - status = getattr(ctx, "host_backend_status", None) - if callable(status): - return dict(await status()) - return self._host_backend_status(ctx) + status_fn = getattr(ctx, "host_backend_status", None) + if callable(status_fn): + return dict(await status_fn()) + return host_backend_status(ctx) async def host_start(self) -> dict[str, Any]: - """Start daemon-owned CuaDriver without resetting chat state.""" - async with self._engine_lock: + async with self._turn_admission.exclusive(): ctx = self.context start = getattr(ctx, "host_backend_start", None) if not callable(start): @@ -680,8 +537,7 @@ async def host_start(self) -> dict[str, Any]: return dict(await start()) async def host_stop(self) -> dict[str, Any]: - """Stop daemon-owned CuaDriver without shutting down leapd.""" - async with self._engine_lock: + async with self._turn_admission.exclusive(): ctx = self.context stop = getattr(ctx, "host_backend_stop", None) if not callable(stop): @@ -689,154 +545,68 @@ async def host_stop(self) -> dict[str, Any]: return dict(await stop()) async def host_restart(self) -> dict[str, Any]: - """Restart daemon-owned CuaDriver without resetting chat state.""" - async with self._engine_lock: + async with self._turn_admission.exclusive(): ctx = self.context restart = getattr(ctx, "host_backend_restart", None) if not callable(restart): return {"ok": False, "started": False, "last_error": "host lifecycle is unavailable"} return dict(await restart()) + # ── Delegate: memory / signal ──────────────────────────────────── + + async def signal_record(self, signal_data: dict[str, Any]) -> dict[str, Any]: + ctx = self.context + event_type = str(signal_data.get("type") or "daemon.signal") + payload = dict(signal_data.get("payload") or {}) + await ctx.event_bus.handle_event(event_type, payload) + return {"ok": True} + + async def memory_search(self, query: str, *, limit: int = 10, workspace_root: str = "") -> list[dict[str, Any]]: + ctx = self.context + # Derive session_scope from active engine if available + engine = getattr(ctx, "engine", None) + session_scope = str(getattr(engine, "_current_session_id", "") or "") + memory_query = MemoryQuery( + keywords=query.split()[:8], + limit=limit, + workspace_root=workspace_root, + session_scope=session_scope, + ) + results = await ctx.memory.search(memory_query) + return [memory_entry_to_dict(item) for item in results] + + async def memory_insert(self, content: str, kind: str = "fact", *, workspace_root: str = "", **kwargs: Any) -> str: + ctx = self.context + metadata = dict(kwargs.get("metadata") or {}) + if workspace_root and "workspace_root" not in metadata: + try: + metadata["workspace_root"] = str(Path(workspace_root).expanduser().resolve()) + except (OSError, RuntimeError, ValueError): + metadata["workspace_root"] = workspace_root + entry_id = ctx.lt.ingest(kind, content, metadata=metadata) + return str(entry_id) + + # ── Delegate: tools / commands ─────────────────────────────────── + async def tools_list(self) -> dict[str, Any]: - """Return daemon-owned tool summary for slash-command rendering.""" from leapflow.cli.commands.slash_handlers import build_tool_payload - return build_tool_payload(self.context) async def usage_summary(self) -> dict[str, Any]: - """Return token usage for the current daemon-owned session.""" from leapflow.cli.commands.slash_handlers import build_usage_payload - return build_usage_payload(self.context) async def app_command(self, args: str = "") -> dict[str, Any]: - """Return daemon-owned App Connector slash-command payload.""" 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]: - """Execute any engine-routed slash command via unified dispatch.""" from leapflow.cli.commands.slash_handlers import command_execute - return await command_execute(self.context, name, args) - async def approval_status(self) -> dict[str, Any]: - """Return currently pending daemon approval requests.""" - return {"pending": self._pending_payloads()} - - async def approval_resolve( - self, - pending_id: str, - decision: str, - reason: str = "", - ) -> dict[str, Any]: - """Resolve a pending approval request from a thin client.""" - pending = self._approval_pending.get(pending_id) - if pending is None: - return {"ok": False, "error": f"Unknown approval request: {pending_id}"} - future = pending.get("future") - if not isinstance(future, asyncio.Future) or future.done(): - return {"ok": False, "error": f"Approval request is no longer pending: {pending_id}"} - future.set_result({"decision": self._normalize_approval_decision(decision), "reason": reason}) - return {"ok": True, "pending_id": pending_id, "decision": self._normalize_approval_decision(decision)} - - async def approval_cancel(self, pending_id: str, reason: str = "cancelled") -> dict[str, Any]: - """Cancel a pending approval request, causing the action to be denied.""" - return await self.approval_resolve(pending_id, "deny", reason=reason) - - @staticmethod - def _runtime_source() -> str: - import leapflow - - return str(getattr(leapflow, "__file__", "")) - - @staticmethod - def _runtime_version() -> str: - try: - from leapflow.version import __version__ - except ImportError: - return "unknown" - return str(__version__) - - def _engine_context_metadata(self, engine: Any | None, settings: Any) -> dict[str, Any]: - """Return safe context-budget metadata for daemon status and stream events.""" - context_length = max(0, int(getattr(settings, "llm_context_length", 0) or 0)) - metadata: dict[str, Any] = { - "llm_context_length": context_length, - "context_used": 0, - } - if engine is None: - return metadata - metadata["context_used"] = max(0, int(getattr(engine, "context_token_count", 0) or 0)) - snapshot = getattr(engine, "context_budget_snapshot", {}) - if callable(snapshot): - snapshot = snapshot() - if isinstance(snapshot, dict) and snapshot: - safe_snapshot = dict(snapshot) - if safe_snapshot.get("context_length"): - metadata["llm_context_length"] = max(1, int(safe_snapshot["context_length"])) - if safe_snapshot.get("total_tokens") is not None: - metadata["context_used"] = max(0, int(safe_snapshot["total_tokens"])) - posture = safe_snapshot.get("context_posture") - if posture: - metadata["context_posture"] = str(posture) - signal = safe_snapshot.get("context_signal") - if signal: - metadata["context_signal"] = str(signal) - guidance = safe_snapshot.get("context_guidance") - if guidance: - metadata["context_guidance"] = str(guidance) - for key in ( - "compression_reason", - "compression_savings_ratio", - "compression_saved_tokens", - "disclosure_level", - "disclosure_reason", - "disclosure", - ): - if safe_snapshot.get(key) is not None: - metadata[key] = safe_snapshot[key] - metadata["context_budget_snapshot"] = safe_snapshot - return metadata - - def _host_backend_status(self, ctx: Any | None) -> dict[str, Any]: - if ctx is None: - return {"backend": "none", "started": False, "reason": "runtime_not_initialized"} - rpc = getattr(ctx, "rpc", None) - snapshot = getattr(rpc, "status_snapshot", None) - if callable(snapshot): - try: - return dict(snapshot()) - except Exception as exc: - return {"backend": type(rpc).__name__, "started": False, "last_error": str(exc)} - return { - "backend": type(rpc).__name__ if rpc is not None else "none", - "started": rpc is not None, - "pid": None, - "pid_source": "unavailable", - } - - async def shutdown(self) -> None: - if self._ctx is None: - return - ctx = self._ctx - self._ctx = None - if self._monitors is not None: - try: - await self._monitors.stop() - except Exception: - logger.debug("daemon: monitor stop failed", exc_info=True) - self._monitors = None - self._checkpoint_open_connection(ctx) - await ctx.cleanup() + # ── Delegate: gateway (stubs) ──────────────────────────────────── - async def gateway_connect( - self, - platform: str, - credentials: dict[str, str], - options: dict[str, Any] | None = None, - ) -> dict[str, Any]: + async def gateway_connect(self, platform: str, credentials: dict[str, str], options: dict[str, Any] | None = None) -> dict[str, Any]: raise NotImplementedError("gateway.connect is not available in this daemon phase") async def gateway_disconnect(self, platform: str) -> dict[str, Any]: @@ -845,59 +615,18 @@ async def gateway_disconnect(self, platform: str) -> dict[str, Any]: async def gateway_status(self) -> list[dict[str, Any]]: raise NotImplementedError("gateway.status is not available in this daemon phase") - async def gateway_send( - self, - platform: str, - chat_id: str, - text: str, - thread_id: str = "", - ) -> dict[str, Any]: + async def gateway_send(self, platform: str, chat_id: str, text: str, thread_id: str = "") -> dict[str, Any]: raise NotImplementedError("gateway.send is not available in this daemon phase") - def _prune_engine_request_ledger(self) -> None: - """Bound completed/failed engine request replay records by TTL and size.""" - now = time.time() - for request_id, record in list(self._engine_request_ledger.items()): - status = str(record.get("status") or "") - if status == "running": - continue - completed_at = float(record.get("completed_at") or record.get("created_at") or 0.0) - if now - completed_at > self._request_ledger_ttl_s: - self._engine_request_ledger.pop(request_id, None) - overflow = len(self._engine_request_ledger) - self._request_ledger_max_entries - if overflow <= 0: - return - evictable = sorted( - ( - (float(record.get("completed_at") or record.get("created_at") or 0.0), request_id) - for request_id, record in self._engine_request_ledger.items() - if str(record.get("status") or "") != "running" - ), - key=lambda item: item[0], - ) - for _timestamp, request_id in evictable[:overflow]: - self._engine_request_ledger.pop(request_id, None) + # ── Delegate: skill / scheduler (stubs) ────────────────────────── - def _memory_entry_to_dict(self, entry: MemoryEntry) -> dict[str, Any]: - return { - "entry_id": entry.entry_id, - "kind": entry.kind.value, - "domain": entry.domain.value, - "content": entry.content, - "timestamp": entry.timestamp, - "score": entry.score, - "metadata": dict(entry.metadata), - } + async def skill_execute(self, skill_name: str, params: dict[str, Any]) -> dict[str, Any]: + raise NotImplementedError("skill.execute is not available in this daemon phase") - def _checkpoint_open_connection(self, ctx: Any) -> None: - holder = getattr(ctx, "_db_holder", None) - conn = getattr(holder, "_conn", None) - if conn is None: - return - try: - conn.execute("CHECKPOINT") - except Exception: - logger.debug("daemon: DuckDB checkpoint skipped", exc_info=True) + async def scheduler_arm(self, task_config: dict[str, Any]) -> str: + raise NotImplementedError("scheduler.arm is not available in this daemon phase") + + # ── Notifications ──────────────────────────────────────────────── async def subscribe_notifications(self) -> AsyncIterator[StreamChunk]: """Long-lived streaming RPC: yield notifications until client disconnects.""" @@ -909,321 +638,149 @@ async def subscribe_notifications(self) -> AsyncIterator[StreamChunk]: if notification is None: break yield StreamChunk( - request_id="", - content="", - event_type="status", + request_id="", content="", event_type="status", metadata=notification.to_dict(), ) finally: self.notification_bus.unsubscribe(subscriber_id) - def _install_learn_notifications(self, ctx: Any) -> None: - """Wire session progress/completion callbacks to the notification bus.""" - bus = self.notification_bus - - def _on_progress(stage: str, current: int, total: int) -> None: - bus.emit_event( - "teach.progress", - phase=stage, - current=current, - total=total, - progress=current / total if total > 0 else 0.0, - ) + # ── Status ─────────────────────────────────────────────────────── - def _on_complete(result: Any) -> None: - payload: dict[str, Any] = {"phase": "done"} - if result: - payload["step_count"] = getattr(result, "step_count", 0) - payload["duration"] = getattr(result, "duration", 0.0) - candidates = getattr(result, "candidates", None) or [] - payload["candidate_count"] = len(candidates) - activated = getattr(result, "activated_skill_names", None) or set() - payload["activated_skills"] = list(activated) - new = getattr(result, "new_skills", None) or [] - payload["new_skills"] = list(new) - bus.emit_event("teach.complete", **payload) - - if ctx.session: - ctx.session.set_on_learn_progress(_on_progress) - if hasattr(ctx.session, "set_on_learn_complete"): - ctx.session.set_on_learn_complete(_on_complete) - - # Monitor mode changes to notify TUI of idle-watchdog stops - original_on_idle = ctx.session._on_idle_timeout - - def _on_idle_with_notification() -> None: - bus.emit_event("teach.stopped", reason="idle_timeout") - original_on_idle() - - ctx.session._on_idle_timeout = _on_idle_with_notification - - def _install_daemon_approval(self, ctx: Any) -> None: - try: - from leapflow.security.approval import SessionAwareGate - from leapflow.security.actions import ActionDescriptor - from leapflow.security.orchestrator import ApprovalOrchestrator - from leapflow.tools.gateway_tool import set_gateway_approval_gate - from leapflow.tools.registry_bootstrap import set_file_read_gate, set_file_write_gate - from leapflow.tools.shell_tools import set_approval_gate - - existing = getattr(ctx, "_approval_orchestrator", None) - gate = SessionAwareGate(_DaemonApprovalGate(self)) - orchestrator = ApprovalOrchestrator( - gate, - grants=getattr(existing, "grants", None), - audit=getattr(existing, "audit", None), - ) - ctx._approval_gate = gate - ctx._approval_orchestrator = orchestrator - set_approval_gate(orchestrator) - set_gateway_approval_gate(orchestrator) - - class _FileReadGate: - def __init__(self) -> None: - self.denial_message = "" - - async def check( - self, - path: str, - mode: str = "raw", - sensitivity_meta: dict | None = None, - ) -> bool: - result = await orchestrator.evaluate( - ActionDescriptor.file_read(path, mode=mode, metadata=dict(sensitivity_meta or {})) - ) - self.denial_message = result.denial_message if not result.approved else "" - return result.approved - - class _FileWriteGate: - def __init__(self) -> None: - self.denial_message = "" - - async def check( - self, - path: str, - content: str, - mode: str = "overwrite", - sensitivity_meta: dict | None = None, - ) -> bool: - result = await orchestrator.evaluate( - ActionDescriptor.file_write(path, content, mode=mode, metadata=dict(sensitivity_meta or {})) - ) - self.denial_message = result.denial_message if not result.approved else "" - return result.approved + async def status(self) -> dict[str, Any]: + ctx = self._ctx + settings = getattr(ctx, "settings", self._settings) if ctx is not None else self._settings + engine = getattr(ctx, "engine", None) if ctx is not None else None + db_holder = getattr(ctx, "_db_holder", None) if ctx is not None else None + layout = settings.layout + profile_layout = settings.profile_layout + workspace_root = Path(str(getattr(settings, "workspace_root", os.getcwd()))) + context_metadata = engine_context_metadata(engine, settings) + self._approval_coordinator.prune_stale() + clients = await asyncio.to_thread(self._safe_client_lease_summaries) + host = await asyncio.to_thread(host_backend_status, ctx) + return { + "pid": os.getpid(), + "profile": getattr(settings, "profile", "default"), + "profile_dir": str(settings.profile_dir), + "profile_manifest_path": str(profile_layout.manifest_path), + "profile_config_dir": str(profile_layout.config_dir), + "user_config_path": str(layout.user_config_path), + "mcp_servers_path": str(layout.mcp_servers_path), + "workspace_config_path": str(layout.workspace_config_path(workspace_root)), + "workspace_manifest_path": str(layout.workspace_manifest_path(workspace_root)), + "config_sources": list(getattr(settings, "config_sources", ())), + "config_warnings": list(getattr(settings, "config_warnings", ())), + "watched_config_paths": [str(p) for p in getattr(settings, "watched_config_paths", ())], + "runtime_dir": str(getattr(settings, "runtime_dir", "")), + "tui_history_path": str(profile_layout.tui_history_path), + "cache_index_path": str(profile_layout.cache.index_path), + "secrets_scope": str(getattr(getattr(settings, "profile_manifest", None), "secrets_scope", "profile")), + "db_path": str(getattr(db_holder, "db_path", settings.duckdb_path)), + "volatile": bool(getattr(ctx, "storage_volatile", False)) if ctx is not None else False, + "uptime_s": max(0.0, time.time() - self._started_at), + "active_clients": max(0, self._client_count()), + "active_connections": max(0, self._client_count()), + "connected_clients": len(clients), + "clients": clients, + "model": getattr(settings, "llm_model", ""), + "llm_context_length": context_metadata.get("llm_context_length", getattr(settings, "llm_context_length", 0)), + "context_used": context_metadata.get("context_used", 0), + "context_posture": context_metadata.get("context_posture", "baseline"), + "context_signal": context_metadata.get("context_signal", ""), + "context_guidance": context_metadata.get("context_guidance", ""), + "compression_reason": context_metadata.get("compression_reason", ""), + "compression_savings_ratio": context_metadata.get("compression_savings_ratio", 0.0), + "context_budget_snapshot": context_metadata.get("context_budget_snapshot", {}), + "session_id": str(getattr(engine, "_current_session_id", "") or ""), + "runtime_source": runtime_source(), + "runtime_executable": sys.executable, + "runtime_version": runtime_version(), + "pending_approvals": self._approval_coordinator.pending_count(), + "turn_admission": self._turn_admission_status(), + "deferred_init": self._deferred_init_status(ctx), + "watch_summary": self._monitor_coordinator.get_summary(), + "host_backend": host, + } - set_file_read_gate(_FileReadGate()) - set_file_write_gate(_FileWriteGate()) - logger.debug("daemon approval gate installed") - except Exception: - logger.debug("daemon approval gate installation skipped", exc_info=True) + # ── Internal helpers ───────────────────────────────────────────── - async def _stream_engine_events( - self, - stream: AsyncIterator[object], - approval_queue: asyncio.Queue[StreamChunk], - *, - request_id: str = "", - ) -> AsyncIterator[StreamChunk]: - engine_task: asyncio.Task[Any] | None = asyncio.create_task(anext(stream)) - approval_task: asyncio.Task[StreamChunk] | None = asyncio.create_task(approval_queue.get()) - try: - while engine_task is not None: - wait_set = {task for task in (engine_task, approval_task) if task is not None} - done, _ = await asyncio.wait(wait_set, return_when=asyncio.FIRST_COMPLETED) - if approval_task is not None and approval_task in done: - yield approval_task.result() - approval_task = asyncio.create_task(approval_queue.get()) - continue - if engine_task in done: - try: - event = engine_task.result() - except StopAsyncIteration: - engine_task = None - break - stream_event = self._normalize_event(event) - yield self._chunk_from_event(stream_event, request_id=request_id) - engine_task = asyncio.create_task(anext(stream)) - finally: - for task in (engine_task, approval_task): - if task is not None and not task.done(): - task.cancel() - self._deny_pending_for_queue(approval_queue, reason="stream_closed") - if hasattr(stream, "aclose"): - try: - await stream.aclose() - except Exception: - logger.debug("daemon: failed to close engine stream", exc_info=True) + def _workspace_root(self) -> Path: + ctx = self._ctx + settings = getattr(ctx, "settings", self._settings) if ctx is not None else self._settings + return Path(str(getattr(settings, "workspace_root", os.getcwd()))).expanduser().resolve() - def _pending_payloads(self) -> list[dict[str, Any]]: - return [dict(item.get("request") or {}) for item in self._approval_pending.values()] + def _turn_admission_status(self, *, queued_delta: int = 0) -> dict[str, Any]: + snapshot = dict(self._turn_admission.snapshot()) + if queued_delta: + snapshot["waiting"] = max(0, int(snapshot.get("waiting", 0) or 0) + queued_delta) + snapshot["active_request_ids"] = sorted(self._active_engines) + return snapshot - async def _request_approval(self, request: Any) -> str: - queue = self._approval_event_queue - if queue is None: - return "deny" - pending_id = str(getattr(request, "request_id", "") or uuid.uuid4().hex) - request_id = self._active_engine_request_id or pending_id - payload = request.to_dict() - payload["pending_id"] = pending_id - payload["request_id"] = request_id - future: asyncio.Future[dict[str, Any]] = asyncio.get_running_loop().create_future() - self._approval_pending[pending_id] = { - "request": payload, - "future": future, - "queue": queue, - "created_at": time.time(), + def _deferred_init_status(self, ctx: Any | None) -> dict[str, Any]: + """Return a non-blocking diagnostic snapshot of deferred init state.""" + if ctx is None: + return {"initialized": False, "running": False, "attempts": 0, "max_attempts": 0} + runner = getattr(ctx, "_deferred_task", None) + service_task = self._deferred_init_task + attempts = int(getattr(ctx, "_deferred_attempts", 0) or 0) + max_attempts = int(getattr(ctx, "_DEFERRED_MAX_ATTEMPTS", 0) or 0) + running = bool( + (runner is not None and not runner.done()) + or (service_task is not None and not service_task.done()) + ) + snapshot: dict[str, Any] = { + "initialized": bool(getattr(ctx, "_deferred_initialized", False)), + "running": running, + "attempts": attempts, + "max_attempts": max_attempts, + "done": bool(runner.done()) if runner is not None else False, + "cancelled": bool(runner.cancelled()) if runner is not None else False, } - await queue.put(StreamChunk( - request_id=request_id, - content="Approval required", - event_type="approval_request", - metadata={"approval": payload, "request_id": request_id}, - )) - timeout_s = 120.0 - if getattr(request, "expires_at", None): - timeout_s = max(1.0, float(request.expires_at) - time.time()) + if runner is not None and runner.done() and not runner.cancelled(): + exc = runner.exception() + if exc is not None: + snapshot["error"] = str(exc) + if max_attempts and attempts >= max_attempts and not snapshot["initialized"]: + snapshot["degraded"] = True + return snapshot + + def _safe_client_lease_summaries(self) -> list[dict[str, Any]]: try: - result = await asyncio.wait_for(future, timeout=timeout_s) - return str(result.get("decision") or "deny") - except TimeoutError: - return "deny" - finally: - self._approval_pending.pop(pending_id, None) - - @staticmethod - def _normalize_approval_decision(decision: str) -> str: - allowed = { - "allow", - "allow_once", - "allow_session", - "allow_always", - "deny", - "deny_always", - "cancel_workflow", - } - value = str(decision or "deny").strip().lower() - return value if value in allowed else "deny" - - def _deny_pending_for_queue( - self, - queue: asyncio.Queue[StreamChunk], - *, - reason: str, - ) -> None: - for pending_id, pending in list(self._approval_pending.items()): - if pending.get("queue") is not queue: - continue - future = pending.get("future") - if isinstance(future, asyncio.Future) and not future.done(): - future.set_result({"decision": "deny", "reason": reason}) - self._approval_pending.pop(pending_id, None) + return self._client_lease_summaries() + except Exception: + logger.debug("daemon: client lease status unavailable", exc_info=True) + return [] - def _normalize_event(self, event: object) -> StreamEvent: - if isinstance(event, StreamEvent): - return event - return StreamEvent(type="chunk", content=str(event), metadata=None) + def _client_lease_summaries(self) -> list[dict[str, Any]]: + """Per-client lease view for status observability.""" + return [ + { + "client_id": snap.client_id, "pid": snap.pid, + "kind": snap.kind, "state": snap.state, + "session_id": snap.session_id, "workspace_root": snap.cwd, + } + for snap in self._client_leases() + ] - def _chunk_from_event(self, event: StreamEvent, *, request_id: str = "") -> StreamChunk: - ctx = self.context - engine = getattr(ctx, "engine", None) - metadata = dict(event.metadata or {}) - session_id = getattr(engine, "_current_session_id", "") if engine else "" - if request_id: - metadata.setdefault("request_id", request_id) - if session_id: - metadata.setdefault("session_id", str(session_id)) - if engine is not None: - metadata.update(self._engine_context_metadata(engine, getattr(ctx, "settings", self._settings))) - return StreamChunk( - request_id=request_id, - content=event.content, - done=False, - event_type=event.type, - metadata=metadata, + def _prune_engine_request_ledger(self) -> None: + """Bound completed/failed engine request replay records by TTL and size.""" + now = time.time() + for rid, record in list(self._engine_request_ledger.items()): + if str(record.get("status") or "") == "running": + continue + completed_at = float(record.get("completed_at") or record.get("created_at") or 0.0) + if now - completed_at > self._request_ledger_ttl_s: + self._engine_request_ledger.pop(rid, None) + overflow = len(self._engine_request_ledger) - self._request_ledger_max_entries + if overflow <= 0: + return + evictable = sorted( + ( + (float(r.get("completed_at") or r.get("created_at") or 0.0), rid) + for rid, r in self._engine_request_ledger.items() + if str(r.get("status") or "") != "running" + ), + key=lambda item: item[0], ) - - -_SESSION_ANALYSIS_SYSTEM = ( - "You are a session analyst writing FOR THE USER (not for the agent). Read the " - "conversation transcript and return STRICT JSON only, with keys: story (a " - "user-facing narrative of the user's goals, findings, and outcomes — NOT a " - "replay of the agent's tool calls), insights (array of {title, summary, " - "severity in [info,notable,alert], kind in [finding,process]}), decisions " - "(array of strings), action_items (array of strings), open_questions (array " - "of strings), entities (array of strings), next_prompts (array of strings), " - "process_notes (array of strings), series_intents (array of {id, label, unit, " - "kind in [line,area,ohlc,distribution]}). " - "DE-WEIGHT the agent's own mechanics: tool usage, failures, retries, auth " - "errors, and script fixes are LOW-SIGNAL process. Omit them, or fold at most " - "one into insights with kind='process' and severity='info'; never emit them " - "as decisions or action_items unless the USER must act (e.g. provide an API " - "key). Put unavoidable process remarks in process_notes. In series_intents, " - "only NAME chart-worthy quantitative series that are actually present in the " - "data (labels/units); do NOT invent numbers — numeric values are extracted " - "separately. If a Session file artifacts section is present, treat artifact " - "contents as first-class evidence. Do not wrap the JSON in prose or code fences." -) - -_SESSION_SALIENCE_SYSTEM = ( - "Answer with only YES or NO: does the latest conversation contain a new decision, " - "a topic shift, or a new action item that would justify refreshing an analysis " - "dashboard?" -) - - -def _parse_session_json(content: str) -> Any: - """Best-effort extraction of a JSON object from an LLM response.""" - import json as _json - - text = str(content or "").strip() - if text.startswith("```"): - text = text.strip("`") - if "\n" in text: - first, rest = text.split("\n", 1) - if first.strip().lower().startswith("json"): - text = rest - start, end = text.find("{"), text.rfind("}") - if start != -1 and end != -1 and end > start: - text = text[start:end + 1] - try: - return _json.loads(text) - except Exception: - return None - - -class _ProducerServices: - """Facade exposing daemon capabilities to monitor producers (session, etc.).""" - - def __init__(self, service: "RuntimeLeapService") -> None: - self._service = service - - async def session_history(self) -> dict[str, Any]: - return await self._service.session_history() - - async def analyze_session( - self, - messages: list[dict[str, Any]], - *, - prior: dict[str, Any] | None = None, - artifacts: list[dict[str, Any]] | None = None, - ) -> dict[str, Any]: - return await self._service._analyze_session_llm(messages, artifacts=artifacts) - - async def should_refresh(self, messages: list[dict[str, Any]]) -> bool: - return await self._service._session_should_refresh(messages) - - -class _DaemonApprovalGate: - """Approval gate that bridges daemon-side actions to thin clients.""" - - def __init__(self, service: RuntimeLeapService) -> None: - self._service = service - - async def request_approval(self, request: Any) -> Any: - from leapflow.security.approval import ApprovalDecision - - decision = await self._service._request_approval(request) - try: - return ApprovalDecision(decision) - except ValueError: - return ApprovalDecision.DENY + for _ts, rid in evictable[:overflow]: + self._engine_request_ledger.pop(rid, None) diff --git a/src/leapflow/daemon/session_coordinator.py b/src/leapflow/daemon/session_coordinator.py new file mode 100644 index 0000000..78afa39 --- /dev/null +++ b/src/leapflow/daemon/session_coordinator.py @@ -0,0 +1,395 @@ +"""Manages session lifecycle: create, resume, history, analysis, artifacts. + +Extracted from service.py (Phase 2.3) to keep RuntimeLeapService focused on +orchestration while SessionCoordinator owns all session management logic. +""" +from __future__ import annotations + +import json +import logging +import os +import re +from pathlib import Path +from typing import Any + +logger = logging.getLogger(__name__) + +_MAX_SESSION_ARTIFACTS = 5 +_MAX_SESSION_ARTIFACT_CHARS = 6000 +_MAX_SESSION_ARTIFACT_TOTAL_CHARS = 16000 +_PATH_RE = re.compile(r'(?Ppath|file_path)["\'=:\s]+(?P[^"\'\n|]+)') + +_SESSION_ANALYSIS_SYSTEM = ( + "You are a session analyst writing FOR THE USER (not for the agent). Read the " + "conversation transcript and return STRICT JSON only, with keys: story (a " + "user-facing narrative of the user's goals, findings, and outcomes — NOT a " + "replay of the agent's tool calls), insights (array of {title, summary, " + "severity in [info,notable,alert], kind in [finding,process]}), decisions " + "(array of strings), action_items (array of strings), open_questions (array " + "of strings), entities (array of strings), next_prompts (array of strings), " + "process_notes (array of strings), series_intents (array of {id, label, unit, " + "kind in [line,area,ohlc,distribution]}). " + "DE-WEIGHT the agent's own mechanics: tool usage, failures, retries, auth " + "errors, and script fixes are LOW-SIGNAL process. Omit them, or fold at most " + "one into insights with kind='process' and severity='info'; never emit them " + "as decisions or action_items unless the USER must act (e.g. provide an API " + "key). Put unavoidable process remarks in process_notes. In series_intents, " + "only NAME chart-worthy quantitative series that are actually present in the " + "data (labels/units); do NOT invent numbers — numeric values are extracted " + "separately. If a Session file artifacts section is present, treat artifact " + "contents as first-class evidence. Do not wrap the JSON in prose or code fences." +) + +_SESSION_SALIENCE_SYSTEM = ( + "Answer with only YES or NO: does the latest conversation contain a new decision, " + "a topic shift, or a new action item that would justify refreshing an analysis " + "dashboard?" +) + + +class SessionCoordinator: + """Manages session lifecycle: create, resume, history, analysis, artifacts.""" + + def __init__(self) -> None: + self._session_registry: Any | None = None + + # ── Registry ────────────────────────────────────────────────────────── + + def ensure_registry(self, base_engine: Any, settings: Any) -> Any: + """Lazily build the per-session engine registry around the base engine. + + The first session reuses ``base_engine`` (single-session daemon + unchanged); additional sessions get isolated engines via the P3-1 + factory with a fresh working memory of the same capacity. + """ + if self._session_registry is None: + from leapflow.daemon.session_registry import SessionRegistry + from leapflow.engine.session_factory import build_session_engine + from leapflow.memory import WorkingMemoryProvider + + base_wm = getattr(base_engine, "_wm", None) + max_tokens = int(getattr(base_wm, "_max_tokens", 8192) or 8192) + self._session_registry = SessionRegistry( + base_engine=base_engine, + build_engine=lambda base, sid, wm, workspace_root: build_session_engine( + base, + session_id=sid, + working_memory=wm, + workspace_root=workspace_root, + ), + build_working_memory=lambda: WorkingMemoryProvider(max_tokens=max_tokens), + max_sessions=int(getattr(settings, "daemon_max_live_sessions", 16) or 16), + idle_ttl_s=float(getattr(settings, "daemon_session_idle_ttl_s", 1800.0) or 1800.0), + ) + return self._session_registry + + @property + def registry(self) -> Any: + """Access the session registry (may be None if not initialized).""" + return self._session_registry + + # ── Create / Resume ─────────────────────────────────────────────────── + + async def create(self, ctx: Any, **kwargs: Any) -> dict[str, Any]: + """Create a new session.""" + session_id = getattr(ctx.session, "session_id", "") if ctx.session else "" + return {"session_id": str(session_id), "created": bool(session_id), **kwargs} + + async def resume(self, ctx: Any, session_id: str) -> dict[str, Any]: + """Resume an existing session.""" + engine = getattr(ctx, "engine", None) + found = bool(engine and engine.load_session(session_id)) + current = getattr(engine, "_current_session_id", "") if engine else "" + return {"found": found, "session_id": str(current or session_id)} + + # ── History ─────────────────────────────────────────────────────────── + + async def get_history(self, ctx: Any, settings: Any, limit: int = 200) -> dict[str, Any]: + """Get session message history with token stats.""" + 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 "" + messages: list[dict[str, Any]] = [] + if engine is not None: + wm = getattr(engine, "_wm", None) + if wm is not None and hasattr(wm, "as_chat_messages"): + try: + messages = [dict(m) for m in wm.as_chat_messages() if isinstance(m, dict)] + except Exception: + messages = [] + store_messages = self._session_store_messages(ctx, session_id, limit=int(limit)) + if store_messages: + if not messages: + messages = store_messages + else: + messages.extend(m for m in store_messages if m.get("role") == "tool") + normalized = [ + { + "role": str(m.get("role", "")), + "content": str(m.get("content", "")), + "tool_name": str(m.get("tool_name", "") or ""), + "created_at": float(m.get("created_at", 0.0) or 0.0), + } + for m in messages + ][-int(limit):] + workspace = self._workspace_root(ctx, settings) + artifacts = self._collect_session_artifacts(session_id, store_messages or normalized, workspace) + return { + "session_id": session_id, + "turn_count": int(getattr(engine, "turn_count", 0)) if engine else 0, + "token_count": int(getattr(engine, "context_token_count", 0)) if engine else 0, + "messages": normalized, + "artifacts": artifacts, + } + + # ── Analysis ────────────────────────────────────────────────────────── + + async def analyze(self, monitors: Any, ctx: Any, settings: Any) -> dict[str, Any]: + """Trigger LLM session analysis via the monitor subsystem.""" + if monitors is None: + return {"ok": False, "error": "monitor runtime unavailable"} + watch_id = await self._ensure_session_watch(monitors, settings) + result = await monitors.run_watch_once(watch_id, force=True) + return {"ok": bool(result.get("ok", True)), "watch_id": watch_id, "result": result} + + async def analyze_llm( + self, + ctx: Any, + messages: list[dict[str, Any]], + *, + artifacts: list[dict[str, Any]] | None = None, + ) -> dict[str, Any]: + """Run LLM-based session analysis on a message transcript.""" + base: dict[str, Any] = { + "story": "", "insights": [], "decisions": [], "action_items": [], + "open_questions": [], "entities": [], "next_prompts": [], + "process_notes": [], "series_intents": [], "usage": {}, + } + llm = getattr(ctx, "llm", None) if ctx is not None else None + if llm is None or not messages: + return base + transcript = "\n".join( + f"{m.get('role', '')}: {str(m.get('content', ''))[:500]}" for m in messages[-40:] + )[:12000] + artifact_block = self._format_artifact_context(artifacts or []) + user_content = transcript if not artifact_block else f"{transcript}\n\n## Session file artifacts\n{artifact_block}" + prompt = [ + {"role": "system", "content": _SESSION_ANALYSIS_SYSTEM}, + {"role": "user", "content": user_content[:18000]}, + ] + try: + response = await llm.achat(prompt, stream=False) + data = _parse_session_json(getattr(response, "content", "")) + except Exception: + logger.debug("daemon: session analysis LLM call failed", exc_info=True) + return base + if isinstance(data, dict): + for key in base: + if key != "usage" and key in data: + base[key] = data[key] + return base + + async def should_refresh(self, ctx: Any, messages: list[dict[str, Any]]) -> bool: + """Determine if recent conversation warrants a dashboard refresh.""" + llm = getattr(ctx, "llm", None) if ctx is not None else None + if llm is None or not messages: + return False + tail = "\n".join( + f"{m.get('role', '')}: {str(m.get('content', ''))[:200]}" for m in messages[-6:] + )[:2000] + prompt = [ + {"role": "system", "content": _SESSION_SALIENCE_SYSTEM}, + {"role": "user", "content": tail}, + ] + try: + response = await llm.achat(prompt, stream=False) + return str(getattr(response, "content", "")).strip().upper().startswith("Y") + except Exception: + return False + + # ── Private helpers ─────────────────────────────────────────────────── + + async def _ensure_session_watch(self, monitors: Any, settings: Any) -> str: + """Ensure the session analysis watch exists and return its id.""" + from leapflow.monitor.session_producer import ensure_session_watch, session_watch_params + + return await ensure_session_watch(monitors, params=session_watch_params(settings)) + + def _session_store_messages(self, ctx: Any, session_id: str, *, limit: int = 200) -> list[dict[str, Any]]: + """Query persisted messages from the conversation store.""" + store = getattr(ctx, "_conversation_store", None) if ctx is not None else None + if store is None or not session_id: + return [] + try: + rows = store.get_messages(session_id, limit=int(limit)) + except Exception: + logger.debug("daemon: session store messages unavailable", exc_info=True) + return [] + return [self._conversation_message_to_dict(row) for row in rows] + + @staticmethod + def _conversation_message_to_dict(message: Any) -> dict[str, Any]: + if isinstance(message, dict): + return dict(message) + return { + "role": str(getattr(message, "role", "")), + "content": str(getattr(message, "content", "")), + "tool_name": str(getattr(message, "tool_name", "") or ""), + "tool_call_id": str(getattr(message, "tool_call_id", "") or ""), + "created_at": float(getattr(message, "created_at", 0.0) or 0.0), + "metadata": dict(getattr(message, "metadata", {}) or {}), + } + + def _collect_session_artifacts( + self, session_id: str, messages: list[dict[str, Any]], workspace: Path + ) -> list[dict[str, Any]]: + if not session_id: + return [] + candidates: list[tuple[str, dict[str, Any]]] = [] + for message in messages: + if str(message.get("role", "")) != "tool": + continue + tool_name = str(message.get("tool_name", "") or "") + if tool_name and tool_name not in {"file_write", "write_file"}: + continue + for path in self._extract_artifact_paths(message): + candidates.append((path, message)) + seen: set[str] = set() + artifacts: list[dict[str, Any]] = [] + total_chars = 0 + for raw_path, message in reversed(candidates): + if len(artifacts) >= _MAX_SESSION_ARTIFACTS: + break + artifact = self._read_session_artifact(raw_path, workspace, message) + key = str(artifact.get("path") or raw_path) + if key in seen: + continue + seen.add(key) + if artifact.get("status") == "included": + content = str(artifact.get("content_excerpt", "")) + remaining = max(0, _MAX_SESSION_ARTIFACT_TOTAL_CHARS - total_chars) + if len(content) > remaining: + artifact["content_excerpt"] = content[:remaining] + artifact["truncated"] = True + artifact["reason"] = "artifact context budget reached" + total_chars += len(str(artifact.get("content_excerpt", ""))) + artifacts.append(artifact) + artifacts.reverse() + return artifacts + + @staticmethod + def _extract_artifact_paths(message: dict[str, Any]) -> list[str]: + paths: list[str] = [] + payloads = [message.get("content", ""), message.get("metadata", {})] + for payload in payloads: + if isinstance(payload, dict): + for key in ("path", "file_path"): + if payload.get(key): + paths.append(str(payload[key])) + continue + text = str(payload or "") + try: + data = json.loads(text) + if isinstance(data, dict): + for key in ("path", "file_path"): + if data.get(key): + paths.append(str(data[key])) + except Exception: + pass + for match in _PATH_RE.finditer(text): + value = match.group("value").strip().strip(",}") + if value: + paths.append(value) + return paths + + @staticmethod + def _read_session_artifact(raw_path: str, workspace: Path, message: dict[str, Any]) -> dict[str, Any]: + target = Path(raw_path).expanduser() + if not target.is_absolute(): + target = workspace / target + try: + target = target.resolve() + except OSError: + target = target.absolute() + base = { + "path": str(target), + "name": target.name, + "source": "file_write", + "tool_call_id": str(message.get("tool_call_id", "") or ""), + "status": "skipped", + } + try: + target.relative_to(workspace) + except ValueError: + return {**base, "reason": "outside workspace boundary"} + try: + from leapflow.security.path_sensitivity import classify_path_sensitivity + sensitivity = classify_path_sensitivity(target) + except Exception: + sensitivity = None + if sensitivity is not None: + base.update({"sensitivity": sensitivity.category, "sensitivity_level": sensitivity.level}) + if not sensitivity.readable or sensitivity.requires_approval or sensitivity.redact_on_read: + return {**base, "reason": f"sensitive path ({sensitivity.category}) not read in background"} + if not target.exists() or not target.is_file(): + return {**base, "reason": "file no longer exists"} + try: + stat = target.stat() + content = target.read_text(encoding="utf-8", errors="replace") + except OSError as exc: + return {**base, "reason": f"read failed: {exc}"} + truncated = len(content) > _MAX_SESSION_ARTIFACT_CHARS + excerpt = content[:_MAX_SESSION_ARTIFACT_CHARS] + try: + from leapflow.security.redact import redact_sensitive_text + excerpt = redact_sensitive_text(excerpt, file_read=bool(getattr(sensitivity, "redact_on_read", False))) + except Exception: + pass + return { + **base, + "status": "included", + "size": int(stat.st_size), + "mtime": float(stat.st_mtime), + "content_excerpt": excerpt, + "truncated": truncated, + } + + @staticmethod + def _format_artifact_context(artifacts: list[dict[str, Any]]) -> str: + lines: list[str] = [] + for artifact in artifacts: + status = str(artifact.get("status", "")) + path = str(artifact.get("path", "")) + if status != "included": + lines.append(f"- SKIPPED {path}: {artifact.get('reason', 'not included')}") + continue + excerpt = str(artifact.get("content_excerpt", ""))[:_MAX_SESSION_ARTIFACT_CHARS] + truncated = " (truncated)" if artifact.get("truncated") else "" + lines.append(f"- FILE {path}{truncated}\n```text\n{excerpt}\n```") + return "\n".join(lines) + + @staticmethod + def _workspace_root(ctx: Any, settings: Any) -> Path: + s = getattr(ctx, "settings", settings) if ctx is not None else settings + return Path(str(getattr(s, "workspace_root", os.getcwd()))).expanduser().resolve() + + +def _parse_session_json(content: str) -> Any: + """Best-effort extraction of a JSON object from an LLM response.""" + import json as _json + + text = str(content or "").strip() + if text.startswith("```"): + text = text.strip("`") + if "\n" in text: + first, rest = text.split("\n", 1) + if first.strip().lower().startswith("json"): + text = rest + start, end = text.find("{"), text.rfind("}") + if start != -1 and end != -1 and end > start: + text = text[start:end + 1] + try: + return _json.loads(text) + except Exception: + return None diff --git a/src/leapflow/daemon/session_registry.py b/src/leapflow/daemon/session_registry.py new file mode 100644 index 0000000..e59f36a --- /dev/null +++ b/src/leapflow/daemon/session_registry.py @@ -0,0 +1,149 @@ +"""Session-scoped execution registry for the daemon (Stage 3, P3-2a). + +Maps a ``session_id`` to a :class:`SessionExecutionContext` — the per-session +engine (built via ``build_session_engine``) plus its own turn lock. Concurrent +turns of *different* sessions therefore run on *different* engine instances +(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. + +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 +P3-2b. See ``temp/plan/concurrent_turns_stage3.md`` §4.1/4.4. +""" +from __future__ import annotations + +import asyncio +import time +from pathlib import Path +from typing import Any, Callable, Dict, List, Optional + + +class WorkspaceMismatchError(ValueError): + """Raised when one session id is reused from a different workspace root.""" + + def __init__(self, session_id: str, expected: Path, requested: Path) -> None: + super().__init__( + f"Session {session_id!r} is bound to workspace {expected}; " + f"current request uses {requested}. Start a fresh TUI session for this workspace." + ) + self.session_id = session_id + self.expected = expected + self.requested = requested + + +class SessionExecutionContext: + """Per-session execution state: an engine + workspace + turn lock.""" + + def __init__(self, session_id: str, engine: Any, workspace_root: Path) -> None: + self.session_id = session_id + self.engine = engine + self.workspace_root = workspace_root + self.lock = asyncio.Lock() # serialize this session's own turns + self.last_active = time.monotonic() + + def touch(self) -> None: + self.last_active = time.monotonic() + + +class SessionRegistry: + """Create/reuse per-session execution contexts, bounded and idle-evicted. + + Parameters + ---------- + base_engine: + The daemon's existing wired engine. The first session reuses it so a + single-session daemon is unchanged. + build_engine: + ``(base_engine, session_id, working_memory) -> engine`` — normally + ``leapflow.engine.session_factory.build_session_engine`` (adapted). + build_working_memory: + ``() -> WorkingMemoryProvider`` — a fresh per-session working memory. + max_sessions / idle_ttl_s: + Registry bound + idle eviction (protect memory). + """ + + def __init__( + self, + *, + base_engine: Any, + build_engine: Callable[[Any, str, Any, Path], Any], + build_working_memory: Callable[[], Any], + max_sessions: int = 16, + idle_ttl_s: float = 1800.0, + ) -> None: + self._base = base_engine + self._build_engine = build_engine + self._build_wm = build_working_memory + self._max_sessions = max(1, int(max_sessions)) + self._idle_ttl_s = float(idle_ttl_s) + self._contexts: Dict[str, SessionExecutionContext] = {} + self._primary_session_id: Optional[str] = None + self._lock = asyncio.Lock() # guards registry mutation + + def _default_workspace_root(self) -> Path: + settings = getattr(self._base, "_settings", None) + root = getattr(settings, "workspace_root", Path.cwd()) + return Path(str(root)).expanduser().resolve() + + async def acquire( + self, + session_id: str, + workspace_root: str | Path | None = None, + ) -> SessionExecutionContext: + """Return the context for ``session_id``, creating it if needed. + + A session is bound to the workspace from its first request. Reusing the + same session id from another workspace is rejected because it would mix + one conversation's memory, task contract, and tool path boundary across + projects. + """ + sid = str(session_id or "") + requested_root = ( + Path(str(workspace_root)).expanduser().resolve() + if workspace_root else None + ) + async with self._lock: + self._evict_idle() + existing = self._contexts.get(sid) + if existing is not None: + if requested_root is not None and requested_root != existing.workspace_root: + raise WorkspaceMismatchError(sid, existing.workspace_root, requested_root) + existing.touch() + return existing + root = requested_root or self._default_workspace_root() + if self._primary_session_id is None: + self._primary_session_id = sid + elif len(self._contexts) >= self._max_sessions: + self._evict_oldest() + engine = self._build_engine(self._base, sid, self._build_wm(), root) + ctx = SessionExecutionContext(sid, engine, root) + self._contexts[sid] = ctx + return ctx + + def _evict_idle(self) -> None: + if self._idle_ttl_s <= 0: + return + now = time.monotonic() + for sid in [s for s, c in self._contexts.items() + if s != self._primary_session_id and (now - c.last_active) > self._idle_ttl_s]: + del self._contexts[sid] + + def _evict_oldest(self) -> None: + # Never evict the primary (base-engine) session. + candidates: List[SessionExecutionContext] = [ + c for s, c in self._contexts.items() if s != self._primary_session_id + ] + if not candidates: + return + oldest = min(candidates, key=lambda c: c.last_active) + del self._contexts[oldest.session_id] + + def active_count(self) -> int: + return len(self._contexts) + + def session_ids(self) -> List[str]: + return list(self._contexts.keys()) diff --git a/src/leapflow/daemon/turn_admission.py b/src/leapflow/daemon/turn_admission.py new file mode 100644 index 0000000..1ea1405 --- /dev/null +++ b/src/leapflow/daemon/turn_admission.py @@ -0,0 +1,120 @@ +"""Turn admission control for bounded concurrent execution (Stage 3, P3-4). + +``TurnAdmission`` bounds how many agent turns run concurrently (up to N) while +still allowing exclusive maintenance operations (host restart, re-entry +dispatch) to run with no turn in flight. It is a readers/writer discipline built +on a semaphore: + +* ``turn_slot()`` acquires one of N slots — up to N turns run concurrently. +* ``exclusive()`` drains all N slots, so it runs alone and blocks new turns until + it finishes; concurrent exclusive ops are serialized (no drain deadlock). + +``N = 1`` reduces to a plain mutex — exactly the daemon's pre-P3-4 behavior. +""" +from __future__ import annotations + +import asyncio +from contextlib import asynccontextmanager +from typing import Any, AsyncIterator, List + + +class TurnAdmission: + """Bounded concurrent turns with exclusive maintenance windows.""" + + def __init__(self, max_concurrent_turns: int) -> None: + self._n = max(1, int(max_concurrent_turns)) + self._sem = asyncio.Semaphore(self._n) + self._exclusive_lock = asyncio.Lock() + # Runtime metrics for TUI/daemon visibility. ``_slots_in_use`` counts + # both active turns and exclusive maintenance drains; ``_active_turns`` + # counts user agent turns only. All mutations happen on the daemon event + # loop, so no extra thread lock is needed. + self._slots_in_use = 0 + self._active_turns = 0 + self._waiting_turns = 0 + + @property + def max_concurrent(self) -> int: + return self._n + + def locked(self) -> bool: + """True when no turn slot is free (all N slots in use).""" + return self._sem.locked() + + def snapshot(self) -> dict[str, int | bool]: + """Return a structured runtime snapshot for status/UI reporting.""" + available = max(0, self._n - self._slots_in_use) + return { + "max_concurrent": self._n, + "active": max(0, self._active_turns), + "waiting": max(0, self._waiting_turns), + "available": available, + "slots_in_use": max(0, self._slots_in_use), + "locked": self.locked(), + } + + @asynccontextmanager + async def turn_slot(self) -> AsyncIterator[None]: + """Acquire one turn slot (blocks when all N are in use).""" + queued = self.locked() + if queued: + self._waiting_turns += 1 + try: + await self._sem.acquire() + except BaseException: + if queued: + self._waiting_turns = max(0, self._waiting_turns - 1) + raise + if queued: + self._waiting_turns = max(0, self._waiting_turns - 1) + self._slots_in_use += 1 + self._active_turns += 1 + try: + yield + finally: + self._active_turns = max(0, self._active_turns - 1) + self._slots_in_use = max(0, self._slots_in_use - 1) + self._sem.release() + + @asynccontextmanager + async def exclusive(self) -> AsyncIterator[None]: + """Run with no turn in flight (drains all N slots; blocks new turns).""" + async with self._exclusive_lock: # serialize exclusive ops → no drain deadlock + acquired = 0 + try: + for _ in range(self._n): + await self._sem.acquire() + self._slots_in_use += 1 + acquired += 1 + yield + finally: + for _ in range(acquired): + self._slots_in_use = max(0, self._slots_in_use - 1) + self._sem.release() + + def exclusive_gate(self) -> "_ExclusiveGate": + """Return a reusable ``async with gate:`` handle for exclusive access. + + Lets callers that hold a stored lock-like object (e.g. the re-entry + service) keep ``async with self._engine_lock:`` unchanged while getting + exclusive semantics. + """ + return _ExclusiveGate(self) + + +class _ExclusiveGate: + """Reusable async context manager adapting ``TurnAdmission.exclusive()``.""" + + def __init__(self, admission: TurnAdmission) -> None: + self._admission = admission + self._stack: List[Any] = [] + + async def __aenter__(self) -> None: + cm = self._admission.exclusive() + self._stack.append(cm) + await cm.__aenter__() + return None + + async def __aexit__(self, *exc: Any) -> None: + cm = self._stack.pop() + await cm.__aexit__(*exc) diff --git a/src/leapflow/engine/agent_loop.py b/src/leapflow/engine/agent_loop.py new file mode 100644 index 0000000..9dffc11 --- /dev/null +++ b/src/leapflow/engine/agent_loop.py @@ -0,0 +1,96 @@ +"""Per-frame execution state for the agent OODA loop (W4-M1). + +An ``AgentLoopFrame`` bundles everything that must be *fresh and isolated* for a +single loop frame. The top-level turn is depth 0; each delegated subagent will +run a deeper frame with its own budget, governance, ledger, commitment, usage +tracker, recovery coordinator, and compressor. Shared, cross-frame services +(LLM, settings, stores, working memory) deliberately do NOT live here -- they +belong in ``AgentLoopServices`` (introduced in M2 alongside the runner). + +M1 introduces the value object and its pure depth-governance / tool-filter +helpers only. The runner that consumes a frame (M2/M3) and the child-frame +factory (M4) land in later, separately reviewed steps -- this keeps M1 +zero-risk: no engine wiring, no runtime import of the bundled subsystems. +""" +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any, Dict, FrozenSet, Optional + +if TYPE_CHECKING: # imported lazily; never required at runtime for the value object + from leapflow.engine.budget import IterationBudget + from leapflow.engine.context_compressor import ContextCompressor + from leapflow.engine.context_control import ContextGovernanceController + from leapflow.engine.prefix_commitment import PrefixCommitmentController + from leapflow.engine.recovery_coordinator import RecoveryCoordinator + from leapflow.engine.research_ledger import ResearchLedger + from leapflow.engine.turn_recovery import TurnRecoveryState + from leapflow.engine.turn_usage import TurnUsageTracker + + +@dataclass +class AgentLoopFrame: + """Isolated per-frame state for one OODA loop (top-level turn or subagent). + + Carries every mutable per-turn subsystem so the same loop can run the + top-level turn (root frame) or a recursive subagent (a deeper frame with + fresh subsystems) without cross-frame contamination. Mutable: per-turn + fields such as ``last_context_snapshot`` are reassigned during the loop. + """ + + user_text: str + depth: int = 0 + # Per-frame subsystems. Optional so the value object and its pure helpers are + # testable in isolation; the engine constructs frames with all populated. + budget: "Optional[IterationBudget]" = None + governance: "Optional[ContextGovernanceController]" = None + ledger: "Optional[ResearchLedger]" = None + commitment: "Optional[PrefixCommitmentController]" = None + usage_tracker: "Optional[TurnUsageTracker]" = None + recovery: "Optional[TurnRecoveryState]" = None + compressor: "Optional[ContextCompressor]" = None + recovery_coordinator: "Optional[RecoveryCoordinator]" = None + recovery_budget: Optional[Any] = None + # Per-turn observability / continuity state, reassigned during the loop. + last_context_snapshot: Dict[str, Any] = field(default_factory=dict) + last_turn_tool_categories: FrozenSet[str] = frozenset() + # Progress-gated continuation state (P0): a fingerprint of task progress + # (ledger findings/questions/decisions + governance evidence/sources) and a + # count of consecutive rounds without progress. Drives budget extension vs + # convergence so a productive long task continues while a stalled one stops. + progress_marker: tuple = () + stalled_rounds: int = 0 + # ``None`` tool_filter means "all tools available"; a set restricts to it. + tool_filter: Optional[FrozenSet[str]] = None + enable_thinking: bool = False + parent_session_id: Optional[str] = None + # Per-turn identity carried on the frame so a turn's state is fully + # self-contained (foundation for per-turn concurrency isolation). Populated + # at frame build; ``_cancel_requested`` is intentionally NOT here — it is a + # cross-frame signal that must propagate into a running child. + session_id: str = "" + turn_id: str = "" + command_id: str = "" + metadata: Dict[str, Any] = field(default_factory=dict) + + @property + def is_root(self) -> bool: + """Whether this is the top-level (user-facing) frame.""" + return self.depth == 0 + + @property + def child_depth(self) -> int: + """Depth a child frame spawned from this one would have.""" + return self.depth + 1 + + def can_delegate(self, max_depth: int) -> bool: + """Whether this frame may spawn a child within the depth budget. + + A child at ``child_depth`` is only permitted while it stays strictly + below ``max_depth`` (depth 0 -> child 1 allowed when max_depth >= 2). + """ + return self.child_depth < max_depth + + def allows_tool(self, name: str) -> bool: + """Whether a tool name is permitted in this frame's filter.""" + return self.tool_filter is None or name in self.tool_filter diff --git a/src/leapflow/engine/budget.py b/src/leapflow/engine/budget.py index ca29b15..6394a0c 100644 --- a/src/leapflow/engine/budget.py +++ b/src/leapflow/engine/budget.py @@ -15,43 +15,149 @@ class BudgetStatus(Enum): @dataclass(frozen=True) class BudgetConfig: - """Configuration for iteration budget control.""" + """Configuration for iteration budget control. + + Supports two modes, selected purely by configuration: + + * Fixed (default): ``iter_ceiling <= max_iterations``. The cap is + ``max_iterations`` and the soft/warning tiers are the absolute + ``soft_limit`` / ``warning_threshold``. This is what bounded, one-shot + budgets (e.g. per-skill tool execution) want, and it is byte-identical to + the historical behavior. + * Elastic: ``iter_ceiling > max_iterations``. ``max_iterations`` becomes the + baseline floor and the effective cap can be raised at runtime up to + ``iter_ceiling`` via :meth:`IterationBudget.retarget`, proportionally to an + observed difficulty signal. Soft/warning tiers then scale with the current + effective cap (``soft_ratio`` / ``warning_ratio``) so "approaching the + limit" tracks the widened horizon rather than a stale constant. + """ max_iterations: int = 20 soft_limit: int = 14 warning_threshold: int = 10 + iter_ceiling: int = 0 + hard_cap: int = 0 + scale_k: float = 1.0 + max_refunds: int = 0 + soft_ratio: float = 0.8 + warning_ratio: float = 0.5 refundable_tools: FrozenSet[str] = field( default_factory=lambda: frozenset({"shell"}) ) + @property + def ceiling(self) -> int: + """Absolute upper bound on iterations (elastic ceiling or fixed cap).""" + return self.iter_ceiling if self.iter_ceiling > self.max_iterations else self.max_iterations + + @property + def absolute_ceiling(self) -> int: + """Hard upper bound including progress-gated extensions. + + The difficulty-scaled ``retarget`` is bounded by :attr:`ceiling`; the + progress-gated :meth:`IterationBudget.grant_extension` may push the + effective cap past the elastic ceiling up to this absolute backstop + (``hard_cap`` when set, else just the elastic ceiling — no extension). + """ + return max(self.hard_cap, self.ceiling) if self.hard_cap > 0 else self.ceiling + + @property + def elastic(self) -> bool: + """Whether this budget can widen its cap at runtime.""" + return self.iter_ceiling > self.max_iterations + class IterationBudget: - """Tracks iteration consumption with three-tier alerting.""" + """Tracks iteration consumption with three-tier alerting. + + The effective cap starts at ``config.max_iterations`` (the baseline) and, for + elastic configs, may be raised monotonically toward ``config.ceiling`` via + :meth:`retarget` as difficulty rises. It is never lowered below what has + already been consumed (a physical constraint) nor below the baseline. + """ def __init__(self, config: BudgetConfig): self._config = config self._consumed = 0 self._refunded = 0 + self._effective_max = max(1, config.max_iterations) def consume(self) -> BudgetStatus: """Consume one iteration. Returns current budget status.""" self._consumed += 1 - used = self._consumed - self._refunded - if used >= self._config.max_iterations: + return self._tier(self._consumed - self._refunded) + + def status(self) -> BudgetStatus: + """Current tier WITHOUT consuming (for progress-gated re-checks after a + :meth:`grant_extension`).""" + return self._tier(self._consumed - self._refunded) + + def _tier(self, used: int) -> BudgetStatus: + """Map current usage to a budget tier against the effective cap.""" + if used >= self._effective_max: return BudgetStatus.EXHAUSTED - if used >= self._config.soft_limit: + if self._config.elastic: + soft = max(1, round(self._config.soft_ratio * self._effective_max)) + warning = max(1, round(self._config.warning_ratio * self._effective_max)) + else: + soft = self._config.soft_limit + warning = self._config.warning_threshold + if used >= soft: return BudgetStatus.SOFT_LIMIT - if used >= self._config.warning_threshold: + if used >= warning: return BudgetStatus.WARNING return BudgetStatus.OK def refund(self, reason: str = "") -> None: - """Refund one iteration (e.g., long-running tool calls).""" + """Refund one iteration (e.g., long-running tool calls). + + Bounded by ``config.max_refunds`` when set (>0) so a slow, refundable + tool cannot manufacture an unbounded loop; ``0`` means unbounded (legacy). + """ + if self._config.max_refunds and self._refunded >= self._config.max_refunds: + return self._refunded += 1 + def elastic_max(self, difficulty: float) -> int: + """Target cap for a given difficulty in [0, 1] (clamped to [base, ceiling]).""" + base = self._config.max_iterations + span = max(0, self._config.ceiling - base) + clamped = max(0.0, min(1.0, difficulty)) + target = base + round(self._config.scale_k * clamped * span) + return max(base, min(target, self._config.ceiling)) + + def retarget(self, new_max: int) -> None: + """Raise the effective cap toward a new target (monotonic, bounded). + + Never lowers below what is already consumed or below the baseline, and + never exceeds the configured (elastic) ceiling. This is the + difficulty-driven widening; progress-gated widening past the elastic + ceiling goes through :meth:`grant_extension`. + """ + floor = max(self._config.max_iterations, self.used) + candidate = min(int(new_max), self._config.ceiling) + self._effective_max = max(self._effective_max, candidate, floor) + + @property + def can_extend(self) -> bool: + """Whether the effective cap can still grow toward the absolute ceiling.""" + return self._effective_max < self._config.absolute_ceiling + + def grant_extension(self, step: int) -> None: + """Progress-gated widening of the effective cap past the elastic ceiling. + + Distinct from :meth:`retarget` (bounded by the elastic ceiling): this is + called by the loop only when the task is productively unfinished, and + pushes the effective cap up to the absolute hard cap so a genuinely long + task is bounded by real resources (and the hard cap) rather than by a + fixed iteration count. No-op once the absolute ceiling is reached. + """ + step = max(1, int(step)) + self._effective_max = min(self._effective_max + step, self._config.absolute_ceiling) + @property def remaining(self) -> int: - return self._config.max_iterations - (self._consumed - self._refunded) + return self._effective_max - (self._consumed - self._refunded) @property def exhausted(self) -> bool: @@ -61,6 +167,11 @@ def exhausted(self) -> bool: def used(self) -> int: return self._consumed - self._refunded + @property + def effective_max(self) -> int: + """Current effective iteration cap (baseline, possibly widened).""" + return self._effective_max + @classmethod def for_react(cls, config: BudgetConfig) -> "IterationBudget": """Factory for the main ReAct loop.""" @@ -68,7 +179,7 @@ def for_react(cls, config: BudgetConfig) -> "IterationBudget": @classmethod def for_tool_execution(cls, max_calls: int = 30, soft: int = 24) -> "IterationBudget": - """Factory for tool execution within a single skill step.""" + """Factory for tool execution within a single skill step (fixed budget).""" return cls( BudgetConfig( max_iterations=max_calls, diff --git a/src/leapflow/engine/context_compressor.py b/src/leapflow/engine/context_compressor.py index eecaf94..265ac0b 100644 --- a/src/leapflow/engine/context_compressor.py +++ b/src/leapflow/engine/context_compressor.py @@ -107,10 +107,20 @@ class CompressorConfig: trim_budget_activation_ratio: float = _TRIM_BUDGET_ACTIVATION_RATIO summarize_threshold_messages: int = 16 summarize_keep_recent: int = 6 + summarize_append_only: bool = True archive_threshold_messages: int = 24 archive_keep_recent: int = 8 drop_threshold_messages: int = 32 drop_keep_recent: int = 4 + # Token-utilization triggers (fraction of ``token_budget``). Compression is + # driven by actual context pressure, NOT by raw message count, so a + # large-context model holds many messages before anything is compressed, the + # gentle Summarize stage always precedes the last-resort Drop, and Drop never + # fires on message count alone. These scale naturally with the model window + # because ``token_budget`` is derived from ``context_length``. + summarize_token_ratio: float = 0.5 + archive_token_ratio: float = 0.75 + drop_token_ratio: float = 0.95 enabled_stages: List[str] = field(default_factory=lambda: ["trim", "summarize", "archive", "drop"]) # Dedup: collapse identical tool results above this size @@ -132,9 +142,12 @@ def __post_init__(self) -> None: self.trim_threshold_chars = self.max_output_chars if self.threshold != 16 or self.summarize_threshold_messages == 16: self.summarize_threshold_messages = self.threshold - if self.keep_tail != 4 or self.summarize_keep_recent == 6: - self.summarize_keep_recent = max(self.keep_tail, 4) - self.drop_keep_recent = self.keep_tail + # Keep a generous recent window so immediate context is never lost: honor + # an explicit larger ``keep_tail``, else apply a safe floor (Summarize + # keeps more than Drop, and Drop — the last resort — still keeps several + # recent turns rather than nuking to a handful). + self.summarize_keep_recent = max(self.keep_tail, 8) + self.drop_keep_recent = max(self.keep_tail, 6) self._base_trim_threshold = self.trim_threshold_chars self._apply_adaptive_scaling() @@ -382,11 +395,15 @@ def __init__( keep_recent: int = 6, summarize_fn: Optional[SummarizeFn] = None, summary_target_ratio: float = 0.2, + append_only: bool = True, + token_ratio: float = 0.5, ) -> None: self._threshold = threshold_messages self._keep_recent = keep_recent self._summarize_fn = summarize_fn self._summary_target_ratio = summary_target_ratio + self._append_only = append_only + self._token_ratio = token_ratio self._previous_summary: Optional[str] = None self._compression_count: int = 0 self._last_savings_ratio: float = 1.0 @@ -396,15 +413,20 @@ def name(self) -> str: return "summarize" def should_apply(self, messages: List[Dict[str, Any]], token_count: int, budget: int) -> bool: + # Need enough messages to have a compressible middle at all. if len(messages) <= self._threshold: return False - if token_count <= budget * 0.5: - return False + # Anti-thrashing: skip if recent compressions barely helped. if self._compression_count >= 2 and self._last_savings_ratio < 0.10: logger.debug("SummarizeStage: skipped (anti-thrashing, last savings %.1f%%)", self._last_savings_ratio * 100) return False - return True + # Token-utilization driven: summarize once the context is genuinely large + # relative to the budget (which scales with the model window). This is the + # *primary* compression and fires well before the last-resort Drop. + if budget <= 0: + return True + return token_count > budget * self._token_ratio def apply(self, messages: List[Dict[str, Any]], budget: int) -> List[Dict[str, Any]]: if len(messages) <= self._keep_recent + 2: @@ -435,23 +457,37 @@ def apply(self, messages: List[Dict[str, Any]], budget: int) -> List[Dict[str, A def _partition( self, messages: List[Dict[str, Any]] ) -> tuple[List[Dict[str, Any]], List[Dict[str, Any]], List[Dict[str, Any]]]: - """Split messages into head (system), tail (recent), middle (compressible).""" + """Split messages into head (system), tail (recent), middle (compressible). + + Append-only mode freezes already-produced summary segments into the head + so each history window is summarized exactly once (no summary-of-summary + drift; frozen segments stay byte-stable and cacheable). Only newly + accumulated turns become the compressible middle. + """ head = [m for m in messages[:2] if m.get("role") == "system"] or messages[:1] head_count = len(head) - tail_start = max(head_count, len(messages) - self._keep_recent) + frozen_count = 0 + if self._append_only: + idx = head_count + while idx < len(messages) and messages[idx].get("_compressed_summary"): + frozen_count += 1 + idx += 1 + stable_count = head_count + frozen_count + + tail_start = max(stable_count, len(messages) - self._keep_recent) tail_start = self._align_boundary_backward(messages, tail_start) - if self._compression_count > 0: + if self._append_only or self._compression_count > 0: protect_first_n = 0 else: protect_first_n = min(2, len(messages) - head_count) - middle_start = head_count + protect_first_n + middle_start = stable_count + protect_first_n middle_start = self._align_boundary_forward(messages, middle_start) if middle_start >= tail_start: - return head, messages[head_count:], [] + return head + messages[head_count:stable_count], messages[stable_count:], [] middle = messages[middle_start:tail_start] tail = messages[tail_start:] @@ -463,7 +499,7 @@ def _build_summary(self, middle: List[Dict[str, Any]]) -> str: if self._summarize_fn is not None: try: turns_text = self._format_turns_for_summary(middle) - if self._previous_summary: + if self._previous_summary and not self._append_only: prompt = _ITERATIVE_PROMPT.format( previous_summary=self._previous_summary, new_turns=turns_text, @@ -607,17 +643,23 @@ def __init__( threshold_messages: int = 24, keep_recent: int = 8, archive_fn: Optional[ArchiveFn] = None, + token_ratio: float = 0.75, ) -> None: self._threshold = threshold_messages self._keep_recent = keep_recent self._archive_fn = archive_fn + self._token_ratio = token_ratio @property def name(self) -> str: return "archive" def should_apply(self, messages: List[Dict[str, Any]], token_count: int, budget: int) -> bool: - return len(messages) > self._threshold and token_count > budget * 0.8 + if len(messages) <= self._threshold: + return False + if budget <= 0: + return False + return token_count > budget * self._token_ratio def apply(self, messages: List[Dict[str, Any]], budget: int) -> List[Dict[str, Any]]: if len(messages) <= self._keep_recent + 2: @@ -656,37 +698,65 @@ def apply(self, messages: List[Dict[str, Any]], budget: int) -> List[Dict[str, A class DropStage: - """Stage 4: Force-drop oldest turns (last resort). + """Stage 4: last-resort drop of the oldest *middle* turns (token-overflow only). - Only keeps system prompt + most recent N turns. - Information IS lost — this is the nuclear option. + Fires ONLY when the token count approaches the hard budget — never on message + count alone, so a large-context model can hold many messages. Preserves the + system prefix, any frozen (append-only) summary segments, and the recent + tail, dropping only the uncompressed middle. Structured history therefore + survives even this nuclear option. """ - def __init__(self, *, threshold_messages: int = 32, keep_recent: int = 4) -> None: + def __init__( + self, + *, + threshold_messages: int = 32, + keep_recent: int = 4, + token_ratio: float = 0.95, + ) -> None: self._threshold = threshold_messages self._keep_recent = keep_recent + self._token_ratio = token_ratio @property def name(self) -> str: return "drop" def should_apply(self, messages: List[Dict[str, Any]], token_count: int, budget: int) -> bool: - return len(messages) > self._threshold or token_count > budget + # Token-driven last resort ONLY. Never drop on message count while the + # context window still has room — message-count dropping was the cause of + # catastrophic context loss on large-context models. + if budget <= 0: + return len(messages) > self._threshold + return token_count > budget * self._token_ratio def apply(self, messages: List[Dict[str, Any]], budget: int) -> List[Dict[str, Any]]: if len(messages) <= self._keep_recent + 1: return messages - head = messages[:1] if messages and messages[0].get("role") == "system" else [] - tail = messages[-self._keep_recent:] - dropped = len(messages) - len(head) - len(tail) - + head_end = 1 if messages and messages[0].get("role") == "system" else 0 + # Preserve contiguous frozen (append-only) summary segments after the head. + frozen_end = head_end + while frozen_end < len(messages) and messages[frozen_end].get("_compressed_summary"): + frozen_end += 1 + tail_start = max(frozen_end, len(messages) - self._keep_recent) + dropped = tail_start - frozen_end + if dropped <= 0: + return messages # nothing between the preserved head and the recent tail + + head = messages[:frozen_end] # system + frozen summaries + tail = messages[tail_start:] drop_notice = { "role": "system", - "content": f"[Context overflow: {dropped} messages dropped. Only recent context available.]", + "content": ( + f"[Context overflow: {dropped} older messages dropped; " + f"structured summary + {len(tail)} recent messages retained.]" + ), } - - logger.warning("DropStage: force-dropped %d messages (context overflow)", dropped) + logger.warning( + "DropStage: dropped %d middle messages (token overflow); kept summary + %d recent", + dropped, len(tail), + ) return head + [drop_notice] + tail @@ -791,15 +861,19 @@ def _build_stages(self, config: CompressorConfig) -> List[CompressionStage]: threshold_messages=config.summarize_threshold_messages, keep_recent=config.summarize_keep_recent, summarize_fn=config.summarize_fn, + append_only=config.summarize_append_only, + token_ratio=config.summarize_token_ratio, ), "archive": ArchiveStage( threshold_messages=config.archive_threshold_messages, keep_recent=config.archive_keep_recent, archive_fn=config.archive_fn, + token_ratio=config.archive_token_ratio, ), "drop": DropStage( threshold_messages=config.drop_threshold_messages, keep_recent=config.drop_keep_recent, + token_ratio=config.drop_token_ratio, ), } return [stage_map[name] for name in config.enabled_stages if name in stage_map] diff --git a/src/leapflow/engine/context_control.py b/src/leapflow/engine/context_control.py index e47a9d2..984f1a5 100644 --- a/src/leapflow/engine/context_control.py +++ b/src/leapflow/engine/context_control.py @@ -29,6 +29,7 @@ _POSTURE_BASELINE = "baseline" _POSTURE_EXPANDED = "expanded" _POSTURE_RESEARCH = "research" +_POSTURE_EXPANDING = "expanding" _POSTURE_CONVERGING = "converging" _POSTURE_FINALIZING = "finalizing" @@ -302,16 +303,82 @@ def _file_read_evidence(self, arguments: Dict[str, Any], result: Dict[str, Any]) def _file_list_evidence(self, result: Dict[str, Any]) -> Dict[str, Any]: entries = result.get("entries", []) - compact_entries = entries[: self._max_items] if isinstance(entries, list) else [] + tree = result.get("tree") + + if tree is not None: + # Depth > 0: flatten the nested tree to compact indented path strings + # so the LLM receives a readable structure without verbose dicts. + flat: List[str] = [] + self._flatten_tree_nodes(tree, prefix="", out=flat, limit=self._max_items) + return { + "ok": True, + "kind": "file_list_evidence", + "path": result.get("path", ""), + "depth": result.get("depth", 1), + "tree": flat, + "total_entries": result.get("total_entries", len(flat)), + "truncated": bool(result.get("truncated", False)), + } + + # Flat listing (depth=0): compact entry strings instead of verbose + # {name, type, size} dicts — ~3× more entries per token budget. + all_compact = [ + self._compact_entry(e) for e in entries + ] if isinstance(entries, list) else [] + visible = all_compact[: self._max_items] return { "ok": True, "kind": "file_list_evidence", "path": result.get("path", ""), - "entries": compact_entries, - "entry_count": result.get("entry_count", len(entries) if isinstance(entries, list) else 0), - "truncated": bool(result.get("truncated", False) or (isinstance(entries, list) and len(entries) > len(compact_entries))), + "entries": visible, + "entry_count": result.get("entry_count", len(all_compact)), + "truncated": bool( + result.get("truncated", False) or + (isinstance(entries, list) and len(entries) > len(visible)) + ), } + @staticmethod + def _compact_entry(entry: Any) -> str: + """Render a {name, type, size} dict as a short token-efficient string.""" + if not isinstance(entry, dict): + return str(entry) + name = str(entry.get("name", "")) + if entry.get("type") == "dir": + return f"{name}/" + size = entry.get("size") + if size is not None: + if size >= 1_000_000: + return f"{name} ({size / 1_000_000:.1f}MB)" + if size >= 1_000: + return f"{name} ({size / 1_000:.0f}KB)" + return f"{name} ({size}B)" + return name + + def _flatten_tree_nodes( + self, nodes: Any, *, prefix: str, out: List[str], limit: int + ) -> None: + """Recursively flatten a tree structure to indented compact strings.""" + if not isinstance(nodes, list): + return + for node in nodes: + if len(out) >= limit: + break + if not isinstance(node, dict): + continue + name = str(node.get("name", "")) + if node.get("type") == "dir": + summary = node.get("summary", "") + label = f"{prefix}{name}/" + (f" [{summary}]" if summary else "") + out.append(label) + children = node.get("children") + if isinstance(children, list): + self._flatten_tree_nodes( + children, prefix=prefix + " ", out=out, limit=limit + ) + else: + out.append(prefix + self._compact_entry(node)) + def _shell_evidence(self, result: Dict[str, Any]) -> Dict[str, Any]: return { "ok": bool(result.get("ok", True)), @@ -364,10 +431,28 @@ def _platform_action_evidence(self, arguments: Dict[str, Any], result: Dict[str, return evidence def _compact_error(self, result: Dict[str, Any]) -> Dict[str, Any]: - return { + compact: Dict[str, Any] = { "ok": False, "error": self._head_tail(str(result.get("error", "unknown error")), self._max_content_chars), } + # Preserve structured repair/recovery hints so the model can self-correct + # in-turn: invalid_arguments -> missing/accepted_parameters; edit_file + # anchor errors -> match_count; code_search -> error_type; etc. + for key in ("error_type", "retryable", "missing", "accepted_parameters", "required", "match_count", "failure_code"): + if key in result: + compact[key] = self._compact_value(result[key]) + # Diagnostic output matters MOST on failure: preserve stdout/stderr + # (head+tail so the tail traceback survives) and the exit code, so the + # agent can see the actual error (e.g. a Python traceback from a failed + # shell_run) and fix its cause instead of a bare "unknown error". + for key in ("stdout", "stderr"): + value = result.get(key) + if value: + compact[key] = self._head_tail(str(value), self._max_content_chars) + for key in ("returncode", "exit_code"): + if key in result: + compact[key] = result[key] + return compact def _app_connector_evidence(self, result: Dict[str, Any]) -> Dict[str, Any]: ok = bool(result.get("ok", True)) @@ -471,6 +556,37 @@ class ContextPostureConfig: expanded_tool_call_threshold: int = 3 research_source_threshold: int = 3 research_evidence_threshold: int = 5 + # Bidirectional posture: high-end expansion arm + low-end answer-ready arm. + expand_difficulty_threshold: float = 0.55 + expand_context_ceiling: float = 0.70 + answer_ready_min_round: int = 2 + + +@dataclass(frozen=True) +class DifficultyConfig: + """Weights and saturation denominators for the continuous difficulty signal. + + Difficulty is a bounded [0, 1] estimate of how hard / long-horizon the active + task is, derived only from structural exploration-ledger signals (breadth, + evidence volume, friction, persistence, marginal growth, tool activity). It + drives the elastic iteration budget and the expansion arm of the posture + ladder. All denominators and weights are configurable; nothing here reads the + user's free-form text. + """ + + d_sources: int = 6 + d_evidence: int = 10 + d_rounds: int = 12 + d_marginal: float = 1.0 + d_tool_calls: int = 8 + marginal_window: int = 3 + ema_alpha: float = 0.4 + w_breadth: float = 0.15 + w_volume: float = 0.15 + w_friction: float = 0.15 + w_persistence: float = 0.15 + w_marginal: float = 0.20 + w_activity: float = 0.20 @dataclass(frozen=True) @@ -486,6 +602,7 @@ class ExplorationSnapshot: should_converge: bool = False convergence_reason: str = "" guidance: str = "" + difficulty: float = 0.0 def as_dict(self) -> Dict[str, Any]: """Return a JSON-serializable snapshot for daemon/TUI metadata.""" @@ -499,6 +616,7 @@ def as_dict(self) -> Dict[str, Any]: "should_converge": self.should_converge, "convergence_reason": self.convergence_reason, "guidance": self.guidance, + "difficulty": self.difficulty, } @@ -509,7 +627,13 @@ class ContextGovernanceController: evidence_builder: ToolEvidenceBuilder repeated_read_limit: int = 2 convergence_round: int = 12 + # Difficulty-adaptive convergence: the effective round at which long-exploration + # converging kicks in is scaled by observed difficulty. A hard task earns more + # exploration rounds; the ceiling prevents truly stuck tasks from running forever. + convergence_round_ceiling: int = 40 + convergence_scale: float = 2.0 posture_config: ContextPostureConfig = field(default_factory=ContextPostureConfig) + difficulty_config: DifficultyConfig = field(default_factory=DifficultyConfig) evidence_tools: frozenset[str] = _EVIDENCE_TOOLS research_source_threshold: int | None = None research_evidence_threshold: int | None = None @@ -523,11 +647,19 @@ def __post_init__(self) -> None: expanded_tool_call_threshold=self.posture_config.expanded_tool_call_threshold, research_source_threshold=self.research_source_threshold or self.posture_config.research_source_threshold, research_evidence_threshold=self.research_evidence_threshold or self.posture_config.research_evidence_threshold, + expand_difficulty_threshold=self.posture_config.expand_difficulty_threshold, + expand_context_ceiling=self.posture_config.expand_context_ceiling, + answer_ready_min_round=self.posture_config.answer_ready_min_round, ) self._reads: dict[str, int] = {} self._sources_seen: set[str] = set() self._tool_counts: dict[str, int] = {} self._evidence_count = 0 + self._tool_failures = 0 + self._difficulty_prev = 0.0 + self._difficulty_ema_round = -1 + self._evidence_by_round: dict[int, int] = {} + self._evidence_round_hwm = -1 def reset_turn_scope(self) -> None: """Clear per-turn exploration state so posture never leaks across tasks.""" @@ -535,12 +667,31 @@ def reset_turn_scope(self) -> None: self._sources_seen.clear() self._tool_counts.clear() self._evidence_count = 0 + self._tool_failures = 0 + self._difficulty_prev = 0.0 + self._difficulty_ema_round = -1 + self._evidence_by_round.clear() + self._evidence_round_hwm = -1 reset_task_scope = reset_turn_scope + def _effective_convergence_round(self, difficulty: float) -> int: + """Scale the convergence round with task difficulty, bounded by a ceiling. + + At difficulty=0 returns the base ``convergence_round`` unchanged. + At difficulty=1 the effective round rises by ``convergence_scale`` × + the base, capped at ``convergence_round_ceiling``. This gives hard + tasks proportionally more exploration while still converging them + eventually. + """ + extension = round(self.convergence_round * max(0.0, min(1.0, difficulty)) * self.convergence_scale) + return min(self.convergence_round_ceiling, self.convergence_round + extension) + def compact_tool_result(self, tool_name: str, arguments: Dict[str, Any] | None, result: Any) -> Any: """Return evidence and update the session exploration ledger.""" self._tool_counts[tool_name] = self._tool_counts.get(tool_name, 0) + 1 + if isinstance(result, dict) and result.get("ok") is False: + self._tool_failures += 1 if tool_name in self.evidence_tools: self._evidence_count += 1 if tool_name in {"file_read", "gp_file_read"}: @@ -552,7 +703,12 @@ def compact_tool_result(self, tool_name: str, arguments: Dict[str, Any] | None, elif tool_name in {"file_list", "gp_file_list"}: path = str((arguments or {}).get("path") or (result.get("path") if isinstance(result, dict) else "")) if path: - self._sources_seen.add(str(Path(path).expanduser())) + key = str(Path(path).expanduser()) + # Track repeated directory listings alongside repeated file reads: + # both are evidence of an agent struggling to make progress, and + # both should push the posture toward converging. + self._reads[key] = self._reads.get(key, 0) + 1 + self._sources_seen.add(key) return self.evidence_builder.build(tool_name, arguments, result) def tool_metadata(self, tool_name: str, arguments: Dict[str, Any] | None, result: Any) -> Dict[str, Any]: @@ -566,6 +722,15 @@ def tool_metadata(self, tool_name: str, arguments: Dict[str, Any] | None, result count = self._reads.get(str(Path(path).expanduser()), 0) metadata["read_count"] = count metadata["repeat_read"] = count > self.repeated_read_limit + if tool_name in {"file_list", "gp_file_list"}: + # Expose the same repeat_read signal for directories so the UI and + # the agent loop can surface the same converging guidance as for + # repeated file reads. + path = str((arguments or {}).get("path") or (result.get("path") if isinstance(result, dict) else "")) + if path: + count = self._reads.get(str(Path(path).expanduser()), 0) + metadata["read_count"] = count + metadata["repeat_read"] = count > self.repeated_read_limit if isinstance(result, dict): if result.get("truncated"): metadata["tool_truncated"] = True @@ -579,31 +744,123 @@ def tool_metadata(self, tool_name: str, arguments: Dict[str, Any] | None, result metadata["context_guidance"] = ledger.guidance return metadata - def snapshot(self, *, context_ratio: float = 0.0, round_number: int = 0) -> ExplorationSnapshot: - """Return the current adaptive-governance posture without exposing a mode.""" + def _marginal_evidence(self, round_number: int) -> float: + """Recent per-round growth in evidence volume (idempotent within a round). + + Records the current evidence count for ``round_number`` (only when the + round advances, so out-of-order / stale peeks -- e.g. a round-0 + ``tool_metadata`` snapshot interleaved with authoritative round-N calls + -- never overwrite an earlier round's baseline), then measures growth + against the level ~one window of rounds earlier. A positive value means + the task is still surfacing new evidence ("there is more to dig"). + """ + if round_number > self._evidence_round_hwm: + self._evidence_by_round[round_number] = self._evidence_count + self._evidence_round_hwm = round_number + if not self._evidence_by_round: + return 0.0 + window = max(1, self.difficulty_config.marginal_window) + past_round = round_number - window + earlier = [r for r in self._evidence_by_round if r <= past_round] + baseline_round = max(earlier) if earlier else min(self._evidence_by_round) + delta = self._evidence_count - self._evidence_by_round[baseline_round] + return max(0.0, delta / window) + + def _compute_difficulty( + self, + *, + round_number: int, + sources_seen: int, + tool_calls: int, + repeated_reads: int, + marginal: float, + ) -> float: + """Continuous [0, 1] task-difficulty estimate from structural signals only. + + EMA smoothing is applied at most once per round (idempotent to repeated + snapshot() calls in the same round) so difficulty rises/falls smoothly. + """ + cfg = self.difficulty_config + breadth_n = min(sources_seen / cfg.d_sources, 1.0) if cfg.d_sources > 0 else 0.0 + volume_n = min(self._evidence_count / cfg.d_evidence, 1.0) if cfg.d_evidence > 0 else 0.0 + repeat_ratio = min(repeated_reads / max(1, self.repeated_read_limit), 1.0) + fail_rate = self._tool_failures / max(1, tool_calls) + friction_n = min(0.5 * repeat_ratio + 0.5 * fail_rate, 1.0) + persistence_n = min(round_number / cfg.d_rounds, 1.0) if cfg.d_rounds > 0 else 0.0 + marginal_n = min(marginal / cfg.d_marginal, 1.0) if cfg.d_marginal > 0 else 0.0 + activity_n = min(tool_calls / cfg.d_tool_calls, 1.0) if cfg.d_tool_calls > 0 else 0.0 + raw = ( + cfg.w_breadth * breadth_n + + cfg.w_volume * volume_n + + cfg.w_friction * friction_n + + cfg.w_persistence * persistence_n + + cfg.w_marginal * marginal_n + + cfg.w_activity * activity_n + ) + raw = max(0.0, min(1.0, raw)) + if round_number > self._difficulty_ema_round: + alpha = cfg.ema_alpha + self._difficulty_prev = alpha * raw + (1.0 - alpha) * self._difficulty_prev + self._difficulty_ema_round = round_number + return self._difficulty_prev + + def snapshot(self, *, context_ratio: float = 0.0, round_number: int = 0, open_questions: int | None = None) -> ExplorationSnapshot: + """Return the current adaptive-governance posture without exposing a mode. + + The posture ladder is bidirectional and difficulty-aware. Priority order, + safety-first: (1) finalizing on context pressure, (2) converging on + repeat-read loops, (3) EXPANDING when difficulty is high and context is + healthy (a hard task earns a wider horizon), (4) converging on long + low-difficulty exploration, then (5) research / expanded / baseline. A + low-end "answer-ready" arm nudges early finalization for simple tasks + without inflating disclosure. + """ cfg = self.posture_config repeated_reads = sum(1 for count in self._reads.values() if count > self.repeated_read_limit) tool_calls = sum(self._tool_counts.values()) sources_seen = len(self._sources_seen) + marginal = self._marginal_evidence(round_number) + difficulty = self._compute_difficulty( + round_number=round_number, + sources_seen=sources_seen, + tool_calls=tool_calls, + repeated_reads=repeated_reads, + marginal=marginal, + ) dominant_signal = "" posture = _POSTURE_BASELINE guidance = "" convergence_reason = "" + expand_ok = ( + difficulty >= cfg.expand_difficulty_threshold + and context_ratio < cfg.expand_context_ceiling + and marginal > 0.0 + ) + if context_ratio >= cfg.finalizing_ratio: posture = _POSTURE_FINALIZING dominant_signal = "context-critical" convergence_reason = "context budget is critical" guidance = "finalize with existing evidence" - elif repeated_reads > 0 or round_number >= self.convergence_round: + elif repeated_reads > 0: posture = _POSTURE_CONVERGING - dominant_signal = "repeat-read" if repeated_reads > 0 else "long-exploration" - convergence_reason = "repeat reads detected" if repeated_reads > 0 else "exploration round limit reached" - guidance = ( - "switch to complementary sources, outlines, symbols, or bounded ranges" - if repeated_reads > 0 else - "deduplicate evidence and prefer targeted reads" + dominant_signal = "repeat-read" + convergence_reason = "repeat reads detected" + guidance = "switch to complementary sources, outlines, symbols, or bounded ranges" + elif expand_ok: + posture = _POSTURE_EXPANDING + dominant_signal = "high-difficulty" + guidance = "broaden investigation, decompose, or delegate; iteration budget widened" + elif round_number >= self._effective_convergence_round(difficulty): + effective_round = self._effective_convergence_round(difficulty) + posture = _POSTURE_CONVERGING + dominant_signal = "long-exploration" + convergence_reason = ( + f"exploration round limit reached (base {self.convergence_round}, " + f"effective {effective_round} at difficulty {difficulty:.2f})" ) + guidance = "deduplicate evidence and prefer targeted reads" elif sources_seen >= cfg.research_source_threshold or self._evidence_count >= cfg.research_evidence_threshold: posture = _POSTURE_RESEARCH dominant_signal = "multi-source" if sources_seen >= cfg.research_source_threshold else "evidence-volume" @@ -614,6 +871,26 @@ def snapshot(self, *, context_ratio: float = 0.0, round_number: int = 0) -> Expl guidance = "prefer outline, symbols, or range reads before raw content" should_converge = posture in {_POSTURE_CONVERGING, _POSTURE_FINALIZING} + # Low-end symmetric arm: a low-difficulty turn that already gathered + # evidence and is no longer surfacing new evidence should finalize early. + # It keeps posture at baseline/expanded so disclosure stays minimal (a + # simple task must never be pushed into full disclosure). When a research + # ledger is active with unresolved open questions, this early convergence + # is suppressed -- tracked open work means the task is not done, so a long + # task is never cut short (open_questions: None = no ledger signal). + if ( + not should_converge + and posture in {_POSTURE_BASELINE, _POSTURE_EXPANDED} + and self._evidence_count >= 1 + and marginal <= 0.0 + and difficulty < cfg.expand_difficulty_threshold + and round_number >= cfg.answer_ready_min_round + and (open_questions is None or open_questions == 0) + ): + should_converge = True + convergence_reason = "answer-ready" + guidance = "you likely have enough evidence; if the question is answered, provide the final answer now" + return ExplorationSnapshot( posture=posture, sources_seen=sources_seen, @@ -624,11 +901,12 @@ def snapshot(self, *, context_ratio: float = 0.0, round_number: int = 0) -> Expl should_converge=should_converge, convergence_reason=convergence_reason, guidance=guidance, + difficulty=round(difficulty, 4), ) - def convergence_notice(self, round_number: int) -> str: + def convergence_notice(self, round_number: int, *, open_questions: int | None = None) -> str: """Return a notice that nudges synthesis after excessive exploration.""" - snapshot = self.snapshot(round_number=round_number) + snapshot = self.snapshot(round_number=round_number, open_questions=open_questions) if not snapshot.should_converge: return "" if snapshot.dominant_signal == "repeat-read": @@ -638,6 +916,11 @@ def convergence_notice(self, round_number: int) -> str: "directory outline, symbols, bounded line ranges, adjacent modules, tests, docs, or synthesize " "from the evidence already gathered if enough context exists." ) + if snapshot.convergence_reason == "answer-ready": + return ( + "SYSTEM: You likely have enough evidence to answer. If the user's request is " + "already addressed, provide the final answer now instead of gathering more." + ) reason = snapshot.convergence_reason or snapshot.dominant_signal or "context pressure" return ( "SYSTEM: Adaptive context governance is converging " diff --git a/src/leapflow/engine/context_disclosure.py b/src/leapflow/engine/context_disclosure.py index 37e448c..0d216b2 100644 --- a/src/leapflow/engine/context_disclosure.py +++ b/src/leapflow/engine/context_disclosure.py @@ -196,7 +196,7 @@ def plan( manifests = self.manifests or build_capability_manifests(tool_definitions) manifest_by_name = {m.name: m for m in manifests if m.name} - if runtime.slash_command or runtime.context_posture in {"research", "converging", "finalizing"} or runtime.recent_failure: + if runtime.slash_command or runtime.context_posture in {"research", "expanding", "converging", "finalizing"} or runtime.recent_failure: return self.full_plan(tool_definitions, runtime, _full_reason(runtime)) core_defs, core_names = _core_whitelist(tool_definitions, manifest_by_name) diff --git a/src/leapflow/engine/engine.py b/src/leapflow/engine/engine.py index 487c406..b7fbd94 100644 --- a/src/leapflow/engine/engine.py +++ b/src/leapflow/engine/engine.py @@ -8,7 +8,7 @@ import re import sys import time -from dataclasses import dataclass +from dataclasses import asdict, dataclass, replace from datetime import datetime from pathlib import Path from typing import Any, AsyncIterator, Dict, List, Literal, Optional, Union @@ -16,6 +16,9 @@ from leapflow.platform.protocol import HostRpc, Methods from leapflow.config import Settings from leapflow.engine.budget import BudgetConfig, BudgetStatus, IterationBudget +from leapflow.engine.prefix_commitment import PrefixCommitmentController +from leapflow.engine.research_ledger import ResearchLedger +from leapflow.engine.agent_loop import AgentLoopFrame from leapflow.engine.context_compressor import CompressorConfig, ContextCompressor from leapflow.engine.context_control import ( ContextBudgetEstimator, @@ -45,13 +48,14 @@ from leapflow.engine.prompt_cache import CacheStrategy from leapflow.engine.stale_stream import StaleStreamError, stale_guarded_stream, build_continuation_prompt from leapflow.engine.turn_recovery import TurnRecoveryState -from leapflow.engine.turn_usage import TurnUsageTracker +from leapflow.engine.turn_usage import TurnUsageTracker, cost_ceiling_exceeded, build_adaptive_learning_signal from leapflow.engine.recovery_coordinator import RecoveryCoordinator from leapflow.engine.recovery_budget import RecoveryBudget from leapflow.engine.unified_classifier import UnifiedErrorClassifier from leapflow.engine.recovery_decision import RecoveryAction, RecoveryDecision from leapflow.engine.recovery_strategies import default_strategies from leapflow.engine.recovery_audit import JsonlAuditSink, create_audit_entry +from leapflow.engine.failure_envelope import Recoverability from leapflow.engine.recovery_checkpoint import RecoveryCheckpoint, InMemoryCheckpointStore from leapflow.engine.tool_concurrency import ( DefaultConcurrencyPolicy, @@ -74,7 +78,6 @@ from leapflow.memory.providers.working import WorkingMemoryProvider from leapflow.memory.providers.evolution import EvolutionMemoryProvider from leapflow.memory.manager import MemoryManager -from leapflow.prompts.templates import REACT_SYSTEM_TEMPLATE from leapflow.learning.active_learning import SkillMerger from leapflow.skills.builtin import app_launcher, clipboard_manager, file_organizer from leapflow.security.permission_failures import ( @@ -82,6 +85,7 @@ is_permission_hard_stop_payload, ) from leapflow.storage.skill_library import SkillLibraryStore +from leapflow.storage.reentry_store import build_reentry_trigger from leapflow.skills.registry import Skill, SkillRegistry from leapflow.tools.name_resolver import ToolRegistry, ToolResolution @@ -109,6 +113,18 @@ def _normalize_tool_name(tool_name: str) -> str: return _default_tool_registry().normalize_name(tool_name) +def _concurrency_spec_lookup(tool_name: str) -> Any: + """Return the registry ToolSpec for a (possibly gp_-prefixed) tool name. + + Injected into the tool concurrency policy so parallel-safety is classified + from the same registry metadata that drives idempotency and the batch-stop + gate (one source of truth). Returns None for an unregistered tool, which the + policy treats as sequential. + """ + specs = _default_tool_registry().specs + return specs.get(tool_name) or specs.get(tool_name.removeprefix("gp_")) + + def _normalize_tool_call(tool_call: Dict[str, Any]) -> Dict[str, Any]: """Return a resolved tool call while preserving the original tool name.""" original_name = str(tool_call.get("name", "")) @@ -278,17 +294,6 @@ def _is_permission_hard_stop_payload(payload: Dict[str, Any]) -> bool: _SIDE_EFFECT_STOP_POLICIES = frozenset({"external_side_effect", "mutating_once", "mutating_idempotent"}) -_SIDE_EFFECT_STOP_TOOLS = frozenset({ - "shell_run", - "scm_sync", - "platform_action", - "platform_connect", - "gateway_send", - "gateway_connect", - "hub_push", - "hub_pull", - "hub_sync", -}) def _tool_result_counts_as_failure(payload: Dict[str, Any]) -> bool: @@ -315,14 +320,143 @@ def _tool_failure_text(payload: Dict[str, Any]) -> str: 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.""" + """Return whether a failed side-effect result must stop the current tool batch. + + Side-effect determination is policy-driven: the execution ledger injects an + ``execution_policy`` (derived from registry metadata — risk level, mutation, + idempotency) into every executed tool result, so a mutating/side-effecting + tool is identified by its declared policy rather than a hardcoded tool-name + list. This keeps the safety gate general and free of vendor-specific names. + """ if _is_permission_hard_stop_payload(payload): return True if not _tool_result_counts_as_failure(payload): return False - policy = str(payload.get("execution_policy") or "") - normalized_name = str(tool_name or "").removeprefix("gp_") - return policy in _SIDE_EFFECT_STOP_POLICIES or normalized_name in _SIDE_EFFECT_STOP_TOOLS + return str(payload.get("execution_policy") or "") in _SIDE_EFFECT_STOP_POLICIES + + +def _validate_tool_arguments(spec: Any, args: Dict[str, Any]) -> Optional[Dict[str, Any]]: + """Pre-execution argument check against a tool's declared required params. + + Returns a structured ``invalid_arguments`` result (for in-turn self-repair) if + a required parameter key is absent, else ``None``. Presence-only (an empty but + present value is the handler's concern) to avoid rejecting legitimately empty + values. The result is marked non-failing and carries no execution_policy, so it + neither trips the side-effect batch-stop gate nor penalizes failure budgets — + the model simply sees the missing fields plus the accepted schema and retries. + """ + if spec is None: + return None + required = getattr(spec, "required", frozenset()) or frozenset() + if not required: + return None + missing = [name for name in required if name not in args] + if not missing: + return None + accepted = sorted((getattr(spec, "parameters", frozenset()) or frozenset()) | set(required)) + tool_name = str(getattr(spec, "name", "") or "") + return { + "ok": False, + "error": f"Invalid arguments for {tool_name}: missing required parameter(s): {', '.join(sorted(missing))}", + "error_type": "invalid_arguments", + "tool_name": tool_name, + "missing": sorted(missing), + "required": sorted(required), + "accepted_parameters": accepted, + "retryable": True, + "counts_as_failure": False, + } + + +def _head_tail_truncate(text: str, allow: int) -> str: + """Keep the head and tail of a long string with an explicit elision marker. + + The tail of stdout/stderr/tracebacks/test output usually holds the actual + error, so a naive head-only cut discards the most useful part. + """ + if len(text) <= allow: + return text + keep = max(40, allow - 40) # leave room for the marker + head = (keep * 2) // 3 + tail = keep - head + elided = len(text) - head - tail + return f"{text[:head]}\n… [{elided} chars elided] …\n{text[-tail:]}" + + +def _truncate_result_for_budget(payload: Any, budget: int) -> str: + """Serialize a tool result to JSON within ``budget``, preserving structure. + + Pass 1 – prune list fields (e.g. file_list entries): drop tail elements + and annotate ``_omitted`` so the LLM knows how many were removed. + Pass 2 – shrink the largest string fields with head+tail truncation so + the tail error / trace survives. The final fallback emits a minimal + valid-JSON sentinel; a raw string cut that leaves invalid JSON is never + returned. Never raises. + """ + try: + text = json.dumps(payload, default=str, ensure_ascii=False) + except (TypeError, ValueError): + return str(payload)[:budget] + if len(text) <= budget: + return text + if isinstance(payload, dict): + shrunk = dict(payload) + + # Pass 1: prune list fields until the result fits. + # This handles file_list / file_find payloads that carry many entries. + for key in list(shrunk): + v = shrunk[key] + if not isinstance(v, list) or not v: + continue + orig_len = len(v) + # Estimate target entry count from a small sample to minimise + # iterations; then fine-tune with a tight while-loop. + sample = json.dumps(v[:min(4, orig_len)], default=str, ensure_ascii=False) + chars_per = max(1, len(sample) / min(4, orig_len)) + empty_payload = {**shrunk, key: [], key + "_omitted": orig_len} + overhead = len(json.dumps(empty_payload, default=str, ensure_ascii=False)) + target = max(0, int((budget - overhead) / chars_per)) + shrunk[key] = v[:target] + if target < orig_len: + shrunk[key + "_omitted"] = orig_len - target + # Fine-tune (estimation may be off by ±1 entry). + while shrunk[key] and len(json.dumps(shrunk, default=str, ensure_ascii=False)) > budget: + shrunk[key] = shrunk[key][:-1] + shrunk[key + "_omitted"] = orig_len - len(shrunk[key]) + if len(json.dumps(shrunk, default=str, ensure_ascii=False)) <= budget: + return json.dumps(shrunk, default=str, ensure_ascii=False) + + # Pass 2: shrink the largest string fields with head+tail truncation. + while True: + over = len(json.dumps(shrunk, default=str, ensure_ascii=False)) - budget + if over <= 0: + break + candidates = [(k, v) for k, v in shrunk.items() if isinstance(v, str) and len(v) > 160] + if not candidates: + break + key, value = max(candidates, key=lambda kv: len(kv[1])) + allow = max(120, len(value) - over - 60) + if allow >= len(value): + break + shrunk[key] = _head_tail_truncate(value, allow) + + text = json.dumps(shrunk, default=str, ensure_ascii=False) + if len(text) <= budget: + return text + + # Sentinel: emit minimal valid JSON rather than a raw string cut that + # leaves the LLM with an unparseable fragment. + sentinel = json.dumps({ + "ok": payload.get("ok"), + "kind": payload.get("kind", ""), + "truncated": True, + "original_chars": len(text), + "budget_chars": budget, + }, default=str, ensure_ascii=False) + return sentinel + + # Non-dict: hard string cut is unavoidable; the LLM sees a partial raw value. + return text[:budget] def _skipped_after_failure_result(blocking_tool: str, blocking_result: Dict[str, Any]) -> Dict[str, Any]: @@ -710,19 +844,6 @@ class StreamEvent: metadata: Optional[Dict[str, Any]] = None -@dataclass -class _LoopContext: - """Mutable state carried across state machine transitions.""" - - messages: List[Dict[str, Any]] - last_content: str = "" - last_action: Optional[Dict[str, Any]] = None - last_observation: Any = None - last_error: Optional[Exception] = None - consecutive_failures: int = 0 - prefetch_done: bool = False # track whether memory prefetch ran this loop - - @dataclass(frozen=True) class _PromptAssembly: """Resolved prompt pieces for a unified-loop turn.""" @@ -754,6 +875,11 @@ def render(self) -> str: "- Treat relative project paths as relative to the workspace root; never infer `.` " "as the project root when a workspace root is provided." ), + ( + "- Workspace boundary is enforced by tools: do not read, search, edit, or run " + "commands against paths outside the allowed roots unless the user explicitly " + "requests an external path and the tool/approval policy permits it." + ), ( "- LeapFlow workspace config is optional at `/.leapflow/config.yaml`; " "runtime config is loaded from `~/.leapflow/config/user.yaml` and " @@ -837,7 +963,8 @@ def __init__( # Tool concurrency policy (None = sequential fallback) self._concurrency_policy: Optional[ToolConcurrencyPolicy] = ( - concurrency_policy if concurrency_policy is not None else DefaultConcurrencyPolicy() + concurrency_policy if concurrency_policy is not None + else DefaultConcurrencyPolicy(spec_lookup=_concurrency_spec_lookup) ) # Session persistence (injected by CLI) @@ -855,6 +982,14 @@ def __init__( # Cancellation: tracks active task for interrupt support self._active_task: Optional[asyncio.Task] = None self._cancel_requested = False + # Per-turn identity, set at turn start; initialized so reads and the + # subagent frame save/restore never hit an unset attribute. NOTE: + # _current_session_id is intentionally NOT re-initialized here — it is set + # to None at the top of __init__, and the session-creation path relies on + # that ``is None`` sentinel to mint a new session id, so overriding it to + # "" would silently disable conversation persistence. + self._current_turn_id: str = "" + self._current_command_id: str = "" # Tool loop guardrails (injected by CLI) self._guardrail: Optional[Any] = None @@ -890,45 +1025,40 @@ def __init__( # State-machine loop infrastructure (config-driven) self._budget_config = BudgetConfig( - max_iterations=settings.react_max_iterations, + max_iterations=settings.agent_iter_floor, soft_limit=settings.react_soft_limit, warning_threshold=settings.react_warning_threshold, + iter_ceiling=settings.agent_iter_ceiling, + hard_cap=settings.agent_iter_hard_cap, + scale_k=settings.agent_budget_scale_k, ) + # S3-L3: baseline difficulty weight, kept as the rollback/recompute anchor + # so calibration never compounds and reset is exact. + self._baseline_scale_k = settings.agent_budget_scale_k + # S3-L4: calibrated finalize-posture threshold (None = use configured baseline). + self._calibrated_finalizing_ratio: Optional[float] = None + # S3 periodic re-calibration (opt-in): evolution store + root-turn counter. + self._calibration_store: Optional[Any] = None + self._turns_since_calibration = 0 self._error_classifier = ErrorClassifier( recovery_map=build_recovery_map( transient_max_retries=settings.error_transient_max_retries, rate_limit_base_delay=settings.error_rate_limit_base_delay, ) ) - ctx_len = settings.llm_context_length - self._compressor = ContextCompressor(CompressorConfig( - token_budget=max(1, int(ctx_len * settings.context_hard_limit_ratio)), - context_length=ctx_len, - threshold=settings.compress_threshold, - keep_tail=settings.compress_keep_tail, - max_output_chars=settings.max_tool_output_chars, - )) + self._compressor = self._new_compressor() self._context_controller = ContextWindowController( estimator=ContextBudgetEstimator(), hard_limit_ratio=settings.context_hard_limit_ratio, warning_ratio=settings.context_warning_ratio, ) - self._context_governance_controller = ContextGovernanceController( - evidence_builder=ToolEvidenceBuilder( - max_content_chars=settings.tool_evidence_max_chars, - context_length=ctx_len, - ), - repeated_read_limit=settings.repeated_read_limit, - convergence_round=settings.long_task_convergence_round, - posture_config=ContextPostureConfig( - expanded_ratio=settings.context_expanded_ratio, - finalizing_ratio=settings.context_finalizing_ratio, - expanded_evidence_threshold=settings.context_expanded_evidence_threshold, - expanded_tool_call_threshold=settings.context_expanded_tool_call_threshold, - research_source_threshold=settings.context_research_source_threshold, - research_evidence_threshold=settings.context_research_evidence_threshold, - ), - ) + self._context_governance_controller = self._new_governance() + self._prefix_commitment = PrefixCommitmentController() + self._research_ledger = ResearchLedger() + self._research_ledger_store: Optional[Any] = None + self._reentry_store: Optional[Any] = None + self._active_frame: Optional[AgentLoopFrame] = None + self._full_tools_tokens: int | None = None self._last_context_snapshot: dict[str, Any] = {} self._last_disclosure_metadata: dict[str, Any] = {} self._current_task_contract: TaskContract | None = None @@ -954,6 +1084,21 @@ def __init__( self._checkpoint_store = InMemoryCheckpointStore() self._audit_sink = JsonlAuditSink() # In-memory; path-based if layout available + # Apply startup-time tool configuration derived from settings. + self._configure_tool_defaults() + + def _configure_tool_defaults(self) -> None: + """Wire settings-derived values into module-level tool defaults at start-up. + + Kept in its own method so child frames and test fixtures can call it + without re-running the full ``__init__`` body. + """ + try: + from leapflow.tools.shell_tools import set_max_shell_timeout + set_max_shell_timeout(self._settings.max_shell_timeout_s) + except Exception: # noqa: BLE001 - optional; defaults remain if import fails + logger.debug("_configure_tool_defaults: shell_tools not available") + # ── Optional strategy setters (config-driven) ──────────────────────── def set_cache_strategy(self, strategy: CacheStrategy | None) -> None: @@ -1004,35 +1149,13 @@ def reconfigure_runtime( self._llm = llm self._vlm = vlm self._classifier = classifier - ctx_len = settings.llm_context_length - self._compressor = ContextCompressor(CompressorConfig( - token_budget=max(1, int(ctx_len * settings.context_hard_limit_ratio)), - context_length=ctx_len, - threshold=settings.compress_threshold, - keep_tail=settings.compress_keep_tail, - max_output_chars=settings.max_tool_output_chars, - )) + self._compressor = self._new_compressor() self._context_controller = ContextWindowController( estimator=ContextBudgetEstimator(), hard_limit_ratio=settings.context_hard_limit_ratio, warning_ratio=settings.context_warning_ratio, ) - self._context_governance_controller = ContextGovernanceController( - evidence_builder=ToolEvidenceBuilder( - max_content_chars=settings.tool_evidence_max_chars, - context_length=ctx_len, - ), - repeated_read_limit=settings.repeated_read_limit, - convergence_round=settings.long_task_convergence_round, - posture_config=ContextPostureConfig( - expanded_ratio=settings.context_expanded_ratio, - finalizing_ratio=settings.context_finalizing_ratio, - expanded_evidence_threshold=settings.context_expanded_evidence_threshold, - expanded_tool_call_threshold=settings.context_expanded_tool_call_threshold, - research_source_threshold=settings.context_research_source_threshold, - research_evidence_threshold=settings.context_research_evidence_threshold, - ), - ) + self._context_governance_controller = self._new_governance() self._skill_merger = SkillMerger( registry=self._registry, llm=llm, @@ -1210,16 +1333,78 @@ def _check_guardrail( if not violation.violated: return None logger.warning("guardrail: %s", violation.reason) - if violation.severity == "halt": + # Progress-aware: while the task is still advancing (stall counter at 0), + # a detected repetition/domination is producing progress -> never halt, + # and the finalize/diversify nudge is suppressed so legitimate batch or + # sequential work on a long task is not cut short. Only when the task is + # ALSO stalled does the guardrail escalate to a halt (or emit a nudge). + frame = self._active_frame + stalled = bool(frame is not None and getattr(frame, "stalled_rounds", 0) >= 1) + if violation.severity == "halt" and stalled: messages.append(build_user_message_text( f"SYSTEM GUARDRAIL: {violation.reason}. {violation.suggestion}" )) return "halt" + if not stalled: + return None # productive: neither halt nor nudge messages.append(build_user_message_text( f"SYSTEM WARNING: {violation.reason}. {violation.suggestion}" )) return None + def _evaluate_tool_failures( + self, failed_items: List[tuple[str, Dict[str, Any]]], *, turn_id: int, + ) -> Optional[str]: + """Single recovery decision point for tool-result failures. + + A tool failure is an OBSERVATION for autonomous diagnosis: the failed + result is already in the message history and is fed back to the LLM, + which reasons about it and retries or changes approach on the next round. + There is NO blanket count-based break — a task that fails then fixes keeps + going; a genuinely stuck failure loop is bounded by the iteration budget, + progress-based stall detection, and the progress-aware guardrail. + + Each failure is classified into a FailureEnvelope. The turn halts ONLY + for a non-recoverable failure (e.g. permission denied), routed through + the coordinator for the terminal decision + audit. Recoverable failures + are fed back and audited as a zero-cost decision so they never spend the + system recovery budget (reserved for infrastructure recovery). Returns a + halt reason when the turn must stop, else None. + """ + coordinator = self._recovery_coordinator + if coordinator is None: + return None + session_id = getattr(self, "_current_session_id", "") or "" + for tool_name, result in failed_items: + if not isinstance(result, dict): + continue + envelope = self._unified_classifier.classify_tool_result( + result, tool_name=tool_name, + execution_policy=result.get("execution_policy", "read_only"), + ) + if envelope is None: + continue + if envelope.recoverability == Recoverability.NON_RECOVERABLE: + decision = coordinator.evaluate(envelope) + self._audit_sink.record(create_audit_entry( + envelope, decision, coordinator.budget, + session_id=session_id, turn_id=turn_id, + )) + return decision.reason or f"Non-recoverable tool failure ({envelope.category})" + # Recoverable: fed back to the agent (zero-cost, no recovery budget spent). + feedback = RecoveryDecision.create( + envelope=envelope, + action=RecoveryAction.SKIP_AND_CONTINUE, + reason="Tool failure fed back to the agent for autonomous diagnosis and retry", + strategy_key="tool_feedback", + budget_cost=0, + ) + self._audit_sink.record(create_audit_entry( + feedback.envelope, feedback, coordinator.budget, + session_id=session_id, turn_id=turn_id, + )) + return None + def set_tool_timeouts(self, timeouts: Dict[str, float]) -> None: """Set per-tool execution timeout overrides (seconds).""" self._tool_timeouts = dict(timeouts) @@ -1281,6 +1466,24 @@ def set_conversation_store(self, store: Any) -> None: self._conversation_store = store self._tool_execution_ledger.reset(store=store) + def set_research_ledger_store(self, store: Any) -> None: + """Inject the research-ledger persistence store (S1, optional). + + Wires the ledger change-listener so each note is persisted per session + (durable Orient). Without a store, the ledger degrades gracefully to + per-turn in-memory state. + """ + self._research_ledger_store = store + self._research_ledger.set_change_listener(self._persist_research_ledger) + + def set_reentry_store(self, store: Any) -> None: + """Inject the re-entry store (S2, optional). + + Absent => ``schedule_reentry`` reports "not configured". Registration is + additionally gated by ``agent_reentry_enabled`` (default off). + """ + self._reentry_store = store + def load_session(self, session_id: str) -> bool: """Resume a previous session by loading messages from DuckDB. @@ -1357,10 +1560,24 @@ def _active_context_length(self) -> int: def _begin_turn_context(self, user_text: str) -> None: """Reset turn-scoped state and build the stable task contract.""" + self._maybe_periodic_recalibration() self._memory_context_snapshot = None self._last_context_snapshot = {} self._last_disclosure_metadata = {} self._context_governance_controller.reset_turn_scope() + self._prefix_commitment.reset() + if self._research_ledger_store is not None and self._current_session_id: + self._research_ledger.load_state( + self._research_ledger_store.load(self._current_session_id) + ) + else: + self._research_ledger.reset() + try: + from leapflow.tools.registry_bootstrap import set_research_ledger, set_reentry_scheduler + set_research_ledger(self._research_ledger) + set_reentry_scheduler(self._schedule_reentry) + except ImportError: + pass self._current_task_contract = self._build_task_contract(user_text) self._current_turn_id = self._current_task_contract.task_id self._current_command_id = self._current_task_contract.task_id @@ -1585,7 +1802,14 @@ def _merge_expanded_tool_schemas( return {"tools": existing} def _build_session_summary_context(self, *, max_messages: int) -> str: - """Return a compact local session summary without retrieval or extra LLM calls.""" + """Return a structured local session summary without retrieval or extra LLM calls. + + Structured format preserves more signal per turn compared to a flat + 180-char single-line preview: + - User turns: full first line up to 400 chars (preserves intent). + - Assistant turns with tool calls: tool names + brief outcome. + - Assistant prose turns: content preview up to 300 chars. + """ messages = self._wm.as_chat_messages() summary_lines: list[str] = [] for message in messages[-max(0, max_messages):]: @@ -1600,9 +1824,23 @@ def _build_session_summary_context(self, *, max_messages: int) -> str: ) elif not isinstance(content, str): content = str(content) - preview = _single_line_preview(content, limit=180) - if preview: - summary_lines.append(f"- {role}: {preview}") + + if role == "user": + # Preserve full user intent: first meaningful line, up to 400 chars. + first_line = content.strip().split("\n")[0][:400] + if first_line: + summary_lines.append(f"- [user] {first_line}") + elif content.startswith("[Called:"): + # Working-memory stores tool-calling turns as "[Called: t1, t2]" + # summary strings. Extract and preserve the tool list concisely. + called_text = content[8:].rstrip("]").strip()[:200] + summary_lines.append(f"- [assistant] called: {called_text}") + else: + # Assistant prose: single-line preview up to 300 chars. + preview = _single_line_preview(content, limit=300) + if preview: + summary_lines.append(f"- [assistant] {preview}") + if not summary_lines: return "" return "\n## Recent Session Summary\n" + "\n".join(summary_lines) + "\n" @@ -1653,6 +1891,15 @@ def _prepare_llm_messages( context_length = self._active_context_length() token_count = self._context_controller.estimator.estimate_messages(messages) prepared = self._compressor.compress(messages, token_count=token_count) + if ( + getattr(self._settings, "agent_compression_writeback", False) + and len(prepared) < len(messages) + ): + # E-3 (CL-8): persist the structural compression so append-only frozen + # segments stay byte-stable across rounds -> continuous prefix-cache + # reuse. The volatile notices appended below are NOT written back; the + # recent raw tail is preserved by the compressor. Opt-in (default off). + messages[:] = prepared prepared = self._ensure_task_contract_message(prepared) compression_trace = self._compressor.last_trace.as_dict() prepared = self._compressor.preflight_check(prepared, context_length=context_length) @@ -1672,10 +1919,17 @@ def _prepare_llm_messages( decision.snapshot, round_number=round_number, ) - convergence = self._context_governance_controller.convergence_notice(round_number) - for notice in (warning, convergence): + open_questions = self._ledger_open_questions() + convergence = self._context_governance_controller.convergence_notice( + round_number, open_questions=open_questions, + ) + cost_notice = self._cost_ceiling_notice() + for notice in (warning, convergence, cost_notice): if notice: prepared = [*prepared, build_user_message_text(notice)] + ledger_block = self._research_ledger.render() + if ledger_block: + prepared = [*prepared, build_user_message_text(ledger_block)] prepared = self._ensure_task_contract_message(prepared) snapshot = self._context_controller.estimator.snapshot( prepared, @@ -1685,6 +1939,7 @@ def _prepare_llm_messages( governance = self._context_governance_controller.snapshot( context_ratio=snapshot.ratio, round_number=round_number, + open_questions=open_questions, ).as_dict() compressed = decision.compressed or bool(compression_trace.get("stages_applied")) self._last_context_tokens = snapshot.total_tokens @@ -1701,6 +1956,9 @@ def _prepare_llm_messages( "compression_savings_ratio": compression_trace.get("savings_ratio", 0.0), "compression_saved_tokens": compression_trace.get("saved_tokens", 0), "context_governance": governance, + "difficulty": governance.get("difficulty", 0.0), + "cumulative_effective_tokens": self._usage_tracker.summary().effective_prompt_tokens(), + "open_questions": open_questions, "context_posture": governance.get("posture", "baseline"), "context_signal": governance.get("dominant_signal", ""), "context_guidance": governance.get("guidance", ""), @@ -1713,6 +1971,346 @@ def _prepare_llm_messages( self._usage_tracker.mark_compression() return prepared + def recalibrate_difficulty(self, store: Any) -> Any: + """S3-L3: apply offline calibration (S3-L2) to the difficulty weight. + + Bounded, gated, and reversible: reads recent turn signals from the + evolution store and — only when ``agent.calibration_enabled`` — installs a + clamped ``scale_k`` derived from the *baseline* weight. Default-off, so + budget behavior is byte-identical unless explicitly enabled. Returns the + ``CalibrationResult`` for observability. + """ + from leapflow.learning.difficulty_calibration import ( + CalibrationResult, + apply_calibration, + build_calibration_report_from_store, + ) + + enabled = bool(getattr(self._settings, "agent_calibration_enabled", False)) + if not enabled or store is None: + return CalibrationResult( + self._baseline_scale_k, self._budget_config.scale_k, False, + "calibration disabled" if not enabled else "no evolution store", + ) + try: + report = build_calibration_report_from_store(store) + except Exception: + logger.debug("difficulty calibration: report build failed", exc_info=True) + return CalibrationResult( + self._baseline_scale_k, self._budget_config.scale_k, False, "report build failed", + ) + result = apply_calibration( + self._baseline_scale_k, report, enabled=True, + min_confidence=float(getattr(self._settings, "agent_calibration_min_confidence", 0.3)), + ) + if result.applied: + self._budget_config = replace(self._budget_config, scale_k=result.effective_k) + logger.info( + "difficulty calibration applied: scale_k %.3f -> %.3f (%s)", + self._baseline_scale_k, result.effective_k, result.reason, + ) + return result + + def reset_calibration(self) -> None: + """Revert any applied difficulty calibration to the configured baseline.""" + self._budget_config = replace(self._budget_config, scale_k=self._baseline_scale_k) + + def recalibrate_thresholds(self, store: Any) -> Any: + """S3-L4: tune the finalize posture threshold from stored signals. + + Same bounded/gated/reversible contract as :meth:`recalibrate_difficulty`, + applied to ``context_finalizing_ratio`` (clamped to a safe band) and + derived from the configured baseline. Default-off; rebuilds the governance + controller so subsequent frames observe the calibrated threshold. + """ + from leapflow.learning.difficulty_calibration import ( + CalibrationResult, + apply_calibration, + build_threshold_report_from_store, + ) + + baseline = self._settings.context_finalizing_ratio + current = self._calibrated_finalizing_ratio or baseline + enabled = bool(getattr(self._settings, "agent_calibration_enabled", False)) + if not enabled or store is None: + return CalibrationResult( + baseline, current, False, + "calibration disabled" if not enabled else "no evolution store", + ) + try: + report = build_threshold_report_from_store(store) + except Exception: + logger.debug("threshold calibration: report build failed", exc_info=True) + return CalibrationResult(baseline, current, False, "report build failed") + result = apply_calibration( + baseline, report, enabled=True, + min_confidence=float(getattr(self._settings, "agent_calibration_min_confidence", 0.3)), + k_min=0.6, k_max=0.98, + ) + if result.applied: + self._calibrated_finalizing_ratio = result.effective_k + self._context_governance_controller = self._new_governance() + logger.info( + "threshold calibration applied: finalizing_ratio %.3f -> %.3f (%s)", + baseline, result.effective_k, result.reason, + ) + return result + + def reset_threshold_calibration(self) -> None: + """Revert any applied finalize-threshold calibration to the baseline.""" + self._calibrated_finalizing_ratio = None + self._context_governance_controller = self._new_governance() + + def set_calibration_store(self, store: Any) -> None: + """Install the evolution store used for periodic S3 re-calibration.""" + self._calibration_store = store + + def _maybe_periodic_recalibration(self) -> None: + """S3-L3/L4 periodic re-calibration (opt-in via agent.calibration_interval_turns). + + The one-shot startup calibration already applies the learned adjustment; + when a positive interval is set, re-run every N *root* turns so calibration + tracks accumulating outcome data. Default 0 = one-shot only (no periodic). + Bounded/gated/reversible like the underlying recalibration; never raises. + """ + if not getattr(self._settings, "agent_calibration_enabled", False): + return + interval = int(getattr(self._settings, "agent_calibration_interval_turns", 0) or 0) + if interval <= 0 or self._calibration_store is None: + return + self._turns_since_calibration += 1 + if self._turns_since_calibration < interval: + return + self._turns_since_calibration = 0 + try: + self.recalibrate_difficulty(self._calibration_store) + self.recalibrate_thresholds(self._calibration_store) + except Exception: + logger.debug("periodic recalibration failed", exc_info=True) + + def _widen_budget_for_difficulty(self, budget: IterationBudget) -> None: + """Raise the elastic iteration cap to match the observed difficulty. + + Reads the difficulty produced by the most recent ``_prepare_llm_messages`` + governance snapshot and retargets the budget toward the difficulty-scaled + ceiling. No-op for fixed budgets and for difficulty 0 (baseline floor). + This is how a hard task earns a wider horizon while a simple task stays + near the floor and relies on self-stop / answer-ready convergence. + """ + difficulty = float(self._last_context_snapshot.get("difficulty", 0.0) or 0.0) + budget.retarget(budget.elastic_max(difficulty)) + + def _task_progress_marker(self) -> tuple: + """Fingerprint of task progress for stall detection (P0). + + Combines the research-ledger shape (findings / open questions / + decisions / next step) with governance evidence breadth (evidence count, + distinct sources). A change between rounds means the task advanced; an + unchanged marker across rounds indicates a stall. Works for ledger-using + tasks and, via governance signals, for tasks that never call research_note. + """ + d = self._research_ledger.as_dict() + gov = self._last_context_snapshot.get("context_governance", {}) or {} + return ( + len(d.get("findings", [])), + len(d.get("open_questions", [])), + len(d.get("decisions", [])), + d.get("next_step", ""), + int(gov.get("evidence_count", 0) or 0), + int(gov.get("sources_seen", 0) or 0), + ) + + def _update_progress_and_stall(self, frame: AgentLoopFrame) -> None: + """Advance the frame's stall counter: reset on progress, else increment.""" + marker = self._task_progress_marker() + if marker == frame.progress_marker: + frame.stalled_rounds += 1 + else: + frame.stalled_rounds = 0 + frame.progress_marker = marker + # Genuine progress: re-arm content-level recovery one-shots so a long + # task can recover again later (e.g. multiple max_tokens continuations + # or force-compressions across a long turn). Storm-prone infrastructure + # one-shots stay strict (bounded by the RecoveryBudget instead). + if frame.recovery is not None: + frame.recovery.rearm_after_progress() + + def _within_resource_limits(self) -> bool: + """Whether real resource budgets (cost) still allow continuation. + + The absolute iteration hard cap is enforced by the budget itself; this + guards the *cost* ceiling when configured (0 disables). Context pressure + is handled separately by the finalizing posture. + """ + multiple = float(getattr(self._settings, "agent_cost_ceiling_context_multiple", 0.0) or 0.0) + if multiple <= 0: + return True + effective = self._usage_tracker.summary().effective_prompt_tokens() + ceiling = multiple * float(self._active_context_length() or 0) + return ceiling <= 0 or effective < ceiling + + def _should_extend_budget(self, frame: AgentLoopFrame) -> bool: + """Progress-gated continuation decision (P0). + + Extend the iteration budget past the elastic ceiling only when the task + is *productively unfinished*: within resource limits, still making + progress (not stalled), and not already signalled complete by the ledger + (zero open questions). A stalled, complete, or resource-exhausted task is + allowed to converge and stop — so a productive long task continues while a + spinning one halts. + """ + if not self._within_resource_limits(): + return False + stall_rounds = int(getattr(self._settings, "agent_stall_rounds", 6) or 6) + if frame.stalled_rounds >= stall_rounds: + return False + open_q = self._ledger_open_questions() + if open_q is not None and open_q == 0: + return False + return True + + def _ledger_open_questions(self) -> int | None: + """Ledger sufficiency signal for convergence: None when the ledger is + inactive/empty (fall back to the marginal heuristic), else the current + open-question count. A positive count suppresses early answer-ready + convergence so a long task with tracked open work is never cut short. + """ + ledger = self._research_ledger + return None if ledger.is_empty else ledger.open_question_count + + def orientation_view(self, *, now: Optional[float] = None) -> Any: + """Read-only unified orientation across immediate/working/long-term layers (S4-D1). + + Observe-only aggregation of existing state: the current research ledger + forms the working layer (findings / open questions / next step). Changes + no state; usable by dashboards, diagnostics, and future autonomy phases. + """ + from leapflow.world_model.orientation import build_orientation_from_ledger + + return build_orientation_from_ledger( + self._research_ledger.to_state(), + now=now if now is not None else time.time(), + ) + + def _persist_research_ledger(self) -> None: + """Persist the ledger for the active session (best-effort; S1 durable Orient). + + Fired as the ledger change-listener after each note. No-op when no store + is wired or no session is established yet. + """ + store = self._research_ledger_store + session_id = self._current_session_id + if store is None or not session_id: + return + store.save(session_id, self._research_ledger.to_state()) + + def _schedule_reentry( + self, + *, + kind: str = "time", + reason: str = "", + delay_seconds: Any = 0.0, + event_match: Any = None, + max_reentries: Any = 1, + deadline_seconds: Any = 0.0, + ) -> Dict[str, Any]: + """Register a re-entry trigger seeded with the current orientation (S2 N2). + + Gated by ``agent_reentry_enabled`` (default off). Only persists a trigger + (Orient snapshot = research-ledger state + task contract + reason); the + actual wake-up dispatch is a later phase (N3+). + """ + if not getattr(self._settings, "agent_reentry_enabled", False): + return {"ok": False, "error": "re-entry is disabled (set agent.reentry_enabled=true)"} + if self._reentry_store is None: + return {"ok": False, "error": "re-entry store not configured"} + contract = self._current_task_contract + task_id = contract.task_id if contract else (self._current_session_id or "task") + try: + trigger = build_reentry_trigger( + task_id=task_id, + session_id=self._current_session_id or "", + ledger_state=self._research_ledger.to_state(), + task_contract=asdict(contract) if contract else {}, + continuation_summary=reason, + kind=kind, + delay_seconds=float(delay_seconds or 0.0), + event_match=dict(event_match or {}), + max_reentries=int(max_reentries or 1), + deadline_seconds=float(deadline_seconds or 0.0), + ) + self._reentry_store.save(trigger) + except Exception as exc: + return {"ok": False, "error": f"failed to schedule re-entry: {exc}"} + return { + "ok": True, + "trigger_id": trigger.trigger_id, + "kind": trigger.kind, + "due_at": trigger.due_at, + "note": "registered; wake-up dispatch activates in a later phase", + } + + def _cost_ceiling_notice(self) -> str: + """Soft finalize nudge when cumulative effective cost crosses the ceiling. + + Opt-in safety companion to the elastic iteration cap: bounds runaway cost + on large-context long tasks. Soft (a nudge, not a hard stop) so no work is + lost; the iteration ceiling remains the hard bound. Disabled by default + (``agent_cost_ceiling_context_multiple`` = 0). + """ + multiple = float(getattr(self._settings, "agent_cost_ceiling_context_multiple", 0.0) or 0.0) + if multiple <= 0: + return "" + effective = self._usage_tracker.summary().effective_prompt_tokens() + if not cost_ceiling_exceeded( + effective_prompt_tokens=effective, + context_length=self._active_context_length(), + context_multiple=multiple, + ): + return "" + return ( + "SYSTEM: Cumulative cost budget reached. Synthesize and provide the final " + "answer now from the evidence already gathered; do not start new exploratory " + "tool calls unless strictly required." + ) + + def _full_tool_schema_tokens(self) -> int: + """Cached token estimate of the full tool catalog schema (static per process).""" + if self._full_tools_tokens is None: + from leapflow.tools.registry_bootstrap import TOOL_DEFINITIONS + self._full_tools_tokens = self._context_controller.estimator.estimate_tools(TOOL_DEFINITIONS) + return self._full_tools_tokens + + def _evaluate_prefix_commitment(self, budget: IterationBudget) -> None: + """Evaluate the adaptive prefix-commitment decision (observe-only, W2 slice 2). + + Computes whether the task should commit to a stable, cacheable prefix and + records the decision in the context snapshot for observability. Does not + yet enforce (freeze disclosure / lock tools / cache-aware compression) -- + that is W2 slice 3. Reuses the token counts already produced by + ``_prepare_llm_messages`` plus the post-retarget budget headroom, so it is + cheap (no re-estimation of the message body) and changes no behavior. + """ + snap = self._last_context_snapshot + if not snap: + return + difficulty = float(snap.get("difficulty", 0.0) or 0.0) + posture = str(snap.get("context_posture") or "baseline") + message_tokens = int(snap.get("message_tokens", 0) or 0) + disclosed_tool_tokens = int(snap.get("tool_schema_tokens", 0) or 0) + est_full = message_tokens + self._full_tool_schema_tokens() + est_pcd = message_tokens + disclosed_tool_tokens + state = self._prefix_commitment.evaluate( + difficulty=difficulty, + posture=posture, + round_number=budget.used, + remaining_rounds=budget.remaining, + est_full_prefix_tokens=est_full, + est_pcd_prefix_tokens=est_pcd, + ) + snap["prefix_commitment"] = state.as_dict() + snap["prefix_committed"] = state.committed + def _record_provider_usage(self, model: str, usage: Dict[str, Any]) -> None: """Prefer provider prompt usage when available and learn observed limits.""" provider_prompt = int(usage.get("prompt_tokens", 0) or 0) @@ -1850,388 +2448,212 @@ def _build_app_connector_section(self) -> str: logger.debug("app connector prompt section unavailable", exc_info=True) return "" - # ── Complex Task Handling (DAG path) ───────────────────────────── - - async def _handle_complex_task(self, user_goal: str) -> str: - """Handle complex multi-step tasks via DAG planning and execution. + # ── Unified Tool Loop (chat scenarios) ─────────────────────────────── - Flow: GraphPlanner → TaskScheduler → summary report. - Falls back to ReAct loop on planning failure. - """ - assert self._graph_planner is not None - assert self._scheduler is not None + def _new_compressor(self) -> ContextCompressor: + """Fresh context compressor (per engine, or per isolated child frame).""" + ctx_len = self._settings.llm_context_length + return ContextCompressor(CompressorConfig( + token_budget=max(1, int(ctx_len * self._settings.context_hard_limit_ratio)), + context_length=ctx_len, + threshold=self._settings.compress_threshold, + keep_tail=self._settings.compress_keep_tail, + max_output_chars=self._settings.max_tool_output_chars, + )) - try: - _log_progress("Building execution plan...") - graph = await self._graph_planner.plan(user_goal) - self._wm.remember_event( - "dag_plan", graph.summary(), {"nodes": len(graph.nodes)} - ) - node_names = [n.name for n in list(graph.nodes.values())[:10]] - _log_progress(f"Execution plan ready: {len(graph.nodes)} steps — {', '.join(node_names)}") - graph = await self._scheduler.execute_graph(graph) - _log_progress("Plan execution complete") - return graph.summary() - except (ValueError, Exception) as e: - _log_progress(f"DAG planning failed ({e}), falling back to ReAct loop") - logger.warning("audit.dag_fallback reason=%s", e) - return await self._fallback_react(user_goal) - - async def _fallback_react(self, user_text: str) -> str: - """Fallback to ReAct loop when DAG planning/execution fails.""" - steps = await self._plan_steps(user_text) - self._wm.remember_event("plan", " | ".join(steps), {"steps": steps}) - return await self._react_loop(user_text, steps) - - async def _plan_steps(self, user_goal: str) -> List[str]: - """Generate flat step list via LLM for the ReAct loop.""" - catalog = self._registry.describe() - messages = [ - build_system_message( - "Return STRICT JSON: {\"steps\":[\"...\", ...]} with 3-7 steps for the goal. " - f"Available skills:\n{catalog}" + def _new_governance(self) -> ContextGovernanceController: + """Fresh context-governance controller (per engine, or per child frame).""" + ctx_len = self._settings.llm_context_length + return ContextGovernanceController( + evidence_builder=ToolEvidenceBuilder( + max_content_chars=self._settings.tool_evidence_max_chars, + context_length=ctx_len, ), - build_user_message_text(user_goal), - ] - try: - resp = await self._llm.achat(messages, stream=False, enable_thinking=False) - raw = (resp.content or "").strip() - start = raw.find("{") - end = raw.rfind("}") - blob = raw[start : end + 1] if start != -1 and end != -1 else raw - data = json.loads(blob) - steps = [str(x) for x in list(data.get("steps") or [])] - return steps[:10] - except Exception: - logger.debug("plan_steps failed", exc_info=True) - return [user_goal] + repeated_read_limit=self._settings.repeated_read_limit, + convergence_round=self._settings.long_task_convergence_round, + convergence_round_ceiling=self._settings.convergence_round_ceiling, + convergence_scale=self._settings.convergence_scale, + posture_config=ContextPostureConfig( + expanded_ratio=self._settings.context_expanded_ratio, + finalizing_ratio=( + getattr(self, "_calibrated_finalizing_ratio", None) + or self._settings.context_finalizing_ratio + ), + expanded_evidence_threshold=self._settings.context_expanded_evidence_threshold, + expanded_tool_call_threshold=self._settings.context_expanded_tool_call_threshold, + research_source_threshold=self._settings.context_research_source_threshold, + research_evidence_threshold=self._settings.context_research_evidence_threshold, + ), + ) - async def _react_loop( + def _build_child_frame( self, user_text: str, - steps: List[str], *, + depth: int, + tool_filter: "frozenset[str] | None" = None, enable_thinking: bool = False, - ) -> str: - """State-machine driven ReAct loop (async shell, sync semantics).""" - budget = IterationBudget.for_react(self._budget_config) - trace = ExecutionTrace() - ctx = _LoopContext(messages=self._build_loop_messages(user_text, steps)) - state = ExecutionMode.PREPARING - - while state != ExecutionMode.COMPLETE: - state = await self._loop_step( - state, ctx, budget, trace, - user_text=user_text, - enable_thinking=enable_thinking, - ) - - # Fire-and-forget: emit learning signal to the evolution ring - if trace.has_learning_signal: - asyncio.create_task(self._emit_execution_trace(trace)) - - # Sync conversation turn to long-term memory (non-blocking) - if self._memory_manager and self._settings.memory_integration_enabled: - asyncio.create_task(self._sync_turn_safe(ctx.messages)) + parent_session_id: Optional[str] = None, + ) -> AgentLoopFrame: + """Build an isolated child frame with fresh per-turn subsystems. - logger.info( - "react_loop.complete steps=%d tokens=%d success=%s", - trace.step_count, trace.total_tokens, trace.success, + A recursive subagent runs the same ``_run_agent_loop`` on this frame; the + fresh budget/recovery/governance/ledger/commitment/usage/compressor keep + its OODA loop from contaminating the parent frame's state. + """ + return AgentLoopFrame( + user_text=user_text, + depth=depth, + budget=IterationBudget.for_react(self._budget_config), + recovery=TurnRecoveryState(), + governance=self._new_governance(), + ledger=ResearchLedger(), + commitment=PrefixCommitmentController(), + usage_tracker=TurnUsageTracker(), + compressor=self._new_compressor(), + tool_filter=tool_filter, + enable_thinking=enable_thinking, + parent_session_id=parent_session_id, ) - return ctx.last_content or "Stopped after step budget." - - # ── State Machine Core ────────────────────────────────────────────── - - async def _loop_step( # noqa: C901 (state machine dispatch) - self, - state: ExecutionMode, - ctx: _LoopContext, - budget: IterationBudget, - trace: ExecutionTrace, - *, - user_text: str, - enable_thinking: bool, - ) -> ExecutionMode: - """Execute one state transition. Returns the next state.""" - - if state == ExecutionMode.PREPARING: - return await self._state_preparing(ctx, budget, trace, user_text=user_text) - - if state == ExecutionMode.REASONING: - return await self._state_reasoning(ctx, trace, enable_thinking=enable_thinking) - - if state == ExecutionMode.ROUTING: - return self._state_routing(ctx, trace) - - if state == ExecutionMode.ACTING: - return await self._state_acting(ctx, trace, user_text=user_text) - - if state == ExecutionMode.OBSERVING: - return self._state_observing(ctx, budget, trace) - - if state == ExecutionMode.RECOVERING: - return await self._state_recovering(ctx, budget, trace) - - # Fallback: unreachable unless enum extended - trace.record(ExecutionMode.COMPLETE, error="invalid_state") - return ExecutionMode.COMPLETE - - # ── State Handlers ────────────────────────────────────────────────── - - async def _state_preparing( - self, ctx: _LoopContext, budget: IterationBudget, trace: ExecutionTrace, - *, user_text: str = "", - ) -> ExecutionMode: - """Budget check + memory prefetch + message healing + compression.""" - status = budget.consume() - if status == BudgetStatus.EXHAUSTED: - trace.record(ExecutionMode.COMPLETE, error="budget_exhausted") - ctx.last_content = self._budget_exhausted_response(ctx.messages) - return ExecutionMode.COMPLETE - - # Prefetch memory context on first iteration (non-blocking with timeout) - if not ctx.prefetch_done and self._memory_manager and self._settings.memory_integration_enabled: - ctx.prefetch_done = True - try: - entries = await asyncio.wait_for( - self._memory_manager.prefetch( - user_text, limit=self._settings.memory_prefetch_limit, - ), - timeout=self._settings.memory_prefetch_timeout_s, - ) - if entries: - context_lines = [e.content for e in entries if e.content] - if context_lines: - memory_block = ( - "MEMORY_CONTEXT (relevant past experiences):\n" - + "\n".join(f"- {line}" for line in context_lines) - ) - ctx.messages.append(build_user_message_text(memory_block)) - logger.debug("memory.prefetch injected %d entries", len(context_lines)) - except asyncio.TimeoutError: - logger.debug("memory.prefetch timed out (%.1fs)", self._settings.memory_prefetch_timeout_s) - except Exception: - logger.debug("memory.prefetch failed", exc_info=True) - - # Heal and compress - ctx.messages = self._healer.heal(ctx.messages) - ctx.messages = self._compressor.compress(ctx.messages) - - # Inject convergence hint near soft limit - if status == BudgetStatus.SOFT_LIMIT: - ctx.messages.append(build_user_message_text( - "SYSTEM: Approaching iteration limit. Please converge and provide final answer." - )) - - remaining = budget.remaining - _log_progress(f"Thinking (budget {remaining} remaining)...") - return ExecutionMode.REASONING - - async def _state_reasoning( - self, ctx: _LoopContext, trace: ExecutionTrace, *, enable_thinking: bool - ) -> ExecutionMode: - """LLM call — the only await in the hot path.""" - t0 = time.perf_counter() - try: - resp = await self._llm.achat( - ctx.messages + self._wm.as_chat_messages(), - stream=False, - enable_thinking=enable_thinking, - ) - latency = (time.perf_counter() - t0) * 1000 - content = (resp.content or "").strip() - tokens = getattr(resp, "usage_tokens", 0) - trace.record(ExecutionMode.REASONING, tokens_used=tokens, latency_ms=latency) - - if resp.thinking_content: - logger.debug("audit.thinking chars=%s", len(resp.thinking_content)) - - ctx.last_content = content - self._wm.remember_chat(build_assistant_message(content)) - ctx.messages.append(build_assistant_message(content)) - return ExecutionMode.ROUTING - - except Exception as exc: - trace.record(ExecutionMode.RECOVERING, error=str(exc)) - ctx.last_error = exc - return ExecutionMode.RECOVERING - - def _state_routing(self, ctx: _LoopContext, trace: ExecutionTrace) -> ExecutionMode: - """Parse LLM output and decide: final answer, action, or raw text.""" - content = ctx.last_content + def _install_frame(self, frame: AgentLoopFrame) -> Dict[str, Any]: + """Install a frame's per-turn subsystems as the engine's active state. + Returns the previous per-turn state for restoration. This lets a child + frame run the full loop on the shared engine while the parent frame's + subsystems stay untouched (see ``_run_child_frame``). + """ + saved: Dict[str, Any] = { + "governance": self._context_governance_controller, + "ledger": self._research_ledger, + "commitment": self._prefix_commitment, + "usage": self._usage_tracker, + "compressor": self._compressor, + "coordinator": self._recovery_coordinator, + "snapshot": self._last_context_snapshot, + "categories": self._last_turn_tool_categories, + # Per-turn identity is turn-start-set and non-propagating, so it is + # scoped to the frame (a child that reassigns it must not leak to the + # parent). NOTE: _cancel_requested is deliberately NOT saved here — it + # is a cross-frame signal that must propagate into a running child. + "session_id": self._current_session_id, + "turn_id": self._current_turn_id, + "command_id": self._current_command_id, + "frame": self._active_frame, + } + self._context_governance_controller = frame.governance + self._research_ledger = frame.ledger + self._prefix_commitment = frame.commitment + self._usage_tracker = frame.usage_tracker + self._compressor = frame.compressor + if frame.recovery_coordinator is not None: + self._recovery_coordinator = frame.recovery_coordinator + self._last_context_snapshot = frame.last_context_snapshot + self._last_turn_tool_categories = frame.last_turn_tool_categories + self._active_frame = frame + return saved + + def _restore_per_turn_state(self, saved: Dict[str, Any]) -> None: + """Restore per-turn state previously saved by ``_install_frame``.""" + self._context_governance_controller = saved["governance"] + self._research_ledger = saved["ledger"] + self._prefix_commitment = saved["commitment"] + self._usage_tracker = saved["usage"] + self._compressor = saved["compressor"] + self._recovery_coordinator = saved["coordinator"] + self._last_context_snapshot = saved["snapshot"] + self._last_turn_tool_categories = saved["categories"] + self._current_session_id = saved["session_id"] + self._current_turn_id = saved["turn_id"] + self._current_command_id = saved["command_id"] + self._active_frame = saved["frame"] + + async def _run_child_frame(self, frame: AgentLoopFrame) -> str: + """Run a recursive subagent's isolated frame through the full loop. + + Swaps the engine's per-turn state to the child frame for the duration of + the child loop, then restores the parent's state — so recursion is fully + state-isolated without duplicating the loop body. + """ + saved = self._install_frame(frame) try: - obj = _extract_json_object(content) - except Exception: - # No parseable JSON — treat as final answer - logger.info("audit.react_no_json; returning assistant text") - trace.record(ExecutionMode.COMPLETE) - return ExecutionMode.COMPLETE - - action = obj.get("action") or {} - a_type = str(action.get("type", "")).strip() + return await self._run_agent_loop(frame) + finally: + self._restore_per_turn_state(saved) - # Final answer - if a_type == "answer" and str(action.get("name")) == "final": - payload = action.get("payload") or {} - ans = str(payload.get("text") or payload.get("content") or "").strip() - ctx.last_content = ans or content - trace.record(ExecutionMode.COMPLETE) - return ExecutionMode.COMPLETE - - if not a_type: - # No action type — treat content as final answer - trace.record(ExecutionMode.COMPLETE) - return ExecutionMode.COMPLETE - - # Prepare prediction loop (world model) - action_name = str(action.get("name", a_type)).strip() - predicted_effect = str(obj.get("predicted_effect", "")).strip() - if predicted_effect: - self._wm.remember_event( - "react_prediction", - f"action={action_name}, predicted={predicted_effect}", - {"action": action_name}, - ) - - ctx.last_action = action - return ExecutionMode.ACTING - - async def _state_acting( - self, ctx: _LoopContext, trace: ExecutionTrace, *, user_text: str - ) -> ExecutionMode: - """Execute the parsed action via skill/bridge.""" - action = ctx.last_action or {} - action_name = str(action.get("name", action.get("type", ""))).strip() - action_payload = action.get("payload") or {} - - payload_hint = json.dumps(action_payload, ensure_ascii=False) - if len(payload_hint) > 200: - payload_hint = payload_hint[:200] + "..." - _log_progress(f"Executing action: {action_name} — {payload_hint}") + async def _run_subagent_goal( + self, + goal: str, + *, + depth: int, + tool_filter: "frozenset[str] | None" = None, + enable_thinking: bool = False, + ) -> str: + """Run a subagent goal as an isolated child frame through the full loop. - # Prediction loop pre-snapshot - a_type = str(action.get("type", "")).strip() - predicted_effect = "" - # Extract predicted_effect from original JSON if available - try: - obj = _extract_json_object(ctx.last_content) - predicted_effect = str(obj.get("predicted_effect", "")).strip() - except Exception: - pass + Bridge for ``EngineFrameSubagentExecutor`` (opt-in full-loop subagents): + the child frame's fresh subsystems + per-frame swap keep the subagent + from contaminating the parent turn's state. + """ + frame = self._build_child_frame( + goal, depth=depth, tool_filter=tool_filter, enable_thinking=enable_thinking, + ) + return await self._run_child_frame(frame) - react_prediction = None - pl = self._registry.prediction_loop - if predicted_effect and pl is not None and pl.enabled: - react_prediction = pl.create_from_react_prediction( - action_desc=f"{a_type}:{action_name}", - predicted_effect=predicted_effect, - ) - await pl.capture_pre_snapshot() + def _build_frame( + self, user_text: str, enable_thinking: bool, budget: Any, recovery: Any, + ) -> AgentLoopFrame: + """Bundle per-frame state around the given budget/recovery. - t0 = time.perf_counter() - observation = await self._execute_action(action, user_text) - latency = (time.perf_counter() - t0) * 1000 - - # Prediction loop verification - if react_prediction is not None and pl is not None: - await pl.verify_prediction(react_prediction) - - trace.record( - ExecutionMode.ACTING, - action=action, - observation=observation if isinstance(observation, dict) else {"result": str(observation)}, - latency_ms=latency, + The root frame wraps the engine's (freshly reset) per-turn subsystems so + loop-path reads through ``self._active_frame`` are byte-equivalent to the + singletons; recursive subagents (later) build frames with fresh subsystems. + """ + return AgentLoopFrame( + user_text=user_text, + enable_thinking=enable_thinking, + budget=budget, + recovery=recovery, + governance=self._context_governance_controller, + ledger=self._research_ledger, + commitment=self._prefix_commitment, + usage_tracker=self._usage_tracker, + compressor=self._compressor, + session_id=self._current_session_id, + turn_id=self._current_turn_id, + command_id=self._current_command_id, ) - ctx.last_observation = observation - return ExecutionMode.OBSERVING - - def _state_observing( - self, ctx: _LoopContext, budget: IterationBudget, trace: ExecutionTrace - ) -> ExecutionMode: - """Evaluate observation and feed back into messages.""" - observation = ctx.last_observation - is_error = isinstance(observation, dict) and not observation.get("ok", True) - - if is_error: - ctx.consecutive_failures += 1 - error_detail = ( - observation.get("error", "unknown error") - if isinstance(observation, dict) - else str(observation) - ) - category = self._error_classifier.classify_tool_error(observation) - max_tool_failures = self._settings.max_consecutive_tool_failures - - if category == ErrorCategory.PERMANENT or ctx.consecutive_failures >= max_tool_failures: - _log_progress(f"{ctx.consecutive_failures} consecutive failures — stopping") - trace.record(ExecutionMode.COMPLETE, error="max_tool_failures") - ctx.last_content = self._error_response(observation) - return ExecutionMode.COMPLETE - - _log_progress(f"Action failed ({ctx.consecutive_failures}/3): {error_detail}") - budget.refund("tool_failure") - error_obs = json.dumps( - {"observation": observation, "recovery_hint": "Previous action failed. Try an alternative approach."}, - ensure_ascii=False, - ) - ctx.messages.append(build_user_message_text(error_obs)) - self._wm.remember_event("react_error", str(error_detail)[:200], {}) - return ExecutionMode.PREPARING - - # Success - ctx.consecutive_failures = 0 - obs_summary = str(observation) - if len(obs_summary) > 300: - obs_summary = obs_summary[:300] + "..." - _log_progress(f"Observation: {obs_summary}") - obs_text = json.dumps({"observation": observation}, ensure_ascii=False) - ctx.messages.append(build_user_message_text(obs_text)) - self._wm.remember_event("react_observation", obs_text, {}) - return ExecutionMode.PREPARING - - async def _state_recovering( - self, ctx: _LoopContext, budget: IterationBudget, trace: ExecutionTrace - ) -> ExecutionMode: - """Handle LLM/network errors with classified recovery.""" - exc = ctx.last_error - if exc is None: - trace.record(ExecutionMode.COMPLETE, error="unknown_recovery") - return ExecutionMode.COMPLETE - - category = self._error_classifier.classify(exc) - recovery = self._error_classifier.get_recovery(category) - - if recovery.retry and ctx.consecutive_failures < recovery.max_retries: - ctx.consecutive_failures += 1 - if recovery.backoff: - delay = jittered_backoff(ctx.consecutive_failures, base=recovery.base_delay) - await asyncio.sleep(delay) - if recovery.compress: - ctx.messages = self._compressor.force_compress(ctx.messages) - logger.warning( - "react_loop.recovery category=%s attempt=%d", - category.value, ctx.consecutive_failures, - ) - return ExecutionMode.PREPARING - - # Unrecoverable - trace.record(ExecutionMode.COMPLETE, error=str(exc)) - ctx.last_content = f"Error: {exc}" - return ExecutionMode.COMPLETE - # ── Unified Tool Loop (chat scenarios) ─────────────────────────────── + def _build_root_frame(self, user_text: str, *, enable_thinking: bool = False) -> AgentLoopFrame: + """Build the top-level (depth-0) agent-loop frame for a turn.""" + return self._build_frame( + user_text, enable_thinking, + IterationBudget.for_react(self._budget_config), + TurnRecoveryState(), + ) async def _unified_tool_loop( self, user_text: str, *, enable_thinking: bool = False ) -> str: - """Unified chat+tool loop: LLM dynamically decides tools vs direct response. + """Entry adapter: build the root frame and run the unified agent loop.""" + return await self._run_agent_loop( + self._build_root_frame(user_text, enable_thinking=enable_thinking) + ) - Uses native OpenAI tool_calls when provider supports them, falls back to - text-based parsing. Injects working memory for multi-turn coherence. - Reuses IterationBudget, ErrorClassifier, ContextCompressor, MessageHealer. + async def _run_agent_loop(self, frame: AgentLoopFrame) -> str: + """Unified adaptive OODA loop over an isolated per-frame state. + + Per-frame execution state (budget, recovery) lives on ``frame`` so the + same loop serves the top-level turn (root frame) and, in a later phase, + recursive subagents (deeper frames with their own budget). Capabilities + remain engine methods; the LLM dynamically decides tools vs direct reply. """ + user_text = frame.user_text + enable_thinking = frame.enable_thinking + budget = frame.budget + recovery = frame.recovery + self._active_frame = frame + # Detect slash command → inject skill context if user_text.startswith("/"): slash_name = user_text.split()[0][1:] # Remove leading / @@ -2243,11 +2665,23 @@ async def _unified_tool_loop( from leapflow.tools.registry_bootstrap import TOOL_DEFINITIONS, TOOL_HANDLERS - budget = IterationBudget.for_react(self._budget_config) + # A restricted frame (e.g. a subagent) is offered only its permitted + # tools; the root frame (tool_filter=None) sees the full registry. + tool_defs = TOOL_DEFINITIONS + tool_handlers = TOOL_HANDLERS + if frame.tool_filter is not None: + tool_defs = [ + td for td in TOOL_DEFINITIONS + if td.get("function", {}).get("name", "") in frame.tool_filter + ] + tool_handlers = { + name: fn for name, fn in TOOL_HANDLERS.items() if name in frame.tool_filter + } + trace = ExecutionTrace() assembly = await self._assemble_unified_prompt( user_text, - tool_definitions=TOOL_DEFINITIONS, + tool_definitions=tool_defs, enable_thinking=enable_thinking, slash_command=user_text.startswith("/"), ) @@ -2265,9 +2699,12 @@ async def _unified_tool_loop( content = "" fatal_error: Optional[str] = None - recovery = TurnRecoveryState() # P3: Initialize recovery coordinator for this turn - recovery_budget = RecoveryBudget() + recovery_budget = RecoveryBudget( + turn_deadline_s=self._settings.recovery_turn_deadline_s, + total_recovery_actions=self._settings.recovery_total_actions, + max_retry_per_category=self._settings.recovery_max_retry_per_category, + ) recovery_budget.start_deadline() self._recovery_coordinator = RecoveryCoordinator( strategies=default_strategies(), @@ -2284,7 +2721,7 @@ async def _unified_tool_loop( self._cancel_requested = False _signal_watermark = [time.time()] - session_id = self._ensure_session(user_text) + session_id = self._ensure_session_for_frame(frame, user_text) while not budget.exhausted: if self._cancel_requested: @@ -2293,7 +2730,21 @@ async def _unified_tool_loop( status = budget.consume() if status == BudgetStatus.EXHAUSTED: - break + # Progress-gated continuation: a productively-unfinished task + # (open ledger work, still progressing, within resource limits) + # extends past the elastic ceiling toward the hard cap instead of + # terminating; a stalled/complete/over-budget task stops here. + if budget.can_extend and self._should_extend_budget(frame): + budget.grant_extension(self._settings.agent_iter_extension_step) + if budget.status() == BudgetStatus.EXHAUSTED: + break # absolute hard cap reached + logger.info( + "unified_loop: budget extended (progress-gated) to %d (stalled=%d)", + budget.effective_max, frame.stalled_rounds, + ) + status = budget.status() + else: + break self._inject_live_signals(messages, _signal_watermark) @@ -2303,6 +2754,9 @@ async def _unified_tool_loop( tools=tools_kwarg.get("tools"), round_number=budget.used, ) + self._widen_budget_for_difficulty(budget) + self._update_progress_and_stall(frame) + self._evaluate_prefix_commitment(budget) try: resp = await self._llm.achat( @@ -2435,7 +2889,7 @@ async def _unified_tool_loop( self._persist_message(session_id, "assistant", "", tool_calls=assistant_msg.get("tool_calls")) results = await self._execute_tools_concurrent( - native_calls, TOOL_HANDLERS, trace=trace, messages=messages, + native_calls, tool_handlers, trace=trace, messages=messages, ) self._record_tool_call_categories(native_calls) tools_kwarg = self._merge_expanded_tool_schemas(tools_kwarg, results) @@ -2459,15 +2913,21 @@ async def _unified_tool_loop( ) if retryable_unknown and not unknown_tool_retry_used: unknown_tool_retry_used = True - tools_kwarg = self._expand_tools_kwarg_full(tools_kwarg, TOOL_DEFINITIONS) + tools_kwarg = self._expand_tools_kwarg_full(tools_kwarg, tool_defs) use_native_tools = bool(tools_kwarg) messages.append(build_user_message_text(_unknown_tool_retry_prompt(retryable_unknown))) continue - failures = self._count_consecutive_tool_failures(messages) - recovery.consecutive_tool_failures = failures - if failures >= self._settings.max_consecutive_tool_failures: - logger.warning("unified_loop: %d consecutive tool failures, stopping", failures) + halt_reason = self._evaluate_tool_failures( + [ + (item.get("name") or "", item["result"]) + for item in results + if isinstance(item.get("result"), dict) and _tool_result_counts_as_failure(item["result"]) + ], + turn_id=budget.used, + ) + if halt_reason: + fatal_error = halt_reason break # Guardrail check after tool execution @@ -2478,7 +2938,7 @@ async def _unified_tool_loop( f"[Called: {', '.join(tc.name for tc in native_calls)}]" )) - if status == BudgetStatus.SOFT_LIMIT: + if status == BudgetStatus.SOFT_LIMIT and not self._should_extend_budget(frame): messages.append(build_user_message_text( "SYSTEM: Approaching limit. Provide final answer now." )) @@ -2512,7 +2972,7 @@ async def _unified_tool_loop( }) _show_progress("executing", tool_name) result = await self._execute_tool_with_ledger( - normalized_tool_call, TOOL_HANDLERS, tool_call_id=f"text-{budget.used}", + normalized_tool_call, tool_handlers, tool_call_id=f"text-{budget.used}", ) _clear_indicator() self._emit_chat_event("tool_result", { @@ -2533,7 +2993,7 @@ async def _unified_tool_loop( else: recovery.record_tool_success() result_payload = self._compact_tool_result(tool_name, tool_arguments, result) - result_text = json.dumps(result_payload, default=str, ensure_ascii=False)[:result_budget] + result_text = _truncate_result_for_budget(result_payload, result_budget) messages.append(build_user_message_text( f"Tool result ({tool_name}):\n{result_text}" )) @@ -2551,50 +3011,32 @@ async def _unified_tool_loop( ) break - # Classify tool failures through coordinator for audit and decision - if ( - isinstance(result, dict) - and not result.get("ok", True) - and result.get("counts_as_failure") is not False - ): - tool_envelope = self._unified_classifier.classify_tool_result( - result, tool_name=tool_name, - execution_policy=result.get("execution_policy", "read_only"), - ) - if tool_envelope is not None: - tool_decision = self._recovery_coordinator.evaluate(tool_envelope) - self._audit_sink.record(create_audit_entry( - tool_envelope, tool_decision, self._recovery_coordinator.budget, - session_id=getattr(self, '_current_session_id', '') or '', - turn_id=budget.used, - )) - if tool_decision.action in (RecoveryAction.HALT_CLEAN, RecoveryAction.HALT_WITH_CHECKPOINT): - fatal_error = tool_decision.reason - break - # Other actions: let LLM handle (append result to messages as before) - if _is_retryable_unknown_tool_result(result) and not unknown_tool_retry_used: unknown_tool_retry_used = True messages.append(build_user_message_text(_unknown_tool_retry_prompt(result))) continue - if is_error and recovery.consecutive_tool_failures >= self._settings.max_consecutive_tool_failures: - logger.warning("unified_loop: %d consecutive tool failures, stopping", - recovery.consecutive_tool_failures) - break + if is_error: + halt_reason = self._evaluate_tool_failures([(tool_name, result)], turn_id=budget.used) + if halt_reason: + fatal_error = halt_reason + break if self._check_guardrail(messages) == "halt": break - if status == BudgetStatus.SOFT_LIMIT: + if status == BudgetStatus.SOFT_LIMIT and not self._should_extend_budget(self._active_frame): messages.append(build_user_message_text( "SYSTEM: Approaching limit. Provide final answer now." )) - if self._memory_manager and self._settings.memory_integration_enabled: + # Turn-end learning/memory-sync are top-level-turn concerns; a recursive + # child frame (subagent) must not pollute the parent's evolution/memory + # (its result flows back via SubagentResult) nor leak background tasks. + if getattr(self._active_frame, "is_root", True) and self._memory_manager and self._settings.memory_integration_enabled: asyncio.create_task(self._sync_turn_safe(messages)) - if self._evolution is not None and content: + if getattr(self._active_frame, "is_root", True) and self._evolution is not None and content: asyncio.create_task(self._post_turn_review(messages, content)) llm = self._llm @@ -2614,7 +3056,7 @@ async def _unified_tool_loop( fallback = ( _app_onboarding_recovery_message(messages) or _last_tool_failures_recovery_message(messages) - or "I've reached my processing limit." + or self._budget_exhausted_response(messages) ) self._emit_chat_event("response", {"content": fallback[:500]}) return fallback @@ -2660,6 +3102,7 @@ async def _post_turn_review( skill_name = tool_actions[0]["tool"] if tool_actions else "unknown" episode_context = {"final_content_preview": final_content[:200]} episode_context.update(self._usage_tracker.to_learning_signal()) + episode_context.update(build_adaptive_learning_signal(self._last_context_snapshot or {})) episode = self._evolution.record_episode( skill_name=f"turn_{skill_name}", actions=tool_actions[:10], @@ -2775,8 +3218,13 @@ async def _unified_tool_loop_stream( content = "" fatal_error: Optional[str] = None turn_recovery = TurnRecoveryState() + self._active_frame = self._build_frame(user_text, enable_thinking, budget, turn_recovery) # Initialize recovery coordinator for stream turn - recovery_budget = RecoveryBudget() + recovery_budget = RecoveryBudget( + turn_deadline_s=self._settings.recovery_turn_deadline_s, + total_recovery_actions=self._settings.recovery_total_actions, + max_retry_per_category=self._settings.recovery_max_retry_per_category, + ) recovery_budget.start_deadline() self._recovery_coordinator = RecoveryCoordinator( strategies=default_strategies(), @@ -2802,7 +3250,20 @@ async def _unified_tool_loop_stream( status = budget.consume() if status == BudgetStatus.EXHAUSTED: - break + # Progress-gated continuation (mirrors _run_agent_loop): extend a + # productively-unfinished task past the elastic ceiling toward the + # hard cap; a stalled/complete/over-budget task stops here. + if budget.can_extend and self._should_extend_budget(self._active_frame): + budget.grant_extension(self._settings.agent_iter_extension_step) + if budget.status() == BudgetStatus.EXHAUSTED: + break # absolute hard cap reached + logger.info( + "unified_loop_stream: budget extended (progress-gated) to %d", + budget.effective_max, + ) + status = budget.status() + else: + break self._inject_live_signals(messages, _signal_watermark) @@ -2812,6 +3273,9 @@ async def _unified_tool_loop_stream( tools=tools_kwarg.get("tools") if use_native_tools else None, round_number=budget.used, ) + self._widen_budget_for_difficulty(budget) + self._update_progress_and_stall(self._active_frame) + self._evaluate_prefix_commitment(budget) content = "" @@ -2964,16 +3428,22 @@ async def _unified_tool_loop_stream( ) break - failures = self._count_consecutive_tool_failures(messages) - turn_recovery.consecutive_tool_failures = failures if retryable_unknown and not unknown_tool_retry_used: unknown_tool_retry_used = True tools_kwarg = self._expand_tools_kwarg_full(tools_kwarg, TOOL_DEFINITIONS) use_native_tools = bool(tools_kwarg) messages.append(build_user_message_text(_unknown_tool_retry_prompt(retryable_unknown))) continue - if failures >= self._settings.max_consecutive_tool_failures: - logger.warning("unified_loop_stream: %d consecutive tool failures, stopping", failures) + halt_reason = self._evaluate_tool_failures( + [ + (item.get("name") or "", item["result"]) + for item in results + if isinstance(item.get("result"), dict) and _tool_result_counts_as_failure(item["result"]) + ], + turn_id=budget.used, + ) + if halt_reason: + fatal_error = halt_reason break if self._check_guardrail(messages) == "halt": @@ -2982,7 +3452,7 @@ async def _unified_tool_loop_stream( self._wm.remember_chat(build_assistant_message( f"[Called: {', '.join(tc.name for tc in native_calls)}]" )) - if status == BudgetStatus.SOFT_LIMIT: + if status == BudgetStatus.SOFT_LIMIT and not self._should_extend_budget(self._active_frame): messages.append(build_user_message_text( "SYSTEM: Approaching limit. Provide final answer now." )) @@ -3211,7 +3681,7 @@ async def _unified_tool_loop_stream( turn_recovery.record_tool_success() result_payload = self._compact_tool_result(tool_name, tool_arguments, result) - result_text = json.dumps(result_payload, default=str, ensure_ascii=False)[:result_budget] + result_text = _truncate_result_for_budget(result_payload, result_budget) messages.append(build_user_message_text( f"Tool result ({tool_name}):\n{result_text}" )) @@ -3234,23 +3704,27 @@ async def _unified_tool_loop_stream( messages.append(build_user_message_text(_unknown_tool_retry_prompt(result))) continue - if is_error and turn_recovery.consecutive_tool_failures >= self._settings.max_consecutive_tool_failures: - logger.warning("unified_loop_stream: %d consecutive tool failures, stopping", - turn_recovery.consecutive_tool_failures) - break + if is_error: + halt_reason = self._evaluate_tool_failures([(tool_name, result)], turn_id=budget.used) + if halt_reason: + fatal_error = halt_reason + break if self._check_guardrail(messages) == "halt": break - if status == BudgetStatus.SOFT_LIMIT: + if status == BudgetStatus.SOFT_LIMIT and not self._should_extend_budget(self._active_frame): messages.append(build_user_message_text( "SYSTEM: Approaching limit. Provide final answer now." )) - if self._memory_manager and self._settings.memory_integration_enabled: + # Turn-end learning/memory-sync are top-level-turn concerns; a recursive + # child frame (subagent) must not pollute the parent's evolution/memory + # (its result flows back via SubagentResult) nor leak background tasks. + if getattr(self._active_frame, "is_root", True) and self._memory_manager and self._settings.memory_integration_enabled: asyncio.create_task(self._sync_turn_safe(messages)) - if self._evolution is not None and content: + if getattr(self._active_frame, "is_root", True) and self._evolution is not None and content: asyncio.create_task(self._post_turn_review(messages, content)) llm = self._llm @@ -3269,7 +3743,7 @@ async def _unified_tool_loop_stream( _app_onboarding_recovery_message(messages) or _last_tool_failures_recovery_message(messages) or fatal_error - or "I've reached my processing limit." + or self._budget_exhausted_response(messages) ) self._emit_chat_event("response", {"content": fallback[:500]}) yield StreamEvent(type="final", content=fallback) @@ -3369,7 +3843,7 @@ async def _execute_tools_concurrent( observation=result if isinstance(result, dict) else {"result": str(result)}, ) result_payload = self._compact_tool_result(normalized_name, tc.arguments, result) - result_text = json.dumps(result_payload, default=str, ensure_ascii=False)[:result_budget] + result_text = _truncate_result_for_budget(result_payload, result_budget) messages.append({"role": "tool", "tool_call_id": tc.id, "content": result_text}) self._persist_message( self._current_session_id, "tool", result_text, @@ -3408,8 +3882,12 @@ async def _execute_tools_concurrent( len(sequential), ) - # Execute concurrent group via asyncio.gather + # Execute concurrent group via asyncio.gather, bounded so a large batch + # does not fan out unbounded IO/subprocess load. if concurrent: + max_parallel = max(1, int(getattr(self._settings, "agent_max_parallel_tools", 8) or 8)) + _parallel_sem = asyncio.Semaphore(max_parallel) + async def _run_one(ctc: ConcurrentToolCall) -> Dict[str, Any]: original_name = original_names_by_id.get(str(ctc.id), ctc.name) tool_call_dict = { @@ -3418,9 +3896,10 @@ async def _run_one(ctc: ConcurrentToolCall) -> Dict[str, Any]: "original_tool_name": original_name, "normalized_tool_name": ctc.name, } - return await self._execute_tool_with_ledger( - tool_call_dict, handlers, tool_call_id=str(ctc.id), - ) + async with _parallel_sem: + return await self._execute_tool_with_ledger( + tool_call_dict, handlers, tool_call_id=str(ctc.id), + ) gather_results = await asyncio.gather( *[_run_one(ctc) for ctc in concurrent], @@ -3446,7 +3925,7 @@ async def _run_one(ctc: ConcurrentToolCall) -> Dict[str, Any]: observation=error_result, ) result_payload = self._compact_tool_result(ctc.name, ctc.arguments, error_result) - result_text = json.dumps(result_payload, default=str, ensure_ascii=False)[:result_budget] + result_text = _truncate_result_for_budget(result_payload, result_budget) else: _print_tool_result(ctc.name, result, enabled=self._settings.verbose_progress) trace.record( @@ -3455,7 +3934,7 @@ async def _run_one(ctc: ConcurrentToolCall) -> Dict[str, Any]: observation=result if isinstance(result, dict) else {"result": str(result)}, ) result_payload = self._compact_tool_result(ctc.name, ctc.arguments, result) - result_text = json.dumps(result_payload, default=str, ensure_ascii=False)[:result_budget] + result_text = _truncate_result_for_budget(result_payload, result_budget) effective_result = error_result if isinstance(result, Exception) else result messages.append({"role": "tool", "tool_call_id": ctc.id, "content": result_text}) self._persist_message( @@ -3506,7 +3985,7 @@ async def _run_one(ctc: ConcurrentToolCall) -> Dict[str, Any]: observation=result if isinstance(result, dict) else {"result": str(result)}, ) result_payload = self._compact_tool_result(ctc.name, ctc.arguments, result) - result_text = json.dumps(result_payload, default=str, ensure_ascii=False)[:result_budget] + result_text = _truncate_result_for_budget(result_payload, result_budget) messages.append({"role": "tool", "tool_call_id": ctc.id, "content": result_text}) self._persist_message( self._current_session_id, "tool", result_text, @@ -3537,6 +4016,34 @@ async def _run_one(ctc: ConcurrentToolCall) -> Dict[str, Any]: break return executed + def _tool_execution_context(self) -> Any | None: + """Build the tool context from the current task contract, if any.""" + contract = self._current_task_contract + if contract is None: + return None + from leapflow.tools.execution_context import ToolExecutionContext + + return ToolExecutionContext.from_strings( + workspace_root=contract.workspace_root, + allowed_roots=contract.allowed_roots, + session_id=str(self._current_session_id or ""), + task_id=contract.task_id, + ) + + async def _execute_tool_scoped( + self, + tool_call: Dict[str, Any], + handlers: Dict[str, Any], + ) -> Dict[str, Any]: + """Execute a tool with the current turn's workspace context installed.""" + from leapflow.tools.execution_context import reset_tool_context, set_tool_context + + token = set_tool_context(self._tool_execution_context()) + try: + return await self._execute_general_tool(tool_call, handlers) + finally: + reset_tool_context(token) + async def _execute_tool_with_ledger( self, tool_call: Dict[str, Any], @@ -3551,11 +4058,16 @@ async def _execute_tool_with_ledger( registry = _default_tool_registry() resolution = registry.resolve(proposed_name, args) if not resolution.auto_executable or resolution.normalized_name is None: - return await self._execute_general_tool(tool_call, handlers) + return await self._execute_tool_scoped(tool_call, handlers) tool_name = resolution.normalized_name spec = registry.specs.get(tool_name) policy = execution_policy_for(tool_name, spec) + if getattr(self._settings, "agent_validate_tool_args", True): + invalid_args = _validate_tool_arguments(spec, args) + if invalid_args is not None: + logger.info("tool_args_invalid: tool=%s missing=%s", tool_name, invalid_args.get("missing")) + return invalid_args session_id = self._current_session_id or "ephemeral" turn_id = self._current_turn_id or f"turn-{self._session_turn_count}" command_id = self._current_command_id or turn_id @@ -3594,7 +4106,7 @@ async def _execute_tool_with_ledger( return duplicate try: - result = await self._execute_general_tool(normalized_call, handlers) + result = await self._execute_tool_scoped(normalized_call, handlers) except Exception as exc: failed_result: Dict[str, Any] = { "ok": False, @@ -3741,40 +4253,27 @@ def _post_process_tool_result(tool_name: str, result: Dict[str, Any]) -> Dict[st # ── Helpers ────────────────────────────────────────────────────────── - def _build_loop_messages(self, user_text: str, steps: List[str]) -> List[Dict[str, Any]]: - """Build initial messages for the ReAct loop.""" - skill_catalog = self._registry.describe_with_params() - - # Append memory tool descriptions if available - memory_tools_desc = "" - if self._memory_manager and self._settings.memory_integration_enabled: - schemas = self._memory_manager.get_tool_schemas() - if schemas: - tool_lines = [] - for s in schemas: - tool_lines.append(f" - {s.name}: {s.description}") - memory_tools_desc = ( - "\n\nMEMORY TOOLS (type=\"memory\"):\n" - + "\n".join(tool_lines) - ) - - system_prompt = REACT_SYSTEM_TEMPLATE.format(skill_catalog=skill_catalog) - if memory_tools_desc: - system_prompt += memory_tools_desc + def _budget_exhausted_response(self, messages: List[Dict[str, Any]]) -> str: + """Response when the iteration hard cap is reached. - return [ - build_system_message(system_prompt), - build_user_message_text( - "GOAL:\n" - f"{user_text}\n\n" - "CONTEXT:\n" - f"- plan_steps: {json.dumps(steps, ensure_ascii=False)}\n" - ), + When the research ledger shows unfinished work, surface the remaining + open-question count and next step so the stop is informative and + continuable (not a bare dead-stop); otherwise the plain notice. + """ + base = "I've reached my reasoning step limit. Here's my best answer based on progress so far." + led = self._research_ledger + if led.is_empty or led.open_question_count == 0: + return base + d = led.as_dict() + parts = [ + base, + "", + f"Note: {led.open_question_count} open question(s) remain — the task is not fully complete.", ] - - def _budget_exhausted_response(self, messages: List[Dict[str, Any]]) -> str: - """Generate response when budget is exhausted.""" - return "I've reached my reasoning step limit. Here's my best answer based on progress so far." + next_step = d.get("next_step", "") + if next_step: + parts.append(f"Suggested next step: {next_step}") + return "\n".join(parts) @staticmethod def _error_response(observation: Any) -> str: @@ -3803,12 +4302,40 @@ async def _emit_execution_trace(self, trace: ExecutionTrace) -> None: actions=actions, outcome=outcome, reward=reward, - context={"steps": trace.step_count, "tokens": trace.total_tokens}, + context={ + "steps": trace.step_count, + "tokens": trace.total_tokens, + **build_adaptive_learning_signal(self._last_context_snapshot or {}), + }, ) logger.debug("evolution.record_episode outcome=%s actions=%d", outcome, len(actions)) except Exception: pass # never fail the main loop + def _ensure_session_for_frame(self, frame: AgentLoopFrame, user_text: str) -> Optional[str]: + """Resolve the persistence session for a loop frame (S4-E isolation). + + Root frames reuse the turn's conversation session; a recursive child + frame (subagent) gets its *own* isolated ``sub_`` session so its + transcript is persisted separately and never mixes into the parent + turn's conversation. + """ + if frame.is_root: + return self._ensure_session(user_text) + if not self._conversation_store or not self._settings.session_persistence_enabled: + return None + try: + import uuid as _uuid + child_session = f"sub_{_uuid.uuid4().hex[:12]}" + title = user_text[:80].replace("\n", " ").strip() or "subagent" + self._conversation_store.create_session( + child_session, title=title, model=self._settings.llm_model, source="subagent", + ) + return child_session + except Exception: + logger.debug("child session creation failed; skipping child persistence", exc_info=True) + return None + def _ensure_session(self, user_text: str) -> Optional[str]: """Create or reuse a conversation session. Returns session_id or None.""" if not self._conversation_store or not self._settings.session_persistence_enabled: @@ -3817,10 +4344,16 @@ def _ensure_session(self, user_text: str) -> Optional[str]: import uuid as _uuid if self._current_session_id is None: self._current_session_id = _uuid.uuid4().hex[:16] + # Create the session row if it does not exist yet. This covers a + # freshly-minted id and a client-provided id alike (e.g. a distinct + # per-TUI session bound by the daemon), so persistence works no matter + # who chose the id. + if self._conversation_store.get_session(self._current_session_id) is None: title = user_text[:80].replace("\n", " ").strip() self._conversation_store.create_session( self._current_session_id, title=title, model=self._settings.llm_model, source="cli", + cwd=str(getattr(self._settings, "workspace_root", "") or ""), ) self._persist_message(self._current_session_id, "user", user_text) return self._current_session_id @@ -3904,12 +4437,13 @@ async def _prefetch_and_freeze_memory(self, user_text: str) -> str: if self._current_task_contract else "" ), scope_keywords=self._task_scope_keywords(user_text), + session_scope=self._current_session_id or "", ), timeout=self._settings.memory_prefetch_timeout_s, ) if entries: parts.append("## Recent Context\n" + "\n".join( - f"- [{e.kind.value}] {e.content[:100]}" for e in entries + f"- [{e.kind.value}] {e.content[:500]}" for e in entries )) except asyncio.TimeoutError: logger.debug( @@ -3925,8 +4459,16 @@ async def _sync_turn_safe(self, messages: List[Dict[str, Any]]) -> None: """Non-blocking wrapper for MemoryManager.sync_turn.""" try: assert self._memory_manager is not None + workspace_root = ( + self._current_task_contract.workspace_root + if self._current_task_contract else "" + ) await asyncio.wait_for( - self._memory_manager.sync_turn(messages), + self._memory_manager.sync_turn( + messages, + workspace_root=workspace_root, + session_id=self._current_session_id or "", + ), timeout=self._settings.memory_prefetch_timeout_s, ) logger.debug("memory.sync_turn completed") @@ -4453,8 +4995,14 @@ async def _execute_action(self, action: Dict[str, Any], user_goal: str) -> Any: # Memory tool interception: route memory_* calls to MemoryManager if (a_type == "memory" or name.startswith("memory_")) and self._memory_manager: tool_name = name if name.startswith("memory_") else f"memory_{name}" + workspace_root = ( + self._current_task_contract.workspace_root + if self._current_task_contract else "" + ) try: - result = await self._memory_manager.handle_tool_call(tool_name, payload) + result = await self._memory_manager.handle_tool_call( + tool_name, payload, workspace_root=workspace_root + ) logger.info("audit.memory_tool name=%s", tool_name) return {"ok": True, "result": result} except Exception as exc: diff --git a/src/leapflow/engine/prefix_commitment.py b/src/leapflow/engine/prefix_commitment.py new file mode 100644 index 0000000..6e5e8db --- /dev/null +++ b/src/leapflow/engine/prefix_commitment.py @@ -0,0 +1,194 @@ +"""Adaptive prefix-commitment decision (mechanism 7, W2 slice 2). + +Decides whether a task should *commit* to a stable, cacheable prompt prefix. +Prefix caching rewards stability, not mere length: committing pays off only when +a long horizon amortizes the first-call write cost against cheap cached reads. + +This module owns the decision only (pure, testable). Enforcement -- freezing +disclosure at FULL, byte-stabilizing the tool payload, cache-aware compression, +layered layout, and provider breakpoints -- is a separate concern (W2 slice 3). + +Design contract (aligns with the design doc 7.2): +- Commitment is monotonic: UNCOMMITTED -> COMMITTED, never back (7.2.6). +- Triggered by *expansion / long-horizon* signals, never by convergence: a task + that is wrapping up must not newly commit a large prefix it cannot amortize. +- The commit predicate is a deterministic amortization inequality over a + provider-neutral CachePriceModel; no natural-language fitting. +""" +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum +from typing import Any, Dict, FrozenSet + + +class CommitmentStatus(str, Enum): + """Lifecycle of the per-task prefix commitment (monotonic).""" + + UNCOMMITTED = "uncommitted" + COMMITTED = "committed" + + +@dataclass(frozen=True) +class CachePriceModel: + """Relative per-token prices for a provider's prefix cache. + + Prices are normalized so an uncached (miss) prompt token costs ``price_miss`` + (1.0 by default). ``price_read`` is a cached-read token (providers bill ~0.1x) + and ``price_write`` is the first-materialization premium (auto-cache + providers 1.0, Anthropic ~1.25 for the 5m TTL). Provided by the LLM adapter; + the decision logic is provider-neutral and only consumes this model. + """ + + price_miss: float = 1.0 + price_read: float = 0.1 + price_write: float = 1.0 + + +@dataclass(frozen=True) +class PrefixCommitmentConfig: + """Thresholds for the commitment decision (7.2.2).""" + + commit_difficulty_threshold: float = 0.60 + min_prefix_tokens: int = 1024 + min_remaining_rounds: int = 3 + margin: float = 0.15 + # Expansion / long-horizon postures that make committing worthwhile. Note + # this deliberately excludes converging/finalizing (near-end): see 7.2.2. + expansion_postures: FrozenSet[str] = frozenset({"research", "expanding"}) + + +@dataclass(frozen=True) +class PrefixCommitmentState: + """Immutable snapshot of the current commitment (surfaced for observability).""" + + status: CommitmentStatus = CommitmentStatus.UNCOMMITTED + committed_at_round: int = -1 + prefix_token_estimate: int = 0 + projected_savings: float = 0.0 + reason: str = "" + + @property + def committed(self) -> bool: + return self.status is CommitmentStatus.COMMITTED + + def as_dict(self) -> Dict[str, Any]: + return { + "status": self.status.value, + "committed": self.committed, + "committed_at_round": self.committed_at_round, + "prefix_token_estimate": self.prefix_token_estimate, + "projected_savings": round(self.projected_savings, 2), + "reason": self.reason, + } + + +class PrefixCommitmentController: + """Per-task controller: decides (once) whether to commit the prefix. + + Stateless w.r.t. the decision math (``should_commit`` is pure); holds only + the monotonic commitment state, reset per task via :meth:`reset`. + """ + + def __init__( + self, + *, + config: PrefixCommitmentConfig | None = None, + price_model: CachePriceModel | None = None, + ) -> None: + self._config = config or PrefixCommitmentConfig() + self._price = price_model or CachePriceModel() + self._state = PrefixCommitmentState() + + @property + def state(self) -> PrefixCommitmentState: + return self._state + + @property + def committed(self) -> bool: + return self._state.committed + + def reset(self) -> None: + """Clear commitment state at the start of a new task/turn.""" + self._state = PrefixCommitmentState() + + def projected_savings( + self, + *, + remaining_rounds: int, + est_full_prefix_tokens: int, + est_pcd_prefix_tokens: int, + ) -> float: + """Effective-cost savings of committing vs churning over the horizon (7.2.3). + + Positive means committing is cheaper. ``cost_commit`` writes the stable + prefix once then reads it cheaply; ``cost_nocommit`` re-encodes the + churning prefix at the miss price every remaining round. + """ + price = self._price + rounds = max(1, remaining_rounds) + cost_commit = ( + est_full_prefix_tokens * price.price_write + + (rounds - 1) * est_full_prefix_tokens * price.price_read + ) + cost_nocommit = rounds * est_pcd_prefix_tokens * price.price_miss + return cost_nocommit - cost_commit * (1.0 + self._config.margin) + + def should_commit( + self, + *, + difficulty: float, + posture: str, + remaining_rounds: int, + est_full_prefix_tokens: int, + est_pcd_prefix_tokens: int, + ) -> bool: + """Deterministic commit predicate (7.2.2 gates + 7.2.3 amortization).""" + cfg = self._config + if difficulty < cfg.commit_difficulty_threshold: + return False + if posture not in cfg.expansion_postures: + return False + if est_full_prefix_tokens < cfg.min_prefix_tokens: + return False + if remaining_rounds < cfg.min_remaining_rounds: + return False + return self.projected_savings( + remaining_rounds=remaining_rounds, + est_full_prefix_tokens=est_full_prefix_tokens, + est_pcd_prefix_tokens=est_pcd_prefix_tokens, + ) > 0.0 + + def evaluate( + self, + *, + difficulty: float, + posture: str, + round_number: int, + remaining_rounds: int, + est_full_prefix_tokens: int, + est_pcd_prefix_tokens: int, + ) -> PrefixCommitmentState: + """Evaluate and (once) transition to COMMITTED. Monotonic (7.2.1).""" + if self._state.committed: + return self._state + if self.should_commit( + difficulty=difficulty, + posture=posture, + remaining_rounds=remaining_rounds, + est_full_prefix_tokens=est_full_prefix_tokens, + est_pcd_prefix_tokens=est_pcd_prefix_tokens, + ): + savings = self.projected_savings( + remaining_rounds=remaining_rounds, + est_full_prefix_tokens=est_full_prefix_tokens, + est_pcd_prefix_tokens=est_pcd_prefix_tokens, + ) + self._state = PrefixCommitmentState( + status=CommitmentStatus.COMMITTED, + committed_at_round=round_number, + prefix_token_estimate=est_full_prefix_tokens, + projected_savings=savings, + reason=f"difficulty={difficulty:.2f} posture={posture} R={remaining_rounds}", + ) + return self._state diff --git a/src/leapflow/engine/recovery_budget.py b/src/leapflow/engine/recovery_budget.py index 2ea1dae..845429a 100644 --- a/src/leapflow/engine/recovery_budget.py +++ b/src/leapflow/engine/recovery_budget.py @@ -110,8 +110,14 @@ def can_rotate(self) -> bool: return self._rotations_used < self.max_credential_rotations def is_deadline_exceeded(self) -> bool: - """Whether the wall-clock deadline for this turn has been exceeded.""" - if self._deadline_start == 0.0: + """Whether the wall-clock deadline for this turn has been exceeded. + + A non-positive ``turn_deadline_s`` means *no* wall-clock deadline + (unlimited): recovery is then bounded only by the action-count budget, so + a long-running task is never denied recovery for a late transient error + merely because wall-clock time has elapsed. + """ + if self._deadline_start == 0.0 or self.turn_deadline_s <= 0: return False elapsed = time.monotonic() - self._deadline_start return elapsed > self.turn_deadline_s diff --git a/src/leapflow/engine/research_ledger.py b/src/leapflow/engine/research_ledger.py new file mode 100644 index 0000000..8db42df --- /dev/null +++ b/src/leapflow/engine/research_ledger.py @@ -0,0 +1,146 @@ +"""Structured research ledger for long-horizon task state (mechanism 5, W3). + +A compact, bounded record of the active task's accumulated findings, open +questions, decisions / excluded paths, and next step. It is re-injected into the +*volatile tail* of every turn (never the cached prefix) so long-task state +survives context compression no matter how much raw history is summarized or +dropped -- the highest-priority guarantee for multi-turn deep work: information +integrity first, compression ratio second. + +The ledger is maintained by the agent via the cheap ``research_note`` tool. It +is per-task (reset each turn) and deliberately bounded (per-list cap + per-note +truncation + dedupe) so it stays small and high-signal. +""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Callable, Dict, List, Optional + +# Recognized note kinds. ``resolved`` closes a matching open question and files +# it as a finding, so the open-question set reflects only what remains. +LEDGER_KINDS = ("finding", "open_question", "resolved", "decision", "next_step") + +_MAX_ITEMS = 24 # per-list cap; keep the most recent (SNR over completeness) +_MAX_CHARS = 200 # per-note cap: one concise sentence (bounds every-round re-injection) + + +@dataclass +class ResearchLedger: + """Per-task structured state; bounded and re-injected each turn.""" + + max_items: int = _MAX_ITEMS + max_chars: int = _MAX_CHARS + + def __post_init__(self) -> None: + self._findings: List[str] = [] + self._open_questions: List[str] = [] + self._decisions: List[str] = [] + self._next_step: str = "" + self._on_change: Optional[Callable[[], None]] = None + + def set_change_listener(self, callback: Optional[Callable[[], None]]) -> None: + """Install a listener fired after each successful note (e.g. persist).""" + self._on_change = callback + + def reset(self) -> None: + """Clear all state at the start of a new task/turn.""" + self._findings.clear() + self._open_questions.clear() + self._decisions.clear() + self._next_step = "" + + def note(self, kind: str, text: str) -> bool: + """Record a structured note. Returns False for invalid kind/empty text.""" + text = (text or "").strip()[: self.max_chars] + if not text: + return False + kind = (kind or "").strip().lower() + if kind == "finding": + self._append(self._findings, text) + elif kind == "open_question": + self._append(self._open_questions, text) + elif kind == "resolved": + self._resolve(text) + elif kind == "decision": + self._append(self._decisions, text) + elif kind == "next_step": + self._next_step = text + else: + return False + if self._on_change is not None: + self._on_change() + return True + + def _append(self, bucket: List[str], text: str) -> None: + if text in bucket: + bucket.remove(text) # dedupe: move to most-recent + bucket.append(text) + if len(bucket) > self.max_items: # keep most recent + del bucket[: len(bucket) - self.max_items] + + def _resolve(self, text: str) -> None: + low = text.lower() + for i, question in enumerate(self._open_questions): + ql = question.lower() + if low in ql or ql in low: + self._open_questions.pop(i) + break + self._append(self._findings, text) + + @property + def open_question_count(self) -> int: + return len(self._open_questions) + + @property + def is_empty(self) -> bool: + return not ( + self._findings or self._open_questions or self._decisions or self._next_step + ) + + def render(self) -> str: + """Compact, injectable block. Empty ledger renders to an empty string.""" + if self.is_empty: + return "" + lines = [ + "## Research Ledger (task state; authoritative and preserved across compression)" + ] + if self._findings: + lines.append("Findings:") + lines.extend(f"- {item}" for item in self._findings) + if self._open_questions: + lines.append("Open questions:") + lines.extend(f"- {item}" for item in self._open_questions) + if self._decisions: + lines.append("Decisions / excluded paths:") + lines.extend(f"- {item}" for item in self._decisions) + if self._next_step: + lines.append(f"Next step: {self._next_step}") + return "\n".join(lines) + + def as_dict(self) -> Dict[str, Any]: + return { + "findings": list(self._findings), + "open_questions": list(self._open_questions), + "decisions": list(self._decisions), + "next_step": self._next_step, + "open_question_count": self.open_question_count, + } + + def to_state(self) -> Dict[str, Any]: + """Persistable snapshot (excludes derived counters).""" + return { + "findings": list(self._findings), + "open_questions": list(self._open_questions), + "decisions": list(self._decisions), + "next_step": self._next_step, + } + + def load_state(self, state: Optional[Dict[str, Any]]) -> None: + """Replace state from a persisted snapshot; does not fire the listener.""" + self.reset() + if not state: + return + self._findings = [str(x) for x in state.get("findings", []) if str(x).strip()][-self.max_items:] + self._open_questions = [str(x) for x in state.get("open_questions", []) if str(x).strip()][-self.max_items:] + self._decisions = [str(x) for x in state.get("decisions", []) if str(x).strip()][-self.max_items:] + self._next_step = str(state.get("next_step", "")) diff --git a/src/leapflow/engine/session_factory.py b/src/leapflow/engine/session_factory.py new file mode 100644 index 0000000..3d0722d --- /dev/null +++ b/src/leapflow/engine/session_factory.py @@ -0,0 +1,92 @@ +"""Session-scoped engine factory for concurrent, isolated turn execution (Stage 3). + +Builds a per-session ``AgentEngine`` that SHARES the base engine's stateless / +already-wired services (LLM client, DuckDB stores, skill registry, tool bridge, +compressor config, guardrail, subagent manager, ...) by reference, but owns a +FRESH per-session working memory and idempotency ledger and starts from a clean +per-turn state slate. + +This isolates exactly the substrate that concurrent turns corrupt — the working +memory (a single unkeyed deque) and the engine's per-turn state — without +duplicating the engine wiring (which is scattered across the context setup) or +changing the engine's single-turn internals. + +Phase P3-1: additive only. The factory is not yet used by the daemon (that is +P3-2's SessionRegistry); this phase adds the mechanism and proves isolation. + +See ``temp/plan/concurrent_turns_stage3.md`` (Approach D, §4.1–4.3). +""" +from __future__ import annotations + +import copy +from dataclasses import replace +from pathlib import Path +from typing import Any + +from leapflow.engine.prefix_commitment import PrefixCommitmentController +from leapflow.engine.recovery_coordinator import RecoveryCoordinator +from leapflow.engine.research_ledger import ResearchLedger +from leapflow.engine.tool_execution import ToolExecutionLedger +from leapflow.engine.turn_usage import TurnUsageTracker + + +def _settings_for_workspace(settings: Any, workspace_root: str | Path | None) -> Any: + if settings is None or not workspace_root: + return settings + root = Path(str(workspace_root)).expanduser().resolve() + try: + return replace(settings, workspace_root=root) + except TypeError: + cloned = copy.copy(settings) + setattr(cloned, "workspace_root", root) + return cloned + + +def build_session_engine( + base_engine: Any, + *, + session_id: str, + working_memory: Any, + workspace_root: str | Path | None = None, +) -> Any: + """Return a per-session engine sharing ``base_engine``'s wired services. + + The returned engine has its own working memory, idempotency ledger, and + FRESH per-turn subsystems (governance / research ledger / commitment / usage + / recovery), plus a clean per-turn state slate. This is required because some + of those subsystems accumulate state across a turn/session (e.g. context + governance tracks exploration rounds) and must not be shared with the base or + other sessions, or concurrent turns would trigger each other's nudges. + + Stateless / session-keyed shared services (LLM, DuckDB stores, registry, tool + bridge, and the context compressor — which operates on passed messages and + keeps its archive_fn wiring) are shared by reference. The engine's single-turn + internals are unchanged. + """ + engine = copy.copy(base_engine) # shallow copy: own __dict__, shared attr refs + engine._settings = _settings_for_workspace( + getattr(base_engine, "_settings", None), + workspace_root, + ) + # Fresh per-session substrate (the concurrency-corrupting parts). + engine._wm = working_memory + engine._tool_execution_ledger = ToolExecutionLedger() + # Fresh per-turn subsystems (stateful accumulators): a session engine must not + # share governance/ledger/usage/recovery with the base or other sessions. + engine._context_governance_controller = engine._new_governance() + engine._research_ledger = ResearchLedger() + engine._prefix_commitment = PrefixCommitmentController() + engine._usage_tracker = TurnUsageTracker() + engine._recovery_coordinator = RecoveryCoordinator() + engine._last_context_snapshot = {} + engine._last_turn_tool_categories = frozenset() + # Clean per-turn state slate (each turn also reassigns these, but a fresh + # session engine must not inherit the base engine's in-flight state). + engine._current_session_id = session_id + engine._current_turn_id = "" + engine._current_command_id = "" + engine._active_frame = None + engine._cancel_requested = False + engine._active_task = None + engine._session_turn_count = 0 + return engine diff --git a/src/leapflow/engine/subagent.py b/src/leapflow/engine/subagent.py index f4eab45..bbf7d5b 100644 --- a/src/leapflow/engine/subagent.py +++ b/src/leapflow/engine/subagent.py @@ -16,6 +16,7 @@ from __future__ import annotations import asyncio +import contextvars import logging import time import uuid @@ -28,11 +29,39 @@ _MAX_CONCURRENT_CHILDREN = 3 _SUMMARY_MAX_CHARS = 4000 +# Depth of the subagent frame currently executing, propagated across the await +# chain so a nested delegate_task can compute its child's depth. 0 = top level. +# +# Safe by construction: set() and reset() (in SubagentManager.delegate below) +# execute inside the same call with no Task boundary in between -- +# execute_subagent() (including EngineFrameSubagentExecutor's full-loop path, +# engine.py::_run_child_frame) resolves to a single value without ever +# yielding back through the parent engine's run_stream() generator. It +# therefore never crosses a per-chunk asyncio.create_task() boundary the way +# the daemon's leapd_approval_route once did (see the contract note on that +# ContextVar in daemon/service.py). If a future executor lets a subagent's +# progress stream back out through run_stream() before it completes, re-verify +# this invariant and, if violated, pin a shared contextvars.Context the same +# way server.py::_dispatch_stream does. +_current_depth: contextvars.ContextVar[int] = contextvars.ContextVar( + "leapflow_subagent_depth", default=0 +) + + +def current_subagent_depth() -> int: + """Depth of the subagent frame currently executing (0 = top-level turn).""" + return _current_depth.get() + + +# delegate_task is intentionally NOT blocked here: recursion is gated by depth in +# build_subagent_tool_filter + SubagentManager (a child is only offered/allowed +# while it stays within max_depth). Blocking it outright would disable recursion. DELEGATE_BLOCKED_TOOLS: FrozenSet[str] = frozenset({ - "delegate_task", "gp_delegate_task", "memory_write", "gp_memory_write", "send_message", "gp_send_message", "clarify", "gp_clarify", + "research_note", "gp_research_note", + "schedule_reentry", "gp_schedule_reentry", }) @@ -128,6 +157,7 @@ async def delegate(self, config: SubagentConfig) -> SubagentResult: async with self._semaphore: t0 = time.monotonic() + depth_token = _current_depth.set(config.depth) try: result = await self._executor.execute_subagent(config) result = self._trim_summary(result, config.summary_max_chars) @@ -148,6 +178,8 @@ async def delegate(self, config: SubagentConfig) -> SubagentResult: elapsed_s=time.monotonic() - t0, error=str(e), ) + finally: + _current_depth.reset(depth_token) if self._on_complete: try: @@ -218,6 +250,10 @@ async def execute_subagent(self, config: SubagentConfig) -> SubagentResult: available_tools = build_subagent_tool_filter( list(self._tool_handlers.keys()), config, + max_depth=( + getattr(self._settings, "agent_subagent_max_depth", _MAX_SPAWN_DEPTH) + if self._settings is not None else _MAX_SPAWN_DEPTH + ), ) filtered_handlers = { name: self._tool_handlers[name] @@ -253,7 +289,33 @@ async def execute_subagent(self, config: SubagentConfig) -> SubagentResult: else _SUMMARY_MAX_CHARS ) - for _ in range(config.max_iterations): + # Adaptive depth: a fresh elastic budget widened by an independent + # difficulty signal, so a hard sub-task earns more iterations while a + # simple one stays short (reuses the W1 budget + governance components). + from leapflow.engine.budget import BudgetConfig, BudgetStatus, IterationBudget + from leapflow.engine.context_control import ( + ContextGovernanceController, + ToolEvidenceBuilder, + ) + + floor = config.max_iterations + if self._settings is not None: + cfg_iters = getattr(self._settings, "agent_subagent_max_iterations", 0) + if cfg_iters > 0: + floor = cfg_iters + budget = IterationBudget.for_react( + BudgetConfig(max_iterations=floor, iter_ceiling=floor * 2) + ) + governance = ContextGovernanceController( + evidence_builder=ToolEvidenceBuilder(max_content_chars=result_budget), + ) + round_no = 0 + + import json as _json_sub + while True: + if budget.consume() == BudgetStatus.EXHAUSTED: + break + round_no += 1 try: resp = await self._llm.achat( messages, stream=False, enable_thinking=False, @@ -273,7 +335,6 @@ async def execute_subagent(self, config: SubagentConfig) -> SubagentResult: if not native_calls: break - import json as _json_sub assistant_msg: dict[str, Any] = {"role": "assistant", "content": content} assistant_msg["tool_calls"] = [ { @@ -291,6 +352,7 @@ async def execute_subagent(self, config: SubagentConfig) -> SubagentResult: else: try: result = await handler(tc.arguments) + governance.compact_tool_result(tc.name, tc.arguments, result) result_text = _json_sub.dumps(result, default=str, ensure_ascii=False) except Exception as e: result_text = _json_sub.dumps({"ok": False, "error": str(e)}) @@ -298,6 +360,10 @@ async def execute_subagent(self, config: SubagentConfig) -> SubagentResult: messages.append({"role": "tool", "tool_call_id": tc.id, "content": result_text}) tool_call_count += 1 + # widen the frame's budget toward its difficulty (bounded by ceiling) + difficulty = governance.snapshot(round_number=round_no).difficulty + budget.retarget(budget.elastic_max(difficulty)) + return SubagentResult( session_id=session_id, goal=config.goal, @@ -311,17 +377,76 @@ async def execute_subagent(self, config: SubagentConfig) -> SubagentResult: def build_subagent_tool_filter( parent_tools: List[str], config: SubagentConfig, + *, + max_depth: int = _MAX_SPAWN_DEPTH, ) -> List[str]: """Compute the effective tool list for a subagent. - Intersection of parent tools minus blocked tools, optionally filtered by allowed_tools. + Intersection of parent tools minus blocked tools, optionally filtered by + allowed_tools. delegate_task is offered only while a child would stay within + the depth budget (child_depth = config.depth + 1 < max_depth). """ available = set(parent_tools) - config.blocked_tools if config.allowed_tools is not None: available = available & config.allowed_tools - if config.depth >= _MAX_SPAWN_DEPTH - 1: + if config.depth + 1 >= max_depth: available -= {"delegate_task", "gp_delegate_task"} return sorted(available) + + +class EngineFrameSubagentExecutor: + """SubagentExecutor that runs the engine's full adaptive OODA loop on an + isolated child frame (opt-in via ``agent.subagent_full_loop``). + + Where :class:`DefaultSubagentExecutor` runs a deliberately lightweight loop, + this delegates to the engine's own ``_run_child_frame`` so the subagent gains + the full loop (progressive disclosure, compression, recovery, research + ledger) while staying state-isolated via the engine's per-frame state swap. + Tool access is still restricted by :func:`build_subagent_tool_filter`, and + recursion depth stays gated by the shared ``_current_depth`` contract. + """ + + def __init__( + self, + *, + run_child: Callable[..., Any], + tool_names: List[str], + settings: Any = None, + ) -> None: + self._run_child = run_child + self._tool_names = list(tool_names) + self._settings = settings + + async def execute_subagent(self, config: SubagentConfig) -> SubagentResult: + """Run an isolated subagent via the engine's full loop.""" + session_id = f"sub_{uuid.uuid4().hex[:12]}" + t0 = time.monotonic() + max_depth = ( + getattr(self._settings, "agent_subagent_max_depth", _MAX_SPAWN_DEPTH) + if self._settings is not None else _MAX_SPAWN_DEPTH + ) + available = build_subagent_tool_filter(self._tool_names, config, max_depth=max_depth) + goal = config.goal + if config.context: + goal = f"{config.goal}\n\nContext:\n{config.context}" + try: + summary = await self._run_child( + goal, + depth=config.depth, + tool_filter=frozenset(available), + enable_thinking=False, + ) + except Exception as exc: # isolate subagent failure from the parent loop + return SubagentResult( + session_id=session_id, goal=config.goal, + summary=f"Subagent error: {exc}", status="failed", + elapsed_s=time.monotonic() - t0, error=str(exc), + ) + return SubagentResult( + session_id=session_id, goal=config.goal, + summary=(summary or "")[:config.summary_max_chars], + status="completed", elapsed_s=time.monotonic() - t0, + ) diff --git a/src/leapflow/engine/tool_concurrency.py b/src/leapflow/engine/tool_concurrency.py index 6b1e280..057b310 100644 --- a/src/leapflow/engine/tool_concurrency.py +++ b/src/leapflow/engine/tool_concurrency.py @@ -1,17 +1,29 @@ -"""Tool concurrency policy — determines which tool calls can execute in parallel. - -Design (inspired by hermes tool_dispatch_helpers): -- Three-tier classification: always-parallel, path-scoped, always-sequential -- Path overlap detection prevents concurrent writes to same file subtree -- MCP tools get parallel safety from registry metadata -- Configurable via constructor injection (OCP) +"""Tool concurrency policy — metadata-driven parallel/sequential partitioning. + +Parallel-safety is derived from the SAME registry metadata that already drives +the idempotency ledger and the side-effect batch-stop gate +(``execution_policy_for``), rather than from a hardcoded tool-name list: + +- ``read_only`` -> parallel (pure reads never conflict) +- ``mutating_idempotent`` -> path-scoped: parallel iff its file path does not + overlap another concurrent write in the batch +- ``mutating_once`` / ``external_side_effect`` -> sequential +- unknown / unregistered -> sequential (conservative default; a new tool never + auto-parallelizes just because it is unlisted) + +A tool's concurrency behavior therefore follows from its declared ``x_leapflow`` +metadata, keeping this in lockstep with approval, idempotency, and batch-stop — +one source of truth, and it generalizes to any new tool (including MCP tools +that declare their metadata) with no name enumeration to maintain. """ from __future__ import annotations import logging import os from dataclasses import dataclass -from typing import Any, Dict, FrozenSet, Protocol, Sequence, Tuple, runtime_checkable +from typing import Any, Callable, Optional, Protocol, Sequence, Tuple, runtime_checkable + +from leapflow.engine.tool_execution import execution_policy_for logger = logging.getLogger(__name__) @@ -40,46 +52,10 @@ def partition( ... -# Tools that are always safe to parallelize (pure reads, no side effects). -# Names MUST match actual tool names from registry_bootstrap.py: -# gp_file_read, gp_file_list, gp_text_search, gp_skills_list, gp_skill_view, -# gp_time_get, gp_env_info, plus their unprefixed aliases. -_DEFAULT_PARALLEL_SAFE: FrozenSet[str] = frozenset({ - "gp_file_read", "gp_file_list", "gp_text_search", - "gp_skills_list", "gp_skill_view", - "gp_time_get", "gp_env_info", - "gp_web_search", "gp_web_extract", - "gp_session_search", "gp_memory_search", - "file_read", "file_list", "text_search", - "skills_list", "skill_view", - "time_get", "env_info", - "web_search", "web_extract", - "session_search", "memory_search", -}) - -# Tools where parallelism depends on non-overlapping file paths -_DEFAULT_PATH_SCOPED: FrozenSet[str] = frozenset({ - "gp_file_write", "gp_text_replace", - "file_write", "text_replace", -}) - -# Tools that must never run in parallel (side effects, user interaction, shell, -# or external platform authorization boundaries where one failure can block the -# rest of the turn). -_DEFAULT_NEVER_PARALLEL: FrozenSet[str] = frozenset({ - "gp_shell_run", "shell_run", - "gp_scm_sync", "scm_sync", - "clarify", "gp_clarify", - "delegate_task", "gp_delegate_task", - "memory_add", "gp_memory_add", - "platform_action", "gp_platform_action", - "platform_connect", "gp_platform_connect", - "gateway_send", "gp_gateway_send", - "gateway_connect", "gp_gateway_connect", - "hub_push", "gp_hub_push", - "hub_pull", "gp_hub_pull", - "hub_sync", "gp_hub_sync", -}) +# ``spec_lookup(tool_name) -> spec | None`` returns the registry metadata object +# (a ``ToolSpec`` with risk_level / mutates_state / effect_scope / +# idempotency_scope) for a normalized tool name, or None when unregistered. +SpecLookup = Callable[[str], Any] def _paths_overlap(left: str, right: str) -> bool: @@ -99,7 +75,7 @@ def _paths_overlap(left: str, right: str) -> bool: ) -def _extract_path(arguments: Dict[str, Any]) -> str: +def _extract_path(arguments: dict[str, Any]) -> str: """Extract path from tool call arguments.""" for key in ("path", "file_path", "target", "filename"): val = arguments.get(key) @@ -109,31 +85,16 @@ def _extract_path(arguments: Dict[str, Any]) -> str: class DefaultConcurrencyPolicy: - """Three-tier concurrency policy with path overlap detection. - - Tier 1: Always-parallel tools (pure reads) - Tier 2: Path-scoped tools (parallel iff paths don't overlap) - Tier 3: Always-sequential (stateful/interactive) + """Metadata-driven concurrency partition (see module docstring). - MCP tools are parallel-safe unless registered otherwise. + ``spec_lookup`` is injected (dependency inversion) so this module never + imports the registry directly; when it is absent or returns None the tool is + treated as sequential, so parallelism is strictly opt-in via declared + read-only / path-scoped-idempotent metadata. """ - def __init__( - self, - *, - parallel_safe: FrozenSet[str] | None = None, - path_scoped: FrozenSet[str] | None = None, - never_parallel: FrozenSet[str] | None = None, - stateful_prefixes: FrozenSet[str] | None = None, - mcp_parallel_safe: FrozenSet[str] | None = None, - ) -> None: - self._parallel_safe = parallel_safe or _DEFAULT_PARALLEL_SAFE - self._path_scoped = path_scoped or _DEFAULT_PATH_SCOPED - self._never_parallel = never_parallel or _DEFAULT_NEVER_PARALLEL - self._stateful_prefixes = stateful_prefixes or frozenset({ - "shell", "batch_", "clipboard", - }) - self._mcp_parallel_safe = mcp_parallel_safe or frozenset() + def __init__(self, *, spec_lookup: Optional[SpecLookup] = None) -> None: + self._spec_lookup = spec_lookup def partition( self, tool_calls: Sequence[ToolCall] @@ -146,35 +107,21 @@ def partition( concurrent_paths: list[str] = [] for tc in tool_calls: - if tc.name in self._never_parallel: - sequential.append(tc) - continue - - if tc.name in self._parallel_safe: + policy = self._policy_for(tc.name) + if policy == "read_only": concurrent.append(tc) - continue - - if tc.name.startswith("mcp_"): - if tc.name in self._mcp_parallel_safe or self._is_mcp_read(tc): + elif policy == "mutating_idempotent": + # Path-scoped: safe to parallelize only when this write's path + # does not overlap another write already in the concurrent group. + path = _extract_path(tc.arguments) + if path and not any(_paths_overlap(path, ep) for ep in concurrent_paths): concurrent.append(tc) + concurrent_paths.append(path) else: sequential.append(tc) - continue - - if tc.name in self._path_scoped: - path = _extract_path(tc.arguments) - if path and any(_paths_overlap(path, ep) for ep in concurrent_paths): - sequential.append(tc) - else: - concurrent.append(tc) - if path: - concurrent_paths.append(path) - continue - - if self._is_stateful(tc.name): - sequential.append(tc) else: - concurrent.append(tc) + # mutating_once / external_side_effect / unknown -> sequential. + sequential.append(tc) logger.debug( "tool_concurrency.partition concurrent=%d sequential=%d", @@ -183,13 +130,19 @@ def partition( ) return concurrent, sequential - def _is_stateful(self, tool_name: str) -> bool: - if tool_name in self._never_parallel: - return True - return any(tool_name.startswith(prefix) for prefix in self._stateful_prefixes) + def _policy_for(self, name: str) -> Optional[str]: + """Return the tool's execution policy from registry metadata. - @staticmethod - def _is_mcp_read(tc: ToolCall) -> bool: - """Heuristic: MCP tools with 'read', 'get', 'list', 'search' are likely read-only.""" - name_lower = tc.name.lower() - return any(verb in name_lower for verb in ("read", "get", "list", "search", "fetch", "query")) + Returns None for an unregistered / unresolvable tool, which the caller + treats as sequential (conservative default). + """ + if self._spec_lookup is None: + return None + try: + spec = self._spec_lookup(name) + except Exception: # a lookup failure must never break tool dispatch + logger.debug("tool_concurrency: spec_lookup failed for %s", name, exc_info=True) + return None + if spec is None: + return None + return execution_policy_for(name, spec) diff --git a/src/leapflow/engine/tool_guardrails.py b/src/leapflow/engine/tool_guardrails.py index 8c44af2..651fa09 100644 --- a/src/leapflow/engine/tool_guardrails.py +++ b/src/leapflow/engine/tool_guardrails.py @@ -17,7 +17,7 @@ import hashlib import json import logging -from dataclasses import dataclass, field +from dataclasses import dataclass from typing import Any, Dict, List, Optional, Protocol, runtime_checkable logger = logging.getLogger(__name__) @@ -96,30 +96,48 @@ def __init__(self, *, window: int = 10, min_success_rate: float = 0.2) -> None: def check(self, history: List[Dict[str, Any]]) -> GuardrailViolation: tool_results = [ m for m in history[-self._window * 3:] - if m.get("role") in ("tool", "user") + if self._is_tool_result_message(m) ] if len(tool_results) < self._window: return GuardrailViolation(violated=False) + recent = tool_results[-self._window:] successes = 0 - for msg in tool_results[-self._window:]: + for msg in recent: content = msg.get("content", "") if not isinstance(content, str): continue if self._is_success_result(content): successes += 1 - rate = successes / self._window + rate = successes / len(recent) if rate < self._min_rate: return GuardrailViolation( violated=True, - reason=f"Low tool success rate ({rate:.0%}) in last {self._window} calls", + reason=f"Low tool success rate ({rate:.0%}) in last {len(recent)} calls", severity="warning", suggestion="Most recent tool calls are failing. Reassess your approach.", ) return GuardrailViolation(violated=False) + @staticmethod + def _is_tool_result_message(msg: Dict[str, Any]) -> bool: + """Whether a message is a genuine tool result (not injected context). + + Only native ``tool`` messages and text-mode ``Tool result (...)`` user + messages count. Injected user/system context (live signals, research + ledger, convergence/cost notices, memory) is excluded so the success + rate is not diluted by non-tool messages on a context-heavy long task. + """ + role = msg.get("role") + if role == "tool": + return True + if role == "user": + content = msg.get("content", "") + return isinstance(content, str) and content.lstrip().startswith("Tool result (") + return False + @staticmethod def _is_success_result(content: str) -> bool: """Detect tool success from native tool JSON or text-mode tool results.""" @@ -165,7 +183,7 @@ def check(self, history: List[Dict[str, Any]]) -> GuardrailViolation: violated=True, reason=f"Tool '{tail[0]}' used {self._threshold} times consecutively", severity="warning", - suggestion=f"Consider using a different tool or providing the answer directly.", + suggestion="Consider using a different tool or providing the answer directly.", ) return GuardrailViolation(violated=False) diff --git a/src/leapflow/engine/turn_recovery.py b/src/leapflow/engine/turn_recovery.py index a7c1834..a0becb7 100644 --- a/src/leapflow/engine/turn_recovery.py +++ b/src/leapflow/engine/turn_recovery.py @@ -108,6 +108,33 @@ def try_multimodal_strip(self) -> bool: self._multimodal_strip = True return True + def rearm_after_progress(self) -> bool: + """Re-arm content-level one-shot recovery guards after genuine progress. + + A long task is a single turn spanning many iterations, and legitimately + needs the same *content* recovery more than once — e.g. several max_tokens + continuations or context force-compressions across the turn. Once the + task has made progress (so it is not in a recovery storm), these guards + are re-armed so recovery stays available for the rest of the turn. + + Infrastructure-level one-shots (native->text fallback, provider failover, + credential rotation) stay strict for the whole turn — they are storm-prone + and are already bounded separately by the RecoveryBudget. Returns True if + any guard was actually re-armed. + """ + rearmed = ( + self._compressed or self._length_continuation or self._format_recovery + or self._image_shrunk or self._multimodal_strip or self._thinking_disabled + ) + if rearmed: + self._compressed = False + self._length_continuation = False + self._format_recovery = False + self._image_shrunk = False + self._multimodal_strip = False + self._thinking_disabled = False + return rearmed + def record_api_error(self, category: Optional[str] = None) -> int: """Increment API error counter. Returns new count.""" self.consecutive_api_errors += 1 diff --git a/src/leapflow/engine/turn_usage.py b/src/leapflow/engine/turn_usage.py index 04918ac..7a408a3 100644 --- a/src/leapflow/engine/turn_usage.py +++ b/src/leapflow/engine/turn_usage.py @@ -11,9 +11,8 @@ from __future__ import annotations import logging -import time -from dataclasses import dataclass, field -from typing import Any, Dict, List, Optional +from dataclasses import dataclass +from typing import Any, Dict, List logger = logging.getLogger(__name__) @@ -25,6 +24,7 @@ class TurnUsageSummary: prompt_tokens: int = 0 completion_tokens: int = 0 total_tokens: int = 0 + cached_tokens: int = 0 latency_ms: int = 0 api_calls: int = 0 tool_calls: int = 0 @@ -34,6 +34,62 @@ class TurnUsageSummary: provider_name: str = "" model: str = "" + @property + def cache_hit_rate(self) -> float: + """Fraction of prompt tokens served from the provider prefix cache.""" + return round(self.cached_tokens / self.prompt_tokens, 4) if self.prompt_tokens else 0.0 + + def effective_prompt_tokens(self, cached_price_ratio: float = 0.1) -> float: + """Prompt tokens weighted by cache pricing (cached reads are cheaper). + + ``cached_price_ratio`` is the price of a cached-read token relative to an + uncached (miss) token; providers typically bill cached reads at ~0.1x. + Used by cost accounting so a well-cached long task costs less per turn. + """ + miss = max(0, self.prompt_tokens - self.cached_tokens) + return round(miss + self.cached_tokens * max(0.0, cached_price_ratio), 2) + + +def cost_ceiling_exceeded( + *, + effective_prompt_tokens: float, + context_length: int, + context_multiple: float, +) -> bool: + """Whether cumulative effective prompt cost has crossed the turn ceiling. + + The ceiling is ``context_length * context_multiple`` effective prompt tokens + accumulated across the turn. ``context_multiple <= 0`` disables it (the + elastic iteration cap remains the hard bound). Intended as a *soft* safety: + callers nudge finalization rather than hard-stopping, so no work is lost. + """ + if context_multiple <= 0 or context_length <= 0: + return False + return effective_prompt_tokens >= context_length * context_multiple + + +def build_adaptive_learning_signal(snapshot: Dict[str, Any]) -> Dict[str, Any]: + """Compact adaptive-depth orient snapshot for S3 calibration (observe-only). + + Derives, from the turn's last context snapshot, the predicted difficulty / + posture / commitment so offline analysis (S3-L2) can relate them to the + recorded outcome and effort and calibrate the difficulty weights and + posture/commitment thresholds. Purely derived; never changes behavior. + """ + snap = snapshot or {} + signal: Dict[str, Any] = { + "final_difficulty": round(float(snap.get("difficulty", 0.0) or 0.0), 4), + "final_posture": str(snap.get("context_posture", "") or ""), + "prefix_committed": bool(snap.get("prefix_committed", False)), + } + open_questions = snap.get("open_questions", None) + if open_questions is not None: + signal["open_questions"] = open_questions + effective = snap.get("cumulative_effective_tokens", None) + if effective: + signal["cumulative_effective_tokens"] = effective + return signal + @dataclass class _ToolCallRecord: @@ -57,6 +113,7 @@ def __init__(self) -> None: self._prompt_tokens: int = 0 self._completion_tokens: int = 0 self._total_tokens: int = 0 + self._cached_tokens: int = 0 self._total_latency_ms: int = 0 self._api_calls: int = 0 self._tool_records: List[_ToolCallRecord] = [] @@ -76,6 +133,7 @@ def record_api_call( self._prompt_tokens += usage.get("prompt_tokens", 0) self._completion_tokens += usage.get("completion_tokens", 0) self._total_tokens += usage.get("total_tokens", 0) + self._cached_tokens += usage.get("cached_tokens", 0) self._total_latency_ms += usage.get("latency_ms", 0) if provider: self._provider_name = provider @@ -97,6 +155,7 @@ def summary(self) -> TurnUsageSummary: prompt_tokens=self._prompt_tokens, completion_tokens=self._completion_tokens, total_tokens=self._total_tokens, + cached_tokens=self._cached_tokens, latency_ms=self._total_latency_ms, api_calls=self._api_calls, tool_calls=len(self._tool_records), @@ -112,6 +171,7 @@ def reset(self) -> None: self._prompt_tokens = 0 self._completion_tokens = 0 self._total_tokens = 0 + self._cached_tokens = 0 self._total_latency_ms = 0 self._api_calls = 0 self._tool_records.clear() @@ -132,6 +192,7 @@ def to_learning_signal(self) -> Dict[str, Any]: "tool_failure_rate": round(s.tool_failures / max(s.tool_calls, 1), 3), "total_latency_ms": s.latency_ms, "total_tokens": s.total_tokens, + "cache_hit_rate": s.cache_hit_rate, } def format_log_line(self) -> str: @@ -140,6 +201,7 @@ def format_log_line(self) -> str: return ( f"tokens={s.total_tokens} " f"(prompt={s.prompt_tokens} completion={s.completion_tokens}) " + f"cache_hit={s.cache_hit_rate:.0%} " f"api_calls={s.api_calls} tools={s.tool_calls} " f"(ok={s.tool_successes} fail={s.tool_failures}) " f"latency={s.latency_ms}ms provider={s.provider_name}" diff --git a/src/leapflow/learning/difficulty_calibration.py b/src/leapflow/learning/difficulty_calibration.py new file mode 100644 index 0000000..218103d --- /dev/null +++ b/src/leapflow/learning/difficulty_calibration.py @@ -0,0 +1,325 @@ +"""S3-L2: offline difficulty calibration analysis (report-only). + +Consumes the adaptive-depth learning signals captured per turn (S3-L1) from the +evolution episode store and relates the *predicted* difficulty to the *actual* +effort and outcome. Produces a bounded, report-only calibration suggestion; it +never mutates runtime weights (that is S3-L3's online step, gated + reviewed). + +Core question: "Does the difficulty signal correctly predict how much effort a +turn needs?" If high-difficulty turns do not cost more (over-prediction) or +low-difficulty turns fail / cost a lot (under-prediction), the difficulty +sensitivity weight should be scaled. This module only *reports* the suggestion. + +All analysis is pure and hermetic; ``build_calibration_report_from_store`` is a +thin offline consumer that reads persisted episodes on demand (never in the +hot loop). +""" +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional + +_SUCCESS_OUTCOMES = frozenset({"success", "completed"}) +_LOW_MAX = 0.34 +_HIGH_MIN = 0.66 +_SCALE_MIN = 0.5 +_SCALE_MAX = 1.5 +_MIN_SAMPLES = 3 +_CONFIDENCE_FULL_AT = 50.0 + + +@dataclass(frozen=True) +class DifficultyBucket: + """Aggregate effort/success for one predicted-difficulty band.""" + + label: str + sample_size: int + avg_effort: float + success_rate: float + + +@dataclass(frozen=True) +class DifficultyCalibrationReport: + """Report-only calibration analysis over captured turn signals. + + ``suggested_weight_scale`` is a bounded multiplier (``[0.5, 1.5]``) for the + difficulty sensitivity; ``1.0`` means "well calibrated, no change". It is a + suggestion only — applying it is S3-L3's gated online step. + """ + + sample_size: int + buckets: List[DifficultyBucket] = field(default_factory=list) + effort_monotonic: bool = True + suggested_weight_scale: float = 1.0 + confidence: float = 0.0 + rationale: str = "insufficient data" + + def summary(self) -> Dict[str, Any]: + """Flat dict for logging / CLI / dashboard surfacing.""" + return { + "sample_size": self.sample_size, + "effort_monotonic": self.effort_monotonic, + "suggested_weight_scale": round(self.suggested_weight_scale, 3), + "confidence": round(self.confidence, 3), + "rationale": self.rationale, + "buckets": [ + { + "label": b.label, + "n": b.sample_size, + "avg_effort": round(b.avg_effort, 2), + "success_rate": round(b.success_rate, 3), + } + for b in self.buckets + ], + } + + +def _effort(record: Dict[str, Any]) -> Optional[float]: + """Actual effort proxy: explicit round count, else API calls, else actions.""" + ctx = record.get("context") or {} + if "steps" in ctx: + try: + return float(ctx["steps"]) + except (TypeError, ValueError): + pass + if "api_retries" in ctx: # api_calls == api_retries + 1 (see TurnUsageTracker) + try: + return float(ctx["api_retries"]) + 1.0 + except (TypeError, ValueError): + pass + actions = record.get("actions") or [] + if actions: + return float(len(actions)) + return None + + +def _is_success(record: Dict[str, Any]) -> bool: + try: + if float(record.get("reward", 0.0)) > 0.0: + return True + except (TypeError, ValueError): + pass + return str(record.get("outcome", "")).lower() in _SUCCESS_OUTCOMES + + +def _difficulty(record: Dict[str, Any]) -> Optional[float]: + val = (record.get("context") or {}).get("final_difficulty") + if val is None: + return None + try: + return float(val) + except (TypeError, ValueError): + return None + + +def analyze_difficulty_calibration( + records: List[Dict[str, Any]], +) -> DifficultyCalibrationReport: + """Relate predicted difficulty to actual effort/outcome (report-only). + + ``records`` are evolution episodes (as returned by the evolution store): + each may carry the S3-L1 signal under ``context.final_difficulty`` plus an + effort proxy and an outcome/reward. Episodes lacking the signal are skipped. + """ + usable: List[tuple[float, float, bool]] = [] + for record in records: + difficulty = _difficulty(record) + effort = _effort(record) + if difficulty is None or effort is None: + continue + usable.append((difficulty, effort, _is_success(record))) + + sample_size = len(usable) + if sample_size < _MIN_SAMPLES: + return DifficultyCalibrationReport(sample_size=sample_size) + + bands: Dict[str, List[tuple[float, bool]]] = {"low": [], "medium": [], "high": []} + for difficulty, effort, ok in usable: + band = "low" if difficulty < _LOW_MAX else ("high" if difficulty >= _HIGH_MIN else "medium") + bands[band].append((effort, ok)) + + buckets: List[DifficultyBucket] = [] + for label in ("low", "medium", "high"): + items = bands[label] + if not items: + buckets.append(DifficultyBucket(label, 0, 0.0, 0.0)) + continue + avg_effort = sum(effort for effort, _ in items) / len(items) + success_rate = sum(1 for _, ok in items if ok) / len(items) + buckets.append(DifficultyBucket(label, len(items), avg_effort, success_rate)) + + by_label = {b.label: b for b in buckets} + low, high = by_label["low"], by_label["high"] + + populated = [b for b in buckets if b.sample_size > 0] + efforts = [b.avg_effort for b in populated] + monotonic = all(efforts[i] <= efforts[i + 1] + 1e-9 for i in range(len(efforts) - 1)) + + scale = 1.0 + rationale = "difficulty tracks effort; no adjustment suggested" + if high.sample_size > 0 and low.sample_size > 0 and high.avg_effort <= low.avg_effort: + scale = 0.8 + rationale = "high-difficulty turns cost no more than low ones (over-predicted): reduce sensitivity" + elif low.sample_size > 0 and ( + low.success_rate < 0.5 + or (high.sample_size > 0 and low.avg_effort >= high.avg_effort) + ): + scale = 1.2 + rationale = "low-difficulty turns fail or cost like hard ones (under-predicted): raise sensitivity" + elif not monotonic: + scale = 0.9 + rationale = "difficulty is a noisy effort predictor: slightly reduce sensitivity" + + scale = max(_SCALE_MIN, min(_SCALE_MAX, scale)) + confidence = round(min(1.0, sample_size / _CONFIDENCE_FULL_AT), 3) + + return DifficultyCalibrationReport( + sample_size=sample_size, + buckets=buckets, + effort_monotonic=monotonic, + suggested_weight_scale=scale, + confidence=confidence, + rationale=rationale, + ) + + +def build_calibration_report_from_store( + store: Any, *, limit: int = 500, +) -> DifficultyCalibrationReport: + """Load recent episodes from an evolution store and analyze calibration. + + Thin offline consumer bridging the persisted S3-L1 signals to the pure + analyzer (for on-demand reporting; never called in the hot loop). + """ + records = store.load_recent_episodes(limit=limit) + return analyze_difficulty_calibration(records) + + +_CALIB_MIN_SAMPLES = 10 +_CALIB_MIN_CONFIDENCE = 0.3 +_CALIB_K_MIN = 0.25 +_CALIB_K_MAX = 3.0 + + +@dataclass(frozen=True) +class CalibrationResult: + """Outcome of applying a calibration report to the difficulty weight. + + ``effective_k`` is always derived from ``baseline_k`` (never the previously + calibrated value), so repeated recalibration cannot compound or drift and + resetting to baseline is exact. + """ + + baseline_k: float + effective_k: float + applied: bool + reason: str + + +def apply_calibration( + baseline_k: float, + report: DifficultyCalibrationReport, + *, + enabled: bool, + min_confidence: float = _CALIB_MIN_CONFIDENCE, + min_samples: int = _CALIB_MIN_SAMPLES, + k_min: float = _CALIB_K_MIN, + k_max: float = _CALIB_K_MAX, +) -> CalibrationResult: + """S3-L3: turn a calibration report into a bounded, gated weight adjustment. + + Applies ``report.suggested_weight_scale`` to ``baseline_k`` only when + calibration is enabled, there is enough evidence (samples + confidence), and + an adjustment is actually suggested. The result is clamped to ``[k_min, + k_max]``. Pure and side-effect free — the caller decides whether to install + ``effective_k`` (and can revert to ``baseline_k`` at any time). + """ + if not enabled: + return CalibrationResult(baseline_k, baseline_k, False, "calibration disabled") + if report.sample_size < min_samples: + return CalibrationResult( + baseline_k, baseline_k, False, + f"insufficient samples ({report.sample_size} < {min_samples})", + ) + if report.confidence < min_confidence: + return CalibrationResult( + baseline_k, baseline_k, False, + f"confidence too low ({report.confidence:.2f} < {min_confidence:.2f})", + ) + if report.suggested_weight_scale == 1.0: + return CalibrationResult(baseline_k, baseline_k, False, "already well calibrated") + effective = max(k_min, min(k_max, baseline_k * report.suggested_weight_scale)) + return CalibrationResult(baseline_k, round(effective, 4), True, report.rationale) + + +_PREMATURE_HIGH = 0.4 +_PREMATURE_LOW = 0.1 + + +@dataclass(frozen=True) +class ThresholdCalibrationReport: + """S3-L4: report on finalize-posture threshold health (report-only). + + Exposes ``sample_size`` / ``confidence`` / ``suggested_weight_scale`` with the + same shape as :class:`DifficultyCalibrationReport`, so it reuses + :func:`apply_calibration` (with ratio bounds) unchanged. + """ + + sample_size: int + premature_finalize_rate: float = 0.0 + suggested_weight_scale: float = 1.0 + confidence: float = 0.0 + rationale: str = "insufficient data" + + def summary(self) -> Dict[str, Any]: + return { + "sample_size": self.sample_size, + "premature_finalize_rate": round(self.premature_finalize_rate, 3), + "suggested_weight_scale": round(self.suggested_weight_scale, 3), + "confidence": round(self.confidence, 3), + "rationale": self.rationale, + } + + +def analyze_threshold_calibration( + records: List[Dict[str, Any]], +) -> ThresholdCalibrationReport: + """Detect premature finalization to tune the finalize posture threshold. + + A turn whose final posture is ``finalizing`` but whose outcome failed likely + finalized too early. A high premature-finalize rate suggests raising the + finalize threshold (converge later); a very low rate allows finalizing a + touch earlier. Report-only — applying is gated via :func:`apply_calibration`. + """ + finalizing = [ + r for r in records + if str((r.get("context") or {}).get("final_posture", "")).lower() == "finalizing" + ] + n = len(finalizing) + if n < _MIN_SAMPLES: + return ThresholdCalibrationReport(sample_size=n) + failures = sum(1 for r in finalizing if not _is_success(r)) + rate = failures / n + scale = 1.0 + rationale = "finalize threshold healthy" + if rate >= _PREMATURE_HIGH: + scale = 1.1 + rationale = f"high premature-finalize rate ({rate:.2f}): raise finalize threshold (converge later)" + elif rate <= _PREMATURE_LOW: + scale = 0.95 + rationale = f"low premature-finalize rate ({rate:.2f}): allow slightly earlier finalize" + confidence = round(min(1.0, n / _CONFIDENCE_FULL_AT), 3) + return ThresholdCalibrationReport( + sample_size=n, + premature_finalize_rate=round(rate, 3), + suggested_weight_scale=scale, + confidence=confidence, + rationale=rationale, + ) + + +def build_threshold_report_from_store( + store: Any, *, limit: int = 500, +) -> ThresholdCalibrationReport: + """Load recent episodes and analyze finalize-threshold calibration (offline).""" + return analyze_threshold_calibration(store.load_recent_episodes(limit=limit)) diff --git a/src/leapflow/llm/openai_provider.py b/src/leapflow/llm/openai_provider.py index 013b1e5..c943e65 100644 --- a/src/leapflow/llm/openai_provider.py +++ b/src/leapflow/llm/openai_provider.py @@ -28,6 +28,26 @@ ) +def _extract_cached_tokens(usage: Any) -> int: + """Best-effort cached-prompt-token count across OpenAI-compatible providers. + + Recognizes OpenAI (``usage.prompt_tokens_details.cached_tokens``) and + DeepSeek (``usage.prompt_cache_hit_tokens``) prefix-cache reporting. Returns + 0 when the provider reports no prefix-cache hit, so effective-cost accounting + degrades gracefully on providers without cache telemetry. + """ + cached = 0 + details = getattr(usage, "prompt_tokens_details", None) + if details is not None: + value = getattr(details, "cached_tokens", None) + if isinstance(value, int) and value > 0: + cached = value + hit = getattr(usage, "prompt_cache_hit_tokens", None) + if isinstance(hit, int) and hit > cached: + cached = hit + return cached + + @dataclass(frozen=True) class _ProviderProfile: """Provider-specific behavior flags.""" @@ -213,6 +233,9 @@ async def _achat_nonstream( v = getattr(u, k, None) if isinstance(v, int): usage_map[k] = v + cached = _extract_cached_tokens(u) + if cached: + usage_map["cached_tokens"] = cached usage_map["latency_ms"] = dt_ms # Extract native tool_calls from the response message @@ -290,6 +313,9 @@ async def _achat_stream_collapsed( v = getattr(u, k, None) if isinstance(v, int): usage_map[k] = v + cached = _extract_cached_tokens(u) + if cached: + usage_map["cached_tokens"] = cached dt_ms = int((time.monotonic() - t0) * 1000) usage_map.setdefault("latency_ms", dt_ms) @@ -410,6 +436,9 @@ def _chat_nonstream( v = getattr(u, k, None) if isinstance(v, int): usage_map[k] = v + cached = _extract_cached_tokens(u) + if cached: + usage_map["cached_tokens"] = cached usage_map["latency_ms"] = dt_ms # Extract native tool_calls from the response message (sync path) @@ -484,6 +513,9 @@ def _chat_stream_collapsed( v = getattr(u, k, None) if isinstance(v, int): usage_map[k] = v + cached = _extract_cached_tokens(u) + if cached: + usage_map["cached_tokens"] = cached dt_ms = int((time.monotonic() - t0) * 1000) usage_map.setdefault("latency_ms", dt_ms) diff --git a/src/leapflow/memory/manager.py b/src/leapflow/memory/manager.py index c24d3a0..6aedee7 100644 --- a/src/leapflow/memory/manager.py +++ b/src/leapflow/memory/manager.py @@ -5,6 +5,7 @@ """ from __future__ import annotations +import inspect import json import logging import math @@ -128,14 +129,20 @@ def on_turn_start(self, turn: int, user_message: str) -> None: # ═══════════════ Core operations ═══════════════ - async def insert(self, entry: MemoryEntry) -> str: + async def insert(self, entry: MemoryEntry, *, session_id: str = "") -> str: """Route entry to accepting providers and insert. Routing strategy: 1. If a provider accepts() the entry → use it 2. Otherwise fallback to first available provider After insert, fire on_inserted hook on all providers. + + When *session_id* is provided, the entry is tagged with that session + so that session-scoped searches can later isolate it. """ + if session_id: + entry.metadata = {**(entry.metadata or {}), "_session_id": session_id} + inserted_by: Optional[str] = None entry_id = entry.entry_id @@ -143,7 +150,13 @@ async def insert(self, entry: MemoryEntry) -> str: provider = self._providers[name] if provider.accepts(entry): try: - entry_id = await provider.insert(entry) + # Pass session_id to providers that support it + insert_fn = provider.insert + sig = inspect.signature(insert_fn) + if "session_id" in sig.parameters: + entry_id = await insert_fn(entry, session_id=session_id) + else: + entry_id = await insert_fn(entry) inserted_by = name break except Exception as exc: @@ -153,7 +166,12 @@ async def insert(self, entry: MemoryEntry) -> str: if inserted_by is None and self._providers: first = self._providers[self._provider_order[0]] try: - entry_id = await first.insert(entry) + insert_fn = first.insert + sig = inspect.signature(insert_fn) + if "session_id" in sig.parameters: + entry_id = await insert_fn(entry, session_id=session_id) + else: + entry_id = await insert_fn(entry) inserted_by = self._provider_order[0] except Exception as exc: logger.warning("memory.insert_fallback_failed error=%s", exc) @@ -171,11 +189,12 @@ async def search(self, query: MemoryQuery) -> List[MemoryEntry]: Otherwise, queries all providers and merges by score. """ if query.cross_domain: - return await self.search_cross_domain( + correlated = await self.search_cross_domain( " ".join(query.keywords), time_window_s=self._cross_domain_window_s, limit=query.limit, ) + return self._isolate_by_workspace(correlated, query.workspace_root) candidates = self._route_search(query) all_results: List[MemoryEntry] = [] @@ -193,6 +212,10 @@ async def search(self, query: MemoryQuery) -> List[MemoryEntry]: if entry.entry_id not in seen: seen.add(entry.entry_id) unique.append(entry) + # Cross-workspace isolation: a daemon shared across projects must never + # surface another workspace's tagged memories. Untagged entries are + # global (user prefs, cross-project facts, legacy) and always pass. + unique = self._isolate_by_workspace(unique, query.workspace_root) return unique[:query.limit] async def search_cross_domain( @@ -270,8 +293,9 @@ async def prefetch( workspace_root: str = "", task_id: str = "", scope_keywords: List[str] | None = None, + session_scope: str = "", ) -> List[MemoryEntry]: - """Quick search for LLM context injection with optional project/task scope.""" + """Quick search for LLM context injection with optional project/task/session scope.""" keywords = query_text.split()[:5] scope_terms = [term for term in (scope_keywords or []) if term] query = MemoryQuery( @@ -280,6 +304,7 @@ async def prefetch( workspace_root=workspace_root, task_id=task_id, scope_keywords=scope_terms, + session_scope=session_scope, ) entries = await self.search(query) if workspace_root or scope_terms or task_id: @@ -346,7 +371,55 @@ def _entry_matches_scope(entry: MemoryEntry, *, workspace_root: str, scope_terms ]).lower() return bool(scope_terms and any(term in haystack for term in scope_terms)) - async def sync_turn(self, messages: List[Dict[str, Any]]) -> None: + @staticmethod + def _normalize_workspace(root: str) -> str: + """Resolve a workspace root to a canonical absolute path string.""" + if not root: + return "" + try: + return str(Path(str(root)).expanduser().resolve()) + except (OSError, RuntimeError, ValueError): + return str(root) + + @classmethod + def _entry_workspace_tag(cls, entry: MemoryEntry) -> str: + """Return the authoritative workspace tag on an entry, if any.""" + metadata = entry.metadata or {} + raw = metadata.get("workspace_root") or metadata.get("project_root") or "" + return cls._normalize_workspace(str(raw)) if raw else "" + + @classmethod + def _isolate_by_workspace( + cls, entries: List[MemoryEntry], workspace_root: str + ) -> List[MemoryEntry]: + """Drop entries whose workspace tag belongs to a different workspace. + + Entries without a workspace tag are treated as global (user prefs, + cross-project facts, legacy records) and always pass. This enforces + per-workspace isolation on a daemon shared across concurrent projects + without hiding genuinely global knowledge. + """ + root = cls._normalize_workspace(workspace_root) + if not root: + return entries + kept: List[MemoryEntry] = [] + for entry in entries: + tag = cls._entry_workspace_tag(entry) + if not tag or tag == root: + kept.append(entry) + return kept + + @classmethod + def tag_entry_workspace(cls, entry: MemoryEntry, workspace_root: str) -> MemoryEntry: + """Tag an entry with the active workspace unless it already carries one.""" + root = cls._normalize_workspace(workspace_root) + if root and not (entry.metadata or {}).get("workspace_root"): + entry.metadata = {**(entry.metadata or {}), "workspace_root": root} + return entry + + async def sync_turn( + self, messages: List[Dict[str, Any]], *, workspace_root: str = "", session_id: str = "" + ) -> None: """Background sync of conversation turn (fire-and-forget safe).""" # Extract last assistant message for storage for msg in reversed(messages): @@ -356,7 +429,8 @@ async def sync_turn(self, messages: List[Dict[str, Any]]) -> None: domain=SignalDomain.SYSTEM, content=msg["content"][:500], ) - await self.insert(entry) + self.tag_entry_workspace(entry, workspace_root) + await self.insert(entry, session_id=session_id) break # ═══════════════ Promotion ═══════════════ @@ -463,17 +537,21 @@ def get_openai_tool_schemas(self) -> List[Dict[str, Any]]: for s in self.get_tool_schemas() ] - async def handle_tool_call(self, tool_name: str, args: Dict[str, Any]) -> str: + async def handle_tool_call( + self, tool_name: str, args: Dict[str, Any], *, workspace_root: str = "" + ) -> str: """Route LLM tool calls to the appropriate handler. Manager-level tools (memory_search, memory_add) are handled directly. Provider-specific tools are dispatched to the owning provider. + ``workspace_root`` scopes reads and tags writes to the active project + so a shared daemon never mixes memories across workspaces. """ # Manager-level tools if tool_name == "memory_search": - return await self._handle_memory_search(args) + return await self._handle_memory_search(args, workspace_root=workspace_root) if tool_name == "memory_add": - return await self._handle_memory_add(args) + return await self._handle_memory_add(args, workspace_root=workspace_root) # Provider-specific tools provider_name = self._tool_dispatch.get(tool_name) @@ -533,7 +611,9 @@ def _rebuild_tool_dispatch(self) -> None: except Exception: pass - async def _handle_memory_search(self, args: Dict[str, Any]) -> str: + async def _handle_memory_search( + self, args: Dict[str, Any], *, workspace_root: str = "" + ) -> str: """Handle manager-level memory_search tool call.""" query_text = args.get("query", "") domain_str = args.get("domain") @@ -544,6 +624,7 @@ async def _handle_memory_search(self, args: Dict[str, Any]) -> str: keywords=query_text.split()[:8], domains=domains, limit=limit, + workspace_root=workspace_root, ) results = await self.search(mq) return json.dumps({ @@ -558,7 +639,9 @@ async def _handle_memory_search(self, args: Dict[str, Any]) -> str: ] }, ensure_ascii=False) - async def _handle_memory_add(self, args: Dict[str, Any]) -> str: + async def _handle_memory_add( + self, args: Dict[str, Any], *, workspace_root: str = "" + ) -> str: """Handle manager-level memory_add tool call.""" content = args.get("content", "") if not content: @@ -579,5 +662,6 @@ async def _handle_memory_add(self, args: Dict[str, Any]) -> str: domain=domain, content=content[:2200], # Safety cap per design doc ) + self.tag_entry_workspace(entry, workspace_root) entry_id = await self.insert(entry) return json.dumps({"success": True, "id": entry_id}) diff --git a/src/leapflow/memory/protocol.py b/src/leapflow/memory/protocol.py index d9d8e67..7c8af4e 100644 --- a/src/leapflow/memory/protocol.py +++ b/src/leapflow/memory/protocol.py @@ -78,6 +78,7 @@ class MemoryQuery: workspace_root: str = "" task_id: str = "" scope_keywords: List[str] = field(default_factory=list) + session_scope: str = "" # When non-empty, restrict results to this session @dataclass diff --git a/src/leapflow/memory/providers/episodic.py b/src/leapflow/memory/providers/episodic.py index 9dc4818..969f97e 100644 --- a/src/leapflow/memory/providers/episodic.py +++ b/src/leapflow/memory/providers/episodic.py @@ -118,6 +118,7 @@ def __init__( self._entries: Dict[str, MemoryEntry] = {} self._access_counts: Dict[str, int] = {} self._gc_task: Optional[asyncio.Task[None]] = None + self._gc_started: bool = False self._counter: int = 0 # ── Protocol properties ─────────────────────────────────────────── @@ -129,8 +130,10 @@ def name(self) -> str: # ── Protocol methods ────────────────────────────────────────────── async def initialize(self, **kwargs: Any) -> None: - """Start background GC loop.""" - self._start_gc() + """Prepare provider. GC loop is deferred to first insert (lazy warmup).""" + # GC is now lazily started on first insert/ingest to avoid background + # tasks when the provider sees no traffic. + pass async def shutdown(self) -> None: """Stop GC and clear state.""" @@ -141,14 +144,21 @@ async def shutdown(self) -> None: def accepts(self, entry: MemoryEntry) -> bool: return entry.kind in self._ACCEPTED_KINDS - async def insert(self, entry: MemoryEntry) -> str: - """Ingest a new episodic entry.""" + async def insert(self, entry: MemoryEntry, *, session_id: str = "") -> str: + """Ingest a new episodic entry. + + When *session_id* is provided, the entry is tagged so that + session-scoped searches can isolate it later. + """ + self._ensure_gc_started() + if session_id: + entry.metadata = {**(entry.metadata or {}), "_session_id": session_id} self._entries[entry.entry_id] = entry self._access_counts.setdefault(entry.entry_id, 1) self._evict_overflow() logger.debug( - "episodic.insert id=%s kind=%s domain=%s", - entry.entry_id, entry.kind.value, entry.domain.value, + "episodic.insert id=%s kind=%s domain=%s session=%s", + entry.entry_id, entry.kind.value, entry.domain.value, session_id or "-", ) return entry.entry_id @@ -156,6 +166,7 @@ async def search(self, query: MemoryQuery) -> List[MemoryEntry]: """Search with keyword + domain + time_range filters, scored by decay.""" now = time.time() keywords_lower = [k.lower() for k in query.keywords] if query.keywords else [] + session_scope = query.session_scope results: List[MemoryEntry] = [] for entry in self._entries.values(): @@ -163,6 +174,12 @@ async def search(self, query: MemoryQuery) -> List[MemoryEntry]: if now - entry.timestamp > self._ttl: continue + # Session scope gate: when active, only return entries from this session + if session_scope: + entry_session = (entry.metadata or {}).get("_session_id", "") + if entry_session != session_scope: + continue + # Kind filter if query.kinds and entry.kind not in query.kinds: continue @@ -287,6 +304,7 @@ def ingest( metadata: Optional[Dict[str, Any]] = None, ) -> MemoryFragment: """Insert a new fragment from an incoming system event (legacy API).""" + self._ensure_gc_started() self._counter += 1 entry_id = f"imm_{self._counter}_{int(time.time() * 1000)}" now = time.time() @@ -344,7 +362,7 @@ def search_fragments( def start_gc(self) -> None: """Start GC loop (legacy compatibility).""" - self._start_gc() + self._ensure_gc_started() def stop_gc(self) -> None: """Stop GC loop (legacy compatibility).""" @@ -368,6 +386,24 @@ def _evict_overflow(self) -> None: del self._entries[oldest_id] self._access_counts.pop(oldest_id, None) + def _ensure_gc_started(self) -> None: + """Lazily start the GC loop on first write operation. + + Safe to call from sync contexts: if no event loop is running, the GC + task creation is silently skipped (GC sweep still runs on turn + boundaries via on_turn_start). + """ + if self._gc_started and self._gc_task is not None and not self._gc_task.done(): + return + self._gc_started = True + try: + asyncio.get_running_loop() + except RuntimeError: + # No running event loop (sync caller). GC still runs on turn + # boundaries via on_turn_start -> _gc_sweep. + return + self._start_gc() + def _start_gc(self) -> None: if self._gc_task is None or self._gc_task.done(): self._gc_task = asyncio.create_task(self._gc_loop()) diff --git a/src/leapflow/memory/providers/narrative.py b/src/leapflow/memory/providers/narrative.py index 1ce13ce..0f73df8 100644 --- a/src/leapflow/memory/providers/narrative.py +++ b/src/leapflow/memory/providers/narrative.py @@ -146,6 +146,13 @@ def _workspace_dir(self) -> Optional[Path]: return self._memory_dir / "workspaces" / self._workspace_hash async def initialize(self, **kwargs: Any) -> None: + """Prepare provider. Directory creation is deferred to first write.""" + # Phase 3: directories are now lazily created on first insert() call + # to avoid filesystem I/O during startup when narrative is unused. + pass + + def _ensure_dirs(self) -> None: + """Create storage directories on demand (idempotent).""" self._global_dir.mkdir(parents=True, exist_ok=True) index = self._global_dir / "MEMORY.md" if not index.exists(): @@ -167,6 +174,7 @@ def accepts(self, entry: MemoryEntry) -> bool: async def insert(self, entry: MemoryEntry) -> str: """Append entry as a bullet point to the appropriate MEMORY.md section.""" + self._ensure_dirs() section_name = _KIND_SECTION.get(entry.kind, "Notes") scope = entry.metadata.get("scope", "global") target_dir = ( diff --git a/src/leapflow/memory/providers/semantic.py b/src/leapflow/memory/providers/semantic.py index 42841eb..3535796 100644 --- a/src/leapflow/memory/providers/semantic.py +++ b/src/leapflow/memory/providers/semantic.py @@ -128,18 +128,24 @@ def accepts(self, entry: MemoryEntry) -> bool: # Semantic tier accepts everything — it's the final store return True - async def insert(self, entry: MemoryEntry) -> str: - """Persist an entry to DuckDB. Returns entry_id.""" + async def insert(self, entry: MemoryEntry, *, session_id: str = "") -> str: + """Persist an entry to DuckDB. Returns entry_id. + + When *session_id* is provided, the entry is stored with that session + tag so session-scoped queries can isolate it. + """ from leapflow.storage.write_buffer import execute_with_retry con = self._connection() now = time.time() + if session_id: + entry.metadata = {**(entry.metadata or {}), "_session_id": session_id} meta_json = json.dumps(entry.metadata, ensure_ascii=False) path = entry.metadata.get("path") execute_with_retry( con, """ - INSERT INTO leap_memory (id, kind, domain, content, path, metadata, created_at, accessed_at, access_count) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO leap_memory (id, kind, domain, content, path, metadata, created_at, accessed_at, access_count, session_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, [ entry.entry_id, @@ -151,6 +157,7 @@ async def insert(self, entry: MemoryEntry) -> str: entry.timestamp, now, 1, + session_id, ], ) return entry.entry_id @@ -164,6 +171,11 @@ async def search(self, query: MemoryQuery) -> List[MemoryEntry]: conditions: List[str] = [] params: List[Any] = [] + # Session scope gate: restrict to entries from the given session + if query.session_scope: + conditions.append("session_id = ?") + params.append(query.session_scope) + # Keyword filter (AND semantics) keywords = [k.strip() for k in query.keywords if k.strip()] if query.keywords else [] if keywords: @@ -606,6 +618,7 @@ def get_tool_schemas(self) -> list: "keywords": {"type": "string", "description": "Search keywords"}, "domain": {"type": "string", "enum": [d.value for d in SignalDomain]}, "limit": {"type": "integer", "default": 10}, + "session_id": {"type": "string", "description": "Optional session ID to scope search"}, }, "required": ["keywords"], }, @@ -620,8 +633,14 @@ def handle_tool_call(self, tool_name: str, args: Dict[str, Any]) -> str: keywords = args.get("keywords", "").split() domain_str = args.get("domain") limit = int(args.get("limit", 10)) + session_id = args.get("session_id") or "" domains = [SignalDomain(domain_str)] if domain_str else None - mq = MemoryQuery(keywords=keywords, domains=domains, limit=limit) + mq = MemoryQuery( + keywords=keywords, + domains=domains, + limit=limit, + session_scope=session_id if session_id else None, + ) try: loop = asyncio.get_running_loop() import concurrent.futures @@ -656,7 +675,7 @@ def _ensure_connection(self) -> duckdb.DuckDBPyConnection: return self._con def _init_schema(self) -> None: - """Create or migrate the schema with domain + path columns.""" + """Create or migrate the schema with domain + path + session_id columns.""" con = self._connection() con.execute( """ @@ -669,17 +688,27 @@ def _init_schema(self) -> None: metadata TEXT, created_at DOUBLE NOT NULL, accessed_at DOUBLE NOT NULL, - access_count INTEGER NOT NULL DEFAULT 1 + access_count INTEGER NOT NULL DEFAULT 1, + session_id TEXT NOT NULL DEFAULT '' ); """ ) con.execute("CREATE INDEX IF NOT EXISTS idx_lm_created ON leap_memory(created_at);") con.execute("CREATE INDEX IF NOT EXISTS idx_lm_kind ON leap_memory(kind);") - con.execute("CREATE INDEX IF NOT EXISTS idx_lm_domain ON leap_memory(domain);") # Migrations: add columns if table existed without them - for col in ["domain", "path"]: + for col, default in [("domain", "'system'"), ("path", None), ("session_id", "''")]: try: - con.execute(f"ALTER TABLE leap_memory ADD COLUMN {col} TEXT DEFAULT 'system'" if col == "domain" else f"ALTER TABLE leap_memory ADD COLUMN {col} TEXT") + ddl = f"ALTER TABLE leap_memory ADD COLUMN {col} TEXT" + if default is not None: + ddl += f" DEFAULT {default}" + con.execute(ddl) except duckdb.CatalogException: pass # Column already exists + + # Indexes on migrated columns MUST be created after the ALTER TABLE + # loop above: on legacy databases the columns do not exist yet, and + # creating the index first fails with a Binder Error, aborting the + # whole provider initialization. + con.execute("CREATE INDEX IF NOT EXISTS idx_lm_domain ON leap_memory(domain);") + con.execute("CREATE INDEX IF NOT EXISTS idx_lm_session ON leap_memory(session_id);") diff --git a/src/leapflow/prompts/templates.py b/src/leapflow/prompts/templates.py index bc2681b..2dc48f8 100644 --- a/src/leapflow/prompts/templates.py +++ b/src/leapflow/prompts/templates.py @@ -115,6 +115,14 @@ def build_react_system(language: str = "en", skill_catalog: str = "") -> str: 6. **Summarize results naturally**: After tool execution, synthesize the results into a helpful answer rather than dumping raw output. 7. **Stay conversational**: Maintain a natural, helpful tone. Acknowledge context from earlier in the conversation. +## Coding & Verification +When working with code, prefer the precise built-in tools over ad-hoc shell: use `repo_map` to orient in an +unfamiliar project, `code_search`/`file_find` to locate and `code_intel` for symbols, `edit_file` (anchored +search-replace, or a unified `diff`) to change files — never rewrite a whole file to change a few lines — and +`git_query` to inspect diffs/log. After edits, check `syntax_ok` in the result and run `test_run`/`lint_check` +before declaring the work done. Batch independent read-only calls (search/read/query) into a single turn — they +run in parallel. + ## Presentation Style 1. **Polished Markdown only**: Format user-facing answers with clean Markdown headings, short paragraphs, and concise bullets. Use tables only when they improve comparison or scanning. 2. **Terminal-friendly layout**: Keep lines readable in a TUI; avoid dense walls of text, deeply nested lists, oversized ASCII art, or heavy visual blocks. diff --git a/src/leapflow/scheduler/reentry_driver.py b/src/leapflow/scheduler/reentry_driver.py new file mode 100644 index 0000000..5c28b34 --- /dev/null +++ b/src/leapflow/scheduler/reentry_driver.py @@ -0,0 +1,140 @@ +"""Dispatches due re-entry triggers by seeding an Orient-seeded run (S2, phase N3). + +Pure orchestration: reads due TIME triggers from a ``ReentryStore``, atomically +claims each (CAS ``fire`` -> at-most-once), and invokes an injected async runner +with the trigger's ``OrientSnapshot`` (typically ``engine.resume_from_orient``). +It does NOT touch the engine core loop -- the runner is an abstraction, and the +caller (daemon) is responsible for serializing dispatch via ``_engine_lock`` so +no concurrent engine runs occur. + +Guardrails: bounded per-tick fan-out; a claim happens *before* running (so a +failed re-entry is not silently retried into a storm); recurring triggers that +remain armed after a claim have their next due time advanced to prevent +same-tick re-fire. Enablement is injected (default off via config). +""" +from __future__ import annotations + +import logging +import time +from typing import Any, Awaitable, Callable, Optional, Union + +from leapflow.storage.reentry_store import OrientSnapshot, ReentryState, ReentryStore + +logger = logging.getLogger(__name__) + +ReentryRunner = Callable[[OrientSnapshot, Any], Awaitable[Any]] + +_MAX_CTX_ITEMS = 12 + + +def build_reentry_subagent_config(orient: OrientSnapshot) -> Any: + """Turn an OrientSnapshot into an isolated subagent config (S2 N3b). + + Re-entry runs as an *isolated* subagent (fresh context, own budget) seeded + with the orientation as text -- so an autonomous re-entry never pollutes the + interactive engine's working memory or session. The original goal becomes + the subagent goal; findings / open questions / next step / continuation + summary become its context. + """ + from leapflow.engine.subagent import SubagentConfig + + contract = orient.task_contract or {} + goal = str(contract.get("original_request", "") or "").strip() or "Continue the task." + ledger = orient.ledger_state or {} + lines: list[str] = [] + if orient.continuation_summary: + lines.append(f"Continue: {orient.continuation_summary}") + findings = list(ledger.get("findings") or [])[:_MAX_CTX_ITEMS] + if findings: + lines.append("Findings so far:") + lines.extend(f"- {item}" for item in findings) + open_questions = list(ledger.get("open_questions") or [])[:_MAX_CTX_ITEMS] + if open_questions: + lines.append("Open questions to resolve:") + lines.extend(f"- {item}" for item in open_questions) + next_step = str(ledger.get("next_step") or "").strip() + if next_step: + lines.append(f"Next step: {next_step}") + return SubagentConfig( + goal=goal, + context="\n".join(lines), + metadata={"reentry": True, "task_id": orient.task_id}, + ) + + +def event_matches(event_match: dict, *, platform: str = "", chat: str = "", text: str = "") -> bool: + """Whether an inbound gateway message matches an EVENT trigger's filter. + + An empty filter matches *nothing* (safety: never fire on all traffic). Each + present field must match: platform (exact), chat (exact, ``chat`` or + ``chat_id``), keyword (case-insensitive substring of the message text). + """ + if not event_match: + return False + want_platform = event_match.get("platform") + if want_platform and str(want_platform) != str(platform): + return False + want_chat = event_match.get("chat") or event_match.get("chat_id") + if want_chat and str(want_chat) != str(chat): + return False + keyword = event_match.get("keyword") + if keyword and str(keyword).lower() not in str(text).lower(): + return False + return True + + +class ReentryDriver: + """Periodic dispatcher of due re-entry triggers (ticked by the caller).""" + + def __init__( + self, + *, + store: ReentryStore, + runner: ReentryRunner, + enabled: Union[Callable[[], bool], bool] = True, + max_per_tick: int = 4, + recurring_interval_seconds: float = 3600.0, + ) -> None: + self._store = store + self._runner = runner + self._enabled = enabled + self._max_per_tick = max(1, int(max_per_tick)) + self._recurring_interval = max(1.0, float(recurring_interval_seconds)) + + def _is_enabled(self) -> bool: + try: + return self._enabled() if callable(self._enabled) else bool(self._enabled) + except Exception: + return False + + async def tick(self, now: Optional[float] = None) -> int: + """Dispatch due TIME triggers. Returns the number successfully dispatched. + + For each due trigger (bounded by ``max_per_tick``): CAS-claim via + ``fire()``; if claimed, run the injected runner with its OrientSnapshot. + A recurring trigger still armed after the claim has its ``due_at`` + advanced so it does not re-fire within the same tick window. + """ + if not self._is_enabled(): + return 0 + now = time.time() if now is None else now + due = self._store.list_due(now) + dispatched = 0 + for trig in due[: self._max_per_tick]: + claimed = self._store.fire(trig.trigger_id, now=now) + if claimed is None: + continue # lost the race / not consumable + # Recurring: still armed after the claim -> push next due to avoid a storm. + if claimed.state == ReentryState.ARMED.value: + self._store.advance_due(claimed.trigger_id, now + self._recurring_interval) + if claimed.orient is None: + logger.warning("reentry trigger %s has no orient snapshot; skipping run", + claimed.trigger_id) + continue + try: + await self._runner(claimed.orient, claimed) + dispatched += 1 + except Exception: + # Already claimed (at-most-once): a failed re-entry is logged, not retried. + logger.error("reentry dispatch failed for %s", claimed.trigger_id, exc_info=True) + return dispatched diff --git a/src/leapflow/scheduler/reentry_send.py b/src/leapflow/scheduler/reentry_send.py new file mode 100644 index 0000000..9b0eeef --- /dev/null +++ b/src/leapflow/scheduler/reentry_send.py @@ -0,0 +1,165 @@ +"""S2 outbound SO1+SO4: outbound contracts, target resolution, and the pure +governance decision for autonomous re-entry sends. + +This is the *decision kernel* for governed proactive delivery: it decides, for a +resolved target, whether an autonomous send may go out automatically (Progressive +Trust), must be queued for human approval, or is denied — under hard rate / +idempotency / global-budget guards. It performs no I/O and sends nothing; the +integration layer (SO3) consumes ``SendDecision`` to actually send or enqueue. + +Default-off: the governor returns ``BLOCKED("disabled")`` unless explicitly +enabled, so behavior is unchanged until the feature is turned on. +""" +from __future__ import annotations + +import hashlib +from dataclasses import dataclass +from enum import Enum +from typing import Any, Dict, List, Optional + +from leapflow.security.send_trust import SendTrustLedger + + +@dataclass(frozen=True) +class SendTarget: + """Resolved outbound destination for a re-entry result.""" + + platform: str + chat: str + + def scope_key(self) -> str: + """Rate-limit / identity key (platform + chat).""" + return f"{self.platform}:{self.chat}" + + def grant_key(self, action: str = "reply") -> str: + """Trust grant key (platform + chat + action).""" + return f"{self.platform}:{self.chat}:{action}" + + +@dataclass(frozen=True) +class ReentrySendSpec: + """A proposed autonomous outbound send from a completed re-entry.""" + + target: Optional[SendTarget] + text: str + origin_trigger_id: str + kind: str = "reply" + + def idempotency_key(self) -> str: + """Stable key so a re-fired trigger cannot double-send the same content.""" + digest = hashlib.sha256(f"{self.origin_trigger_id}:{self.text}".encode("utf-8")) + return digest.hexdigest()[:16] + + +def resolve_reentry_send_target(trigger: Any) -> Optional[SendTarget]: + """Resolve the outbound target from a re-entry trigger (pure). + + First phase: only event-triggered re-entries carry an explicit + ``event_match`` with platform + chat, which is the originating chat to reply + to. Time-triggered re-entries have no resolvable target here and return + ``None`` (no send), keeping the default conservative. + """ + event_match = getattr(trigger, "event_match", None) or {} + if not isinstance(event_match, dict): + return None + platform = str(event_match.get("platform") or "").strip() + chat = str(event_match.get("chat") or "").strip() + if platform and chat: + return SendTarget(platform=platform, chat=chat) + return None + + +class SendRateLimiter: + """Per-scope sliding-window rate limiter (``per_hour`` sends max).""" + + def __init__(self, *, per_hour: int) -> None: + self._per_hour = int(per_hour) + self._events: Dict[str, List[float]] = {} + + def allow(self, scope_key: str, *, now: float) -> bool: + if self._per_hour <= 0: + return True # unlimited + window = [t for t in self._events.get(scope_key, []) if now - t < 3600.0] + if len(window) >= self._per_hour: + self._events[scope_key] = window + return False + window.append(now) + self._events[scope_key] = window + return True + + +class SendAction(Enum): + """Governance verdict for a proposed autonomous send.""" + + AUTO_ALLOW = "auto_allow" # trust sufficient -> send now + NEEDS_APPROVAL = "needs_approval" # queue for asynchronous human approval + DENY = "deny" # no approver and no trust -> do not send + BLOCKED = "blocked" # guard tripped (disabled/target/rate/budget/dup) + + +@dataclass(frozen=True) +class SendDecision: + action: SendAction + reason: str + + +class SendGovernor: + """Pure decision flow for governed autonomous re-entry sends (SO1+SO4). + + Combines the feature gate, target resolution, idempotency, global budget, + rate limit, and the Progressive Trust ledger into a single verdict. Holds no + transport; ``record_sent`` is called by the integration layer after a send + actually commits so duplicates and the global budget are tracked exactly. + """ + + def __init__( + self, + *, + trust: SendTrustLedger, + rate: SendRateLimiter, + enabled: bool, + global_budget: int = 50, + ) -> None: + self._trust = trust + self._rate = rate + self._enabled = bool(enabled) + self._global_budget = int(global_budget) + self._sent_total = 0 + self._seen_keys: set[str] = set() + + def decide( + self, + spec: ReentrySendSpec, + *, + destructive: bool, + has_approver: bool, + now: float, + ) -> SendDecision: + if not self._enabled: + return SendDecision(SendAction.BLOCKED, "disabled") + if spec.target is None: + return SendDecision(SendAction.BLOCKED, "no_target") + if spec.idempotency_key() in self._seen_keys: + return SendDecision(SendAction.BLOCKED, "duplicate") + if self._global_budget > 0 and self._sent_total >= self._global_budget: + return SendDecision(SendAction.BLOCKED, "global_budget_exhausted") + if not self._rate.allow(spec.target.scope_key(), now=now): + return SendDecision(SendAction.BLOCKED, "rate_limited") + if self._trust.auto_approve_ok(spec.target.grant_key(spec.kind), destructive=destructive): + return SendDecision(SendAction.AUTO_ALLOW, "trust_verified") + if has_approver: + return SendDecision(SendAction.NEEDS_APPROVAL, "queue_for_human") + return SendDecision(SendAction.DENY, "no_approver_no_trust") + + def record_sent(self, spec: ReentrySendSpec) -> None: + """Account for a committed send (idempotency + global budget).""" + self._sent_total += 1 + self._seen_keys.add(spec.idempotency_key()) + + def record_human_allow(self, grant_key: str) -> None: + """A human approved a send in this scope: accrue Progressive Trust.""" + self._trust.record_allow(grant_key) + + def record_human_deny(self, grant_key: str) -> None: + """A human denied a send in this scope: freeze trust back to DRAFT.""" + self._trust.record_deny(grant_key) diff --git a/src/leapflow/scheduler/reentry_service.py b/src/leapflow/scheduler/reentry_service.py new file mode 100644 index 0000000..ac8529c --- /dev/null +++ b/src/leapflow/scheduler/reentry_service.py @@ -0,0 +1,226 @@ +"""Re-entry orchestration service (S2 phases N3b–N5). + +Consolidates the two trigger sources (time ticks and gateway events) behind one +dispatch path: build an isolated subagent config from the OrientSnapshot, run it +serialized (optional engine lock), and record the outcome. Safety closure (N5): + +- **Audit**: every dispatch emits ``reentry.dispatched`` / ``reentry.completed`` + notifications and a structured log line. +- **Global budget**: a lifetime cap across all triggers backstops runaway loops + (per-trigger ``max_reentries`` / ``deadline`` still apply in the store). +- **Governed proactive Act (SO3, default-off)**: the re-entry subagent still + blocks ``send_message``; any outbound delivery of the *result* is decided at + this service level by ``SendGovernor`` (send-scope Progressive Trust) and, + below VERIFIED trust, an asynchronous ApprovalGate (deny on timeout / no + approver). Enabled only via ``agent.reentry_send_enabled``. +""" +from __future__ import annotations + +import logging +import time +from typing import Any, Callable, Optional + +from leapflow.scheduler.reentry_driver import ( + ReentryDriver, + build_reentry_subagent_config, + event_matches, +) +from leapflow.scheduler.reentry_send import ( + ReentrySendSpec, + SendAction, + resolve_reentry_send_target, +) +from leapflow.storage.reentry_store import OrientSnapshot, ReentryStore + +logger = logging.getLogger(__name__) + +NotifyFn = Callable[..., Any] + +# TTL for a queued autonomous-send approval; the daemon denies on timeout. +_SEND_APPROVAL_TTL = 300.0 +_ALLOW_DECISIONS = frozenset({"allow", "allow_once", "allow_session", "allow_always"}) + + +class ReentryService: + """Owns re-entry dispatch for both time and event triggers.""" + + def __init__( + self, + *, + store: ReentryStore, + manager: Any, + settings: Any, + engine_lock: Any = None, + notify: Optional[NotifyFn] = None, + global_budget: int = 100, + max_per_tick: int = 4, + send_governor: Any = None, + send_fn: Optional[Callable[..., Any]] = None, + request_approval: Optional[Callable[..., Any]] = None, + ) -> None: + self._store = store + self._manager = manager + self._settings = settings + self._engine_lock = engine_lock + self._notify = notify + self._global_budget = max(0, int(global_budget)) + self._dispatched_total = 0 + # SO3: governed proactive delivery (all optional; None => never sends). + self._send_governor = send_governor + self._send_fn = send_fn + self._request_approval = request_approval + self._driver = ReentryDriver( + store=store, + runner=self._dispatch, + enabled=self._enabled, + max_per_tick=max_per_tick, + ) + + def _enabled(self) -> bool: + return bool(getattr(self._settings, "agent_reentry_enabled", False)) + + def _budget_ok(self) -> bool: + return self._global_budget <= 0 or self._dispatched_total < self._global_budget + + def _emit(self, event_type: str, **payload: Any) -> None: + if self._notify is None: + return + try: + self._notify(event_type, **payload) + except Exception: + logger.debug("reentry notify failed", exc_info=True) + + async def _dispatch(self, orient: OrientSnapshot, trigger: Any = None) -> None: + """Run one re-entry as an isolated subagent (shared by both sources).""" + if not self._budget_ok(): + logger.warning( + "reentry global budget exhausted (%d); skipping task=%s", + self._global_budget, orient.task_id, + ) + return + config = build_reentry_subagent_config(orient) + self._emit("reentry.dispatched", task_id=orient.task_id) + if self._engine_lock is not None: + async with self._engine_lock: + result = await self._manager.delegate(config) + else: + result = await self._manager.delegate(config) + self._dispatched_total += 1 + status = getattr(result, "status", "") + summary = str(getattr(result, "summary", "") or "") + logger.info( + "reentry.completed task=%s status=%s total=%d", + orient.task_id, status, self._dispatched_total, + ) + self._emit( + "reentry.completed", + task_id=orient.task_id, + status=status, + summary=summary[:2000], + ) + # SO3: governed proactive delivery of the result (outside the engine lock). + if trigger is not None: + await self._maybe_send(trigger, orient, summary) + + async def _maybe_send(self, trigger: Any, orient: OrientSnapshot, summary: str) -> None: + """SO3: decide + perform governed outbound delivery of a re-entry result. + + Default-off and fail-safe: does nothing unless a ``SendGovernor`` and a + send function are wired and the governor is enabled. Never raises into + the dispatch path. Below VERIFIED trust, delivery requires an approval + (which also accrues trust); autonomous context with no approver denies. + """ + gov = self._send_governor + if gov is None or self._send_fn is None or not summary.strip(): + return + try: + spec = ReentrySendSpec( + target=resolve_reentry_send_target(trigger), + text=summary, + origin_trigger_id=str(getattr(trigger, "trigger_id", "") or orient.task_id), + ) + decision = gov.decide( + spec, + destructive=False, # first phase: reply to the originating chat only + has_approver=self._request_approval is not None, + now=time.time(), + ) + if decision.action is SendAction.AUTO_ALLOW: + sent = await self._do_send(spec) + self._emit("reentry.send", task_id=orient.task_id, + result="auto_allow" if sent else "send_failed") + elif decision.action is SendAction.NEEDS_APPROVAL: + await self._approve_and_send(spec, orient) + else: + self._emit("reentry.send", task_id=orient.task_id, result=decision.reason) + except Exception: + logger.error("reentry send failed for task=%s", orient.task_id, exc_info=True) + + async def _do_send(self, spec: ReentrySendSpec) -> bool: + """Perform the actual gateway send; record it for idempotency/budget.""" + if spec.target is None or self._send_fn is None: + return False + try: + result = await self._send_fn(spec.target.platform, spec.target.chat, spec.text) + except Exception: + logger.error("gateway send failed", exc_info=True) + return False + ok = bool(result.get("ok")) if isinstance(result, dict) else bool(result) + if ok: + self._send_governor.record_sent(spec) + return ok + + async def _approve_and_send(self, spec: ReentrySendSpec, orient: OrientSnapshot) -> None: + """Queue an asynchronous human approval; send + accrue trust on ALLOW.""" + from leapflow.security.approval import ApprovalRequest + + grant = spec.target.grant_key(spec.kind) + request = ApprovalRequest( + category="reentry_send", + detail=f"Autonomous reply to {spec.target.platform}:{spec.target.chat} — {spec.text[:200]}", + risk_hint=0.7, + expires_at=time.time() + _SEND_APPROVAL_TTL, + metadata={"platform": spec.target.platform, "chat": spec.target.chat, "task_id": orient.task_id}, + ) + try: + decision = await self._request_approval(request) + except Exception: + decision = "deny" + value = str(getattr(decision, "value", decision)).lower() + if value in _ALLOW_DECISIONS: + self._send_governor.record_human_allow(grant) + sent = await self._do_send(spec) + self._emit("reentry.send", task_id=orient.task_id, + result="approved" if sent else "approved_send_failed") + else: + self._send_governor.record_human_deny(grant) + self._emit("reentry.send", task_id=orient.task_id, result="denied") + + async def tick(self, now: Optional[float] = None) -> int: + """Dispatch due TIME triggers (called periodically by the daemon).""" + return await self._driver.tick(now) + + async def on_gateway_message( + self, *, platform: str = "", chat: str = "", text: str = "", + ) -> int: + """Match an inbound gateway message against armed EVENT triggers (N4). + + For each match: CAS-claim (``fire``) and dispatch. Single-shot triggers + become exhausted; recurring ones stay armed to match future events + (bounded by ``max_reentries``). + """ + if not self._enabled(): + return 0 + dispatched = 0 + for trig in self._store.list_armed_events(): + if not event_matches(trig.event_match, platform=platform, chat=chat, text=text): + continue + claimed = self._store.fire(trig.trigger_id) + if claimed is None or claimed.orient is None: + continue + try: + await self._dispatch(claimed.orient, claimed) + dispatched += 1 + except Exception: + logger.error("reentry event dispatch failed for %s", claimed.trigger_id, exc_info=True) + return dispatched diff --git a/src/leapflow/security/send_trust.py b/src/leapflow/security/send_trust.py new file mode 100644 index 0000000..076e7b1 --- /dev/null +++ b/src/leapflow/security/send_trust.py @@ -0,0 +1,90 @@ +"""S2 outbound SO2: send-scope Progressive Trust ledger. + +Autonomous re-entry has no synchronous human approver, so an outbound send is +only auto-approved once a specific ``(platform, chat, action)`` scope has earned +trust through *repeated human approvals* (mirroring the skill trust gradient +DRAFT -> CANDIDATE -> VERIFIED -> PRODUCTION). A single human DENY freezes the +scope back to DRAFT (conservative). Destructive targets (cross-chat, broadcast, +first-time) are never auto-approved regardless of trust. + +Pure and hermetic; ``to_state``/``load_state`` allow later durable persistence +without changing the decision logic. +""" +from __future__ import annotations + +from enum import IntEnum +from typing import Any, Dict + + +class SendTrustLevel(IntEnum): + """Trust gradient for an outbound send scope (higher = more autonomy).""" + + DRAFT = 0 + CANDIDATE = 1 + VERIFIED = 2 + PRODUCTION = 3 + + +_PRODUCTION_AT = 8 + + +class SendTrustLedger: + """Per-scope trust earned by human approvals of outbound sends. + + Trust rises only via ``record_allow`` (a human approved a send in that + scope) and is frozen to DRAFT by ``record_deny``. ``auto_approve_ok`` gates + autonomous sends: only VERIFIED+ and non-destructive targets may bypass + human approval. + """ + + def __init__(self, *, verified_at: int = 3) -> None: + self._verified_at = max(1, int(verified_at)) + self._allows: Dict[str, int] = {} + self._frozen: set[str] = set() + + def level(self, grant_key: str) -> SendTrustLevel: + if grant_key in self._frozen: + return SendTrustLevel.DRAFT + count = self._allows.get(grant_key, 0) + if count >= _PRODUCTION_AT: + return SendTrustLevel.PRODUCTION + if count >= self._verified_at: + return SendTrustLevel.VERIFIED + if count >= 1: + return SendTrustLevel.CANDIDATE + return SendTrustLevel.DRAFT + + def record_allow(self, grant_key: str) -> None: + """A human approved a send in this scope: unfreeze and accrue trust.""" + self._frozen.discard(grant_key) + self._allows[grant_key] = self._allows.get(grant_key, 0) + 1 + + def record_deny(self, grant_key: str) -> None: + """A human denied a send in this scope: freeze it back to DRAFT.""" + self._frozen.add(grant_key) + + def auto_approve_ok(self, grant_key: str, *, destructive: bool) -> bool: + """Whether an autonomous send may bypass human approval. + + Never for destructive targets (cross-chat / broadcast / first-time); + otherwise only when the scope has reached VERIFIED trust. + """ + if destructive: + return False + return self.level(grant_key) >= SendTrustLevel.VERIFIED + + # ── Durable state (for later persistence; logic-neutral) ── + + def to_state(self) -> Dict[str, Any]: + return { + "verified_at": self._verified_at, + "allows": dict(self._allows), + "frozen": sorted(self._frozen), + } + + def load_state(self, state: Dict[str, Any]) -> None: + if not state: + return + self._verified_at = max(1, int(state.get("verified_at", self._verified_at))) + self._allows = {str(k): int(v) for k, v in (state.get("allows") or {}).items()} + self._frozen = {str(k) for k in (state.get("frozen") or [])} diff --git a/src/leapflow/storage/connection.py b/src/leapflow/storage/connection.py index 139898e..099e76a 100644 --- a/src/leapflow/storage/connection.py +++ b/src/leapflow/storage/connection.py @@ -14,6 +14,7 @@ import logging import tempfile +import threading from pathlib import Path from typing import Optional, Protocol, runtime_checkable @@ -49,10 +50,13 @@ def close(self) -> None: class LocalConnectionHolder: """In-process holder that lazily opens a single DuckDB connection. - Thread-safety: DuckDB's embedded connection is single-writer. - Within one process, all stores share this holder and access is - serialized by DuckDB's internal lock. For multi-process, use the - leapd daemon (P4). + Thread-safety: ``DuckDBPyConnection`` is NOT thread-safe. The root + connection is owned by the thread that first opens it (typically the + event loop thread). Any other thread that asks for ``connection`` gets + a thread-local ``cursor()`` — a full duplicate connection sharing the + same database, which is DuckDB's documented multi-threading pattern. + Writes across root connection and cursors are safe (DuckDB applies + optimistic concurrency control internally). """ def __init__(self, db_path: Path, *, volatile_on_lock: bool = False) -> None: @@ -61,6 +65,9 @@ def __init__(self, db_path: Path, *, volatile_on_lock: bool = False) -> None: self._volatile_on_lock = volatile_on_lock self._volatile_dir: tempfile.TemporaryDirectory[str] | None = None self._locked_error: DatabaseLockedError | None = None + self._owner_thread_id: Optional[int] = None + self._thread_local = threading.local() + self._open_lock = threading.Lock() @property def db_path(self) -> Path: @@ -76,10 +83,27 @@ def locked_error(self) -> DatabaseLockedError | None: @property def connection(self) -> duckdb.DuckDBPyConnection: + # Root connection is created and owned by the first thread that + # opens it (normally the event loop thread). Other threads receive + # a thread-local cursor, DuckDB's documented multi-threaded pattern. if self._conn is None: - self._conn = self._connect() - logger.info("duckdb: opened %s", self._db_path.name) - return self._conn + with self._open_lock: + if self._conn is None: + self._conn = self._connect() + self._owner_thread_id = threading.get_ident() + logger.info("duckdb: opened %s", self._db_path.name) + return self._conn + if threading.get_ident() == self._owner_thread_id: + return self._conn + cursor = getattr(self._thread_local, "cursor", None) + if cursor is None: + cursor = self._conn.cursor() + self._thread_local.cursor = cursor + logger.debug( + "duckdb: created thread-local cursor for %s (thread=%d)", + self._db_path.name, threading.get_ident(), + ) + return cursor def _connect(self) -> duckdb.DuckDBPyConnection: try: @@ -97,6 +121,9 @@ def _connect(self) -> duckdb.DuckDBPyConnection: return _lock_aware_connect(self._db_path) def close(self) -> None: + # Only the root connection is closed here: thread-local cursors + # become invalid together with it. Callers must shut down worker + # threads (e.g. the deferred-DB executor) before invoking close(). if self._conn is not None: try: self._conn.close() @@ -104,6 +131,8 @@ def close(self) -> None: except Exception: pass self._conn = None + self._owner_thread_id = None + self._thread_local = threading.local() if self._volatile_dir is not None: self._volatile_dir.cleanup() self._volatile_dir = None diff --git a/src/leapflow/storage/reentry_store.py b/src/leapflow/storage/reentry_store.py new file mode 100644 index 0000000..6e9b6f5 --- /dev/null +++ b/src/leapflow/storage/reentry_store.py @@ -0,0 +1,316 @@ +"""DuckDB-backed persistence for event-driven re-entry (S2, phase N1). + +Enables "finalize + Orient-seeded re-entry": a task can finalize a turn while +registering a ``ReentryTrigger`` ("wake me when