diff --git a/AGENTS.md b/AGENTS.md index 58f6bb6..51f292f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. @@ -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 diff --git a/README.md b/README.md index 59f0a39..bf76043 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/src/leapflow/cli/approval_view.py b/src/leapflow/cli/approval_view.py index b380168..a8ac20f 100644 --- a/src/leapflow/cli/approval_view.py +++ b/src/leapflow/cli/approval_view.py @@ -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", @@ -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, diff --git a/src/leapflow/cli/commands/interactive.py b/src/leapflow/cli/commands/interactive.py index cb68dac..03bdb6d 100644 --- a/src/leapflow/cli/commands/interactive.py +++ b/src/leapflow/cli/commands/interactive.py @@ -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) @@ -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 @@ -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") @@ -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() diff --git a/src/leapflow/cli/commands/slash_handlers.py b/src/leapflow/cli/commands/slash_handlers.py index 08de0b4..6771e98 100644 --- a/src/leapflow/cli/commands/slash_handlers.py +++ b/src/leapflow/cli/commands/slash_handlers.py @@ -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: @@ -1127,6 +1143,7 @@ def build_orient_payload(ctx: "Context") -> dict[str, Any]: "ok": True, "message": "\n".join(lines), "orientation": orientation.summary(), + "focus": focus, } diff --git a/src/leapflow/cli/context.py b/src/leapflow/cli/context.py index 972a5e6..f3d7912 100644 --- a/src/leapflow/cli/context.py +++ b/src/leapflow/cli/context.py @@ -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), ) @@ -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, @@ -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.""" diff --git a/src/leapflow/cli/tui_app/stream.py b/src/leapflow/cli/tui_app/stream.py index c6e8efe..a8d0e33 100644 --- a/src/leapflow/cli/tui_app/stream.py +++ b/src/leapflow/cli/tui_app/stream.py @@ -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[\w+-]*)\s*\n(?P.*?)\n```", re.DOTALL) @@ -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: @@ -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: @@ -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 @@ -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 diff --git a/src/leapflow/config.py b/src/leapflow/config.py index e203312..d519d40 100644 --- a/src/leapflow/config.py +++ b/src/leapflow/config.py @@ -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 @@ -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 @@ -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 @@ -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, @@ -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 @@ -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")) @@ -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")) @@ -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, diff --git a/src/leapflow/config_loader.py b/src/leapflow/config_loader.py index 21ce648..1978b2a 100644 --- a/src/leapflow/config_loader.py +++ b/src/leapflow/config_loader.py @@ -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, {}) @@ -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 diff --git a/src/leapflow/daemon/approval_coordinator.py b/src/leapflow/daemon/approval_coordinator.py index c329b73..76e3d86 100644 --- a/src/leapflow/daemon/approval_coordinator.py +++ b/src/leapflow/daemon/approval_coordinator.py @@ -29,6 +29,7 @@ def install_gate(self, ctx: Any, service: Any) -> None: from leapflow.security.approval import SessionAwareGate from leapflow.security.actions import ActionDescriptor from leapflow.security.orchestrator import ApprovalOrchestrator + from leapflow.security.policy import ApprovalPolicyEngine from leapflow.tools.config_tools import set_config_approval_gate from leapflow.tools.gateway_tool import set_gateway_approval_gate from leapflow.tools.registry_bootstrap import ( @@ -43,6 +44,7 @@ def install_gate(self, ctx: Any, service: Any) -> None: gate = SessionAwareGate(_DaemonApprovalGate(self)) orchestrator = ApprovalOrchestrator( gate, + policy=ApprovalPolicyEngine(bypass=getattr(getattr(ctx, 'settings', None), 'approval_bypass', False)), grants=getattr(existing, "grants", None), audit=getattr(existing, "audit", None), ) diff --git a/src/leapflow/daemon/service.py b/src/leapflow/daemon/service.py index b5b6ad9..76f5f88 100644 --- a/src/leapflow/daemon/service.py +++ b/src/leapflow/daemon/service.py @@ -322,6 +322,7 @@ async def engine_chat(self, message: str, **kwargs: Any) -> AsyncIterator[Stream if ctx.reload_runtime_config_if_changed(): self._settings = ctx.settings self._monitor_coordinator.update_settings(ctx.settings) + self._propagate_config_to_sessions(ctx) chunk = StreamChunk( request_id=request_id, content="Configuration reloaded in leapd.", @@ -544,6 +545,35 @@ async def session_analyze(self) -> dict[str, Any]: def _ensure_session_registry(self, base_engine: Any) -> Any: return self._session_coordinator.ensure_registry(base_engine, self._settings) + def _propagate_config_to_sessions(self, ctx: Any) -> None: + """Propagate updated LLM/config to all active session engines.""" + registry = self._session_coordinator.registry + if registry is None: + return + settings = ctx.settings + llm = ctx.llm + vlm = getattr(ctx, "vlm", None) + classifier = getattr(ctx, "intent_classifier", None) + for sid in registry.session_ids(): + session_ctx = registry.get(sid) + if session_ctx is None: + continue + engine = session_ctx.engine + if engine is None or engine is getattr(ctx, "engine", None): + # Base engine is already reconfigured by reload_runtime_config_if_changed. + continue + try: + engine.reconfigure_runtime( + settings=settings, + llm=llm, + vlm=vlm, + classifier=classifier, + ) + except Exception: + logger.debug( + "Failed to propagate config to session %s", sid, exc_info=True, + ) + # ── Delegate: watch (monitor subsystem) ────────────────────────── def has_active_watches(self) -> bool: diff --git a/src/leapflow/engine/context_disclosure.py b/src/leapflow/engine/context_disclosure.py index 0d216b2..e29402d 100644 --- a/src/leapflow/engine/context_disclosure.py +++ b/src/leapflow/engine/context_disclosure.py @@ -17,10 +17,13 @@ """ from __future__ import annotations +import logging from dataclasses import dataclass, field from enum import Enum from typing import Any, Iterable, Mapping, Sequence +logger = logging.getLogger(__name__) + class DisclosureLevel(str, Enum): """Stable disclosure levels understood by the unified loop. @@ -97,7 +100,7 @@ def from_tool_definition(cls, tool_definition: Mapping[str, Any]) -> "Capability or {} ) metadata = raw_metadata if isinstance(raw_metadata, Mapping) else {} - category = str(metadata.get("category") or _infer_category(name, description)) + category = str(metadata.get("category") or _infer_category(name, tool_definition)) signals = _metadata_signals(metadata.get("input_signals")) risk_level = str(metadata.get("risk_level") or _risk_for_category(category)) requires_approval = bool(metadata.get("requires_approval", category in {"write", "shell", "gateway"})) @@ -138,6 +141,7 @@ class PromptAssemblyPlan: reason: str = "" selected_tool_names: tuple[str, ...] = () expanded_categories: tuple[str, ...] = () + context_planes: tuple[str, ...] = () max_prior_turns: int = 2 def metadata(self) -> dict[str, Any]: @@ -148,6 +152,7 @@ def metadata(self) -> dict[str, Any]: "tools": list(self.selected_tool_names), "tool_count": len(self.selected_tool_names), "expanded_categories": list(self.expanded_categories), + "context_planes": list(self.context_planes), "memory": self.memory.value, "history": self.history.value, "reasoning": self.reasoning.value, @@ -245,6 +250,7 @@ def plan( reason=reason, selected_tool_names=tuple(sorted(expanded_names)), expanded_categories=tuple(expanded_categories), + context_planes=("task_semantic", "control_plane"), max_prior_turns=6 if expanded_categories else 2, ) @@ -270,6 +276,7 @@ def full_plan( reason=reason, selected_tool_names=names, expanded_categories=tuple(sorted({m.category for m in manifests if m.category})), + context_planes=("task_semantic", "control_plane"), max_prior_turns=10, ) @@ -314,18 +321,58 @@ def _metadata_signals(value: Any) -> tuple[str, ...]: return tuple(str(item).lower().strip() for item in value if str(item).strip()) -def _infer_category(name: str, description: str) -> str: - """Best-effort category guess for tools that declare no explicit x_leapflow. +def _infer_category(tool_name: str, tool_schema: Mapping[str, Any] | None = None) -> str: + """Determine tool category from schema metadata, with deprecated substring fallback. + + Priority 1: Read the explicit ``x_leapflow.category`` declaration from the + tool schema (the authoritative source). + Priority 2: Fall back to legacy substring inference on tool name and + description. This path emits a debug-level deprecation notice; tool + authors should declare ``x_leapflow.category`` in the schema instead. + """ + # Priority 1: Explicit x_leapflow.category declaration from tool schema + if tool_schema and isinstance(tool_schema, Mapping): + function = tool_schema.get("function", {}) + if not isinstance(function, Mapping): + function = {} + raw_metadata = ( + tool_schema.get("x_leapflow") + or tool_schema.get("x-leapflow") + or function.get("x_leapflow") + or function.get("x-leapflow") + or {} + ) + metadata = raw_metadata if isinstance(raw_metadata, Mapping) else {} + category = str(metadata.get("category", "")) + if category: + return category + + # Priority 2: Deprecated substring inference + description = "" + if tool_schema and isinstance(tool_schema, Mapping): + func = tool_schema.get("function", {}) + if isinstance(func, Mapping): + description = str(func.get("description") or "") + + category = _legacy_infer_category(tool_name, description) + if category != "unclassified": + logger.debug( + "Tool %s using deprecated substring category inference (%s); " + "declare x_leapflow.category in tool schema instead", + tool_name, category, + ) + return category + + +def _legacy_infer_category(tool_name: str, description: str = "") -> str: + """Deprecated: infer category from tool name/description substrings. - This is purely a *bootstrap convenience* for a handful of well-known, - already-audited built-in tools — it must never be the mechanism that - grants a brand-new, unaudited tool core-whitelist eligibility. Anything - that does not match one of the recognized safe keyword patterns below - falls through to "unclassified", which `_risk_for_category` deliberately - treats as non-core by default (fail-closed): a future tool added without - explicit metadata must be reviewed and opted in, not silently trusted. + This is purely a bootstrap convenience for a handful of well-known, + already-audited built-in tools. New tools MUST declare + ``x_leapflow.category`` in their schema instead of relying on this. + Anything unmatched falls through to 'unclassified' (fail-closed). """ - text = f"{name} {description}".lower() + text = f"{tool_name} {description}".lower() if any(token in text for token in ("write", "replace", "delete", "store", "add")): return "write" if any(token in text for token in ("shell", "command", "execute")): @@ -347,20 +394,38 @@ def _infer_category(name: str, description: str) -> str: return "unclassified" +# ── Category → risk level data table ────────────────────────────────── +# Configurable mapping; 'unclassified' deliberately defaults to 'medium' +# (fail-closed) so an undeclared tool cannot silently enter Tier 0.5. +_CATEGORY_RISK_MAP: dict[str, str] = { + "write": "high", + "shell": "high", + "execute": "high", + "gateway": "high", + "file": "read_only", + "memory": "read_only", + "skill": "medium", + "delegate": "medium", + "hub": "medium", + "read": "read_only", + "search": "read_only", + "system": "read_only", + "general": "read_only", + "dev": "read_only", + "scm": "high", + "desktop": "medium", + "unclassified": "medium", +} + + def _risk_for_category(category: str) -> str: - """Map a category to its default risk level. + """Look up risk level from the category data table. - ``unclassified`` deliberately does *not* fall through to "read_only": a - tool that could not be matched against any recognized safe keyword - pattern (and declared no explicit ``x_leapflow`` metadata) must not be - silently granted Tier 0.5 core-whitelist eligibility. Only categories - that have been explicitly reviewed as safe reach "read_only" here. + Unknown categories default to 'medium' (fail-closed): a tool whose + category is not in the table cannot silently obtain Tier 0.5 + core-whitelist eligibility. """ - if category in {"write", "shell", "gateway"}: - return "high" - if category in {"delegate", "hub", "unclassified"}: - return "medium" - return "read_only" + return _CATEGORY_RISK_MAP.get(category, "medium") def _dedupe_by_name(manifests: Iterable[CapabilityManifest]) -> list[CapabilityManifest]: diff --git a/src/leapflow/engine/context_focus.py b/src/leapflow/engine/context_focus.py new file mode 100644 index 0000000..9f44311 --- /dev/null +++ b/src/leapflow/engine/context_focus.py @@ -0,0 +1,494 @@ +"""Session-level semantic focus state for prompt context assembly. + +The focus plane is deliberately separate from progressive tool disclosure. PCD +answers "which capabilities are visible this turn"; this module answers "what is +the user's current task target" and "which recent events are only control-plane +state". Keeping those concerns separate prevents runtime configuration changes +from stealing the task focus in later turns. +""" + +from __future__ import annotations + +import hashlib +import json +import logging +import re +import time +from dataclasses import dataclass, field, replace +from enum import Enum +from typing import Any, Iterable, Mapping + +logger = logging.getLogger(__name__) + + +class ContextPlane(str, Enum): + """Semantic plane for context events stored during a session.""" + + TASK_SEMANTIC = "task_semantic" + TOOL_EVIDENCE = "tool_evidence" + CONTROL_PLANE = "control_plane" + RUNTIME_DIAGNOSTIC = "runtime_diagnostic" + + +@dataclass(frozen=True) +class FocusEntity: + """A user-visible entity that can become the task focus.""" + + entity_id: str + kind: str + canonical_name: str + plane: ContextPlane = ContextPlane.TASK_SEMANTIC + aliases: tuple[str, ...] = () + evidence_refs: tuple[str, ...] = () + salience: float = 1.0 + first_turn: int = 0 + last_task_turn: int = 0 + last_mentioned_turn: int = 0 + + def with_observation( + self, + *, + turn_id: int, + evidence_refs: Iterable[str] = (), + salience_boost: float = 0.2, + ) -> "FocusEntity": + """Return an updated entity after another task observation.""" + refs = tuple(dict.fromkeys((*self.evidence_refs, *evidence_refs))) + return replace( + self, + evidence_refs=refs, + salience=min(2.0, self.salience + salience_boost), + last_task_turn=max(self.last_task_turn, turn_id), + last_mentioned_turn=max(self.last_mentioned_turn, turn_id), + ) + + def to_dict(self) -> dict[str, Any]: + return { + "entity_id": self.entity_id, + "kind": self.kind, + "canonical_name": self.canonical_name, + "plane": self.plane.value, + "aliases": list(self.aliases), + "evidence_refs": list(self.evidence_refs), + "salience": self.salience, + "first_turn": self.first_turn, + "last_task_turn": self.last_task_turn, + "last_mentioned_turn": self.last_mentioned_turn, + } + + +@dataclass(frozen=True) +class ControlEvent: + """A runtime/control-plane event that must not replace task focus.""" + + action: str + key: str + value: str + tool_name: str + turn_id: int + user_visible_summary: str + ts: float = field(default_factory=time.time) + + def to_dict(self) -> dict[str, Any]: + return { + "action": self.action, + "key": self.key, + "value": self.value, + "tool_name": self.tool_name, + "turn_id": self.turn_id, + "user_visible_summary": self.user_visible_summary, + "ts": self.ts, + } + + +@dataclass(frozen=True) +class ReferenceResolution: + """Result of resolving a deictic user reference against focus state.""" + + target_kind: str + target_id: str + target_name: str + plane: ContextPlane + confidence: float + reason: str + needs_clarification: bool = False + + @classmethod + def unresolved(cls, reason: str, *, target_kind: str = "") -> "ReferenceResolution": + return cls( + target_kind=target_kind, + target_id="", + target_name="", + plane=ContextPlane.TASK_SEMANTIC, + confidence=0.0, + reason=reason, + needs_clarification=False, + ) + + @classmethod + def ambiguous(cls, reason: str, *, target_kind: str = "") -> "ReferenceResolution": + return cls( + target_kind=target_kind, + target_id="", + target_name="", + plane=ContextPlane.TASK_SEMANTIC, + confidence=0.0, + reason=reason, + needs_clarification=True, + ) + + def to_dict(self) -> dict[str, Any]: + return { + "target_kind": self.target_kind, + "target_id": self.target_id, + "target_name": self.target_name, + "plane": self.plane.value, + "confidence": self.confidence, + "reason": self.reason, + "needs_clarification": self.needs_clarification, + } + + +# ── Deprecated legacy tool name lists ───────────────────────────────── +# Prefer declaring x_leapflow.plane in tool schema metadata instead. + +_LEGACY_CONTROL_TOOLS: frozenset[str] = frozenset( + {"config_get", "config_set", "config_list"} +) # deprecated: declare x_leapflow.plane = "control" on the tool schema + +_LEGACY_TASK_TOOLS: frozenset[str] = frozenset( + {"file_read", "web_fetch", "code_search", "text_search", "memory_search"} +) # deprecated: declare x_leapflow.plane = "task" on the tool schema + + +def _tool_plane(tool_name: str, tool_schema: dict[str, Any] | None = None) -> str: + """Determine semantic plane from tool metadata, falling back to legacy list.""" + if tool_schema: + x_leapflow = tool_schema.get("x_leapflow", {}) + if isinstance(x_leapflow, dict): + plane = x_leapflow.get("plane", "") + if plane: + return str(plane) + # Deprecated fallback + if tool_name in _LEGACY_CONTROL_TOOLS: + logger.debug( + "Tool %s has no x_leapflow.plane declaration, using legacy classification", + tool_name, + ) + return "control" + if tool_name in _LEGACY_TASK_TOOLS: + logger.debug( + "Tool %s has no x_leapflow.plane declaration, using legacy classification", + tool_name, + ) + return "task" + return "unknown" + + +# ── Entity kind pattern registry ────────────────────────────────────── + + +@dataclass(frozen=True) +class KindPattern: + """URL or name pattern to entity kind mapping.""" + + pattern: str + kind: str + match_type: str = "substring" # "substring" | "suffix" | "glob" + + +_KIND_PATTERNS: list[KindPattern] = [ + KindPattern(pattern="arxiv.org/", kind="paper"), + KindPattern(pattern=".pdf", kind="document", match_type="suffix"), + KindPattern(pattern=".md", kind="document", match_type="suffix"), + KindPattern(pattern=".docx", kind="document", match_type="suffix"), +] + + +# ── Kind synonym groups ─────────────────────────────────────────────── + + +_KIND_GROUPS: dict[str, frozenset[str]] = { + "document": frozenset({"paper", "document", "file", "report"}), + "code": frozenset({"code", "script", "module", "package"}), +} + + +class SessionFocusState: + """Mutable session focus state used by the engine's prompt assembly.""" + + def __init__(self, *, max_focus_items: int = 12, max_control_events: int = 12) -> None: + self.active_focus: FocusEntity | None = None + self.focus_stack: list[FocusEntity] = [] + self.entity_registry: dict[str, FocusEntity] = {} + self.recent_control_events: list[ControlEvent] = [] + self.max_focus_items = max(1, max_focus_items) + self.max_control_events = max(1, max_control_events) + + def record_focus(self, entity: FocusEntity) -> FocusEntity: + """Promote a task entity to active focus.""" + if entity.plane == ContextPlane.CONTROL_PLANE: + raise ValueError("control-plane entities cannot become task focus") + existing = self.entity_registry.get(entity.entity_id) + if existing is not None: + entity = existing.with_observation( + turn_id=max(entity.last_task_turn, entity.last_mentioned_turn), + evidence_refs=entity.evidence_refs, + ) + self.entity_registry[entity.entity_id] = entity + self.focus_stack = [item for item in self.focus_stack if item.entity_id != entity.entity_id] + self.focus_stack.append(entity) + self.focus_stack = self.focus_stack[-self.max_focus_items:] + self.active_focus = entity + return entity + + def record_control_event(self, event: ControlEvent) -> None: + """Record a control-plane event without changing active task focus.""" + self.recent_control_events.append(event) + self.recent_control_events = self.recent_control_events[-self.max_control_events:] + + def record_tool_result( + self, + tool_name: str, + arguments: Mapping[str, Any] | None, + result: Any, + *, + turn_id: int, + tool_schema: dict[str, Any] | None = None, + ) -> None: + """Update focus ledgers from a completed tool result.""" + name = _canonical_tool_name(tool_name) + args = dict(arguments or {}) + plane = _tool_plane(name, tool_schema) + if plane == "control": + event = control_event_from_tool(name, args, result, turn_id=turn_id) + if event is not None: + self.record_control_event(event) + return + if plane == "task": + entity = focus_entity_from_tool(name, args, result, turn_id=turn_id) + if entity is not None: + self.record_focus(entity) + + def latest_control_event(self, *, key: str = "") -> ControlEvent | None: + """Return the newest control event, optionally scoped to one config key.""" + for event in reversed(self.recent_control_events): + if not key or event.key == key: + return event + return None + + def task_focus_candidates(self, *, kind: str = "") -> list[FocusEntity]: + """Return active task candidates ordered by recency and salience.""" + candidates = [ + item for item in self.focus_stack + if item.plane != ContextPlane.CONTROL_PLANE and _kind_matches(item.kind, kind) + ] + candidates.sort(key=lambda item: (item.last_task_turn, item.salience), reverse=True) + return candidates + + def render_prompt_context(self, resolution: ReferenceResolution | None = None) -> str: + """Render a compact prompt block describing task focus and control events.""" + if self.active_focus is None and not self.recent_control_events and not resolution: + return "" + lines = ["## Semantic Focus Plane"] + if self.active_focus is not None: + focus = self.active_focus + lines.append("Current task focus:") + lines.append(f"- Target: {focus.canonical_name}") + lines.append(f"- Type: {focus.kind}") + if focus.evidence_refs: + lines.append(f"- Evidence refs: {', '.join(focus.evidence_refs[:4])}") + else: + lines.append("Current task focus: (none established)") + if resolution and resolution.target_id: + lines.append("Resolved user reference:") + lines.append( + f"- {resolution.target_kind or 'target'} -> {resolution.target_name} " + f"({resolution.plane.value}, confidence={resolution.confidence:.2f})" + ) + elif resolution and resolution.needs_clarification: + lines.append(f"Reference ambiguity: {resolution.reason}") + if self.recent_control_events: + lines.append("Recent control-plane events (runtime settings, not task targets unless explicitly requested):") + for event in self.recent_control_events[-3:]: + lines.append(f"- {event.user_visible_summary}") + lines.append( + "Use Current task focus for deictic task references such as 'the above paper'; " + "use control-plane events only when the user explicitly asks about runtime settings." + ) + return "\n".join(lines) + + def summary(self) -> dict[str, Any]: + return { + "active_focus": self.active_focus.to_dict() if self.active_focus else None, + "focus_stack": [item.to_dict() for item in self.focus_stack], + "recent_control_events": [event.to_dict() for event in self.recent_control_events], + } + + +def _canonical_tool_name(tool_name: str) -> str: + return str(tool_name or "").removeprefix("gp_") + + +def _stable_id(prefix: str, text: str) -> str: + digest = hashlib.sha1(text.encode("utf-8", errors="ignore")).hexdigest()[:12] + return f"{prefix}:{digest}" + + +def _string_value(value: Any) -> str: + if value is None: + return "" + if isinstance(value, str): + return value.strip() + return str(value).strip() + + +def _result_mapping(result: Any) -> Mapping[str, Any]: + return result if isinstance(result, Mapping) else {} + + +def control_event_from_tool( + tool_name: str, + arguments: Mapping[str, Any], + result: Any, + *, + turn_id: int, +) -> ControlEvent | None: + """Build a control event from a config tool result.""" + payload = _result_mapping(result) + key = _string_value(payload.get("key") or arguments.get("key")) + if not key and tool_name != "config_list": + return None + if tool_name == "config_set": + value = _string_value(payload.get("value")) if "value" in payload else "" + else: + value = _string_value(payload.get("value") if "value" in payload else arguments.get("value")) + action = "list" if tool_name == "config_list" else ("set" if tool_name == "config_set" else "get") + if key: + summary = f"{key} -> {value}" if value else f"{key} {action}" + else: + summary = "config catalog listed" + return ControlEvent( + action=action, + key=key, + value=value, + tool_name=tool_name, + turn_id=turn_id, + user_visible_summary=summary, + ) + + +def focus_entity_from_tool( + tool_name: str, + arguments: Mapping[str, Any], + result: Any, + *, + turn_id: int, +) -> FocusEntity | None: + """Infer a task focus entity from structured evidence-producing tools.""" + payload = _result_mapping(result) + name = _entity_name(tool_name, arguments, payload) + if not name: + return None + kind = _entity_kind(tool_name, arguments, payload) + ref = _evidence_ref(tool_name, arguments, payload) + entity_id = _stable_id(kind, name.lower()) + return FocusEntity( + entity_id=entity_id, + kind=kind, + canonical_name=name[:200], + plane=ContextPlane.TASK_SEMANTIC, + aliases=tuple(_aliases(name)), + evidence_refs=(ref,) if ref else (), + salience=1.0, + first_turn=turn_id, + last_task_turn=turn_id, + last_mentioned_turn=turn_id, + ) + + +def _entity_name(tool_name: str, arguments: Mapping[str, Any], payload: Mapping[str, Any]) -> str: + for key in ("title", "name", "document_title"): + value = _string_value(payload.get(key)) + if value: + return value + for key in ("url", "path", "file_path", "query"): + value = _string_value(arguments.get(key) or payload.get(key)) + if value: + return value + content = _string_value(payload.get("content") or payload.get("text") or payload.get("summary")) + if content: + return _first_meaningful_line(content) + if isinstance(payload, Mapping) and payload: + return _first_meaningful_line(json.dumps(payload, ensure_ascii=False, default=str)) + return _string_value(arguments.get("query")) + + +def _entity_kind(tool_name: str, arguments: Mapping[str, Any], payload: Mapping[str, Any]) -> str: + """Infer entity kind from URL/name using the pattern registry.""" + source = " ".join( + _string_value(value) + for value in ( + arguments.get("url"), arguments.get("path"), arguments.get("file_path"), payload.get("url"), + ) + if value + ).lower() + for rule in _KIND_PATTERNS: + if rule.match_type == "substring" and rule.pattern in source: + return rule.kind + if rule.match_type == "suffix" and source.endswith(rule.pattern): + return rule.kind + # Fallback heuristics by tool name + if tool_name == "file_read": + return "file" + if tool_name in {"code_search", "text_search"}: + return "search_result" + return "document" + + +def _evidence_ref(tool_name: str, arguments: Mapping[str, Any], payload: Mapping[str, Any]) -> str: + for key in ("url", "path", "file_path", "query"): + value = _string_value(arguments.get(key) or payload.get(key)) + if value: + return f"{tool_name}:{value[:120]}" + return tool_name + + +def _first_meaningful_line(text: str) -> str: + for line in text.splitlines(): + stripped = line.strip(" #\t") + if stripped: + return stripped[:200] + return "" + + +def _aliases(name: str) -> list[str]: + aliases = [name] + compact = re.sub(r"\s+", " ", name).strip() + if compact and compact != name: + aliases.append(compact) + return list(dict.fromkeys(aliases))[:4] + + +def _kind_matches(actual: str, requested: str) -> bool: + """Check if two kinds are in the same semantic group.""" + if not requested: + return True + if actual == requested: + return True + for group in _KIND_GROUPS.values(): + if actual in group and requested in group: + return True + return False + + +__all__ = [ + "ContextPlane", + "ControlEvent", + "FocusEntity", + "KindPattern", + "ReferenceResolution", + "SessionFocusState", + "control_event_from_tool", + "focus_entity_from_tool", +] diff --git a/src/leapflow/engine/engine.py b/src/leapflow/engine/engine.py index 939f212..fac4a2a 100644 --- a/src/leapflow/engine/engine.py +++ b/src/leapflow/engine/engine.py @@ -35,6 +35,8 @@ PromptAssemblyPlan, build_capability_manifests, ) +from leapflow.engine.context_focus import ContextPlane, ReferenceResolution, SessionFocusState +from leapflow.engine.reference_resolver import ReferenceResolver from leapflow.engine.error_classifier import ( ErrorCategory, ErrorClassifier, @@ -1203,6 +1205,9 @@ def __init__( self._last_disclosure_metadata: dict[str, Any] = {} self._current_task_contract: TaskContract | None = None self._disclosure_planner = DisclosurePlanner() + self._focus_state = SessionFocusState() + self._reference_resolver = ReferenceResolver() + self._last_reference_resolution: ReferenceResolution | None = None # Tier 1 structural continuity gate: capability categories used by native # tool_calls in the most recently completed turn. Working memory only # stores a synthetic "[Called: ...]" summary (no structured tool_calls), @@ -1915,6 +1920,82 @@ def _ensure_task_contract_message(self, messages: List[Dict[str, Any]]) -> List[ return prepared return [build_system_message(block), *prepared] + def _semantic_focus_context(self, user_text: str) -> str: + """Return the structured focus block for prompt assembly. + + This is separate from DisclosurePlanner: tool-schema disclosure remains + driven only by structural gates, while this block describes the session's + current semantic focus and recent control-plane events. + """ + resolution = self._reference_resolver.resolve(user_text, self._focus_state) + self._last_reference_resolution = resolution + visible_resolution = resolution if (resolution.target_id or resolution.needs_clarification) else None + return self._focus_state.render_prompt_context(visible_resolution) + + def _focus_turn_id(self) -> int: + """Return a stable monotonic turn id for focus observations.""" + try: + return int(self._session_turn_count) + except (TypeError, ValueError): + return 0 + + def _record_tool_focus( + self, + tool_name: str, + arguments: Dict[str, Any] | None, + result: Any, + ) -> None: + """Record semantic focus/control-plane state from a completed tool.""" + try: + self._focus_state.record_tool_result( + tool_name, + arguments or {}, + result, + turn_id=self._focus_turn_id(), + ) + except (TypeError, ValueError, RuntimeError): + logger.debug("semantic focus update failed for tool %s", tool_name, exc_info=True) + + def _tool_focus_metadata( + self, + tool_name: str, + arguments: Dict[str, Any] | None, + result: Any, + ) -> Dict[str, Any]: + """Return compact metadata describing a tool result's context plane.""" + name = str(tool_name or "").removeprefix("gp_") + if name.startswith("config_"): + metadata: Dict[str, Any] = {"context_plane": ContextPlane.CONTROL_PLANE.value} + if isinstance(result, dict): + key = str(result.get("key") or (arguments or {}).get("key") or "") + if key: + metadata["control_event_key"] = key + return metadata + if name in {"file_read", "web_fetch", "code_search", "text_search", "memory_search"}: + return {"context_plane": ContextPlane.TOOL_EVIDENCE.value} + return {} + + def _tool_execution_metadata_with_focus( + self, + tool_name: str, + arguments: Dict[str, Any] | None, + result: Any, + ) -> Dict[str, Any]: + """Merge existing execution metadata with semantic-focus metadata.""" + metadata = self._tool_execution_metadata(result) + metadata.update(self._tool_focus_metadata(tool_name, arguments, result)) + return metadata + + def focus_view(self) -> dict[str, Any]: + """Return read-only semantic focus diagnostics for /orient and tests.""" + data = self._focus_state.summary() + data["last_reference_resolution"] = ( + self._last_reference_resolution.to_dict() + if self._last_reference_resolution is not None + else None + ) + return data + async def _assemble_unified_prompt( self, user_text: str, @@ -1952,6 +2033,10 @@ async def _assemble_unified_prompt( memory_context = await self._prefetch_and_freeze_memory(user_text) skill_section = self._build_skill_section(include_skills=plan.level != DisclosureLevel.CORE) app_connector_section = self._build_app_connector_section() + focus_context = self._semantic_focus_context(user_text) + memory_context = "\n\n".join( + part for part in (focus_context, memory_context) if part + ) system = UNIFIED_SYSTEM_TEMPLATE.format( tool_catalog=tool_catalog, app_connector_section=app_connector_section, @@ -1959,7 +2044,15 @@ async def _assemble_unified_prompt( memory_context=memory_context, ) system = self._append_task_contract_to_system(system) - self._last_disclosure_metadata = plan.metadata() + self._last_disclosure_metadata = { + **plan.metadata(), + "context_planes": [ContextPlane.TASK_SEMANTIC.value, ContextPlane.CONTROL_PLANE.value], + "reference_resolution": ( + self._last_reference_resolution.to_dict() + if self._last_reference_resolution is not None + else None + ), + } prior_turns = self._prior_turns_for_plan(plan) return _PromptAssembly(system=system, plan=plan, prior_turns=prior_turns) @@ -3276,6 +3369,7 @@ async def _run_agent_loop(self, frame: AgentLoopFrame) -> str: recovery.record_tool_failure() else: recovery.record_tool_success() + self._record_tool_focus(tool_name, tool_arguments, result) result_payload = self._compact_tool_result(tool_name, tool_arguments, result) result_text = _truncate_result_for_budget(result_payload, result_budget) messages.append(build_user_message_text( @@ -3284,7 +3378,7 @@ async def _run_agent_loop(self, frame: AgentLoopFrame) -> str: self._persist_message( session_id, "tool", result_text, tool_name=tool_name, tool_call_id=f"text-{budget.used}", - metadata=self._tool_execution_metadata(result), + metadata=self._tool_execution_metadata_with_focus(tool_name, tool_arguments, result), ) if _is_permission_hard_stop_payload(result): @@ -3641,6 +3735,11 @@ async def _unified_tool_loop_stream( if self._sanitizer: content = self._sanitizer.sanitize(content) + # Surface provider reasoning/thinking to TUI + thinking = getattr(resp, 'thinking_content', None) + if thinking and thinking.strip(): + yield StreamEvent(type="thinking", content=thinking.strip()) + # Length continuation for native tool path finish = getattr(resp, 'finish_reason', None) if finish in ("length", "max_tokens") and turn_recovery.try_length_continuation(): @@ -3651,6 +3750,10 @@ async def _unified_tool_loop_stream( native_calls = getattr(resp, "tool_calls", None) or [] if native_calls: + # Surface pre-tool-call reasoning to TUI as thinking + # (excluded from context to prevent repetition, but valuable for user visibility) + if content: + yield StreamEvent(type="thinking", content=content) # Preamble exclusion: content alongside tool_calls is ephemeral # reasoning — exclude from context to prevent final-answer repetition. assistant_msg: Dict[str, Any] = {"role": "assistant", "content": ""} @@ -3899,6 +4002,11 @@ async def _unified_tool_loop_stream( if self._sanitizer: content = self._sanitizer.sanitize(content) + # Surface provider reasoning/thinking to TUI + thinking = getattr(resp, 'thinking_content', None) + if thinking and thinking.strip(): + yield StreamEvent(type="thinking", content=thinking.strip()) + # Length continuation for non-stream path finish = getattr(resp, 'finish_reason', None) if finish in ("length", "max_tokens") and turn_recovery.try_length_continuation(): @@ -4003,6 +4111,7 @@ async def _unified_tool_loop_stream( else: turn_recovery.record_tool_success() + self._record_tool_focus(tool_name, tool_arguments, result) result_payload = self._compact_tool_result(tool_name, tool_arguments, result) result_text = _truncate_result_for_budget(result_payload, result_budget) messages.append(build_user_message_text( @@ -4011,7 +4120,7 @@ async def _unified_tool_loop_stream( self._persist_message( session_id, "tool", result_text, tool_name=tool_name, tool_call_id=f"text-{budget.used}", - metadata=self._tool_execution_metadata(result), + metadata=self._tool_execution_metadata_with_focus(tool_name, tool_arguments, result), ) if _is_permission_hard_stop_payload(result): @@ -4245,13 +4354,14 @@ async def _execute_tools_concurrent( action=tool_call_dict, observation=result if isinstance(result, dict) else {"result": str(result)}, ) + self._record_tool_focus(normalized_name, tc.arguments, result) result_payload = self._compact_tool_result(normalized_name, tc.arguments, result) 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, tool_name=normalized_name, tool_call_id=str(tc.id), - metadata=self._tool_execution_metadata(result), + metadata=self._tool_execution_metadata_with_focus(normalized_name, tc.arguments, result), ) executed.append({ "id": tc.id, @@ -4339,11 +4449,12 @@ async def _run_one(ctc: ConcurrentToolCall) -> Dict[str, Any]: result_payload = self._compact_tool_result(ctc.name, ctc.arguments, result) result_text = _truncate_result_for_budget(result_payload, result_budget) effective_result = error_result if isinstance(result, Exception) else result + self._record_tool_focus(ctc.name, ctc.arguments, effective_result) messages.append({"role": "tool", "tool_call_id": ctc.id, "content": result_text}) self._persist_message( self._current_session_id, "tool", result_text, tool_name=ctc.name, tool_call_id=str(ctc.id), - metadata=self._tool_execution_metadata(effective_result), + metadata=self._tool_execution_metadata_with_focus(ctc.name, ctc.arguments, effective_result), ) executed.append({ "id": ctc.id, @@ -4389,11 +4500,12 @@ async def _run_one(ctc: ConcurrentToolCall) -> Dict[str, Any]: ) result_payload = self._compact_tool_result(ctc.name, ctc.arguments, result) result_text = _truncate_result_for_budget(result_payload, result_budget) + self._record_tool_focus(ctc.name, ctc.arguments, result) messages.append({"role": "tool", "tool_call_id": ctc.id, "content": result_text}) self._persist_message( self._current_session_id, "tool", result_text, tool_name=ctc.name, tool_call_id=str(ctc.id), - metadata=self._tool_execution_metadata(result), + metadata=self._tool_execution_metadata_with_focus(ctc.name, ctc.arguments, result), ) executed.append({ "id": ctc.id, @@ -4426,11 +4538,19 @@ def _tool_execution_context(self) -> Any | None: return None from leapflow.tools.execution_context import ToolExecutionContext + try: + from leapflow.tools.shell_tools import _approval_gate + orchestrator = _approval_gate + except Exception: # noqa: BLE001 + orchestrator = None + 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, + approval_bypass=getattr(self._settings, 'approval_bypass', False), + orchestrator=orchestrator, ) async def _execute_tool_scoped( diff --git a/src/leapflow/engine/error_classifier.py b/src/leapflow/engine/error_classifier.py index 6d764aa..512bbde 100644 --- a/src/leapflow/engine/error_classifier.py +++ b/src/leapflow/engine/error_classifier.py @@ -5,6 +5,7 @@ - Structured ClassifiedError with recovery hints - Provider-agnostic pattern matching - Config-driven recovery strategies (OCP) +- Data-driven classification via registry tables (no if-elif chains) """ from __future__ import annotations @@ -12,7 +13,7 @@ import random from dataclasses import dataclass, field from enum import Enum -from typing import Any, Dict, Optional +from typing import Any, Callable, Dict, FrozenSet, List, Optional, Tuple logger = logging.getLogger(__name__) @@ -149,12 +150,268 @@ def build_recovery_map( ), } -_AUTH_KEYWORDS = ("api_key", "api key", "unauthorized", "forbidden", "401", "403") -_BILLING_KEYWORDS = ("insufficient_quota", "billing", "payment", "quota exceeded", "402") -_RATE_LIMIT_KEYWORDS = ("rate", "429", "too many", "throttl") -_OVERLOAD_KEYWORDS = ("overloaded", "503", "capacity", "server busy") -_CONTEXT_KEYWORDS = ("context", "token", "length", "maximum context", "max_tokens") -_CONTENT_POLICY_KEYWORDS = ("content_policy", "safety", "content filter", "moderation") + +# --------------------------------------------------------------------------- +# Data-Driven HTTP Status Code Classification +# --------------------------------------------------------------------------- + +# Exact status code -> category (no message refinement needed) +_STATUS_CODE_CLASSIFICATION: Dict[int, ErrorCategory] = { + 401: ErrorCategory.AUTH_PERMANENT, + 413: ErrorCategory.PAYLOAD_TOO_LARGE, + 503: ErrorCategory.OVERLOADED, + 504: ErrorCategory.TRANSIENT, +} + +# Status codes that require message-based refinement to determine category. +# Each entry maps: status -> list of (keywords_to_check, category_if_matched) +# with a final fallback category. +_StatusRefinement = Tuple[List[Tuple[Tuple[str, ...], ErrorCategory]], ErrorCategory] + +_STATUS_REFINEMENT: Dict[int, _StatusRefinement] = { + 403: ( + [ + (("billing", "quota", "payment"), ErrorCategory.BILLING), + ], + ErrorCategory.AUTH_PERMANENT, + ), + 402: ( + [ + (("try again", "resets at", "temporary"), ErrorCategory.RATE_LIMITED), + ], + ErrorCategory.BILLING, + ), + 404: ( + [ + (("model",), ErrorCategory.MODEL_NOT_FOUND), + ], + ErrorCategory.PERMANENT, + ), + 422: ( + [ + (("context", "token", "length", "maximum context", "max_tokens"), ErrorCategory.CONTEXT_OVERFLOW), + ], + ErrorCategory.FORMAT_ERROR, + ), + 429: ( + [ + (("overloaded", "capacity"), ErrorCategory.OVERLOADED), + ], + ErrorCategory.RATE_LIMITED, + ), + 500: ( + [ + (("context", "token", "length", "maximum context", "max_tokens"), ErrorCategory.CONTEXT_OVERFLOW), + ], + ErrorCategory.TRANSIENT, + ), + 502: ( + [ + (("context", "token", "length", "maximum context", "max_tokens"), ErrorCategory.CONTEXT_OVERFLOW), + ], + ErrorCategory.TRANSIENT, + ), +} + +# Fallback ranges for codes not in the exact or refinement maps. +_STATUS_RANGE_CLASSIFICATION: List[Tuple[range, _StatusRefinement]] = [ + # 4xx fallback with message-based refinement + (range(400, 500), ( + [ + (("content_policy", "safety", "blocked"), ErrorCategory.CONTENT_BLOCKED), + (("image",), ErrorCategory.IMAGE_TOO_LARGE), # refined further below + ], + ErrorCategory.FORMAT_ERROR, + )), + # 5xx fallback + (range(500, 600), ( + [], + ErrorCategory.TRANSIENT, + )), +] + + +# --------------------------------------------------------------------------- +# Data-Driven Message Classification Rules +# --------------------------------------------------------------------------- + +@dataclass(frozen=True) +class MessageClassificationRule: + """A pattern-based rule for classifying error messages. + + Rules are evaluated in registration order; the first match wins. + """ + category: ErrorCategory + keywords: FrozenSet[str] + description: str = "" + # Optional predicate for compound conditions that cannot be expressed + # as simple keyword presence (e.g. require two keywords simultaneously). + predicate: Optional[Callable[[str], bool]] = field(default=None, compare=False) + + +# Ordered rule table — first match wins. +_MESSAGE_RULES: List[MessageClassificationRule] = [ + # SSL/TLS (must precede transient to avoid "connection" match) + MessageClassificationRule( + category=ErrorCategory.SSL_ERROR, + keywords=frozenset({"ssl", "certificate"}), + description="SSL/TLS certificate or handshake failures", + predicate=lambda msg: ("ssl" in msg or "certificate" in msg) and ("verify" in msg or "expired" in msg), + ), + # Transient: timeout/connection (check early to catch network issues) + MessageClassificationRule( + category=ErrorCategory.TRANSIENT, + keywords=frozenset({"timeout", "timed out", "connection"}), + description="Transient network/timeout errors", + ), + # Rate limiting + MessageClassificationRule( + category=ErrorCategory.RATE_LIMITED, + keywords=frozenset({"rate", "429", "too many", "throttl"}), + description="Rate limiting / throttling", + ), + # Overloaded + MessageClassificationRule( + category=ErrorCategory.OVERLOADED, + keywords=frozenset({"overloaded", "503", "capacity", "server busy"}), + description="Server overload / capacity", + ), + # Context overflow + MessageClassificationRule( + category=ErrorCategory.CONTEXT_OVERFLOW, + keywords=frozenset({"context", "token", "length", "maximum context", "max_tokens"}), + description="Context window overflow", + ), + # Billing + MessageClassificationRule( + category=ErrorCategory.BILLING, + keywords=frozenset({"insufficient_quota", "billing", "payment", "quota exceeded", "402"}), + description="Billing / quota failures", + ), + # Auth (recoverable — credential rotation may help) + MessageClassificationRule( + category=ErrorCategory.AUTH_ERROR, + keywords=frozenset({"api_key", "api key", "unauthorized", "forbidden", "401", "403"}), + description="Authentication / authorization errors", + ), + # Content policy + MessageClassificationRule( + category=ErrorCategory.CONTENT_BLOCKED, + keywords=frozenset({"content_policy", "safety", "content filter", "moderation"}), + description="Content policy violations", + ), + # Format / parse (explicit keywords only) + MessageClassificationRule( + category=ErrorCategory.FORMAT_ERROR, + keywords=frozenset({"format", "json", "parse"}), + description="Format / JSON parse errors", + ), + # Model not found (compound predicate) + MessageClassificationRule( + category=ErrorCategory.MODEL_NOT_FOUND, + keywords=frozenset({"model"}), + description="Model not found", + predicate=lambda msg: "model" in msg and ("not found" in msg or "does not exist" in msg), + ), +] + +# Tool error classification rules (used by classify_tool_error) +_TOOL_ERROR_RULES: List[MessageClassificationRule] = [ + MessageClassificationRule( + category=ErrorCategory.TOOL_FAILURE, + keywords=frozenset({"permission", "access denied"}), + description="Tool permission failures", + ), + MessageClassificationRule( + category=ErrorCategory.TRANSIENT, + keywords=frozenset({"timeout", "timed out"}), + description="Tool timeout / transient failures", + ), + MessageClassificationRule( + category=ErrorCategory.TOOL_FAILURE, + keywords=frozenset({"not found"}), + description="Tool resource not found", + ), + MessageClassificationRule( + category=ErrorCategory.RATE_LIMITED, + keywords=frozenset({"rate", "throttl"}), + description="Tool rate limiting", + ), +] + + +def register_message_rule(rule: MessageClassificationRule, *, priority: int = -1) -> None: + """Register a custom classification rule. + + Args: + rule: The classification rule to register. + priority: Index at which to insert. -1 appends before the final fallback. + """ + if priority < 0 or priority >= len(_MESSAGE_RULES): + _MESSAGE_RULES.append(rule) + else: + _MESSAGE_RULES.insert(priority, rule) + + +def register_status_code(status_code: int, category: ErrorCategory) -> None: + """Register or override a status code -> category mapping.""" + _STATUS_CODE_CLASSIFICATION[status_code] = category + + +# --------------------------------------------------------------------------- +# Classification Functions +# --------------------------------------------------------------------------- + +def _classify_by_status(status: int, msg: str) -> Optional[ErrorCategory]: + """Disambiguate errors by HTTP status + message content. + + Uses data-driven tables for lookup: exact match -> refinement rules -> range fallback. + """ + # 1. Exact match (no refinement needed) + if status in _STATUS_CODE_CLASSIFICATION: + return _STATUS_CODE_CLASSIFICATION[status] + + # 2. Status codes requiring message refinement + if status in _STATUS_REFINEMENT: + refinements, fallback = _STATUS_REFINEMENT[status] + for keywords, category in refinements: + if any(kw in msg for kw in keywords): + return category + return fallback + + # 3. Range-based fallback with optional refinement + for code_range, (refinements, fallback) in _STATUS_RANGE_CLASSIFICATION: + if status in code_range: + for keywords, category in refinements: + if category == ErrorCategory.IMAGE_TOO_LARGE: + # Compound check: "image" AND ("large" or "size") + if "image" in msg and ("large" in msg or "size" in msg): + return category + elif any(kw in msg for kw in keywords): + return category + return fallback + + return None + + +def _classify_by_message(msg: str) -> ErrorCategory: + """Classify by pattern rules in error message. First matching rule wins.""" + for rule in _MESSAGE_RULES: + if rule.predicate is not None: + if rule.predicate(msg): + return rule.category + elif any(kw in msg for kw in rule.keywords): + return rule.category + return ErrorCategory.PERMANENT + + +def _classify_tool_error_by_message(error: str) -> ErrorCategory: + """Classify a tool error message using the tool error rule table.""" + lower = error.lower() + for rule in _TOOL_ERROR_RULES: + if any(kw in lower for kw in rule.keywords): + return rule.category + return ErrorCategory.TOOL_FAILURE class ErrorClassifier: @@ -162,7 +419,7 @@ class ErrorClassifier: Classification pipeline (priority order): 1. HTTP status code + message refinement - 2. Known keyword patterns + 2. Known keyword patterns (data-driven rule table) 3. SSL/transport errors 4. Fallback to PERMANENT """ @@ -178,11 +435,11 @@ def classify(self, exc: Exception) -> ErrorCategory: status = self._extract_status_code(exc) if status is not None: - category = self._classify_by_status(status, msg) + category = _classify_by_status(status, msg) if category is not None: return category - return self._classify_by_message(msg) + return _classify_by_message(msg) def classify_detailed(self, exc: Exception) -> ClassifiedError: """Classify with full context for advanced recovery logic.""" @@ -204,16 +461,8 @@ def classify_tool_error(self, observation: Dict[str, Any]) -> ErrorCategory: """Classify a tool execution error from observation dict.""" if observation.get("ok", True): return ErrorCategory.TRANSIENT - error = str(observation.get("error", "")).lower() - if "permission" in error or "access denied" in error: - return ErrorCategory.TOOL_FAILURE - if "timeout" in error or "timed out" in error: - return ErrorCategory.TRANSIENT - if "not found" in error: - return ErrorCategory.TOOL_FAILURE - if "rate" in error or "throttl" in error: - return ErrorCategory.RATE_LIMITED - return ErrorCategory.TOOL_FAILURE + error = str(observation.get("error", "")) + return _classify_tool_error_by_message(error) def get_recovery(self, category: ErrorCategory) -> RecoveryStrategy: return self._map.get(category, RecoveryStrategy()) @@ -246,88 +495,6 @@ def _extract_status_code(exc: Exception) -> Optional[int]: return code return None - @staticmethod - def _classify_by_status(status: int, msg: str) -> Optional[ErrorCategory]: - """Disambiguate errors by HTTP status + message content.""" - if status == 401: - return ErrorCategory.AUTH_PERMANENT - if status == 403: - if any(kw in msg for kw in ("billing", "quota", "payment")): - return ErrorCategory.BILLING - return ErrorCategory.AUTH_PERMANENT - if status == 402: - if any(kw in msg for kw in ("try again", "resets at", "temporary")): - return ErrorCategory.RATE_LIMITED - return ErrorCategory.BILLING - if status == 404: - if "model" in msg: - return ErrorCategory.MODEL_NOT_FOUND - return ErrorCategory.PERMANENT - if status == 413: - return ErrorCategory.PAYLOAD_TOO_LARGE - if status == 422: - if any(kw in msg for kw in _CONTEXT_KEYWORDS): - return ErrorCategory.CONTEXT_OVERFLOW - return ErrorCategory.FORMAT_ERROR - if status == 429: - if "overloaded" in msg or "capacity" in msg: - return ErrorCategory.OVERLOADED - return ErrorCategory.RATE_LIMITED - if status in (500, 502): - if any(kw in msg for kw in _CONTEXT_KEYWORDS): - return ErrorCategory.CONTEXT_OVERFLOW - return ErrorCategory.TRANSIENT - if status == 503: - return ErrorCategory.OVERLOADED - if status == 504: - return ErrorCategory.TRANSIENT - if 400 <= status < 500: - if any(kw in msg for kw in ("content_policy", "safety", "blocked")): - return ErrorCategory.CONTENT_BLOCKED - if "image" in msg and ("large" in msg or "size" in msg): - return ErrorCategory.IMAGE_TOO_LARGE - return ErrorCategory.FORMAT_ERROR - if status >= 500: - return ErrorCategory.TRANSIENT - return None - - @staticmethod - def _classify_by_message(msg: str) -> ErrorCategory: - """Classify by keyword patterns in error message.""" - if "ssl" in msg or "certificate" in msg: - if "verify" in msg or "expired" in msg: - return ErrorCategory.SSL_ERROR - return ErrorCategory.TRANSIENT - - if "timeout" in msg or "timed out" in msg or "connection" in msg: - return ErrorCategory.TRANSIENT - - if any(kw in msg for kw in _RATE_LIMIT_KEYWORDS): - return ErrorCategory.RATE_LIMITED - - if any(kw in msg for kw in _OVERLOAD_KEYWORDS): - return ErrorCategory.OVERLOADED - - if any(kw in msg for kw in _CONTEXT_KEYWORDS): - return ErrorCategory.CONTEXT_OVERFLOW - - if any(kw in msg for kw in _BILLING_KEYWORDS): - return ErrorCategory.BILLING - - if any(kw in msg for kw in _AUTH_KEYWORDS): - return ErrorCategory.AUTH_ERROR - - if any(kw in msg for kw in _CONTENT_POLICY_KEYWORDS): - return ErrorCategory.CONTENT_BLOCKED - - if "format" in msg or "json" in msg or "parse" in msg: - return ErrorCategory.FORMAT_ERROR - - if "model" in msg and ("not found" in msg or "does not exist" in msg): - return ErrorCategory.MODEL_NOT_FOUND - - return ErrorCategory.PERMANENT - def jittered_backoff(attempt: int, *, base: float = 1.0, cap: float = 60.0) -> float: """Decorrelated jitter backoff: random(0, min(cap, base * 2^attempt)).""" diff --git a/src/leapflow/engine/reference_resolver.py b/src/leapflow/engine/reference_resolver.py new file mode 100644 index 0000000..2c4af14 --- /dev/null +++ b/src/leapflow/engine/reference_resolver.py @@ -0,0 +1,98 @@ +"""Focus-state-driven reference resolution for session context assembly. + +This resolver does not parse user text for keywords, does not choose tools, +and does not route intents. It resolves potential deictic references ("the +above paper", "that thing I just set") purely from the structured +SessionFocusState — which entities are active, how recently they were +observed, and whether control-plane events are the only recent activity. + +Design rationale: keyword-driven intent routing (regex + if-else chains) +violates the LLM-native principle. Instead, the LLM itself understands +natural language; this module only provides structured context about what +the session focus is, so the LLM can ground its reasoning. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +from leapflow.engine.context_focus import ContextPlane, ReferenceResolution, SessionFocusState + + +@dataclass(frozen=True) +class ReferenceResolverConfig: + """Tunable confidence thresholds for focus-based resolution.""" + + single_entity_confidence: float = 0.92 + recency_fallback_confidence: float = 0.75 + no_entity_confidence: float = 0.0 + + +@dataclass(frozen=True) +class ReferenceResolver: + """Resolve deictic references using structured focus state only. + + Resolution is based entirely on SessionFocusState — no regex, no keyword + parsing, no if-else routing by user-text content. The strategy is: + + 1. If task-semantic entities exist in the focus stack, return the most + recent (highest confidence when only one exists). + 2. If no task entities but control-plane events exist, return the most + recent control event as a fallback. + 3. If nothing is in the focus state, return unresolved. + """ + + config: ReferenceResolverConfig = field(default_factory=ReferenceResolverConfig) + + def resolve(self, user_text: str, state: SessionFocusState) -> ReferenceResolution: + """Resolve a potential reference against the current focus state. + + Args: + user_text: The user's input (accepted for API compatibility but + not parsed for keywords). + state: The current session focus state containing structured + entity and control-event records. + + Returns: + A ReferenceResolution indicating the resolved target, confidence, + and reasoning. + """ + # Priority 1: task-semantic entities from the focus stack + candidates = state.task_focus_candidates() + if candidates: + focus = candidates[0] + if len(candidates) == 1: + return ReferenceResolution( + target_kind=focus.kind, + target_id=focus.entity_id, + target_name=focus.canonical_name, + plane=focus.plane, + confidence=self.config.single_entity_confidence, + reason="single active entity in focus state", + ) + return ReferenceResolution( + target_kind=focus.kind, + target_id=focus.entity_id, + target_name=focus.canonical_name, + plane=focus.plane, + confidence=self.config.recency_fallback_confidence, + reason="most recent entity from multiple candidates", + ) + + # Priority 2: control-plane events when no task entities exist + latest_control = state.latest_control_event() + if latest_control is not None: + return ReferenceResolution( + target_kind="config", + target_id=f"control:{latest_control.key}:{latest_control.turn_id}", + target_name=latest_control.value or latest_control.key, + plane=ContextPlane.CONTROL_PLANE, + confidence=self.config.recency_fallback_confidence, + reason="fallback to most recent control-plane event", + ) + + # Nothing in focus state + return ReferenceResolution.unresolved("no active entities in focus state") + + +__all__ = ["ReferenceResolver", "ReferenceResolverConfig"] diff --git a/src/leapflow/engine/unified_classifier.py b/src/leapflow/engine/unified_classifier.py index 027b57e..6d72a27 100644 --- a/src/leapflow/engine/unified_classifier.py +++ b/src/leapflow/engine/unified_classifier.py @@ -3,11 +3,16 @@ Provides a single classification entry point that produces FailureEnvelope instances from any error source. Wraps the existing ErrorClassifier for LLM errors and adds structured classification for tool results and system exceptions. + +Tool-result and system-error classification uses data-driven rule tables for +timeout/connection/network detection, mirroring the registry pattern in +error_classifier.py. """ from __future__ import annotations import logging -from typing import Any +from dataclasses import dataclass +from typing import Any, FrozenSet, List from leapflow.engine.error_classifier import ErrorCategory, ErrorClassifier from leapflow.engine.failure_envelope import ( @@ -95,6 +100,36 @@ def categories(self) -> list[str]: _PERMISSION_FAILURE_CODES = frozenset({"access_denied", "missing_scope", "platform_degraded"}) +# --------------------------------------------------------------------------- +# Data-driven tool/system error message classification rules +# --------------------------------------------------------------------------- + +@dataclass(frozen=True) +class ToolMessageRule: + """A keyword-based rule for classifying tool/system error messages.""" + category: str + keywords: FrozenSet[str] + description: str = "" + + +# Rules for classify_tool_result — timeout detection by substring +_TOOL_TIMEOUT_KEYWORDS: FrozenSet[str] = frozenset({"timeout", "timed out"}) + +# Rules for classify_system_error — ordered, first match wins +_SYSTEM_ERROR_RULES: List[ToolMessageRule] = [ + ToolMessageRule( + category="system_timeout", + keywords=frozenset({"timeout", "timed out"}), + description="Timeout in system call", + ), + ToolMessageRule( + category="system_network", + keywords=frozenset({"connection", "refused", "reset"}), + description="Network connectivity issues", + ), +] + + class UnifiedErrorClassifier: """Unified classification entry point producing FailureEnvelope from any error source. @@ -258,9 +293,9 @@ def classify_tool_result( context=FailureContext.from_dict_args(tool_name=tool_name), ) - # 3. Timeout detection + # 3. Timeout detection (data-driven keyword set) error_lower = error_msg.lower() - if "timeout" in error_lower or "timed out" in error_lower: + if any(kw in error_lower for kw in _TOOL_TIMEOUT_KEYWORDS): timeout_recoverability = ( Recoverability.AUTO_RETRY if execution_policy == "read_only" @@ -293,12 +328,16 @@ def classify_tool_result( ) def classify_system_error(self, exc: Exception) -> FailureEnvelope: - """Classify a system-level exception (resource, timeout, etc.).""" + """Classify a system-level exception (resource, timeout, etc.). + + Uses a combination of type-based dispatch (for Python exception types) + and data-driven keyword rules (for message-based classification). + """ msg = str(exc).lower() exc_type = type(exc).__name__ - # Timeout errors - if isinstance(exc, (TimeoutError,)) or "timeout" in msg or "timed out" in msg: + # Type-based dispatch (most specific first) + if isinstance(exc, TimeoutError): return FailureEnvelope.create( source=FailureSource.SYSTEM, category="system_timeout", @@ -309,7 +348,6 @@ def classify_system_error(self, exc: Exception) -> FailureEnvelope: side_effect_state=SideEffectState.NONE, ) - # Memory errors if isinstance(exc, MemoryError): return FailureEnvelope.create( source=FailureSource.SYSTEM, @@ -321,7 +359,6 @@ def classify_system_error(self, exc: Exception) -> FailureEnvelope: side_effect_state=SideEffectState.UNKNOWN, ) - # OS/IO errors if isinstance(exc, OSError): return FailureEnvelope.create( source=FailureSource.SYSTEM, @@ -333,17 +370,29 @@ def classify_system_error(self, exc: Exception) -> FailureEnvelope: side_effect_state=SideEffectState.UNKNOWN, ) - # Connection errors (subclass of OSError but check explicitly) - if "connection" in msg or "refused" in msg or "reset" in msg: - return FailureEnvelope.create( - source=FailureSource.SYSTEM, - category="system_network", - failure_class="connection_error", - failure_code="connection_failed", - message=str(exc)[:500], - recoverability=Recoverability.AUTO_RETRY, - side_effect_state=SideEffectState.NONE, - ) + # Data-driven message classification (keyword rule table) + for rule in _SYSTEM_ERROR_RULES: + if any(kw in msg for kw in rule.keywords): + if rule.category == "system_timeout": + return FailureEnvelope.create( + source=FailureSource.SYSTEM, + category=rule.category, + failure_class="timeout", + failure_code="system_timeout", + message=str(exc)[:500], + recoverability=Recoverability.AUTO_RETRY, + side_effect_state=SideEffectState.NONE, + ) + if rule.category == "system_network": + return FailureEnvelope.create( + source=FailureSource.SYSTEM, + category=rule.category, + failure_class="connection_error", + failure_code="connection_failed", + message=str(exc)[:500], + recoverability=Recoverability.AUTO_RETRY, + side_effect_state=SideEffectState.NONE, + ) # Generic system error return FailureEnvelope.create( diff --git a/src/leapflow/security/approval.py b/src/leapflow/security/approval.py index 1c1ab65..488b9bb 100644 --- a/src/leapflow/security/approval.py +++ b/src/leapflow/security/approval.py @@ -25,6 +25,7 @@ class ApprovalDecision(Enum): ALLOW = "allow" ALLOW_ONCE = "allow_once" ALLOW_SESSION = "allow_session" + ALLOW_ALL_SESSION = "allow_all_session" ALLOW_ALWAYS = "allow_always" DENY = "deny" DENY_ALWAYS = "deny_always" @@ -107,6 +108,7 @@ class SessionAwareGate: def __init__(self, delegate: ApprovalGate) -> None: self._delegate = delegate self._approved_categories: set[str] = set() + self._bypass_all: bool = False self._decision_log: list[dict[str, Any]] = [] async def check(self, command: str) -> bool: @@ -128,12 +130,21 @@ async def check(self, command: str) -> bool: async def request_approval( self, request: ApprovalRequest, ) -> ApprovalDecision: + # Session-wide bypass: auto-approve everything without prompting + if self._bypass_all: + self._log_decision(request, ApprovalDecision.ALLOW, auto=True) + return ApprovalDecision.ALLOW + grant_key = request.grant_key if grant_key in self._approved_categories: self._log_decision(request, ApprovalDecision.ALLOW, auto=True) return ApprovalDecision.ALLOW decision = await self._delegate.request_approval(request) + if decision == ApprovalDecision.ALLOW_ALL_SESSION: + self._bypass_all = True + self._log_decision(request, decision, session=True) + return ApprovalDecision.ALLOW if decision == ApprovalDecision.ALLOW_SESSION: self._approved_categories.add(grant_key) self._log_decision(request, ApprovalDecision.ALLOW_SESSION, session=True) diff --git a/src/leapflow/security/orchestrator.py b/src/leapflow/security/orchestrator.py index 809b172..f89b55f 100644 --- a/src/leapflow/security/orchestrator.py +++ b/src/leapflow/security/orchestrator.py @@ -218,7 +218,7 @@ def _denied( @staticmethod def _choices(allow_permanent: bool) -> tuple[str, ...]: - base = ["allow_once", "allow_session"] + base = ["allow_once", "allow_session", "allow_all_session"] if allow_permanent: base.append("allow_always") base.extend(["deny", "deny_always", "show_details"]) diff --git a/src/leapflow/security/policy.py b/src/leapflow/security/policy.py index 3f07f6e..d336ed6 100644 --- a/src/leapflow/security/policy.py +++ b/src/leapflow/security/policy.py @@ -36,21 +36,26 @@ def check(self, action: ActionDescriptor, risk: RiskAssessment) -> PolicyDecisio class ApprovalPolicyEngine: """Small policy engine: hardline deny, meaningful risk ask, safe allow.""" - def __init__(self, rules: list[ApprovalPolicyRule] | None = None) -> None: + def __init__(self, rules: list[ApprovalPolicyRule] | None = None, *, bypass: bool = False) -> None: self._rules = list(rules or []) + self._bypass = bypass def evaluate(self, action: ActionDescriptor, risk: RiskAssessment) -> PolicyDecision: - for rule in self._rules: - decision = rule.check(action, risk) - if decision is not None: - return decision - + # Hardline/CRITICAL always denied regardless of bypass if risk.hardline or risk.level == RiskLevel.CRITICAL: return PolicyDecision( verdict=PolicyVerdict.DENY, reason="; ".join(risk.reasons) or "hardline_block", allow_permanent=False, ) + # Bypass mode: auto-allow everything below CRITICAL + if self._bypass: + return PolicyDecision(verdict=PolicyVerdict.ALLOW, reason="bypass_mode") + # Normal rule-based evaluation + for rule in self._rules: + decision = rule.check(action, risk) + if decision is not None: + return decision if risk.level in {RiskLevel.HIGH, RiskLevel.MEDIUM} or risk.score >= 0.35: return PolicyDecision( verdict=PolicyVerdict.ASK, diff --git a/src/leapflow/tools/execution_context.py b/src/leapflow/tools/execution_context.py index be06653..78e9607 100644 --- a/src/leapflow/tools/execution_context.py +++ b/src/leapflow/tools/execution_context.py @@ -8,7 +8,7 @@ from __future__ import annotations import contextvars -from dataclasses import dataclass +from dataclasses import dataclass, field from pathlib import Path from typing import Any @@ -21,6 +21,8 @@ class ToolExecutionContext: allowed_roots: tuple[Path, ...] session_id: str = "" task_id: str = "" + approval_bypass: bool = False + orchestrator: Any = field(default=None, compare=False, hash=False, repr=False) @classmethod def from_strings( @@ -30,6 +32,8 @@ def from_strings( allowed_roots: tuple[str, ...] = (), session_id: str = "", task_id: str = "", + approval_bypass: bool = False, + orchestrator: Any = None, ) -> "ToolExecutionContext": root = Path(workspace_root).expanduser().resolve() roots = tuple(Path(item).expanduser().resolve() for item in allowed_roots if item) @@ -38,6 +42,8 @@ def from_strings( allowed_roots=roots or (root,), session_id=session_id, task_id=task_id, + approval_bypass=approval_bypass, + orchestrator=orchestrator, ) @@ -129,9 +135,8 @@ def leapflow_managed_hint(path: Path) -> str: def workspace_scope_error(path: Path, *, operation: str) -> dict[str, Any] | None: """Return a structured error when ``path`` escapes the active workspace. - The workspace boundary is a hard gate evaluated at the tool entry point: it - is deliberately not routed through ApprovalGate, so the message must not - imply that approving something will unblock it. + The workspace boundary is gated by the approval orchestrator: the caller + routes through _approve_workspace_escape when bypass is inactive. """ ctx = current_tool_context() if ctx is None or is_within_allowed_roots(path, ctx): @@ -142,8 +147,8 @@ def workspace_scope_error(path: Path, *, operation: str) -> dict[str, Any] | Non "error": ( f"{operation} path is outside the active workspace. " f"Resolved path: {path}; workspace root: {ctx.workspace_root}. " - "This boundary cannot be lifted by approval; work inside the workspace, " - "or ask the user to open a session in that directory." + hint + "Approval is required to access paths outside the workspace." + + hint ), "error_type": "outside_workspace", "retryable": False, diff --git a/src/leapflow/tools/registry_bootstrap.py b/src/leapflow/tools/registry_bootstrap.py index 888eca0..1226365 100644 --- a/src/leapflow/tools/registry_bootstrap.py +++ b/src/leapflow/tools/registry_bootstrap.py @@ -443,6 +443,7 @@ }, }, }, + "x_leapflow": {"category": "read", "plane": "task"}, }, { "type": "function", @@ -457,6 +458,7 @@ "required": ["name"], }, }, + "x_leapflow": {"category": "read", "plane": "task"}, }, # ── Memory tools (agent can actively search/add memory) ── { diff --git a/src/leapflow/tools/shell_tools.py b/src/leapflow/tools/shell_tools.py index 807cf6a..5a1bc40 100644 --- a/src/leapflow/tools/shell_tools.py +++ b/src/leapflow/tools/shell_tools.py @@ -30,6 +30,15 @@ logger = logging.getLogger(__name__) + +def _is_bypass_active() -> bool: + """Check if approval bypass mode is active for the current context.""" + ctx = current_tool_context() + if ctx is None: + return False + return getattr(ctx, 'approval_bypass', False) + + # Raw capture ceilings. These bound what the tool returns before the context # layers (evidence builder, result budget, trim) decide how much reaches the # model. Build and test logs routinely exceed 10K, and truncating there dropped @@ -147,6 +156,36 @@ async def _approve_command(command: str, cwd: str | None) -> tuple[bool, str]: return False, "Dangerous command requires approval (denied)" +async def _approve_workspace_escape(command: str, target_path: str, error_info: dict) -> tuple[bool, str]: + """Request user approval for a command that accesses paths outside workspace.""" + from leapflow.security.actions import ActionDescriptor + + ctx = current_tool_context() + orchestrator = getattr(ctx, 'orchestrator', None) if ctx else None + if orchestrator is None: + orchestrator = _approval_gate # module-level fallback + if orchestrator is None or not isinstance(orchestrator, ActionApprovalEvaluator): + return False, "No approval gate available" + + action = ActionDescriptor( + kind="shell.workspace_escape", + summary=f"Allow shell access to {target_path}?", + detail=f"shell_run wants to access path outside workspace: {target_path}", + effect="access_external", + resource=target_path, + metadata={"command": command, "error_info": error_info}, + ) + try: + result = await orchestrator.evaluate(action) + if getattr(result, "approved", False): + return True, "" + reason = str(getattr(result, "denial_message", "") or "User denied workspace escape") + return False, reason + except Exception: + logger.debug("workspace escape approval check failed", exc_info=True) + return False, "Workspace escape approval failed" + + def _expand_operand(token: str) -> str: """Return the inspectable path operand carried by a shell token. @@ -225,8 +264,7 @@ def _command_workspace_escape(command: str, cwd: Path | None = None) -> dict[str "error": ( "shell_run command references a path outside the active workspace. " f"Path: {resolved}; workspace root: {ctx.workspace_root}. " - "This boundary cannot be lifted by approval; work inside the workspace, " - "or ask the user to open a session in that directory." + "Approval is required to access paths outside the workspace." + leapflow_managed_hint(resolved) ), "error_type": "outside_workspace", @@ -261,12 +299,20 @@ async def shell_run(params: Dict[str, Any]) -> Dict[str, Any]: if cwd_path is not None: scope_error = workspace_scope_error(cwd_path, operation="shell_run cwd") - if scope_error: - return scope_error + if scope_error and not _is_bypass_active(): + approved, _ = await _approve_workspace_escape( + command, str(cwd_path), scope_error + ) + if not approved: + return scope_error command_scope_error = _command_workspace_escape(str(command), cwd=cwd_path) - if command_scope_error: - return command_scope_error + if command_scope_error and not _is_bypass_active(): + approved, _ = await _approve_workspace_escape( + command, command_scope_error.get("resolved_path", ""), command_scope_error + ) + if not approved: + return command_scope_error if _is_dangerous(command): approved, message = await _approve_command(command, cwd) diff --git a/tests/_fixtures/cassettes/r1_conversation/cassette-model-8a16792e03d455d4.cassette.json b/tests/_fixtures/cassettes/r1_conversation/cassette-model-8a16792e03d455d4.cassette.json new file mode 100644 index 0000000..0f5bd65 --- /dev/null +++ b/tests/_fixtures/cassettes/r1_conversation/cassette-model-8a16792e03d455d4.cassette.json @@ -0,0 +1,67 @@ +{ + "fingerprint": "8a16792e03d455d4eb21491dee30fcb9a118ec939bfeb9bd440396cae0c21f7d", + "note": "captured in seed mode", + "request": { + "model": "cassette-model", + "stream": false, + "messages": [ + { + "role": "system", + "content": "You are LeapFlow, an intelligent assistant that can both converse naturally and take real actions on the user's computer.\n\n## Capabilities\nThe tool index below lists **every** registered tool by name and a one-line summary — this is the complete\ncapability contract; nothing else exists. Only a subset is directly callable this turn (via native tool calling,\nnot a JSON block in your reply). If you need a tool from the index that is not yet callable, call\n`capability_expand` with its category name first — the matching tools become callable immediately after.\n- **file_list**(path, pattern, depth): List files and directories at a given path. Use depth=1 or depth=2 to get a recursive tree in one call instead of listing each sub-directory separately.\n- **file_read**(path, max_lines, start_line, max_chars, mode): Read text file content with adaptive context governance. For large or unfamiliar files, prefer mode='outline' or mode='symbols' first, then use mode='raw' with start_line/max_lines for the specific range you actually need. For LeapFlow's own settings, use config_list / config_get / config_set — its config files are outside the workspace and not readable here.\n- **file_write**(path, content, mode) [capability_expand category: write]: Write content to a file (overwrite or append).\n- **code_search**(pattern, patterns, path, glob, ignore_case, multiline, max_results, context_lines): Search file CONTENTS by regex across a directory tree (ripgrep-backed). Prefer this over shell_run grep: faster, skips VCS/dependency/build dirs, and returns structured path:line:column matches. Batch related lookups into ONE call via `patterns` (OR-combined, single pass) instead of issuing several separate searches. Use file_read for the surrounding context of a hit.\n- **file_find**(glob, path, max_results): Find files by a recursive glob pattern under a base path (e.g. '**/test_*.py' or '*.md'). Prefer this over shell_run find; skips VCS/dependency/build dirs.\n- **edit_file**(path, edits, dry_run, diff) [capability_expand category: file]: Apply targeted, anchored search-replace edits to an EXISTING text file (use file_write to create/overwrite). Each edit is {original_text, new_text, replace_all?}; original_text must match exactly and uniquely (or set replace_all) — a non-unique or missing anchor is rejected so files are never corrupted. Set dry_run to preview. Alternatively pass a unified 'diff' to apply its hunks as anchored edits. Far cheaper and safer than rewriting a whole file.\n- **code_intel**(path, operation): Precise document symbols (outline) for a source file: classes, functions, and methods with line ranges. Python uses an exact AST parse; other languages use a keyword-prefix scan. Prefer over file_read mode=symbols for accurate navigation before editing. Read-only.\n- **repo_map**(path): Compact project orientation for a repository root: languages, detected test/lint commands, top-level structure, entry points, manifest, and VCS branch. Call this first when entering an unfamiliar codebase. Read-only.\n- **shell_run**(command, cwd, timeout) [capability_expand category: shell]: Execute a one-shot shell command with timeout protection. Runs in the active workspace; paths resolving outside it are refused. Reach for a structured tool first when one fits — web_fetch for anything over HTTP(S), git_query/scm_sync for git, code_search/file_find/file_read for the repo, config_get/config_set for LeapFlow's own settings — because those report typed results, while a failed shell command can only be diagnosed from its exit code and stderr. Every shell run counts as an external side effect, so a failure stops the rest of the batch and is not retried automatically.\n- **scm_sync**(action, cwd, remote, pull_ref, push_ref, timeout) [capability_expand category: scm]: Run a typed git SCM action. Use this instead of shell_run for git pull/push/status. For 'pull origin main then push', set action='pull_then_push', remote='origin', pull_ref='main', and omit push_ref so LeapFlow pushes the current local branch.\n- **git_query**(action, cwd, ref, path, staged, max_count, stat): Read-only structured git inspection: action=diff|log|status|branch|show. Prefer over shell_run for reading repo state — output is clipped, redacted, and log/branch are parsed into structured fields. Use scm_sync for pull/push.\n- **git_write**(action, cwd, message, stage_all, name, ref, create) [capability_expand category: scm]: Mutating git actions: action=commit (message, stage_all), branch (create+switch), checkout (switch; create=true for -b). Approval-gated. Use scm_sync for pull/push and git_query for reads.\n- **time_get**(): Get current date and time.\n- **env_info**(): Get system environment information (OS, Python version, cwd).\n- **text_search**(text, pattern): Search for a regex pattern in text.\n- **text_replace**(text, old, new, count) [capability_expand category: write]: Replace occurrences of a substring in text.\n- **skills_list**(query, category, source): List available learned skills. Use when user asks about capabilities or you need a specific skill.\n- **skill_view**(name): View the full content of a specific skill document.\n- **memory_search**(query, limit): Search agent memory for relevant past experiences, observations, and facts.\n- **memory_add**(content, kind) [capability_expand category: write]: Store a new observation or insight in memory for future reference.\n- **research_note**(kind, text): Record a compact, structured note about the current task's state so it survives context compression on long / multi-step tasks. Use for durable findings, open questions still to resolve, decisions / excluded paths, and the immediate next step. One concise sentence per note.\n- **schedule_reentry**(kind, reason, delay_seconds, event_match, max_reentries, deadline_seconds): Register a re-entry so this task can resume later from its current orientation (findings / open questions / next step). Use when work must pause and continue after a delay (kind=time) or when a matching platform event arrives (kind=event), instead of finishing now. The research-ledger state is carried over automatically.\n- **capability_expand**(category): Fetch the full callable schema for every tool in a capability category. Current non-core categories that require expansion: config, delegate, file, gateway, hub, scm, shell, terminal, write. The compact tool index always lists every registered tool by name and a one-line summary tagged with its exact capability_expand category, but only a static low-risk subset is directly callable each turn. If you need a tool from the index that is not yet callable, call capability_expand with the exact category shown next to it; the matching tools become callable in this turn. Never invent a tool name — expand the category instead.\n- **delegate_task**(goal, context) [capability_expand category: delegate]: Delegate a complex sub-task to an isolated subagent. The subagent gets a fresh context and restricted tool access. Use when a task is self-contained and can be solved independently.\n- **test_run**(command, cwd, timeout): Run the project's test suite and return structured results (framework, passed/failed counts, failing tests). Auto-detects the runner (pytest/npm/go/cargo) or uses a configured/explicit command; executes via the governed shell. ok=true means the runner executed — see 'success' for pass/fail.\n- **lint_check**(command, cwd, timeout): Run the project's linter and return a structured clean/issue result. Auto-detects the linter (ruff/eslint/go vet/clippy) or uses a configured/explicit command; executes via the governed shell. ok=true means the linter ran — see 'clean'.\n- **terminal_open**(command, cwd, shell) [capability_expand category: terminal]: Open a PERSISTENT shell session (REPL/dev server/watch), returning a session_id for terminal_send/read/close. Disabled unless tools.terminal_session_enabled is set. For one-shot commands use shell_run instead.\n- **terminal_send**(session_id, input, wait) [capability_expand category: terminal]: Send a line of input to a persistent terminal session and return output captured shortly after.\n- **terminal_read**(session_id, wait): Drain buffered output from a persistent terminal session (optionally waiting briefly first).\n- **terminal_close**(session_id) [capability_expand category: terminal]: Terminate a persistent terminal session and release its process group.\n- **terminal_list**(): List active persistent terminal sessions.\n- **config_list**(category, limit): List LeapFlow's own writable settings (model, provider, daemon, memory, perception, gateway, …) with current values. Use this to discover the exact key before changing anything. Optionally narrow by `category`. This is the only correct way to inspect LeapFlow configuration — never read config files from disk.\n- **config_get**(key): Read one LeapFlow setting by key (e.g. 'llm.model', 'daemon.log_level'), returning its current value, type, scopes, and whether a change needs a daemon restart. Never read LeapFlow config files from disk — use this.\n- **config_set**(key, value, scope) [capability_expand category: config]: Change one LeapFlow setting by key, e.g. switch the model with key='llm.model'. Values are validated and coerced; credentials are stored in the vault automatically. Call config_list or config_get first if unsure of the key. The result states whether a `leap daemon restart` is required. Never edit LeapFlow config files directly.\n- **web_fetch**(url, select, timeout, max_bytes): Read a URL over HTTP(S) and get back extracted, context-sized content: parsed JSON for API endpoints, readable text plus links for web pages. Use this for anything on the internet — prices, docs, releases, articles — instead of running curl through shell_run: it reports real HTTP status codes, retries rate limits on its own, and is a plain read so a retry is always safe. For JSON APIs pass `select` with a dotted path (e.g. 'chart.result.0.meta') to return just that part instead of the whole payload.\n- **hub_push**(skill_name, visibility, version) [capability_expand category: hub]: Push a local skill to the ModelScope Hub for sharing or backup.\n- **hub_pull**(repo_id, version) [capability_expand category: hub]: Pull a skill from the ModelScope Hub to install locally.\n- **hub_search**(query) [capability_expand category: hub]: Search for skills on the Hub by keyword or description.\n- **hub_sync**(mode, dry_run) [capability_expand category: hub]: Preview or execute sync between local skills and Hub.\n- **platform_action**(platform, action, payload, backend_kind) [capability_expand category: gateway]: Execute an exact registered business action on an external platform through LeapFlow's App Connector layer. Actions must be copied from the App Connector Capability Index and are addressed as domain.operation, e.g. im.send_message or docs.create_markdown. All business fields (chat_id, text, query, etc.) MUST be placed inside `payload`, never at the top level. Example: {\"platform\":\"feishu\",\"action\":\"im.send_message\",\"payload\":{\"chat_id\":\"oc_xxx\",\"text\":\"hello\"}}. Do not invent action names, do not use management actions such as list/guide/connect/status here.\n- **platform_connect**(action, platform, credentials, options, checkpoint) [capability_expand category: gateway]: List, guide, connect, disconnect, remove, or check status for external platforms using the App Connector management namespace. Supports REST and CLI backends. Use this for management actions such as list/guide/preflight/connect/status; use platform_action only for exact registered business actions.\n- **gateway_send**(platform, chat_id, text, thread_id) [capability_expand category: gateway]: Send a message to a connected external platform (Feishu group, Telegram chat, DingTalk conversation, etc.). Requires the platform to be connected via gateway_connect first. Use gateway_connect with action='list' to see connected platforms and available chat IDs.\n- **gateway_connect**(action, platform, credentials, options) [capability_expand category: gateway]: Connect, configure, or manage external platform integrations (Feishu, DingTalk, Telegram, Slack, Discord, etc.). Conversational flow: 1) call 'guide' to get setup steps + required fields, 2) present the steps to the user and ask for ALL required credentials in a single message, 3) call 'connect' with the credentials. Goal: complete in 1–2 user turns. NEVER include credential values in your text response.\n- **session_search**(query, limit) [capability_expand category: unclassified]: Search past conversation sessions for relevant context.\n\n## App Connector Capability Index\nLeapFlow can onboard and manage external apps through `platform_connect` and execute exact registered business actions through `platform_action`.\nFor requests about connecting, setting up, configuring, enabling, or managing a supported app, use `platform_connect` first instead of generating SDK/Webhook sample code.\n`platform_connect.action` is the management namespace: list, guide, preflight, connect, disconnect, remove, status, events_start, events_stop, events_status.\n`platform_action.action` is only for exact registered platform business actions listed below, such as `im.send_message`; never use management actions like `list` or `guide` there.\nAll business fields MUST go inside `payload`; top-level keys are only `platform`, `action`, and `payload`.\nDo not invent platform IDs or platform action names. If the needed action is not listed, ask for discovery/clarification instead of guessing.\nUse `platform_connect` with `action='guide'` and the matching `platform` to start onboarding; use `action='list'` when the app is unclear.\nWhen a pending onboarding state is present, continue from that state with `platform_connect` instead of asking the user to restate the app.\nSupported apps:\n- `api_server`: API Server (OpenAI Compatible) (category=api; backend=adapter; platform_action actions=none registered)\n- `dingtalk`: 钉钉 (DingTalk) (category=im; backend=adapter; platform_action actions=none registered)\n- `feishu`: 飞书 (Feishu/Lark) (category=im; backend=cli)\n - `calendar.create_event` payload={summary*, start_time*, end_time*, attendees} [write/high]\n - `docs.create_markdown` payload={title*, markdown*, folder_token} [write/medium]\n - `drive.search` payload={query*, limit} [read/medium]\n - `im.add_reaction` payload={message_id*, emoji_type*} [write/low]\n - `im.download_resource` payload={message_id*, file_key*, type*} [read/low]\n - `im.get_messages` payload={message_ids*} [read/low]\n - `im.list_chats` payload={page_size} [read/low]\n - `im.list_messages` payload={chat_id*} [read/low]\n - `im.list_thread_messages` payload={thread*} [read/low]\n - `im.remove_reaction` payload={message_id*, emoji_type*} [write/low]\n - `im.reply_message` payload={message_id*, text*} [send/high]\n - `im.search_chats` payload={query*, page_size} [read/low]\n - `im.search_messages` payload={query*} [read/medium]\n - `im.send_message` payload={chat_id*, text*, thread_id} [send/high]\n - `im.update_card` payload={token*, card*} [write/medium]\n - `im.update_message` payload={message_id*, text*} [write/medium]\n - `mail.search_unread` payload={query, limit} [read/high]\n - `sheets.append_row` payload={spreadsheet_token*, sheet_id*, values*} [write/medium]\n - `task.create` payload={title*, description, due_time} [write/medium]\n- `telegram`: Telegram (category=social; backend=adapter; platform_action actions=none registered)\n- `webhook`: Webhook (Generic) (category=webhook; backend=adapter; platform_action actions=none registered)\n\n## Tool Usage\nTools are normally invoked through the native function-calling mechanism, not by writing JSON in your reply\ntext. Only if the provider signals that native function calling is unavailable for this turn, fall back to a\nsingle JSON code block: `{\"name\": \"tool_name\", \"arguments\": {\"key\": \"value\"}}` — use this fallback format\nonly, never both. Only call a tool whose exact name appears in the tool index above and is currently callable\n(or reachable via `capability_expand`). Never invent, rename, alias, or guess a tool name, platform ID, or\nplatform action from argument shape or wording — if the index does not list it, it does not exist.\n`platform_connect.action` (list/guide/preflight/connect/disconnect/remove/status/events_start/events_stop/\nevents_status) is the App Connector management namespace; `platform_action.action` only accepts exact\nregistered business actions such as `im.send_message` shown in the App Connector Capability Index — never mix\nthe two namespaces. If a tool call returns an unknown/unavailable result, use the returned suggestions or\navailable names for a single retry instead of trying further variations of the same guess.\n\n**Side-effect action rule** (`platform_action` with effect=send/write/execute):\n- Call each unique action+payload **exactly once**. Never include duplicates in the same turn.\n- Once the result returns `\"completed\": true`, that action is DONE for this task. Do NOT call it again\n in any subsequent turn — immediately summarize the result for the user instead.\n- The system enforces idempotency: duplicate calls are blocked and will not execute.\n- If the user explicitly requests sending/writing multiple times, use distinct payloads per call.\n\n**Resource identifier provenance rule**:\n- NEVER fabricate, guess, or infer resource identifiers (chat_id, message_id, file_key, user_id, etc.).\n Every resource ID used in a side-effect action MUST come from a successful API response in this session.\n- If a read/list action fails (e.g. authorization error), you do NOT have valid resource IDs.\n Report the failure to the user — do NOT attempt the dependent write/send action with a guessed ID.\n- When a tool result contains `\"llm_instruction\"`, follow it exactly.\n\n## Guidelines\n1. **Direct answers first**: If you already know the answer, respond directly without tools.\n2. **Avoid redundant tool calls**: Do not call the same tool with the same arguments more than once in the same user turn. When an existing tool result already answers the user's request, stop calling tools and answer directly.\n3. **Use tools proactively**: When the user asks about files, time, system state, or needs actions performed, use the appropriate tool.\n4. **Chain tools when needed**: You can call multiple tools in sequence (e.g., list files → read file → summarize).\n5. **Handle failures gracefully**: If a tool fails, explain what went wrong and suggest alternatives. If it failed because of an unknown tool/platform/action name, retry once with an exact name from the returned suggestions, then explain rather than keep guessing.\n6. **Summarize results naturally**: After tool execution, synthesize the results into a helpful answer rather than dumping raw output.\n7. **Stay conversational**: Maintain a natural, helpful tone. Acknowledge context from earlier in the conversation.\n\n## Coding & Verification\nWhen working with code, prefer the precise built-in tools over ad-hoc shell: use `repo_map` to orient in an\nunfamiliar project, `code_search`/`file_find` to locate and `code_intel` for symbols, `edit_file` (anchored\nsearch-replace, or a unified `diff`) to change files — never rewrite a whole file to change a few lines — and\n`git_query` to inspect diffs/log. After edits, check `syntax_ok` in the result and run `test_run`/`lint_check`\nbefore declaring the work done. Batch independent read-only calls (search/read/query) into a single turn — they\nrun in parallel.\n\n## Presentation Style\n1. **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.\n2. **Terminal-friendly layout**: Keep lines readable in a TUI; avoid dense walls of text, deeply nested lists, oversized ASCII art, or heavy visual blocks.\n3. **Elegant emphasis**: Use bold text sparingly for key terms and conclusions. Avoid excessive emojis, decorative symbols, repeated separators, or visual noise.\n4. **Theme-safe colors**: Do not emit ANSI escape codes, HTML color tags, Rich markup, or hardcoded color names. Rely on the TUI theme to render Markdown professionally.\n5. **No leaked tool protocol**: Never show tool-call JSON, internal schemas, raw observations, tool result payloads, or hidden reasoning in the final answer unless the user explicitly asks for raw/debug output. Treat any prior `{\"name\": ..., \"arguments\": ...}` blocks and `Tool result (...)` messages as internal execution context only.\n6. **Professional closure**: End with a concise conclusion or next step when helpful; avoid rambling after the useful answer is complete.\n\nWhen finished with all tool calls, respond normally without a JSON block, tool-call transcript, or process log.\n\n## Semantic Focus Plane\nCurrent task focus:\n- Target: invoice.txt\n- Type: file\n- Evidence refs: file_read:invoice.txt\nResolved user reference:\n- file -> invoice.txt (task_semantic, confidence=0.92)\nUse Current task focus for deictic task references such as 'the above paper'; use control-plane events only when the user explicitly asks about runtime settings.\n\n## Task Contract\n- Task ID: turn-3\n- Original user request: Is that the same invoice?\n- Workspace root: \n- Allowed roots: \n- Treat relative project paths as relative to the workspace root; never infer `.` as the project root when a workspace root is provided.\n- 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.\n- LeapFlow workspace config is optional at `/.leapflow/config.yaml`; runtime config is loaded from `~/.leapflow/config/user.yaml` and `~/.leapflow/profiles//config/*.yaml`.\n- Preserve this task contract across summarization, compression, tool loops, and memory retrieval.\n" + }, + { + "role": "user", + "content": "Say hello." + }, + { + "role": "assistant", + "content": "Hello from LeapFlow." + }, + { + "role": "user", + "content": "Use the file_read tool on invoice.txt and report the total." + }, + { + "role": "assistant", + "content": "[Called: file_read]\nThe invoice total is 128.50 USD." + }, + { + "role": "user", + "content": "Is that the same invoice?\nIs that the same invoice?" + } + ], + "tools": [ + "capability_expand", + "code_intel", + "code_search", + "config_get", + "config_list", + "edit_file", + "env_info", + "file_find", + "file_list", + "file_read", + "git_query", + "lint_check", + "memory_search", + "repo_map", + "research_note", + "schedule_reentry", + "skill_view", + "skills_list", + "terminal_list", + "terminal_read", + "test_run", + "text_search", + "time_get", + "web_fetch" + ] + }, + "responses": [ + { + "status": 200, + "content_type": "application/json", + "body": "{\"id\": \"chatcmpl-cassette\", \"object\": \"chat.completion\", \"model\": \"cassette-model\", \"choices\": [{\"index\": 0, \"message\": {\"role\": \"assistant\", \"content\": \"Yes, that is the same invoice.\"}, \"finish_reason\": \"stop\"}], \"usage\": {\"prompt_tokens\": 64, \"completion_tokens\": 16, \"total_tokens\": 80}}" + } + ] +} diff --git a/tests/_fixtures/cassettes/r1_conversation/cassette-model-c223eb3fe510faa7.cassette.json b/tests/_fixtures/cassettes/r1_conversation/cassette-model-c223eb3fe510faa7.cassette.json new file mode 100644 index 0000000..fece558 --- /dev/null +++ b/tests/_fixtures/cassettes/r1_conversation/cassette-model-c223eb3fe510faa7.cassette.json @@ -0,0 +1,67 @@ +{ + "fingerprint": "c223eb3fe510faa7b5dd3b20779a15337c3332c58c5b53cc1367698a36d01e02", + "note": "captured in seed mode", + "request": { + "model": "cassette-model", + "stream": false, + "messages": [ + { + "role": "system", + "content": "You are LeapFlow, an intelligent assistant that can both converse naturally and take real actions on the user's computer.\n\n## Capabilities\nThe tool index below lists **every** registered tool by name and a one-line summary — this is the complete\ncapability contract; nothing else exists. Only a subset is directly callable this turn (via native tool calling,\nnot a JSON block in your reply). If you need a tool from the index that is not yet callable, call\n`capability_expand` with its category name first — the matching tools become callable immediately after.\n- **file_list**(path, pattern, depth): List files and directories at a given path. Use depth=1 or depth=2 to get a recursive tree in one call instead of listing each sub-directory separately.\n- **file_read**(path, max_lines, start_line, max_chars, mode): Read text file content with adaptive context governance. For large or unfamiliar files, prefer mode='outline' or mode='symbols' first, then use mode='raw' with start_line/max_lines for the specific range you actually need. For LeapFlow's own settings, use config_list / config_get / config_set — its config files are outside the workspace and not readable here.\n- **file_write**(path, content, mode) [capability_expand category: write]: Write content to a file (overwrite or append).\n- **code_search**(pattern, patterns, path, glob, ignore_case, multiline, max_results, context_lines): Search file CONTENTS by regex across a directory tree (ripgrep-backed). Prefer this over shell_run grep: faster, skips VCS/dependency/build dirs, and returns structured path:line:column matches. Batch related lookups into ONE call via `patterns` (OR-combined, single pass) instead of issuing several separate searches. Use file_read for the surrounding context of a hit.\n- **file_find**(glob, path, max_results): Find files by a recursive glob pattern under a base path (e.g. '**/test_*.py' or '*.md'). Prefer this over shell_run find; skips VCS/dependency/build dirs.\n- **edit_file**(path, edits, dry_run, diff) [capability_expand category: file]: Apply targeted, anchored search-replace edits to an EXISTING text file (use file_write to create/overwrite). Each edit is {original_text, new_text, replace_all?}; original_text must match exactly and uniquely (or set replace_all) — a non-unique or missing anchor is rejected so files are never corrupted. Set dry_run to preview. Alternatively pass a unified 'diff' to apply its hunks as anchored edits. Far cheaper and safer than rewriting a whole file.\n- **code_intel**(path, operation): Precise document symbols (outline) for a source file: classes, functions, and methods with line ranges. Python uses an exact AST parse; other languages use a keyword-prefix scan. Prefer over file_read mode=symbols for accurate navigation before editing. Read-only.\n- **repo_map**(path): Compact project orientation for a repository root: languages, detected test/lint commands, top-level structure, entry points, manifest, and VCS branch. Call this first when entering an unfamiliar codebase. Read-only.\n- **shell_run**(command, cwd, timeout) [capability_expand category: shell]: Execute a one-shot shell command with timeout protection. Runs in the active workspace; paths resolving outside it are refused. Reach for a structured tool first when one fits — web_fetch for anything over HTTP(S), git_query/scm_sync for git, code_search/file_find/file_read for the repo, config_get/config_set for LeapFlow's own settings — because those report typed results, while a failed shell command can only be diagnosed from its exit code and stderr. Every shell run counts as an external side effect, so a failure stops the rest of the batch and is not retried automatically.\n- **scm_sync**(action, cwd, remote, pull_ref, push_ref, timeout) [capability_expand category: scm]: Run a typed git SCM action. Use this instead of shell_run for git pull/push/status. For 'pull origin main then push', set action='pull_then_push', remote='origin', pull_ref='main', and omit push_ref so LeapFlow pushes the current local branch.\n- **git_query**(action, cwd, ref, path, staged, max_count, stat): Read-only structured git inspection: action=diff|log|status|branch|show. Prefer over shell_run for reading repo state — output is clipped, redacted, and log/branch are parsed into structured fields. Use scm_sync for pull/push.\n- **git_write**(action, cwd, message, stage_all, name, ref, create) [capability_expand category: scm]: Mutating git actions: action=commit (message, stage_all), branch (create+switch), checkout (switch; create=true for -b). Approval-gated. Use scm_sync for pull/push and git_query for reads.\n- **time_get**(): Get current date and time.\n- **env_info**(): Get system environment information (OS, Python version, cwd).\n- **text_search**(text, pattern): Search for a regex pattern in text.\n- **text_replace**(text, old, new, count) [capability_expand category: write]: Replace occurrences of a substring in text.\n- **skills_list**(query, category, source): List available learned skills. Use when user asks about capabilities or you need a specific skill.\n- **skill_view**(name): View the full content of a specific skill document.\n- **memory_search**(query, limit): Search agent memory for relevant past experiences, observations, and facts.\n- **memory_add**(content, kind) [capability_expand category: write]: Store a new observation or insight in memory for future reference.\n- **research_note**(kind, text): Record a compact, structured note about the current task's state so it survives context compression on long / multi-step tasks. Use for durable findings, open questions still to resolve, decisions / excluded paths, and the immediate next step. One concise sentence per note.\n- **schedule_reentry**(kind, reason, delay_seconds, event_match, max_reentries, deadline_seconds): Register a re-entry so this task can resume later from its current orientation (findings / open questions / next step). Use when work must pause and continue after a delay (kind=time) or when a matching platform event arrives (kind=event), instead of finishing now. The research-ledger state is carried over automatically.\n- **capability_expand**(category): Fetch the full callable schema for every tool in a capability category. Current non-core categories that require expansion: config, delegate, file, gateway, hub, scm, shell, terminal, write. The compact tool index always lists every registered tool by name and a one-line summary tagged with its exact capability_expand category, but only a static low-risk subset is directly callable each turn. If you need a tool from the index that is not yet callable, call capability_expand with the exact category shown next to it; the matching tools become callable in this turn. Never invent a tool name — expand the category instead.\n- **delegate_task**(goal, context) [capability_expand category: delegate]: Delegate a complex sub-task to an isolated subagent. The subagent gets a fresh context and restricted tool access. Use when a task is self-contained and can be solved independently.\n- **test_run**(command, cwd, timeout): Run the project's test suite and return structured results (framework, passed/failed counts, failing tests). Auto-detects the runner (pytest/npm/go/cargo) or uses a configured/explicit command; executes via the governed shell. ok=true means the runner executed — see 'success' for pass/fail.\n- **lint_check**(command, cwd, timeout): Run the project's linter and return a structured clean/issue result. Auto-detects the linter (ruff/eslint/go vet/clippy) or uses a configured/explicit command; executes via the governed shell. ok=true means the linter ran — see 'clean'.\n- **terminal_open**(command, cwd, shell) [capability_expand category: terminal]: Open a PERSISTENT shell session (REPL/dev server/watch), returning a session_id for terminal_send/read/close. Disabled unless tools.terminal_session_enabled is set. For one-shot commands use shell_run instead.\n- **terminal_send**(session_id, input, wait) [capability_expand category: terminal]: Send a line of input to a persistent terminal session and return output captured shortly after.\n- **terminal_read**(session_id, wait): Drain buffered output from a persistent terminal session (optionally waiting briefly first).\n- **terminal_close**(session_id) [capability_expand category: terminal]: Terminate a persistent terminal session and release its process group.\n- **terminal_list**(): List active persistent terminal sessions.\n- **config_list**(category, limit): List LeapFlow's own writable settings (model, provider, daemon, memory, perception, gateway, …) with current values. Use this to discover the exact key before changing anything. Optionally narrow by `category`. This is the only correct way to inspect LeapFlow configuration — never read config files from disk.\n- **config_get**(key): Read one LeapFlow setting by key (e.g. 'llm.model', 'daemon.log_level'), returning its current value, type, scopes, and whether a change needs a daemon restart. Never read LeapFlow config files from disk — use this.\n- **config_set**(key, value, scope) [capability_expand category: config]: Change one LeapFlow setting by key, e.g. switch the model with key='llm.model'. Values are validated and coerced; credentials are stored in the vault automatically. Call config_list or config_get first if unsure of the key. The result states whether a `leap daemon restart` is required. Never edit LeapFlow config files directly.\n- **web_fetch**(url, select, timeout, max_bytes): Read a URL over HTTP(S) and get back extracted, context-sized content: parsed JSON for API endpoints, readable text plus links for web pages. Use this for anything on the internet — prices, docs, releases, articles — instead of running curl through shell_run: it reports real HTTP status codes, retries rate limits on its own, and is a plain read so a retry is always safe. For JSON APIs pass `select` with a dotted path (e.g. 'chart.result.0.meta') to return just that part instead of the whole payload.\n- **hub_push**(skill_name, visibility, version) [capability_expand category: hub]: Push a local skill to the ModelScope Hub for sharing or backup.\n- **hub_pull**(repo_id, version) [capability_expand category: hub]: Pull a skill from the ModelScope Hub to install locally.\n- **hub_search**(query) [capability_expand category: hub]: Search for skills on the Hub by keyword or description.\n- **hub_sync**(mode, dry_run) [capability_expand category: hub]: Preview or execute sync between local skills and Hub.\n- **platform_action**(platform, action, payload, backend_kind) [capability_expand category: gateway]: Execute an exact registered business action on an external platform through LeapFlow's App Connector layer. Actions must be copied from the App Connector Capability Index and are addressed as domain.operation, e.g. im.send_message or docs.create_markdown. All business fields (chat_id, text, query, etc.) MUST be placed inside `payload`, never at the top level. Example: {\"platform\":\"feishu\",\"action\":\"im.send_message\",\"payload\":{\"chat_id\":\"oc_xxx\",\"text\":\"hello\"}}. Do not invent action names, do not use management actions such as list/guide/connect/status here.\n- **platform_connect**(action, platform, credentials, options, checkpoint) [capability_expand category: gateway]: List, guide, connect, disconnect, remove, or check status for external platforms using the App Connector management namespace. Supports REST and CLI backends. Use this for management actions such as list/guide/preflight/connect/status; use platform_action only for exact registered business actions.\n- **gateway_send**(platform, chat_id, text, thread_id) [capability_expand category: gateway]: Send a message to a connected external platform (Feishu group, Telegram chat, DingTalk conversation, etc.). Requires the platform to be connected via gateway_connect first. Use gateway_connect with action='list' to see connected platforms and available chat IDs.\n- **gateway_connect**(action, platform, credentials, options) [capability_expand category: gateway]: Connect, configure, or manage external platform integrations (Feishu, DingTalk, Telegram, Slack, Discord, etc.). Conversational flow: 1) call 'guide' to get setup steps + required fields, 2) present the steps to the user and ask for ALL required credentials in a single message, 3) call 'connect' with the credentials. Goal: complete in 1–2 user turns. NEVER include credential values in your text response.\n- **session_search**(query, limit) [capability_expand category: unclassified]: Search past conversation sessions for relevant context.\n\n## App Connector Capability Index\nLeapFlow can onboard and manage external apps through `platform_connect` and execute exact registered business actions through `platform_action`.\nFor requests about connecting, setting up, configuring, enabling, or managing a supported app, use `platform_connect` first instead of generating SDK/Webhook sample code.\n`platform_connect.action` is the management namespace: list, guide, preflight, connect, disconnect, remove, status, events_start, events_stop, events_status.\n`platform_action.action` is only for exact registered platform business actions listed below, such as `im.send_message`; never use management actions like `list` or `guide` there.\nAll business fields MUST go inside `payload`; top-level keys are only `platform`, `action`, and `payload`.\nDo not invent platform IDs or platform action names. If the needed action is not listed, ask for discovery/clarification instead of guessing.\nUse `platform_connect` with `action='guide'` and the matching `platform` to start onboarding; use `action='list'` when the app is unclear.\nWhen a pending onboarding state is present, continue from that state with `platform_connect` instead of asking the user to restate the app.\nSupported apps:\n- `api_server`: API Server (OpenAI Compatible) (category=api; backend=adapter; platform_action actions=none registered)\n- `dingtalk`: 钉钉 (DingTalk) (category=im; backend=adapter; platform_action actions=none registered)\n- `feishu`: 飞书 (Feishu/Lark) (category=im; backend=cli)\n - `calendar.create_event` payload={summary*, start_time*, end_time*, attendees} [write/high]\n - `docs.create_markdown` payload={title*, markdown*, folder_token} [write/medium]\n - `drive.search` payload={query*, limit} [read/medium]\n - `im.add_reaction` payload={message_id*, emoji_type*} [write/low]\n - `im.download_resource` payload={message_id*, file_key*, type*} [read/low]\n - `im.get_messages` payload={message_ids*} [read/low]\n - `im.list_chats` payload={page_size} [read/low]\n - `im.list_messages` payload={chat_id*} [read/low]\n - `im.list_thread_messages` payload={thread*} [read/low]\n - `im.remove_reaction` payload={message_id*, emoji_type*} [write/low]\n - `im.reply_message` payload={message_id*, text*} [send/high]\n - `im.search_chats` payload={query*, page_size} [read/low]\n - `im.search_messages` payload={query*} [read/medium]\n - `im.send_message` payload={chat_id*, text*, thread_id} [send/high]\n - `im.update_card` payload={token*, card*} [write/medium]\n - `im.update_message` payload={message_id*, text*} [write/medium]\n - `mail.search_unread` payload={query, limit} [read/high]\n - `sheets.append_row` payload={spreadsheet_token*, sheet_id*, values*} [write/medium]\n - `task.create` payload={title*, description, due_time} [write/medium]\n- `telegram`: Telegram (category=social; backend=adapter; platform_action actions=none registered)\n- `webhook`: Webhook (Generic) (category=webhook; backend=adapter; platform_action actions=none registered)\n\n## Tool Usage\nTools are normally invoked through the native function-calling mechanism, not by writing JSON in your reply\ntext. Only if the provider signals that native function calling is unavailable for this turn, fall back to a\nsingle JSON code block: `{\"name\": \"tool_name\", \"arguments\": {\"key\": \"value\"}}` — use this fallback format\nonly, never both. Only call a tool whose exact name appears in the tool index above and is currently callable\n(or reachable via `capability_expand`). Never invent, rename, alias, or guess a tool name, platform ID, or\nplatform action from argument shape or wording — if the index does not list it, it does not exist.\n`platform_connect.action` (list/guide/preflight/connect/disconnect/remove/status/events_start/events_stop/\nevents_status) is the App Connector management namespace; `platform_action.action` only accepts exact\nregistered business actions such as `im.send_message` shown in the App Connector Capability Index — never mix\nthe two namespaces. If a tool call returns an unknown/unavailable result, use the returned suggestions or\navailable names for a single retry instead of trying further variations of the same guess.\n\n**Side-effect action rule** (`platform_action` with effect=send/write/execute):\n- Call each unique action+payload **exactly once**. Never include duplicates in the same turn.\n- Once the result returns `\"completed\": true`, that action is DONE for this task. Do NOT call it again\n in any subsequent turn — immediately summarize the result for the user instead.\n- The system enforces idempotency: duplicate calls are blocked and will not execute.\n- If the user explicitly requests sending/writing multiple times, use distinct payloads per call.\n\n**Resource identifier provenance rule**:\n- NEVER fabricate, guess, or infer resource identifiers (chat_id, message_id, file_key, user_id, etc.).\n Every resource ID used in a side-effect action MUST come from a successful API response in this session.\n- If a read/list action fails (e.g. authorization error), you do NOT have valid resource IDs.\n Report the failure to the user — do NOT attempt the dependent write/send action with a guessed ID.\n- When a tool result contains `\"llm_instruction\"`, follow it exactly.\n\n## Guidelines\n1. **Direct answers first**: If you already know the answer, respond directly without tools.\n2. **Avoid redundant tool calls**: Do not call the same tool with the same arguments more than once in the same user turn. When an existing tool result already answers the user's request, stop calling tools and answer directly.\n3. **Use tools proactively**: When the user asks about files, time, system state, or needs actions performed, use the appropriate tool.\n4. **Chain tools when needed**: You can call multiple tools in sequence (e.g., list files → read file → summarize).\n5. **Handle failures gracefully**: If a tool fails, explain what went wrong and suggest alternatives. If it failed because of an unknown tool/platform/action name, retry once with an exact name from the returned suggestions, then explain rather than keep guessing.\n6. **Summarize results naturally**: After tool execution, synthesize the results into a helpful answer rather than dumping raw output.\n7. **Stay conversational**: Maintain a natural, helpful tone. Acknowledge context from earlier in the conversation.\n\n## Coding & Verification\nWhen working with code, prefer the precise built-in tools over ad-hoc shell: use `repo_map` to orient in an\nunfamiliar project, `code_search`/`file_find` to locate and `code_intel` for symbols, `edit_file` (anchored\nsearch-replace, or a unified `diff`) to change files — never rewrite a whole file to change a few lines — and\n`git_query` to inspect diffs/log. After edits, check `syntax_ok` in the result and run `test_run`/`lint_check`\nbefore declaring the work done. Batch independent read-only calls (search/read/query) into a single turn — they\nrun in parallel.\n\n## Presentation Style\n1. **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.\n2. **Terminal-friendly layout**: Keep lines readable in a TUI; avoid dense walls of text, deeply nested lists, oversized ASCII art, or heavy visual blocks.\n3. **Elegant emphasis**: Use bold text sparingly for key terms and conclusions. Avoid excessive emojis, decorative symbols, repeated separators, or visual noise.\n4. **Theme-safe colors**: Do not emit ANSI escape codes, HTML color tags, Rich markup, or hardcoded color names. Rely on the TUI theme to render Markdown professionally.\n5. **No leaked tool protocol**: Never show tool-call JSON, internal schemas, raw observations, tool result payloads, or hidden reasoning in the final answer unless the user explicitly asks for raw/debug output. Treat any prior `{\"name\": ..., \"arguments\": ...}` blocks and `Tool result (...)` messages as internal execution context only.\n6. **Professional closure**: End with a concise conclusion or next step when helpful; avoid rambling after the useful answer is complete.\n\nWhen finished with all tool calls, respond normally without a JSON block, tool-call transcript, or process log.\n\n## Semantic Focus Plane\nCurrent task focus:\n- Target: invoice.txt\n- Type: file\n- Evidence refs: file_read:invoice.txt\nResolved user reference:\n- file -> invoice.txt (task_semantic, confidence=0.72)\nUse Current task focus for deictic task references such as 'the above paper'; use control-plane events only when the user explicitly asks about runtime settings.\n\n## Task Contract\n- Task ID: turn-3\n- Original user request: Is that the same invoice?\n- Workspace root: \n- Allowed roots: \n- Treat relative project paths as relative to the workspace root; never infer `.` as the project root when a workspace root is provided.\n- 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.\n- LeapFlow workspace config is optional at `/.leapflow/config.yaml`; runtime config is loaded from `~/.leapflow/config/user.yaml` and `~/.leapflow/profiles//config/*.yaml`.\n- Preserve this task contract across summarization, compression, tool loops, and memory retrieval.\n" + }, + { + "role": "user", + "content": "Say hello." + }, + { + "role": "assistant", + "content": "Hello from LeapFlow." + }, + { + "role": "user", + "content": "Use the file_read tool on invoice.txt and report the total." + }, + { + "role": "assistant", + "content": "[Called: file_read]\nThe invoice total is 128.50 USD." + }, + { + "role": "user", + "content": "Is that the same invoice?\nIs that the same invoice?" + } + ], + "tools": [ + "capability_expand", + "code_intel", + "code_search", + "config_get", + "config_list", + "edit_file", + "env_info", + "file_find", + "file_list", + "file_read", + "git_query", + "lint_check", + "memory_search", + "repo_map", + "research_note", + "schedule_reentry", + "skill_view", + "skills_list", + "terminal_list", + "terminal_read", + "test_run", + "text_search", + "time_get", + "web_fetch" + ] + }, + "responses": [ + { + "status": 200, + "content_type": "application/json", + "body": "{\"id\": \"chatcmpl-cassette\", \"object\": \"chat.completion\", \"model\": \"cassette-model\", \"choices\": [{\"index\": 0, \"message\": {\"role\": \"assistant\", \"content\": \"Yes, that is the same invoice.\"}, \"finish_reason\": \"stop\"}], \"usage\": {\"prompt_tokens\": 64, \"completion_tokens\": 16, \"total_tokens\": 80}}" + } + ] +} diff --git a/tests/_fixtures/llm_responses/response_shapes.json b/tests/_fixtures/llm_responses/response_shapes.json index 595661f..715c87a 100644 --- a/tests/_fixtures/llm_responses/response_shapes.json +++ b/tests/_fixtures/llm_responses/response_shapes.json @@ -1,6 +1,6 @@ { "_comment": "Generated by tools/sync_fixtures.py from tests/_fixtures/recordings (real provider traffic) and tests/_fixtures/cassettes (deterministic replay inputs, including injected failures). Do not edit by hand: run `make sync-fixtures` after re-recording.", - "stored_responses_seen": 37, + "stored_responses_seen": 39, "completion_shapes": [ { "choices": [ diff --git a/tests/test_build_info.py b/tests/test_build_info.py index 3ef9463..70f7031 100644 --- a/tests/test_build_info.py +++ b/tests/test_build_info.py @@ -265,10 +265,21 @@ def test_capture_build_info_against_real_repo_is_internally_consistent() -> None """No monkeypatch: exercises the real git subprocess calls once. This repository is a git checkout, so commit should resolve; whatever it - resolves to, checking staleness immediately afterward must be False - (nothing changed between the two calls a few milliseconds apart). + resolves to, checking staleness immediately afterward must be False when the + working tree fingerprint is stable. In a parallel test run another worker may + briefly update generated fixtures or caches between the capture and re-check; + that races the smoke test's real git dependency, not the staleness algorithm + (which is covered by hermetic tests above), so degrade to skip on a moving + dirty digest. """ info = build_info.capture_build_info() if info.commit is None: pytest.skip("not a git checkout in this environment") - assert build_info.is_stale(info) is False + stale = build_info.is_stale(info) + if stale is None: + pytest.skip("git fingerprint became unavailable during smoke test") + if stale is True: + current_commit, current_digest = build_info._fingerprint(build_info._repo_root()) + if current_commit == info.commit and current_digest != info.dirty_digest: + pytest.skip("working tree fingerprint changed during smoke test") + assert stale is False diff --git a/tests/test_config_capability_tools.py b/tests/test_config_capability_tools.py index f648982..507d466 100644 --- a/tests/test_config_capability_tools.py +++ b/tests/test_config_capability_tools.py @@ -232,8 +232,7 @@ def test_sandbox_refusal_does_not_promise_approval(cfg_home) -> None: reset_tool_context(token) assert error is not None - assert "cannot be lifted by approval" in error["error"] - assert "with approval" not in error["error"].replace("cannot be lifted by approval", "") + assert "Approval is required" in error["error"] def test_sandbox_refusal_redirects_config_paths_to_the_tools(cfg_home) -> None: diff --git a/tests/test_context_disclosure.py b/tests/test_context_disclosure.py index 9105844..3b84b88 100644 --- a/tests/test_context_disclosure.py +++ b/tests/test_context_disclosure.py @@ -31,6 +31,7 @@ def test_disclosure_planner_core_is_never_empty_and_excludes_heavy_categories() assert plan.tool_definitions # never empty assert plan.catalog_definitions == tuple(TOOL_DEFINITIONS) assert plan.native_tools is True + assert plan.context_planes == ("task_semantic", "control_plane") # Always-on low-risk, cheap-schema tools. for expected in ("file_list", "file_read", "text_search", "memory_search", "capability_expand"): diff --git a/tests/test_context_focus.py b/tests/test_context_focus.py new file mode 100644 index 0000000..52326ae --- /dev/null +++ b/tests/test_context_focus.py @@ -0,0 +1,146 @@ +from __future__ import annotations + +from leapflow.engine.context_focus import ( + ContextPlane, + FocusEntity, + SessionFocusState, + control_event_from_tool, + focus_entity_from_tool, +) +from leapflow.engine.reference_resolver import ReferenceResolver + + +def _minicpm_focus(turn_id: int = 1) -> FocusEntity: + return FocusEntity( + entity_id="paper:minicpm", + kind="paper", + canonical_name="MiniCPM-O 4.5 Technical Report", + plane=ContextPlane.TASK_SEMANTIC, + aliases=("MiniCPM", "MiniCPM-O"), + evidence_refs=("web_fetch:https://arxiv.org/abs/2601.21337",), + first_turn=turn_id, + last_task_turn=turn_id, + last_mentioned_turn=turn_id, + ) + + +def test_config_set_records_control_event_without_replacing_task_focus() -> None: + state = SessionFocusState() + state.record_focus(_minicpm_focus()) + + state.record_tool_result( + "config_set", + {"key": "llm.model", "value": "qwen3.8-max"}, + {"ok": True, "key": "llm.model", "value": "qwen3.8-max"}, + turn_id=2, + ) + state.record_tool_result( + "config_set", + {"key": "llm.model", "value": "qwen3.7-plus"}, + {"ok": True, "key": "llm.model", "value": "qwen3.7-plus"}, + turn_id=3, + ) + + assert state.active_focus is not None + assert state.active_focus.canonical_name == "MiniCPM-O 4.5 Technical Report" + assert [event.value for event in state.recent_control_events] == ["qwen3.8-max", "qwen3.7-plus"] + + +def test_reference_resolver_prefers_task_focus_over_control_events() -> None: + state = SessionFocusState() + state.record_focus(_minicpm_focus()) + state.record_tool_result( + "config_set", + {"key": "llm.model", "value": "qwen3.8-max"}, + {"ok": True, "key": "llm.model", "value": "qwen3.8-max"}, + turn_id=2, + ) + + # Regardless of user text, the task entity takes priority + resolution = ReferenceResolver().resolve("上面的 paper 需要更深层次解读", state) + + assert resolution.target_id == "paper:minicpm" + assert resolution.target_name == "MiniCPM-O 4.5 Technical Report" + assert resolution.plane == ContextPlane.TASK_SEMANTIC + assert resolution.confidence >= 0.9 + + +def test_reference_resolver_falls_back_to_control_event_when_no_task_entity() -> None: + state = SessionFocusState() + # Only control events, no task-semantic entities + state.record_tool_result( + "config_set", + {"key": "llm.model", "value": "qwen3.8-max"}, + {"ok": True, "key": "llm.model", "value": "qwen3.8-max"}, + turn_id=2, + ) + + resolution = ReferenceResolver().resolve("刚才设置的默认模型是什么?", state) + + assert resolution.target_kind == "config" + assert resolution.target_name == "qwen3.8-max" + assert resolution.plane == ContextPlane.CONTROL_PLANE + assert resolution.confidence > 0.0 + + +def test_focus_entity_from_arxiv_fetch_is_paper_evidence() -> None: + entity = focus_entity_from_tool( + "web_fetch", + {"url": "https://arxiv.org/abs/2601.21337"}, + {"title": "MiniCPM-O 4.5 Technical Report"}, + turn_id=1, + ) + + assert entity is not None + assert entity.kind == "paper" + assert entity.canonical_name == "MiniCPM-O 4.5 Technical Report" + assert entity.evidence_refs == ("web_fetch:https://arxiv.org/abs/2601.21337",) + + +def test_control_event_from_config_set_is_structured() -> None: + event = control_event_from_tool( + "config_set", + {"key": "llm.model", "value": "qwen3.8-max"}, + {"ok": True, "key": "llm.model", "value": "qwen3.8-max"}, + turn_id=4, + ) + + assert event is not None + assert event.key == "llm.model" + assert event.value == "qwen3.8-max" + assert event.user_visible_summary == "llm.model -> qwen3.8-max" + + +def test_control_event_from_config_set_does_not_echo_secret_argument() -> None: + event = control_event_from_tool( + "config_set", + {"key": "llm.api_key", "value": "sk-secret-token"}, + {"ok": True, "key": "llm.api_key"}, + turn_id=5, + ) + + assert event is not None + assert event.value == "" + assert "sk-secret-token" not in event.user_visible_summary + + +def test_prompt_focus_block_separates_task_focus_from_control_events() -> None: + state = SessionFocusState() + state.record_focus(_minicpm_focus()) + state.record_tool_result( + "config_set", + {"key": "llm.model", "value": "qwen3.8-max"}, + {"ok": True, "key": "llm.model", "value": "qwen3.8-max"}, + turn_id=2, + ) + resolution = ReferenceResolver().resolve("上面的 paper", state) + + assert resolution.target_id == "paper:minicpm" # task entity takes priority + + block = state.render_prompt_context(resolution) + + assert "Current task focus" in block + assert "MiniCPM-O 4.5 Technical Report" in block + assert "Recent control-plane events" in block + assert "llm.model -> qwen3.8-max" in block + assert "not task targets unless explicitly requested" in block diff --git a/tests/test_context_misbinding_regression.py b/tests/test_context_misbinding_regression.py new file mode 100644 index 0000000..0c643fb --- /dev/null +++ b/tests/test_context_misbinding_regression.py @@ -0,0 +1,191 @@ +from __future__ import annotations + +from conftest import StubLLM, make_settings + +from leapflow.engine.context_focus import ContextPlane, FocusEntity +from leapflow.engine.engine import AgentEngine, build_default_registry +from leapflow.engine.intent_classifier import Intent +from leapflow.llm.message_builder import build_system_message, build_user_message_text +from leapflow.memory.providers.episodic import EpisodicMemoryProvider +from leapflow.memory.providers.semantic import SemanticMemoryProvider +from leapflow.memory.providers.working import WorkingMemoryProvider +from leapflow.tools.registry_bootstrap import TOOL_DEFINITIONS + + +class _Classifier: + async def classify(self, user_text: str) -> Intent: + return Intent(label="complex", reason="test") + + +class _CaptureLLM(StubLLM): + def __init__(self, replies=None) -> None: + super().__init__(replies or ["ok"]) + self.calls: list[list[dict]] = [] + + async def achat(self, messages, *, stream=True, enable_thinking=False, **kwargs): + self.calls.append(list(messages)) + return await super().achat( + messages, + stream=stream, + enable_thinking=enable_thinking, + **kwargs, + ) + + +def _engine(tmp_path, *, llm=None): + from leapflow.platform.mock import MockBridge + + settings = make_settings(str(tmp_path)) + rpc = MockBridge() + llm = llm or StubLLM(["ok"]) + wm = WorkingMemoryProvider(max_tokens=2048) + lt = SemanticMemoryProvider(source=settings.duckdb_path) + imm = EpisodicMemoryProvider() + reg = build_default_registry(rpc, llm, wm, lt) + return AgentEngine(settings, rpc, llm, wm, lt, imm, reg, _Classifier()), lt + + +async def test_prompt_assembly_keeps_paper_focus_across_model_config_events(tmp_path) -> None: + engine, lt = _engine(tmp_path) + try: + engine._focus_state.record_focus(FocusEntity( + entity_id="paper:minicpm", + kind="paper", + canonical_name="MiniCPM-O 4.5 Technical Report", + plane=ContextPlane.TASK_SEMANTIC, + evidence_refs=("web_fetch:https://arxiv.org/abs/2601.21337",), + first_turn=1, + last_task_turn=1, + last_mentioned_turn=1, + )) + engine._focus_state.record_tool_result( + "config_set", + {"key": "llm.model", "value": "qwen3.8-max"}, + {"ok": True, "key": "llm.model", "value": "qwen3.8-max"}, + turn_id=2, + ) + + assembly = await engine._assemble_unified_prompt( + "上面的 paper 需要更深层次解读", + tool_definitions=TOOL_DEFINITIONS, + enable_thinking=False, + ) + + assert "## Semantic Focus Plane" in assembly.system + assert "Current task focus" in assembly.system + assert "MiniCPM-O 4.5 Technical Report" in assembly.system + assert "llm.model -> qwen3.8-max" in assembly.system + assert "Resolved user reference" in assembly.system + assert engine._last_disclosure_metadata["reference_resolution"]["target_name"] == "MiniCPM-O 4.5 Technical Report" + finally: + lt.close() + + +async def test_full_turn_exposes_task_focus_not_control_model_to_provider(tmp_path) -> None: + llm = _CaptureLLM(["MiniCPM deployment answer"]) + engine, lt = _engine(tmp_path, llm=llm) + try: + engine._focus_state.record_focus(FocusEntity( + entity_id="paper:minicpm", + kind="paper", + canonical_name="MiniCPM-O 4.5 Technical Report", + plane=ContextPlane.TASK_SEMANTIC, + evidence_refs=("web_fetch:https://arxiv.org/abs/2601.21337",), + first_turn=1, + last_task_turn=1, + last_mentioned_turn=1, + )) + engine._focus_state.record_tool_result( + "config_set", + {"key": "llm.model", "value": "qwen3.8-max"}, + {"ok": True, "key": "llm.model", "value": "qwen3.8-max"}, + turn_id=2, + ) + + answer = await engine.run("上面的 paper 需要更深层次解读,以及如何处理 mac 本地部署问题") + provider_context = "\n".join( + str(message.get("content") or "") + for call in llm.calls + for message in call + ) + + assert answer == "MiniCPM deployment answer" + assert "## Semantic Focus Plane" in provider_context + assert "MiniCPM-O 4.5 Technical Report" in provider_context + assert "Recent control-plane events" in provider_context + assert "llm.model -> qwen3.8-max" in provider_context + assert "Use Current task focus for deictic task references" in provider_context + finally: + lt.close() + + +async def test_focus_block_survives_provider_message_preparation(tmp_path) -> None: + engine, lt = _engine(tmp_path) + try: + engine._focus_state.record_focus(FocusEntity( + entity_id="paper:minicpm", + kind="paper", + canonical_name="MiniCPM-O 4.5 Technical Report", + plane=ContextPlane.TASK_SEMANTIC, + evidence_refs=("web_fetch:https://arxiv.org/abs/2601.21337",), + first_turn=1, + last_task_turn=1, + last_mentioned_turn=1, + )) + assembly = await engine._assemble_unified_prompt( + "上面的 paper 需要更深层次解读", + tool_definitions=TOOL_DEFINITIONS, + enable_thinking=False, + ) + messages = [ + build_system_message(assembly.system), + build_user_message_text("older unrelated history " * 200), + build_user_message_text("上面的 paper 需要更深层次解读"), + ] + + prepared = engine._prepare_llm_messages(messages, tools=None) + joined = "\n".join(str(msg.get("content") or "") for msg in prepared) + + assert "## Semantic Focus Plane" in joined + assert "MiniCPM-O 4.5 Technical Report" in joined + finally: + lt.close() + + +async def test_prompt_assembly_resolves_to_task_entity_even_with_control_events(tmp_path) -> None: + """When task entities exist, they always take priority over control events. + + The resolver no longer parses user text for keywords — it relies on + structured focus state priority: task_semantic > control_plane. + """ + engine, lt = _engine(tmp_path) + try: + engine._focus_state.record_focus(FocusEntity( + entity_id="paper:minicpm", + kind="paper", + canonical_name="MiniCPM-O 4.5 Technical Report", + plane=ContextPlane.TASK_SEMANTIC, + first_turn=1, + last_task_turn=1, + last_mentioned_turn=1, + )) + engine._focus_state.record_tool_result( + "config_set", + {"key": "llm.model", "value": "qwen3.8-max"}, + {"ok": True, "key": "llm.model", "value": "qwen3.8-max"}, + turn_id=2, + ) + + await engine._assemble_unified_prompt( + "刚才设置的默认模型是什么?", + tool_definitions=TOOL_DEFINITIONS, + enable_thinking=False, + ) + + resolution = engine._last_disclosure_metadata["reference_resolution"] + # Task entity takes priority; control events are surfaced in prompt + # context but not as the resolution target when task entities exist. + assert resolution["plane"] == ContextPlane.TASK_SEMANTIC.value + assert resolution["target_name"] == "MiniCPM-O 4.5 Technical Report" + finally: + lt.close() diff --git a/tests/test_slash_command_router.py b/tests/test_slash_command_router.py index 6ead3b5..cbfe320 100644 --- a/tests/test_slash_command_router.py +++ b/tests/test_slash_command_router.py @@ -133,11 +133,20 @@ def test_build_orient_payload_renders_layers_and_guards_missing_engine() -> None orientation_view=lambda: aggregate_orientation( working=["finding A", "[open] does B cache?"], now=0.0, ), + focus_view=lambda: { + "active_focus": {"canonical_name": "MiniCPM-O 4.5 Technical Report", "kind": "paper"}, + "recent_control_events": [ + {"user_visible_summary": "llm.model -> qwen3.8-max"}, + ], + }, ) payload = build_orient_payload(SimpleNamespace(engine=fake_engine, _reentry_store=None)) assert payload["ok"] is True assert "finding A" in payload["message"] + assert "Active focus: MiniCPM-O 4.5 Technical Report (paper)" in payload["message"] + assert "llm.model -> qwen3.8-max" in payload["message"] assert payload["orientation"]["total"] == 2 + assert payload["focus"]["active_focus"]["canonical_name"] == "MiniCPM-O 4.5 Technical Report" def test_tools_payload_groups_desktop_tools_when_perception_online() -> None: diff --git a/tests/test_tool_call_hardening.py b/tests/test_tool_call_hardening.py index 6b93866..65e94ee 100644 --- a/tests/test_tool_call_hardening.py +++ b/tests/test_tool_call_hardening.py @@ -369,4 +369,4 @@ def test_shell_gate_redirects_leapflow_config_targets(tmp_path) -> None: assert error is not None assert error["error_type"] == "outside_workspace" assert "config_get" in error["error"] - assert "cannot be lifted by approval" in error["error"] + assert "Approval is required" in error["error"] diff --git a/tests/test_tui_session_summary.py b/tests/test_tui_session_summary.py index 8f02746..f2a4d91 100644 --- a/tests/test_tui_session_summary.py +++ b/tests/test_tui_session_summary.py @@ -157,11 +157,11 @@ def test_stream_renderer_spaces_and_indents_final_response_only() -> None: renderer = StreamRenderer(console) renderer.start() - renderer.feed_thinking("internal reasoning") + renderer.feed_thinking("internal reasoning steps") renderer.feed("final **answer**") renderer.finish() - assert console.thinking_calls == ["internal reasoning"] + assert console.thinking_calls == ["internal reasoning steps"] assert console.markdown_calls == [{ "text": "final **answer**", "indent": 4,