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
86 changes: 43 additions & 43 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
@@ -1,43 +1,43 @@
#name: CI
#
#on:
# push:
# branches: [main]
# pull_request:
# branches: [main]
#
#concurrency:
# group: ${{ github.workflow }}-${{ github.ref }}
# cancel-in-progress: true
#
#jobs:
# test:
# runs-on: ${{ matrix.os }}
# strategy:
# fail-fast: false
# matrix:
# os: [ubuntu-latest, macos-latest]
# python-version: ['3.11', '3.12', '3.13']
#
# steps:
# - uses: actions/checkout@v4
#
# - name: Set up Python ${{ matrix.python-version }}
# uses: actions/setup-python@v5
# with:
# python-version: ${{ matrix.python-version }}
#
# - name: Install uv
# uses: astral-sh/setup-uv@v4
#
# - name: Install dependencies
# run: uv sync --all-extras
#
# - name: Lint
# run: uv run ruff check src/leapflow/ tests/
#
# - name: Run tests
# run: uv run pytest tests/ -q --tb=short
# env:
# LEAPFLOW_MOCK_HOST: '1'
# LEAPFLOW_LLM_API_KEY: 'test-key-ci'
name: CI

on:
push:
branches: [main]
pull_request:
branches: [main]

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest]
python-version: ['3.11', '3.12', '3.13']

steps:
- uses: actions/checkout@v4

- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}

- name: Install uv
uses: astral-sh/setup-uv@v4

- name: Install dependencies
run: uv sync --all-extras

- name: Lint
run: uv run ruff check src/leapflow/ tests/

- name: Run tests
run: uv run pytest tests/ -q --tb=short
env:
LEAPFLOW_MOCK_HOST: '1'
LEAPFLOW_LLM_API_KEY: 'test-key-ci'
13 changes: 9 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ This document is the LeapFlow engineering collaboration contract. It is not only
- **Progressive Context Disclosure (PCD)**: Keep one unified execution loop, but never default every turn to full disclosure. Each LLM call must use the smallest sufficient PromptAssemblyPlan for tools, memory, history, reasoning, streaming, and risk; upgrade progressively only when observable signals require it.
- **Gateway as Signal Boundary**: External IM/platform integrations are not just messaging features; they extend LeapFlow's Observe/Orient boundary into collaboration environments. Inbound platform events must enter as structured signals (`BackendEvent` → normalized domain event/message), pass SNR filtering and privacy/safety gates, then feed memory, decision, and action paths according to their classification.
- **Transport-Lifecycle Separation**: Short-lived actions (`ExecutionBackend`/`CliBackend`) and long-lived observations (`BackendEventSource`) are separate responsibilities. Do not implement streaming subscribers, webhooks, polling loops, or CLI NDJSON consumers inside one-shot action execution code.
- **Platform-Neutral Gateway Core**: Gateway core owns protocols, lifecycle, routing, session isolation, approval, audit, and memory integration. Platform adapters own authentication, send semantics, event-source configuration, and schema normalization. Core modules must not import platform SDKs directly.
- **Platform-Neutral Gateway Core**: Gateway core owns protocols, lifecycle, routing, session isolation, approval, audit, and memory integration. Platform adapters own authentication, send semantics, event-source configuration, and schema normalization. Core modules must not import platform SDKs directly. Per-vendor code — including credential validators — lives in a platform sub-package (`adapters/`, `normalizers/`, `action_packs/`, `validators/<platform>.py`), never in a core module; core keeps only the neutral registry and contracts.
- **Platform vs App Business Boundary**: Platform layers may define stable contracts and governance primitives (`ActionSpec`, `ActionFailure`, `ActionAuthSpec`, `CapabilityHealthLedger`, approval/feasibility gates, audit, and metadata propagation). Third-party app or vendor specifics — SDK/CLI wire formats, scope names, auth commands, console URLs, error JSON shapes, resource naming, and recovery playbooks — must live in that app's action pack, adapter, backend, or normalizer, never in gateway core.
- **Dependency Inversion**: Core logic depends on Protocol abstractions, never on concrete implementations
- **Protocol over ABC**: Use `typing.Protocol` with `runtime_checkable` for all extension points
Expand All @@ -50,7 +50,8 @@ This document is the LeapFlow engineering collaboration contract. It is not only
- **Single Source of Truth**: DuckDB for persistence, EventBus for communication, Settings for configuration
- **Inbound Signal Classification**: Platform events must be classified before they activate the agent. Message/callback events may enter Decide; signal/lifecycle events should be stored or routed without triggering LLM by default; ignored events must be explicit (e.g. self-message, duplicate, blocked scope).
- **Single Recovery Decision Point**: All agent loop errors (LLM, tool, system, security) enter one `RecoveryCoordinator`. No parallel decision paths, no scattered if/break logic. The pipeline is always: `FailureEnvelope` → `RecoveryDecision` → `StrategyOutcome` feedback.
- **Side-Effect Gating**: Recovery actions are gated by `SideEffectState`. Committed or partial side effects block automatic retry; only user-mediated or checkpoint-based resumption is permitted after state mutation.
- **Side-Effect Gating**: Recovery is gated by `SideEffectState` at two levels. Within a tool batch, a failed side-effecting call stops the remaining calls in that batch, decided by the declared `execution_policy` rather than any tool-name list. Within `RecoveryCoordinator`, any state other than `NONE` blocks replaying actions (retry, transform-and-retry, failover) and yields a checkpointed halt carrying an `InteractionRequest`; only user-mediated or checkpoint-based resumption is permitted. `UNKNOWN` blocks like `COMMITTED` and `PARTIAL` do: it is the classifier's fallback and the state assigned to `external_side_effect` (outbound sends, external API calls), so exempting it would leave the highest-risk case ungated.
- **Uncertain Effects Are Reported, Not Retried Blindly**: a failed call whose effect may already have landed (`external_side_effect`, `mutating_once`) must carry that verdict in its result so the next turn verifies before repeating it. An error is not proof that nothing happened. Idempotent mutations are exempt — re-applying them converges, so flagging them would only stall safe retries.
- **Budget-Constrained Recovery**: Turn-level deadlines, per-category limits, and a global recovery budget prevent infinite retry loops. Every recovery action has an explicit cost; exhaustion triggers a clean halt or user escalation.
- **Recovery Strategy as Protocol**: Recovery strategies implement a `RecoveryStrategy` Protocol (`can_apply` + `decide`), registered by priority, composable, and extensible without modifying the coordinator.

