Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
27 changes: 26 additions & 1 deletion .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,9 @@ jobs:

# ── L2: main lane ──────────────────────────────────────────────────────
# Everything, unscoped, across the supported matrix.
# Coverage XML + artifact are generated only on the canonical environment
# (Ubuntu + Python 3.12) to avoid duplicate artifacts without doubling
# the test runtime.
main:
if: github.event_name == 'push'
runs-on: ${{ matrix.os }}
Expand Down Expand Up @@ -103,7 +106,29 @@ jobs:
run: uv run python tools/sync_fixtures.py --check

- name: Mock layer — full
run: uv run pytest tests/ -q -m "not e2e" --tb=short -n auto
run: |
COV_FLAGS=""
if [[ "${{ matrix.os }}" == "ubuntu-latest" && "${{ matrix.python-version }}" == "3.12" ]]; then
COV_FLAGS="--cov=leapflow --cov-report=term-missing:skip-covered --cov-report=xml:coverage.xml"
fi
uv run pytest tests/ -q -m "not e2e" --tb=short -n auto $COV_FLAGS

- name: Upload coverage artifact
if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.12'
uses: actions/upload-artifact@v4
with:
name: coverage-report
path: coverage.xml
retention-days: 30
if-no-files-found: warn

- name: Coverage summary
if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.12'
run: |
if [ -f coverage.xml ]; then
RATE=$(python3 -c 'import xml.etree.ElementTree as ET; r=ET.parse("coverage.xml").getroot(); print(round(float(r.attrib.get("line-rate",0))*100,1))')
echo "### Coverage: ${RATE}%" >> "$GITHUB_STEP_SUMMARY"
fi

- name: Real layer — full
run: uv run pytest tests/journeys -q -m e2e --tb=short -n 4
Expand Down
77 changes: 77 additions & 0 deletions .github/workflows/live-e2e.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# Live LLM end-to-end lane.
#
# Runs tests/live/ against a REAL provider. These cost tokens, so the lane is
# opt-in and never part of every-PR CI:
# - nightly schedule (once a day),
# - manual dispatch, or
# - a pull request carrying the "ci:live" label.
#
# Credentials come from repository secrets and are injected as the same env vars
# production reads (leapflow.config._build_settings_from_env), so one secret set
# drives both the product and the lane.

name: Live E2E

on:
workflow_dispatch:
schedule:
# 03:17 UTC daily. Off the hour to dodge the top-of-hour scheduling surge.
- cron: "17 3 * * *"
pull_request:
types: [labeled]

concurrency:
# One live run at a time per ref: duplicate runs waste tokens and can race on
# shared provider rate limits.
group: live-e2e-${{ github.ref }}
cancel-in-progress: true

jobs:
live:
# Schedule and manual dispatch always run; a PR runs only when it carries the
# ci:live label (checked here rather than only via `types` so a re-label of
# an unrelated PR cannot trigger it).
if: >-
github.event_name == 'workflow_dispatch' ||
github.event_name == 'schedule' ||
(github.event_name == 'pull_request' &&
contains(github.event.pull_request.labels.*.name, 'ci:live'))
runs-on: ubuntu-latest
timeout-minutes: 10

env:
LEAPFLOW_LLM_BASE_URL: ${{ secrets.LEAPFLOW_LLM_BASE_URL }}
LEAPFLOW_LLM_API_KEY: ${{ secrets.LEAPFLOW_LLM_API_KEY }}
LEAPFLOW_LLM_MODEL: ${{ secrets.LEAPFLOW_LLM_MODEL }}
# Total-suite token ceiling. The lane fails if the realised total crosses
# it, so a prompt-growth regression cannot quietly raise the bill.
LEAPFLOW_LIVE_TOKEN_BUDGET: "75000"

steps:
- uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"

- name: Install uv
uses: astral-sh/setup-uv@v4
with:
enable-cache: true

- name: Install dependencies
run: uv sync --all-extras --no-extra leapspace

- name: Live E2E — real provider, budget-bounded
run: |
uv run pytest tests/live/ -m live -n 1 --tb=short -q \
2>&1 | tee live-e2e.log

- name: Upload logs on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: live-e2e-log
path: live-e2e.log
retention-days: 14
35 changes: 34 additions & 1 deletion AGENTS.md

Large diffs are not rendered by default.

9 changes: 6 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,17 @@ sync: ## Sync dependencies (excludes the heavy leapspace extra)
space-sync: ## Sync all dependencies including the leapspace extra
uv sync --all-extras

lint: ## Lint source code
uv run ruff check src/ tests/ tools/
# Identical scope + runner to the CI "Lint" step (.github/workflows/ci.yaml), so
# `make lint` and CI can never disagree. leapspace is opt-in everywhere else and
# CI never syncs it (--no-extra leapspace), so it stays out of the lint gate too.
lint: ## Lint source code (mirrors the CI Lint step exactly)
uv run ruff check src/leapflow/ tests/ tools/

# ── Test layers ───────────────────────────────────────────────────────────────
# The mock layer is broad and fast; the real layer is small, coarse, and never
# skipped. Both run offline: the LLM boundary is served from committed cassettes.

