Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ This document is the LeapFlow engineering collaboration contract. It is not only

- **System Boundary Awareness**: LeapFlow is a multi-entry, multi-module runtime. Changes must account for the affected path across CLI/TUI, leapd, engine, skills/tools, LLM, storage, memory, gateway, hub, and platform adapters.
- **TUI as the Primary User Entry**: The interactive TUI is the default product surface. Preserve streaming feedback, command queue behavior, approval prompts, status bar accuracy, long-input robustness, history, and session continuity.
- **Concurrent TUI Instances Are a Supported Scenario (MANDATORY)**: several TUIs in *different workspaces*, sharing one leapd and one profile, is a normal way to use LeapFlow — not an edge case. Each instance must remain fully usable and must see only its own session, conversation, context usage, and turn state. A change to session routing, `status()`, stream metadata, the client lease, or anything the status bar renders is not verified until it has been exercised with two instances in two workspaces at the same time. One instance degrading another is a release blocker, not a limitation to document.
- **TUI Command Clarity**: Global task-control commands stay short and unambiguous (`/cancel`, `/skip`, `/pause`, `/resume`, `/queue`, `/drop`); teach-mode controls must use the `/teach ...` namespace and should not keep bare compatibility aliases during early iteration.
- **TUI Prompt Ownership**: Input prompt and placeholder rendering must have a single owner. Avoid duplicate prompt sources; placeholder text stays visually subordinate, offset after the prompt, and disappears as soon as the user types.
- **leapd Runtime Consistency**: Daemon-backed behavior must preserve lifecycle correctness: start, stop, restart, status, RPC streaming, cancellation, pending approvals, runtime config reload, multi-client state, and version consistency.
Expand All @@ -46,13 +47,19 @@ This document is the LeapFlow engineering collaboration contract. It is not only
- **Event-Driven Communication**: Modules interact through typed events on EventBus, not direct imports
- **Session Engine is the Only Reporting Source (MANDATORY)**: conversation state lives on the per-session engines built by `SessionRegistry`. `ctx.engine` is only the template they are cloned from and **never accumulates turns, context, or history**. Any code reporting runtime state — stream chunk metadata, `status()`, session analysis, dashboards, status bar values — must resolve the engine through the single entry point (`RuntimeLeapService._active_engine()` / `SessionCoordinator.resolve_session_engine()`), never `getattr(ctx, "engine")`. Reading the template silently yields zeros, which is invisible in review and has surfaced repeatedly as an empty LeapBoard and a status bar frozen at `0/<limit>`. When a value is produced *by* a specific engine (e.g. a stream event), pass that engine explicitly instead of re-resolving, so concurrent sessions cannot be cross-reported.
- **Client-Visible Runtime State Must Be Pushed, Not Inferred**: a daemon-mode TUI is a separate process; it seeds model, context length, and usage at startup and can only learn about later changes from metadata the daemon returns. Any runtime value the status bar renders must travel on ordinary status/stream metadata (and on the mutation payload for command RPCs) — never rely on a one-off change notification, which change-detection can legitimately skip.
- **Session Identity Belongs to the Client That Created It (MANDATORY)**: a `session_id` is the client's own identity, not shared daemon state. The daemon must never report a session a caller did not name, and a client must never adopt a session id it did not ask for. Concretely: every RPC that returns session-scoped state (`status()`, stream chunk metadata, history, analysis) takes the caller's `session_id` and resolves *that* session; when the caller names none, the reply carries **no** session identity and no per-session figures rather than a substitute. A client accepts a reported session id only when it matches its own or it has none yet (first assignment, or an explicit `--resume`). Violating this is not cosmetic: a second TUI adopted the first's session, sent it with its own workspace, and was rejected on every turn — with advice ("start a fresh session") that could not work, because a fresh client re-adopted the same id on its first status poll.
- **Cross-Session Fallbacks Must Be Named For What They Do**: a resolver that answers "whichever session was most recently active" ignores workspace and client identity, so it is valid only for genuinely aggregate views (a dashboard summarizing all activity). Such helpers must say so in their name (e.g. `most_recent_any_client()`), and no code path that describes one caller may use them. A friendly name like "the current session" invites exactly the misuse that leaked one client's identity to another.
- **Workspace Binding Is Part of Session Identity**: a session is bound to the workspace of its first request, and reuse from another workspace is refused. Because correct clients cannot trigger this, a mismatch means either an explicit `--resume` into another workspace (the only legitimate cause, and what the message must name) or a defect — never something to tell the user to work around.
- **Terminal Output Must Wrap at the Console Layer**: `LeapConsole` owns wrapping (`soft_wrap=False`), because prompt_toolkit's renderer clips at the window edge rather than reflowing. Never enable `soft_wrap` on the shared console or hand it pre-formatted long lines; long answers silently lose their tail. A standalone `Console` for fixed-width art (e.g. the banner) may opt out, but must set an explicit `width`.
- **Immutable Domain Types**: Use `@dataclass(frozen=True)` or `NamedTuple` for domain objects
- **Config-Driven Behavior**: Thresholds, intervals, feature flags, model budgets, platform capabilities, hub backends, gateway manifests, and paths must be configurable through Settings/env/config layers.
- **Graceful Degradation**: Every optional component (LLM, Hub) can be absent without crash
- **Single Source of Truth**: DuckDB for persistence, EventBus for communication, Settings for configuration
- **Inbound Signal Classification**: Platform events must be classified before they activate the agent. Message/callback events may enter Decide; signal/lifecycle events should be stored or routed without triggering LLM by default; ignored events must be explicit (e.g. self-message, duplicate, blocked scope).
- **Single Recovery Decision Point**: All agent loop errors (LLM, tool, system, security) enter one `RecoveryCoordinator`. No parallel decision paths, no scattered if/break logic. The pipeline is always: `FailureEnvelope` → `RecoveryDecision` → `StrategyOutcome` feedback.
- **A Local Defect Is Never a Provider Failure (MANDATORY)**: an `except` around a provider call must wrap *only* that call. Post-response bookkeeping — usage recording, calibration, capability learning — belongs outside it, in a helper that contains its own failures, because telemetry must never fail a turn. Exceptions that mean "LeapFlow has a bug" (`AttributeError`, `TypeError`, `NameError`, `KeyError`, `IndexError`, `ImportError`, `AssertionError`, `NotImplementedError`) are classified by *type* into the non-recoverable `internal_defect` category before the provider taxonomy is consulted. The provider classifier matches on message text, so one mistyped attribute whose name contained "context" was read as a context overflow and driven through three compressions, a provider failover, and a credential rotation before halting — every turn, for every user, with the suite green.
- **Every Terminal Decision Must Be Actionable**: a halt is the last thing a stopped turn can say, so it always carries an `InteractionRequest` naming the failure and the next step. The raw `reason` is written for the audit log; surfacing it alone produces internal jargon as the user's entire answer ("No applicable recovery strategy found").
- **Recovery Must Leave Evidence**: every recovery entry point logs the exception with `exc_info`, and the audit sink is constructed with the profile layout's audit path. An in-memory sink and an unlogged branch made a reproducible outage undiagnosable after the fact — the incident had to be reconstructed from token counts in an unrelated log line.
- **Side-Effect Gating**: Recovery is gated by `SideEffectState` at two levels. Within a tool batch, a failed side-effecting call stops the remaining calls in that batch, decided by the declared `execution_policy` rather than any tool-name list. Within `RecoveryCoordinator`, any state other than `NONE` blocks replaying actions (retry, transform-and-retry, failover) and yields a checkpointed halt carrying an `InteractionRequest`; only user-mediated or checkpoint-based resumption is permitted. `UNKNOWN` blocks like `COMMITTED` and `PARTIAL` do: it is the classifier's fallback and the state assigned to `external_side_effect` (outbound sends, external API calls), so exempting it would leave the highest-risk case ungated.
- **Uncertain Effects Are Reported, Not Retried Blindly**: a failed call whose effect may already have landed (`external_side_effect`, `mutating_once`) must carry that verdict in its result so the next turn verifies before repeating it. An error is not proof that nothing happened. Idempotent mutations are exempt — re-applying them converges, so flagging them would only stall safe retries.
- **Budget-Constrained Recovery**: Turn-level deadlines, per-category limits, and a global recovery budget prevent infinite retry loops. Every recovery action has an explicit cost; exhaustion triggers a clean halt or user escalation.
Expand Down Expand Up @@ -119,6 +126,8 @@ This document is the LeapFlow engineering collaboration contract. It is not only
- **Verification sequence**: compile → import → unit test → integration (if applicable)
- **Behavior contracts over snapshots**: assert invariants, not frozen values
- **Mock at boundaries only**: mock external I/O (network, disk), never internal logic
- **A test may not fabricate the wiring it claims to cover**: building an object with `object.__new__` and assigning the private attributes the code reads cannot detect a wrong attribute *name* — the test simply agrees with the typo. Calibration tests did exactly that and stayed green while every real turn raised `AttributeError`. Any test whose stated purpose is wiring must construct the real object and drive the production path.
- **Multi-client behavior needs multi-client tests**: session routing, `status()`, stream metadata, and client-lease changes require two sessions in two workspaces asserting that neither sees the other's identity, usage, or turn state. Single-session tests cannot observe cross-client leakage, which is why a leak shipped with a green suite.
- **Change-scoped validation**: Run the most specific relevant tests first, then broaden only as needed: CLI/TUI changes require CLI/TUI tests; leapd changes require daemon RPC/lifecycle tests; storage or memory changes require persistence tests; gateway, IM, event-source, or approval changes require connector lifecycle, event normalization, routing, idempotency, self-message filtering, security/approval, and failure-recovery tests; skills, learning, perception, and copilot changes require their lifecycle or pipeline tests.
- **Recovery strategy isolation**: Each `RecoveryStrategy` must be testable in isolation — verify `can_apply` predicates, `decide` outputs, and side-effect-state gating independently of the coordinator and other strategies.
- **Budget boundary tests**: Verify that recovery budgets exhaust correctly (per-category, per-turn, deadline), that exhaustion produces a deterministic halt decision, and that cost accounting is exact.
Expand All @@ -141,6 +150,8 @@ This document is the LeapFlow engineering collaboration contract. It is not only
- Magic retry counts or unbounded retry loops without budget constraints and deadline enforcement
- Feeding unstructured error text back to the LLM without classification, recoverability assessment, or side-effect awareness
- Multiple parallel error-handling paths for the same failure domain (LLM errors in one handler, tool errors in another, security errors in a third); use a unified classification and coordination pipeline
- Widening a provider call's `try` block to cover bookkeeping, or classifying a Python-level defect by matching its message text against provider conditions
- Answering "the caller's session" with "the most recently active session", or letting a client adopt a session id the daemon happened to report
- Bare `except:` clauses — always specify the exception type
- `# TODO: implement` stubs — implement or don't commit