Expand Down Expand Up @@ -88,13 +89,17 @@ This document is the LeapFlow engineering collaboration contract. It is not only
- ANSI output must check `sys.stdout.isatty()` before emitting escape codes
- For error recovery, route all failures through the `RecoveryCoordinator` — classify into a `FailureEnvelope`, receive a `RecoveryDecision` with an explainable `reason` and `strategy_key`, then feed the outcome back. Never handle errors with ad-hoc if/break in the loop body.
- Recovery strategies are standalone Protocol implementations with `can_apply()` + `decide()`. Add new strategies by registration, never by modifying the coordinator's decision logic.
- When automatic recovery exhausts its budget or encounters non-recoverable failures, emit a structured `InteractionRequest` (typed action, severity, suggested actions, timeout behavior, resumption key) — not raw text appended to conversation.
- When automatic recovery exhausts its budget or encounters non-recoverable failures, emit a structured `InteractionRequest` (typed action, severity, suggested actions, timeout behavior, resumption key) — not raw text appended to conversation. A terminal decision that carries one must surface it: render its title, description, and suggested actions for the user, and pass the structured payload to the client so it can prompt and resume by `resumption_key`. Dropping it back to `decision.reason` tells the user a turn stopped without saying what to do.

## Review Requirements

- **Deep review for large changes**: When a change substantially affects architecture, runtime behavior, user flows, persistence, safety, or multiple modules, perform an additional deep review before considering the work complete.
- **Human confirmation for TUI changes**: Any TUI layout or interaction-logic change requires a second human confirmation before it is considered ready.
- **Human confirmation for slash-command paths**: Any change that adds, removes, renames, reroutes, or alters the behavior of a slash command (`/...`) — across the registry, router, in-process REPL, daemon REPL, `command_execute`, completion, and rendering — requires a second human confirmation before it is considered ready. This applies especially to user-experience-facing behavior (dispatch, prompts, confirmations, output, browser/dashboard launches, and error/recovery messaging), which must never be shipped on a single pass.
- **Human confirmation for slash-command paths (MANDATORY, no exceptions)**: Any change to what a slash command (`/...`) *does* requires a second human confirmation before it is considered ready — never ship it on a single pass. This covers the whole surface: registry, router, in-process REPL, daemon REPL, `command_execute` (including its RPC signature and parameter plumbing), completion, and rendering.
- **Functional changes count even when the command surface is unchanged.** The name, arguments, and help text staying identical does NOT waive confirmation. Altering what the command observes, targets, arms, schedules, sends, opens, or persists — or which session/workspace/profile it resolves against — is a functional change and must be confirmed.
- Equally in scope: dispatch and routing, prompts and confirmations, emitted output, browser/dashboard launches, background work the command triggers (watches, schedules, re-entries), and error/recovery messaging.
- Passing tests and a clean lint run are NOT a substitute for confirmation. Slash commands are the primary user-facing control plane; correctness of the visible behavior is only established by a human check.
- State the pending confirmation explicitly in the handoff, and name the behavior a human should exercise to verify it.
- **Design goal check**: Verify that the implementation actually achieves the intended design goal and is not just a local patch.
- **Optimality check**: Evaluate whether the solution is the simplest robust design, avoids unnecessary abstractions, and fits the existing architecture.
- **Regression impact check**: Inspect affected modules and user journeys for logic bugs, degraded UX, broken compatibility, slower feedback, weaker diagnostics, or worse failure recovery.
Expand Down
5 changes: 5 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -68,3 +68,8 @@ testpaths = ["tests"]
[tool.ruff]
line-length = 100
target-version = "py311"