test: test-unit test-e2e ## Default gate: mock layer + real journeys (offline)
test: lint test-unit test-e2e ## Default gate: lint + mock layer + real journeys (offline)

test-unit: ## Mock layer — hermetic units and components
uv run pytest tests/ -q -m "not e2e" -n $(JOBS)
Expand Down
31 changes: 31 additions & 0 deletions docs/plugins/third_party_plugin_development.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,33 @@ omit approval/idempotency metadata.
### 2.3 GatewayAdapterPlugin Protocol

```python
@dataclass(frozen=True)
class PlatformCapabilities:
"""Typed declaration of what a platform adapter natively supports."""
supports_streaming: bool = False
supports_rich_text: bool = False
supports_images: bool = False
supports_files: bool = False
supports_reactions: bool = False
supports_threads: bool = False
supports_group_chat: bool = False
supports_edit: bool = False
supports_async_delivery: bool = True
splits_long_messages: bool = False
max_message_length: int = 4000

@runtime_checkable
class PlatformAdapter(Protocol):
@property
def platform_id(self) -> str: ...
@property
def capabilities(self) -> PlatformCapabilities: ...
# Legacy class-level flags retained for structural compatibility:
supports_async_delivery: bool
splits_long_messages: bool
max_message_length: int
...

@runtime_checkable
class GatewayAdapterPlugin(Protocol):
@property
Expand All @@ -181,6 +208,10 @@ class GatewayAdapterPlugin(Protocol):
def create_adapter(self, config: Dict[str, Any]) -> PlatformAdapter: ...
```

`PlatformAdapterMixin` provides a default `capabilities` property that builds
from the three legacy class-level flags. Adapters that natively support
additional features (images, threading, editing, …) override the property.

### 2.4 LLMProviderPlugin Protocol