Expand Down
22 changes: 17 additions & 5 deletions src/leapflow/cli/commands/interactive.py
Original file line number Diff line number Diff line change
Expand Up @@ -305,7 +305,7 @@ def _status(message: str) -> None:
mock_host=self._mock_host,
status_callback=_status,
)
status = await self.client.status()
status = await self.client.status(str(self._session_id_getter() or ""))
self._metadata_applier(status)
session_id = str(self._session_id_getter() or "")
if session_id:
Expand Down Expand Up @@ -993,8 +993,20 @@ def _apply_daemon_runtime_metadata(metadata: dict[str, Any]) -> None:
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"):
active_session_id = str(metadata["session_id"])
# A session id is this client's own identity, so it is only ever accepted
# from the daemon when we have none yet (first assignment, or right after
# an explicit resume). A daemon serving several TUIs can report whichever
# session it resolved; adopting that made a second TUI send another
# workspace's session id and get rejected on every turn.
reported_session = str(metadata.get("session_id") or "")
if reported_session and reported_session != active_session_id:
if active_session_id:
logger.debug(
"ignoring daemon-reported session %s; this client owns %s",
reported_session, active_session_id,
)
else:
active_session_id = reported_session
if metadata.get("model"):
runtime_model_name = str(metadata["model"])
if metadata.get("llm_model"):
Expand Down Expand Up @@ -1078,7 +1090,7 @@ def _render_banner() -> None:
async def _print_daemon_status() -> None:
try:
daemon_status = await bridge.call(
lambda current_client: current_client.status(),
lambda current_client: current_client.status(active_session_id),
description="daemon status",
)
except Exception as exc:
Expand Down Expand Up @@ -1487,7 +1499,7 @@ def _handle_task_control(text: str) -> bool:

try:
_apply_daemon_runtime_metadata(await bridge.call(
lambda current_client: current_client.status(),
lambda current_client: current_client.status(active_session_id),
description="daemon status",
))
except Exception as exc:
Expand Down
5 changes: 4 additions & 1 deletion src/leapflow/cli/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from leapflow.platform.mock import MockBridge
from leapflow.config import Settings, _build_settings_from_env
from leapflow.config_loader import config_signature, load_config_bundle
from leapflow.engine.context_compressor import adaptive_tool_result_chars
from leapflow.engine.engine import AgentEngine, build_default_registry
from leapflow.engine.graph_planner import GraphPlanner
from leapflow.engine.intent_classifier import (
Expand Down Expand Up @@ -671,7 +672,9 @@ def _sync_engine_runtime_budget(self, settings: Settings) -> None:
return

context_length = self._effective_llm_context_length(settings)
dynamic_result_budget = min(settings.max_tool_result_chars, context_length // 20)
dynamic_result_budget = adaptive_tool_result_chars(
settings.max_tool_result_chars, context_length,
)
if dynamic_result_budget != settings.max_tool_result_chars:
logger.info(
"Dynamic tool result budget: %d (context=%d)",
Expand Down
4 changes: 2 additions & 2 deletions src/leapflow/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -372,7 +372,7 @@ class Settings:
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
tool_evidence_max_chars: int = 4000
repeated_read_limit: int = 2
long_task_convergence_round: int = 12
# Adaptive convergence ceiling: on high-difficulty tasks the effective
Expand Down Expand Up @@ -874,7 +874,7 @@ def _build_settings_from_env(
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"))
tool_evidence_max_chars = int(os.getenv("LEAPFLOW_TOOL_EVIDENCE_MAX_CHARS", "4000"))
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"))
Expand Down
6 changes: 6 additions & 0 deletions src/leapflow/daemon/_service_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,12 @@ def engine_context_metadata(engine: Any | None, settings: Any) -> dict[str, Any]
metadata["llm_model"] = model
if engine is None:
return metadata
# Prefer the engine's effective budget over the configured one: an
# authoritative model capability can cap the configured value, and reporting
# the config would claim a window compression is not actually using.
effective = getattr(engine, "active_context_length", 0)
if isinstance(effective, int) and effective > 0:
metadata["llm_context_length"] = effective
metadata["context_used"] = max(0, int(getattr(engine, "context_token_count", 0) or 0))
snapshot = getattr(engine, "context_budget_snapshot", {})
if callable(snapshot):
Expand Down
13 changes: 10 additions & 3 deletions src/leapflow/daemon/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,9 +143,16 @@ async def session_resume(self, session_id: str) -> dict[str, Any]:
result = await self.request("session.resume", {"session_id": session_id})
return dict(result or {})

async def status(self) -> dict[str, Any]:
"""Return daemon status."""
result = await self.request("daemon.status")
async def status(self, session_id: str = "") -> dict[str, Any]:
"""Return daemon status, scoped to ``session_id`` when the caller has one.

Passing the caller's own session is what makes the reply describe *this*
client: without it the daemon has no way to know which of several live
sessions to report, and any session identity it returned would belong to
somebody else.
"""
params = {"session_id": session_id} if session_id else {}
result = await self.request("daemon.status", params)
return dict(result or {})

async def host_status(self) -> dict[str, Any]:
Expand Down
9 changes: 7 additions & 2 deletions src/leapflow/daemon/protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -235,8 +235,13 @@ async def session_analyze(self) -> Dict[str, Any]:
"""Ensure a session-analysis watch and run one analysis cycle now."""
...

async def status(self) -> Dict[str, Any]:
"""Return daemon status (uptime, connections, db path, etc.)."""
async def status(self, session_id: str = "") -> Dict[str, Any]:
"""Return daemon status (uptime, connections, db path, etc.).

``session_id`` scopes the reply to the caller's session; without it no
session identity or per-session context figures are reported, since any
the daemon picked would belong to a different client.
"""
...

async def host_status(self) -> Dict[str, Any]:
Expand Down
Loading
Loading