[tool.ruff.lint]
# Pinned explicitly so a ruff upgrade cannot silently change the enforced set.
# Matches the previously implicit default (pycodestyle errors + pyflakes).
select = ["E4", "E7", "E9", "F"]
2 changes: 1 addition & 1 deletion src/leapflow/causal/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ def graph_to_pair_context(
This adapter enables the existing ContextEnrichedVLMExtractor to consume
causal chain data without modification.
"""
from leapflow.perception.types import InteractionSignal, PairContext
from leapflow.perception.types import PairContext

chains = graph.chains_in_window(t0, t1)
signals: List[InteractionSignal] = []
Expand Down
5 changes: 1 addition & 4 deletions src/leapflow/causal/components.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,18 +13,15 @@

import logging
import math
import time
from collections import defaultdict
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Sequence, Tuple
from typing import Any, Dict, List, Optional, Tuple

from leapflow.causal.channel import AggregationPolicy, ChannelRegistry
from leapflow.causal.types import (
CausalChain,
CausalEvent,
EventSource,
EventType,
FrameRef,
)

logger = logging.getLogger(__name__)
Expand Down
2 changes: 1 addition & 1 deletion src/leapflow/causal/inference.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
import warnings
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, List, Optional, Sequence, Tuple
from typing import Any, Dict, List, Optional, Tuple

import yaml

Expand Down
2 changes: 1 addition & 1 deletion src/leapflow/causal/pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
import logging
import time
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Sequence, TYPE_CHECKING, Union
from typing import Any, Dict, List, Optional, Sequence, TYPE_CHECKING

from leapflow.causal.channel import ChannelRegistry, build_default_registry
from leapflow.causal.components import (
Expand Down
2 changes: 1 addition & 1 deletion src/leapflow/causal/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from collections import defaultdict, deque
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, Dict, FrozenSet, Iterator, List, Optional, Set, Tuple
from typing import Any, Dict, Iterator, List, Optional, Set, Tuple


class EventType(str, Enum):
Expand Down
4 changes: 4 additions & 0 deletions src/leapflow/cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@

async def _async_main(args: argparse.Namespace) -> int:
settings = load_config()
from leapflow.logging_setup import init_cli_logging
init_cli_logging(settings)
mock_host = getattr(args, "mock_host", False)
sys.stderr.write("\033[2m→ Initializing LeapFlow...\033[0m\n")
sys.stderr.flush()
Expand Down Expand Up @@ -128,6 +130,8 @@ async def _async_daemon_main(args: argparse.Namespace) -> int:
from leapflow.daemon.client import DaemonUnavailableError, recover_daemon_client

settings = load_config()
from leapflow.logging_setup import init_cli_logging
init_cli_logging(settings)
mock_host = getattr(args, "mock_host", False)

def _status(message: str) -> None:
Expand Down
6 changes: 6 additions & 0 deletions src/leapflow/cli/commands/daemon.py
Original file line number Diff line number Diff line change
Expand Up @@ -249,5 +249,11 @@ def _restart(settings: object, mock_host: bool, *, force: bool = False) -> int:

async def _serve(settings: object, mock_host: bool) -> int:
from leapflow.daemon.server import serve_daemon
from leapflow.logging_setup import init_daemon_logging

# The daemon writes stdout/stderr to leapd.log; without an explicit logging
# setup Python's lastResort handler only emits WARNING+, hiding the INFO
# field evidence (deferred init progress, turn usage). daemon.log_level is
# independent from runtime.log_level and requires a daemon restart.
init_daemon_logging(settings)
return await serve_daemon(settings, mock_host=mock_host)
4 changes: 4 additions & 0 deletions src/leapflow/cli/commands/dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,11 @@ def cmd_dashboard(args: argparse.Namespace) -> int:

def _serve(args: argparse.Namespace, settings: object) -> int:
from leapflow.dashboard.server import run_server
from leapflow.logging_setup import init_logging

# Long-lived server process: capture INFO evidence in its log output,
# mirroring the leapd daemon surface.
init_logging("INFO")
token = getattr(args, "token", "") or launcher.generate_token()
bind = getattr(args, "bind", "") or settings.dashboard_bind
port = getattr(args, "port", 0) or settings.dashboard_port
Expand Down
6 changes: 3 additions & 3 deletions src/leapflow/cli/commands/host.py
Original file line number Diff line number Diff line change
Expand Up @@ -242,9 +242,9 @@ async def _cmd_start() -> int:

# Module-based runner for the daemon process
daemon_script = (
"import asyncio, logging, signal, sys; "
"logging.basicConfig(level=logging.INFO, "
"format='%(asctime)s %(name)s %(levelname)s %(message)s'); "
"import asyncio, signal, sys; "
"from leapflow.logging_setup import init_logging; "
"init_logging('INFO'); "
"from leapflow.platform.event_bus import EventBus; "
"from leapflow.platform.observers import ObservationDaemon, ObserverConfig; "
"from leapflow.memory.providers.episodic import EpisodicMemoryProvider; "
Expand Down
9 changes: 4 additions & 5 deletions src/leapflow/cli/commands/hub.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
from __future__ import annotations

import logging
import sys
from typing import TYPE_CHECKING, List

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -233,7 +232,7 @@ async def _hub_push(ctx: "Context", args: List[str]) -> int:
client = _build_hub_client(ctx)
repo_id = client._build_repo_id(bundle.manifest.name)

print(f"\n Push Summary:")
print("\n Push Summary:")
print(f" Skill: {bundle.manifest.name}")
print(f" Version: {bundle.manifest.version}")
print(f" Visibility: {visibility.value}")
Expand Down Expand Up @@ -286,7 +285,7 @@ async def _hub_push(ctx: "Context", args: List[str]) -> int:
print(" Push aborted.")
return 0

print(f"\n Pushed successfully!")
print("\n Pushed successfully!")
print(f" Repo: {result.repo_id}")
print(f" Version: {result.version}")
print(f" URL: {result.url}")
Expand Down Expand Up @@ -343,7 +342,7 @@ async def _hub_pull(ctx: "Context", args: List[str]) -> int:
return 0

# Step 4: Show summary
print(f"\n Pull Summary:")
print("\n Pull Summary:")
print(f" Skill: {bundle.manifest.name}")
print(f" Version: {bundle.manifest.version}")
print(f" Source: {repo_id} ({client.hub_type})")
Expand Down Expand Up @@ -458,7 +457,7 @@ async def _hub_sync(ctx: "Context", args: List[str]) -> int:
return 0

# Display plan
print(f"\n Sync Plan:")
print("\n Sync Plan:")
if plan.to_push and not pull_only:
print(f" Push ({len(plan.to_push)}):")
for m in plan.to_push:
Expand Down
8 changes: 5 additions & 3 deletions src/leapflow/cli/commands/interactive.py
Original file line number Diff line number Diff line change
Expand Up @@ -349,7 +349,6 @@ async def cmd_interactive(ctx: "Context", *, resume_id: Optional[str] = None) ->
handle_usage,
handle_model,
handle_config,
handle_clear,
handle_gateway,
handle_app,
render_command_payload,
Expand Down Expand Up @@ -666,7 +665,7 @@ async def handle_input(text: str) -> None:
return

if canonical == "clear":
handle_clear(ctx, console, cmd_args)
app.clear_screen()
_render_banner()
return

Expand Down Expand Up @@ -1285,6 +1284,7 @@ async def handle_input(text: str) -> None:
_show_help(console, runtime="daemon")
return
if canonical == "clear":
app.clear_screen()
_render_banner()
return
# Task control commands are handled by the existing _handle_task_control
Expand All @@ -1294,7 +1294,9 @@ async def handle_input(text: str) -> None:
# Engine-routed commands: dispatch through daemon RPC
try:
payload = await bridge.call(
lambda current_client: current_client.command_execute(canonical, cmd_args),
lambda current_client: current_client.command_execute(
canonical, cmd_args, session_id=active_session_id,
),
description=f"/{canonical}",
)
except Exception as exc:
Expand Down
2 changes: 1 addition & 1 deletion src/leapflow/cli/commands/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from typing import TYPE_CHECKING, Any, Dict, Optional

from leapflow.cli.helpers import require_initialized
from leapflow.engine.situational_assessor import Assessment, AssessmentVerdict
from leapflow.engine.situational_assessor import AssessmentVerdict

if TYPE_CHECKING:
from leapflow.cli.context import Context
Expand Down
Loading
Loading