```python
Expand Down
10 changes: 8 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,12 @@ dev = [
"pytest-xdist>=3.5",
"pytest-cov>=5.0",
]
hub = ["modelscope-hub>=0.1.0"]
hub = ["modelscope-hub>=0.4.5"]
# Native Anthropic Messages API provider. Optional: the core install uses the
# OpenAI-compatible transport by default; this extra enables AnthropicChat for
# endpoints that speak the Anthropic wire format (api.anthropic.com, DeepSeek
# /anthropic compat endpoint, etc.).
anthropic = ["anthropic>=0.39"]
# Better main-content extraction for web_fetch. Optional because the stdlib
# extractor always ships: this upgrades quality, it does not enable the feature.
web = ["trafilatura>=2.2"]
Expand Down Expand Up @@ -99,8 +104,9 @@ testpaths = ["tests"]
markers = [
"unit: hermetic — no real IO, no LLM. Default for tests/*.py",
"component: real local IO (DuckDB, tmp profile) in-process; LLM via cassette replay",
"integration: real DB/storage, stub LLM — synonym for component",
"e2e: real leapd subprocess driven over RPC; LLM via cassette replay",
"live: journey assertions against a real provider (nightly lane only)",
"live: real LLM provider, requires credentials (nightly/manual lane only)",
"invariant: always-on guard — never skipped by impact selection",
"slow: takes more than a few seconds",
]
Expand Down
7 changes: 7 additions & 0 deletions src/leapflow/cli/approval_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,10 @@ def _render(request: ApprovalRequest, choices: list[ApprovalChoice], *, show_det
for line in textwrap.wrap(reason, width=72) or [reason]:
body.append(f"- {line}\n", style="dim")
body.append("\n")
advisory = str(request.display.get("advisory") or "")
if advisory:
body.append(advisory + "\n", style="bold cyan")
body.append("(This is an AI advisory — the authoritative risk level is above.)\n\n", style="dim")
for idx, choice in enumerate(choices, start=1):
body.append(f" {idx}. {choice.label}\n", style="bold" if choice.key == request.default_choice else "")
console.print(Panel(
Expand All @@ -150,6 +154,9 @@ def _render(request: ApprovalRequest, choices: list[ApprovalChoice], *, show_det
sys.stderr.write(f"⚠ {title}\n\n{summary}\n\n{detail}\n\n")
if reason:
sys.stderr.write(f"Why approval is needed: {reason}\n\n")
advisory = str(request.display.get("advisory") or "")
if advisory:
sys.stderr.write(f"{advisory}\n(This is an AI advisory — the authoritative risk level is above.)\n\n")
for idx, choice in enumerate(choices, start=1):
sys.stderr.write(f" {idx}. {choice.label}\n")
sys.stderr.flush()
Expand Down
12 changes: 11 additions & 1 deletion src/leapflow/cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,11 @@ def main(argv: list[str] | None = None) -> int:
hw_replay = hw_sub.add_parser("replay", parents=[hw_json], help="Replay a raw NDJSON segment through the event detector")
hw_replay.add_argument("segment_path", help="Path to the NDJSON segment file")

# leap doctor
doctor_parser = subparsers.add_parser("doctor", help="Run system health diagnostics")
doctor_parser.add_argument("--fix", action="store_true", help="Attempt to auto-fix simple issues (e.g. missing directories)")
doctor_parser.add_argument("--section", choices=["platform", "config", "connectivity", "state", "tools"], help="Only run checks for this section")

# leap config
config_parser = subparsers.add_parser("config", help="View and update LeapFlow configuration")
config_sub = config_parser.add_subparsers(dest="config_action")
Expand Down Expand Up @@ -385,7 +390,7 @@ def main(argv: list[str] | None = None) -> int:

# ── Pre-parse: detect if first non-flag arg is a known subcommand ──
# If not, treat everything non-flag as a chat prompt.
known_commands = {"teach", "run", "skills", "relearn", "host", "daemon", "config", "board", "hw", "evolve"}
known_commands = {"teach", "run", "skills", "relearn", "host", "daemon", "config", "board", "hw", "evolve", "doctor"}
effective_argv = list(argv) if argv is not None else sys.argv[1:]

# Find first non-flag argument, skipping values owned by global options.
Expand Down Expand Up @@ -467,6 +472,11 @@ def main(argv: list[str] | None = None) -> int:
from leapflow.cli.commands.config import cmd_config
return cmd_config(args)

# Doctor does not need full Context initialization
if args.command == "doctor":
from leapflow.cli.commands.doctor_cmd import cmd_doctor
return cmd_doctor(args)

# Host command does not need Context initialization
if args.command == "host":
try:
Expand Down
124 changes: 124 additions & 0 deletions src/leapflow/cli/commands/btw_handler.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
# Copyright (c) Alibaba, Inc. and its affiliates.
"""Handler for ``/btw`` (side question) slash command.

Kept in its own file to avoid inflating ``slash_handlers.py`` (>3700 lines).
The handler creates a :class:`SideQuestionFiber`, streams the LLM response
through the existing ``StreamRenderer``, and cleans up.

Both in-process and daemon code paths funnel here:

- **In-process** (``interactive.py``): called directly as
``await handle_btw(ctx, console, args)``.
- **Daemon** (``command_execute``): called via the ``btw`` branch in
``command_execute``, which returns a streaming payload.
"""
from __future__ import annotations

import logging
from typing import TYPE_CHECKING, Any, Dict

if TYPE_CHECKING:
from leapflow.cli.context import Context
from leapflow.cli.tui_app.console import LeapConsole

logger = logging.getLogger(__name__)


async def handle_btw(
ctx: "Context",
console: "LeapConsole",
args: str,
) -> None:
"""Execute a ``/btw`` side question with streaming output.

The question is answered by the same LLM provider as the main session
but in complete conversation isolation: no messages are written to the
parent session's history, and no tool calls are made.

Parameters
----------
ctx:
CLI context with engine and settings.
console:
TUI console for rendering output.
args:
The side question text (everything after ``/btw ``).
"""
question = args.strip()
if not question:
console.warning("Usage: /btw <question> — ask a quick side question")
return

engine = ctx.engine
if engine is None:
console.warning("No active engine — send a message first, then use /btw.")
return

from leapflow.engine.side_question import SideQuestionConfig, SideQuestionFiber

parent_session_id = getattr(engine, "_current_session_id", "") or ""
config = SideQuestionConfig(
question=question,
parent_session_id=parent_session_id,
)
fiber = SideQuestionFiber(engine, config)

# Stream the response through the existing renderer
from leapflow.cli.tui_app.stream import StreamRenderer

renderer = StreamRenderer(console)
renderer.start()
try:
async for chunk in fiber.run_stream():
renderer.feed(chunk)
except Exception as exc:
logger.warning("/btw streaming failed: %s", exc, exc_info=True)
console.warning(f"Side question failed: {exc}")
return
finally:
renderer.finish()


async def build_btw_payload(
ctx: "Context",
args: str,
) -> Dict[str, Any]:
"""Build a side-question payload for daemon-mode execution.

Unlike most ``command_execute`` payloads, ``/btw`` runs a full LLM
call and returns the answer inline (the question is too lightweight to
justify the full engine chat stream machinery).

Returns a dict compatible with ``render_command_payload``.
"""
question = args.strip()
if not question:
return {"ok": False, "message": "Usage: /btw <question>"}

engine = ctx.engine
if engine is None:
return {"ok": False, "message": "No active engine — send a message first."}

from leapflow.engine.side_question import SideQuestionConfig, SideQuestionFiber

parent_session_id = getattr(engine, "_current_session_id", "") or ""
config = SideQuestionConfig(
question=question,
parent_session_id=parent_session_id,
)
fiber = SideQuestionFiber(engine, config)

try:
answer = await fiber.run()
except Exception as exc:
logger.warning("/btw daemon execution failed: %s", exc, exc_info=True)
return {"ok": False, "message": f"Side question failed: {exc}"}

return {
"ok": True,
"view": "btw",
"question": question,
"answer": answer,
"fiber_id": config.fiber_id,
"parent_session_id": parent_session_id,
}
Loading
Loading