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
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ This document is the LeapFlow engineering collaboration contract. It is not only
- For context assembly, prefer manifest-driven progressive disclosure over shortcuts or intent-handler sprawl: expose compact capability indexes, selected schemas, and targeted memory only when the current plan needs them.
- For gateway or IM work, define the signal contract first: event source, normalizer/classifier, trigger policy, session routing, memory/audit path, and outbound action path. Default inbound activation to least privilege (`mention_only` or equivalent), filter self-generated messages before LLM invocation, and keep cross-chat or proactive sends behind Progressive Trust and ApprovalGate.
- Avoid rule-based natural-language fitting by default. Do not add keyword/action-verb/alias enumerations, intent-handler taxonomies, or brittle routing rules when LLM-native capability disclosure, manifests, schemas, protocols, or configuration-driven contracts can solve the problem. If a rule-based method is truly unavoidable for a stable protocol boundary, offline fallback, or safety hard gate, explain the necessity, scope, alternatives, and rollback path to a human and obtain explicit second confirmation before implementation.
- Specifically within `engine/`: reference resolution, error classification, context disclosure, and focus tracking must not use keyword/substring matching to classify user intent or error type. Reference resolution uses `SessionFocusState` entity registry lookups; error classification uses data-table or registry-pattern matching (never if-elif chains on message text); context disclosure reads tool-declared `x_leapflow` metadata first and treats substring inference as a deprecated fallback that logs a warning.
- Preserve security and audit paths: dangerous actions, file writes, outbound messages, credentials, and path access must flow through the existing policy, approval, redaction, and audit mechanisms.
- Preserve gateway safety boundaries: inbound credentials stay in CredentialVault; outbound send/write/execute actions go through ApprovalGate; bot self-messages and duplicate events are filtered before routing; platform-specific metadata must remain in `metadata` escape hatches instead of polluting core message types.
- Keep App Connector governance thin: platform core should consume normalized contracts and failures, while app-specific auth scopes, CLI/SDK error parsing, vendor recovery steps, and command templates remain in action packs, adapters, or backend-specific helpers. If a new platform requires changing gateway core business rules, first refactor toward a protocol hook or app-side classifier.
Expand Down Expand Up @@ -115,6 +116,7 @@ This document is the LeapFlow engineering collaboration contract. It is not only
- **Regression impact check**: Inspect affected modules and user journeys for logic bugs, degraded UX, broken compatibility, slower feedback, weaker diagnostics, or worse failure recovery.
- **SOLID and extensibility check**: Look for responsibility leaks, tight coupling, hardcoded paths/thresholds/rules, magic strings, and choices that reduce generalization or future extension.
- **Fix what the review finds**: If the review identifies correctness, design, UX, SOLID, hardcoding, or extensibility issues, fix and simplify them directly rather than only reporting them.
- **Anti-hardcoding audit for engine/**: All regex patterns, keyword lists, and if-elif classification chains in `engine/` must be audited against the rule-avoidance principle (Implementation Guidelines). Permitted only when the pattern falls into one of the three explicit exemptions (stable protocol boundary, offline fallback, safety hard gate). Non-exempt hard rules must be refactored to Protocol/registry/config-driven implementations. When reviewing engine changes, verify that new code does not introduce keyword enumerations, substring routing, or magic-number thresholds without a configuration escape hatch.

## Testing Philosophy

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ Unlike instruction-driven agents (Computer-Use, RPA) that reason from scratch on

## Core Philosophy

- **Signal-Grounded Intelligence** — All intelligence derives from observing real-world signals. LeapFlow learns from what actually happens in the environment, not from brittle hardcoded rules.
- **Evolution over Instruction** — Learning is not a one-shot prompt; it is a continuous loop of observation, hypothesis, verification, and refinement across episodes.
- **Signals as First-Class Citizens** — Multi-modal signals (visual, accessibility tree, file system, clipboard, keyboard, etc.) are fused into a unified causal timeline, not treated as isolated events.
- **Persistent Knowledge** — Skills, world-model experiences, and causal patterns are durably stored and compound over time. Nothing learned is ever lost to a session boundary.
Expand Down
2 changes: 2 additions & 0 deletions src/leapflow/cli/approval_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ class ApprovalChoice:
_CHOICE_LABELS = {
"allow_once": "Allow once",
"allow_session": "Allow for this session",
"allow_all_session": "Allow ALL for this session (bypass mode)",
"allow_always": "Add to permanent allowlist",
"deny": "Deny",
"deny_always": "Deny for this session",
Expand All @@ -32,6 +33,7 @@ class ApprovalChoice:
_CHOICE_DECISIONS = {
"allow_once": ApprovalDecision.ALLOW_ONCE,
"allow_session": ApprovalDecision.ALLOW_SESSION,
"allow_all_session": ApprovalDecision.ALLOW_ALL_SESSION,
"allow_always": ApprovalDecision.ALLOW_ALWAYS,
"deny": ApprovalDecision.DENY,
"deny_always": ApprovalDecision.DENY_ALWAYS,
Expand Down
7 changes: 6 additions & 1 deletion src/leapflow/cli/commands/interactive.py
Original file line number Diff line number Diff line change
Expand Up @@ -533,7 +533,7 @@ async def _stream_response(prompt_text: str) -> None:
renderer.start()
turn_completed = False
try:
async for event in ctx.engine.run_stream(prompt_text):
async for event in ctx.engine.run_stream(prompt_text, enable_thinking=ctx.settings.show_thinking):
if isinstance(event, StreamEvent):
if event.type == "chunk":
renderer.feed(event.content)
Expand Down Expand Up @@ -922,6 +922,8 @@ def _handle_task_control(text: str) -> bool:
console.warning(f"Session '{resume_id}' not found; starting a new session.")

_render_banner()
if ctx.settings.approval_bypass:
console.print("\u26a0 Approval bypass active \u2014 all non-hardline actions auto-approved", style="bold yellow")
_print_auth_setup_hint(console, ctx.settings)
_update_status()
exit_code = 0
Expand Down Expand Up @@ -1200,6 +1202,7 @@ async def _stream_response(
prompt_text,
session_id=active_session_id,
workspace_root=str(Path.cwd().resolve()),
enable_thinking=settings.show_thinking,
):
metadata = event.metadata or {}
is_heartbeat = event.type == "status" and metadata.get("heartbeat")
Expand Down Expand Up @@ -1506,6 +1509,8 @@ def _handle_task_control(text: str) -> bool:
console.warning(f"Daemon status unavailable: {exc}")

_render_banner()
if settings.approval_bypass:
console.print("\u26a0 Approval bypass active \u2014 all non-hardline actions auto-approved", style="bold yellow")
_print_auth_setup_hint(console, settings)
_update_status()

Expand Down
17 changes: 17 additions & 0 deletions src/leapflow/cli/commands/slash_handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -1108,12 +1108,28 @@ def build_orient_payload(ctx: "Context") -> dict[str, Any]:
if view_fn is None:
return {"ok": False, "error": "Orientation view not available."}
orientation = view_fn()
focus_view_fn = getattr(engine, "focus_view", None)
focus = focus_view_fn() if callable(focus_view_fn) else {}
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)
active_focus = focus.get("active_focus") if isinstance(focus, dict) else None
if isinstance(active_focus, dict):
name = str(active_focus.get("canonical_name") or "").strip()
kind = str(active_focus.get("kind") or "focus").strip()
if name:
lines.append(f"Active focus: {name} ({kind})")
control_events = focus.get("recent_control_events") if isinstance(focus, dict) else None
if isinstance(control_events, list) and control_events:
lines.append("Recent control-plane events:")
for event in control_events[-3:]:
if isinstance(event, dict):
summary = str(event.get("user_visible_summary") or "").strip()
if summary:
lines.append(f" - {summary}")
store = getattr(ctx, "_reentry_store", None)
if store is not None:
try:
Expand All @@ -1127,6 +1143,7 @@ def build_orient_payload(ctx: "Context") -> dict[str, Any]:
"ok": True,
"message": "\n".join(lines),
"orientation": orientation.summary(),
"focus": focus,
}


Expand Down
15 changes: 9 additions & 6 deletions src/leapflow/cli/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -495,12 +495,14 @@ def __init__(self, settings: Settings, mock_host: bool) -> None:
from leapflow.security.approval import SessionAwareGate
from leapflow.security.grants import ApprovalAuditLog, JsonApprovalGrantStore
from leapflow.security.orchestrator import ApprovalOrchestrator
from leapflow.security.policy import ApprovalPolicyEngine

approval_layout = settings.profile_layout.approval
self._tui_approval = _TUIApprovalGate()
self._approval_gate = SessionAwareGate(self._tui_approval)
self._approval_orchestrator = ApprovalOrchestrator(
self._approval_gate,
policy=ApprovalPolicyEngine(bypass=settings.approval_bypass),
grants=JsonApprovalGrantStore(approval_layout.grants_path),
audit=ApprovalAuditLog(approval_layout.audit_path),
)
Expand Down Expand Up @@ -713,11 +715,8 @@ def _load_runtime_settings_from_files(self) -> Settings:
self.settings.workspace_root,
)
original_env = dict(os.environ)
injected_keys: list[str] = []
for key, value in bundle.env.items():
if key not in original_env:
os.environ[key] = value
injected_keys.append(key)
os.environ[key] = value
try:
return _build_settings_from_env(
layout=self.settings.layout,
Expand All @@ -728,8 +727,12 @@ def _load_runtime_settings_from_files(self) -> Settings:
config_warnings=bundle.warnings,
)
finally:
for key in injected_keys:
os.environ.pop(key, None)
# Restore original env to avoid polluting daemon global state.
for key in bundle.env:
if key in original_env:
os.environ[key] = original_env[key]
else:
os.environ.pop(key, None)

def _configure_mcp_manager(self, settings: Settings) -> None:
"""Rebuild MCP manager and global MCP tool registrations from layout config."""
Expand Down
15 changes: 14 additions & 1 deletion src/leapflow/cli/tui_app/stream.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@
_TOOL_INPUT_LIMIT = 96
_TOOL_OUTPUT_LIMIT = 96
_TOOL_PATH_LIMIT = 72
_MIN_THINKING_LENGTH = 20
_MAX_THINKING_DISPLAY = 2000
_TOOL_CONTEXT_TAG_LIMIT = 3
_SYNTHETIC_THINKING_ROUND_RE = re.compile(r"round\s*\d+", re.IGNORECASE)
_FENCED_BLOCK_RE = re.compile(r"```(?P<lang>[\w+-]*)\s*\n(?P<body>.*?)\n```", re.DOTALL)
Expand Down Expand Up @@ -357,6 +359,7 @@ def __init__(self, console: "LeapConsole") -> None:
self._tool_seq: int = 0
self._tool_history: list[tuple[str, float]] = []
self._permission_block_reason: str = ""
self._last_thinking: str = ""

@property
def text(self) -> str:
Expand Down Expand Up @@ -394,6 +397,7 @@ def start(self) -> None:
self._tool_seq = 0
self._tool_history = []
self._permission_block_reason = ""
self._last_thinking = ""
self._start_time = time.monotonic()

def feed(self, chunk: str) -> None:
Expand All @@ -409,8 +413,13 @@ def feed(self, chunk: str) -> None:
def feed_thinking(self, chunk: str) -> None:
"""Append meaningful thinking/reasoning text."""
text = _normalize_thinking_text(chunk)
if not text:
if not text or len(text) < _MIN_THINKING_LENGTH:
return
if text == self._last_thinking:
return
self._last_thinking = text
if len(text) > _MAX_THINKING_DISPLAY:
text = text[:_MAX_THINKING_DISPLAY] + "\n\u2026[continued]"
if self._thinking_buffer and not self._thinking_buffer.endswith("\n"):
self._thinking_buffer += "\n"
self._thinking_buffer += text
Expand All @@ -421,6 +430,10 @@ def tool_started(self, name: str, metadata: dict[str, Any] | None = None) -> str
Discards any pending content — it was preamble preceding the tool call
and should not appear in the final answer.
"""
# Flush accumulated thinking before starting new tool display
if self._thinking_buffer.strip():
self._console.thinking(self._thinking_buffer)
self._thinking_buffer = ""
self._pending = ""
metadata = metadata or {}
tool_name = _metadata_text(metadata, "normalized_tool_name") or name
Expand Down
21 changes: 13 additions & 8 deletions src/leapflow/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,7 @@ class Settings:
native_tool_calling_enabled: bool = True # Use native OpenAI tool_calls when available
stream_output: bool = True # Enable LLM streaming in interactive mode
verbose_progress: bool = True # Show detailed tool execution progress
show_thinking: bool = True # Display LLM thinking/reasoning in TUI

# Context Compression
compress_threshold: int = 16
Expand All @@ -354,7 +355,7 @@ class Settings:
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_terminal_session_enabled: bool = True # persistent shell sessions
tools_verify_edits: bool = True # post-edit syntax check (advisory) for edit_file/file_write

# web_fetch: first-class read-only HTTP access, so reading a public page is
Expand Down Expand Up @@ -409,6 +410,7 @@ class Settings:
# 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
approval_bypass: bool = False # Skip all approval prompts for non-hardline actions
guardrail_max_repeats: int = 3
guardrail_max_consecutive_same: int = 8
guardrail_stagnation_window: int = 10
Expand Down Expand Up @@ -586,11 +588,8 @@ def load_config() -> Settings:
bundle = load_config_bundle(layout, profile_layout, workspace_root)

original_env = dict(os.environ)
injected_keys: list[str] = []
for key, value in bundle.env.items():
if key not in original_env:
os.environ[key] = value
injected_keys.append(key)
os.environ[key] = value
try:
settings = _build_settings_from_env(
layout=layout,
Expand All @@ -601,8 +600,12 @@ def load_config() -> Settings:
config_warnings=bundle.warnings,
)
finally:
for key in injected_keys:
os.environ.pop(key, None)
# Restore original env to avoid polluting caller's global state.
for key in bundle.env:
if key in original_env:
os.environ[key] = original_env[key]
else:
os.environ.pop(key, None)
return settings


Expand Down Expand Up @@ -887,7 +890,7 @@ def _build_settings_from_env(
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_terminal_session_enabled = os.getenv("LEAPFLOW_TOOLS_TERMINAL_SESSION_ENABLED", "1").strip().lower() in ("1", "true", "yes")
tools_verify_edits = os.getenv("LEAPFLOW_TOOLS_VERIFY_EDITS", "1").strip().lower() in ("1", "true", "yes")
web_transport = os.getenv("LEAPFLOW_WEB_TRANSPORT", "auto").strip().lower() or "auto"
web_timeout_s = float(os.getenv("LEAPFLOW_WEB_TIMEOUT_S", "20"))
Expand Down Expand Up @@ -922,6 +925,7 @@ def _build_settings_from_env(
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")
approval_bypass = os.getenv("LEAPFLOW_APPROVAL_BYPASS", "0").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"))
Expand Down Expand Up @@ -1276,6 +1280,7 @@ def _tuple_env(key: str, default: tuple) -> tuple:
recovery_total_actions=recovery_total_actions,
recovery_max_retry_per_category=recovery_max_retry_per_category,
guardrail_enabled=guardrail_enabled,
approval_bypass=approval_bypass,
guardrail_max_repeats=guardrail_max_repeats,
guardrail_max_consecutive_same=guardrail_max_consecutive_same,
guardrail_stagnation_window=guardrail_stagnation_window,
Expand Down
40 changes: 38 additions & 2 deletions src/leapflow/config_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -246,13 +246,31 @@ def _validate_known_sections(
warnings.append(f"{path}: section '{key}' must be a mapping")


# Known compound keys whose internal underscores are NOT level separators.
# Used by _env_overrides to avoid splitting e.g. LEAPFLOW_LLM_API_KEY into
# {"llm": {"api": {"key": ...}}} instead of the intended {"llm": {"api_key": ...}}.
_COMPOUND_KEYS: frozenset[str] = frozenset({
"api_key", "base_url", "max_retries", "context_length",
"max_tokens", "timeout_s", "idle_ttl_s", "max_live_sessions",
"track_enabled", "hard_limit_ratio", "warning_ratio",
"result_budget", "tool_budget",
})


def _env_overrides() -> dict[str, Any]:
"""Collect process LEAPFLOW_* overrides as nested config values."""
"""Collect process LEAPFLOW_* overrides as nested config values.

The strategy is greedy: after the LEAPFLOW_ prefix, the first segment is the
section. The remaining segments are re-joined and checked against known
compound keys from longest to shortest, so that LEAPFLOW_LLM_API_KEY maps to
{"llm": {"api_key": "..."}} rather than {"llm": {"api": {"key": "..."}}}.
"""
values: dict[str, Any] = {}
for key, value in os.environ.items():
if not key.startswith("LEAPFLOW_"):
continue
path = key[len("LEAPFLOW_"):].lower().split("_")
remainder = key[len("LEAPFLOW_"):].lower()
path = _split_env_key(remainder)
cursor = values
for part in path[:-1]:
next_cursor = cursor.setdefault(part, {})
Expand All @@ -262,3 +280,21 @@ def _env_overrides() -> dict[str, Any]:
cursor = next_cursor
cursor[path[-1]] = value
return values


def _split_env_key(remainder: str) -> list[str]:
"""Split a lowercased env suffix into config path segments.

Uses greedy matching against _COMPOUND_KEYS so that multi-word leaf keys
(api_key, base_url, ...) are not split into extra nesting levels.
"""
parts = remainder.split("_")
if len(parts) <= 1:
return parts
# Try greedy: from the end, find the longest tail that matches a compound key.
for tail_start in range(1, len(parts)):
candidate = "_".join(parts[tail_start:])
if candidate in _COMPOUND_KEYS:
return parts[:tail_start] + [candidate]
# No compound match — fall back to original per-segment splitting.
return parts
Loading
Loading