diff --git a/AGENTS.md b/AGENTS.md index 60cee87..9e5d61b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,7 +6,7 @@ This document is the LeapFlow engineering collaboration contract. It is not only 1. **Signal-Driven Intelligence** — All agent intelligence derives from observing real-world signals, not from hardcoded rules. If a behavior cannot be learned from signals, it is not in scope. -2. **Context Pipeline as Core** — Signal → Filter (SNR) → Compress (intent-preserving) → Store (multi-layer) → Retrieve (goal-dependent) → Decide. Every feature must map to this pipeline. +2. **Context Pipeline as Core** — Signal → Filter (SNR) → Compress (intent-preserving) → Store (multi-layer) → Retrieve (goal-dependent) → Decide. Every feature and every external signal source, including IM collaboration events, must map to this pipeline before it can drive action. 3. **Progressive Trust** — Never auto-execute on first encounter. Earn autonomy through repeated success: DRAFT → CANDIDATE → VERIFIED → PRODUCTION. @@ -36,6 +36,10 @@ This document is the LeapFlow engineering collaboration contract. It is not only - **TUI Prompt Ownership**: Input prompt and placeholder rendering must have a single owner. Avoid duplicate prompt sources; placeholder text stays visually subordinate, offset after the prompt, and disappears as soon as the user types. - **leapd Runtime Consistency**: Daemon-backed behavior must preserve lifecycle correctness: start, stop, restart, status, RPC streaming, cancellation, pending approvals, runtime config reload, multi-client state, and version consistency. - **Progressive Context Disclosure (PCD)**: Keep one unified execution loop, but never default every turn to full disclosure. Each LLM call must use the smallest sufficient PromptAssemblyPlan for tools, memory, history, reasoning, streaming, and risk; upgrade progressively only when observable signals require it. +- **Gateway as Signal Boundary**: External IM/platform integrations are not just messaging features; they extend LeapFlow's Observe/Orient boundary into collaboration environments. Inbound platform events must enter as structured signals (`BackendEvent` → normalized domain event/message), pass SNR filtering and privacy/safety gates, then feed memory, decision, and action paths according to their classification. +- **Transport-Lifecycle Separation**: Short-lived actions (`ExecutionBackend`/`CliBackend`) and long-lived observations (`BackendEventSource`) are separate responsibilities. Do not implement streaming subscribers, webhooks, polling loops, or CLI NDJSON consumers inside one-shot action execution code. +- **Platform-Neutral Gateway Core**: Gateway core owns protocols, lifecycle, routing, session isolation, approval, audit, and memory integration. Platform adapters own authentication, send semantics, event-source configuration, and schema normalization. Core modules must not import platform SDKs directly. +- **Platform vs App Business Boundary**: Platform layers may define stable contracts and governance primitives (`ActionSpec`, `ActionFailure`, `ActionAuthSpec`, `CapabilityHealthLedger`, approval/feasibility gates, audit, and metadata propagation). Third-party app or vendor specifics — SDK/CLI wire formats, scope names, auth commands, console URLs, error JSON shapes, resource naming, and recovery playbooks — must live in that app's action pack, adapter, backend, or normalizer, never in gateway core. - **Dependency Inversion**: Core logic depends on Protocol abstractions, never on concrete implementations - **Protocol over ABC**: Use `typing.Protocol` with `runtime_checkable` for all extension points - **Event-Driven Communication**: Modules interact through typed events on EventBus, not direct imports @@ -43,6 +47,7 @@ This document is the LeapFlow engineering collaboration contract. It is not only - **Config-Driven Behavior**: Thresholds, intervals, feature flags, model budgets, platform capabilities, hub backends, gateway manifests, and paths must be configurable through Settings/env/config layers. - **Graceful Degradation**: Every optional component (LLM, Hub) can be absent without crash - **Single Source of Truth**: DuckDB for persistence, EventBus for communication, Settings for configuration +- **Inbound Signal Classification**: Platform events must be classified before they activate the agent. Message/callback events may enter Decide; signal/lifecycle events should be stored or routed without triggering LLM by default; ignored events must be explicit (e.g. self-message, duplicate, blocked scope). ## Implementation Guidelines @@ -51,8 +56,11 @@ This document is the LeapFlow engineering collaboration contract. It is not only - Consider affected user journeys before changing shared flows; do not introduce regressions, broken links, or worse experiences in adjacent paths - Keep common paths transparent: long-running work must stream progress, surface recoverable errors clearly, and avoid silent stalls. - 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. - 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. - Maintain backward-compatible migrations for persistent state, configuration, skills, trajectories, sessions, and profile data. - Write unit tests before or alongside the implementation - Integrate via EventBus events, not direct function calls between modules @@ -80,7 +88,7 @@ This document is the LeapFlow engineering collaboration contract. It is not only - **Verification sequence**: compile → import → unit test → integration (if applicable) - **Behavior contracts over snapshots**: assert invariants, not frozen values - **Mock at boundaries only**: mock external I/O (network, disk), never internal logic -- **Change-scoped validation**: Run the most specific relevant tests first, then broaden only as needed: CLI/TUI changes require CLI/TUI tests; leapd changes require daemon RPC/lifecycle tests; storage or memory changes require persistence tests; gateway or approval changes require security/approval tests; skills, learning, perception, and copilot changes require their lifecycle or pipeline tests. +- **Change-scoped validation**: Run the most specific relevant tests first, then broaden only as needed: CLI/TUI changes require CLI/TUI tests; leapd changes require daemon RPC/lifecycle tests; storage or memory changes require persistence tests; gateway, IM, event-source, or approval changes require connector lifecycle, event normalization, routing, idempotency, self-message filtering, security/approval, and failure-recovery tests; skills, learning, perception, and copilot changes require their lifecycle or pipeline tests. ## What to Avoid @@ -92,6 +100,9 @@ This document is the LeapFlow engineering collaboration contract. It is not only - Hardcoded paths, URLs, thresholds without config escape hatch - Chinese comments in source code (English only) - Speculative infrastructure: no hooks or extension points without a concrete consumer +- Mixing long-lived event observation into short-lived action execution; `CliBackend` is for bounded commands, while streaming CLI consumers, webhooks, polling, and WebSocket subscriptions belong behind `BackendEventSource`-style contracts. +- Activating IM agents on all inbound messages by default, skipping self-message filtering, or allowing cross-chat/proactive sends before Progressive Trust and approval policies explicitly permit them. +- Putting third-party app business code into platform core: do not add vendor scopes, lark-cli/SDK JSON parsing, auth command construction, console-specific recovery instructions, or resource-specific branching to gateway-wide modules such as action registries, capability ledgers, approval gates, or engine recovery paths. - Shortcut-style natural-language fitting and large intent-handler taxonomies; use stable runtime gates plus capability manifests instead. Rule-based keyword/action-verb/alias matching is prohibited by default and requires explicit human second confirmation before implementation when unavoidable. - Bare `except:` clauses — always specify the exception type - `# TODO: implement` stubs — implement or don't commit diff --git a/src/leapflow/analysis/pipeline.py b/src/leapflow/analysis/pipeline.py index baa5dc5..c54a062 100644 --- a/src/leapflow/analysis/pipeline.py +++ b/src/leapflow/analysis/pipeline.py @@ -129,6 +129,7 @@ def __init__( self._signal_timeline = signal_timeline self._observation_daemon = observation_daemon self._recording_profile = recording_profile + self._daemon_started_for_recording: bool = False self._extracted_video_actions: List[Any] = [] self._video_available: bool = False self._current_goal = "" @@ -184,6 +185,7 @@ async def start_recording(self, *, user_id: Optional[str] = None, goal: str = "" tid = self._recorder.start(user_id=user_id) if goal: self._recorder.attention_context.seed_from_goal(goal) + await self._ensure_observation_daemon() await self._apply_recording_profile() if self._recording_mode.uses_video and self._video_recorder: @@ -327,6 +329,27 @@ def unpause_recording(self) -> None: if self._video_recorder and self._video_recorder.paused: fire_and_forget(self._video_recorder.resume()) + async def _ensure_observation_daemon(self) -> None: + """Start ObservationDaemon on-demand if not already running. + + Enables OS-level signal capture (FS, focus, clipboard, input tap) + even when observer_auto_start was disabled at init time. + """ + if self._observation_daemon is not None: + return + if self._event_bus is None: + return + try: + from leapflow.platform.observers import ObserverConfig + from leapflow.platform.observers.daemon import ObservationDaemon + daemon = ObservationDaemon(bus=self._event_bus, config=ObserverConfig()) + await daemon.start() + self._observation_daemon = daemon + self._daemon_started_for_recording = True + logger.info("ObservationDaemon started on-demand for teach recording") + except Exception: + logger.debug("on-demand ObservationDaemon start failed", exc_info=True) + async def _apply_recording_profile(self) -> None: """Switch observation daemon to high-fidelity recording mode.""" if self._observation_daemon is None or self._recording_profile is None: @@ -344,6 +367,13 @@ async def _reset_recording_profile(self) -> None: await self._observation_daemon.reset_profile() except Exception as e: logger.warning("reset_recording_profile failed: %s", e) + if self._daemon_started_for_recording: + try: + await self._observation_daemon.stop() + except Exception: + logger.debug("on-demand ObservationDaemon stop failed", exc_info=True) + self._observation_daemon = None + self._daemon_started_for_recording = False # ── Video enrichment ── diff --git a/src/leapflow/cli/commands/interactive.py b/src/leapflow/cli/commands/interactive.py index 64caeaa..3614fdf 100644 --- a/src/leapflow/cli/commands/interactive.py +++ b/src/leapflow/cli/commands/interactive.py @@ -438,6 +438,7 @@ async def _stream_response(prompt_text: str) -> None: renderer = StreamRenderer(console) renderer.start() + turn_completed = False try: async for event in ctx.engine.run_stream(prompt_text): if isinstance(event, StreamEvent): @@ -458,8 +459,15 @@ async def _stream_response(prompt_text: str) -> None: renderer.feed(event.content) else: renderer.feed(str(event)) + turn_completed = True finally: - renderer.finish() + if turn_completed and renderer.permission_blocked: + command = app.block_active_command_in_response(renderer.permission_block_reason) + elif turn_completed: + command = app.complete_active_command_in_response() + else: + command = None + renderer.finish(command=command) if renderer.has_output: exit_stats.record_assistant_message() exit_stats.record_tool_calls(renderer.tool_count) @@ -821,6 +829,7 @@ async def cmd_interactive_daemon( from leapflow.cli.commands.router import CommandRouter, render_command_result from leapflow.cli.commands.slash_handlers import ( render_app_payload, + render_command_payload, render_model_payload, render_tools_payload, render_usage_payload, @@ -902,8 +911,9 @@ def _set_active_session_id(session_id: str) -> None: ) def _update_status() -> None: + effective_mode = daemon_session_mode if daemon_session_mode in ("learning", "paused") else "daemon" status.update( - mode="daemon", + mode=effective_mode, skill_count=0, platform_online=runtime_host_online, model_name=runtime_model_name, @@ -912,7 +922,7 @@ def _update_status() -> None: context_max=runtime_context_length, context_state=runtime_context_state, ) - app.prompt_mode = "daemon" + app.prompt_mode = effective_mode def _render_banner() -> None: display_rich_banner( @@ -1061,7 +1071,13 @@ async def _stream_response( ) raise finally: - renderer.finish() + if turn_completed and renderer.permission_blocked: + command = app.block_active_command_in_response(renderer.permission_block_reason) + elif turn_completed: + command = app.complete_active_command_in_response() + else: + command = None + renderer.finish(command=command) if renderer.has_output: exit_stats.record_assistant_message() exit_stats.record_tool_calls(renderer.tool_count) @@ -1078,119 +1094,92 @@ async def _stream_response( console.system("Retrying the interrupted request once after reconnecting to leapd.") await _stream_response(prompt_text, allow_retry=False, record_user=False) + # Session mode tracking for teaching — daemon-side state mirrored here + daemon_session_mode = "idle" + async def handle_input(text: str) -> None: + nonlocal daemon_session_mode + invocation = command_router.parse(text) if invocation is not None: - unsupported = command_router.unsupported_result(invocation) - if unsupported is not None: - render_command_result(console, unsupported) - return canonical = invocation.command.name cmd_args = invocation.args - if canonical == "exit": - app.exit() - return - if canonical == "help": - _show_help(console, runtime="daemon") - return - if canonical == "status": - await _print_daemon_status() - return - if canonical == "host": - action = _host_action(cmd_args) - try: - if action == "status": - result = await bridge.call( - lambda current_client: current_client.host_status(), - description="host status", - ) - elif action == "start": - console.system("Starting CuaDriver OS control for this session…") - result = await bridge.call( - lambda current_client: current_client.host_start(), - description="host start", - ) - elif action == "stop": - console.system("Stopping CuaDriver OS control; chat and memory stay available…") - result = await bridge.call( - lambda current_client: current_client.host_stop(), - description="host stop", - ) - elif action == "restart": - console.system("Restarting CuaDriver OS control…") - result = await bridge.call( - lambda current_client: current_client.host_restart(), - description="host restart", - ) - else: - console.warning("Usage: /host [status|start|stop|restart]") - return - except Exception as exc: - console.warning(f"Host control failed: {exc}") + + # Client-local commands: handled directly by the TUI + if invocation.command.client_local: + if canonical == "exit": + if daemon_session_mode == "learning": + try: + await bridge.call( + lambda c: c.command_execute("teach stop", ""), + description="/teach stop", + ) + except Exception: + pass + app.exit() return - _apply_daemon_runtime_metadata({"host_backend": result}) - _print_host_status(console, result) - _update_status() - return - if canonical == "clear": - _render_banner() - return - if canonical == "tools": - try: - payload = await bridge.call( - lambda current_client: current_client.tools_list(), - description="tools list", - ) - except Exception as exc: - console.warning(f"Tools unavailable: {exc}") + if canonical == "help": + _show_help(console, runtime="daemon") return - render_tools_payload(console, payload) - return - if canonical == "usage": - try: - payload = await bridge.call( - lambda current_client: current_client.usage_summary(), - description="usage summary", - ) - except Exception as exc: - console.warning(f"Usage unavailable: {exc}") + if canonical == "clear": + _render_banner() return - render_usage_payload(console, payload) + # Task control commands are handled by the existing _handle_task_control + _handle_task_control(text) return - if canonical == "model": - try: - payload = await bridge.call( - lambda current_client: current_client.model_info(cmd_args), - description="model info", - ) - except Exception as exc: - console.warning(f"Model info unavailable: {exc}") - return - render_model_payload(console, payload) - return - if _is_app_command(canonical): - app_args = invocation.text[len("app"):].strip() - try: - payload = await bridge.call( - lambda current_client: current_client.app_command(app_args), - description="app command", - ) - except Exception as exc: - console.warning(f"App Connector unavailable: {exc}") - return - render_app_payload(console, payload) + + # Engine-routed commands: dispatch through daemon RPC + try: + payload = await bridge.call( + lambda current_client: current_client.command_execute(canonical, cmd_args), + description=f"/{canonical}", + ) + except Exception as exc: + console.warning(f"/{canonical} failed: {exc}") return - if canonical == "run": - if not cmd_args: - console.warning("Usage: /run ") - return + + # Track session mode changes (teach start/stop/discard) + new_mode = payload.get("session_mode") + if isinstance(new_mode, str): + daemon_session_mode = new_mode + if new_mode == "learning": + app.prompt_mode = "learning" + elif new_mode == "paused": + app.prompt_mode = "paused" + else: + app.prompt_mode = "daemon" + + # Special case: streaming commands (like /run) need chat stream + if payload.get("stream") and payload.get("prompt"): console.system("Running through daemon chat stream; approvals and host state stay visible.") - await _stream_response(cmd_args) + await _stream_response(str(payload["prompt"])) return - console.warning( - f"/{canonical} is not available in daemon mode yet. " - "Use --no-daemon for legacy in-process commands." - ) + + # Special case: /host with runtime metadata + if payload.get("view") == "host" and payload.get("result"): + _apply_daemon_runtime_metadata({"host_backend": payload["result"]}) + _update_status() + + # Render: app-specific views use app renderer, others use generic + view = str(payload.get("view") or "") + if view in ("list", "guide", "connect", "disconnect", "remove", "events", "actions") and "result" in payload: + render_app_payload(console, payload) + else: + render_command_payload(console, payload) + return + + # Learning mode: route through engine (chat-demonstration recording) + # and also annotate for distillation context. + if daemon_session_mode == "learning": + try: + await bridge.call( + lambda current_client: current_client.command_execute("annotate", text), + description="annotate", + ) + except Exception: + pass + console.rule() + await _stream_response(text) return console.rule() @@ -1319,11 +1308,73 @@ def _handle_task_control(text: str) -> bool: _render_banner() _update_status() + + # Background notification subscription for push events (distillation progress, etc.) + _notification_task: asyncio.Task[None] | None = None + + async def _notification_listener() -> None: + """Subscribe to daemon notifications and render them in TUI.""" + nonlocal daemon_session_mode + from leapflow.daemon.client import DaemonUnavailableError + while True: + try: + # Reset stale state on each (re)connection attempt + status.distill_phase = "" + status.distill_progress = 0.0 + async for event in bridge.client.subscribe_notifications(): + event_type = event.get("event_type", "") + payload = event.get("payload") or {} + if event_type == "teach.progress": + status.distill_phase = str(payload.get("phase", "")) + status.distill_progress = float(payload.get("progress", 0.0)) + app.invalidate() + elif event_type == "teach.stopped": + daemon_session_mode = "idle" + app.prompt_mode = "daemon" + reason = payload.get("reason", "") + if reason == "idle_timeout": + console.warning("Recording stopped automatically (idle timeout).") + else: + console.system("Recording stopped.") + _update_status() + elif event_type == "teach.complete": + status.distill_phase = "" + status.distill_progress = 0.0 + new_skills = payload.get("new_skills") or [] + candidate_count = payload.get("candidate_count", 0) + if new_skills: + console.success( + f"Distillation complete — {len(new_skills)} new skill(s)" + ) + for name in new_skills: + console.system(f" → {name}") + elif candidate_count > 0: + console.system( + f"Distillation complete — {candidate_count} candidate(s), none activated" + ) + else: + console.system("Distillation complete — no skills produced (insufficient signal)") + app.invalidate() + except (DaemonUnavailableError, OSError, asyncio.IncompleteReadError): + await asyncio.sleep(3.0) + except asyncio.CancelledError: + break + except Exception: + logger.debug("notification_listener: unexpected error", exc_info=True) + await asyncio.sleep(5.0) + exit_code = 0 try: await client_lease.start() + _notification_task = asyncio.create_task(_notification_listener()) exit_code = await app.run() finally: + if _notification_task is not None: + _notification_task.cancel() + try: + await _notification_task + except (asyncio.CancelledError, Exception): + pass await client_lease.stop() _print_exit_summary() await _prompt_stop_daemon_on_exit(bridge.client, settings, console) @@ -1618,16 +1669,13 @@ def _show_help(console, runtime: str = "in_process") -> None: ] if visible: aliases = f" [dim]({', '.join(visible)})[/]" - support = "" - if not cmd.supports_runtime(runtime): # type: ignore[arg-type] - support = " [dim]not in daemon[/]" if runtime == "daemon" else " [dim]not in this mode[/]" effect = "" if cmd.requires_host: effect = " [dim]host[/]" elif cmd.requires_llm: effect = " [dim]llm[/]" console.print( - f" [cyan]{name:<28}[/] {cmd.description}{aliases}{effect}{support}" + f" [cyan]{name:<28}[/] {cmd.description}{aliases}{effect}" ) console.print() diff --git a/src/leapflow/cli/commands/registry.py b/src/leapflow/cli/commands/registry.py index f587742..e879237 100644 --- a/src/leapflow/cli/commands/registry.py +++ b/src/leapflow/cli/commands/registry.py @@ -48,53 +48,54 @@ class CommandDef: category: str aliases: Tuple[str, ...] = () args_hint: str = "" - supports_in_process: bool = True - supports_daemon: bool = False + client_local: bool = False requires_host: bool = False requires_llm: bool = False effect: CommandEffect = CommandEffect.READ_ONLY execution: CommandExecution = CommandExecution.INSTANT def supports_runtime(self, runtime: CommandRuntime) -> bool: - """Return whether this command can execute in the requested runtime.""" - if runtime == "daemon": - return self.supports_daemon - return self.supports_in_process + """All commands support all runtimes. + + Client-local commands are handled directly by the TUI without RPC. + Engine-routed commands are dispatched through daemon RPC in daemon mode. + """ + return True # ── Registry (single source of truth) ──────────────────────────────── COMMAND_REGISTRY: Tuple[CommandDef, ...] = ( - # Session - CommandDef("help", "Show available commands", "Session", aliases=("?", "帮助"), supports_daemon=True), - CommandDef("clear", "Clear screen and reset display", "Session", supports_daemon=True), - CommandDef("status", "Show session info, model, context, and platform", "Session", supports_daemon=True), + # Session (client_local: handled directly by the TUI without daemon RPC) + CommandDef("help", "Show available commands", "Session", aliases=("?", "帮助"), client_local=True), + CommandDef("clear", "Clear screen and reset display", "Session", client_local=True), + CommandDef("exit", "Quit LeapFlow", "Session", aliases=("quit", "q", "退出"), client_local=True), + CommandDef("status", "Show session info, model, context, and platform", "Session"), CommandDef( "host", "Start, stop, or inspect CuaDriver OS control", "Session", args_hint="[status|start|stop|restart]", - supports_daemon=True, effect=CommandEffect.HOST_CONTROL, execution=CommandExecution.SHORT_OPERATION, ), - CommandDef("exit", "Quit LeapFlow", "Session", aliases=("quit", "q", "退出"), supports_daemon=True), - # Task Control - CommandDef("cancel", "Cancel the currently running task", "Task Control", aliases=("abort",), supports_daemon=True, effect=CommandEffect.SESSION, execution=CommandExecution.SHORT_OPERATION), - CommandDef("skip", "Skip the current running task and continue the queue", "Task Control", supports_daemon=True, effect=CommandEffect.SESSION, execution=CommandExecution.SHORT_OPERATION), - CommandDef("pause", "Pause starting queued tasks", "Task Control", supports_daemon=True, effect=CommandEffect.SESSION, execution=CommandExecution.INSTANT), - CommandDef("resume", "Resume queued task execution", "Task Control", supports_daemon=True, effect=CommandEffect.SESSION, execution=CommandExecution.INSTANT), - CommandDef("queue", "Show or clear queued tasks", "Task Control", args_hint="[clear]", supports_daemon=True, effect=CommandEffect.READ_ONLY), - CommandDef("drop", "Remove a queued task by id", "Task Control", args_hint="", supports_daemon=True, effect=CommandEffect.SESSION), + # Task Control (client_local: queue state is TUI-side) + CommandDef("cancel", "Cancel the currently running task", "Task Control", aliases=("abort",), client_local=True, effect=CommandEffect.SESSION, execution=CommandExecution.SHORT_OPERATION), + CommandDef("skip", "Skip the current running task and continue the queue", "Task Control", client_local=True, effect=CommandEffect.SESSION, execution=CommandExecution.SHORT_OPERATION), + CommandDef("pause", "Pause starting queued tasks", "Task Control", client_local=True, effect=CommandEffect.SESSION, execution=CommandExecution.INSTANT), + CommandDef("resume", "Resume queued task execution", "Task Control", client_local=True, effect=CommandEffect.SESSION, execution=CommandExecution.INSTANT), + CommandDef("queue", "Show or clear queued tasks", "Task Control", args_hint="[clear]", client_local=True, effect=CommandEffect.READ_ONLY), + CommandDef("drop", "Remove a queued task by id", "Task Control", args_hint="", client_local=True, effect=CommandEffect.SESSION), # Chat - CommandDef("model", "Show or switch active model", "Chat", args_hint="[model_name]", supports_daemon=True, requires_llm=True), - CommandDef("usage", "Show token usage for current session", "Chat", supports_daemon=True, requires_llm=True), + CommandDef("model", "Show or switch active model", "Chat", args_hint="[model_name]", requires_llm=True), + CommandDef("usage", "Show token usage for current session", "Chat", requires_llm=True), # Teaching CommandDef("teach start", "Start teaching mode", "Teaching", aliases=("teach",), args_hint="[goal]"), CommandDef("teach stop", "Stop and distill skill", "Teaching"), + CommandDef("teach status", "Show distillation progress", "Teaching"), CommandDef("teach pause", "Pause recording", "Teaching"), CommandDef("teach resume", "Resume recording", "Teaching"), CommandDef("teach discard", "Discard current recording", "Teaching"), @@ -106,8 +107,8 @@ def supports_runtime(self, runtime: CommandRuntime) -> bool: CommandDef("skills show", "Show skill details", "Skills & Tools", args_hint=""), CommandDef("skills disable", "Disable a skill", "Skills & Tools", args_hint=""), CommandDef("skills delete", "Delete a skill", "Skills & Tools", args_hint=""), - CommandDef("tools", "List available tools", "Skills & Tools", supports_daemon=True), - CommandDef("run", "Execute a skill by trigger", "Skills & Tools", args_hint="", supports_daemon=True, requires_llm=True, execution=CommandExecution.STREAMING), + CommandDef("tools", "List available tools", "Skills & Tools"), + CommandDef("run", "Execute a skill by trigger", "Skills & Tools", args_hint="", requires_llm=True, execution=CommandExecution.STREAMING), # Hub CommandDef("hub push", "Push skill to hub", "Hub", args_hint=""), @@ -118,14 +119,14 @@ def supports_runtime(self, runtime: CommandRuntime) -> bool: CommandDef("gateway", "Show connected platforms and gateway status", "Gateway", effect=CommandEffect.EXTERNAL), # App Connector - CommandDef("app", "List supported external apps or open an app setup guide", "App Connector", args_hint="[platform]", supports_daemon=True), - CommandDef("app list", "List supported external apps", "App Connector", supports_daemon=True), - CommandDef("app status", "Show App Connector status", "App Connector", args_hint="[platform]", supports_daemon=True), + CommandDef("app", "List supported external apps or open an app setup guide", "App Connector", args_hint="[platform]"), + CommandDef("app list", "List supported external apps", "App Connector"), + CommandDef("app status", "Show App Connector status", "App Connector", args_hint="[platform]"), CommandDef("app connect", "Connect a supported external app", "App Connector", args_hint=" [--option value]", effect=CommandEffect.EXTERNAL, execution=CommandExecution.SHORT_OPERATION), CommandDef("app disconnect", "Disconnect an external app but keep configuration", "App Connector", args_hint="", effect=CommandEffect.EXTERNAL, execution=CommandExecution.SHORT_OPERATION), CommandDef("app remove", "Remove an app configuration", "App Connector", args_hint="", effect=CommandEffect.DESTRUCTIVE, execution=CommandExecution.SHORT_OPERATION), CommandDef("app events", "Inspect or control an app event source", "App Connector", args_hint="[status|start|stop] ", effect=CommandEffect.EXTERNAL, execution=CommandExecution.SHORT_OPERATION), - CommandDef("app actions", "List App Connector action domains", "App Connector", args_hint="", supports_daemon=True), + CommandDef("app actions", "List App Connector action domains", "App Connector", args_hint=""), # Scheduler CommandDef("arm", "Schedule a skill for timed execution", "Scheduler", args_hint=" "), diff --git a/src/leapflow/cli/commands/router.py b/src/leapflow/cli/commands/router.py index ac55837..c1a4424 100644 --- a/src/leapflow/cli/commands/router.py +++ b/src/leapflow/cli/commands/router.py @@ -83,13 +83,9 @@ def parse(self, raw_text: str) -> CommandInvocation | None: ) def unsupported_result(self, invocation: CommandInvocation) -> CommandResult | None: - """Return a standard unsupported-runtime result when needed.""" - if invocation.command.supports_runtime(invocation.runtime): - return None - mode = "daemon" if invocation.runtime == "daemon" else "in-process" - return CommandResult( - ok=False, - title=f"/{invocation.command.name} is not available in {mode} mode yet.", - summary="The command is registered, but its execution backend has not reached parity in this runtime.", - next_actions=("Use /help to see runtime support", "Try --no-daemon if this is a legacy command"), - ) + """Return None — all commands now support all runtimes. + + Client-local commands are handled directly by the TUI. + Engine-routed commands are dispatched through daemon RPC. + """ + return None diff --git a/src/leapflow/cli/commands/slash_handlers.py b/src/leapflow/cli/commands/slash_handlers.py index 6233115..687e1d9 100644 --- a/src/leapflow/cli/commands/slash_handlers.py +++ b/src/leapflow/cli/commands/slash_handlers.py @@ -732,7 +732,580 @@ async def handle_app(ctx: "Context", console: "LeapConsole", args: str) -> None: render_app_payload(console, await build_app_payload(ctx, args)) - def handle_clear(ctx: "Context", console: "LeapConsole", args: str) -> None: """Clear the terminal screen.""" os.system("cls" if os.name == "nt" else "clear") + + +# ══════════════════════════════════════════════════════════════════════ +# Unified command_execute: dispatches any engine-routed slash command +# ══════════════════════════════════════════════════════════════════════ + + +async def command_execute(ctx: "Context", name: str, args: str = "") -> dict[str, Any]: + """Execute a slash command and return a serializable result payload. + + This is the unified entry point for daemon-mode command execution. + Returns a dict with at minimum ``ok`` and ``message`` keys, plus + optional structured data for rich TUI rendering. + """ + if name == "status": + return build_status_payload(ctx) + if name == "tools": + return build_tools_payload(ctx) + if name == "usage": + return build_usage_payload(ctx) + if name == "model": + return build_model_payload(ctx, args) + if name == "gateway": + return build_gateway_payload(ctx) + if name == "host": + return await _execute_host(ctx, args) + if _is_app_command_name(name): + app_args = name[len("app"):].strip() + if app_args: + app_args = app_args + (" " + args if args else "") + else: + app_args = args + return await build_app_payload(ctx, app_args) + if name.startswith("teach") or name == "annotate": + return await _execute_teach(ctx, name, args) + if name.startswith("skills"): + return _execute_skills(ctx, name, args) + if name.startswith("hub"): + return await _execute_hub(ctx, name, args) + if name == "run": + return {"ok": True, "stream": True, "prompt": args} + if name == "arm": + return await _execute_scheduler_arm(ctx, args) + if name == "tasks": + return _execute_scheduler_tasks(ctx) + return {"ok": False, "message": f"Unknown command: /{name}"} + + +def _is_app_command_name(name: str) -> bool: + return name == "app" or name.startswith("app ") + + +def build_status_payload(ctx: "Context") -> dict[str, Any]: + """Build a serializable status summary.""" + engine = ctx.engine + context_length = 0 + context_used = 0 + turn_count = 0 + if engine is not None: + cap_registry = getattr(engine, "model_capabilities", None) + if cap_registry is not None: + caps = cap_registry.resolve(ctx.settings.llm_model) + context_length = int(caps.context_length) + context_used = int(getattr(engine, "context_token_count", 0)) + turn_count = int(getattr(engine, "turn_count", 0)) + + platform_status = "connected" if (hasattr(ctx.rpc, "connected") and ctx.rpc.connected) else "mock" + cwd = os.getcwd().replace(os.path.expanduser("~"), "~") + + from leapflow.engine.session import SessionMode + mode = "idle" + if ctx.session: + if ctx.session.mode == SessionMode.LEARNING: + mode = "learning" + elif ctx.session.mode == SessionMode.EXECUTING: + mode = "executing" + + gateway_connected: list[str] = [] + gw = getattr(ctx, "gateway_server", None) + if gw is not None: + statuses = gw.platform_status() + for s in statuses: + if s.connected: + m = gw.manifests.get(s.platform_id) + gateway_connected.append(m.display_name if m else s.platform_id) + + return { + "ok": True, + "view": "status", + "model": ctx.settings.llm_model, + "context_length": context_length, + "context_used": context_used, + "turn_count": turn_count, + "platform": platform_status, + "cwd": cwd, + "config_path": str(ctx.settings.data_dir / ".env").replace(os.path.expanduser("~"), "~"), + "session_id": getattr(ctx.session, "session_id", "") if ctx.session else "", + "mode": mode, + "gateway_connected": gateway_connected, + } + + +def build_gateway_payload(ctx: "Context") -> dict[str, Any]: + """Build a serializable gateway status payload.""" + import time as _time + + gw = getattr(ctx, "gateway_server", None) + if gw is None: + return {"ok": False, "message": "Gateway not initialised."} + + statuses = gw.platform_status() + if not statuses: + return {"ok": True, "view": "gateway", "connected": [], "configured": [], "available": []} + + connected = [] + configured = [] + available = [] + for s in statuses: + m = gw.manifests.get(s.platform_id) + name = m.display_name if m else s.platform_id + if s.connected: + uptime = "" + if s.connected_since > 0: + secs = int(_time.time() - s.connected_since) + if secs < 60: + uptime = f"{secs}s" + elif secs < 3600: + uptime = f"{secs // 60}m" + else: + uptime = f"{secs // 3600}h {(secs % 3600) // 60}m" + connected.append({"name": name, "id": s.platform_id, "uptime": uptime}) + elif s.error: + configured.append({"name": name, "id": s.platform_id}) + else: + available.append({"name": name, "id": s.platform_id}) + return {"ok": True, "view": "gateway", "connected": connected, "configured": configured, "available": available} + + +async def _execute_host(ctx: "Context", args: str) -> dict[str, Any]: + """Execute /host command.""" + action = args.strip().lower() if args.strip() else "status" + if action not in {"status", "start", "stop", "restart"}: + return {"ok": False, "message": "Usage: /host [status|start|stop|restart]"} + if action == "status": + result = await ctx.host_backend_status() + elif action == "start": + result = await ctx.host_backend_start() + elif action == "stop": + result = await ctx.host_backend_stop() + else: + result = await ctx.host_backend_restart() + return {"ok": True, "view": "host", "action": action, "result": result} + + +async def _distill_background(session) -> None: + """Run distillation in background without blocking the RPC response.""" + import logging + _log = logging.getLogger(__name__) + try: + final = await session.await_learning() + if final and final.candidates: + _log.info( + "background_distill: %d candidates, activated=%s", + len(final.candidates), + final.activated_skill_names or [], + ) + else: + _log.info("background_distill: no candidates produced") + except Exception: + _log.warning("background_distill failed", exc_info=True) + + +async def _execute_teach(ctx: "Context", name: str, args: str) -> dict[str, Any]: + """Execute teach commands. + + Returns ``session_mode`` in the payload so the TUI client can track + whether it should route subsequent inputs as annotations. + """ + from leapflow.engine.session import SessionMode + + full_cmd = name + (" " + args if args else "") + if full_cmd in ("teach start", "teach") or full_cmd.startswith("teach start "): + if ctx.session is None: + return {"ok": False, "message": "No active session.", "session_mode": "idle"} + if ctx.session.mode == SessionMode.LEARNING: + return {"ok": False, "message": "Already in teaching mode. Say '/teach stop' to end.", "session_mode": "learning"} + goal = args if name == "teach start" else "" + try: + session = await ctx.session.enter_learning(goal=goal) + msg = f"Teaching started — session {session.session_id}" + if goal: + msg += f"\nGoal: {goal}" + msg += "\nCommands: /teach stop │ /teach discard │ /teach pause │ /teach resume │ /teach skip [n] │ /annotate " + return {"ok": True, "message": msg, "session_mode": "learning"} + except Exception as e: + return {"ok": False, "message": str(e)} + + if name == "teach stop": + if not ctx.session or ctx.session.mode != SessionMode.LEARNING: + return {"ok": False, "message": "Not in teaching mode.", "session_mode": "idle"} + try: + result = await ctx.session.exit_learning() + msg = f"Recording stopped — {result.step_count} steps, {result.duration:.1f}s" + if result.step_count == 0: + msg += "\nNo steps recorded — nothing to distill." + elif not getattr(ctx.settings, "has_llm_credentials", False): + msg += "\nNo LLM credentials — distillation skipped." + elif ctx.session.has_pending_distillation: + import asyncio + asyncio.create_task(_distill_background(ctx.session)) + msg += "\nDistillation started in background." + else: + report = getattr(result, "learnability_report", None) + reason = getattr(report, "reason", "assessment decided to skip") if report else "auto-learn disabled" + msg += f"\nDistillation skipped: {reason}" + return { + "ok": True, + "message": msg, + "step_count": result.step_count, + "duration": result.duration, + "session_mode": "idle", + } + except Exception as e: + return {"ok": False, "message": str(e), "session_mode": "idle"} + + if name == "teach pause": + if not ctx.session or ctx.session.mode != SessionMode.LEARNING: + return {"ok": False, "message": "Not in teaching mode."} + ctx.session.pause_learning() + return {"ok": True, "message": "Teaching paused.", "session_mode": "paused"} + + if name == "teach resume": + if not ctx.session or ctx.session.mode != SessionMode.LEARNING: + return {"ok": False, "message": "Not in teaching mode."} + ctx.session.resume_learning() + return {"ok": True, "message": "Teaching resumed.", "session_mode": "learning"} + + if name == "teach discard": + if not ctx.session or ctx.session.mode != SessionMode.LEARNING: + return {"ok": False, "message": "Not in teaching mode."} + ctx.session.discard_learning() + return {"ok": True, "message": "Recording discarded.", "session_mode": "idle"} + + if name == "teach skip": + if not ctx.session or ctx.session.mode != SessionMode.LEARNING: + return {"ok": False, "message": "Not in teaching mode."} + n = 1 + if args.strip().isdigit(): + n = int(args.strip()) + ctx.session.skip_steps(n) + return {"ok": True, "message": f"Marked last {n} step(s) as noise.", "session_mode": "learning"} + + if name == "annotate": + if not ctx.session or ctx.session.mode != SessionMode.LEARNING: + return {"ok": False, "message": "Not in teaching mode."} + if not args.strip(): + return {"ok": False, "message": "Usage: /annotate "} + ctx.session.annotate(args.strip()) + return {"ok": True, "message": f"Annotation recorded: {args.strip()}", "session_mode": "learning"} + + if name == "teach status": + if not ctx.session: + return {"ok": True, "message": "No active session.", "session_mode": "idle"} + mode = ctx.session.mode.value + result_payload: dict[str, Any] = {"ok": True, "session_mode": mode} + if mode == "evolving" or ctx.session.is_distilling: + result_payload["message"] = "Distillation in progress…" + result_payload["distilling"] = True + elif mode == "learning": + step_count = ctx.session.recording_step_count + result_payload["message"] = f"Recording — {step_count} steps captured." + result_payload["step_count"] = step_count + else: + last = ctx.session.last_result + if last: + candidates = getattr(last, "candidates", None) or [] + new_skills = getattr(last, "new_skills", None) or [] + result_payload["message"] = ( + f"Idle. Last result: {last.step_count} steps, " + f"{len(candidates)} candidates, " + f"{len(new_skills)} activated." + ) + else: + result_payload["message"] = "Idle. No previous distillation results." + return result_payload + + return {"ok": False, "message": f"Unknown teach command: /{full_cmd}"} + + +def _execute_skills(ctx: "Context", name: str, args: str) -> dict[str, Any]: + """Execute skills commands.""" + full_cmd = name + (" " + args if args else "") + if full_cmd in ("skills", "skills list"): + skills = ctx.registry.list_all() if ctx.registry else [] + if not skills: + return {"ok": True, "view": "skills_list", "skills": []} + entries = [] + for s in skills: + m = s.metadata + entries.append({ + "name": s.name, + "version": m.version, + "confidence": m.confidence, + "description": s.description[:80], + }) + return {"ok": True, "view": "skills_list", "skills": entries} + + if name == "skills show": + skill_name = args.strip() + if not skill_name: + return {"ok": False, "message": "Usage: /skills show "} + skill = ctx.registry.get(skill_name) if ctx.registry else None + if skill is None: + return {"ok": False, "message": f"Skill '{skill_name}' not found."} + m = skill.metadata + return { + "ok": True, + "view": "skills_show", + "name": skill.name, + "description": skill.description, + "version": m.version, + "confidence": m.confidence, + "triggers": list(skill.triggers) if skill.triggers else [], + } + + if name == "skills disable": + skill_name = args.strip() + if not skill_name: + return {"ok": False, "message": "Usage: /skills disable "} + found = False + if ctx.skill_lib and ctx.skill_lib.deactivate_parameterized(skill_name): + found = True + if ctx.registry and ctx.registry.unregister(skill_name): + found = True + if found: + return {"ok": True, "message": f"Skill '{skill_name}' disabled."} + return {"ok": False, "message": f"Skill '{skill_name}' not found."} + + if name == "skills delete": + skill_name = args.strip() + if not skill_name: + return {"ok": False, "message": "Usage: /skills delete "} + found = False + if ctx.skill_lib: + stored = ctx.skill_lib.load_skill_by_title(skill_name) + if stored: + stored.status = "deleted" + ctx.skill_lib.update_skill(stored) + found = True + if ctx.registry and ctx.registry.unregister(skill_name): + found = True + if found: + return {"ok": True, "message": f"Skill '{skill_name}' deleted."} + return {"ok": False, "message": f"Skill '{skill_name}' not found."} + + return {"ok": False, "message": f"Unknown skills command: /{full_cmd}"} + + +async def _execute_hub(ctx: "Context", name: str, args: str) -> dict[str, Any]: + """Execute hub commands.""" + try: + from leapflow.cli.commands.hub import cmd_hub_payload + return await cmd_hub_payload(ctx, name, args) + except ImportError: + pass + # Fallback: basic hub dispatch + sub = name[len("hub"):].strip() if name.startswith("hub") else "" + if not sub: + sub = args.split()[0] if args.split() else "" + args = " ".join(args.split()[1:]) + command_parts = ["/hub"] + if sub: + command_parts.append(sub) + if args: + command_parts.append(args) + command = " ".join(command_parts) + return {"ok": False, "message": f"Hub command '{command}' is not yet implemented in this runtime."} + + +def _execute_scheduler_tasks(ctx: "Context") -> dict[str, Any]: + """Execute /tasks command.""" + scheduler = getattr(ctx, "scheduler", None) + if scheduler is None: + return {"ok": True, "view": "tasks", "tasks": [], "message": "No scheduler active."} + tasks = scheduler.list_tasks() if hasattr(scheduler, "list_tasks") else [] + entries = [ + {"name": t.name, "schedule": t.schedule, "next_run": str(getattr(t, "next_run", ""))} + for t in tasks + ] + return {"ok": True, "view": "tasks", "tasks": entries} + + +async def _execute_scheduler_arm(ctx: "Context", args: str) -> dict[str, Any]: + """Execute /arm command.""" + scheduler = getattr(ctx, "scheduler", None) + if scheduler is None: + return {"ok": False, "message": "Scheduler not active in this session."} + tokens = args.strip().split(None, 1) + if len(tokens) < 2: + return {"ok": False, "message": "Usage: /arm "} + skill_name, cron_expr = tokens + try: + task_id = await scheduler.arm(skill_name, cron_expr) + return {"ok": True, "message": f"Armed: {skill_name} → {cron_expr} (id={task_id})"} + except Exception as e: + return {"ok": False, "message": str(e)} + + +def render_command_payload(console: "LeapConsole", payload: dict[str, Any]) -> None: + """Render a generic command_execute result payload in the TUI.""" + if not payload.get("ok"): + console.warning(str(payload.get("message") or payload.get("error") or "Command failed.")) + return + + view = str(payload.get("view") or "") + + if view == "status": + _render_status_view(console, payload) + return + if view == "gateway": + _render_gateway_view(console, payload) + return + if view == "host": + _render_host_view(console, payload) + return + if view == "skills_list": + _render_skills_list_view(console, payload) + return + if view == "skills_show": + _render_skills_show_view(console, payload) + return + if view == "tasks": + _render_tasks_view(console, payload) + return + + msg = payload.get("message") + if msg: + console.success(str(msg)) + + +def _render_status_view(console: "LeapConsole", payload: dict[str, Any]) -> None: + from rich.panel import Panel + from rich.text import Text + + info = Text() + info.append("Model: ", style="dim") + info.append(f"{payload.get('model')}\n", style="bold") + + ctx_len = int(payload.get("context_length") or 0) + ctx_used = int(payload.get("context_used") or 0) + turn_count = int(payload.get("turn_count") or 0) + if ctx_len: + pct = int(ctx_used * 100 / ctx_len) if ctx_len else 0 + info.append("Context: ", style="dim") + pct_style = "bold red" if pct >= 90 else ("yellow" if pct >= 75 else "") + info.append(f"{ctx_used:,} / {ctx_len:,} ({pct}%)\n", style=pct_style) + info.append("Turns: ", style="dim") + info.append(f"{turn_count}\n") + info.append("Platform: ", style="dim") + p_status = str(payload.get("platform") or "") + p_style = "green" if p_status == "connected" else "dim" + info.append(f"{p_status}\n", style=p_style) + info.append("CWD: ", style="dim") + info.append(f"{payload.get('cwd')}\n") + info.append("Config: ", style="dim") + info.append(f"{payload.get('config_path')}\n") + session_id = payload.get("session_id") + if session_id: + info.append("Session: ", style="dim") + info.append(f"{session_id}\n") + info.append("Mode: ", style="dim") + info.append(f"{payload.get('mode')}\n") + gw = payload.get("gateway_connected") or [] + if gw: + info.append("Gateway: ", style="dim") + info.append(f"{', '.join(gw)}\n", style="green") + + console.print(Panel(info, title="[bold cyan]LeapFlow Status[/]", border_style="bright_black", padding=(0, 2))) + + +def _render_gateway_view(console: "LeapConsole", payload: dict[str, Any]) -> None: + from rich.panel import Panel + from rich.text import Text + + info = Text() + connected = payload.get("connected") or [] + configured = payload.get("configured") or [] + available = payload.get("available") or [] + + if connected: + info.append("Connected\n", style="bold green") + for entry in connected: + uptime = f" ({entry['uptime']})" if entry.get("uptime") else "" + info.append(f" ● {entry['name']}{uptime}\n", style="green") + if configured: + info.append("Configured (not connected)\n", style="bold yellow") + for entry in configured: + info.append(f" ○ {entry['name']}\n", style="yellow") + if available: + info.append("Available\n", style="bold dim") + names = [entry["name"] for entry in available] + info.append(f" {', '.join(names)}\n", style="dim") + info.append("\n", style="dim") + info.append('Say "connect to " to set up a new integration.', style="dim italic") + console.print(Panel(info, title="[bold cyan]Gateway[/]", border_style="bright_black", padding=(0, 2))) + + +def _render_host_view(console: "LeapConsole", payload: dict[str, Any]) -> None: + result = payload.get("result") or {} + action = payload.get("action") or "status" + if action != "status": + console.success(f"Host {action} completed.") + lines = [] + for key in ("status", "backend", "pid", "session_id"): + if key in result: + lines.append(f" {key}: {result[key]}") + if lines: + console.system("\n".join(lines)) + + +def _render_skills_list_view(console: "LeapConsole", payload: dict[str, Any]) -> None: + from rich.table import Table + + skills = payload.get("skills") or [] + if not skills: + console.system("No skills registered.") + return + table = Table(show_header=True, header_style="bold", border_style="dim") + table.add_column("Name", style="cyan", max_width=30) + table.add_column("Version", justify="center") + table.add_column("Confidence", justify="center") + table.add_column("Description", max_width=40) + for s in skills: + table.add_row( + str(s.get("name") or ""), + f"v{s.get('version', 0)}", + f"{float(s.get('confidence') or 0):.0%}", + str(s.get("description") or "")[:40], + ) + console.print(table) + + +def _render_skills_show_view(console: "LeapConsole", payload: dict[str, Any]) -> None: + from rich.panel import Panel + from rich.text import Text + + info = Text() + info.append(f"Name: {payload.get('name')}\n") + info.append(f"Description: {payload.get('description')}\n") + info.append(f"Version: v{payload.get('version', 0)}\n") + info.append(f"Confidence: {float(payload.get('confidence') or 0):.0%}\n") + triggers = payload.get("triggers") or [] + if triggers: + info.append(f"Triggers: {', '.join(triggers)}") + console.print(Panel(info, title=str(payload.get("name") or "Skill"), border_style="cyan")) + + +def _render_tasks_view(console: "LeapConsole", payload: dict[str, Any]) -> None: + tasks = payload.get("tasks") or [] + msg = payload.get("message") + if msg: + console.system(str(msg)) + return + if not tasks: + console.system("No scheduled tasks.") + return + from rich.table import Table + table = Table(show_header=True, header_style="bold", border_style="dim") + table.add_column("Name", style="cyan") + table.add_column("Schedule") + table.add_column("Next Run") + for t in tasks: + table.add_row(str(t.get("name") or ""), str(t.get("schedule") or ""), str(t.get("next_run") or "")) + console.print(table) diff --git a/src/leapflow/cli/context.py b/src/leapflow/cli/context.py index 1cbecf2..028d56e 100644 --- a/src/leapflow/cli/context.py +++ b/src/leapflow/cli/context.py @@ -85,8 +85,12 @@ def set_handler(self, handler: Optional[Callable[["ApprovalRequest"], Any]]) -> _CATEGORY_LABELS = { "shell_dangerous": ("Shell Command", "yellow"), + "shell.command": ("Shell Command", "yellow"), + "file.read": ("Sensitive File Read", "yellow"), + "file.write": ("File Write", "yellow"), "file_write": ("File Write", "yellow"), "gateway_send": ("External Message", "cyan"), + "gateway.send": ("External Message", "cyan"), } async def request_approval( @@ -123,9 +127,11 @@ async def check(self, command: str) -> bool: def _default_recording_profile(settings: Settings) -> Optional["RecordingProfile"]: - """Build a RecordingProfile if the recording mode supports video.""" - if not settings.recording_mode.uses_video: - return None + """Build a RecordingProfile for high-fidelity recording during teach. + + Always returns a profile so that teach sessions activate InputTapObserver + and tighten FS debounce regardless of recording mode. + """ from leapflow.platform.observers import RecordingProfile return RecordingProfile() @@ -1521,6 +1527,7 @@ async def _copilot_on_idle(duration_ms: int) -> None: GatewaySessionCreated, GatewaySessionEnded, ) + from leapflow.gateway.connectors.protocol import BackendEvent from leapflow.tools.registry_bootstrap import set_gateway_server from leapflow.tools.gateway_tool import set_gateway_approval_gate @@ -1530,7 +1537,7 @@ async def _on_gateway_event(event: object) -> None: logger.info( "gateway.inbound platform=%s session=%s len=%d", event.source.platform, - event.session_key, + event.session_key or "(filtered)", len(event.text), ) episodic = self.memory.get_provider("episodic") @@ -1555,19 +1562,110 @@ async def _on_gateway_event(event: object) -> None: router = getattr(self, "_gateway_router", None) if router is not None: router.clear_session(event.session_key) + elif isinstance(event, BackendEvent): + logger.debug( + "gateway.signal platform=%s type=%s", + event.platform_id, event.event_type, + ) + episodic = self.memory.get_provider("episodic") + if episodic is not None and hasattr(episodic, "ingest"): + episodic.ingest( + "gateway.signal", + f"[{event.platform_id}] {event.event_type}", + metadata={ + "platform": event.platform_id, + "event_type": event.event_type, + "event_id": event.event_id, + }, + ) + + from leapflow.gateway.checkpoint_store import DuckDBCheckpointStore, DuckDBDeduplicationStore + from leapflow.gateway.event_bridge import GatewayEventBridge + + _checkpoint_store = DuckDBCheckpointStore(self._db_holder) + _dedup_store = DuckDBDeduplicationStore(self._db_holder) + self._gateway_event_bridge = GatewayEventBridge(self.event_bus) + + async def _on_gateway_event_with_bridge(event: object) -> None: + await _on_gateway_event(event) + await self._gateway_event_bridge.on_gateway_event(event) self.gateway_server = GatewayServer( settings.profile_dir, extra_manifest_dirs=[settings.profile_dir / "gateway" / "manifests"], - on_event=_on_gateway_event, + on_event=_on_gateway_event_with_bridge, + checkpoint_store=_checkpoint_store, + dedup_store=_dedup_store, ) self.gateway_server.discover_manifests() set_gateway_server(self.gateway_server) set_gateway_approval_gate(self._approval_orchestrator) + self._register_gateway_normalizers(settings) + async def _gateway_send(source: Any, text: str) -> None: await self.gateway_server.send_reply(source, text) + async def _gateway_indicator( + source: Any, message_id: str, phase: str, + ) -> None: + """Processing indicator via platform reactions (fire-and-forget).""" + platform = getattr(source, "platform", "") + if not platform or not message_id: + return + gw = self.gateway_server + if phase == "start": + await gw.execute_platform_action( + platform, "im.add_reaction", + {"message_id": message_id, "emoji_type": "OnIt"}, + ) + elif phase in ("done", "error"): + await gw.execute_platform_action( + platform, "im.remove_reaction", + {"message_id": message_id, "emoji_type": "OnIt"}, + ) + + async def _gateway_stream_send( + source: Any, text: str, message_id: str, + ) -> str: + """Send or update a message for streaming replies. + + When *message_id* is empty, sends a new message and returns its ID. + When *message_id* is provided, updates the existing message in place. + """ + platform = getattr(source, "platform", "") + chat_id = getattr(source, "chat_id", "") + gw = self.gateway_server + if not message_id: + result = await gw.send_message(platform, chat_id, text) + return str(result.get("message_id", "")) + else: + await gw.execute_platform_action( + platform, "im.update_message", + {"message_id": message_id, "text": text}, + ) + return message_id + + async def _gateway_context_fetch( + platform: str, message_id: str, field: str, + ) -> str: + """Fetch parent message text via platform action for thread context.""" + if not platform or not message_id: + return "" + gw = self.gateway_server + result = await gw.execute_platform_action( + platform, "im.get_messages", + {"message_ids": message_id}, + ) + if not result.get("ok"): + return "" + data = result.get("data", {}) + items = data.get("items") or data.get("messages") or [] + if isinstance(items, list) and items: + msg = items[0] if isinstance(items[0], dict) else {} + return str(msg.get("content") or msg.get("text") or "") + return "" + from leapflow.tools.registry_bootstrap import TOOL_DEFINITIONS, TOOL_HANDLERS self._gateway_router = GatewayRouter( @@ -1580,12 +1678,20 @@ async def _gateway_send(source: Any, text: str) -> None: send_fn=_gateway_send, tool_definitions=TOOL_DEFINITIONS, tool_handlers=TOOL_HANDLERS, + persistence=getattr(self, "_conversation_store", None), + indicator_fn=_gateway_indicator, + stream_send_fn=_gateway_stream_send, + context_fetch_fn=_gateway_context_fetch, ) self.gateway_server.set_message_handler( self._gateway_router.handle_message, ) + self.gateway_server.set_callback_handler( + self._gateway_router.handle_callback, + ) # ── Build CompressorConfig with LLM callbacks ── + from leapflow.engine.context_compressor import CompressorConfig async def _summarize_via_llm(prompt: str) -> str: @@ -1615,6 +1721,9 @@ async def _summarize_via_llm(prompt: str) -> str: except Exception: logger.warning("ConversationStore initialization failed", exc_info=True) + if self._conversation_store and hasattr(self, "_gateway_router"): + self._gateway_router._persistence = self._conversation_store + # ── Initialize MCP Manager + register tools into agent surface ── self._mcp_manager = None try: @@ -1804,6 +1913,36 @@ async def check(self, command: str) -> bool: except Exception: logger.debug("Smart approval setup skipped", exc_info=True) + # ── Wire File Read Approval Gate ── + try: + from leapflow.security.actions import ActionDescriptor + from leapflow.tools.registry_bootstrap import set_file_read_gate + + approval_orchestrator = self._approval_orchestrator + + class _FileReadGate: + """File read approval via the action approval orchestrator.""" + + def __init__(self) -> None: + self.denial_message = "" + + async def check( + self, + path: str, + mode: str = "raw", + sensitivity_meta: dict | None = None, + ) -> bool: + meta = dict(sensitivity_meta or {}) + action = ActionDescriptor.file_read(path, mode=mode, metadata=meta) + result = await approval_orchestrator.evaluate(action) + self.denial_message = result.denial_message if not result.approved else "" + return result.approved + + set_file_read_gate(_FileReadGate()) + logger.debug("File read approval gate: action orchestrator") + except Exception: + logger.debug("File read gate setup skipped", exc_info=True) + # ── Wire File Write Approval Gate ── try: from leapflow.security.actions import ActionDescriptor @@ -1817,8 +1956,15 @@ class _FileWriteGate: def __init__(self) -> None: self.denial_message = "" - async def check(self, path: str, content: str, mode: str = "overwrite") -> bool: - action = ActionDescriptor.file_write(path, content, mode=mode) + async def check( + self, + path: str, + content: str, + mode: str = "overwrite", + sensitivity_meta: dict | None = None, + ) -> bool: + meta = dict(sensitivity_meta or {}) + action = ActionDescriptor.file_write(path, content, mode=mode, metadata=meta) result = await approval_orchestrator.evaluate(action) self.denial_message = result.denial_message if not result.approved else "" return result.approved @@ -2286,6 +2432,67 @@ async def _vlm_call(prompt: str) -> str: time.perf_counter() - pipeline_start, phases_ok, phases_failed, ) + _NORMALIZER_FACTORIES: dict[str, type] = {} + + @staticmethod + def _get_normalizer_factories() -> dict[str, type]: + """Lazily load normalizer classes for known platforms.""" + if not Context._NORMALIZER_FACTORIES: + from leapflow.gateway.normalizers.dingtalk import DingTalkEventNormalizer + from leapflow.gateway.normalizers.feishu import FeishuEventNormalizer + from leapflow.gateway.normalizers.telegram import TelegramEventNormalizer + + Context._NORMALIZER_FACTORIES = { + "feishu": FeishuEventNormalizer, + "telegram": TelegramEventNormalizer, + "dingtalk": DingTalkEventNormalizer, + } + return Context._NORMALIZER_FACTORIES + + def _register_gateway_normalizers(self, settings: Any) -> None: + """Register event normalizers and trigger policies for all known platforms.""" + from leapflow.gateway.trigger_policy import TriggerMode, TriggerPolicy + + gw = self.gateway_server + profile = getattr(settings, "active_profile", "default") + factories = self._get_normalizer_factories() + + for platform_id, factory in factories.items(): + normalizer = factory(profile=profile) + gw.register_normalizer(platform_id, normalizer) + + opts = gw.platform_options(platform_id) + raw_mode = str(opts.get("trigger_mode", "mention_only")) + try: + trigger_mode = TriggerMode(raw_mode) + except ValueError: + logger.warning( + "Unknown trigger_mode '%s' for %s, defaulting to mention_only", + raw_mode, platform_id, + ) + trigger_mode = TriggerMode.MENTION_ONLY + + def _to_frozenset(val: Any) -> frozenset[str]: + if isinstance(val, str): + return frozenset(v.strip() for v in val.split(",") if v.strip()) + if isinstance(val, (list, tuple, set, frozenset)): + return frozenset(str(v) for v in val) + return frozenset() + + policy = TriggerPolicy( + mode=trigger_mode, + allowed_chats=_to_frozenset(opts.get("allowed_chats")), + blocked_chats=_to_frozenset(opts.get("blocked_chats")), + allowed_users=_to_frozenset(opts.get("allowed_users")), + blocked_users=_to_frozenset(opts.get("blocked_users")), + keywords=tuple( + str(k) for k in (opts.get("keywords") or []) + ) if opts.get("keywords") else (), + max_events_per_minute=int(opts.get("max_events_per_minute", 30)), + cooldown_per_chat_s=float(opts.get("cooldown_per_chat_s", 1.0)), + ) + gw.register_trigger_policy(platform_id, policy) + async def cleanup(self) -> None: # Persist evolution episodes to DuckDB before shutdown evo_store = getattr(self, "_evolution_store", None) diff --git a/src/leapflow/cli/tui_app/app.py b/src/leapflow/cli/tui_app/app.py index 5fb9b2b..b1d074c 100644 --- a/src/leapflow/cli/tui_app/app.py +++ b/src/leapflow/cli/tui_app/app.py @@ -303,6 +303,30 @@ def active_command(self) -> Optional[TuiCommand]: """Return the currently running command, if any.""" return self._active_command + def complete_active_command_in_response(self) -> Optional[TuiCommand]: + """Mark the active command done when its status is rendered with the response label.""" + if self._active_command is None: + return None + completed = self._active_command.mark_done() + self._active_command = completed + self._active_terminal_status = TuiCommandStatus.DONE + self._active_terminal_reason = "" + self._sync_task_counts() + self._invalidate() + return completed + + def block_active_command_in_response(self, reason: str) -> Optional[TuiCommand]: + """Mark the active command blocked when recovery guidance is rendered inline.""" + if self._active_command is None: + return None + blocked = self._active_command.mark_blocked(reason) + self._active_command = blocked + self._active_terminal_status = TuiCommandStatus.BLOCKED + self._active_terminal_reason = reason + self._sync_task_counts() + self._invalidate() + return blocked + def queued_commands(self) -> list[TuiCommand]: """Return a snapshot of pending commands in queue order.""" return self._pending_input.snapshot() @@ -395,6 +419,8 @@ def _terminal_command( return command.mark_cancelled(reason) if status == TuiCommandStatus.SKIPPED: return command.mark_skipped(reason) + if status == TuiCommandStatus.BLOCKED: + return command.mark_blocked(reason) return command.mark_failed(reason) def _dispatch_control_text(self, text: str) -> bool: @@ -983,6 +1009,8 @@ def _prompt_fragments(self) -> list[tuple[str, str]]: def _placeholder_text(self) -> str: """Return contextual input guidance for an empty buffer.""" + if self._prompt_mode == "learning": + return "/teach stop · /teach status · /teach pause · /teach skip · /annotate " if self._queue_paused: return "Queue paused · /resume continue · /drop remove · /queue view" if self._active_command is not None: diff --git a/src/leapflow/cli/tui_app/command.py b/src/leapflow/cli/tui_app/command.py index 69c0ee7..4734d6c 100644 --- a/src/leapflow/cli/tui_app/command.py +++ b/src/leapflow/cli/tui_app/command.py @@ -31,6 +31,7 @@ class TuiCommandStatus(str, Enum): QUEUED = "queued" RUNNING = "running" DONE = "done" + BLOCKED = "blocked" FAILED = "failed" CANCELLED = "cancelled" SKIPPED = "skipped" @@ -102,6 +103,15 @@ def mark_failed(self, error: str) -> "TuiCommand": error=_truncate(_single_line(error), _MAX_ERROR_LENGTH), ) + def mark_blocked(self, reason: str) -> "TuiCommand": + """Return a copy marked as blocked by an external action requirement.""" + return replace( + self, + status=TuiCommandStatus.BLOCKED, + finished_at=time.monotonic(), + error=_truncate(_single_line(reason), _MAX_ERROR_LENGTH), + ) + def mark_cancelled(self, reason: str = "cancelled by user") -> "TuiCommand": """Return a copy marked as cancelled with a concise reason.""" return replace( diff --git a/src/leapflow/cli/tui_app/console.py b/src/leapflow/cli/tui_app/console.py index 110167f..c8e9c07 100644 --- a/src/leapflow/cli/tui_app/console.py +++ b/src/leapflow/cli/tui_app/console.py @@ -9,7 +9,7 @@ import os import sys -from typing import Optional +from typing import Any, Mapping, Optional from rich.console import Console from rich.markdown import CodeBlock, Markdown @@ -37,6 +37,20 @@ def _format_card_elapsed(seconds: float) -> str: return f"{minutes}m{seconds - minutes * 60:.0f}s" +def _metadata_list(value: Any) -> list[str]: + """Return compact string items from a metadata scalar or sequence.""" + if value is None or value == "": + return [] + if isinstance(value, (list, tuple, set)): + return [str(item) for item in value if item] + return [str(value)] + + +def _metadata_text(metadata: Mapping[str, Any], key: str) -> str: + value = metadata.get(key) + return "" if value is None else str(value) + + def _build_rich_theme(theme: Theme | ResolvedTheme) -> RichTheme: """Map LeapFlow theme to a Rich style dict.""" return RichTheme({ @@ -137,6 +151,7 @@ def command_card(self, command: TuiCommand) -> None: TuiCommandStatus.QUEUED: "leap.border", TuiCommandStatus.RUNNING: "leap.accent", TuiCommandStatus.DONE: "leap.success", + TuiCommandStatus.BLOCKED: "leap.warning", TuiCommandStatus.FAILED: "leap.error", TuiCommandStatus.CANCELLED: "leap.warning", TuiCommandStatus.SKIPPED: "leap.warning", @@ -145,6 +160,7 @@ def command_card(self, command: TuiCommand) -> None: TuiCommandStatus.QUEUED: "leap.muted", TuiCommandStatus.RUNNING: "leap.accent", TuiCommandStatus.DONE: "leap.success", + TuiCommandStatus.BLOCKED: "leap.warning", TuiCommandStatus.FAILED: "leap.error", TuiCommandStatus.CANCELLED: "leap.warning", TuiCommandStatus.SKIPPED: "leap.warning", @@ -237,6 +253,70 @@ def error_panel(self, title: str, body: str) -> None: padding=(0, 1), )) + def permission_recovery_card(self, metadata: Mapping[str, Any]) -> None: + """Render a copy-safe permission recovery card for App Connector failures.""" + platform = _metadata_text(metadata, "platform") or _metadata_text(metadata, "onboarding_platform") + action = _metadata_text(metadata, "action") + capability = _metadata_text(metadata, "capability") + failure_code = _metadata_text(metadata, "failure_code") + recoverability = _metadata_text(metadata, "recoverability") + console_url = _metadata_text(metadata, "console_url") + recovery_hint = _metadata_text(metadata, "recovery_hint") + missing_scopes = _metadata_list(metadata.get("missing_scopes")) + required_scopes = _metadata_list(metadata.get("required_scopes")) + scope_relation = _metadata_text(metadata, "scope_relation") or "all_required" + next_steps = _metadata_list(metadata.get("next_steps")) + + body = Text() + if platform or action or capability: + target = ".".join(part for part in (platform, action) if part) + body.append("目标: ", style="leap.muted") + body.append(target or capability or "平台能力", style="leap.error") + if capability and capability != target: + body.append(f" capability={capability}", style="leap.muted") + body.append("\n") + if failure_code or recoverability: + body.append("原因: ", style="leap.muted") + body.append(failure_code or "authorization_required", style="leap.error") + if recoverability: + body.append(f" recoverability={recoverability}", style="leap.muted") + body.append("\n") + scopes = missing_scopes or required_scopes + if scopes: + label = "缺失权限" if missing_scopes else "需要权限" + if scope_relation == "one_of" and len(scopes) > 1: + body.append(f"{label}(任选其中一项即可):\n", style="leap.muted") + else: + body.append(f"{label}:\n", style="leap.muted") + scope_style = "leap.error" if missing_scopes else "leap.warning" + for scope in scopes: + body.append(f" - {scope}\n", style=scope_style) + if console_url: + body.append("开发者后台链接(可复制):\n", style="leap.muted") + body.append(f" {console_url}\n", style="leap.info") + if next_steps: + body.append("下一步:\n", style="leap.muted") + for step in next_steps[:5]: + body.append(f" {step}\n", style="leap.muted") + elif recovery_hint: + body.append("恢复提示: ", style="leap.muted") + body.append(recovery_hint, style="leap.muted") + body.append("\n") + if not console_url: + body.append("请在平台开发者后台补齐权限后重新发布/安装应用。\n", style="leap.muted") + + body.rstrip() + if not body.plain.strip(): + body = Text("请在平台开发者后台补齐权限后重新发布/安装应用。", style="leap.muted") + + self._console.print(Panel( + body, + title=Text("🔐 权限恢复", style="bold"), + title_align="left", + border_style="leap.warning", + padding=(0, 1), + )) + def rule(self, title: str = "", *, style: Optional[str] = None) -> None: """Print a horizontal rule, optionally titled.""" self._console.print(Rule( @@ -298,13 +378,29 @@ def answer_label(self) -> None: align="left", )) - def response_label(self, elapsed_s: float, *, tool_count: int = 0) -> None: - """Print the response attribution line with elapsed time.""" + def response_label( + self, + elapsed_s: float, + *, + tool_count: int = 0, + command: TuiCommand | None = None, + ) -> None: + """Print the response attribution line with optional command status.""" from leapflow.cli.tui_app.stream import _format_elapsed label = Text() - label.append(" ─ ", style="leap.border") + label.append(" |-- ", style="leap.border") label.append("LEAP", style="leap.accent") + if command is not None: + command_styles = { + TuiCommandStatus.DONE: "leap.success", + TuiCommandStatus.BLOCKED: "leap.warning", + TuiCommandStatus.FAILED: "leap.error", + TuiCommandStatus.CANCELLED: "leap.warning", + TuiCommandStatus.SKIPPED: "leap.warning", + } + command_style = command_styles.get(command.status, "leap.dim") + label.append(f" {command.label} {command.status.value}", style=command_style) elapsed_str = _format_elapsed(elapsed_s) label.append(f" {elapsed_str}", style="leap.dim") if tool_count > 0: diff --git a/src/leapflow/cli/tui_app/status.py b/src/leapflow/cli/tui_app/status.py index a05c035..8d2f4a5 100644 --- a/src/leapflow/cli/tui_app/status.py +++ b/src/leapflow/cli/tui_app/status.py @@ -97,6 +97,8 @@ def __init__(self, theme: Optional[Theme | ResolvedTheme] = None) -> None: self.last_turn_elapsed: float = 0.0 self._turn_start: float = 0.0 self._session_start: float = time.monotonic() + self.distill_phase: str = "" + self.distill_progress: float = 0.0 def mark_turn_start(self) -> None: """Record start of a new agent turn.""" @@ -169,6 +171,21 @@ def __call__(self) -> list[tuple[str, str]]: parts.append((state_cls, f"{_truncate(self.context_state, 12)} ")) parts.append(("class:status-bar.dim", "│ ")) + if self.distill_phase: + _phase_icons = { + "segment": "⚗ segment", + "abstract": "⚗ abstract", + "intent": "⚗ intent", + "distill": "⚗ distill", + "activate": "⚗ activate", + } + phase_label = _phase_icons.get(self.distill_phase, f"⚗ {self.distill_phase}") + parts.append(("class:status-bar.warn", f"{phase_label} ")) + if self.distill_progress > 0: + pct = int(self.distill_progress * 100) + parts.append(("class:status-bar", f"{pct}% ")) + parts.append(("class:status-bar.dim", "│ ")) + elapsed = time.monotonic() - self._session_start parts.append(("class:status-bar", f"{_format_duration(elapsed)} ")) diff --git a/src/leapflow/cli/tui_app/stream.py b/src/leapflow/cli/tui_app/stream.py index afdb900..5fe5522 100644 --- a/src/leapflow/cli/tui_app/stream.py +++ b/src/leapflow/cli/tui_app/stream.py @@ -20,6 +20,8 @@ from rich.text import Text +from leapflow.security.permission_failures import is_permission_failure_payload + _FINAL_RESPONSE_INDENT_SPACES = 4 _FINAL_RESPONSE_MARGIN_TOP = 1 _FINAL_RESPONSE_MARGIN_BOTTOM = 1 @@ -34,6 +36,7 @@ r"[A-Za-z_][\w.-]*(?:\s|$).*", re.MULTILINE, ) +_MARKDOWN_LINK_RE = re.compile(r"(? str: return "\n".join(collapsed).strip() +def _ensure_copyable_markdown_links(text: str) -> str: + """Append visible bare URLs for Markdown links that terminals may not expose.""" + lines: list[str] = [] + for line in text.splitlines(): + lines.append(line) + visible_line = _MARKDOWN_LINK_RE.sub(lambda match: match.group(1), line) + appended: set[str] = set() + for match in _MARKDOWN_LINK_RE.finditer(line): + url = match.group(2) + if url in visible_line or url in appended: + continue + lines.append(f"复制链接:{url}") + appended.add(url) + return "\n".join(lines) + + def _sanitize_final_response(text: str) -> str: - """Remove leaked tool protocol artifacts from user-facing final answers.""" + """Remove leaked tool protocol artifacts and keep critical links copyable.""" without_fences = _strip_tool_protocol_fences(text) without_objects = _strip_tool_protocol_json_objects(without_fences) without_audit_lines = _TOOL_AUDIT_LINE_RE.sub("", without_objects) - return _collapse_blank_lines(without_audit_lines) + with_copyable_links = _ensure_copyable_markdown_links(without_audit_lines) + return _collapse_blank_lines(with_copyable_links) def _normalize_thinking_text(text: str) -> str: @@ -121,6 +141,22 @@ def _metadata_text(metadata: dict[str, Any] | None, key: str) -> str: return value if isinstance(value, str) else ("" if value is None else str(value)) +def _is_permission_recovery_metadata(metadata: dict[str, Any] | None) -> bool: + """Return whether metadata represents a manual authorization recovery case.""" + if not metadata or metadata.get("ok", True) is not False: + return False + if is_permission_failure_payload(metadata): + return True + return bool( + _metadata_text(metadata, "console_url") + and ( + metadata.get("missing_scopes") + or metadata.get("required_scopes") + or _metadata_text(metadata, "recovery_hint") + ) + ) + + def _truncate_detail(text: str, *, limit: int = _TOOL_OUTPUT_LIMIT) -> str: compact = " ".join(text.split()) if len(compact) <= limit: @@ -157,7 +193,10 @@ def _context_tags(metadata: dict[str, Any] | None) -> list[str]: if mode and mode not in {"raw", posture}: tags.append(mode) disclosure = _metadata_text(metadata, "disclosure_level") - if disclosure and disclosure not in {"selected_tools", "minimal"}: + # CORE is the routine, always-on floor level (static low-risk whitelist); + # surfacing it on every single turn would be visual noise, so only tag + # the meaningful escalations (expanded/full) plus any unrecognized value. + if disclosure and disclosure not in {"core", "minimal"}: tags.append(f"disclosure={disclosure}") if metadata.get("tool_truncated"): tags.append("truncated") @@ -203,7 +242,11 @@ def _tool_context_detail(metadata: dict[str, Any] | None) -> str: def _tool_icon(name: str, *, ok: bool = True) -> str: if not ok: - return "❌" + # Use the same subdued "✗" glyph as console.error()/tool_result() instead + # of the full-color "❌" emoji: it still reads as a clear failure marker + # but renders in the theme's error color rather than a fixed bright red, + # so a single tool failure doesn't visually dominate the whole line. + return "✗" if name.startswith("file_list"): return "📁" if name.startswith("file_read"): @@ -278,21 +321,23 @@ class StreamRenderer: def __init__(self, console: "LeapConsole") -> None: self._console = console self._buffer: str = "" + self._pending: str = "" self._thinking_buffer: str = "" self._start_time: float = 0.0 self._tool_start_time: float = 0.0 self._active_tool: str = "" self._active_tool_detail: str = "" self._tool_history: list[tuple[str, float]] = [] + self._permission_block_reason: str = "" @property def text(self) -> str: - """Full accumulated response text.""" - return self._buffer + """Full accumulated response text (committed + pending).""" + return self._buffer + self._pending @property def has_output(self) -> bool: - return bool(self._buffer.strip() or self._thinking_buffer.strip()) + return bool(self._buffer.strip() or self._pending.strip() or self._thinking_buffer.strip()) @property def elapsed(self) -> float: @@ -303,19 +348,35 @@ def elapsed(self) -> float: def tool_count(self) -> int: return len(self._tool_history) + @property + def permission_blocked(self) -> bool: + return bool(self._permission_block_reason) + + @property + def permission_block_reason(self) -> str: + return self._permission_block_reason + def start(self) -> None: """Begin a new streaming session.""" self._buffer = "" + self._pending = "" self._thinking_buffer = "" self._active_tool = "" self._active_tool_detail = "" self._tool_history = [] + self._permission_block_reason = "" self._start_time = time.monotonic() self._tool_start_time = 0.0 def feed(self, chunk: str) -> None: - """Append a text chunk to the response buffer.""" - self._buffer += chunk + """Append a text chunk to the pending buffer. + + Content is staged in _pending until we know whether this turn + produces a tool call. If it does, the pending content is discarded + (it was just preamble). If it's the final turn, pending is flushed + to _buffer in finish(). + """ + self._pending += chunk def feed_thinking(self, chunk: str) -> None: """Append meaningful thinking/reasoning text.""" @@ -327,7 +388,12 @@ def feed_thinking(self, chunk: str) -> None: self._thinking_buffer += text def tool_started(self, name: str, metadata: dict[str, Any] | None = None) -> str: - """Mark a tool call as started. Returns spinner text for LeapApp.""" + """Mark a tool call as started. Returns spinner text for LeapApp. + + Discards any pending content — it was preamble preceding the tool call + and should not appear in the final answer. + """ + self._pending = "" metadata = metadata or {} tool_name = _metadata_text(metadata, "normalized_tool_name") or name self._active_tool = tool_name @@ -369,7 +435,14 @@ def tool_finished( line.append(f" | {_format_elapsed(duration)}", style="leap.tool") self._console.print(line) recovery_hint = _metadata_text(metadata, "recovery_hint") - if recovery_hint: + recovery_card = getattr(self._console, "permission_recovery_card", None) + if _is_permission_recovery_metadata(metadata): + failure_code = _metadata_text(metadata, "failure_code") or "authorization_required" + capability = _metadata_text(metadata, "capability") or _metadata_text(metadata, "action") or tool_name + self._permission_block_reason = f"{failure_code}: {capability}" + if callable(recovery_card): + recovery_card(metadata) + elif recovery_hint: recovery_line = Text() recovery_line.append(" ↳ recovery: ", style="leap.tool") recovery_line.append(_truncate_detail(recovery_hint, limit=_TOOL_OUTPUT_LIMIT), style="leap.tool") @@ -378,8 +451,13 @@ def tool_finished( self._active_tool_detail = "" self._tool_start_time = 0.0 - def finish(self) -> None: + def finish(self, *, command: Any | None = None) -> None: """Render all accumulated content to the console.""" + # Flush pending content — if we reached finish() without a tool_start + # discarding it, the pending content is the final answer. + self._buffer += self._pending + self._pending = "" + if self._thinking_buffer.strip(): self._console.thinking(self._thinking_buffer) @@ -395,5 +473,5 @@ def finish(self) -> None: margin_bottom=_FINAL_RESPONSE_MARGIN_BOTTOM, ) - self._console.response_label(self.elapsed, tool_count=self.tool_count) + self._console.response_label(self.elapsed, tool_count=self.tool_count, command=command) self._console.newline() diff --git a/src/leapflow/daemon/client.py b/src/leapflow/daemon/client.py index 6747ee6..c5d0225 100644 --- a/src/leapflow/daemon/client.py +++ b/src/leapflow/daemon/client.py @@ -87,6 +87,32 @@ async def engine_chat( finally: await _close_writer(writer) + async def subscribe_notifications(self) -> AsyncIterator[dict[str, Any]]: + """Long-lived subscription stream for daemon push notifications. + + Yields notification dicts with keys: event_type, payload, timestamp. + Runs until the server closes the stream or connection is lost. + """ + request = RpcRequest(method="events.subscribe", params={}) + reader, writer = await self._open() + try: + await _send(writer, request.to_json()) + while True: + payload = await self._read_payload(reader) + method = payload.get("method") + params = dict(payload.get("params") or {}) + if method == "stream.chunk" and params.get("id") == request.id: + if params.get("done"): + break + metadata = params.get("metadata") or {} + if metadata.get("event_type"): + yield metadata + continue + if payload.get("id") == request.id: + break + finally: + await _close_writer(writer) + async def engine_cancel(self) -> bool: """Request cancellation of the daemon-owned active engine turn.""" result = await self.request("engine.cancel") @@ -143,6 +169,11 @@ async def app_command(self, args: str = "") -> dict[str, Any]: result = await self.request("app.command", {"args": args}) return dict(result or {}) + async def command_execute(self, name: str, args: str = "") -> dict[str, Any]: + """Execute any engine-routed slash command via daemon.""" + result = await self.request("command.execute", {"name": name, "args": args}) + return dict(result or {}) + async def approval_status(self) -> dict[str, Any]: """Return pending daemon approval requests.""" result = await self.request("approval.status") diff --git a/src/leapflow/daemon/notifications.py b/src/leapflow/daemon/notifications.py new file mode 100644 index 0000000..94f0828 --- /dev/null +++ b/src/leapflow/daemon/notifications.py @@ -0,0 +1,92 @@ +"""Daemon notification bus — push events from background tasks to connected TUI clients. + +Architecture: +- `NotificationBus` manages per-subscriber async queues +- Background tasks (distillation, hub sync, etc.) call `bus.emit(event)` to broadcast +- Each connected `events.subscribe` stream gets its own queue and yields events +- Subscribers auto-unregister on disconnect (context manager) + +This is the daemon's equivalent of server-sent events — generalizable to any +background task that needs to push progress or completion signals to TUI clients. +""" +from __future__ import annotations + +import asyncio +import logging +import time +from dataclasses import dataclass, field +from typing import Any, Dict, Optional + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class Notification: + """A single notification event pushed to TUI clients.""" + + event_type: str + payload: Dict[str, Any] = field(default_factory=dict) + timestamp: float = field(default_factory=time.time) + + def to_dict(self) -> Dict[str, Any]: + return { + "event_type": self.event_type, + "payload": self.payload, + "timestamp": self.timestamp, + } + + +class NotificationBus: + """Broadcast notifications to all subscribed TUI clients. + + Thread-safe via asyncio.Queue per subscriber. + """ + + def __init__(self, maxsize: int = 64) -> None: + self._subscribers: Dict[str, asyncio.Queue[Optional[Notification]]] = {} + self._maxsize = maxsize + + def subscribe(self, subscriber_id: str) -> asyncio.Queue[Optional[Notification]]: + """Register a subscriber and return its queue. + + Send None to the queue to signal graceful shutdown. + """ + q: asyncio.Queue[Optional[Notification]] = asyncio.Queue(maxsize=self._maxsize) + self._subscribers[subscriber_id] = q + logger.debug("notification_bus: subscriber added id=%s total=%d", subscriber_id, len(self._subscribers)) + return q + + def unsubscribe(self, subscriber_id: str) -> None: + """Remove a subscriber.""" + self._subscribers.pop(subscriber_id, None) + logger.debug("notification_bus: subscriber removed id=%s total=%d", subscriber_id, len(self._subscribers)) + + def emit(self, notification: Notification) -> None: + """Broadcast a notification to all subscribers (non-blocking). + + If a subscriber's queue is full, the notification is dropped for that + subscriber (back-pressure). This prevents slow clients from blocking + background tasks. + """ + for sid, q in list(self._subscribers.items()): + try: + q.put_nowait(notification) + except asyncio.QueueFull: + logger.debug("notification_bus: dropped event for slow subscriber %s", sid) + + def emit_event(self, event_type: str, **payload: Any) -> None: + """Convenience: emit with keyword args.""" + self.emit(Notification(event_type=event_type, payload=payload)) + + @property + def subscriber_count(self) -> int: + return len(self._subscribers) + + async def shutdown(self) -> None: + """Signal all subscribers to disconnect.""" + for q in self._subscribers.values(): + try: + q.put_nowait(None) + except asyncio.QueueFull: + pass + self._subscribers.clear() diff --git a/src/leapflow/daemon/protocol.py b/src/leapflow/daemon/protocol.py index 22ac851..7efacf5 100644 --- a/src/leapflow/daemon/protocol.py +++ b/src/leapflow/daemon/protocol.py @@ -219,6 +219,10 @@ async def app_command(self, args: str = "") -> Dict[str, Any]: """Return an App Connector slash-command payload.""" ... + async def command_execute(self, name: str, args: str = "") -> Dict[str, Any]: + """Execute any engine-routed slash command and return structured result.""" + ... + async def approval_status(self) -> Dict[str, Any]: """Return pending approval requests.""" ... @@ -294,6 +298,7 @@ async def gateway_send( "usage.summary": "usage_summary", "model.info": "model_info", "app.command": "app_command", + "command.execute": "command_execute", "approval.status": "approval_status", "approval.resolve": "approval_resolve", "approval.cancel": "approval_cancel", @@ -301,4 +306,5 @@ async def gateway_send( "gateway.disconnect": "gateway_disconnect", "gateway.status": "gateway_status", "gateway.send": "gateway_send", + "events.subscribe": "subscribe_notifications", } diff --git a/src/leapflow/daemon/server.py b/src/leapflow/daemon/server.py index b2f39a1..b7cd3ef 100644 --- a/src/leapflow/daemon/server.py +++ b/src/leapflow/daemon/server.py @@ -156,7 +156,7 @@ async def _dispatch(self, request: RpcRequest, writer: asyncio.StreamWriter) -> method = getattr(self._service, attr) params = dict(request.params or {}) - if request.method == "engine.chat": + if request.method in ("engine.chat", "events.subscribe"): await self._dispatch_stream(request, method, params, writer) return diff --git a/src/leapflow/daemon/service.py b/src/leapflow/daemon/service.py index 97f014b..53fad96 100644 --- a/src/leapflow/daemon/service.py +++ b/src/leapflow/daemon/service.py @@ -32,6 +32,9 @@ def __init__(self, settings: Any, *, mock_host: bool = False) -> None: self._approval_pending: dict[str, dict[str, Any]] = {} self._approval_event_queue: asyncio.Queue[StreamChunk] | None = None + from leapflow.daemon.notifications import NotificationBus + self.notification_bus = NotificationBus() + def set_client_count_provider(self, provider: Callable[[], int]) -> None: """Set a lightweight callback used by status reporting.""" self._client_count = provider @@ -49,6 +52,7 @@ async def start(self) -> None: ctx = Context(self._settings, self._mock_host) await ctx.initialize() self._install_daemon_approval(ctx) + self._install_learn_notifications(ctx) self._ctx = ctx @property @@ -230,6 +234,12 @@ async def app_command(self, args: str = "") -> dict[str, Any]: return await build_app_payload(self.context, args) + async def command_execute(self, name: str, args: str = "") -> dict[str, Any]: + """Execute any engine-routed slash command via unified dispatch.""" + from leapflow.cli.commands.slash_handlers import command_execute + + return await command_execute(self.context, name, args) + async def approval_status(self) -> dict[str, Any]: """Return currently pending daemon approval requests.""" return {"pending": self._pending_payloads()} @@ -378,13 +388,71 @@ def _checkpoint_open_connection(self, ctx: Any) -> None: except Exception: logger.debug("daemon: DuckDB checkpoint skipped", exc_info=True) + async def subscribe_notifications(self) -> AsyncIterator[StreamChunk]: + """Long-lived streaming RPC: yield notifications until client disconnects.""" + subscriber_id = str(uuid.uuid4()) + queue = self.notification_bus.subscribe(subscriber_id) + try: + while True: + notification = await queue.get() + if notification is None: + break + yield StreamChunk( + request_id="", + content="", + event_type="status", + metadata=notification.to_dict(), + ) + finally: + self.notification_bus.unsubscribe(subscriber_id) + + def _install_learn_notifications(self, ctx: Any) -> None: + """Wire session progress/completion callbacks to the notification bus.""" + bus = self.notification_bus + + def _on_progress(stage: str, current: int, total: int) -> None: + bus.emit_event( + "teach.progress", + phase=stage, + current=current, + total=total, + progress=current / total if total > 0 else 0.0, + ) + + def _on_complete(result: Any) -> None: + payload: dict[str, Any] = {"phase": "done"} + if result: + payload["step_count"] = getattr(result, "step_count", 0) + payload["duration"] = getattr(result, "duration", 0.0) + candidates = getattr(result, "candidates", None) or [] + payload["candidate_count"] = len(candidates) + activated = getattr(result, "activated_skill_names", None) or set() + payload["activated_skills"] = list(activated) + new = getattr(result, "new_skills", None) or [] + payload["new_skills"] = list(new) + bus.emit_event("teach.complete", **payload) + + if ctx.session: + ctx.session.set_on_learn_progress(_on_progress) + if hasattr(ctx.session, "set_on_learn_complete"): + ctx.session.set_on_learn_complete(_on_complete) + + # Monitor mode changes to notify TUI of idle-watchdog stops + original_on_idle = ctx.session._on_idle_timeout + + def _on_idle_with_notification() -> None: + bus.emit_event("teach.stopped", reason="idle_timeout") + original_on_idle() + + ctx.session._on_idle_timeout = _on_idle_with_notification + def _install_daemon_approval(self, ctx: Any) -> None: try: from leapflow.security.approval import SessionAwareGate from leapflow.security.actions import ActionDescriptor from leapflow.security.orchestrator import ApprovalOrchestrator from leapflow.tools.gateway_tool import set_gateway_approval_gate - from leapflow.tools.registry_bootstrap import set_file_write_gate + from leapflow.tools.registry_bootstrap import set_file_read_gate, set_file_write_gate from leapflow.tools.shell_tools import set_approval_gate existing = getattr(ctx, "_approval_orchestrator", None) @@ -399,15 +467,40 @@ def _install_daemon_approval(self, ctx: Any) -> None: set_approval_gate(orchestrator) set_gateway_approval_gate(orchestrator) + class _FileReadGate: + def __init__(self) -> None: + self.denial_message = "" + + async def check( + self, + path: str, + mode: str = "raw", + sensitivity_meta: dict | None = None, + ) -> bool: + result = await orchestrator.evaluate( + ActionDescriptor.file_read(path, mode=mode, metadata=dict(sensitivity_meta or {})) + ) + self.denial_message = result.denial_message if not result.approved else "" + return result.approved + class _FileWriteGate: def __init__(self) -> None: self.denial_message = "" - async def check(self, path: str, content: str, mode: str = "overwrite") -> bool: - result = await orchestrator.evaluate(ActionDescriptor.file_write(path, content, mode=mode)) + async def check( + self, + path: str, + content: str, + mode: str = "overwrite", + sensitivity_meta: dict | None = None, + ) -> bool: + result = await orchestrator.evaluate( + ActionDescriptor.file_write(path, content, mode=mode, metadata=dict(sensitivity_meta or {})) + ) self.denial_message = result.denial_message if not result.approved else "" return result.approved + set_file_read_gate(_FileReadGate()) set_file_write_gate(_FileWriteGate()) logger.debug("daemon approval gate installed") except Exception: diff --git a/src/leapflow/domain/trajectory.py b/src/leapflow/domain/trajectory.py index 2445d69..0212715 100644 --- a/src/leapflow/domain/trajectory.py +++ b/src/leapflow/domain/trajectory.py @@ -93,6 +93,10 @@ class ActionType(Enum): UI_MOVE = "ui.move" UI_RESIZE = "ui.resize" UI_DRAG = "ui.drag" + CHAT_USER_MESSAGE = "chat.user_message" + CHAT_TOOL_CALL = "chat.tool_call" + CHAT_TOOL_RESULT = "chat.tool_result" + CHAT_ASSISTANT_RESPONSE = "chat.assistant_response" UNKNOWN = "unknown" @@ -112,6 +116,10 @@ class ActionType(Enum): ("ui.action", "move"): ActionType.UI_MOVE, ("ui.action", "resize"): ActionType.UI_RESIZE, ("ui.action", "drag"): ActionType.UI_DRAG, + ("chat.interaction", "user_message"): ActionType.CHAT_USER_MESSAGE, + ("chat.interaction", "tool_call"): ActionType.CHAT_TOOL_CALL, + ("chat.interaction", "tool_result"): ActionType.CHAT_TOOL_RESULT, + ("chat.interaction", "response"): ActionType.CHAT_ASSISTANT_RESPONSE, } diff --git a/src/leapflow/engine/context_control.py b/src/leapflow/engine/context_control.py index 732c869..8debe7e 100644 --- a/src/leapflow/engine/context_control.py +++ b/src/leapflow/engine/context_control.py @@ -271,6 +271,8 @@ def build(self, tool_name: str, arguments: Dict[str, Any] | None, result: Any) - return self._compact_value(result) if tool_name == "platform_connect": return self._app_connector_evidence(result) + if tool_name in {"platform_action", "gp_platform_action"}: + return self._platform_action_evidence(arguments or {}, result) if result.get("ok") is False: return self._compact_error(result) if tool_name in {"file_read", "gp_file_read"}: @@ -319,6 +321,49 @@ def _shell_evidence(self, result: Dict[str, Any]) -> Dict[str, Any]: "stderr": self._head_tail(str(result.get("stderr", "")), self._max_content_chars // 2), } + def _platform_action_evidence(self, arguments: Dict[str, Any], result: Dict[str, Any]) -> Dict[str, Any]: + """Compact evidence for platform_action with strong completion markers.""" + if result.get("ok") is False: + evidence = self._compact_error(result) + for key in ( + "failure_class", "failure_code", "recoverability", "blocks_approval", + "platform", "action", "capability", "missing_fields", "missing_scopes", + "required_scopes", "scope_relation", "scope_source", "console_url", + "next_steps", "recovery_hint", "expected_schema", "retryable", "skip_approval", + ): + if key in result: + evidence[key] = self._compact_value(result[key]) + for key in ("platform", "action"): + if key not in evidence and arguments.get(key): + evidence[key] = self._compact_value(arguments[key]) + if result.get("llm_instruction"): + evidence["llm_instruction"] = str(result["llm_instruction"]) + if result.get("platform_degraded"): + evidence["platform_degraded"] = True + evidence["degradation_reason"] = str(result.get("degradation_reason", "")) + return evidence + + evidence: Dict[str, Any] = { + "ok": True, + "kind": "platform_action_evidence", + "action": result.get("action") or arguments.get("action", ""), + "platform": result.get("platform") or arguments.get("platform", ""), + } + if result.get("resource_id"): + evidence["resource_id"] = str(result["resource_id"]) + if result.get("completed"): + evidence["status"] = "COMPLETED" + evidence["execution_note"] = str(result.get("execution_note") or "Done. Do not repeat.") + if result.get("already_executed"): + evidence["status"] = "ALREADY_EXECUTED" + evidence["execution_note"] = str(result.get("execution_note") or "") + original = result.get("original_result") + if isinstance(original, dict) and original.get("resource_id"): + evidence["resource_id"] = str(original["resource_id"]) + if isinstance(result.get("data"), dict): + evidence["data"] = self._compact_mapping(result["data"]) + return evidence + def _compact_error(self, result: Dict[str, Any]) -> Dict[str, Any]: return { "ok": False, @@ -326,26 +371,43 @@ def _compact_error(self, result: Dict[str, Any]) -> Dict[str, Any]: } def _app_connector_evidence(self, result: Dict[str, Any]) -> Dict[str, Any]: + ok = bool(result.get("ok", True)) evidence: Dict[str, Any] = { - "ok": bool(result.get("ok", True)), + "ok": ok, "kind": "app_connector_evidence", } - for key in ( - "platform", - "stage", - "onboarding_state", - "recovery_hint", - "next_steps", - "error", - "status", - "connected", - ): - if key in result: - evidence[key] = self._compact_value(result[key]) - if isinstance(result.get("preflight_result"), dict): - evidence["preflight_result"] = self._app_preflight_evidence(result["preflight_result"]) - if "required_fields" in result: - evidence["required_fields"] = self._compact_value(result["required_fields"]) + if ok: + # Success path: expose user-facing facts and onboarding state + # (needed for empty-response recovery), but strip process noise + # like setup_steps, setup_form, setup_guide, optional_settings. + for key in ("platform", "status", "connected"): + if key in result: + evidence[key] = self._compact_value(result[key]) + # Onboarding state is structural (used by recovery fallback), + # not conversational noise — preserve it. + for key in ("onboarding_state", "recovery_hint", "next_steps"): + if key in result: + evidence[key] = self._compact_value(result[key]) + if isinstance(result.get("preflight_result"), dict): + evidence["preflight_result"] = self._app_preflight_evidence(result["preflight_result"]) + else: + # Failure path: retain recovery information for LLM retry logic. + for key in ( + "platform", + "stage", + "onboarding_state", + "recovery_hint", + "next_steps", + "error", + "status", + "connected", + ): + if key in result: + evidence[key] = self._compact_value(result[key]) + if isinstance(result.get("preflight_result"), dict): + evidence["preflight_result"] = self._app_preflight_evidence(result["preflight_result"]) + if "required_fields" in result: + evidence["required_fields"] = self._compact_value(result["required_fields"]) return evidence def _app_check_evidence(self, checks: List[Any]) -> List[Dict[str, Any]]: diff --git a/src/leapflow/engine/context_disclosure.py b/src/leapflow/engine/context_disclosure.py index 8422745..37e448c 100644 --- a/src/leapflow/engine/context_disclosure.py +++ b/src/leapflow/engine/context_disclosure.py @@ -3,22 +3,40 @@ This module decides how much runtime context a turn should disclose before the provider call. It does not answer the user, execute tools, or split the agent runtime into multiple loops. + +Design contract (do not regress): disclosure decisions are derived *only* from +structural, deterministic runtime facts — capability manifests (static tool +metadata) and stable runtime gates (slash commands, context posture, recent +failures, prior-turn tool-category continuity). Reading the user's free-form +text to guess which tools "sound relevant" is explicitly forbidden here: that +approach has unbounded coverage gaps and was the root cause of LLMs inventing +non-existent tool names when their real need fell outside a keyword table. +The compact tool index (Tier 0) is therefore always disclosed in full, and a +static low-risk tool whitelist (Tier 0.5) is always native-callable, so a turn +never presents an empty or contradictory tool contract to the model. """ from __future__ import annotations -import re from dataclasses import dataclass, field from enum import Enum from typing import Any, Iterable, Mapping, Sequence class DisclosureLevel(str, Enum): - """Stable disclosure levels understood by the unified loop.""" - - LIGHT = "light" - INDEXED_CAPABILITIES = "indexed_capabilities" - SELECTED_TOOLS = "selected_tools" - PROJECT_RESEARCH = "project_research" + """Stable disclosure levels understood by the unified loop. + + CORE: Tier 0 compact index + Tier 0.5 static low-risk tool whitelist. + This is the floor — it is never empty and never omitted. + EXPANDED: CORE plus Tier 1 categories opened by a structural gate + (prior-turn continuity, or a model-initiated capability_expand call + already reflected in this turn's native tool schema). + FULL: All registered tool schemas, for high-stakes/broad-context turns + (slash commands, research/converging/finalizing posture, recent + failure recovery). + """ + + CORE = "core" + EXPANDED = "expanded" FULL = "full" @@ -49,7 +67,13 @@ class ReasoningDisclosure(str, Enum): @dataclass(frozen=True) class CapabilityManifest: - """Compact runtime-facing capability metadata derived from tool schemas.""" + """Compact runtime-facing capability metadata derived from tool schemas. + + All fields here are *static* tool properties, declared once at tool + registration time (via the optional ``x_leapflow`` schema extension) or + inferred from the tool's own name/description. None of them are derived + from a given turn's user text. + """ name: str category: str @@ -74,7 +98,7 @@ def from_tool_definition(cls, tool_definition: Mapping[str, Any]) -> "Capability ) metadata = raw_metadata if isinstance(raw_metadata, Mapping) else {} category = str(metadata.get("category") or _infer_category(name, description)) - signals = _metadata_signals(metadata.get("input_signals")) or _signals_for_category(category) + 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"})) schema_cost = str(metadata.get("schema_cost") or ("high" if category in {"hub", "gateway", "delegate"} else "medium")) @@ -88,6 +112,15 @@ def from_tool_definition(cls, tool_definition: Mapping[str, Any]) -> "Capability schema_cost=schema_cost, ) + @property + def is_core(self) -> bool: + """Return whether this tool belongs in the always-on Tier 0.5 whitelist. + + Core tools are read-only and cheap to disclose (small schema). This is + a static property of the tool, evaluated once — never a per-turn guess. + """ + return self.risk_level == "read_only" and self.schema_cost != "high" + @dataclass(frozen=True) class PromptAssemblyPlan: @@ -104,6 +137,7 @@ class PromptAssemblyPlan: risk_level: str = "none" reason: str = "" selected_tool_names: tuple[str, ...] = () + expanded_categories: tuple[str, ...] = () max_prior_turns: int = 2 def metadata(self) -> dict[str, Any]: @@ -113,6 +147,7 @@ def metadata(self) -> dict[str, Any]: "reason": self.reason, "tools": list(self.selected_tool_names), "tool_count": len(self.selected_tool_names), + "expanded_categories": list(self.expanded_categories), "memory": self.memory.value, "history": self.history.value, "reasoning": self.reasoning.value, @@ -124,128 +159,103 @@ def metadata(self) -> dict[str, Any]: @dataclass(frozen=True) class DisclosureRuntimeState: - """Low-cost signals available before assembling the provider payload.""" + """Low-cost, structural signals available before assembling the provider payload. + + Every field here is a deterministic runtime fact, never a text-matching + verdict: enable_thinking is a user/runtime toggle, native_tools_enabled is + a settings flag, slash_command/context_posture/recent_failure are + structured session state, and last_turn_tool_categories is derived from + the prior turn's actual tool_calls (not from re-reading its text). + """ enable_thinking: bool = False native_tools_enabled: bool = False slash_command: bool = False context_posture: str = "baseline" recent_failure: bool = False + last_turn_tool_categories: frozenset[str] = field(default_factory=frozenset) @dataclass(frozen=True) class DisclosurePlanner: - """Manifest-driven planner for progressive context disclosure.""" + """Manifest-driven planner for progressive context disclosure. + + No natural-language fitting of any kind is performed here. Every branch + decides purely from ``DisclosureRuntimeState`` (structural facts) and + ``CapabilityManifest`` (static tool metadata). + """ manifests: tuple[CapabilityManifest, ...] = field(default_factory=tuple) def plan( self, - user_text: str, tool_definitions: Sequence[Mapping[str, Any]], runtime: DisclosureRuntimeState, ) -> PromptAssemblyPlan: - """Build a prompt assembly plan without invoking another LLM.""" - manifests = self.manifests or tuple(CapabilityManifest.from_tool_definition(td) for td in tool_definitions) - if runtime.slash_command or runtime.context_posture in {"research", "finalizing"}: - return self.full_plan(user_text, tool_definitions, runtime, "runtime posture requires full agent context") - - if _asks_about_capabilities(user_text): - return PromptAssemblyPlan( - level=DisclosureLevel.INDEXED_CAPABILITIES, - catalog_definitions=tuple(tool_definitions), - memory=MemoryDisclosure.SESSION_SUMMARY, - history=HistoryDisclosure.SHORT, - reasoning=ReasoningDisclosure.OFF, - native_tools=False, - stream_mode="direct", - risk_level="none", - reason="capability question needs compact index but no executable schemas", - selected_tool_names=tuple(_tool_name(td) for td in tool_definitions if _tool_name(td)), - max_prior_turns=2, - ) - - selected = self._select_capabilities(user_text, manifests) - if _needs_project_research(user_text): - return self.project_research_plan( - user_text, - tool_definitions, - runtime, - "project research task needs scoped file/document evidence without full catalog", - ) - if self._needs_full_context(user_text, selected, runtime): - return self.full_plan(user_text, tool_definitions, runtime, "task requires broad execution context") - - if selected: - selected_names = {manifest.name for manifest in selected} - selected_defs = tuple(td for td in tool_definitions if _tool_name(td) in selected_names) - risk = _highest_risk(selected) - return PromptAssemblyPlan( - level=DisclosureLevel.SELECTED_TOOLS, - tool_definitions=selected_defs, - catalog_definitions=selected_defs, - memory=MemoryDisclosure.QUERY_RETRIEVAL if _needs_memory(user_text, selected) else MemoryDisclosure.NONE, - history=HistoryDisclosure.RECENT, - reasoning=ReasoningDisclosure.AUTO if runtime.enable_thinking else ReasoningDisclosure.OFF, - native_tools=runtime.native_tools_enabled and bool(selected_defs), - stream_mode="tool_aware", - risk_level=risk, - reason="selected capabilities matched observable task signals", - selected_tool_names=tuple(sorted(selected_names)), - max_prior_turns=6, - ) + """Build a prompt assembly plan from structural runtime facts only.""" + manifests = self.manifests or build_capability_manifests(tool_definitions) + manifest_by_name = {m.name: m for m in manifests if m.name} - return PromptAssemblyPlan( - level=DisclosureLevel.LIGHT, - memory=MemoryDisclosure.NONE, - history=HistoryDisclosure.SHORT, - reasoning=ReasoningDisclosure.OFF, - native_tools=False, - stream_mode="direct", - risk_level="none", - reason="no external capability signal detected", - max_prior_turns=2, - ) + if runtime.slash_command or runtime.context_posture in {"research", "converging", "finalizing"} or runtime.recent_failure: + return self.full_plan(tool_definitions, runtime, _full_reason(runtime)) - def project_research_plan( - self, - user_text: str, - tool_definitions: Sequence[Mapping[str, Any]], - runtime: DisclosureRuntimeState, - reason: str, - ) -> PromptAssemblyPlan: - """Build a scoped research plan for code/doc exploration tasks.""" - selected_defs: list[Mapping[str, Any]] = [] - selected_names: list[str] = [] - for tool_definition in tool_definitions: - manifest = CapabilityManifest.from_tool_definition(tool_definition) - if manifest.category in {"file", "memory", "system"} and not manifest.requires_approval: - selected_defs.append(tool_definition) - if manifest.name: - selected_names.append(manifest.name) + core_defs, core_names = _core_whitelist(tool_definitions, manifest_by_name) + expanded_defs: list[Mapping[str, Any]] = list(core_defs) + expanded_names: set[str] = set(core_names) + expanded_categories: list[str] = [] + + for category in sorted(runtime.last_turn_tool_categories): + if not category: + continue + matched = False + for tool_definition in tool_definitions: + name = _tool_name(tool_definition) + manifest = manifest_by_name.get(name) + if manifest and manifest.category == category and name not in expanded_names: + expanded_defs.append(tool_definition) + expanded_names.add(name) + matched = True + if matched: + expanded_categories.append(category) + + level = DisclosureLevel.EXPANDED if expanded_categories else DisclosureLevel.CORE + reason = ( + f"tier1: continuity({', '.join(expanded_categories)})" + if expanded_categories + else "tier0/0.5: static core whitelist" + ) + scoped_manifests = [manifest_by_name[name] for name in expanded_names if name in manifest_by_name] return PromptAssemblyPlan( - level=DisclosureLevel.PROJECT_RESEARCH, - tool_definitions=tuple(selected_defs), - catalog_definitions=tuple(selected_defs), - memory=MemoryDisclosure.TASK_RETRIEVAL, - history=HistoryDisclosure.RECENT, - reasoning=ReasoningDisclosure.AUTO if runtime.enable_thinking else ReasoningDisclosure.OFF, - native_tools=runtime.native_tools_enabled and bool(selected_defs), - stream_mode="tool_aware", - risk_level="read_only", + level=level, + tool_definitions=tuple(expanded_defs), + catalog_definitions=tuple(tool_definitions), + memory=MemoryDisclosure.QUERY_RETRIEVAL if expanded_categories else MemoryDisclosure.NONE, + history=HistoryDisclosure.RECENT if expanded_categories else HistoryDisclosure.SHORT, + # At the CORE floor (no Tier 1 category opened) skip reasoning entirely: a + # turn that only needs the static low-risk whitelist is, by construction, not + # complex enough to justify the added latency of provider-side reasoning. + reasoning=( + ReasoningDisclosure.AUTO + if runtime.enable_thinking and expanded_categories + else ReasoningDisclosure.OFF + ), + native_tools=runtime.native_tools_enabled and bool(expanded_defs), + stream_mode="tool_aware" if expanded_categories else "direct", + risk_level=_highest_risk(scoped_manifests), reason=reason, - selected_tool_names=tuple(sorted(set(selected_names))), - max_prior_turns=6, + selected_tool_names=tuple(sorted(expanded_names)), + expanded_categories=tuple(expanded_categories), + max_prior_turns=6 if expanded_categories else 2, ) def full_plan( self, - user_text: str, tool_definitions: Sequence[Mapping[str, Any]], runtime: DisclosureRuntimeState, reason: str, ) -> PromptAssemblyPlan: """Build a full-disclosure fallback plan for safety and compatibility.""" + manifests = self.manifests or build_capability_manifests(tool_definitions) names = tuple(_tool_name(td) for td in tool_definitions if _tool_name(td)) return PromptAssemblyPlan( level=DisclosureLevel.FULL, @@ -256,63 +266,43 @@ def full_plan( reasoning=ReasoningDisclosure.AUTO if runtime.enable_thinking else ReasoningDisclosure.OFF, native_tools=runtime.native_tools_enabled and bool(tool_definitions), stream_mode="tool_aware", - risk_level="high" if _has_mutation_signal(user_text) else "medium", + risk_level=_highest_risk(manifests), reason=reason, selected_tool_names=names, + expanded_categories=tuple(sorted({m.category for m in manifests if m.category})), max_prior_turns=10, ) - def _select_capabilities( - self, - user_text: str, - manifests: Sequence[CapabilityManifest], - ) -> tuple[CapabilityManifest, ...]: - normalized = _normalize(user_text) - selected: list[CapabilityManifest] = [] - for manifest in manifests: - haystack = " ".join((manifest.name, manifest.category, manifest.summary, *manifest.input_signals)).lower() - if any(signal and signal in normalized for signal in manifest.input_signals): - selected.append(manifest) - continue - if any(token and token in normalized for token in _name_tokens(manifest.name)): - selected.append(manifest) - continue - if ( - manifest.category in {"hub", "gateway"} - and any(token in normalized for token in ("hub", "gateway", "message", "send")) - ): - selected.append(manifest) - continue - if ( - manifest.category == "delegate" - and any(token in normalized for token in ("delegate", "subagent", "parallel", "子任务")) - ): - selected.append(manifest) - continue - if any(token in normalized for token in _description_tokens(haystack)): - selected.append(manifest) - return tuple(_dedupe_by_name(selected)) - - def _needs_full_context( - self, - user_text: str, - selected: Sequence[CapabilityManifest], - runtime: DisclosureRuntimeState, - ) -> bool: - normalized = _normalize(user_text) - if runtime.recent_failure: - return True - if _has_complexity_signal(normalized): - return True - risky_selected = any(item.requires_approval for item in selected) - return risky_selected and _has_mutation_signal(normalized) - def build_capability_manifests(tool_definitions: Sequence[Mapping[str, Any]]) -> tuple[CapabilityManifest, ...]: """Build manifests for all known tools.""" return tuple(CapabilityManifest.from_tool_definition(tool) for tool in tool_definitions) +def _core_whitelist( + tool_definitions: Sequence[Mapping[str, Any]], + manifest_by_name: Mapping[str, CapabilityManifest], +) -> tuple[list[Mapping[str, Any]], list[str]]: + """Return the static Tier 0.5 whitelist: always-on, low-risk, cheap-schema tools.""" + defs: list[Mapping[str, Any]] = [] + names: list[str] = [] + for tool_definition in tool_definitions: + name = _tool_name(tool_definition) + manifest = manifest_by_name.get(name) + if manifest is not None and manifest.is_core: + defs.append(tool_definition) + names.append(name) + return defs, names + + +def _full_reason(runtime: DisclosureRuntimeState) -> str: + if runtime.slash_command: + return "gate: slash_command" + if runtime.recent_failure: + return "gate: recent_failure" + return f"gate: posture({runtime.context_posture})" + + def _tool_name(tool_definition: Mapping[str, Any]) -> str: function = tool_definition.get("function", {}) if isinstance(tool_definition, Mapping) else {} return str(function.get("name") or tool_definition.get("name") or "") @@ -325,6 +315,16 @@ def _metadata_signals(value: Any) -> tuple[str, ...]: def _infer_category(name: str, description: str) -> str: + """Best-effort category guess for tools that declare no explicit x_leapflow. + + 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. + """ text = f"{name} {description}".lower() if any(token in text for token in ("write", "replace", "delete", "store", "add")): return "write" @@ -344,44 +344,25 @@ def _infer_category(name: str, description: str) -> str: return "gateway" if "time" in text or "environment" in text or "date" in text: return "system" - return "general" - - -def _signals_for_category(category: str) -> tuple[str, ...]: - signals = { - "file": ("file", "path", "read", "list", "文件", "目录", "路径", ".py", ".md"), - "write": ("write", "edit", "modify", "replace", "save", "store", "写", "改", "保存", "记住"), - "shell": ("run", "command", "terminal", "execute", "pytest", "命令", "执行", "测试"), - "memory": ("memory", "remember", "recall", "history", "记忆", "回忆"), - "skill": ("skill", "capability", "能力", "技能"), - "system": ("time", "date", "env", "environment", "时间", "环境"), - "delegate": ("delegate", "subagent", "parallel", "子任务", "并行"), - "hub": ("hub", "publish", "install", "marketplace"), - "gateway": ("gateway", "message", "send", "notify", "发送", "通知"), - } - return signals.get(category, ()) + return "unclassified" def _risk_for_category(category: str) -> str: + """Map a category to its default risk level. + + ``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. + """ if category in {"write", "shell", "gateway"}: return "high" - if category in {"delegate", "hub"}: + if category in {"delegate", "hub", "unclassified"}: return "medium" return "read_only" -def _normalize(text: str) -> str: - return text.lower().strip() - - -def _name_tokens(name: str) -> tuple[str, ...]: - return tuple(token for token in re.split(r"[_\W]+", name.lower()) if len(token) >= 3) - - -def _description_tokens(text: str) -> tuple[str, ...]: - return tuple(token for token in re.split(r"\W+", text.lower()) if len(token) >= 8) - - def _dedupe_by_name(manifests: Iterable[CapabilityManifest]) -> list[CapabilityManifest]: seen: set[str] = set() result: list[CapabilityManifest] = [] @@ -399,65 +380,3 @@ def _highest_risk(manifests: Sequence[CapabilityManifest]) -> str: if order.get(manifest.risk_level, 0) > order.get(highest, 0): highest = manifest.risk_level return highest - - -def _asks_about_capabilities(text: str) -> bool: - normalized = _normalize(text) - return any(token in normalized for token in ("what can you do", "capabilities", "skills", "tools", "你能做", "有哪些能力", "技能", "工具")) - - -def _needs_memory(text: str, manifests: Sequence[CapabilityManifest]) -> bool: - normalized = _normalize(text) - return any(manifest.category == "memory" for manifest in manifests) or any( - token in normalized for token in ("memory", "remember", "recall", "history", "记忆", "之前") - ) - - -def _needs_project_research(text: str) -> bool: - normalized = _normalize(text) - architecture_signals = ( - "architecture", "diagram", "mermaid", "system design", "dependency map", - "架构", "框图", "架构图", "系统设计", "依赖图", - ) - project_scope_signals = ( - "project", "repo", "repository", "codebase", "docs", "documentation", - "项目", "仓库", "代码库", "文档", - ) - code_scope_signals = ( - "source", "src", "module", "package", "readme", "源码", "模块", - ) - strong_research_verbs = ( - "study", "research", "analyze", "inspect", "review", "summarize", "map", - "generate", "draw", "trace", "梳理", "研究", "分析", "总结", "生成", - ) - weak_research_verbs = ("read", "give", "读取", "给出") - has_explicit_file = bool(re.search(r"[\w./-]+\.[a-z0-9]{1,8}\b", normalized)) - has_architecture = any(signal in normalized for signal in architecture_signals) - has_project_scope = any(signal in normalized for signal in project_scope_signals) - has_code_scope = any(signal in normalized for signal in code_scope_signals) - has_strong_verb = any(verb in normalized for verb in strong_research_verbs) - has_weak_verb = any(verb in normalized for verb in weak_research_verbs) - - if has_explicit_file and not (has_architecture or has_project_scope): - return False - if has_architecture and (has_strong_verb or has_weak_verb or has_project_scope): - return True - if has_project_scope and (has_strong_verb or (has_weak_verb and has_code_scope)): - return True - return has_strong_verb and has_project_scope and has_code_scope - - -def _has_complexity_signal(normalized_text: str) -> bool: - return any( - token in normalized_text - for token in ( - "implement", "refactor", "debug", "root cause", "architecture", "design", - "analyze", "test", "实现", "重构", "调试", "根因", "架构", "设计", - "深入分析", "执行", "代码", "测试", - ) - ) - - -def _has_mutation_signal(text: str) -> bool: - normalized = _normalize(text) - return any(token in normalized for token in ("write", "edit", "modify", "delete", "send", "execute", "写", "改", "删", "发送", "执行")) diff --git a/src/leapflow/engine/engine.py b/src/leapflow/engine/engine.py index 2299935..7f57127 100644 --- a/src/leapflow/engine/engine.py +++ b/src/leapflow/engine/engine.py @@ -30,6 +30,7 @@ DisclosureRuntimeState, MemoryDisclosure, PromptAssemblyPlan, + build_capability_manifests, ) from leapflow.engine.error_classifier import ( ErrorCategory, @@ -52,7 +53,7 @@ ) from leapflow.engine.graph_planner import GraphPlanner from leapflow.engine.scheduler import TaskScheduler -from leapflow.engine.session import SessionController +from leapflow.engine.session import SessionController, SessionMode from leapflow.analysis.pipeline import ImitationPipeline from leapflow.llm.base import LLMProvider from leapflow.llm.message_builder import ( @@ -68,6 +69,10 @@ from leapflow.prompts.templates import REACT_SYSTEM_TEMPLATE from leapflow.learning.active_learning import SkillMerger from leapflow.skills.builtin import app_launcher, clipboard_manager, file_organizer +from leapflow.security.permission_failures import ( + is_permission_failure_payload, + is_permission_hard_stop_payload, +) from leapflow.storage.skill_library import SkillLibraryStore from leapflow.skills.registry import Skill, SkillRegistry from leapflow.tools.name_resolver import ToolRegistry, ToolResolution @@ -158,6 +163,11 @@ def _tool_result_metadata( ) -> Dict[str, Any]: """Build safe, compact tool-completion metadata for streaming UIs.""" metadata = _tool_args_metadata(tool_name, arguments, original_tool_name=original_tool_name) + if tool_name in {"platform_action", "gp_platform_action"} and arguments: + for key in ("platform", "action"): + value = arguments.get(key) + if value: + metadata[key] = _single_line_preview(value, limit=_TOOL_ARGS_PREVIEW_LIMIT) metadata["ok"] = True if isinstance(result, dict): metadata["ok"] = bool(result.get("ok", True)) @@ -167,6 +177,14 @@ def _tool_result_metadata( for key in ("error_type", "retryable", "resolution_status", "resolution_confidence"): if key in result: metadata[key] = result[key] + # App Connector authorization failure metadata + for key in ( + "failure_class", "failure_code", "recoverability", "blocks_approval", + "platform", "action", "capability", "missing_scopes", "required_scopes", + "scope_relation", "scope_source", "console_url", "next_steps", "skip_approval", + ): + if key in result: + metadata[key] = result[key] for key in ("suggestions", "available_tools"): value = result.get(key) if value: @@ -222,6 +240,17 @@ def _platform_action_fingerprint(tool_name: str, arguments: Mapping[str, Any]) - return f"{platform}:{action}:{payload_key}" +def _has_completed_side_effect(results: List[Dict[str, Any]]) -> bool: + """Return True if any result is a completed side-effect platform_action.""" + for item in results: + result = item.get("result") + if not isinstance(result, dict): + continue + if result.get("ok") and result.get("completed"): + return True + return False + + def _unknown_tool_retry_prompt(result: Dict[str, Any]) -> str: """Build a compact structured correction prompt for a bad tool name.""" suggestions = result.get("suggestions") or [] @@ -240,13 +269,78 @@ def _unknown_tool_retry_prompt(result: Dict[str, Any]) -> str: ) -def _last_tool_failures_recovery_message(messages: List[Dict[str, Any]]) -> str: - """Build a user-facing message from the last consecutive tool failures. +def _is_permission_failure_payload(payload: Dict[str, Any]) -> bool: + """Return whether a tool-result payload represents an unresolved permission failure.""" + return is_permission_failure_payload(payload) - Called when the loop exits with no content due to hitting - max_consecutive_tool_failures. Returns "" when no useful failure context - is available in the recent message history. + +def _is_permission_hard_stop_payload(payload: Dict[str, Any]) -> bool: + """Return whether a failed tool result must stop the current agent turn.""" + return is_permission_hard_stop_payload(payload) + + +def _permission_hard_stop_from_results(results: List[Dict[str, Any]]) -> Dict[str, Any] | None: + """Return the first hard-stop permission failure from native tool results.""" + for item in results: + result = item.get("result") if isinstance(item, dict) else None + if isinstance(result, dict) and _is_permission_hard_stop_payload(result): + return result + return None + + +def _build_permission_recovery_text(failure: Dict[str, Any]) -> str: + """Render a deterministic permission-recovery message from a failure payload. + + This is the single authoritative renderer for authorization failures: it + only cites scopes and links that are literally present in ``failure``, + never invents, infers, or expands scope names, and only uses "one of" + phrasing when ``scope_relation`` explicitly says so. Used both for the + end-of-loop fallback and to override any free-text LLM answer that + follows an unresolved permission failure. """ + platform = str(failure.get("platform") or "") + capability = str(failure.get("capability") or "") + where = f"`{platform}.{capability}`" if platform and capability else (capability or platform or "this action") + missing_scopes: List[str] = [str(s) for s in (failure.get("missing_scopes") or []) if s] + required_scopes: List[str] = [str(s) for s in (failure.get("required_scopes") or []) if s] + scope_relation = str(failure.get("scope_relation") or "all_required") + recovery_hint = str(failure.get("recovery_hint") or "") + recoverability = str(failure.get("recoverability") or "") + console_url = str(failure.get("console_url") or "") + failure_code = str(failure.get("failure_code") or "") + + scopes = missing_scopes or required_scopes + label = "Missing scope(s)" if missing_scopes else "Required scope(s)" + + lines: List[str] = [ + f"Authorization failed for {where}. " + "The platform has denied access — this cannot be resolved by retrying." + ] + if scopes: + quoted = ", ".join(f"`{s}`" for s in scopes) + if scope_relation == "one_of" and len(scopes) > 1: + lines.append(f"{label} (granting ANY ONE of the following is sufficient): {quoted}.") + else: + lines.append(f"{label}: {quoted}.") + if recovery_hint and failure_code not in ("rate_limited",): + lines.append(f"To fix: {recovery_hint}") + elif recoverability == "admin_required": + lines.append( + "An administrator must grant the required permissions in the platform developer console " + "and republish or reinstall the application." + ) + if console_url: + lines.append(f"Developer console: {console_url}") + lines.append( + "Do NOT retry this action. When informing the user, quote ONLY the scope name(s) listed above — " + "never invent, guess, or add other scope names, and never claim they are interchangeable unless " + "explicitly told they are." + ) + return "\n".join(lines) + + +def _extract_recent_tool_failures(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """Return recent consecutive tool failure payloads, most recent first.""" failures: List[Dict[str, Any]] = [] for msg in reversed(messages[-24:]): content = str(msg.get("content") or "").strip() @@ -264,7 +358,66 @@ def _last_tool_failures_recovery_message(messages: List[Dict[str, Any]]) -> str: failures.append(payload) if len(failures) >= 3: break + return failures + +def _latest_turn_tool_result(messages: List[Dict[str, Any]]) -> Dict[str, Any] | None: + """Return the most recent tool-result payload within the current user turn. + + Scans backwards from the tail across both native (``role=="tool"``) and + text-mode (``"Tool result (...):"``-prefixed user messages) tool-call + conventions. Stops and returns ``None`` at the first genuine user message + (the current turn's boundary) or non-JSON tool content. + """ + for msg in reversed(messages): + role = msg.get("role", "") + content = msg.get("content", "") + if role == "tool": + if not isinstance(content, str): + return None + try: + payload = json.loads(content) + except (json.JSONDecodeError, ValueError): + return None + return payload if isinstance(payload, dict) else None + if role == "user": + text = str(content or "") + if text.startswith("Tool result (") and ":\n" in text: + body = text.split(":\n", 1)[1].strip() + try: + payload = json.loads(body) + except (json.JSONDecodeError, ValueError): + return None + return payload if isinstance(payload, dict) else None + # Reached the current turn's real user message boundary. + return None + # Skip interleaved assistant messages (preamble / tool_calls). + continue + return None + + +def _permission_override_message(messages: List[Dict[str, Any]]) -> str: + """Return a deterministic override when the turn's last tool signal is an + unresolved permission failure. + + Prevents the LLM's free-text final answer from paraphrasing, expanding, + or fabricating scope names when the most recent tool call in this turn + failed on authorization and was never followed by a successful retry. + """ + payload = _latest_turn_tool_result(messages) + if payload is None or not _is_permission_failure_payload(payload): + return "" + return _build_permission_recovery_text(payload) + + +def _last_tool_failures_recovery_message(messages: List[Dict[str, Any]]) -> str: + """Build a user-facing message from the last consecutive tool failures. + + Called when the loop exits with no content due to hitting + max_consecutive_tool_failures. Returns "" when no useful failure context + is available in the recent message history. + """ + failures = _extract_recent_tool_failures(messages) if not failures: return "" @@ -275,7 +428,11 @@ def _last_tool_failures_recovery_message(messages: List[Dict[str, Any]]) -> str: available_actions: List[str] = list(last.get("available_action_names") or []) lines: List[str] = [] - if failure_code == "unknown_platform_action": + + # Authorization / permission failures — deterministic, no retry via LLM + if _is_permission_failure_payload(last): + lines.append(_build_permission_recovery_text(last)) + elif failure_code == "unknown_platform_action": platform = str(last.get("platform") or "") action = str(last.get("requested_action") or "") lines.append(f"`{platform}.{action}` is not a registered platform action.") @@ -711,6 +868,13 @@ def __init__( self._last_disclosure_metadata: dict[str, Any] = {} self._current_task_contract: TaskContract | None = None self._disclosure_planner = DisclosurePlanner() + # 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), + # so this dedicated, reset-per-turn attribute is the actual source of + # truth — never derived from re-parsing text. + self._last_turn_tool_categories: frozenset[str] = frozenset() + self._manifests_by_name: Dict[str, Any] | None = None self._healer = MessageHealer() # B2: Prompt cache optimization (None = disabled) @@ -974,6 +1138,32 @@ def set_event_bus(self, event_bus: Any) -> None: """Inject EventBus for emitting learning signals (episode events).""" self._event_bus = event_bus + def _emit_chat_event(self, sub_action: str, payload: Dict[str, Any]) -> None: + """Emit a chat interaction event for trajectory recording during LEARNING. + + Only fires when the session is in LEARNING mode and an EventBus is available. + The recorder's state machine ensures these events are only persisted as + trajectory steps when recording is active. + """ + if self._event_bus is None: + return + if self._session is None or self._session.mode != SessionMode.LEARNING: + return + from leapflow.domain.events import SystemEvent + event = SystemEvent( + event_type="chat.interaction", + source="leapflow.engine", + payload={"action": sub_action, **payload}, + timestamp=time.time(), + ) + try: + loop = asyncio.get_running_loop() + loop.create_task(self._event_bus.handle_event( + event.event_type, event.payload, + )) + except RuntimeError: + pass + def set_experience_store(self, store: Any) -> None: """Inject ExperienceStore for world-model trajectory bridge.""" self._experience_store = store @@ -1063,6 +1253,11 @@ def _begin_turn_context(self, user_text: str) -> None: self._last_disclosure_metadata = {} self._context_governance_controller.reset_turn_scope() self._current_task_contract = self._build_task_contract(user_text) + try: + from leapflow.tools.gateway_tool import reset_platform_action_scope + reset_platform_action_scope() + except ImportError: + pass def _build_task_contract(self, user_text: str) -> TaskContract: workspace_root = ( @@ -1178,13 +1373,13 @@ async def _assemble_unified_prompt( slash_command=slash_command, context_posture=str(self._last_context_snapshot.get("context_posture") or "baseline"), recent_failure=bool(self._last_context_snapshot.get("forced_final_answer")), + last_turn_tool_categories=self._recent_tool_categories(), ) try: - plan = self._disclosure_planner.plan(user_text, tool_definitions, runtime) + plan = self._disclosure_planner.plan(tool_definitions, runtime) except (TypeError, ValueError, RuntimeError) as exc: logger.warning("disclosure planning failed; falling back to full context: %s", exc) plan = DisclosurePlanner().full_plan( - user_text, tool_definitions, runtime, "planner fallback preserved unified-loop behavior", @@ -1196,7 +1391,7 @@ async def _assemble_unified_prompt( memory_context = self._build_session_summary_context(max_messages=plan.max_prior_turns) elif plan.memory in {MemoryDisclosure.QUERY_RETRIEVAL, MemoryDisclosure.TASK_RETRIEVAL}: memory_context = await self._prefetch_and_freeze_memory(user_text) - skill_section = self._build_skill_section(include_skills=plan.level != DisclosureLevel.LIGHT) + skill_section = self._build_skill_section(include_skills=plan.level != DisclosureLevel.CORE) app_connector_section = self._build_app_connector_section() system = UNIFIED_SYSTEM_TEMPLATE.format( tool_catalog=tool_catalog, @@ -1209,6 +1404,74 @@ async def _assemble_unified_prompt( prior_turns = self._prior_turns_for_plan(plan) return _PromptAssembly(system=system, plan=plan, prior_turns=prior_turns) + def _recent_tool_categories(self) -> frozenset[str]: + """Return capability categories used by native tool_calls in the prior turn. + + This is the Tier 1 continuity gate. It reads ``self._last_turn_tool_categories``, + a dedicated attribute updated at the end of each completed turn by + ``_record_tool_call_categories`` — never a re-reading of the user's free + text, and never derived from working memory (which only stores a + synthetic "[Called: ...]" summary string with no structured tool_calls). + """ + return self._last_turn_tool_categories + + def _record_tool_call_categories(self, native_calls: list) -> None: + """Update the Tier 1 continuity state from this turn's executed tool_calls. + + Accumulates into ``self._last_turn_tool_categories`` so a turn that makes + several rounds of tool calls keeps every category it touched, not just + the last round. Reset once per turn by the caller before the first round. + """ + if self._manifests_by_name is None: + from leapflow.tools.registry_bootstrap import TOOL_DEFINITIONS + + self._manifests_by_name = { + m.name: m for m in build_capability_manifests(TOOL_DEFINITIONS) + } + categories = set(self._last_turn_tool_categories) + for call in native_calls: + name = str(getattr(call, "name", "") or "") + manifest = self._manifests_by_name.get(name) + if manifest and manifest.category not in {"system", "general"}: + categories.add(manifest.category) + self._last_turn_tool_categories = frozenset(categories) + + @staticmethod + def _expand_tools_kwarg_full(tools_kwarg: Dict[str, Any], tool_definitions: List[Dict[str, Any]]) -> Dict[str, Any]: + """Expand this turn's native tool schema to the full catalog. + + Structural failure-recovery gate: once an unknown_tool result proves + that this turn's disclosed subset was insufficient, escalate to the + full catalog immediately rather than guessing a smaller subset again. + """ + return {"tools": list(tool_definitions)} + + @staticmethod + def _merge_expanded_tool_schemas( + tools_kwarg: Dict[str, Any], results: List[Dict[str, Any]], + ) -> Dict[str, Any]: + """Merge capability_expand results into this turn's native tool schema. + + Tier 1 model-initiated discovery gate: when the model calls + capability_expand and it succeeds, the returned tool schemas become + callable for the rest of this turn. + """ + additions: List[Dict[str, Any]] = [] + for item in results: + result = item.get("result") + if isinstance(result, dict) and result.get("ok") and result.get("expanded_tools"): + additions.extend(result["expanded_tools"]) + if not additions: + return tools_kwarg + existing = list(tools_kwarg.get("tools") or []) + existing_names = {td.get("function", {}).get("name") for td in existing} + for td in additions: + name = td.get("function", {}).get("name") + if name and name not in existing_names: + existing.append(td) + existing_names.add(name) + return {"tools": existing} + def _build_session_summary_context(self, *, max_messages: int) -> str: """Return a compact local session summary without retrieval or extra LLM calls.""" messages = self._wm.as_chat_messages() @@ -1396,6 +1659,7 @@ async def run(self, user_text: str, *, enable_thinking: bool = False) -> str: self._session_turn_count += 1 logger.info("audit.user_input chars=%s", len(user_text)) self._begin_turn_context(user_text) + self._emit_chat_event("user_message", {"content": user_text[:500]}) # 1. Slash command (skill injection — zero-ambiguity activation) if user_text.startswith("/") and self._skill_injector: @@ -1433,6 +1697,7 @@ async def run_stream( self._session_turn_count += 1 logger.info("audit.user_input chars=%s", len(user_text)) self._begin_turn_context(user_text) + self._emit_chat_event("user_message", {"content": user_text[:500]}) # 1. Slash command (skill injection) if user_text.startswith("/") and self._skill_injector: @@ -1874,6 +2139,10 @@ async def _unified_tool_loop( slash_command=user_text.startswith("/"), ) planned_enable_thinking = self._planned_enable_thinking(assembly.plan, enable_thinking) + # Reset the Tier 1 continuity state now that this turn's plan has been + # assembled from the *previous* turn's value; it accumulates fresh from + # this turn's own tool_calls for the *next* turn's plan. + self._last_turn_tool_categories = frozenset() messages: List[Dict[str, Any]] = [ build_system_message(assembly.system), @@ -1967,7 +2236,10 @@ async def _unified_tool_loop( native_calls = getattr(resp, "tool_calls", None) or [] if native_calls: - assistant_msg: Dict[str, Any] = {"role": "assistant", "content": content} + # Preamble exclusion: content alongside tool_calls is ephemeral + # reasoning — exclude it from the message context to prevent + # the next LLM turn from repeating it in the final answer. + assistant_msg: Dict[str, Any] = {"role": "assistant", "content": ""} assistant_msg["tool_calls"] = [ { "id": tc.id, @@ -1977,11 +2249,22 @@ async def _unified_tool_loop( for tc in native_calls ] messages.append(assistant_msg) - self._persist_message(session_id, "assistant", content, tool_calls=assistant_msg.get("tool_calls")) + self._persist_message(session_id, "assistant", "", tool_calls=assistant_msg.get("tool_calls")) results = await self._execute_tools_concurrent( native_calls, TOOL_HANDLERS, trace=trace, messages=messages, ) + self._record_tool_call_categories(native_calls) + tools_kwarg = self._merge_expanded_tool_schemas(tools_kwarg, results) + + permission_hard_stop = _permission_hard_stop_from_results(results) + if permission_hard_stop: + logger.info( + "unified_loop: permission hard-stop after %s/%s", + permission_hard_stop.get("platform", "platform"), + permission_hard_stop.get("capability") or permission_hard_stop.get("action") or "action", + ) + break retryable_unknown = next( ( @@ -1993,6 +2276,8 @@ async def _unified_tool_loop( ) if retryable_unknown and not unknown_tool_retry_used: unknown_tool_retry_used = True + tools_kwarg = self._expand_tools_kwarg_full(tools_kwarg, TOOL_DEFINITIONS) + use_native_tools = bool(tools_kwarg) messages.append(build_user_message_text(_unknown_tool_retry_prompt(retryable_unknown))) continue @@ -2007,30 +2292,49 @@ async def _unified_tool_loop( break self._wm.remember_chat(build_assistant_message( - content or f"[Called: {', '.join(tc.name for tc in native_calls)}]" + f"[Called: {', '.join(tc.name for tc in native_calls)}]" )) if status == BudgetStatus.SOFT_LIMIT: messages.append(build_user_message_text( "SYSTEM: Approaching limit. Provide final answer now." )) + elif _has_completed_side_effect(results): + messages.append(build_user_message_text( + "SYSTEM: Side-effect action completed (result has completed:true). " + "Do not re-invoke it with the same parameters. " + "If all user-requested actions are done, provide the final answer." + )) continue - self._wm.remember_chat(build_assistant_message(content)) self._persist_message(session_id, "assistant", content) tool_call = self._parse_tool_call_from_content(content) if tool_call is None: + self._wm.remember_chat(build_assistant_message(content)) trace.record(ExecutionMode.COMPLETE) break - messages.append(build_assistant_message(content)) + # Text-mode preamble exclusion: only store call summary in WM, + # not the natural language preamble that surrounds the tool_call tag. normalized_tool_call = _normalize_tool_call(tool_call) tool_name = str(normalized_tool_call["name"]) + self._wm.remember_chat(build_assistant_message(f"[Called: {tool_name}]")) + + messages.append(build_assistant_message(content)) tool_arguments = normalized_tool_call.get("arguments") + self._emit_chat_event("tool_call", { + "tool_name": tool_name, + "arguments_summary": json.dumps(tool_arguments, default=str, ensure_ascii=False)[:300] if tool_arguments else "", + }) _show_progress("executing", tool_name) result = await self._execute_general_tool(normalized_tool_call, TOOL_HANDLERS) _clear_indicator() + self._emit_chat_event("tool_result", { + "tool_name": tool_name, + "ok": bool(result.get("ok")) if isinstance(result, dict) else True, + "summary": json.dumps(result, default=str, ensure_ascii=False)[:300] if isinstance(result, dict) else str(result)[:300], + }) _print_tool_result(tool_name, result, enabled=self._settings.verbose_progress) trace.record( ExecutionMode.ACTING, @@ -2050,6 +2354,14 @@ async def _unified_tool_loop( )) self._persist_message(session_id, "tool", result_text, tool_name=tool_name) + if _is_permission_hard_stop_payload(result): + logger.info( + "unified_loop: permission hard-stop after %s/%s", + result.get("platform", "platform"), + result.get("capability") or result.get("action") or tool_name, + ) + break + if _is_retryable_unknown_tool_result(result) and not unknown_tool_retry_used: unknown_tool_retry_used = True messages.append(build_user_message_text(_unknown_tool_retry_prompt(result))) @@ -2081,15 +2393,20 @@ async def _unified_tool_loop( logger.info("turn_usage: %s", self._usage_tracker.format_log_line()) if content: - return content + permission_override = _permission_override_message(messages) + final = permission_override or content + self._emit_chat_event("response", {"content": final[:500]}) + return final if fatal_error: + self._emit_chat_event("response", {"content": fatal_error[:500]}) return fatal_error - # Derive a recovery message from the last platform_connect failure if present - return ( + fallback = ( _app_onboarding_recovery_message(messages) or _last_tool_failures_recovery_message(messages) or "I've reached my processing limit." ) + self._emit_chat_event("response", {"content": fallback[:500]}) + return fallback async def _post_turn_review( self, messages: List[Dict[str, Any]], final_content: str @@ -2233,6 +2550,10 @@ async def _unified_tool_loop_stream( slash_command=user_text.startswith("/"), ) planned_enable_thinking = self._planned_enable_thinking(assembly.plan, enable_thinking) + # Reset the Tier 1 continuity state now that this turn's plan has been + # assembled from the *previous* turn's value; it accumulates fresh from + # this turn's own tool_calls for the *next* turn's plan. + self._last_turn_tool_categories = frozenset() messages: List[Dict[str, Any]] = [ build_system_message(assembly.system), @@ -2325,7 +2646,9 @@ async def _unified_tool_loop_stream( native_calls = getattr(resp, "tool_calls", None) or [] if native_calls: - assistant_msg: Dict[str, Any] = {"role": "assistant", "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": ""} assistant_msg["tool_calls"] = [ { "id": tc.id, @@ -2336,7 +2659,7 @@ async def _unified_tool_loop_stream( ] messages.append(assistant_msg) self._persist_message( - session_id, "assistant", content, + session_id, "assistant", "", tool_calls=assistant_msg.get("tool_calls"), ) @@ -2356,6 +2679,8 @@ async def _unified_tool_loop_stream( results = await self._execute_tools_concurrent( native_calls, TOOL_HANDLERS, trace=trace, messages=messages ) + self._record_tool_call_categories(native_calls) + tools_kwarg = self._merge_expanded_tool_schemas(tools_kwarg, results) result_by_id = {str(item.get("id")): item for item in results} retryable_unknown = next( ( @@ -2383,10 +2708,21 @@ async def _unified_tool_loop_stream( }, ) + permission_hard_stop = _permission_hard_stop_from_results(results) + if permission_hard_stop: + logger.info( + "unified_loop_stream: permission hard-stop after %s/%s", + permission_hard_stop.get("platform", "platform"), + permission_hard_stop.get("capability") or permission_hard_stop.get("action") or "action", + ) + break + failures = self._count_consecutive_tool_failures(messages) turn_recovery.consecutive_tool_failures = failures if retryable_unknown and not unknown_tool_retry_used: unknown_tool_retry_used = True + tools_kwarg = self._expand_tools_kwarg_full(tools_kwarg, TOOL_DEFINITIONS) + use_native_tools = bool(tools_kwarg) messages.append(build_user_message_text(_unknown_tool_retry_prompt(retryable_unknown))) continue if failures >= self._settings.max_consecutive_tool_failures: @@ -2397,12 +2733,18 @@ async def _unified_tool_loop_stream( break self._wm.remember_chat(build_assistant_message( - content or f"[Called: {', '.join(tc.name for tc in native_calls)}]" + f"[Called: {', '.join(tc.name for tc in native_calls)}]" )) if status == BudgetStatus.SOFT_LIMIT: messages.append(build_user_message_text( "SYSTEM: Approaching limit. Provide final answer now." )) + elif _has_completed_side_effect(results): + messages.append(build_user_message_text( + "SYSTEM: Side-effect action completed (result has completed:true). " + "Do not re-invoke it with the same parameters. " + "If all user-requested actions are done, provide the final answer." + )) continue else: @@ -2490,24 +2832,35 @@ async def _unified_tool_loop_stream( messages.append(build_user_message_text(build_continuation_prompt(content))) continue - self._wm.remember_chat(build_assistant_message(content)) self._persist_message(session_id, "assistant", content) tool_call = self._parse_tool_call_from_content(content) if tool_call is None: + self._wm.remember_chat(build_assistant_message(content)) trace.record(ExecutionMode.COMPLETE) if not content: fallback = _app_onboarding_recovery_message(messages) - yield StreamEvent(type="final", content=fallback or "I processed your request but have no additional output.") + final_text = fallback or "I processed your request but have no additional output." + self._emit_chat_event("response", {"content": final_text[:500]}) + yield StreamEvent(type="final", content=final_text) else: - yield StreamEvent(type="final", content=content) + permission_override = _permission_override_message(messages) + final_text = permission_override or content + self._emit_chat_event("response", {"content": final_text[:500]}) + yield StreamEvent(type="final", content=final_text) return - messages.append(build_assistant_message(content)) normalized_tool_call = _normalize_tool_call(tool_call) tool_name = str(normalized_tool_call["name"]) original_tool_name = str(normalized_tool_call.get("original_tool_name", tool_name)) + self._wm.remember_chat(build_assistant_message(f"[Called: {tool_name}]")) + + messages.append(build_assistant_message(content)) tool_arguments = normalized_tool_call.get("arguments") + self._emit_chat_event("tool_call", { + "tool_name": tool_name, + "arguments_summary": json.dumps(tool_arguments, default=str, ensure_ascii=False)[:300] if tool_arguments else "", + }) yield StreamEvent( type="tool_start", content=tool_name, @@ -2519,6 +2872,11 @@ async def _unified_tool_loop_stream( ) result = await self._execute_general_tool(normalized_tool_call, TOOL_HANDLERS) _clear_indicator() + self._emit_chat_event("tool_result", { + "tool_name": tool_name, + "ok": bool(result.get("ok")) if isinstance(result, dict) else True, + "summary": json.dumps(result, default=str, ensure_ascii=False)[:300] if isinstance(result, dict) else str(result)[:300], + }) yield StreamEvent( type="tool_complete", content=tool_name, @@ -2556,6 +2914,14 @@ async def _unified_tool_loop_stream( )) self._persist_message(session_id, "tool", result_text, tool_name=tool_name) + if _is_permission_hard_stop_payload(result): + logger.info( + "unified_loop_stream: permission hard-stop after %s/%s", + result.get("platform", "platform"), + result.get("capability") or result.get("action") or tool_name, + ) + break + if _is_retryable_unknown_tool_result(result) and not unknown_tool_retry_used: unknown_tool_retry_used = True messages.append(build_user_message_text(_unknown_tool_retry_prompt(result))) @@ -2587,7 +2953,10 @@ async def _unified_tool_loop_stream( logger.info("turn_usage: %s", self._usage_tracker.format_log_line()) if content: - yield StreamEvent(type="final", content=content) + permission_override = _permission_override_message(messages) + final = permission_override or content + self._emit_chat_event("response", {"content": final[:500]}) + yield StreamEvent(type="final", content=final) else: fallback = ( _app_onboarding_recovery_message(messages) @@ -2595,6 +2964,7 @@ async def _unified_tool_loop_stream( or fatal_error or "I've reached my processing limit." ) + self._emit_chat_event("response", {"content": fallback[:500]}) yield StreamEvent(type="final", content=fallback) @@ -2602,7 +2972,13 @@ async def _unified_tool_loop_stream( @staticmethod def _format_tool_catalog(tool_definitions: List[Dict[str, Any]]) -> str: - """Format available tools for the unified system prompt.""" + """Format available tools for the unified system prompt. + + Each non-core tool is annotated with its exact capability_expand category + so the model never has to guess the category string — it reads it directly + from the index, matching this turn's real manifest classification. + """ + manifests = {m.name: m for m in build_capability_manifests(tool_definitions)} lines: List[str] = [] for td in tool_definitions: func = td.get("function", {}) @@ -2611,7 +2987,13 @@ def _format_tool_catalog(tool_definitions: List[Dict[str, Any]]) -> str: params = ", ".join( func.get("parameters", {}).get("properties", {}).keys() ) - lines.append(f"- **{name}**({params}): {desc}") + manifest = manifests.get(name) + tag = ( + f" [capability_expand category: {manifest.category}]" + if manifest is not None and not manifest.is_core + else "" + ) + lines.append(f"- **{name}**({params}){tag}: {desc}") return "\n".join(lines) @staticmethod @@ -2700,9 +3082,18 @@ async def _execute_tools_concurrent( original_name = str(tc.name) tool_call_dict = _normalize_tool_call({"name": original_name, "arguments": tc.arguments}) normalized_name = str(tool_call_dict["name"]) + self._emit_chat_event("tool_call", { + "tool_name": normalized_name, + "arguments_summary": json.dumps(tc.arguments, default=str, ensure_ascii=False)[:300], + }) _show_progress("executing", normalized_name, step=i + 1, total=len(native_calls)) result = await self._execute_general_tool(tool_call_dict, handlers) _clear_indicator() + self._emit_chat_event("tool_result", { + "tool_name": normalized_name, + "ok": bool(result.get("ok")) if isinstance(result, dict) else True, + "summary": json.dumps(result, default=str, ensure_ascii=False)[:300] if isinstance(result, dict) else str(result)[:300], + }) _print_tool_result(normalized_name, result, enabled=self._settings.verbose_progress) trace.record( ExecutionMode.ACTING, @@ -2719,6 +3110,12 @@ async def _execute_tools_concurrent( "arguments": tc.arguments, "result": result, }) + if isinstance(result, dict) and _is_permission_hard_stop_payload(result): + logger.info( + "tool_concurrency: stopping remaining native tool calls after permission blocker from %s", + normalized_name, + ) + break return executed concurrent, sequential = self._concurrency_policy.partition(tc_wrappers) @@ -2775,13 +3172,20 @@ async def _run_one(ctc: ConcurrentToolCall) -> Dict[str, Any]: result_payload = self._compact_tool_result(ctc.name, ctc.arguments, result) result_text = json.dumps(result_payload, default=str, ensure_ascii=False)[:result_budget] messages.append({"role": "tool", "tool_call_id": ctc.id, "content": result_text}) + effective_result = error_result if isinstance(result, Exception) else result executed.append({ "id": ctc.id, "name": ctc.name, "original_tool_name": original_name, "arguments": ctc.arguments, - "result": error_result if isinstance(result, Exception) else result, + "result": effective_result, }) + if isinstance(effective_result, dict) and _is_permission_hard_stop_payload(effective_result): + logger.info( + "tool_concurrency: permission blocker returned from concurrent tool %s; skipping sequential group", + ctc.name, + ) + return executed for i, ctc in enumerate(sequential): original_name = original_names_by_id.get(str(ctc.id), ctc.name) @@ -2810,6 +3214,12 @@ async def _run_one(ctc: ConcurrentToolCall) -> Dict[str, Any]: "arguments": ctc.arguments, "result": result, }) + if isinstance(result, dict) and _is_permission_hard_stop_payload(result): + logger.info( + "tool_concurrency: stopping sequential native tool calls after permission blocker from %s", + ctc.name, + ) + break return executed async def _execute_general_tool( diff --git a/src/leapflow/engine/session.py b/src/leapflow/engine/session.py index 29b5ecb..1dd5223 100644 --- a/src/leapflow/engine/session.py +++ b/src/leapflow/engine/session.py @@ -131,6 +131,27 @@ def __init__( self._pending_learn: Optional[tuple[Trajectory, LearningSession]] = None self._pending_learn_deferred: Optional[tuple[Trajectory, LearningSession]] = None + @property + def has_pending_distillation(self) -> bool: + """Whether exit_learning queued a trajectory for background distillation.""" + return self._pending_learn is not None + + @property + def is_distilling(self) -> bool: + """Whether a background distillation task is currently running.""" + return self._learn_task is not None and not self._learn_task.done() + + @property + def recording_step_count(self) -> int: + """Number of steps captured so far in the active recording.""" + recorder = getattr(self._pipeline, "recorder", None) + return recorder.step_count if recorder else 0 + + @property + def last_result(self) -> Optional["LearnResult"]: + """Most recent distillation result, if any.""" + return self._last_learn_result + @property def idle_timeout(self) -> float: return self._idle_timeout diff --git a/src/leapflow/engine/tool_concurrency.py b/src/leapflow/engine/tool_concurrency.py index b249e0a..dad6960 100644 --- a/src/leapflow/engine/tool_concurrency.py +++ b/src/leapflow/engine/tool_concurrency.py @@ -63,12 +63,21 @@ def partition( "file_write", "text_replace", }) -# Tools that must never run in parallel (side effects, user interaction, shell) +# Tools that must never run in parallel (side effects, user interaction, shell, +# or external platform authorization boundaries where one failure can block the +# rest of the turn). _DEFAULT_NEVER_PARALLEL: FrozenSet[str] = frozenset({ "gp_shell_run", "shell_run", "clarify", "gp_clarify", "delegate_task", "gp_delegate_task", "memory_add", "gp_memory_add", + "platform_action", "gp_platform_action", + "platform_connect", "gp_platform_connect", + "gateway_send", "gp_gateway_send", + "gateway_connect", "gp_gateway_connect", + "hub_push", "gp_hub_push", + "hub_pull", "gp_hub_pull", + "hub_sync", "gp_hub_sync", }) diff --git a/src/leapflow/gateway/action_packs/feishu.yaml b/src/leapflow/gateway/action_packs/feishu.yaml index 223f772..c50e659 100644 --- a/src/leapflow/gateway/action_packs/feishu.yaml +++ b/src/leapflow/gateway/action_packs/feishu.yaml @@ -29,6 +29,20 @@ actions: effect: send risk_level: high output_policy: summary + capability: im.message.send + auth: + identities: [user, bot] + scopes: + common: [im:message:send_as_bot] + user: [im:message.send_as_user, im:message] + bot: [im:message:send_as_bot] + resource_fields: [chat_id] + recovery: + missing_scope: + recoverability: admin_required + hint: >- + Grant im:message:send_as_bot (bot) or im:message.send_as_user (user) + in the Feishu developer console, then re-publish and reinstall the app. schema: required: [chat_id, text] properties: @@ -75,6 +89,15 @@ actions: effect: read risk_level: low output_policy: raw + capability: im.chat.read + auth: + identities: [user, bot] + scopes: + common: [im:chat:read] + recovery: + missing_scope: + recoverability: admin_required + hint: Grant im:chat:read in the Feishu developer console, then re-publish and reinstall the app. schema: properties: page_size: @@ -95,6 +118,15 @@ actions: effect: read risk_level: low output_policy: raw + capability: im.chat.read + auth: + identities: [user, bot] + scopes: + common: [im:chat:read] + recovery: + missing_scope: + recoverability: admin_required + hint: Grant im:chat:read in the Feishu developer console, then re-publish and reinstall the app. schema: required: [query] properties: @@ -113,6 +145,386 @@ actions: - "{query}" timeout_s: 30 + im.list_messages: + description: >- + List messages in a Feishu/Lark chat or P2P conversation. + Returns messages in reverse chronological order by default. + Requires chat_id (oc_xxx format); use im.search_chats or im.list_chats + to resolve a group name to chat_id first. + CLI: lark-cli im +chat-messages-list (supports --start, --end, --order, --page-size flags for advanced use). + effect: read + risk_level: low + output_policy: raw + capability: im.message.read + auth: + identities: [user, bot] + scopes: + common: [im:message:readonly] + user: [im:message.group_msg:get_as_user, im:message.p2p_msg:get_as_user, im:message.reactions:read, contact:user.base:readonly] + bot: [im:message.group_msg, im:message.p2p_msg:readonly, im:message.reactions:read] + resource_fields: [chat_id] + recovery: + missing_scope: + recoverability: admin_required + hint: >- + Grant im:message:readonly (plus identity-specific scopes) in the Feishu developer console, + then re-publish and reinstall the app. + schema: + required: [chat_id] + properties: + chat_id: + type: string + description: "Target chat ID (oc_xxx format)." + backend: + kind: cli + argv: + - im + - +chat-messages-list + - --chat-id + - "{chat_id}" + timeout_s: 30 + + im.get_messages: + description: >- + Batch-fetch Feishu/Lark messages by their IDs. + Returns full message content with sender names and reactions. + Accepts up to 50 message IDs (om_xxx format), comma-separated. + effect: read + risk_level: low + output_policy: raw + capability: im.message.read + auth: + identities: [user, bot] + scopes: + common: [im:message:readonly] + user: [im:message.group_msg:get_as_user, im:message.p2p_msg:get_as_user, im:message.reactions:read, contact:user.basic_profile:readonly] + bot: [im:message.group_msg, im:message.p2p_msg:readonly, im:message.reactions:read, contact:user.base:readonly] + recovery: + missing_scope: + recoverability: admin_required + hint: Grant im:message:readonly in the Feishu developer console, then re-publish and reinstall the app. + schema: + required: [message_ids] + properties: + message_ids: + type: string + description: "Comma-separated message IDs (om_xxx format, max 50)." + backend: + kind: cli + argv: + - im + - +messages-mget + - --message-ids + - "{message_ids}" + timeout_s: 30 + + im.search_messages: + description: >- + Search Feishu/Lark messages across chats by keyword. + Returns matching messages with sender names, chat context, and reactions. + Supports filters: chat_id (limit to specific chat), sender, time range. + Note: requires user identity (--as user); does not work with bot identity. + CLI: lark-cli im +messages-search (supports --chat-id, --sender, --start, --end, + --chat-type, --is-at-me flags for advanced use). + effect: read + risk_level: medium + output_policy: raw + capability: im.message.search + auth: + identities: [user] + scopes: + user: [search:message, im:message.reactions:read, contact:user.basic_profile:readonly] + recovery: + missing_scope: + recoverability: admin_required + hint: >- + Grant search:message and contact:user.basic_profile:readonly (user identity only) + in the Feishu developer console, then re-publish and reinstall the app. + schema: + required: [query] + properties: + query: + type: string + description: "Search keyword or phrase." + backend: + kind: cli + argv: + - im + - +messages-search + - --query + - "{query}" + timeout_s: 60 + + im.list_thread_messages: + description: >- + List messages in a Feishu/Lark thread (reply chain). + Accepts a thread ID (omt_xxx) or a message ID (om_xxx) that belongs to a thread. + Returns thread replies in chronological order by default. + effect: read + risk_level: low + output_policy: raw + capability: im.message.read + auth: + identities: [user, bot] + scopes: + common: [im:message:readonly] + user: [im:message.group_msg:get_as_user, im:message.p2p_msg:get_as_user, im:message.reactions:read, contact:user.basic_profile:readonly] + bot: [im:message.group_msg, im:message.p2p_msg:readonly, im:message.reactions:read, contact:user.base:readonly] + recovery: + missing_scope: + recoverability: admin_required + hint: Grant im:message:readonly in the Feishu developer console, then re-publish and reinstall the app. + schema: + required: [thread] + properties: + thread: + type: string + description: "Thread ID (omt_xxx) or message ID (om_xxx) within the thread." + backend: + kind: cli + argv: + - im + - +threads-messages-list + - --thread + - "{thread}" + timeout_s: 30 + + im.reply_message: + description: >- + Reply to a specific message in a Feishu/Lark chat. + Creates a threaded reply linked to the original message. + Requires message_id (om_xxx format) of the message to reply to. + effect: send + risk_level: high + output_policy: summary + capability: im.message.send + auth: + identities: [user, bot] + scopes: + common: [im:message:send_as_bot] + user: [im:message.send_as_user, im:message] + bot: [im:message:send_as_bot] + resource_fields: [message_id] + recovery: + missing_scope: + recoverability: admin_required + hint: Grant im:message:send_as_bot (bot) or im:message.send_as_user (user) in the developer console. + schema: + required: [message_id, text] + properties: + message_id: + type: string + description: "Message ID (om_xxx) to reply to." + text: + type: string + description: "Reply text content." + backend: + kind: cli + argv: + - im + - +messages-reply + - --message-id + - "{message_id}" + - --text + - "{text}" + timeout_s: 30 + + im.update_message: + description: >- + Update (edit) an existing message sent by the bot. + Use for progressive streaming replies or correcting bot responses. + Only works on messages sent by the current bot identity. + Requires message_id (om_xxx format). + effect: write + risk_level: medium + output_policy: summary + capability: im.message.write + auth: + identities: [user, bot] + scopes: + common: [im:message:send_as_bot] + user: [im:message.send_as_user, im:message] + bot: [im:message:send_as_bot] + resource_fields: [message_id] + recovery: + missing_scope: + recoverability: admin_required + hint: Grant im:message:send_as_bot (bot) or im:message.send_as_user (user) in the developer console. + schema: + required: [message_id, text] + properties: + message_id: + type: string + description: "Message ID (om_xxx) to update." + text: + type: string + description: "New message text content." + backend: + kind: cli + argv: + - im + - +messages-update + - --message-id + - "{message_id}" + - --text + - "{text}" + timeout_s: 30 + + im.add_reaction: + description: >- + Add an emoji reaction to a message. Use for processing indicators + (e.g. add ⏳ when starting, ✅ when done). + Requires message_id and emoji_type. + effect: write + risk_level: low + output_policy: summary + capability: im.message.reaction + auth: + identities: [user, bot] + scopes: + common: [im:message:readonly] + resource_fields: [message_id] + recovery: + missing_scope: + recoverability: admin_required + hint: Grant im:message:readonly in the Feishu developer console, then re-publish and reinstall the app. + schema: + required: [message_id, emoji_type] + properties: + message_id: + type: string + description: "Message ID (om_xxx) to react to." + emoji_type: + type: string + description: "Emoji type string (e.g. 'THUMBSUP', 'DONE', 'OnIt', 'JIAYI')." + backend: + kind: cli + argv: + - im + - +messages-reaction-add + - --message-id + - "{message_id}" + - --emoji-type + - "{emoji_type}" + timeout_s: 15 + + im.remove_reaction: + description: >- + Remove an emoji reaction from a message. + Use to clear processing indicators after completion. + effect: write + risk_level: low + output_policy: summary + capability: im.message.reaction + auth: + identities: [user, bot] + scopes: + common: [im:message:readonly] + resource_fields: [message_id] + recovery: + missing_scope: + recoverability: admin_required + hint: Grant im:message:readonly in the Feishu developer console, then re-publish and reinstall the app. + schema: + required: [message_id, emoji_type] + properties: + message_id: + type: string + description: "Message ID (om_xxx) to remove reaction from." + emoji_type: + type: string + description: "Emoji type string to remove." + backend: + kind: cli + argv: + - im + - +messages-reaction-delete + - --message-id + - "{message_id}" + - --emoji-type + - "{emoji_type}" + timeout_s: 15 + + im.update_card: + description: >- + Update an interactive card message using a reply token from a card action callback. + Reply tokens expire after 30 minutes and can be used at most 2 times. + Use this to update card content in response to user button clicks or form submissions. + effect: write + risk_level: medium + output_policy: summary + capability: im.message.write + auth: + identities: [bot] + scopes: + bot: [im:message:send_as_bot] + recovery: + missing_scope: + recoverability: admin_required + hint: Grant im:message:send_as_bot in the Feishu developer console, then re-publish and reinstall the app. + schema: + required: [token, card] + properties: + token: + type: string + description: "Reply token from card.action.trigger callback." + card: + type: string + description: "Card template JSON string or card builder output." + backend: + kind: cli + argv: + - im + - +card-update + - --token + - "{token}" + - --card + - "{card}" + timeout_s: 30 + + im.download_resource: + description: >- + Download a file or image resource from a Feishu message. + Returns the local file path after download. + Used for processing image/file/audio attachments from inbound messages. + effect: read + risk_level: low + output_policy: raw + capability: im.message.read + auth: + identities: [user, bot] + scopes: + common: [im:message:readonly] + resource_fields: [message_id, file_key] + recovery: + missing_scope: + recoverability: admin_required + hint: Grant im:message:readonly in the Feishu developer console, then re-publish and reinstall the app. + schema: + required: [message_id, file_key, type] + properties: + message_id: + type: string + description: "Message ID (om_xxx) containing the resource." + file_key: + type: string + description: "Resource file key from the message payload." + type: + type: string + description: "Resource type: image, file, audio, video, media." + backend: + kind: cli + argv: + - im + - +messages-resource-download + - --message-id + - "{message_id}" + - --file-key + - "{file_key}" + - --type + - "{type}" + timeout_s: 60 + # ── Docs ─────────────────────────────────────────────────────────────────── docs.create_markdown: @@ -120,6 +532,15 @@ actions: effect: write risk_level: medium output_policy: summary + capability: docs.create + auth: + identities: [user, bot] + scopes: + common: [docs:doc:create, drive:drive:write] + recovery: + missing_scope: + recoverability: admin_required + hint: Grant docs:doc:create and drive:drive:write in the Feishu developer console, then re-publish and reinstall the app. schema: required: [title, markdown] properties: @@ -158,6 +579,15 @@ actions: effect: write risk_level: high output_policy: summary + capability: calendar.event.write + auth: + identities: [user, bot] + scopes: + common: [calendar:calendar:write] + recovery: + missing_scope: + recoverability: admin_required + hint: Grant calendar:calendar:write in the Feishu developer console, then re-publish and reinstall the app. schema: required: [summary, start_time, end_time] properties: @@ -203,6 +633,15 @@ actions: effect: read risk_level: medium output_policy: summary + capability: drive.read + auth: + identities: [user, bot] + scopes: + common: [drive:drive] + recovery: + missing_scope: + recoverability: admin_required + hint: Grant drive:drive in the Feishu developer console, then re-publish and reinstall the app. schema: required: [query] properties: @@ -228,6 +667,15 @@ actions: effect: write risk_level: medium output_policy: summary + capability: sheets.write + auth: + identities: [user, bot] + scopes: + common: [sheets:spreadsheet, drive:drive] + recovery: + missing_scope: + recoverability: admin_required + hint: Grant sheets:spreadsheet and drive:drive in the Feishu developer console, then re-publish and reinstall the app. schema: required: [spreadsheet_token, sheet_id, values] properties: @@ -270,6 +718,15 @@ actions: effect: read risk_level: high output_policy: summary + capability: mail.read + auth: + identities: [user, bot] + scopes: + common: [mail:mail.read] + recovery: + missing_scope: + recoverability: admin_required + hint: Grant mail:mail.read in the Feishu developer console, then re-publish and reinstall the app. schema: properties: query: @@ -293,6 +750,15 @@ actions: effect: write risk_level: medium output_policy: summary + capability: task.write + auth: + identities: [user, bot] + scopes: + common: [task:task:write] + recovery: + missing_scope: + recoverability: admin_required + hint: Grant task:task:write in the Feishu developer console, then re-publish and reinstall the app. schema: required: [title] properties: diff --git a/src/leapflow/gateway/adapters/dingtalk.py b/src/leapflow/gateway/adapters/dingtalk.py index e5b5831..6e36921 100644 --- a/src/leapflow/gateway/adapters/dingtalk.py +++ b/src/leapflow/gateway/adapters/dingtalk.py @@ -14,6 +14,8 @@ parse_json_object, stable_message_id, ) +from leapflow.gateway.connectors.dingtalk_event_source import DingTalkWebhookEventSource +from leapflow.gateway.connectors.protocol import BackendEventSource from leapflow.gateway.protocol import InboundMessage, OutboundContent, SendResult, SendTarget @@ -53,13 +55,22 @@ def __init__( self._path = path if str(path).startswith("/") else f"/{path}" self._http = http_client or UrlLibJsonHttpClient() self._access_token = "" - self._server: TinyJsonHttpServer | None = None + self._event_source_instance: DingTalkWebhookEventSource | None = None + if self._connection_mode == "webhook": + self._event_source_instance = DingTalkWebhookEventSource( + host=self._host, + port=self._port, + path=self._path, + robot_code=self._robot_code, + ) + + def event_source(self) -> BackendEventSource | None: + """Return the webhook-based BackendEventSource for the unified pipeline.""" + return self._event_source_instance # type: ignore[return-value] @property def local_url(self) -> str: - if self._server is None: - return f"http://{self._host}:{self._port}{self._path}" - return f"{self._server.url_base}{self._path}" + return f"http://{self._host}:{self._port}{self._path}" async def connect(self, *, is_reconnect: bool = False) -> None: self._access_token = await self._fetch_access_token() @@ -68,15 +79,9 @@ async def connect(self, *, is_reconnect: bool = False) -> None: "DingTalk built-in adapter currently supports connection_mode='webhook'. " "dingtalk-stream mode is planned as a later adapter enhancement.", ) - if self._server is None: - self._server = TinyJsonHttpServer(self._host, self._port, self._handle_request) - await self._server.start() await super().connect(is_reconnect=is_reconnect) async def disconnect(self) -> None: - if self._server is not None: - await self._server.stop() - self._server = None await super().disconnect() async def send(self, target: SendTarget, content: OutboundContent) -> SendResult: diff --git a/src/leapflow/gateway/adapters/feishu.py b/src/leapflow/gateway/adapters/feishu.py index 7ce2693..3b6f3cf 100644 --- a/src/leapflow/gateway/adapters/feishu.py +++ b/src/leapflow/gateway/adapters/feishu.py @@ -1,13 +1,15 @@ """Feishu/Lark adapter backed by the official lark-cli.""" from __future__ import annotations +import logging from typing import Any, Mapping, Sequence from leapflow.gateway.action_packs.feishu import ACTION_SPECS -from leapflow.gateway.adapters.common import AdapterLifecycle +from leapflow.gateway.adapters.common import AdapterLifecycle, bool_option from leapflow.gateway.backends.cli_backend import CliBackend from leapflow.gateway.connectors.action_registry import ActionRegistry from leapflow.gateway.connectors.event_sources import UnavailableEventSource +from leapflow.gateway.connectors.lark_event_source import BotIdentity, LarkCliEventSource from leapflow.gateway.connectors.protocol import ( ActionDiscovery, ActionPreview, @@ -19,6 +21,8 @@ ) from leapflow.gateway.protocol import OutboundContent, SendResult, SendTarget +logger = logging.getLogger(__name__) + class BackendNotReadyError(RuntimeError): """Raised when a connector backend is installed but not ready to use.""" @@ -42,45 +46,79 @@ def __init__( binary: str = "lark-cli", max_message_length: int = 8000, backend: ExecutionBackend | None = None, + events_enabled: Any = False, + event_keys: Sequence[str] | str | None = None, + event_key: str = "", **_: Any, ) -> None: super().__init__(profile=profile or "default") self._profile = profile or "" self._identity = identity or "bot" + self._binary = binary or "lark-cli" self.max_message_length = max(1, int(max_message_length or 8000)) self._backend = backend or CliBackend( - binary=binary or "lark-cli", + binary=self._binary, profile=self._profile, identity=self._identity, ) discovery = self._backend if isinstance(self._backend, ActionDiscovery) else None self._registry = ActionRegistry(ACTION_SPECS, discovery=discovery) - self._event_source = UnavailableEventSource( - platform_id=self.platform_id, - backend_kind=self._backend.kind, - detail=( - "Feishu inbound events are not enabled yet. Outbound actions can still work; " - "configure lark-event/WebSocket before enabling real-time message intake." - ), - metadata={ - "available": False, - "configuration_hint": "Configure lark-event/WebSocket for the selected CLI profile.", - "current_mode": "outbound_actions_only", - }, - ) + self._bot_id = "" + self._bot_name = "" + + self._events_enabled = bool_option(events_enabled, default=False) + if self._events_enabled: + resolved_keys: Sequence[str] + if event_keys: + resolved_keys = [event_keys] if isinstance(event_keys, str) else list(event_keys) + elif event_key: + resolved_keys = [event_key] + else: + from leapflow.gateway.connectors.lark_event_source import _DEFAULT_EVENT_KEYS + resolved_keys = list(_DEFAULT_EVENT_KEYS) + self._event_source: BackendEventSource = LarkCliEventSource( + event_keys=resolved_keys, + binary=self._binary, + profile=self._profile, + identity=self._identity, + ) + else: + self._event_source = UnavailableEventSource( + platform_id=self.platform_id, + backend_kind=self._backend.kind, + detail=( + "Feishu inbound events are not enabled. " + "Set events_enabled=true in platform options to receive messages." + ), + metadata={ + "available": False, + "current_mode": "outbound_actions_only", + }, + ) async def connect(self, *, is_reconnect: bool = False) -> None: status = await self._backend.status() if not status.ok: metadata = {**self.status_metadata(), **dict(status.metadata)} raise BackendNotReadyError(status.detail or "Feishu CLI backend is not ready", metadata) + + if isinstance(self._event_source, LarkCliEventSource): + identity = await self._event_source.fetch_bot_identity() + self._bot_id = identity.open_id + self._bot_name = identity.app_name await super().connect(is_reconnect=is_reconnect) + @property + def bot_identity(self) -> BotIdentity: + """Return the resolved bot identity (open_id + app_name).""" + return BotIdentity(open_id=self._bot_id, app_name=self._bot_name) + def status_metadata(self) -> dict[str, Any]: """Return non-secret connector diagnostics for status/list UX.""" binary = getattr(self._backend, "binary", "") profile = getattr(self._backend, "profile", self._profile) or "default" identity = getattr(self._backend, "identity", self._identity) + events_available = self._events_enabled return { "backend_kind": self._backend.kind, "binary": binary, @@ -88,9 +126,8 @@ def status_metadata(self) -> dict[str, Any]: "identity": identity, "actions": sorted(self._registry.all().keys()), "event_source": { - "available": False, - "mode": "outbound_actions_only", - "hint": "Configure lark-event/WebSocket to receive Feishu events in real time.", + "available": events_available, + "mode": "bidirectional" if events_available else "outbound_actions_only", }, } @@ -108,14 +145,20 @@ async def disconnect(self) -> None: await super().disconnect() async def send(self, target: SendTarget, content: OutboundContent) -> SendResult: - result = await self.execute_action( - "im.send_message", - { - "chat_id": target.chat_id, - "thread_id": target.thread_id, - "text": content.text[:self.max_message_length], - }, - ) + payload: dict[str, Any] = { + "chat_id": target.chat_id, + "text": content.text[:self.max_message_length], + } + if target.thread_id: + payload["thread_id"] = target.thread_id + + action = "im.send_message" + if target.reply_to_id: + action = "im.reply_message" + payload["message_id"] = target.reply_to_id + payload.pop("chat_id", None) + + result = await self.execute_action(action, payload) if not result.ok: return SendResult(ok=False, error=result.error) return SendResult(ok=True, message_id=result.resource_id) diff --git a/src/leapflow/gateway/adapters/telegram.py b/src/leapflow/gateway/adapters/telegram.py index 48adf2a..ad11dd0 100644 --- a/src/leapflow/gateway/adapters/telegram.py +++ b/src/leapflow/gateway/adapters/telegram.py @@ -12,6 +12,8 @@ chunk_text, stable_message_id, ) +from leapflow.gateway.connectors.protocol import BackendEventSource +from leapflow.gateway.connectors.telegram_event_source import TelegramPollingEventSource from leapflow.gateway.protocol import InboundMessage, OutboundContent, SendResult, SendTarget @@ -44,13 +46,34 @@ def __init__( self._http = http_client or UrlLibJsonHttpClient() self._poll_task: asyncio.Task[None] | None = None self._offset = 0 + self._event_source_instance: TelegramPollingEventSource | None = None + if self._transport == "polling": + self._event_source_instance = TelegramPollingEventSource( + bot_token=self._bot_token, + api_base=self._api_base, + poll_interval_s=self._poll_interval_s, + http_client=self._http, + ) + + def event_source(self) -> BackendEventSource | None: + """Return the polling-based BackendEventSource for the unified pipeline.""" + return self._event_source_instance # type: ignore[return-value] + + @property + def bot_identity(self) -> Any: + """Return bot identity for normalizer injection.""" + if self._event_source_instance: + from leapflow.gateway.connectors.lark_event_source import BotIdentity + return BotIdentity( + open_id=self._event_source_instance.bot_id, + app_name="", + ) + return None async def connect(self, *, is_reconnect: bool = False) -> None: await super().connect(is_reconnect=is_reconnect) if self._transport == "webhook" and self._webhook_url: await self._api("setWebhook", {"url": self._webhook_url}) - if self._transport == "polling" and self._auto_poll and self._poll_task is None: - self._poll_task = asyncio.create_task(self._poll_loop()) async def disconnect(self) -> None: task = self._poll_task diff --git a/src/leapflow/gateway/backends/cli_backend.py b/src/leapflow/gateway/backends/cli_backend.py index d2b0675..27cfd56 100644 --- a/src/leapflow/gateway/backends/cli_backend.py +++ b/src/leapflow/gateway/backends/cli_backend.py @@ -126,10 +126,19 @@ async def execute( argv_result = self._build_action_command(spec, payload) if isinstance(argv_result, ActionResult): return argv_result - return await self._run_json( + result = await self._run_json( argv_result, timeout_s=float(spec.backend_config.get("timeout_s") or self._timeout_s), ) + if not result.ok and result.failure is None: + failure = self._classify_failure(spec, result.error, result.raw) + return ActionResult( + ok=False, + error=result.error, + raw=result.raw, + failure=failure, + ) + return result async def preview( self, @@ -214,6 +223,24 @@ def _base_metadata(self, *, binary_path: str = "") -> dict[str, Any]: "backend_kind": self.kind, } + def _classify_failure( + self, + spec: ActionSpec, + error: str, + raw: Mapping[str, Any], + ) -> "ActionFailure": + """Classify lark-cli backend errors into ActionFailure.""" + from leapflow.gateway.backends.lark_cli_errors import classify_lark_cli_failure + + return classify_lark_cli_failure( + spec, + error, + raw, + binary=self._binary, + profile=self._profile, + identity=self._identity, + ) + def _recovery_metadata(self, error: str) -> dict[str, Any]: status_command = " ".join([self._binary, *self._profile_args(), "auth", "status", "--json"]) login_command = " ".join([self._binary, *self._profile_args(), "auth", "login", "--json"]) diff --git a/src/leapflow/gateway/backends/lark_cli_errors.py b/src/leapflow/gateway/backends/lark_cli_errors.py new file mode 100644 index 0000000..7d18d7f --- /dev/null +++ b/src/leapflow/gateway/backends/lark_cli_errors.py @@ -0,0 +1,373 @@ +"""lark-cli error normalization for App Connector CLI actions. + +This module translates lark-cli's Problem JSON and legacy plain-text failures +into the platform-neutral ActionFailure model. It intentionally lives in the +backend layer so gateway core remains free of vendor/CLI wire-shape knowledge. +""" +from __future__ import annotations + +from typing import Any, Mapping + +from leapflow.gateway.connectors.protocol import ActionFailure, ActionSpec +from leapflow.security.redact import redact_sensitive_text + + +def _declared_required_scopes(spec: ActionSpec, identity: str = "") -> tuple[tuple[str, ...], str]: + """Derive the action's declared scope requirement from its auth contract. + + This is the only scope source permitted when the upstream API did not + return an authoritative missing-scope list (e.g. free-text CLI failures). + Returns ``(scopes, scope_relation)``. When ``identity`` is known, the + identity-specific scopes are combined with ``common`` (conjunction). + When identity is unknown and only identity-specific scopes exist without + a shared ``common`` scope, the identity paths are mutually exclusive + alternatives (``one_of``) — granting either identity's scope is enough. + """ + scopes_map = dict(getattr(spec.auth, "scopes", {}) or {}) + common = tuple(str(s) for s in scopes_map.get("common", ()) if s) + if identity and scopes_map.get(identity): + identity_scopes = tuple(str(s) for s in scopes_map.get(identity, ()) if s) + combined = tuple(dict.fromkeys(common + identity_scopes)) + return combined, "all_required" + if common: + return common, "all_required" + alternatives: list[str] = [] + for key in ("user", "bot"): + for scope in scopes_map.get(key, ()): + scope_text = str(scope) + if scope_text and scope_text not in alternatives: + alternatives.append(scope_text) + if len(alternatives) > 1 and scopes_map.get("user") and scopes_map.get("bot"): + return tuple(alternatives), "one_of" + return tuple(alternatives), "all_required" + + +def classify_lark_cli_failure( + spec: ActionSpec, + error: str, + raw: Mapping[str, Any], + *, + binary: str = "", + profile: str = "", + identity: str = "", +) -> ActionFailure: + """Classify lark-cli action failures into platform-neutral ActionFailure.""" + capability = spec.capability or spec.name + + error_obj = raw.get("error") + if isinstance(error_obj, Mapping): + typed_error = str(error_obj.get("type") or "") + if typed_error: + return _classify_problem_error( + error_obj, + spec, + error, + binary=binary, + profile=profile, + identity=identity or str(error_obj.get("identity") or ""), + ) + error = str(error_obj.get("message") or error_obj.get("hint") or error) + + err_type = str(raw.get("type") or "") + if err_type in ("authorization", "authentication", "api"): + return _classify_problem_error( + raw, + spec, + error, + binary=binary, + profile=profile, + identity=identity, + ) + + lowered = error.lower() + safe_error = redact_sensitive_text(error, force=True) + capability_hint = f"Action: {spec.name}. Capability: {capability}." + + if any(kw in lowered for kw in ("access denied", "access_denied", "permission denied")): + login_cmd = _auth_cmd(binary, profile, "login") + status_cmd = _auth_cmd(binary, profile, "status") + declared_scopes, scope_relation = _declared_required_scopes(spec, identity) + scope_line = ( + f" Declared required scope(s) for this action: {', '.join(declared_scopes)}." + if declared_scopes else "" + ) + return ActionFailure( + failure_class="authorization", + failure_code="access_denied", + message=safe_error, + recoverability="admin_required", + retryable=False, + recovery_hint=( + "Access denied for this operation. Possible causes: missing scope, " + "missing user authorization, or tenant policy restriction. " + "Grant the required permissions in the developer console and reinstall/republish " + f"the application, then retry. {capability_hint}{scope_line}" + ), + next_steps=( + f"Open the developer console and grant the required scopes for capability '{capability}'.", + "Publish or reinstall the application after granting scopes.", + f"Run: {status_cmd}", + f"Retry after authorization: {login_cmd}", + ), + required_scopes=declared_scopes, + scope_relation=scope_relation, + scope_source="declared", + identity=identity, + capability=capability, + blocks_approval=True, + ) + + if any(kw in lowered for kw in ("missing scope", "insufficient scope", "scope", "permission")): + status_cmd = _auth_cmd(binary, profile, "status") + declared_scopes, scope_relation = _declared_required_scopes(spec, identity) + scope_line = ( + f" Declared required scope(s): {', '.join(declared_scopes)}." + if declared_scopes else "" + ) + return ActionFailure( + failure_class="authorization", + failure_code="missing_scope", + message=safe_error, + recoverability="admin_required", + retryable=False, + recovery_hint=( + f"Missing required scope to execute '{spec.name}'.{scope_line} " + "Grant the missing scope in the developer console and re-publish the app." + ), + next_steps=( + f"Grant the required scope for '{capability}' in the developer console.", + "Re-publish or reinstall the application.", + f"Run: {status_cmd}", + ), + required_scopes=declared_scopes, + scope_relation=scope_relation, + scope_source="declared", + identity=identity, + capability=capability, + blocks_approval=True, + ) + + if any(kw in lowered for kw in ("unauthorized", "unauthenticated", "invalid token", "token expired", "not logged in")): + login_cmd = _auth_cmd(binary, profile, "login") + status_cmd = _auth_cmd(binary, profile, "status") + return ActionFailure( + failure_class="authentication", + failure_code="auth_expired", + message=safe_error, + recoverability="user_action", + retryable=True, + recovery_hint=( + "CLI identity is not authenticated or token has expired. " + f"Run: {login_cmd}" + ), + next_steps=( + f"Authenticate: {login_cmd}", + f"Verify: {status_cmd}", + ), + identity=identity, + capability=capability, + blocks_approval=False, + ) + + if any(kw in lowered for kw in ("rate limit", "too many requests", "429")): + return ActionFailure( + failure_class="rate_limit", + failure_code="rate_limited", + message=safe_error, + recoverability="retryable", + retryable=True, + recovery_hint="Rate limit reached. Wait a moment and retry.", + identity=identity, + capability=capability, + blocks_approval=False, + ) + + if any(kw in lowered for kw in ("timeout", "timed out")): + return ActionFailure( + failure_class="timeout", + failure_code="timeout", + message=safe_error, + recoverability="retryable", + retryable=True, + recovery_hint="Request timed out. Retry after a moment.", + identity=identity, + capability=capability, + blocks_approval=False, + ) + + return ActionFailure( + failure_class="unknown", + failure_code="action_failed", + message=safe_error, + recoverability="retryable", + retryable=True, + recovery_hint=f"Platform action failed: {safe_error}", + identity=identity, + capability=capability, + blocks_approval=False, + ) + + +def _classify_problem_error( + error_obj: Mapping[str, Any], + spec: ActionSpec, + fallback_message: str, + *, + binary: str, + profile: str, + identity: str, +) -> ActionFailure: + """Classify lark-cli Problem wire errors.""" + capability = spec.capability or spec.name + err_type = str(error_obj.get("type") or "") + subtype = str(error_obj.get("subtype") or "") + message = str(error_obj.get("message") or error_obj.get("hint") or fallback_message or "") + hint = str(error_obj.get("hint") or "") + retryable = bool(error_obj.get("retryable", False)) + console_url = str(error_obj.get("console_url") or "") + err_identity = str(error_obj.get("identity") or identity) + missing_scopes = tuple(str(s) for s in (error_obj.get("missing_scopes") or []) if s) + requested_scopes = tuple(str(s) for s in (error_obj.get("requested_scopes") or []) if s) + granted_scopes = tuple(str(s) for s in (error_obj.get("granted_scopes") or []) if s) + log_id = str(error_obj.get("log_id") or "") + safe_message = redact_sensitive_text(message, force=True) + + status_cmd = _auth_cmd(binary, profile, "status") + login_cmd = _auth_cmd(binary, profile, "login") + + if err_type == "authorization": + failure_code = subtype or "access_denied" + is_scope = subtype in ("missing_scope", "insufficient_scope") or bool(missing_scopes) + # Authoritative missing_scopes come straight from the upstream API's + # own error payload (lark-cli typed PermissionError). When absent but + # the failure is scope-related, fall back to this action's declared + # contract instead of guessing — never fabricate scope names. + declared_scopes: tuple[str, ...] = () + scope_relation = "all_required" + scope_source = "authoritative" + effective_scopes = missing_scopes + if not effective_scopes and is_scope: + declared_scopes, scope_relation = _declared_required_scopes(spec, err_identity) + effective_scopes = declared_scopes + scope_source = "declared" + recovery_hint_parts = [safe_message] + if hint: + recovery_hint_parts.append(hint) + if missing_scopes: + recovery_hint_parts.append( + f"Missing scopes: {', '.join(missing_scopes)}. " + "Grant these in the developer console, then re-publish/reinstall the app." + ) + elif effective_scopes: + recovery_hint_parts.append( + f"Declared required scope(s): {', '.join(effective_scopes)}. " + "Grant the scope in the developer console and re-publish the app." + ) + elif is_scope: + recovery_hint_parts.append( + "A required permission scope is missing. " + "Grant the scope in the developer console and re-publish the app." + ) + if console_url: + recovery_hint_parts.append(f"Developer console: {console_url}") + if log_id: + recovery_hint_parts.append(f"Log ID: {log_id}") + next_steps: tuple[str, ...] = ( + *( + (f"Grant missing scopes {list(effective_scopes)} in the developer console.",) + if effective_scopes else (f"Grant the required scope for '{capability}' in the developer console.",) + ), + "Re-publish or reinstall the application after granting scopes.", + f"Verify authorization: {status_cmd}", + ) + if console_url: + next_steps = (f"Open: {console_url}",) + next_steps + return ActionFailure( + failure_class="authorization", + failure_code=failure_code, + message=safe_message, + recoverability="admin_required", + retryable=False, + recovery_hint=" ".join(recovery_hint_parts), + next_steps=next_steps, + missing_scopes=missing_scopes, + required_scopes=declared_scopes, + requested_scopes=requested_scopes, + granted_scopes=granted_scopes, + identity=err_identity, + console_url=console_url, + capability=capability, + blocks_approval=True, + raw=dict(error_obj), + scope_relation=scope_relation, + scope_source=scope_source, + ) + + if err_type == "authentication": + return ActionFailure( + failure_class="authentication", + failure_code=subtype or "unauthenticated", + message=safe_message, + recoverability="user_action", + retryable=retryable, + recovery_hint=(hint or f"Authentication failed. Run: {login_cmd}"), + next_steps=( + f"Authenticate: {login_cmd}", + f"Verify: {status_cmd}", + ), + identity=err_identity, + capability=capability, + blocks_approval=False, + raw=dict(error_obj), + ) + + if err_type == "api": + code = int(error_obj.get("code") or 0) + if code == 429 or "rate" in safe_message.lower(): + return ActionFailure( + failure_class="rate_limit", + failure_code="rate_limited", + message=safe_message, + recoverability="retryable", + retryable=True, + recovery_hint="Rate limit reached. Wait a moment and retry.", + identity=err_identity, + capability=capability, + blocks_approval=False, + raw=dict(error_obj), + ) + return ActionFailure( + failure_class="api_error", + failure_code=subtype or f"api_{code}" if code else "api_error", + message=safe_message, + recoverability="retryable" if retryable else "user_action", + retryable=retryable, + recovery_hint=hint or safe_message, + identity=err_identity, + capability=capability, + blocks_approval=False, + raw=dict(error_obj), + ) + + return ActionFailure( + failure_class=err_type or "unknown", + failure_code=subtype or "typed_error", + message=safe_message, + recoverability="retryable", + retryable=retryable, + recovery_hint=hint or safe_message, + identity=err_identity, + capability=capability, + blocks_approval=False, + raw=dict(error_obj), + ) + + +def _auth_cmd(binary: str, profile: str, subcmd: str) -> str: + if not binary: + return f" auth {subcmd} --json" + parts = [binary] + if profile: + parts.extend(["--profile", profile]) + parts.extend(["auth", subcmd, "--json"]) + return " ".join(parts) diff --git a/src/leapflow/gateway/capability_health.py b/src/leapflow/gateway/capability_health.py new file mode 100644 index 0000000..edd4849 --- /dev/null +++ b/src/leapflow/gateway/capability_health.py @@ -0,0 +1,327 @@ +"""Capability health tracking for platform actions. + +Maintains a session-scoped ledger that records authorization failures for +platform capabilities and exposes a feasibility check so platform_action_handler +can block unsafe side-effect retries before they reach the approval gate. + +Design principles: +- Platform-neutral: no Feishu/CLI-specific logic here. +- Capability granularity: tracks by (platform, capability) so successful + revalidation of one capability clears only that capability's stale failure. +- Dynamic recovery: read actions are allowed to revalidate stale authorization + failures, and successful actions actively clear the matching failure record. +- Platform degradation: unresolved authorization failures block side-effect + actions across the platform until the relevant capability is revalidated or + the record expires. This prevents hallucinated resource IDs from reaching the + approval gate when fundamental permissions are still missing. +- Recoverability classes: + retryable: transient — may succeed on retry (rate-limit, timeout) + user_action: user must do something lightweight (re-auth, retry later) + admin_required: an external administrator must change authorization + non_recoverable: hard failure (e.g. feature disabled, unsupported) +""" +from __future__ import annotations + +import logging +import time +from dataclasses import dataclass, field +from typing import Any, Dict, List, Tuple + +from leapflow.gateway.connectors.protocol import ActionFailure, ActionSpec + +logger = logging.getLogger(__name__) + +DEFAULT_AUTHORIZATION_FAILURE_TTL_S = 300.0 + +_RECOVERABILITY_RANK: Dict[str, int] = { + "retryable": 0, + "user_action": 1, + "admin_required": 2, + "non_recoverable": 3, +} + +_BLOCKS_APPROVAL_CLASSES = frozenset({"authorization", "scope_denied"}) +_TRANSIENT_CLASSES = frozenset({"timeout", "rate_limit", "transient"}) + +_SIDE_EFFECT_KINDS = frozenset({"send", "write", "execute"}) + + +@dataclass +class CapabilityStatus: + """Recorded health status for a (platform, capability) pair.""" + + platform: str + capability: str + failure: ActionFailure + recorded_at: float = field(default_factory=time.monotonic) + ttl_s: float = 0.0 + + @property + def is_expired(self) -> bool: + if self.ttl_s <= 0: + return False + return (time.monotonic() - self.recorded_at) > self.ttl_s + + @property + def should_block_approval(self) -> bool: + """Return True when this failure class cannot be resolved by user consent.""" + f = self.failure + if f.failure_class in _TRANSIENT_CLASSES: + return False + return f.blocks_approval or f.failure_class in _BLOCKS_APPROVAL_CLASSES + + +class CapabilityHealthLedger: + """Session-scoped ledger tracking per-capability authorization health. + + Thread-safety: single-threaded async usage expected; no locking. + """ + + def __init__(self, *, authorization_failure_ttl_s: float = DEFAULT_AUTHORIZATION_FAILURE_TTL_S) -> None: + self._records: Dict[Tuple[str, str], CapabilityStatus] = {} + self._authorization_failure_ttl_s = max(0.0, authorization_failure_ttl_s) + # Platform-level degradation: platforms with hard auth failures that + # should block side-effect actions even for capabilities not yet tested. + self._degraded_platforms: Dict[str, str] = {} # platform → reason + + @property + def degraded_platforms(self) -> Dict[str, str]: + """Return a copy of the degradation map for diagnostics.""" + return dict(self._degraded_platforms) + + def record_failure( + self, + platform: str, + capability: str, + failure: ActionFailure, + *, + ttl_s: float = 0.0, + ) -> None: + """Record a capability failure. + + When the failure is a hard authorization error (admin_required or + non_recoverable), also marks the platform as degraded — blocking + subsequent side-effect actions across ALL capabilities. + """ + key = (platform, capability) + existing = self._records.get(key) + new_rank = _RECOVERABILITY_RANK.get(failure.recoverability, 0) + if existing is not None and not existing.is_expired: + old_rank = _RECOVERABILITY_RANK.get(existing.failure.recoverability, 0) + if old_rank >= new_rank: + return + effective_ttl_s = ttl_s + if effective_ttl_s <= 0 and self._is_degrading_failure(failure): + effective_ttl_s = self._authorization_failure_ttl_s + self._records[key] = CapabilityStatus( + platform=platform, + capability=capability, + failure=failure, + ttl_s=effective_ttl_s, + ) + self._refresh_platform_degradation(platform) + logger.debug( + "capability_ledger.recorded platform=%s capability=%s class=%s code=%s recoverability=%s", + platform, capability, failure.failure_class, failure.failure_code, failure.recoverability, + ) + + def record_success(self, platform: str, capability: str) -> bool: + """Clear stale health records after a capability succeeds. + + Returns True when a previous failure was removed. Platform degradation is + recomputed from the remaining unresolved failures. + """ + removed = self.clear_capability(platform, capability) + if removed: + logger.info( + "capability_ledger.revalidated platform=%s capability=%s", + platform, capability, + ) + return removed + + def clear_capability(self, platform: str, capability: str) -> bool: + """Clear one capability failure and refresh platform degradation.""" + key = (platform, capability) + removed = self._records.pop(key, None) is not None + if removed: + self._refresh_platform_degradation(platform) + return removed + + def is_platform_degraded(self, platform: str) -> bool: + """Return True if the platform has an unresolved authorization failure.""" + self._purge_expired(platform) + return platform in self._degraded_platforms + + def platform_degradation_reason(self, platform: str) -> str: + """Return the degradation reason, or empty string if not degraded.""" + self._purge_expired(platform) + return self._degraded_platforms.get(platform, "") + + def get(self, platform: str, capability: str) -> CapabilityStatus | None: + """Return the recorded status for a capability, or None if healthy/expired.""" + key = (platform, capability) + status = self._records.get(key) + if status is None or status.is_expired: + if status is not None: + del self._records[key] + self._refresh_platform_degradation(platform) + return None + return status + + def check_feasibility( + self, + platform: str, + spec: ActionSpec, + ) -> dict[str, Any]: + """Return feasibility dict for a platform action. + + Checks (in order): + 1. Direct capability failure for side-effect actions + 2. Platform-level degradation for side-effect actions + + Read actions intentionally pass through after a prior failure so they can + revalidate external authorization changes, such as a freshly granted + Feishu scope or refreshed CLI token. + """ + capability = spec.capability or spec.name + + status = self.get(platform, capability) + if status is not None and status.should_block_approval: + if spec.effect not in _SIDE_EFFECT_KINDS: + return { + "ok": True, + "permission_revalidation": True, + "previous_failure_code": status.failure.failure_code, + "previous_failure_class": status.failure.failure_class, + "capability": capability, + "platform": platform, + "action": spec.name, + } + return self._failure_response(platform, spec, status.failure) + + if spec.effect in _SIDE_EFFECT_KINDS and self.is_platform_degraded(platform): + reason = self._degraded_platforms[platform] + return { + "ok": False, + "failure_code": "platform_degraded", + "failure_class": "authorization", + "error": ( + f"Platform '{platform}' has authorization failures that block " + f"side-effect actions. {reason}" + ), + "recoverability": "admin_required", + "retryable": False, + "capability": capability, + "platform": platform, + "action": spec.name, + "skip_approval": True, + "platform_degraded": True, + "degradation_reason": reason, + "llm_instruction": ( + f"STOP: Platform '{platform}' is missing required permissions. " + "Do NOT fabricate or guess resource IDs. Report this failure to the user " + "and recommend they fix the authorization in the developer console." + ), + } + + return {"ok": True} + + def clear(self, platform: str | None = None) -> None: + """Clear ledger entries (and degradation) for a platform or all.""" + if platform is None: + self._records.clear() + self._degraded_platforms.clear() + else: + keys = [k for k in self._records if k[0] == platform] + for key in keys: + del self._records[key] + self._degraded_platforms.pop(platform, None) + + def summary(self) -> List[dict[str, Any]]: + """Return a compact summary of all non-expired records.""" + result = [] + self._purge_expired() + for (platform, capability), status in list(self._records.items()): + if status.is_expired: + continue + result.append({ + "platform": platform, + "capability": capability, + "failure_class": status.failure.failure_class, + "failure_code": status.failure.failure_code, + "recoverability": status.failure.recoverability, + "blocks_approval": status.should_block_approval, + "platform_degraded": self.is_platform_degraded(platform), + }) + return result + + def _failure_response( + self, + platform: str, + spec: ActionSpec, + failure: ActionFailure, + ) -> dict[str, Any]: + """Build a structured failure dict for a blocked capability.""" + capability = spec.capability or spec.name + response: dict[str, Any] = { + "ok": False, + "failure_code": failure.failure_code, + "failure_class": failure.failure_class, + "error": failure.message or failure.recovery_hint or "Platform capability is not authorized.", + "recoverability": failure.recoverability, + "retryable": failure.recoverability not in ("admin_required", "non_recoverable"), + "capability": capability, + "platform": platform, + "action": spec.name, + "skip_approval": True, + "llm_instruction": ( + f"STOP: The capability '{capability}' is not authorized on platform '{platform}'. " + "Do NOT fabricate resource IDs or attempt dependent actions. " + "Report this failure to the user." + ), + } + failure_dict = failure.as_dict() + for key in ("recovery_hint", "next_steps", "missing_scopes", "required_scopes", + "requested_scopes", "granted_scopes", "identity", "console_url", + "scope_relation", "scope_source"): + if failure_dict.get(key): + response[key] = failure_dict[key] + return response + + @staticmethod + def _is_degrading_failure(failure: ActionFailure) -> bool: + return ( + failure.failure_class in _BLOCKS_APPROVAL_CLASSES + and failure.recoverability in ("admin_required", "non_recoverable") + ) + + def _purge_expired(self, platform: str | None = None) -> None: + expired_platforms: set[str] = set() + for key, status in list(self._records.items()): + if platform is not None and key[0] != platform: + continue + if status.is_expired: + del self._records[key] + expired_platforms.add(key[0]) + for platform_id in expired_platforms: + self._refresh_platform_degradation(platform_id) + + def _refresh_platform_degradation(self, platform: str) -> None: + for (record_platform, capability), status in list(self._records.items()): + if record_platform != platform: + continue + if status.is_expired: + del self._records[(record_platform, capability)] + continue + if self._is_degrading_failure(status.failure): + reason = ( + f"Authorization failure on {capability}: " + f"{status.failure.message or status.failure.failure_code}" + ) + self._degraded_platforms[platform] = reason + logger.info( + "platform_degraded platform=%s capability=%s code=%s", + platform, capability, status.failure.failure_code, + ) + return + self._degraded_platforms.pop(platform, None) diff --git a/src/leapflow/gateway/checkpoint_store.py b/src/leapflow/gateway/checkpoint_store.py new file mode 100644 index 0000000..bbdf1cd --- /dev/null +++ b/src/leapflow/gateway/checkpoint_store.py @@ -0,0 +1,156 @@ +"""Gateway event checkpoint persistence. + +Stores the last-consumed event_id per platform so event sources can +resume from where they left off after a restart. Uses the shared +DuckDB connection via ``ConnectionHolder``. +""" +from __future__ import annotations + +import logging +import time +from typing import Protocol, runtime_checkable + +logger = logging.getLogger(__name__) + +_TABLE = "gateway_checkpoints" +_DDL = f""" +CREATE TABLE IF NOT EXISTS {_TABLE} ( + platform_id VARCHAR PRIMARY KEY, + checkpoint VARCHAR NOT NULL DEFAULT '', + updated_at DOUBLE NOT NULL DEFAULT 0 +) +""" + + +@runtime_checkable +class CheckpointStore(Protocol): + """Read/write last-consumed checkpoint per platform.""" + + def save(self, platform_id: str, checkpoint: str) -> None: ... + def load(self, platform_id: str) -> str: ... + + +@runtime_checkable +class DeduplicationStore(Protocol): + """Persist seen event_ids for cross-restart dedup.""" + + def save_batch(self, platform_id: str, event_ids: list[str]) -> None: ... + def load_recent(self, platform_id: str, limit: int = 10000) -> list[str]: ... + + +_DEDUP_TABLE = "gateway_seen_events" +_DEDUP_DDL = f""" +CREATE TABLE IF NOT EXISTS {_DEDUP_TABLE} ( + platform_id VARCHAR NOT NULL, + event_id VARCHAR NOT NULL, + seen_at DOUBLE NOT NULL DEFAULT 0, + PRIMARY KEY (platform_id, event_id) +) +""" + + +class DuckDBDeduplicationStore: + """DuckDB-backed dedup store for cross-restart event deduplication.""" + + def __init__(self, connection_holder: object) -> None: + self._holder = connection_holder + self._ensure_table() + + def _ensure_table(self) -> None: + try: + conn = self._holder.connection # type: ignore[union-attr] + conn.execute(_DEDUP_DDL) + except Exception: + logger.warning("Failed to create dedup table", exc_info=True) + + def save_batch(self, platform_id: str, event_ids: list[str]) -> None: + if not platform_id or not event_ids: + return + try: + from leapflow.storage.write_buffer import execute_with_retry + + conn = self._holder.connection # type: ignore[union-attr] + now = time.time() + rows = [(platform_id, eid, now) for eid in event_ids[-1000:]] + if rows: + conn.executemany( + f""" + INSERT OR IGNORE INTO {_DEDUP_TABLE} + (platform_id, event_id, seen_at) VALUES (?, ?, ?) + """, + rows, + ) + cutoff = now - 86400 * 7 + execute_with_retry( + conn, + f"DELETE FROM {_DEDUP_TABLE} WHERE platform_id = ? AND seen_at < ?", + [platform_id, cutoff], + ) + except Exception: + logger.debug("Failed to save dedup batch for %s", platform_id, exc_info=True) + + def load_recent(self, platform_id: str, limit: int = 10000) -> list[str]: + if not platform_id: + return [] + try: + conn = self._holder.connection # type: ignore[union-attr] + rows = conn.execute( + f"SELECT event_id FROM {_DEDUP_TABLE} WHERE platform_id = ? ORDER BY seen_at DESC LIMIT ?", + [platform_id, limit], + ).fetchall() + return [str(r[0]) for r in rows] + except Exception: + logger.debug("Failed to load dedup state for %s", platform_id, exc_info=True) + return [] + + +class DuckDBCheckpointStore: + """DuckDB-backed checkpoint store for gateway event sources.""" + + def __init__(self, connection_holder: object) -> None: + self._holder = connection_holder + self._ensure_table() + + def _ensure_table(self) -> None: + try: + conn = self._holder.connection # type: ignore[union-attr] + conn.execute(_DDL) + except Exception: + logger.warning("Failed to create checkpoint table", exc_info=True) + + def save(self, platform_id: str, checkpoint: str) -> None: + """Upsert the checkpoint for a platform.""" + if not platform_id or not checkpoint: + return + try: + from leapflow.storage.write_buffer import execute_with_retry + + conn = self._holder.connection # type: ignore[union-attr] + execute_with_retry( + conn, + f""" + INSERT INTO {_TABLE} (platform_id, checkpoint, updated_at) + VALUES (?, ?, ?) + ON CONFLICT (platform_id) DO UPDATE + SET checkpoint = excluded.checkpoint, + updated_at = excluded.updated_at + """, + [platform_id, checkpoint, time.time()], + ) + except Exception: + logger.debug("Failed to save checkpoint for %s", platform_id, exc_info=True) + + def load(self, platform_id: str) -> str: + """Load the last checkpoint for a platform, or empty string.""" + if not platform_id: + return "" + try: + conn = self._holder.connection # type: ignore[union-attr] + result = conn.execute( + f"SELECT checkpoint FROM {_TABLE} WHERE platform_id = ?", + [platform_id], + ).fetchone() + return str(result[0]) if result else "" + except Exception: + logger.debug("Failed to load checkpoint for %s", platform_id, exc_info=True) + return "" diff --git a/src/leapflow/gateway/connectors/__init__.py b/src/leapflow/gateway/connectors/__init__.py index 843583f..1da1a3c 100644 --- a/src/leapflow/gateway/connectors/__init__.py +++ b/src/leapflow/gateway/connectors/__init__.py @@ -11,8 +11,12 @@ BackendEventSource, BackendKind, BackendStatus, + EventClassification, + EventKind, EventSourceStatus, ExecutionBackend, + InboundCallback, + PlatformEventNormalizer, ) __all__ = [ @@ -28,10 +32,14 @@ "BackendStatus", "CliDiscovery", "DiscoveredCommand", + "EventClassification", + "EventKind", "EventSourceStatus", "ExecutionBackend", "HelpParser", "HelpParseResult", + "InboundCallback", + "PlatformEventNormalizer", "summarize_action_result", "validate_payload", ] diff --git a/src/leapflow/gateway/connectors/action_registry.py b/src/leapflow/gateway/connectors/action_registry.py index dad6f17..d847bdb 100644 --- a/src/leapflow/gateway/connectors/action_registry.py +++ b/src/leapflow/gateway/connectors/action_registry.py @@ -16,7 +16,7 @@ from pathlib import Path from typing import TYPE_CHECKING, Any, Mapping, Sequence -from leapflow.gateway.connectors.protocol import ActionResult, ActionSpec +from leapflow.gateway.connectors.protocol import ActionAuthSpec, ActionResult, ActionSpec from leapflow.security.redact import redact_sensitive_text if TYPE_CHECKING: @@ -27,10 +27,18 @@ @dataclass(frozen=True) class ValidationResult: - """Result of validating an action payload against a compact JSON schema subset.""" + """Result of validating an action payload against a compact JSON schema subset. + + When validation fails, structured metadata enables machine-driven recovery + rather than relying on the LLM to parse error strings. + """ ok: bool error: str = "" + failure_code: str = "" + missing_fields: tuple[str, ...] = () + type_errors: tuple[str, ...] = () + recovery_hint: str = "" class ActionRegistry: @@ -118,6 +126,8 @@ def from_yaml(cls, yaml_path: str | Path) -> "ActionRegistry": backend_config["output_args"] = list(str(a) for a in (backend["output_args"] or [])) schema = action_data.get("schema") or {} + auth_data = action_data.get("auth") or {} + recovery_data = action_data.get("recovery") or {} specs[str(name)] = ActionSpec( name=str(name), backend_kind=str((backend.get("kind") or "cli")), @@ -127,6 +137,9 @@ def from_yaml(cls, yaml_path: str | Path) -> "ActionRegistry": backend_config=backend_config, risk_level=str(action_data.get("risk_level") or "medium"), output_policy=str(action_data.get("output_policy") or "summary"), + capability=str(action_data.get("capability") or name), + auth=_parse_auth_spec(auth_data), + recovery=dict(recovery_data) if isinstance(recovery_data, Mapping) else {}, ) return cls(specs) @@ -195,17 +208,68 @@ async def refresh_discovery(self, *, groups: Sequence[str] = ()) -> int: return self.merge_discovered(specs) +def normalize_payload( + spec: ActionSpec, + raw_params: Mapping[str, Any], +) -> dict[str, Any]: + """Normalize raw tool-call params into a well-formed payload dict. + + Applies schema-aware heuristics to fix common LLM structural mistakes: + 1. Lifts business fields placed at the top level into ``payload``. + 2. Preserves explicit ``payload`` contents when present. + 3. Never fabricates values — only relocates fields already present. + + Returns the normalized payload ready for validation and execution. + """ + schema = spec.schema or {} + properties = schema.get("properties") or {} + if not isinstance(properties, Mapping): + properties = {} + known_fields = frozenset(str(k) for k in properties.keys()) + meta_keys = frozenset({"platform", "action", "payload", "backend_kind", "source_tool"}) + + explicit_payload = raw_params.get("payload") + base: dict[str, Any] = dict(explicit_payload) if isinstance(explicit_payload, Mapping) else {} + + for key in known_fields: + if key in base and not _is_missing(base[key]): + continue + top_value = raw_params.get(key) + if top_value is not None and key not in meta_keys: + base[key] = top_value + + return base + + def validate_payload(spec: ActionSpec, payload: Mapping[str, Any]) -> ValidationResult: """Validate a payload against the compact schema subset used by action packs.""" schema = spec.schema or {} required = schema.get("required") or () missing = [str(key) for key in required if _is_missing(payload.get(str(key)))] if missing: - return ValidationResult(ok=False, error=f"Missing required fields: {', '.join(missing)}") + field_paths = tuple(f"payload.{f}" for f in missing) + hint_parts = [f"Provide {', '.join(field_paths)} in the payload object."] + properties = schema.get("properties") or {} + for f in missing: + desc = "" + if isinstance(properties, Mapping): + prop = properties.get(f) + if isinstance(prop, Mapping): + desc = str(prop.get("description") or "") + if desc: + hint_parts.append(f" {f}: {desc}") + return ValidationResult( + ok=False, + error=f"Missing required fields: {', '.join(missing)}", + failure_code="missing_required_fields", + missing_fields=tuple(missing), + recovery_hint="\n".join(hint_parts), + ) properties = schema.get("properties") or {} if not isinstance(properties, Mapping): return ValidationResult(ok=True) + type_errors: list[str] = [] for key, rule in properties.items(): if key not in payload or payload[key] is None: continue @@ -213,24 +277,72 @@ def validate_payload(spec: ActionSpec, payload: Mapping[str, Any]) -> Validation continue expected = str(rule.get("type") or "") if expected and not _matches_type(payload[key], expected): - return ValidationResult(ok=False, error=f"Field '{key}' must be {expected}") + type_errors.append(f"Field '{key}' must be {expected}") + if type_errors: + return ValidationResult( + ok=False, + error="; ".join(type_errors), + failure_code="type_mismatch", + type_errors=tuple(type_errors), + recovery_hint="Check field types against the action schema.", + ) return ValidationResult(ok=True) +def _parse_auth_spec(data: Any) -> ActionAuthSpec: + if not isinstance(data, Mapping): + return ActionAuthSpec() + scopes_raw = data.get("scopes") or {} + scopes: dict[str, tuple[str, ...]] = {} + if isinstance(scopes_raw, Mapping): + for key, value in scopes_raw.items(): + if isinstance(value, Sequence) and not isinstance(value, (str, bytes)): + scopes[str(key)] = tuple(str(item) for item in value if str(item)) + elif value: + scopes[str(key)] = (str(value),) + elif isinstance(scopes_raw, Sequence) and not isinstance(scopes_raw, (str, bytes)): + scopes["common"] = tuple(str(item) for item in scopes_raw if str(item)) + identities = data.get("identities") or () + resource_fields = data.get("resource_fields") or () + recovery = data.get("recovery") or {} + return ActionAuthSpec( + identities=tuple(str(item) for item in identities) if isinstance(identities, Sequence) and not isinstance(identities, (str, bytes)) else (), + scopes=scopes, + resource_fields=tuple(str(item) for item in resource_fields) if isinstance(resource_fields, Sequence) and not isinstance(resource_fields, (str, bytes)) else (), + recovery=dict(recovery) if isinstance(recovery, Mapping) else {}, + ) + + +_SIDE_EFFECT_KINDS = frozenset({"send", "write", "execute"}) + + def summarize_action_result(spec: ActionSpec, result: ActionResult) -> dict[str, Any]: """Apply the action output policy before returning data to the LLM context.""" if not result.ok: - return { + response: dict[str, Any] = { "ok": False, "error": redact_sensitive_text(result.error, force=True), "output_policy": spec.output_policy, + "capability": spec.capability or spec.name, } + if result.failure is not None: + response.update(result.failure.as_dict()) + return response summary: dict[str, Any] = { "ok": True, "resource_id": result.resource_id, "output_policy": spec.output_policy, + "capability": spec.capability or spec.name, } + + if spec.effect in _SIDE_EFFECT_KINDS: + summary["completed"] = True + summary["execution_note"] = ( + "Action executed successfully. Do not re-invoke with the same parameters. " + "Report this result to the user." + ) + if spec.output_policy == "raw": summary["data"] = dict(result.data) return summary diff --git a/src/leapflow/gateway/connectors/composite_event_source.py b/src/leapflow/gateway/connectors/composite_event_source.py new file mode 100644 index 0000000..b366ac4 --- /dev/null +++ b/src/leapflow/gateway/connectors/composite_event_source.py @@ -0,0 +1,150 @@ +"""Composite event source that merges multiple BackendEventSource streams. + +Subscribes to N child sources and yields events from all of them through +a single ``events()`` async iterator. Lifecycle (start/stop/status) +delegates to all children. +""" +from __future__ import annotations + +import asyncio +import logging +from typing import AsyncIterator, Sequence + +from leapflow.gateway.connectors.protocol import BackendEvent, EventSourceStatus + +logger = logging.getLogger(__name__) + +_SENTINEL = object() + + +class CompositeEventSource: + """Merges multiple BackendEventSource streams into one. + + Satisfies the ``BackendEventSource`` protocol. Each child source + gets its own consumer task that pushes events into a shared + ``asyncio.Queue``. ``events()`` yields from the queue. + """ + + backend_kind = "composite" + + def __init__( + self, + sources: Sequence[object], + *, + platform_id: str = "", + ) -> None: + self._sources = list(sources) + self.platform_id = platform_id or ( + self._sources[0].platform_id if self._sources else "unknown" # type: ignore[union-attr] + ) + self._queue: asyncio.Queue[BackendEvent | object] = asyncio.Queue() + self._tasks: list[asyncio.Task[None]] = [] + self._running = False + + async def start(self, *, checkpoint: str = "") -> EventSourceStatus: + """Start all child sources.""" + if self._running: + return await self.status() + + results: list[EventSourceStatus] = [] + for src in self._sources: + status = await src.start(checkpoint=checkpoint) # type: ignore[union-attr] + results.append(status) + + all_ok = all(r.ok for r in results) + self._running = True + detail_parts = [f"{i}: {r.detail}" for i, r in enumerate(results)] + return EventSourceStatus( + ok=all_ok, + backend_kind=self.backend_kind, + detail="; ".join(detail_parts), + checkpoint=checkpoint, + metadata={"child_count": len(self._sources), "all_ok": all_ok}, + ) + + async def stop(self) -> EventSourceStatus: + """Stop all child sources and consumer tasks.""" + self._running = False + for task in self._tasks: + if not task.done(): + task.cancel() + if self._tasks: + await asyncio.gather(*self._tasks, return_exceptions=True) + self._tasks.clear() + + for src in self._sources: + try: + await src.stop() # type: ignore[union-attr] + except Exception: + logger.debug("Error stopping child source", exc_info=True) + + return EventSourceStatus( + ok=True, + backend_kind=self.backend_kind, + detail="stopped", + metadata={"child_count": len(self._sources)}, + ) + + async def events(self) -> AsyncIterator[BackendEvent]: + """Yield events from all child sources through a shared queue.""" + self._tasks = [ + asyncio.create_task( + self._consume_child(i, src), + name=f"composite-child-{i}-{self.platform_id}", + ) + for i, src in enumerate(self._sources) + ] + try: + while self._running or not self._queue.empty(): + item = await self._queue.get() + if item is _SENTINEL: + if all(t.done() for t in self._tasks): + break + continue + yield item # type: ignore[misc] + except asyncio.CancelledError: + return + finally: + for task in self._tasks: + if not task.done(): + task.cancel() + if self._tasks: + await asyncio.gather(*self._tasks, return_exceptions=True) + self._tasks.clear() + + async def _consume_child(self, index: int, source: object) -> None: + """Read events from one child source and push them to the shared queue.""" + try: + async for event in source.events(): # type: ignore[union-attr] + await self._queue.put(event) + except asyncio.CancelledError: + pass + except Exception: + logger.error( + "Composite child %d (%s) crashed", + index, self.platform_id, exc_info=True, + ) + finally: + await self._queue.put(_SENTINEL) + + async def status(self) -> EventSourceStatus: + """Aggregate status from all children.""" + statuses = [] + for src in self._sources: + try: + statuses.append(await src.status()) # type: ignore[union-attr] + except Exception: + statuses.append(EventSourceStatus( + ok=False, backend_kind="unknown", detail="status check failed", + )) + all_ok = all(s.ok for s in statuses) + return EventSourceStatus( + ok=self._running and all_ok, + backend_kind=self.backend_kind, + detail=f"{sum(1 for s in statuses if s.ok)}/{len(statuses)} children ok", + metadata={ + "running": self._running, + "child_count": len(self._sources), + "children_ok": sum(1 for s in statuses if s.ok), + }, + ) diff --git a/src/leapflow/gateway/connectors/dingtalk_event_source.py b/src/leapflow/gateway/connectors/dingtalk_event_source.py new file mode 100644 index 0000000..8ea00c4 --- /dev/null +++ b/src/leapflow/gateway/connectors/dingtalk_event_source.py @@ -0,0 +1,118 @@ +"""DingTalk webhook event source. + +Implements ``BackendEventSource`` by running a lightweight HTTP server +that receives DingTalk callback payloads and yields them as +``BackendEvent`` objects. +""" +from __future__ import annotations + +import asyncio +import logging +from typing import Any, AsyncIterator, Mapping + +from leapflow.gateway.adapters.common import ( + HttpRequest, + HttpResponse, + TinyJsonHttpServer, + parse_bind_port, + parse_json_object, +) +from leapflow.gateway.connectors.protocol import BackendEvent, EventSourceStatus + +logger = logging.getLogger(__name__) + + +class DingTalkWebhookEventSource: + """BackendEventSource wrapping a local HTTP webhook receiver for DingTalk. + + Yields ``BackendEvent`` objects for each incoming DingTalk callback. + """ + + platform_id = "dingtalk" + backend_kind = "webhook" + + def __init__( + self, + *, + host: str = "127.0.0.1", + port: int | str = 9092, + path: str = "/dingtalk/events", + robot_code: str = "", + ) -> None: + self._host = host or "127.0.0.1" + self._port = parse_bind_port(port, 9092) + self._path = path if str(path).startswith("/") else f"/{path}" + self._robot_code = robot_code + self._server: TinyJsonHttpServer | None = None + self._queue: asyncio.Queue[BackendEvent] = asyncio.Queue() + self._running = False + + async def start(self, *, checkpoint: str = "") -> EventSourceStatus: + if self._running: + return await self.status() + self._server = TinyJsonHttpServer(self._host, self._port, self._handle_request) + await self._server.start() + self._running = True + url = f"{self._server.url_base}{self._path}" + logger.info("DingTalk webhook event source listening at %s", url) + return EventSourceStatus( + ok=True, + backend_kind=self.backend_kind, + detail=f"Webhook listening at {url}", + checkpoint=checkpoint, + metadata={"url": url}, + ) + + async def stop(self) -> EventSourceStatus: + self._running = False + if self._server is not None: + await self._server.stop() + self._server = None + return EventSourceStatus( + ok=True, + backend_kind=self.backend_kind, + detail="Webhook stopped", + ) + + async def events(self) -> AsyncIterator[BackendEvent]: + while self._running: + try: + event = await asyncio.wait_for(self._queue.get(), timeout=5.0) + yield event + except asyncio.TimeoutError: + continue + except asyncio.CancelledError: + return + + async def status(self) -> EventSourceStatus: + return EventSourceStatus( + ok=self._running and self._server is not None, + backend_kind=self.backend_kind, + detail="running" if self._running else "stopped", + metadata={"queue_size": self._queue.qsize()}, + ) + + async def _handle_request(self, request: HttpRequest) -> HttpResponse: + if request.path.split("?", 1)[0] != self._path: + return HttpResponse(404, {"ok": False, "error": "not found"}) + if request.method != "POST": + return HttpResponse(405, {"ok": False, "error": "method not allowed"}) + + payload = parse_json_object(request.body) + event = self._to_backend_event(payload) + if event is not None: + await self._queue.put(event) + return HttpResponse(202, {"ok": True, "event_id": event.event_id}) + return HttpResponse(202, {"ok": True, "ignored": True}) + + def _to_backend_event(self, payload: Mapping[str, Any]) -> BackendEvent | None: + msg_id = str(payload.get("msgId") or payload.get("message_id") or "") + event_type_raw = str( + payload.get("msgtype") or payload.get("chatbotCorpId", "message") or "message" + ) + return BackendEvent( + event_id=msg_id, + event_type=event_type_raw, + platform_id=self.platform_id, + payload=dict(payload), + ) diff --git a/src/leapflow/gateway/connectors/event_sources.py b/src/leapflow/gateway/connectors/event_sources.py index efba808..cca55f5 100644 --- a/src/leapflow/gateway/connectors/event_sources.py +++ b/src/leapflow/gateway/connectors/event_sources.py @@ -1,10 +1,22 @@ """Reusable backend event source implementations.""" from __future__ import annotations +import asyncio +import json +import logging +import re +from contextlib import suppress +from dataclasses import dataclass, field from typing import Any, AsyncIterator, Mapping from leapflow.gateway.connectors.protocol import BackendEvent, EventSourceStatus +logger = logging.getLogger(__name__) + + +# ═══════════════════════════════════════════════════════════════ +# UnavailableEventSource — sentinel for unconfigured platforms +# ═══════════════════════════════════════════════════════════════ class UnavailableEventSource: """Event source that explicitly reports an unsupported or unconfigured inbound path.""" @@ -49,3 +61,334 @@ async def status(self) -> EventSourceStatus: detail=self._detail, metadata={**self._metadata, "available": False, "started": self._started}, ) + + +# ═══════════════════════════════════════════════════════════════ +# CliNdjsonEventSource — generic long-lived CLI subprocess +# ═══════════════════════════════════════════════════════════════ + +@dataclass(frozen=True) +class CliEventSourceConfig: + """Configuration for a CLI-backed NDJSON event source.""" + + binary: str + args: tuple[str, ...] + platform_id: str + env: Mapping[str, str] = field(default_factory=dict) + ready_pattern: str = "" + error_pattern: str = "" + ready_timeout_s: float = 30.0 + restart_backoff_base_s: float = 5.0 + max_restart_backoff_s: float = 300.0 + max_restarts: int = 20 + + +class CliNdjsonEventSource: + """Long-lived CLI subprocess that yields NDJSON events on stdout. + + Satisfies the ``BackendEventSource`` protocol. The subprocess + lifecycle follows the lark-cli event consume contract: + + - stdin is kept open (closing it triggers graceful exit) + - stderr is monitored for a ready marker and error patterns + - stdout is read line-by-line as NDJSON + + This class is platform-agnostic. Platform-specific wrappers + (e.g. ``LarkCliEventSource``) provide the ``CliEventSourceConfig`` + with platform-tuned binary, args, and stderr patterns. + """ + + backend_kind = "cli" + + def __init__(self, config: CliEventSourceConfig) -> None: + self._config = config + self.platform_id = config.platform_id + self._proc: asyncio.subprocess.Process | None = None + self._running = False + self._ready = asyncio.Event() + self._restart_count = 0 + self._last_error = "" + self._stderr_task: asyncio.Task[None] | None = None + self._ready_re = re.compile(config.ready_pattern) if config.ready_pattern else None + self._error_re = re.compile(config.error_pattern) if config.error_pattern else None + + async def start(self, *, checkpoint: str = "") -> EventSourceStatus: + """Spawn the subprocess and wait for the ready marker.""" + if self._running: + return await self.status() + self._restart_count = 0 + self._last_error = "" + try: + await self._spawn_process() + except FileNotFoundError: + return EventSourceStatus( + ok=False, + backend_kind=self.backend_kind, + detail=f"CLI binary not found: {self._config.binary}", + metadata={"available": False}, + ) + except OSError as exc: + return EventSourceStatus( + ok=False, + backend_kind=self.backend_kind, + detail=f"Failed to spawn CLI process: {exc}", + metadata={"available": False}, + ) + + if self._ready_re: + try: + await asyncio.wait_for( + self._ready.wait(), timeout=self._config.ready_timeout_s, + ) + except asyncio.TimeoutError: + await self._kill_process() + return EventSourceStatus( + ok=False, + backend_kind=self.backend_kind, + detail=( + f"Event source did not become ready within " + f"{self._config.ready_timeout_s}s" + ), + metadata={"available": True, "started": False, + "last_error": self._last_error}, + ) + + if self._last_error: + await self._kill_process() + return EventSourceStatus( + ok=False, + backend_kind=self.backend_kind, + detail=self._last_error, + metadata={"available": True, "started": False}, + ) + + self._running = True + return EventSourceStatus( + ok=True, + backend_kind=self.backend_kind, + detail="Event source started", + checkpoint=checkpoint, + metadata={"available": True, "started": True}, + ) + + async def stop(self) -> EventSourceStatus: + """Gracefully stop the subprocess.""" + self._running = False + self._ready.clear() + await self._kill_process() + return EventSourceStatus( + ok=True, + backend_kind=self.backend_kind, + detail="Event source stopped", + metadata={"available": True, "started": False}, + ) + + async def events(self) -> AsyncIterator[BackendEvent]: + """Yield parsed BackendEvent objects from stdout NDJSON lines.""" + while self._running: + proc = self._proc + if proc is None or proc.stdout is None: + if not self._running: + return + await self._restart_with_backoff() + continue + + try: + line = await proc.stdout.readline() + except asyncio.CancelledError: + return + + if not line: + exit_code = await proc.wait() + logger.info( + "Event source %s process exited (code=%s)", + self.platform_id, exit_code, + ) + if not self._running: + return + await self._restart_with_backoff() + continue + + event = self._parse_ndjson_line(line) + if event is not None: + self._restart_count = 0 + yield event + + async def status(self) -> EventSourceStatus: + """Return current event source health.""" + alive = self._proc is not None and self._proc.returncode is None + return EventSourceStatus( + ok=self._running and alive, + backend_kind=self.backend_kind, + detail=self._last_error or ("running" if alive else "stopped"), + metadata={ + "available": True, + "started": self._running, + "process_alive": alive, + "restart_count": self._restart_count, + }, + ) + + # ── Internal ───────────────────────────────────────────── + + async def _spawn_process(self) -> None: + """Spawn the CLI subprocess with stdin/stdout/stderr pipes.""" + if self._stderr_task is not None and not self._stderr_task.done(): + self._stderr_task.cancel() + with suppress(asyncio.CancelledError): + await self._stderr_task + self._stderr_task = None + + argv = [self._config.binary, *self._config.args] + env = dict(self._config.env) if self._config.env else None + self._ready.clear() + self._last_error = "" + + self._proc = await asyncio.create_subprocess_exec( + *argv, + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + env=env, + ) + self._stderr_task = asyncio.create_task( + self._monitor_stderr(), + name=f"stderr-{self.platform_id}", + ) + + async def _monitor_stderr(self) -> None: + """Read stderr for ready markers and error signals.""" + proc = self._proc + if proc is None or proc.stderr is None: + return + try: + while True: + line = await proc.stderr.readline() + if not line: + break + text = line.decode("utf-8", errors="replace").strip() + if not text: + continue + if self._ready_re and self._ready_re.search(text): + self._ready.set() + + if text.startswith("{"): + self._handle_stderr_json(text) + elif self._error_re and self._error_re.search(text): + self._last_error = text + logger.warning( + "Event source %s stderr error: %s", + self.platform_id, text, + ) + if not self._ready.is_set(): + self._ready.set() + else: + logger.debug("Event source %s stderr: %s", self.platform_id, text) + except asyncio.CancelledError: + pass + + def _handle_stderr_json(self, text: str) -> None: + """Parse structured JSON error from stderr (lark-cli error envelope).""" + try: + data = json.loads(text) + except json.JSONDecodeError: + return + if isinstance(data, dict) and data.get("ok") is False: + error = data.get("error", {}) + if isinstance(error, dict): + msg = str(error.get("message", "")) + hint = str(error.get("hint", "")) + self._last_error = f"{msg} ({hint})" if hint else msg + elif error: + self._last_error = str(error) + if not self._ready.is_set(): + self._ready.set() + + async def _restart_with_backoff(self) -> None: + """Restart the subprocess with exponential backoff.""" + self._restart_count += 1 + if self._restart_count > self._config.max_restarts: + self._running = False + logger.error( + "Event source %s exceeded max restarts (%d), stopping", + self.platform_id, self._config.max_restarts, + ) + return + backoff = min( + self._config.restart_backoff_base_s * (2 ** (self._restart_count - 1)), + self._config.max_restart_backoff_s, + ) + logger.warning( + "Event source %s restarting in %.1fs (attempt %d/%d)", + self.platform_id, backoff, + self._restart_count, self._config.max_restarts, + ) + await asyncio.sleep(backoff) + if not self._running: + return + try: + await self._spawn_process() + if self._ready_re: + await asyncio.wait_for( + self._ready.wait(), timeout=self._config.ready_timeout_s, + ) + except asyncio.TimeoutError: + logger.warning( + "Event source %s ready timeout on restart attempt %d", + self.platform_id, self._restart_count, + ) + except (FileNotFoundError, OSError) as exc: + logger.error("Event source %s spawn failed: %s", self.platform_id, exc) + + async def _kill_process(self) -> None: + """Terminate the subprocess gracefully, then forcefully.""" + if self._stderr_task and not self._stderr_task.done(): + self._stderr_task.cancel() + with suppress(asyncio.CancelledError): + await self._stderr_task + self._stderr_task = None + + proc = self._proc + self._proc = None + if proc is None or proc.returncode is not None: + return + + if proc.stdin and not proc.stdin.is_closing(): + proc.stdin.close() + with suppress(Exception): + await proc.stdin.drain() + + try: + await asyncio.wait_for(proc.wait(), timeout=5.0) + except asyncio.TimeoutError: + with suppress(ProcessLookupError): + proc.terminate() + try: + await asyncio.wait_for(proc.wait(), timeout=3.0) + except asyncio.TimeoutError: + with suppress(ProcessLookupError): + proc.kill() + with suppress(ProcessLookupError): + await proc.wait() + + def _parse_ndjson_line(self, line: bytes) -> BackendEvent | None: + """Parse one NDJSON line into a BackendEvent.""" + text = line.decode("utf-8", errors="replace").strip() + if not text: + return None + try: + data = json.loads(text) + except json.JSONDecodeError: + logger.debug( + "Event source %s: skipping non-JSON line: %.100s", + self.platform_id, text, + ) + return None + if not isinstance(data, dict): + return None + return BackendEvent( + event_id=str(data.get("event_id", "")), + event_type=str(data.get("type") or data.get("event_type", "")), + platform_id=self.platform_id, + payload=data, + ) diff --git a/src/leapflow/gateway/connectors/lark_event_source.py b/src/leapflow/gateway/connectors/lark_event_source.py new file mode 100644 index 0000000..655cf1e --- /dev/null +++ b/src/leapflow/gateway/connectors/lark_event_source.py @@ -0,0 +1,145 @@ +"""Feishu/Lark event source backed by ``lark-cli event consume``.""" +from __future__ import annotations + +import asyncio +import json +import logging +from typing import Any, AsyncIterator, NamedTuple, Sequence + +from leapflow.gateway.connectors.composite_event_source import CompositeEventSource +from leapflow.gateway.connectors.event_sources import CliEventSourceConfig, CliNdjsonEventSource +from leapflow.gateway.connectors.protocol import BackendEvent, EventSourceStatus + +logger = logging.getLogger(__name__) + +_DEFAULT_EVENT_KEYS: tuple[str, ...] = ( + "im.message.receive_v1", + "card.action.trigger", + "im.message.reaction.created_v1", + "im.message.read_v1", + "im.chat.member.bot.added_v1", + "im.chat.member.bot.deleted_v1", +) + + +class BotIdentity(NamedTuple): + """Resolved bot identity from ``lark-cli auth status --verify``.""" + + open_id: str = "" + app_name: str = "" + + +class LarkCliEventSource: + """Feishu-specific event source wrapping ``lark-cli event consume``. + + Builds a ``CliEventSourceConfig`` tuned for the lark-cli contract: + + - stderr ready marker: ``[event] ready event_key=`` + - stdin EOF triggers graceful exit (unbounded non-TTY mode) + - ``auth status --verify --json`` to discover bot ``openId`` + + Supports multiple event keys via ``CompositeEventSource``. Each + event key spawns its own ``lark-cli event consume`` subprocess. + """ + + platform_id = "feishu" + backend_kind = "cli" + + def __init__( + self, + *, + event_keys: Sequence[str] = _DEFAULT_EVENT_KEYS, + binary: str = "lark-cli", + profile: str = "", + identity: str = "bot", + ) -> None: + self._event_keys = tuple(event_keys) + self._binary = binary + self._profile = profile + self._identity = identity + + children = [ + self._make_child_source(key) for key in self._event_keys + ] + if len(children) == 1: + self._source: CliNdjsonEventSource | CompositeEventSource = children[0] + else: + self._source = CompositeEventSource(children, platform_id="feishu") + + def _make_child_source(self, event_key: str) -> CliNdjsonEventSource: + args: list[str] = ["event", "consume", event_key] + if self._profile: + args.extend(["--profile", self._profile]) + if self._identity: + args.extend(["--as", self._identity]) + config = CliEventSourceConfig( + binary=self._binary, + args=tuple(args), + platform_id="feishu", + ready_pattern=r"\[event\] ready event_key=", + error_pattern=r"\[error\]", + ready_timeout_s=30.0, + ) + return CliNdjsonEventSource(config) + + async def start(self, *, checkpoint: str = "") -> EventSourceStatus: + return await self._source.start(checkpoint=checkpoint) + + async def stop(self) -> EventSourceStatus: + return await self._source.stop() + + async def events(self) -> AsyncIterator[BackendEvent]: + async for event in self._source.events(): + yield event + + async def status(self) -> EventSourceStatus: + return await self._source.status() + + async def fetch_bot_identity(self) -> BotIdentity: + """Fetch the bot's identity via ``lark-cli auth status --verify``. + + Returns ``BotIdentity(open_id, app_name)`` for self-message + filtering and mention detection. Degrades gracefully — empty + fields disable the corresponding filter without blocking. + """ + argv: list[str] = [self._binary] + if self._profile: + argv.extend(["--profile", self._profile]) + argv.extend(["auth", "status", "--json", "--verify"]) + try: + proc = await asyncio.create_subprocess_exec( + *argv, + stdin=asyncio.subprocess.DEVNULL, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.DEVNULL, + ) + try: + stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=15.0) + except asyncio.TimeoutError: + if proc.returncode is None: + try: + proc.kill() + except ProcessLookupError: + pass + await proc.wait() + raise + data = json.loads(stdout.decode("utf-8", errors="replace")) + identities = data.get("identities") or {} + bot_info = identities.get("bot") or {} + open_id = str(bot_info.get("openId") or "") + app_name = str(bot_info.get("appName") or "") + if open_id: + logger.info( + "Feishu bot identity resolved: %s… (%s)", + open_id[:10], app_name or "unnamed", + ) + return BotIdentity(open_id=open_id, app_name=app_name) + except FileNotFoundError: + logger.warning("lark-cli binary not found for bot identity fetch") + except asyncio.TimeoutError: + logger.warning("lark-cli auth status timed out") + except (json.JSONDecodeError, KeyError, TypeError) as exc: + logger.warning("Failed to parse bot identity from lark-cli: %s", exc) + except OSError as exc: + logger.warning("OS error fetching bot identity: %s", exc) + return BotIdentity() diff --git a/src/leapflow/gateway/connectors/protocol.py b/src/leapflow/gateway/connectors/protocol.py index e8a7947..dedeb45 100644 --- a/src/leapflow/gateway/connectors/protocol.py +++ b/src/leapflow/gateway/connectors/protocol.py @@ -3,12 +3,17 @@ This module defines the platform-neutral contract used by REST, CLI, and future MCP backends. Platform-specific packages provide action specs; backend implementations only execute those specs and return normalized results. + +It also defines the event normalisation contract used by the gateway consumer +loop to classify raw backend events into domain types (Message, Callback, +Signal, Lifecycle, Ignored). """ from __future__ import annotations +import time from dataclasses import dataclass, field from enum import Enum -from typing import Any, AsyncIterator, Mapping, Protocol, Sequence, runtime_checkable +from typing import Any, AsyncIterator, Dict, Mapping, Protocol, Sequence, runtime_checkable class BackendKind(str, Enum): @@ -19,6 +24,80 @@ class BackendKind(str, Enum): MCP = "mcp" +@dataclass(frozen=True) +class ActionAuthSpec: + """Platform-neutral authorization contract for an action.""" + + identities: tuple[str, ...] = () + scopes: Mapping[str, tuple[str, ...]] = field(default_factory=dict) + resource_fields: tuple[str, ...] = () + recovery: Mapping[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True) +class ActionFailure: + """Structured action failure used for recovery and approval gating.""" + + failure_class: str + failure_code: str + message: str + recoverability: str = "retryable" + retryable: bool = True + recovery_hint: str = "" + next_steps: tuple[str, ...] = () + required_scopes: tuple[str, ...] = () + missing_scopes: tuple[str, ...] = () + requested_scopes: tuple[str, ...] = () + granted_scopes: tuple[str, ...] = () + identity: str = "" + console_url: str = "" + capability: str = "" + blocks_approval: bool = False + raw: Mapping[str, Any] = field(default_factory=dict) + # ── Scope authority metadata (permission recovery contract) ────────── + # scope_relation describes how the listed scopes combine: + # "all_required" — every listed scope must be granted (conjunction) + # "one_of" — any single listed scope is sufficient (disjunction) + # scope_source describes where the scope list came from, in descending + # trust order: + # "authoritative" — extracted from the upstream API's own error payload + # (e.g. lark-cli typed PermissionError.MissingScopes) + # "declared" — derived from this action's own manifest/action-pack + # auth.scopes contract (feishu.yaml, etc.) + # "unverified" — inferred from free-text/heuristic matching; must + # never be surfaced as a concrete scope list to users + scope_relation: str = "all_required" + scope_source: str = "declared" + + def as_dict(self) -> dict[str, Any]: + """Return safe non-empty fields for tool results and UI metadata.""" + data: dict[str, Any] = { + "failure_class": self.failure_class, + "failure_code": self.failure_code, + "recoverability": self.recoverability, + "retryable": self.retryable, + "blocks_approval": self.blocks_approval, + } + optional: dict[str, Any] = { + "recovery_hint": self.recovery_hint, + "next_steps": list(self.next_steps), + "required_scopes": list(self.required_scopes), + "missing_scopes": list(self.missing_scopes), + "requested_scopes": list(self.requested_scopes), + "granted_scopes": list(self.granted_scopes), + "identity": self.identity, + "console_url": self.console_url, + "capability": self.capability, + } + for key, value in optional.items(): + if value: + data[key] = value + if data.get("required_scopes") or data.get("missing_scopes"): + data["scope_relation"] = self.scope_relation + data["scope_source"] = self.scope_source + return data + + @dataclass(frozen=True) class ActionSpec: """Declarative description of one platform action.""" @@ -31,6 +110,9 @@ class ActionSpec: backend_config: Mapping[str, Any] = field(default_factory=dict) risk_level: str = "medium" output_policy: str = "summary" + capability: str = "" + auth: ActionAuthSpec = field(default_factory=ActionAuthSpec) + recovery: Mapping[str, Any] = field(default_factory=dict) @dataclass(frozen=True) @@ -42,6 +124,7 @@ class ActionResult: error: str = "" resource_id: str = "" raw: Mapping[str, Any] = field(default_factory=dict) + failure: ActionFailure | None = None @dataclass(frozen=True) @@ -52,6 +135,7 @@ class ActionPreview: summary: str = "" data: Mapping[str, Any] = field(default_factory=dict) error: str = "" + failure: ActionFailure | None = None @dataclass(frozen=True) @@ -197,3 +281,64 @@ async def execute_action( ) -> ActionResult: """Validate and execute a platform action.""" ... + + +# ═══════════════════════════════════════════════════════════════ +# Event classification types (Orient stage) +# ═══════════════════════════════════════════════════════════════ + +class EventKind(str, Enum): + """Classification of a backend event for routing.""" + + MESSAGE = "message" + CALLBACK = "callback" + SIGNAL = "signal" + LIFECYCLE = "lifecycle" + IGNORED = "ignored" + + +@dataclass(frozen=True) +class InboundCallback: + """Platform callback event (card button click, form submit, etc.). + + ``reply_token`` may have platform-specific TTL and usage limits + (e.g. Feishu: 30 min, max 2 updates). + """ + + source: "MessageSource" + callback_id: str + action_type: str + action_value: Mapping[str, Any] = field(default_factory=dict) + original_message_id: str = "" + reply_token: str = "" + metadata: Dict[str, Any] = field(default_factory=dict) + timestamp: float = field(default_factory=time.time) + + +@dataclass(frozen=True) +class EventClassification: + """Result of classifying a BackendEvent.""" + + kind: EventKind + message: "InboundMessage | None" = None + callback: InboundCallback | None = None + raw_event: BackendEvent | None = None + + +@runtime_checkable +class PlatformEventNormalizer(Protocol): + """Classifies raw backend events into domain types. + + Each platform provides a concrete implementation that maps its + event schema to the shared ``EventClassification`` type. + """ + + platform_id: str + + def classify(self, event: BackendEvent) -> EventClassification: + """Classify and normalise a backend event.""" + ... + + def is_self_message(self, event: BackendEvent, bot_id: str) -> bool: + """Return True if the event was produced by the bot itself.""" + ... diff --git a/src/leapflow/gateway/connectors/telegram_event_source.py b/src/leapflow/gateway/connectors/telegram_event_source.py new file mode 100644 index 0000000..5aa58ee --- /dev/null +++ b/src/leapflow/gateway/connectors/telegram_event_source.py @@ -0,0 +1,152 @@ +"""Telegram polling event source. + +Implements ``BackendEventSource`` by long-polling the Telegram Bot API +for updates and yielding them as ``BackendEvent`` objects. +""" +from __future__ import annotations + +import asyncio +import logging +from typing import Any, AsyncIterator, Mapping + +from leapflow.gateway.connectors.protocol import BackendEvent, EventSourceStatus + +logger = logging.getLogger(__name__) + + +class TelegramPollingEventSource: + """BackendEventSource wrapping Telegram getUpdates long-polling. + + Constructed with the bot token and API base, runs its own poll loop + and yields BackendEvent per update. + """ + + platform_id = "telegram" + backend_kind = "http_poll" + + def __init__( + self, + *, + bot_token: str, + api_base: str = "https://api.telegram.org", + poll_interval_s: float = 1.0, + http_client: Any = None, + ) -> None: + self._bot_token = bot_token + self._api_base = api_base.rstrip("/") + self._poll_interval_s = poll_interval_s + from leapflow.gateway.adapters.common import UrlLibJsonHttpClient + self._http = http_client or UrlLibJsonHttpClient() + self._offset = 0 + self._running = False + self._bot_id = "" + + async def start(self, *, checkpoint: str = "") -> EventSourceStatus: + if self._running: + return await self.status() + if checkpoint: + try: + self._offset = int(checkpoint) + except ValueError: + pass + self._running = True + await self._fetch_bot_id() + return EventSourceStatus( + ok=True, + backend_kind=self.backend_kind, + detail="Telegram polling started", + checkpoint=str(self._offset), + metadata={"bot_id": self._bot_id}, + ) + + async def stop(self) -> EventSourceStatus: + self._running = False + return EventSourceStatus( + ok=True, + backend_kind=self.backend_kind, + detail="Telegram polling stopped", + checkpoint=str(self._offset), + ) + + async def events(self) -> AsyncIterator[BackendEvent]: + while self._running: + try: + status_code, data = await self._api( + "getUpdates", + {"offset": self._offset, "timeout": 25}, + timeout_s=30, + ) + if status_code < 400 and data.get("ok"): + for update in data.get("result", []) or []: + if not isinstance(update, dict): + continue + update_id = int(update.get("update_id", self._offset)) + self._offset = max(self._offset, update_id + 1) + event = self._to_backend_event(update) + if event is not None: + yield event + except asyncio.CancelledError: + return + except Exception: + logger.debug("Telegram poll error", exc_info=True) + await asyncio.sleep(self._poll_interval_s) + await asyncio.sleep(self._poll_interval_s) + + async def status(self) -> EventSourceStatus: + return EventSourceStatus( + ok=self._running, + backend_kind=self.backend_kind, + detail="running" if self._running else "stopped", + checkpoint=str(self._offset), + metadata={"bot_id": self._bot_id}, + ) + + @property + def bot_id(self) -> str: + return self._bot_id + + def _to_backend_event(self, update: Mapping[str, Any]) -> BackendEvent | None: + update_id = str(update.get("update_id", "")) + event_type = "message" + if "callback_query" in update: + event_type = "callback_query" + elif "edited_message" in update: + event_type = "edited_message" + elif "my_chat_member" in update or "chat_member" in update: + event_type = "chat_member_update" + elif "message_reaction" in update: + event_type = "message_reaction" + return BackendEvent( + event_id=update_id, + event_type=event_type, + platform_id=self.platform_id, + payload=dict(update), + ) + + async def _fetch_bot_id(self) -> None: + """Resolve bot user_id via getMe for self-message filtering.""" + try: + status_code, data = await self._api("getMe", {}, timeout_s=10) + if status_code < 400 and data.get("ok"): + result = data.get("result", {}) + self._bot_id = str(result.get("id", "")) + bot_name = str(result.get("username", "")) + if self._bot_id: + logger.info( + "Telegram bot identity: id=%s username=%s", + self._bot_id, bot_name, + ) + except Exception: + logger.debug("Failed to fetch Telegram bot identity", exc_info=True) + + async def _api( + self, + method: str, + payload: Mapping[str, Any], + *, + timeout_s: float = 10.0, + ) -> tuple[int, dict[str, Any]]: + url = f"{self._api_base}/bot{self._bot_token}/{method}" + return await self._http.request_json( + "POST", url, json_body=payload, timeout_s=timeout_s, + ) diff --git a/src/leapflow/gateway/event_bridge.py b/src/leapflow/gateway/event_bridge.py new file mode 100644 index 0000000..25b79c2 --- /dev/null +++ b/src/leapflow/gateway/event_bridge.py @@ -0,0 +1,76 @@ +"""Bridge between gateway events and the platform EventBus. + +Subscribes to ``GatewayServer.on_event`` and publishes equivalent +``SystemEvent`` objects into ``EventBus``, making gateway IM signals +visible to the learning pipeline (PatternMiner, Copilot, etc.) +without coupling the gateway to the EventBus directly. +""" +from __future__ import annotations + +import asyncio +import logging +import time +from dataclasses import asdict +from typing import Any + +logger = logging.getLogger(__name__) + + +class GatewayEventBridge: + """Translates gateway events into EventBus SystemEvents. + + Usage:: + + bridge = GatewayEventBridge(event_bus) + gateway_server = GatewayServer(..., on_event=bridge.on_gateway_event) + """ + + _EVENT_TYPE_MAP = { + "GatewayMessageReceived": "gateway.message.received", + "GatewaySessionCreated": "gateway.session.created", + "GatewaySessionEnded": "gateway.session.ended", + "BackendEvent": "gateway.signal", + "InboundCallback": "gateway.callback.received", + } + + def __init__(self, event_bus: Any) -> None: + self._event_bus = event_bus + + async def on_gateway_event(self, event: object) -> None: + """Convert a gateway event and publish it to the EventBus.""" + class_name = type(event).__name__ + event_type = self._EVENT_TYPE_MAP.get(class_name) + if event_type is None: + return + + payload = self._extract_payload(event) + payload["_gateway_class"] = class_name + + try: + await self._event_bus.handle_event(event_type, payload) + except Exception: + logger.debug("Failed to bridge gateway event %s", event_type, exc_info=True) + + @staticmethod + def _extract_payload(event: object) -> dict[str, Any]: + """Extract a serializable dict from a gateway event object.""" + try: + data = asdict(event) # type: ignore[arg-type] + except (TypeError, AttributeError): + data = {} + for attr in ("source", "text", "session_key", "platform_id", + "event_type", "event_id", "callback_id"): + val = getattr(event, attr, None) + if val is not None: + data[attr] = str(val) if not isinstance(val, (dict, list)) else val + + source = getattr(event, "source", None) + if source is not None: + platform = getattr(source, "platform", "") + if platform: + data["_platform"] = platform + + if "timestamp" not in data: + data["timestamp"] = time.time() + + return data diff --git a/src/leapflow/gateway/manifests/feishu.yaml b/src/leapflow/gateway/manifests/feishu.yaml index 2a4d14b..5404a95 100644 --- a/src/leapflow/gateway/manifests/feishu.yaml +++ b/src/leapflow/gateway/manifests/feishu.yaml @@ -33,6 +33,27 @@ options: advanced: true help_zh: "发送 IM 文本前在 LeapFlow 侧保留的最大字符数" help_en: "Maximum text characters retained by LeapFlow before sending IM messages" + - key: events_enabled + label: 入站事件 + type: boolean + default: false + help_zh: "启用后 LeapFlow 可接收飞书群消息并自动回复(需要先完成 lark-cli 事件总线配置)" + help_en: "When enabled, LeapFlow receives Feishu chat messages and can auto-reply (requires lark-cli event bus setup)" + - key: event_key + label: 事件键 + type: string + default: "im.message.receive_v1" + advanced: true + help_zh: "lark-cli event consume 订阅的事件键" + help_en: "Event key for lark-cli event consume subscription" + - key: trigger_mode + label: 触发模式 + type: choice + choices: [mention_only, all, keyword, manual] + default: mention_only + advanced: true + help_zh: "何时触发回复:mention_only=被@或私聊时, all=所有消息, keyword=关键词匹配, manual=手动" + help_en: "When to trigger replies: mention_only=@mention or DM, all=every message, keyword=keyword match, manual=never" setup_guide: summary_zh: | @@ -66,10 +87,10 @@ actions: events: kind: cli - status: unavailable - command: lark-event + status: available + command: "lark-cli event consume" checkpoint: profile - note: "Enable after lark-event/WebSocket is configured; no legacy webhook fallback." + note: "Requires lark-cli event bus; run 'lark-cli event status' to verify readiness." adapter: module: leapflow.gateway.adapters.feishu diff --git a/src/leapflow/gateway/normalizers/__init__.py b/src/leapflow/gateway/normalizers/__init__.py new file mode 100644 index 0000000..5e8923d --- /dev/null +++ b/src/leapflow/gateway/normalizers/__init__.py @@ -0,0 +1 @@ +"""Platform event normalizers.""" diff --git a/src/leapflow/gateway/normalizers/dingtalk.py b/src/leapflow/gateway/normalizers/dingtalk.py new file mode 100644 index 0000000..a14f6d5 --- /dev/null +++ b/src/leapflow/gateway/normalizers/dingtalk.py @@ -0,0 +1,151 @@ +"""DingTalk event normalizer. + +Maps DingTalk webhook/stream callback payloads (from a ``BackendEventSource``) +into the shared ``EventClassification`` types. +""" +from __future__ import annotations + +import re +import time +from typing import Any, Mapping + +from leapflow.gateway.connectors.protocol import ( + BackendEvent, + EventClassification, + EventKind, +) +from leapflow.gateway.protocol import InboundMessage, MessageSource + + +_AT_ALL_PATTERN = re.compile(r"@(?:all(?:\b|$)|所有人)", re.IGNORECASE) + + +class DingTalkEventNormalizer: + """Normalizes DingTalk BackendEvents into domain types. + + Satisfies the ``PlatformEventNormalizer`` protocol via structural subtyping. + """ + + platform_id = "dingtalk" + + def __init__( + self, *, bot_id: str = "", bot_name: str = "", robot_code: str = "", profile: str = "default", + ) -> None: + self._bot_id = bot_id + self._bot_name = bot_name + self._robot_code = robot_code + self._profile = profile + + @property + def bot_id(self) -> str: + return self._bot_id + + @bot_id.setter + def bot_id(self, value: str) -> None: + self._bot_id = value + + @property + def bot_name(self) -> str: + return self._bot_name + + @bot_name.setter + def bot_name(self, value: str) -> None: + self._bot_name = value + + def is_self_message(self, event: BackendEvent, bot_id: str) -> bool: + payload = event.payload + sender_id = str(payload.get("senderStaffId") or payload.get("senderId") or "") + if bot_id and sender_id == bot_id: + return True + robot = str(payload.get("robotCode") or "") + if self._robot_code and robot == self._robot_code: + is_robot_msg = payload.get("isFromRobot") or payload.get("robotCode") + if is_robot_msg: + return True + return False + + def classify(self, event: BackendEvent) -> EventClassification: + payload = event.payload + event_type = event.event_type + + if event_type in ("chat_member_join", "chat_member_leave", "org_suite_relay"): + return EventClassification(kind=EventKind.LIFECYCLE, raw_event=event) + + text = self._extract_text(payload) + if text: + if self.is_self_message(event, self._bot_id): + return EventClassification(kind=EventKind.IGNORED) + message = self._to_inbound_message(event, text) + if message is None: + return EventClassification(kind=EventKind.IGNORED, raw_event=event) + return EventClassification(kind=EventKind.MESSAGE, message=message) + + if payload.get("actionCardActionIds") or payload.get("actionCardCallbackUrl"): + return EventClassification(kind=EventKind.CALLBACK, raw_event=event) + + return EventClassification(kind=EventKind.SIGNAL, raw_event=event) + + def _to_inbound_message(self, event: BackendEvent, text: str) -> InboundMessage | None: + payload = event.payload + chat_id = str( + payload.get("conversationId") + or payload.get("conversation_id") + or payload.get("chat_id") + or "" + ) + chat_type = "group" if payload.get("conversationType") == "2" else "dm" + user_id = str(payload.get("senderStaffId") or payload.get("senderId") or "") + user_name = str(payload.get("senderNick") or payload.get("senderName") or "") + message_id = str(payload.get("msgId") or payload.get("message_id") or "") + + bot_mentioned = ( + bool(payload.get("isInAtList")) + or bool(payload.get("isAtAll")) + or self._detect_bot_mention(text) + ) + + metadata: dict[str, Any] = { + "robot_code": self._robot_code, + } + if bot_mentioned: + metadata["bot_mentioned"] = True + + create_time = payload.get("createAt") or payload.get("create_time") + try: + timestamp = int(create_time) / 1000.0 if create_time else time.time() + except (ValueError, TypeError): + timestamp = time.time() + + return InboundMessage( + source=MessageSource( + platform="dingtalk", + chat_id=chat_id, + chat_type=chat_type, + user_id=user_id, + user_name=user_name, + profile=self._profile, + ), + text=text, + message_id=message_id, + metadata=metadata, + timestamp=timestamp, + ) + + def _detect_bot_mention(self, content: str) -> bool: + if _AT_ALL_PATTERN.search(content): + return True + if not self._bot_name: + return False + return f"@{self._bot_name}" in content + + @staticmethod + def _extract_text(payload: Mapping[str, Any]) -> str: + text = payload.get("text") + if isinstance(text, dict): + return str(text.get("content") or "") + if text: + return str(text) + content = payload.get("content") + if isinstance(content, dict): + return str(content.get("text") or content.get("content") or "") + return str(payload.get("msgContent") or "") diff --git a/src/leapflow/gateway/normalizers/feishu.py b/src/leapflow/gateway/normalizers/feishu.py new file mode 100644 index 0000000..fc5de77 --- /dev/null +++ b/src/leapflow/gateway/normalizers/feishu.py @@ -0,0 +1,288 @@ +"""Feishu/Lark event normalizer. + +Maps the flat NDJSON output of ``lark-cli event consume`` into the +shared ``EventClassification`` types. lark-cli already resolves +@-mentions inline and unwraps message content, so the normalizer +works with pre-processed fields. + +NDJSON fields (lark-cli ``ImMessageReceiveOutput``): + + type, event_id, timestamp, message_id, create_time, + chat_id, chat_type, message_type, sender_id, content +""" +from __future__ import annotations + +import logging +import re +import time +from typing import Any, Mapping + +from leapflow.gateway.connectors.protocol import ( + BackendEvent, + EventClassification, + EventKind, + InboundCallback, +) +from leapflow.gateway.protocol import InboundMessage, MediaAttachment, MessageSource + +logger = logging.getLogger(__name__) + +_AT_ALL_PATTERN = re.compile(r"@(?:all(?:\b|$)|所有人)", re.IGNORECASE) + +_MESSAGE_EVENT_TYPES = frozenset({ + "im.message.receive_v1", +}) + +_CALLBACK_EVENT_TYPES = frozenset({ + "card.action.trigger", +}) + +_SIGNAL_EVENT_TYPES = frozenset({ + "im.message.reaction.created_v1", + "im.message.reaction.deleted_v1", + "im.message.read_v1", +}) + +_LIFECYCLE_EVENT_TYPES = frozenset({ + "im.chat.member.bot.added_v1", + "im.chat.member.bot.deleted_v1", + "im.chat.updated_v1", + "im.chat.disbanded_v1", +}) + +_MEDIA_MESSAGE_TYPES = frozenset({"image", "file", "audio", "video", "media", "sticker"}) + + +class FeishuEventNormalizer: + """Normalizes Feishu backend events into domain types. + + Satisfies the ``PlatformEventNormalizer`` protocol via structural + subtyping (no base class). + """ + + platform_id = "feishu" + + def __init__( + self, + *, + bot_id: str = "", + bot_name: str = "", + profile: str = "default", + ) -> None: + self._bot_id = bot_id + self._bot_name = bot_name + self._profile = profile + + @property + def bot_id(self) -> str: + return self._bot_id + + @bot_id.setter + def bot_id(self, value: str) -> None: + self._bot_id = value + + @property + def bot_name(self) -> str: + return self._bot_name + + @bot_name.setter + def bot_name(self, value: str) -> None: + self._bot_name = value + + def is_self_message(self, event: BackendEvent, bot_id: str) -> bool: + """Check if the event was produced by the bot itself.""" + sender = _get_sender_id(event.payload) + return bool(bot_id and sender and sender == bot_id) + + def classify(self, event: BackendEvent) -> EventClassification: + """Classify a Feishu backend event into a domain type.""" + event_type = event.event_type + + if event_type in _MESSAGE_EVENT_TYPES: + if self.is_self_message(event, self._bot_id): + return EventClassification(kind=EventKind.IGNORED) + message = self._to_inbound_message(event) + if message is None: + return EventClassification(kind=EventKind.IGNORED, raw_event=event) + return EventClassification(kind=EventKind.MESSAGE, message=message) + + if event_type in _CALLBACK_EVENT_TYPES: + callback = self._to_inbound_callback(event) + return EventClassification( + kind=EventKind.CALLBACK, callback=callback, raw_event=event, + ) + + if event_type in _SIGNAL_EVENT_TYPES: + return EventClassification(kind=EventKind.SIGNAL, raw_event=event) + + if event_type in _LIFECYCLE_EVENT_TYPES: + return EventClassification(kind=EventKind.LIFECYCLE, raw_event=event) + + return EventClassification(kind=EventKind.SIGNAL, raw_event=event) + + def _to_inbound_message(self, event: BackendEvent) -> InboundMessage | None: + """Convert a message event to InboundMessage. + + lark-cli emits **flat** NDJSON — fields are top-level, not + nested under ``event.message`` / ``event.sender``. + """ + payload = event.payload + content = str(payload.get("content", "")) + message_type = str(payload.get("message_type", "text")) + + media = self._extract_media(payload, message_type) + + if not content and not media: + return None + + chat_type_raw = str(payload.get("chat_type", "")) + chat_type = "dm" if chat_type_raw in ("p2p", "private", "") else "group" + + bot_mentioned = self._detect_bot_mention(content) if content else False + + source = MessageSource( + platform="feishu", + chat_id=str(payload.get("chat_id", "")), + chat_type=chat_type, + user_id=str(payload.get("sender_id", "")), + profile=self._profile, + ) + + create_time = payload.get("create_time") or payload.get("timestamp", "") + try: + timestamp = int(create_time) / 1000.0 if create_time else time.time() + except (ValueError, TypeError): + timestamp = time.time() + + if not content and media: + content = f"[{message_type}]" + + metadata: dict[str, Any] = { + "event_id": event.event_id, + "message_type": message_type, + } + if bot_mentioned: + metadata["bot_mentioned"] = True + + message_id = str(payload.get("message_id") or payload.get("id", "")) + parent_id = str(payload.get("parent_id") or payload.get("root_id") or "") + + return InboundMessage( + source=source, + text=content, + message_id=message_id, + reply_to_id=parent_id or None, + media=tuple(media), + metadata=metadata, + timestamp=timestamp, + ) + + @staticmethod + def _extract_media( + payload: Mapping[str, Any], message_type: str, + ) -> list[MediaAttachment]: + """Extract media attachments from non-text message types. + + Returns attachment metadata with ``file_key`` for deferred + download via ``im.download_resource``. + """ + if message_type == "text": + return [] + + attachments: list[MediaAttachment] = [] + message_id = str(payload.get("message_id") or payload.get("id", "")) + + if message_type not in _MEDIA_MESSAGE_TYPES: + return [] + + file_key = str(payload.get("file_key") or payload.get("image_key") or "") + filename = str(payload.get("file_name") or payload.get("filename") or "") + size = 0 + try: + size = int(payload.get("file_size") or payload.get("size") or 0) + except (ValueError, TypeError): + pass + + if file_key: + attachments.append(MediaAttachment( + url=f"feishu://resource/{message_id}/{file_key}", + media_type=message_type, + filename=filename, + size_bytes=size, + )) + + return attachments + + def _to_inbound_callback(self, event: BackendEvent) -> InboundCallback: + """Parse a card.action.trigger payload into InboundCallback. + + Feishu card action payloads (flat NDJSON from lark-cli) contain: + - open_message_id: the card message that was clicked + - open_chat_id: chat where the card lives + - action: {tag, value, ...} — the button/form action + - token: reply token for updating the card + """ + payload = event.payload + action = payload.get("action", {}) + if isinstance(action, str): + action = {} + + action_value = action.get("value", {}) + if isinstance(action_value, str): + action_value = {"raw": action_value} + + chat_id = str(payload.get("open_chat_id", "")) + user_id = str(payload.get("open_id", "")) + chat_type_raw = str(payload.get("chat_type", "")) + chat_type = "dm" if chat_type_raw in ("p2p", "private", "") else "group" + + source = MessageSource( + platform="feishu", + chat_id=chat_id, + chat_type=chat_type, + user_id=user_id, + profile=self._profile, + ) + + return InboundCallback( + source=source, + callback_id=str(event.event_id), + action_type=str(action.get("tag", "button")), + action_value=action_value, + original_message_id=str(payload.get("open_message_id", "")), + reply_token=str(payload.get("token", "")), + metadata={ + "event_type": event.event_type, + "operator_name": str(payload.get("operator_name", "")), + }, + ) + + def _detect_bot_mention(self, content: str) -> bool: + """Detect bot @-mention or @all in pre-rendered content text. + + lark-cli resolves mentions inline as ``@DisplayName``. + ``@all`` / ``@所有人`` are treated as a bot mention since + they address everyone in the chat including the bot. + """ + if _AT_ALL_PATTERN.search(content): + return True + if not self._bot_name: + return False + return f"@{self._bot_name}" in content + + +def _get_sender_id(payload: Mapping[str, Any]) -> str: + """Extract sender open_id from flat or nested event payload.""" + sender_id = payload.get("sender_id") + if isinstance(sender_id, str): + return sender_id + if isinstance(sender_id, dict): + return str(sender_id.get("open_id", "")) + sender = payload.get("sender") + if isinstance(sender, dict): + sid = sender.get("sender_id") + if isinstance(sid, dict): + return str(sid.get("open_id", "")) + if isinstance(sid, str): + return sid + return "" diff --git a/src/leapflow/gateway/normalizers/telegram.py b/src/leapflow/gateway/normalizers/telegram.py new file mode 100644 index 0000000..c0d0765 --- /dev/null +++ b/src/leapflow/gateway/normalizers/telegram.py @@ -0,0 +1,187 @@ +"""Telegram event normalizer. + +Maps raw Telegram update payloads (from a ``BackendEventSource``) +into the shared ``EventClassification`` types. +""" +from __future__ import annotations + +import time +from typing import Any, Mapping + +from leapflow.gateway.connectors.protocol import ( + BackendEvent, + EventClassification, + EventKind, +) +from leapflow.gateway.protocol import InboundMessage, MediaAttachment, MessageSource + + +class TelegramEventNormalizer: + """Normalizes Telegram BackendEvents into domain types. + + Satisfies the ``PlatformEventNormalizer`` protocol via structural subtyping. + """ + + platform_id = "telegram" + + def __init__(self, *, bot_id: str = "", bot_name: str = "", profile: str = "default") -> None: + self._bot_id = bot_id + self._bot_name = bot_name + self._profile = profile + + @property + def bot_id(self) -> str: + return self._bot_id + + @bot_id.setter + def bot_id(self, value: str) -> None: + self._bot_id = value + + @property + def bot_name(self) -> str: + return self._bot_name + + @bot_name.setter + def bot_name(self, value: str) -> None: + self._bot_name = value + + def is_self_message(self, event: BackendEvent, bot_id: str) -> bool: + payload = event.payload + raw_message = payload.get("message") or payload.get("edited_message") or {} + if not isinstance(raw_message, dict): + return False + from_user = raw_message.get("from", {}) + if isinstance(from_user, dict): + sender_id = str(from_user.get("id", "")) + return bool(bot_id and sender_id == bot_id) + return False + + def classify(self, event: BackendEvent) -> EventClassification: + payload = event.payload + if payload.get("message") or payload.get("edited_message"): + if self.is_self_message(event, self._bot_id): + return EventClassification(kind=EventKind.IGNORED) + message = self._to_inbound_message(event) + if message is None: + return EventClassification(kind=EventKind.IGNORED, raw_event=event) + return EventClassification(kind=EventKind.MESSAGE, message=message) + + if payload.get("callback_query"): + return EventClassification(kind=EventKind.CALLBACK, raw_event=event) + + if payload.get("my_chat_member") or payload.get("chat_member"): + return EventClassification(kind=EventKind.LIFECYCLE, raw_event=event) + + if payload.get("message_reaction") or payload.get("message_reaction_count"): + return EventClassification(kind=EventKind.SIGNAL, raw_event=event) + + return EventClassification(kind=EventKind.SIGNAL, raw_event=event) + + def _to_inbound_message(self, event: BackendEvent) -> InboundMessage | None: + payload = event.payload + raw_message = payload.get("message") or payload.get("edited_message") + if not isinstance(raw_message, dict): + return None + + text = str(raw_message.get("text") or raw_message.get("caption") or "") + media = self._extract_media(raw_message) + + if not text and not media: + return None + + chat = raw_message.get("chat", {}) + user = raw_message.get("from", {}) + if not isinstance(chat, dict): + chat = {} + if not isinstance(user, dict): + user = {} + + chat_id = str(chat.get("id", "")) + chat_type_raw = str(chat.get("type", "private")) + chat_type = "dm" if chat_type_raw == "private" else "group" + user_id = str(user.get("id", "")) + user_name = str(user.get("username") or user.get("first_name") or "") + + bot_mentioned = self._detect_bot_mention(raw_message) + + if not text and media: + text = f"[{media[0].media_type}]" + + metadata: dict[str, Any] = { + "update_id": str(payload.get("update_id", "")), + } + if bot_mentioned: + metadata["bot_mentioned"] = True + + return InboundMessage( + source=MessageSource( + platform="telegram", + chat_id=chat_id, + chat_type=chat_type, + user_id=user_id, + user_name=user_name, + profile=self._profile, + ), + text=text, + message_id=str(raw_message.get("message_id", "")), + media=tuple(media), + metadata=metadata, + timestamp=float(raw_message.get("date", time.time())), + ) + + @staticmethod + def _extract_media(raw_message: Mapping[str, Any]) -> list[MediaAttachment]: + """Extract media attachments from a Telegram message.""" + attachments: list[MediaAttachment] = [] + for media_key, media_type in ( + ("photo", "image"), ("document", "file"), + ("audio", "audio"), ("video", "video"), + ("voice", "audio"), ("sticker", "sticker"), + ): + media_data = raw_message.get(media_key) + if media_data is None: + continue + if media_key == "photo" and isinstance(media_data, list) and media_data: + best = media_data[-1] if media_data else {} + file_id = str(best.get("file_id", "")) if isinstance(best, dict) else "" + if file_id: + attachments.append(MediaAttachment( + url=f"telegram://file/{file_id}", + media_type=media_type, + size_bytes=int(best.get("file_size", 0)) if isinstance(best, dict) else 0, + )) + elif isinstance(media_data, dict): + file_id = str(media_data.get("file_id", "")) + if file_id: + attachments.append(MediaAttachment( + url=f"telegram://file/{file_id}", + media_type=media_type, + filename=str(media_data.get("file_name", "")), + size_bytes=int(media_data.get("file_size", 0)), + )) + return attachments + + def _detect_bot_mention(self, raw_message: Mapping[str, Any]) -> bool: + """Detect @bot mention or @all-equivalent in Telegram message entities.""" + entities = raw_message.get("entities", []) + if not isinstance(entities, list): + return False + text = str(raw_message.get("text", "")) + for ent in entities: + if not isinstance(ent, dict): + continue + if ent.get("type") == "mention": + offset = int(ent.get("offset", 0)) + length = int(ent.get("length", 0)) + mentioned = text[offset:offset + length].lstrip("@") + if mentioned.lower() in ("all", "everyone"): + return True + if self._bot_name and mentioned.lower() == self._bot_name.lower(): + return True + if ent.get("type") == "text_mention": + user = ent.get("user", {}) + if isinstance(user, dict): + uid = str(user.get("id", "")) + if self._bot_id and uid == self._bot_id: + return True + return False diff --git a/src/leapflow/gateway/resource_provenance.py b/src/leapflow/gateway/resource_provenance.py new file mode 100644 index 0000000..d73e175 --- /dev/null +++ b/src/leapflow/gateway/resource_provenance.py @@ -0,0 +1,199 @@ +"""Resource provenance tracking for platform actions. + +Maintains a session-scoped pool of resource identifiers (chat_id, message_id, +file_key, etc.) that have been observed from successful API responses. Used to +detect hallucinated resource references before they reach the approval gate. + +Design: +- Platform-neutral: no Feishu-specific logic. +- Populated automatically from successful action results that contain fields + matching declared ``resource_fields`` from any action spec. +- Queried before side-effect actions to determine provenance of referenced + resources: VERIFIED (seen from API), UNVERIFIED (pool populated but ID not + found), or UNKNOWN (pool never populated for this resource type). +""" +from __future__ import annotations + +import logging +from dataclasses import dataclass, field +from enum import Enum +from typing import Any, Dict, Mapping, Set, Sequence + +logger = logging.getLogger(__name__) + + +class ProvenanceStatus(str, Enum): + """Result of checking a resource identifier's provenance.""" + + VERIFIED = "verified" + UNVERIFIED = "unverified" + UNKNOWN = "unknown" + + +@dataclass(frozen=True) +class ProvenanceResult: + """Outcome of a provenance check for a single resource field.""" + + field_name: str + value: str + status: ProvenanceStatus + known_values_count: int = 0 + + @property + def is_safe(self) -> bool: + """True if the resource is verified or provenance cannot be determined.""" + return self.status in (ProvenanceStatus.VERIFIED, ProvenanceStatus.UNKNOWN) + + +class ResourceProvenancePool: + """Session-scoped pool of known resource identifiers per platform. + + Thread-safety: single-threaded async usage expected; no locking. + """ + + def __init__(self) -> None: + # Key: (platform, resource_field_name) → set of known values + self._pool: Dict[tuple[str, str], Set[str]] = {} + + def register( + self, + platform: str, + resource_field: str, + value: str, + ) -> None: + """Register a resource identifier observed from a successful API call.""" + if not value: + return + key = (platform, resource_field) + if key not in self._pool: + self._pool[key] = set() + self._pool[key].add(value) + + def register_from_result( + self, + platform: str, + resource_fields: Sequence[str], + result_data: Any, + ) -> int: + """Extract and register resource IDs from an action result. + + Recursively scans the result data for keys matching declared + resource_fields and registers all found string values. + + Returns the number of values registered. + """ + if not resource_fields or not result_data: + return 0 + target_fields = set(resource_fields) + found = _extract_values(result_data, target_fields) + count = 0 + for field_name, values in found.items(): + for value in values: + self.register(platform, field_name, value) + count += 1 + if count > 0: + logger.debug( + "resource_provenance.registered platform=%s fields=%s count=%d", + platform, list(found.keys()), count, + ) + return count + + def check( + self, + platform: str, + resource_field: str, + value: str, + ) -> ProvenanceResult: + """Check whether a resource identifier has known provenance.""" + key = (platform, resource_field) + pool = self._pool.get(key) + if pool is None: + return ProvenanceResult( + field_name=resource_field, + value=value, + status=ProvenanceStatus.UNKNOWN, + known_values_count=0, + ) + if value in pool: + return ProvenanceResult( + field_name=resource_field, + value=value, + status=ProvenanceStatus.VERIFIED, + known_values_count=len(pool), + ) + return ProvenanceResult( + field_name=resource_field, + value=value, + status=ProvenanceStatus.UNVERIFIED, + known_values_count=len(pool), + ) + + def check_payload( + self, + platform: str, + resource_fields: Sequence[str], + payload: Mapping[str, Any], + ) -> list[ProvenanceResult]: + """Check provenance of all resource fields referenced in a payload. + + Returns a list of ProvenanceResult for each resource field that has a + non-empty value in the payload. + """ + results: list[ProvenanceResult] = [] + for field_name in resource_fields: + value = str(payload.get(field_name) or "") + if not value: + continue + results.append(self.check(platform, field_name, value)) + return results + + def has_data(self, platform: str, resource_field: str) -> bool: + """Return True if any values have been registered for this resource type.""" + key = (platform, resource_field) + pool = self._pool.get(key) + return pool is not None and len(pool) > 0 + + def clear(self, platform: str | None = None) -> None: + """Clear pool entries for a platform, or all entries if platform is None.""" + if platform is None: + self._pool.clear() + else: + keys = [k for k in self._pool if k[0] == platform] + for key in keys: + del self._pool[key] + + def summary(self) -> Dict[str, Dict[str, int]]: + """Return a compact summary: platform → field → count.""" + result: Dict[str, Dict[str, int]] = {} + for (platform, field_name), values in self._pool.items(): + if platform not in result: + result[platform] = {} + result[platform][field_name] = len(values) + return result + + +def _extract_values( + data: Any, + target_fields: set[str], + *, + _depth: int = 0, +) -> Dict[str, list[str]]: + """Recursively extract values for target field names from nested data.""" + if _depth > 10: + return {} + found: Dict[str, list[str]] = {} + if isinstance(data, Mapping): + for key, value in data.items(): + str_key = str(key) + if str_key in target_fields and isinstance(value, str) and value: + found.setdefault(str_key, []).append(value) + elif isinstance(value, (dict, list)): + nested = _extract_values(value, target_fields, _depth=_depth + 1) + for nk, nv in nested.items(): + found.setdefault(nk, []).extend(nv) + elif isinstance(data, (list, tuple)): + for item in data: + nested = _extract_values(item, target_fields, _depth=_depth + 1) + for nk, nv in nested.items(): + found.setdefault(nk, []).extend(nv) + return found diff --git a/src/leapflow/gateway/router.py b/src/leapflow/gateway/router.py index a24a210..ea9f029 100644 --- a/src/leapflow/gateway/router.py +++ b/src/leapflow/gateway/router.py @@ -23,15 +23,53 @@ import asyncio import json import logging -from typing import Any, Callable, Coroutine, Dict, List, Optional, Sequence +from typing import Any, Callable, Coroutine, Dict, List, Optional, Protocol, Sequence, runtime_checkable from leapflow.gateway.protocol import InboundMessage, MessageSource -from leapflow.tools.name_resolver import ToolRegistry +from leapflow.tools.name_resolver import TOOL_NAME_ALIASES, ToolRegistry + + +@runtime_checkable +class SessionPersistence(Protocol): + """Minimal persistence interface for gateway sessions. + + Satisfied by ``DuckDBConversationStore`` without requiring + a direct import — dependency inversion via structural subtyping. + """ + + def create_session(self, session_id: str, *, title: str = "", **kwargs: Any) -> Any: ... + def get_session(self, session_id: str) -> Any: ... + def append_message(self, session_id: str, role: str, content: str, **kwargs: Any) -> Any: ... + def get_messages(self, session_id: str, *, limit: int = 100, active_only: bool = True) -> list[Any]: ... + + +class IndicatorPhase: + """Processing indicator lifecycle phases.""" + + START = "start" + DONE = "done" + ERROR = "error" + + +IndicatorFn = Callable[["MessageSource", str, str], Coroutine[Any, Any, None]] + +StreamSendFn = Callable[ + ["MessageSource", str, str], + Coroutine[Any, Any, str], +] + +ContextFetchFn = Callable[ + [str, str, str], + Coroutine[Any, Any, str], +] logger = logging.getLogger(__name__) SendFn = Callable[[MessageSource, str], Coroutine[Any, Any, None]] +_STREAM_UPDATE_INTERVAL_S = 1.5 +_STREAM_MIN_DELTA_CHARS = 40 + SAFE_TOOLS: frozenset[str] = frozenset({ "memory_search", "memory_add", "time_get", "env_info", @@ -78,6 +116,11 @@ def __init__( allowed_tools: frozenset[str] = SAFE_TOOLS, max_history: int = 50, max_tool_rounds: int = 3, + persistence: Optional[SessionPersistence] = None, + indicator_fn: Optional[IndicatorFn] = None, + stream_send_fn: Optional[StreamSendFn] = None, + streaming_enabled: bool = True, + context_fetch_fn: Optional[ContextFetchFn] = None, ) -> None: self._llm = llm self._system_prompt = system_prompt @@ -86,6 +129,11 @@ def __init__( self._max_tool_rounds = max_tool_rounds self._sessions: Dict[str, List[Dict[str, Any]]] = {} self._locks: Dict[str, asyncio.Lock] = {} + self._persistence = persistence + self._indicator_fn = indicator_fn + self._stream_send_fn = stream_send_fn + self._streaming_enabled = streaming_enabled and stream_send_fn is not None + self._context_fetch_fn = context_fetch_fn self._tool_defs = [ td for td in tool_definitions @@ -95,7 +143,9 @@ def __init__( self._tool_handlers = { k: v for k, v in all_handlers.items() if k in allowed_tools } - self._tool_registry = ToolRegistry.from_definitions(self._tool_defs, self._tool_handlers) + self._tool_registry = ToolRegistry.from_definitions( + self._tool_defs, self._tool_handlers, aliases=TOOL_NAME_ALIASES + ) async def handle_message( self, @@ -117,27 +167,220 @@ async def _process( ) -> None: history = self._sessions.get(session_key) if history is None: - history = [] - if self._system_prompt: - history.append({"role": "system", "content": self._system_prompt}) + history = self._init_session(session_key, message) self._sessions[session_key] = history - history.append({"role": "user", "content": message.text}) + user_content = message.text + if message.reply_to_id and self._context_fetch_fn: + parent_text = await self._fetch_parent_context( + message.source.platform, message.reply_to_id, + ) + if parent_text: + user_content = f"[Replying to: {parent_text[:500]}]\n\n{message.text}" + + history.append({"role": "user", "content": user_content}) + self._persist_message(session_key, "user", user_content) self._trim_history(history) - reply = await self._llm_with_tools(history) - if not reply: + await self._signal_indicator(message.source, message.message_id, IndicatorPhase.START) + phase = IndicatorPhase.ERROR + reply = "" + streamed = False + try: + if self._streaming_enabled: + reply = await self._stream_reply(message.source, history) + streamed = bool(reply) + if not reply: + reply = await self._llm_with_tools(history) + if not reply: + phase = IndicatorPhase.DONE + return + + history.append({"role": "assistant", "content": reply}) + self._persist_message(session_key, "assistant", reply) + + if not streamed: + try: + await self._send_fn(message.source, reply) + except Exception: + logger.error( + "Failed to send reply for session %s", + session_key, + exc_info=True, + ) + phase = IndicatorPhase.DONE + finally: + await self._signal_indicator(message.source, message.message_id, phase) + + def _init_session( + self, + session_key: str, + message: InboundMessage, + ) -> List[Dict[str, Any]]: + """Initialize a session, restoring history from persistence if available.""" + history: List[Dict[str, Any]] = [] + if self._system_prompt: + history.append({"role": "system", "content": self._system_prompt}) + + if self._persistence is not None: + try: + existing = self._persistence.get_session(session_key) + if existing is not None: + stored = self._persistence.get_messages( + session_key, limit=self._max_history, + ) + for msg in stored: + role = getattr(msg, "role", "") + content = getattr(msg, "content", "") + if role in ("user", "assistant") and content: + history.append({"role": role, "content": content}) + if stored: + logger.info( + "Restored %d messages for session %s", + len(stored), session_key[:30], + ) + else: + source = message.source + title = f"{source.platform}:{source.chat_type}:{source.chat_id}" + self._persistence.create_session( + session_key, + title=title[:200], + source=f"gateway:{source.platform}", + ) + except Exception: + logger.debug("Session persistence error for %s", session_key, exc_info=True) + + return history + + async def _fetch_parent_context( + self, platform: str, message_id: str, + ) -> str: + """Fetch the text of a parent message for reply context.""" + if self._context_fetch_fn is None: + return "" + try: + return await asyncio.wait_for( + self._context_fetch_fn(platform, message_id, "text"), + timeout=10.0, + ) + except (asyncio.TimeoutError, Exception): + logger.debug("Parent context fetch failed for %s", message_id, exc_info=True) + return "" + + async def _signal_indicator( + self, source: MessageSource, message_id: str, phase: str, + ) -> None: + """Fire-and-forget processing indicator signal.""" + if self._indicator_fn is None: return + try: + await asyncio.wait_for( + self._indicator_fn(source, message_id, phase), + timeout=5.0, + ) + except (asyncio.TimeoutError, Exception): + logger.debug("Processing indicator %s failed", phase, exc_info=True) - history.append({"role": "assistant", "content": reply}) + def _persist_message(self, session_key: str, role: str, content: str) -> None: + """Append a message to persistent storage (fire-and-forget).""" + if self._persistence is None: + return try: - await self._send_fn(message.source, reply) + self._persistence.append_message(session_key, role, content) except Exception: - logger.error( - "Failed to send reply for session %s", - session_key, - exc_info=True, - ) + logger.debug("Failed to persist %s message for %s", role, session_key, exc_info=True) + + async def _stream_reply( + self, + source: MessageSource, + history: List[Dict[str, Any]], + ) -> str: + """Stream LLM response with progressive message updates. + + Sends an initial placeholder, then progressively edits the + message as chunks arrive from the LLM streaming API. Falls + back to non-streaming ``_llm_with_tools`` if the LLM wants to + call tools (detected by ``tool_calls`` on the first chunks). + Returns empty string to signal the caller to use the tool path. + """ + assert self._stream_send_fn is not None + + try: + llm_kwargs: Dict[str, Any] = {"stream": True} + if self._tool_defs: + llm_kwargs["tools"] = self._tool_defs + resp = await self._llm.achat(history, **llm_kwargs) + except Exception: + logger.debug("Streaming LLM call failed, deferring to tool path", exc_info=True) + return "" + + chunks: list[str] = [] + message_id = "" + last_update = 0.0 + last_sent_len = 0 + has_tool_calls = False + + async def _collect_stream() -> None: + nonlocal message_id, last_update, last_sent_len, has_tool_calls + async for chunk in resp: + if getattr(chunk, "tool_calls", None): + has_tool_calls = True + return + delta = getattr(chunk, "content", "") or "" + if not delta: + continue + chunks.append(delta) + now = asyncio.get_running_loop().time() + accumulated = "".join(chunks) + + should_update = ( + now - last_update >= _STREAM_UPDATE_INTERVAL_S + and len(accumulated) - last_sent_len >= _STREAM_MIN_DELTA_CHARS + ) + + if not message_id and accumulated: + try: + message_id = await self._stream_send_fn( + source, accumulated, "", + ) + last_update = now + last_sent_len = len(accumulated) + except Exception: + logger.debug("Initial stream send failed", exc_info=True) + elif should_update and message_id: + try: + await self._stream_send_fn( + source, accumulated, message_id, + ) + last_update = now + last_sent_len = len(accumulated) + except Exception: + logger.debug("Stream update failed", exc_info=True) + + try: + await _collect_stream() + except Exception: + logger.debug("Stream collection error", exc_info=True) + + if has_tool_calls: + return "" + + full_text = "".join(chunks).strip() + if not full_text: + return "" + + if message_id and len(full_text) > last_sent_len: + try: + await self._stream_send_fn(source, full_text, message_id) + except Exception: + logger.debug("Final stream update failed", exc_info=True) + elif not message_id: + try: + await self._send_fn(source, full_text) + except Exception: + logger.debug("Fallback send after stream failed", exc_info=True) + + return full_text async def _llm_with_tools( self, @@ -211,6 +454,52 @@ def _trim_history(self, history: List[Dict[str, Any]]) -> None: else: history[:] = history[-keep:] + async def handle_callback( + self, + callback: Any, + session_key: str, + ) -> None: + """Process an inbound callback: inject action context into session, LLM call, reply. + + Callbacks (card button clicks, form submissions) carry action_value + that provides context for the LLM to generate an appropriate response. + """ + lock = self._locks.setdefault(session_key, asyncio.Lock()) + async with lock: + from leapflow.gateway.connectors.protocol import InboundCallback + + if not isinstance(callback, InboundCallback): + return + action_desc = ( + f"[Card action: {callback.action_type}] " + f"value={callback.action_value}" + ) + history = self._sessions.get(session_key) + if history is None: + history = [] + if self._system_prompt: + history.append({"role": "system", "content": self._system_prompt}) + self._sessions[session_key] = history + + history.append({"role": "user", "content": action_desc}) + self._persist_message(session_key, "user", action_desc) + self._trim_history(history) + + reply = await self._llm_with_tools(history) + if not reply: + return + + history.append({"role": "assistant", "content": reply}) + self._persist_message(session_key, "assistant", reply) + try: + await self._send_fn(callback.source, reply) + except Exception: + logger.error( + "Failed to send callback reply for session %s", + session_key, + exc_info=True, + ) + def clear_session(self, session_key: str) -> None: """Remove all state for a session (called on disconnect/timeout).""" self._sessions.pop(session_key, None) diff --git a/src/leapflow/gateway/server.py b/src/leapflow/gateway/server.py index 7986f35..e8c3a8a 100644 --- a/src/leapflow/gateway/server.py +++ b/src/leapflow/gateway/server.py @@ -18,13 +18,30 @@ import asyncio import importlib import logging +import re import time +from collections import OrderedDict from pathlib import Path from typing import Any, Callable, Dict, List, Optional +from leapflow.gateway.capability_health import CapabilityHealthLedger +from leapflow.gateway.checkpoint_store import CheckpointStore, DeduplicationStore +from leapflow.gateway.resource_provenance import ResourceProvenancePool from leapflow.gateway.config_store import GatewayConfigStore from leapflow.gateway.connectors.action_registry import summarize_action_result -from leapflow.gateway.connectors.protocol import ActionPreview, ActionSpec, BackendEventSource, EventSourceStatus +from leapflow.gateway.connectors.event_sources import UnavailableEventSource +from leapflow.gateway.connectors.protocol import ( + ActionPreview, + ActionSpec, + BackendEvent, + BackendEventSource, + EventClassification, + EventKind, + EventSourceStatus, + InboundCallback, + PlatformEventNormalizer, +) +from leapflow.gateway.trigger_policy import TriggerPolicy, _RateTracker from leapflow.gateway.credential_vault import CredentialVault from leapflow.gateway.events import ( GatewayMessageReceived, @@ -48,6 +65,104 @@ EventCallback = Callable[..., Any] +_MARKDOWN_PATTERNS = ( + re.compile(r"```"), + re.compile(r"^#{1,6}\s", re.MULTILINE), + re.compile(r"^\s*[-*+]\s", re.MULTILINE), + re.compile(r"^\s*\d+\.\s", re.MULTILINE), + re.compile(r"\*\*.+?\*\*"), + re.compile(r"`.+?`"), + re.compile(r"\[.+?\]\(.+?\)"), +) + + +def _has_rich_formatting(text: str) -> bool: + """Detect markdown-like formatting in text.""" + if not text or len(text) < 10: + return False + matches = sum(1 for p in _MARKDOWN_PATTERNS if p.search(text)) + return matches >= 2 + + +_DEFAULT_DEDUP_CAPACITY = 10_000 + + +def _chunk_text(text: str, max_len: int) -> list[str]: + """Split text into chunks respecting paragraph/line boundaries. + + Prefers splitting at paragraph boundaries (double newline), then + single newlines, then sentence endings, then at max_len as fallback. + """ + if not text or max_len <= 0: + return [text] if text else [] + if len(text) <= max_len: + return [text] + + chunks: list[str] = [] + remaining = text + while remaining: + if len(remaining) <= max_len: + chunks.append(remaining) + break + split_at = max_len + for sep in ("\n\n", "\n", "。", ". ", "!", "! ", "?", "? "): + pos = remaining.rfind(sep, 0, max_len) + if pos > max_len // 4: + split_at = pos + len(sep) + break + chunk = remaining[:split_at].rstrip() + if chunk: + chunks.append(chunk) + remaining = remaining[split_at:].lstrip() + return chunks or [text] + + +class EventDeduplicator: + """LRU-based event_id dedup cache for the consumer loop. + + Prevents duplicate processing when a platform redelivers events + (e.g. after reconnection). In-memory only; checkpoint persistence + covers the restart case. + """ + + __slots__ = ("_capacity", "_seen") + + def __init__(self, capacity: int = _DEFAULT_DEDUP_CAPACITY) -> None: + self._capacity = max(1, capacity) + self._seen: OrderedDict[str, None] = OrderedDict() + + def is_duplicate(self, event_id: str) -> bool: + """Return True if the event_id has been seen before.""" + if not event_id: + return False + if event_id in self._seen: + self._seen.move_to_end(event_id) + return True + self._seen[event_id] = None + if len(self._seen) > self._capacity: + self._seen.popitem(last=False) + return False + + def load_from_store( + self, platform_id: str, store: DeduplicationStore, + ) -> int: + """Pre-seed the cache from persistent storage.""" + ids = store.load_recent(platform_id, limit=self._capacity) + for eid in reversed(ids): + if eid not in self._seen: + self._seen[eid] = None + if len(self._seen) > self._capacity: + self._seen.popitem(last=False) + return len(ids) + + def save_to_store( + self, platform_id: str, store: DeduplicationStore, + ) -> None: + """Persist current cache to the dedup store.""" + ids = list(self._seen.keys()) + if ids: + store.save_batch(platform_id, ids) + class GatewayServer: """Manages platform adapter lifecycle and message routing.""" @@ -58,19 +173,32 @@ def __init__( *, extra_manifest_dirs: Optional[List[Path]] = None, on_event: Optional[EventCallback] = None, + checkpoint_store: Optional[CheckpointStore] = None, + dedup_store: Optional[DeduplicationStore] = None, ) -> None: self._profile_dir = profile_dir self._vault = CredentialVault(profile_dir) + self._checkpoint_store = checkpoint_store + self._dedup_store = dedup_store self._config_store = GatewayConfigStore(profile_dir, self._vault) self._manifest_loader = ManifestLoader(extra_dirs=extra_manifest_dirs) self._manifests: Dict[str, PlatformManifest] = {} self._adapters: Dict[str, PlatformAdapter] = {} self._event_sources: Dict[str, BackendEventSource] = {} + self._consumer_tasks: Dict[str, asyncio.Task[None]] = {} + self._normalizers: Dict[str, PlatformEventNormalizer] = {} + self._trigger_policies: Dict[str, TriggerPolicy] = {} + self._rate_trackers: Dict[str, _RateTracker] = {} + self._deduplicators: Dict[str, EventDeduplicator] = {} + self._last_event_ids: Dict[str, str] = {} self._connected_since: Dict[str, float] = {} self._known_sessions: set[SessionKey] = set() self._message_handler: Optional[Callable[..., Any]] = None + self._callback_handler: Optional[Callable[..., Any]] = None self._on_event = on_event self._started = False + self._capability_health = CapabilityHealthLedger() + self._resource_provenance = ResourceProvenancePool() # ── Manifest discovery ─────────────────────────────────── @@ -90,6 +218,29 @@ def set_message_handler(self, handler: Callable[..., Any]) -> None: """Set the callback for inbound messages (typically engine dispatch).""" self._message_handler = handler + def set_callback_handler(self, handler: Callable[..., Any]) -> None: + """Set the callback for inbound interactive callbacks (card actions, etc.).""" + self._callback_handler = handler + + def register_normalizer( + self, platform_id: str, normalizer: PlatformEventNormalizer, + ) -> None: + """Register a platform event normalizer for the consumer loop.""" + self._normalizers[platform_id] = normalizer + + def register_trigger_policy( + self, platform_id: str, policy: TriggerPolicy, + ) -> None: + """Register a trigger policy for the consumer loop.""" + self._trigger_policies[platform_id] = policy + self._rate_trackers[platform_id] = _RateTracker() + + def platform_options(self, platform_id: str) -> dict[str, Any]: + """Return stored options for a configured platform (public API).""" + config = self._config_store.load() + pc = config.platforms.get(platform_id) + return dict(pc.options) if pc and pc.options else {} + # ── Platform connection ────────────────────────────────── async def connect_platform( @@ -135,7 +286,6 @@ async def connect_platform( adapter = self._instantiate_adapter( manifest, credentials, options or {}, ) - adapter.on_message = self._on_inbound_message await adapter.connect(is_reconnect=is_reconnect) self._adapters[platform_id] = adapter self._connected_since[platform_id] = time.time() @@ -155,6 +305,7 @@ async def connect_platform( return response display = manifest.display_name + self._capability_health.clear(platform_id) response = { "ok": True, "status": "connected", @@ -170,9 +321,16 @@ async def connect_platform( async def disconnect_platform(self, platform_id: str) -> Dict[str, Any]: """Disconnect a platform adapter and clean up its sessions.""" + await self._cancel_consumer_task(platform_id) adapter = self._adapters.pop(platform_id, None) - self._event_sources.pop(platform_id, None) + source = self._event_sources.pop(platform_id, None) + if source is not None: + try: + await source.stop() + except Exception: + logger.debug("Error stopping event source for %s", platform_id, exc_info=True) self._connected_since.pop(platform_id, None) + self._capability_health.clear(platform_id) affected = {k for k in self._known_sessions if k.platform == platform_id} self._known_sessions -= affected @@ -246,6 +404,7 @@ async def start(self) -> int: ) if result.get("ok"): connected += 1 + await self._auto_start_events(pid) except Exception as exc: logger.warning( "gateway.auto_connect_failed platform=%s error=%s", @@ -256,8 +415,35 @@ async def start(self) -> int: self._started = True return connected + async def _auto_start_events(self, platform_id: str) -> None: + """Start event source for a platform if adapter reports one.""" + try: + result = await self.start_platform_events(platform_id) + if result.get("ok"): + logger.info("Auto-started event source for %s", platform_id) + else: + logger.debug( + "Event source not started for %s: %s", + platform_id, result.get("detail") or result.get("error", ""), + ) + except Exception: + logger.warning( + "Failed to auto-start events for %s", + platform_id, exc_info=True, + ) + async def stop(self) -> None: - """Disconnect all adapters.""" + """Disconnect all adapters and stop all consumer tasks.""" + tasks_to_await = [] + for task in self._consumer_tasks.values(): + if not task.done(): + task.cancel() + tasks_to_await.append(task) + self._consumer_tasks.clear() + + if tasks_to_await: + await asyncio.gather(*tasks_to_await, return_exceptions=True) + for pid in list(self._adapters): await self.disconnect_platform(pid) self._started = False @@ -332,12 +518,64 @@ async def preview_platform_action( return {"ok": False, "error": f"Unknown platform action: {action}"} return {"ok": True, "summary": f"Run {platform_id}.{action}"} result: ActionPreview = await preview(action, payload) - return { + response = { "ok": result.ok, "summary": result.summary, "data": dict(result.data), **({"error": result.error} if result.error else {}), } + if result.failure is not None: + response.update(result.failure.as_dict()) + return response + + def check_platform_action_feasibility( + self, + platform_id: str, + action: str, + payload: Dict[str, Any] | None = None, + ) -> Dict[str, Any]: + """Check whether an action is known to be executable before approval.""" + spec = self.get_platform_action_spec(platform_id, action) + if spec is None: + return {"ok": True} + return self._capability_health.check_feasibility(platform_id, spec) + + def clear_platform_capability_health(self, platform_id: str) -> None: + """Forget stale authorization failures for a platform. + + Management flows such as status/preflight use this as an explicit + refresh boundary. The next business action revalidates against the live + backend and records a fresh failure if authorization is still missing. + """ + self._capability_health.clear(platform_id) + + def capability_health_summary(self) -> List[Dict[str, Any]]: + """Return compact non-secret capability health diagnostics.""" + return self._capability_health.summary() + + @property + def resource_provenance(self) -> ResourceProvenancePool: + """Return the session-scoped resource provenance pool.""" + return self._resource_provenance + + def _collect_all_resource_fields(self, platform_id: str) -> tuple[str, ...]: + """Return all declared resource_fields across a platform's action specs.""" + adapter = self._adapters.get(platform_id) + if adapter is None: + return () + action_specs_fn = getattr(adapter, "action_specs", None) + if action_specs_fn is None: + return () + specs = action_specs_fn() + if not isinstance(specs, dict): + return () + fields: set[str] = set() + for spec in specs.values(): + auth = getattr(spec, "auth", None) + if auth is not None: + for f in getattr(auth, "resource_fields", ()): + fields.add(str(f)) + return tuple(sorted(fields)) async def execute_platform_action( self, @@ -356,6 +594,22 @@ async def execute_platform_action( spec = self.get_platform_action_spec(platform_id, action) result = await execute(action, payload) if spec is not None: + if not result.ok and result.failure is not None: + self._capability_health.record_failure( + platform_id, + spec.capability or spec.name, + result.failure, + ) + elif result.ok: + self._capability_health.record_success( + platform_id, + spec.capability or spec.name, + ) + resource_fields = self._collect_all_resource_fields(platform_id) + if resource_fields and result.data: + self._resource_provenance.register_from_result( + platform_id, resource_fields, result.data, + ) summary = summarize_action_result(spec, result) summary.update({"platform": platform_id, "action": action}) return summary @@ -376,7 +630,7 @@ async def start_platform_events( *, checkpoint: str = "", ) -> Dict[str, Any]: - """Start the backend event source for a connected platform.""" + """Start the backend event source and consumer loop for a platform.""" adapter = self._adapters.get(platform_id) if adapter is None: return {"ok": False, "error": f"Platform '{platform_id}' is not connected"} @@ -386,16 +640,37 @@ async def start_platform_events( source = event_source_factory() if source is None: return {"ok": False, "error": f"Platform '{platform_id}' has no backend event source"} + if isinstance(source, UnavailableEventSource): + unavailable_status = await source.status() + return self._event_status_dict(unavailable_status) + + if not checkpoint and self._checkpoint_store is not None: + checkpoint = self._checkpoint_store.load(platform_id) + if checkpoint: + logger.info("Resuming %s from checkpoint %s", platform_id, checkpoint[:20]) + status: EventSourceStatus = await source.start(checkpoint=checkpoint) - if status.ok: - self._event_sources[platform_id] = source + if not status.ok: + return self._event_status_dict(status) + + self._event_sources[platform_id] = source + await self._cancel_consumer_task(platform_id) + + task = asyncio.create_task( + self._consume_platform_events(platform_id, source), + name=f"gateway-consumer-{platform_id}", + ) + self._consumer_tasks[platform_id] = task return self._event_status_dict(status) async def stop_platform_events(self, platform_id: str) -> Dict[str, Any]: - """Stop the backend event source for a connected platform.""" + """Stop the backend event source and consumer task for a platform.""" + await self._cancel_consumer_task(platform_id) source = self._event_sources.pop(platform_id, None) if source is None: return {"ok": True, "status": "stopped"} + self._save_checkpoint(platform_id) + self._save_dedup_state(platform_id) status = await source.stop() return self._event_status_dict(status) @@ -470,6 +745,7 @@ async def send_reply( """Send a reply back to the originating conversation. Convenience wrapper around ``send_message`` for ``GatewayRouter``. + Automatically chunks text that exceeds the adapter's max_message_length. """ adapter = self._adapters.get(source.platform) if adapter is None: @@ -482,8 +758,20 @@ async def send_reply( chat_id=source.chat_id, thread_id=source.thread_id, ) - content = OutboundContent(text=text) - return await adapter.send(target, content) + max_len = getattr(adapter, "max_message_length", 0) or 8000 + chunks = _chunk_text(text, max_len) + if not chunks: + return SendResult(ok=True) + last_result: Optional[SendResult] = None + for chunk in chunks: + metadata: Dict[str, Any] = {} + if _has_rich_formatting(chunk): + metadata["format_hint"] = "markdown" + content = OutboundContent(text=chunk, metadata=metadata) + last_result = await adapter.send(target, content) + if last_result and not last_result.ok: + return last_result + return last_result def remove_platform_config(self, platform_id: str) -> None: """Remove a platform's saved configuration from disk. @@ -531,6 +819,166 @@ async def _on_inbound_message(self, message: InboundMessage) -> None: exc_info=True, ) + async def _on_inbound_callback(self, callback: "InboundCallback") -> None: + """Route an inbound callback (card button click, form submit, etc.).""" + session_key = build_session_key(callback.source) + await self._emit_event(callback) + + if self._callback_handler is None: + logger.debug( + "No callback handler set, dropping callback %s from %s", + callback.callback_id, callback.source.platform, + ) + return + + try: + result = self._callback_handler(callback, str(session_key)) + if asyncio.iscoroutine(result): + await result + except Exception: + logger.error( + "Error handling callback from %s", + callback.source.platform, + exc_info=True, + ) + + # ── Event consumer loop ──────────────────────────────────── + + async def _consume_platform_events( + self, + platform_id: str, + source: BackendEventSource, + ) -> None: + """Background task that reads events and routes them.""" + normalizer = self._normalizers.get(platform_id) + policy = self._trigger_policies.get(platform_id) + rate_tracker = self._rate_trackers.get(platform_id) + dedup = self._deduplicators.setdefault(platform_id, EventDeduplicator()) + + if self._dedup_store is not None: + loaded = dedup.load_from_store(platform_id, self._dedup_store) + if loaded: + logger.info("Loaded %d dedup entries for %s", loaded, platform_id) + + self._inject_bot_identity(platform_id, normalizer) + try: + async for event in source.events(): + if dedup.is_duplicate(event.event_id): + logger.debug("Dedup: skipping duplicate event %s", event.event_id) + continue + try: + await self._process_backend_event( + platform_id, event, normalizer, policy, rate_tracker, + ) + if event.event_id: + self._last_event_ids[platform_id] = event.event_id + except Exception: + logger.error( + "Error processing event %s from %s", + event.event_id, platform_id, exc_info=True, + ) + except asyncio.CancelledError: + logger.info("Consumer task cancelled for %s", platform_id) + except Exception: + logger.error( + "Consumer loop crashed for %s", platform_id, exc_info=True, + ) + finally: + self._save_checkpoint(platform_id) + self._save_dedup_state(platform_id) + + async def _process_backend_event( + self, + platform_id: str, + event: BackendEvent, + normalizer: PlatformEventNormalizer | None, + policy: TriggerPolicy | None, + rate_tracker: _RateTracker | None, + ) -> None: + """Classify and route one backend event.""" + if normalizer is None: + logger.debug("No normalizer for %s, emitting raw event", platform_id) + await self._emit_event(event) + return + + classification = normalizer.classify(event) + + if classification.kind == EventKind.IGNORED: + return + + if classification.kind == EventKind.MESSAGE and classification.message: + if policy and not policy.should_activate( + classification.message, rate_tracker=rate_tracker, + ): + await self._emit_event(GatewayMessageReceived( + source=classification.message.source, + session_key="", + text=classification.message.text, + media_urls=tuple(m.url for m in classification.message.media), + )) + return + await self._on_inbound_message(classification.message) + return + + if classification.kind == EventKind.CALLBACK and classification.callback: + await self._on_inbound_callback(classification.callback) + return + + if classification.raw_event: + await self._emit_event(classification.raw_event) + + def _inject_bot_identity( + self, + platform_id: str, + normalizer: PlatformEventNormalizer | None, + ) -> None: + """Inject adapter-resolved bot identity into the normalizer. + + Uses duck typing — works with any adapter that exposes + ``bot_identity`` with ``open_id`` / ``app_name`` attributes. + """ + if normalizer is None: + return + adapter = self._adapters.get(platform_id) + if adapter is None: + return + identity = getattr(adapter, "bot_identity", None) + if identity is None: + return + open_id = getattr(identity, "open_id", "") + app_name = getattr(identity, "app_name", "") + if open_id and hasattr(normalizer, "bot_id"): + normalizer.bot_id = open_id + logger.debug("Injected bot_id=%s… for %s", open_id[:10], platform_id) + if app_name and hasattr(normalizer, "bot_name"): + normalizer.bot_name = app_name + + def _save_dedup_state(self, platform_id: str) -> None: + """Persist dedup cache for a platform to the store.""" + if self._dedup_store is None: + return + dedup = self._deduplicators.get(platform_id) + if dedup is not None: + dedup.save_to_store(platform_id, self._dedup_store) + logger.debug("Saved dedup state for %s", platform_id) + + def _save_checkpoint(self, platform_id: str) -> None: + """Persist the last-consumed event_id for a platform.""" + event_id = self._last_event_ids.get(platform_id) + if event_id and self._checkpoint_store is not None: + self._checkpoint_store.save(platform_id, event_id) + logger.debug("Saved checkpoint %s for %s", event_id[:20], platform_id) + + async def _cancel_consumer_task(self, platform_id: str) -> None: + """Cancel and await a consumer task if one exists.""" + task = self._consumer_tasks.pop(platform_id, None) + if task is not None and not task.done(): + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + async def _emit_event(self, event: object) -> None: """Dispatch an event to the optional callback (non-blocking).""" if self._on_event is None: diff --git a/src/leapflow/gateway/trigger_policy.py b/src/leapflow/gateway/trigger_policy.py new file mode 100644 index 0000000..932edc0 --- /dev/null +++ b/src/leapflow/gateway/trigger_policy.py @@ -0,0 +1,120 @@ +"""Trigger policy for inbound IM messages. + +Controls which inbound messages activate the agent's Decide stage. +Analogous to Attention Layer 0 foreground gating in desktop perception. +""" +from __future__ import annotations + +import time +from collections import defaultdict +from dataclasses import dataclass, field +from enum import Enum +from typing import Any + +from leapflow.gateway.protocol import InboundMessage + + +class TriggerMode(str, Enum): + """Trigger activation mode for inbound messages.""" + + MENTION_ONLY = "mention_only" + ALL = "all" + KEYWORD = "keyword" + MANUAL = "manual" + + +@dataclass(frozen=True) +class TriggerPolicy: + """Immutable policy that decides whether an inbound message + should activate agent processing. + """ + + mode: TriggerMode = TriggerMode.MENTION_ONLY + allowed_chats: frozenset[str] = frozenset() + blocked_chats: frozenset[str] = frozenset() + allowed_users: frozenset[str] = frozenset() + blocked_users: frozenset[str] = frozenset() + keywords: tuple[str, ...] = () + max_events_per_minute: int = 30 + cooldown_per_chat_s: float = 1.0 + + def should_activate( + self, + message: InboundMessage, + *, + rate_tracker: _RateTracker | None = None, + ) -> bool: + """Evaluate whether a message should trigger agent processing.""" + source = message.source + + if source.chat_id in self.blocked_chats: + return False + if source.user_id in self.blocked_users: + return False + if self.allowed_chats and source.chat_id not in self.allowed_chats: + return False + if self.allowed_users and source.user_id not in self.allowed_users: + return False + + if rate_tracker is not None and not rate_tracker.allow( + source.chat_id, + max_per_minute=self.max_events_per_minute, + cooldown_s=self.cooldown_per_chat_s, + ): + return False + + if self.mode is TriggerMode.ALL: + return True + if self.mode is TriggerMode.MANUAL: + return False + if self.mode is TriggerMode.KEYWORD: + text_lower = message.text.lower() + return any(kw.lower() in text_lower for kw in self.keywords) + + return self._is_mention_or_dm(message) + + def _is_mention_or_dm(self, message: InboundMessage) -> bool: + """Check if the message is a DM or contains a bot mention. + + Mention detection is the normalizer's responsibility — the + normalizer sets ``metadata["bot_mentioned"]`` during event + classification. TriggerPolicy only reads that flag. + """ + if message.source.chat_type in ("dm", "p2p", "private"): + return True + + metadata = message.metadata or {} + return bool(metadata.get("bot_mentioned")) + + +class _RateTracker: + """Sliding-window rate limiter per chat (not persisted).""" + + def __init__(self) -> None: + self._windows: dict[str, list[float]] = defaultdict(list) + self._last_pass: dict[str, float] = {} + + def allow( + self, + chat_id: str, + *, + max_per_minute: int, + cooldown_s: float, + ) -> bool: + now = time.monotonic() + + if cooldown_s > 0: + last = self._last_pass.get(chat_id, 0.0) + if now - last < cooldown_s: + return False + + if max_per_minute > 0: + window = self._windows[chat_id] + cutoff = now - 60.0 + window[:] = [t for t in window if t > cutoff] + if len(window) >= max_per_minute: + return False + window.append(now) + + self._last_pass[chat_id] = now + return True diff --git a/src/leapflow/prompts/templates.py b/src/leapflow/prompts/templates.py index 75a336b..bc2681b 100644 --- a/src/leapflow/prompts/templates.py +++ b/src/leapflow/prompts/templates.py @@ -73,25 +73,38 @@ def build_react_system(language: str = "en", skill_catalog: str = "") -> str: You are LeapFlow, an intelligent assistant that can both converse naturally and take real actions on the user's computer. ## Capabilities -You have access to these tools: +The tool index below lists **every** registered tool by name and a one-line summary — this is the complete +capability contract; nothing else exists. Only a subset is directly callable this turn (via native tool calling, +not a JSON block in your reply). If you need a tool from the index that is not yet callable, call +`capability_expand` with its category name first — the matching tools become callable immediately after. {tool_catalog} {app_connector_section}{skill_section} ## Tool Usage -Call tools using JSON code blocks: -```json -{{"name": "tool_name", "arguments": {{"key": "value"}}}} -``` -Only call a tool whose exact name appears in the tool list above. Never invent, rename, alias, or guess a -tool name, platform ID, or platform action from argument shape or wording. If no exact tool matches, say -so or ask for clarification instead of guessing. `platform_connect.action` (list/guide/preflight/connect/ -disconnect/remove/status/events_start/events_stop/events_status) is the App Connector management -namespace; `platform_action.action` only accepts exact registered business actions such as -`im.send_message` shown in the App Connector Capability Index — never mix the two namespaces. If a tool -call returns an unknown/unavailable result, use the returned suggestions or available names for a single -retry instead of trying further variations of the same guess. For `platform_action` calls with -`effect=send` or `effect=write` (e.g. `im.send_message`, `docs.create_markdown`), include -**exactly one** call per assistant turn — do not repeat the same action+payload as a confirmation -pass or pre-flight attempt; the system enforces this and will discard duplicates. +Tools are normally invoked through the native function-calling mechanism, not by writing JSON in your reply +text. Only if the provider signals that native function calling is unavailable for this turn, fall back to a +single JSON code block: `{{"name": "tool_name", "arguments": {{"key": "value"}}}}` — use this fallback format +only, never both. Only call a tool whose exact name appears in the tool index above and is currently callable +(or reachable via `capability_expand`). Never invent, rename, alias, or guess a tool name, platform ID, or +platform action from argument shape or wording — if the index does not list it, it does not exist. +`platform_connect.action` (list/guide/preflight/connect/disconnect/remove/status/events_start/events_stop/ +events_status) is the App Connector management namespace; `platform_action.action` only accepts exact +registered business actions such as `im.send_message` shown in the App Connector Capability Index — never mix +the two namespaces. If a tool call returns an unknown/unavailable result, use the returned suggestions or +available names for a single retry instead of trying further variations of the same guess. + +**Side-effect action rule** (`platform_action` with effect=send/write/execute): +- Call each unique action+payload **exactly once**. Never include duplicates in the same turn. +- Once the result returns `"completed": true`, that action is DONE for this task. Do NOT call it again + in any subsequent turn — immediately summarize the result for the user instead. +- The system enforces idempotency: duplicate calls are blocked and will not execute. +- If the user explicitly requests sending/writing multiple times, use distinct payloads per call. + +**Resource identifier provenance rule**: +- NEVER fabricate, guess, or infer resource identifiers (chat_id, message_id, file_key, user_id, etc.). + Every resource ID used in a side-effect action MUST come from a successful API response in this session. +- If a read/list action fails (e.g. authorization error), you do NOT have valid resource IDs. + Report the failure to the user — do NOT attempt the dependent write/send action with a guessed ID. +- When a tool result contains `"llm_instruction"`, follow it exactly. ## Guidelines 1. **Direct answers first**: If you already know the answer, respond directly without tools. diff --git a/src/leapflow/recording/recorder.py b/src/leapflow/recording/recorder.py index 3407891..b92fab1 100644 --- a/src/leapflow/recording/recorder.py +++ b/src/leapflow/recording/recorder.py @@ -374,6 +374,17 @@ def _event_to_action(self, event: SystemEvent) -> RawAction: if window_title: params["window_title"] = str(window_title) + elif event.event_type == "chat.interaction": + target = str(event.payload.get("tool_name", "")) + target_label = str(event.payload.get("content", ""))[:200] + target_role = sub_action + app_bundle_id = "leapflow.engine" + app_name = "LeapFlow" + params = { + k: v for k, v in event.payload.items() + if k not in ("action",) and isinstance(v, (str, int, float, bool, list)) + } + elif event.event_type == "ui.action": target = str(event.payload.get("node_id", "")) target_label = str(event.payload.get("label", "")) diff --git a/src/leapflow/security/actions.py b/src/leapflow/security/actions.py index 8b4eafb..c724c9b 100644 --- a/src/leapflow/security/actions.py +++ b/src/leapflow/security/actions.py @@ -14,6 +14,7 @@ class ActionKind(str, Enum): """High-level action families that may require approval.""" SHELL_COMMAND = "shell.command" + FILE_READ = "file.read" FILE_WRITE = "file.write" FILE_DELETE = "file.delete" GATEWAY_SEND = "gateway.send" @@ -88,6 +89,25 @@ def shell( metadata=merged, ) + @classmethod + def file_read( + cls, + path: str, + *, + mode: str = "raw", + metadata: dict[str, Any] | None = None, + ) -> "ActionDescriptor": + merged = dict(metadata or {}) + merged.update({"mode": mode}) + return cls( + kind=ActionKind.FILE_READ.value, + summary=f"Read file: {path}", + detail=f"Read local file content from {path}", + effect=ActionEffect.READ.value, + resource=path, + metadata=merged, + ) + @classmethod def file_write( cls, diff --git a/src/leapflow/security/path_sensitivity.py b/src/leapflow/security/path_sensitivity.py new file mode 100644 index 0000000..b848f40 --- /dev/null +++ b/src/leapflow/security/path_sensitivity.py @@ -0,0 +1,177 @@ +"""Path sensitivity classification for local file access governance. + +The classifier is intentionally policy-oriented and tool-agnostic: it maps a +path to a stable sensitivity category that file tools and risk assessment can +use before performing reads or writes. It does not execute file operations and +it does not know about UI or approval rendering. +""" +from __future__ import annotations + +import os +from dataclasses import dataclass +from pathlib import Path + + +@dataclass(frozen=True) +class PathSensitivity: + """Structured sensitivity decision for a local path.""" + + category: str = "normal" + level: str = "low" + hardline: bool = False + readable: bool = True + writable: bool = True + requires_approval: bool = False + redact_on_read: bool = False + reason: str = "ordinary_file" + + @property + def is_sensitive(self) -> bool: + return self.category != "normal" or self.requires_approval or self.hardline + + +_SYSTEM_WRITE_PREFIXES = ( + "/System", "/usr", "/bin", "/sbin", "/etc", + "/var/root", "/Library/System", +) + +_CREDENTIAL_NAMES = frozenset({ + ".env", ".env.local", ".env.production", ".env.staging", + "credentials.json", "service_account.json", "token.json", + "secrets.yaml", "secrets.yml", ".netrc", ".npmrc", ".pypirc", + "gateway.yaml", ".credential_key", +}) + +_CREDENTIAL_PATTERNS = ( + ".ssh/id_rsa", ".ssh/id_ed25519", ".ssh/id_ecdsa", ".ssh/id_dsa", + ".ssh/authorized_keys", ".ssh/known_hosts", + ".aws/credentials", ".aws/config", ".kube/config", + ".gnupg/", ".gpg", +) + +_STORAGE_SUFFIXES = frozenset({".duckdb", ".sqlite", ".db"}) +_RUNTIME_CONTROL_NAMES = frozenset({ + "leapd.sock", "leapd.pid", "leapd.lock", +}) +_BINARY_EXTENSIONS = frozenset({ + ".exe", ".dll", ".so", ".dylib", ".bin", ".dat", + ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".ico", ".webp", + ".mp3", ".mp4", ".avi", ".mov", ".mkv", ".wav", ".flac", + ".zip", ".tar", ".gz", ".bz2", ".xz", ".7z", ".rar", + ".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx", + ".pyc", ".pyo", ".class", ".o", ".obj", ".wasm", +}) + + +def classify_path_sensitivity(path: Path) -> PathSensitivity: + """Classify a path before file read/write execution.""" + expanded = path.expanduser() + normalized = str(expanded).replace("\\", "/") + lowered = normalized.lower() + name = expanded.name.lower() + + if lowered.startswith("/dev/") or lowered.startswith("/proc/"): + return PathSensitivity( + category="device_path", + level="critical", + hardline=True, + readable=False, + writable=False, + reason="device_path_access", + ) + + if any(normalized.startswith(prefix) for prefix in _SYSTEM_WRITE_PREFIXES): + return PathSensitivity( + category="system_path", + level="critical", + hardline=True, + readable=True, + writable=False, + reason="system_path_write", + ) + + if name in _RUNTIME_CONTROL_NAMES or name.endswith(".sock") or name.endswith(".lock"): + return PathSensitivity( + category="runtime_control", + level="critical", + hardline=True, + readable=False, + writable=False, + reason="runtime_control_file", + ) + + if name.endswith(".duckdb.wal") or expanded.suffix.lower() in _STORAGE_SUFFIXES: + return PathSensitivity( + category="runtime_database", + level="critical", + hardline=True, + readable=False, + writable=False, + reason="runtime_database_file", + ) + + if name in _CREDENTIAL_NAMES or any(pattern in normalized for pattern in _CREDENTIAL_PATTERNS): + return PathSensitivity( + category="credential", + level="high", + hardline=False, + readable=True, + writable=True, + requires_approval=True, + redact_on_read=True, + reason="credential_or_secret_file", + ) + + data_dir = os.getenv("LEAPFLOW_DATA_DIR", "~/.leapflow") + leapflow_dir = str(Path(data_dir).expanduser()).replace("\\", "/") + if normalized.startswith(leapflow_dir): + if "/approval/" in lowered or name == "audit.jsonl": + return PathSensitivity( + category="audit_log", + level="high", + readable=True, + writable=False, + requires_approval=True, + redact_on_read=True, + reason="approval_or_runtime_audit_log", + ) + if "/memory/" in lowered: + return PathSensitivity( + category="memory_store", + level="high", + readable=True, + writable=True, + requires_approval=True, + redact_on_read=True, + reason="user_memory_store", + ) + if "/run/" in lowered: + return PathSensitivity( + category="runtime_state", + level="medium", + readable=True, + writable=False, + requires_approval=True, + redact_on_read=True, + reason="runtime_state_file", + ) + return PathSensitivity( + category="leapflow_profile_data", + level="medium", + readable=True, + writable=True, + requires_approval=True, + redact_on_read=True, + reason="leapflow_profile_data", + ) + + if expanded.suffix.lower() in _BINARY_EXTENSIONS: + return PathSensitivity( + category="binary_file", + level="medium", + readable=False, + writable=True, + reason="binary_file_content", + ) + + return PathSensitivity() diff --git a/src/leapflow/security/permission_failures.py b/src/leapflow/security/permission_failures.py new file mode 100644 index 0000000..62d3d23 --- /dev/null +++ b/src/leapflow/security/permission_failures.py @@ -0,0 +1,35 @@ +"""Shared permission-failure predicates for agent and TUI recovery flows.""" +from __future__ import annotations + +from typing import Any, Mapping + +PERMISSION_FAILURE_CLASSES = frozenset({"authorization", "scope_denied"}) +PERMISSION_FAILURE_CODES = frozenset({"access_denied", "missing_scope", "platform_degraded"}) + + +def is_permission_failure_payload(payload: Mapping[str, Any] | None) -> bool: + """Return whether a tool-result payload represents an unresolved permission failure.""" + if not payload or payload.get("ok", True) is not False: + return False + failure_class = str(payload.get("failure_class") or "") + failure_code = str(payload.get("failure_code") or "") + return failure_class in PERMISSION_FAILURE_CLASSES or failure_code in PERMISSION_FAILURE_CODES + + +def is_permission_hard_stop_payload(payload: Mapping[str, Any] | None) -> bool: + """Return whether a failed tool result must stop the current agent turn. + + Permission blockers are external platform boundary conditions, not normal + retryable tool errors. The agent should surface deterministic recovery + guidance immediately instead of giving the LLM another chance to retry, + paraphrase, or invent permission scopes. + """ + if not payload or payload.get("ok", True) is not False: + return False + if is_permission_failure_payload(payload): + return True + if bool(payload.get("blocks_approval")): + return True + recoverability = str(payload.get("recoverability") or "") + retryable = bool(payload.get("retryable", True)) + return recoverability == "admin_required" and not retryable diff --git a/src/leapflow/security/redact.py b/src/leapflow/security/redact.py index b7ec067..43a1081 100644 --- a/src/leapflow/security/redact.py +++ b/src/leapflow/security/redact.py @@ -88,7 +88,8 @@ def _mask_token(token: str, *, preserve_prefix: int = 6, preserve_suffix: int = """Mask a secret token, preserving prefix/suffix for identification.""" if len(token) < 18: return "***" - return f"{token[:preserve_prefix]}...{token[-preserve_suffix:]}" + suffix = token[-preserve_suffix:] if preserve_suffix > 0 else "" + return f"{token[:preserve_prefix]}...{suffix}" def redact_sensitive_text( diff --git a/src/leapflow/security/risk.py b/src/leapflow/security/risk.py index 24bdeea..af26510 100644 --- a/src/leapflow/security/risk.py +++ b/src/leapflow/security/risk.py @@ -98,6 +98,8 @@ class DefaultRiskClassifier: def assess(self, action: ActionDescriptor) -> RiskAssessment: if action.kind == ActionKind.SHELL_COMMAND.value: return self._assess_shell(action) + if action.kind == ActionKind.FILE_READ.value: + return self._assess_file_read(action) if action.kind == ActionKind.FILE_WRITE.value: return self._assess_file_write(action) if action.kind == ActionKind.GATEWAY_SEND.value: @@ -206,6 +208,38 @@ def _assess_shell(self, action: ActionDescriptor) -> RiskAssessment: ) return RiskAssessment(level=RiskLevel.LOW, score=0.15, reasons=("ordinary_shell_command",)) + def _assess_file_read(self, action: ActionDescriptor) -> RiskAssessment: + path = Path(action.resource).expanduser() + name = path.name.lower() + normalized = str(path).replace("\\", "/").lower() + meta = action.metadata or {} + category = str(meta.get("sensitivity_category") or "") + if category == "credential": + return RiskAssessment( + level=RiskLevel.HIGH, + score=0.82, + reasons=("credential_file_read",), + explanation="This reads credentials, tokens, or security-sensitive configuration. Content will be redacted.", + allow_permanent=False, + ) + if category in ("audit_log", "memory_store", "leapflow_profile_data", "runtime_state"): + return RiskAssessment( + level=RiskLevel.HIGH, + score=0.75, + reasons=(f"{category}_read",), + explanation="This reads LeapFlow internal data. Content will be redacted.", + allow_permanent=False, + ) + if name in self._SENSITIVE_NAMES or any(part in normalized for part in self._SENSITIVE_PARTS): + return RiskAssessment( + level=RiskLevel.HIGH, + score=0.78, + reasons=("sensitive_file_read",), + explanation="This reads credentials, configuration, or security-sensitive files.", + allow_permanent=False, + ) + return RiskAssessment(level=RiskLevel.LOW, score=0.1, reasons=("ordinary_file_read",)) + def _assess_file_write(self, action: ActionDescriptor) -> RiskAssessment: path = Path(action.resource).expanduser() name = path.name.lower() diff --git a/src/leapflow/tools/file_operations.py b/src/leapflow/tools/file_operations.py index 750548b..efc326b 100644 --- a/src/leapflow/tools/file_operations.py +++ b/src/leapflow/tools/file_operations.py @@ -13,47 +13,11 @@ import logging from pathlib import Path -from typing import Any, Dict, FrozenSet, Iterable +from typing import Any, Dict, Iterable -logger = logging.getLogger(__name__) - -_BLOCKED_WRITE_PREFIXES = ( - "/System", "/usr", "/bin", "/sbin", "/etc", - "/var/root", "/Library/System", -) +from leapflow.security.path_sensitivity import PathSensitivity, classify_path_sensitivity -# Sensitive paths that should NEVER be read and returned to an LLM -_SENSITIVE_READ_PATTERNS: FrozenSet[str] = frozenset({ - ".ssh/id_rsa", ".ssh/id_ed25519", ".ssh/id_ecdsa", ".ssh/id_dsa", - ".ssh/authorized_keys", ".ssh/known_hosts", - ".gnupg/", ".gpg", - ".aws/credentials", ".aws/config", - ".env", ".env.local", ".env.production", - "credentials.json", "service_account.json", - ".netrc", ".npmrc", ".pypirc", - "token.json", "secrets.yaml", "secrets.yml", - ".kube/config", - "id_rsa", "id_ed25519", - "gateway.yaml", ".credential_key", -}) - -_SENSITIVE_EXACT_NAMES: FrozenSet[str] = frozenset({ - ".env", ".env.local", ".env.production", ".env.staging", - "credentials.json", "service_account.json", "token.json", - "secrets.yaml", "secrets.yml", ".netrc", ".npmrc", ".pypirc", - "gateway.yaml", ".credential_key", -}) - -# Binary extensions that should not be read as text -_BINARY_EXTENSIONS: FrozenSet[str] = frozenset({ - ".exe", ".dll", ".so", ".dylib", ".bin", ".dat", - ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".ico", ".webp", - ".mp3", ".mp4", ".avi", ".mov", ".mkv", ".wav", ".flac", - ".zip", ".tar", ".gz", ".bz2", ".xz", ".7z", ".rar", - ".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx", - ".pyc", ".pyo", ".class", ".o", ".obj", - ".wasm", ".sqlite", ".db", ".duckdb", -}) +logger = logging.getLogger(__name__) _MAX_READ_CHARS = 100_000 _FILE_LIST_LIMIT = 100 @@ -119,27 +83,37 @@ def _read_text_window(path: Path, *, max_chars: int) -> tuple[str, bool]: return raw[:max_chars], True -def _is_write_blocked(path: Path) -> bool: - resolved = str(path) - return any(resolved.startswith(prefix) for prefix in _BLOCKED_WRITE_PREFIXES) +def _read_block_message(path: Path, sensitivity: PathSensitivity) -> str: + if sensitivity.category == "binary_file": + return f"Binary file cannot be read as text: {path.name}" + if sensitivity.category == "runtime_database": + return f"Runtime database files cannot be read as text: {path.name}" + if sensitivity.category == "runtime_control": + return f"Runtime control files cannot be read through file_read: {path.name}" + if sensitivity.category == "device_path": + return f"Device path reads are blocked: {path}" + return f"File read blocked by safety policy: {path}" -def _is_sensitive_read(path: Path) -> bool: - """Check if reading the file would expose credentials or secrets.""" - resolved = str(path) - name = path.name - if name in _SENSITIVE_EXACT_NAMES: - return True - return any(pattern in resolved for pattern in _SENSITIVE_READ_PATTERNS) +def _write_block_message(path: Path, sensitivity: PathSensitivity) -> str: + if sensitivity.category == "system_path": + return f"Write blocked by safety policy: {path}" + if sensitivity.category == "runtime_database": + return f"Runtime database files cannot be written through file_write: {path.name}" + if sensitivity.category == "runtime_control": + return f"Runtime control files cannot be written through file_write: {path.name}" + if sensitivity.category == "audit_log": + return f"Audit logs cannot be modified through file_write: {path.name}" + return f"File write blocked by safety policy: {path}" -def _is_binary(path: Path) -> bool: - return path.suffix.lower() in _BINARY_EXTENSIONS - - -def _is_device_path(path: Path) -> bool: - resolved = str(path) - return resolved.startswith("/dev/") or resolved.startswith("/proc/") +def _sensitivity_metadata(sensitivity: PathSensitivity) -> dict[str, Any]: + return { + "sensitivity_category": sensitivity.category, + "sensitivity_level": sensitivity.level, + "sensitivity_reason": sensitivity.reason, + "redact_on_read": sensitivity.redact_on_read, + } def _is_unsupported_leapflow_config_probe(path: Path) -> bool: @@ -190,9 +164,10 @@ async def file_read(params: Dict[str, Any]) -> Dict[str, Any]: return {"ok": False, "error": "Missing required parameter: path"} target = Path(path).expanduser().resolve() + sensitivity = classify_path_sensitivity(target) - if _is_device_path(target): - return {"ok": False, "error": f"Device path reads are blocked: {path}"} + if not sensitivity.readable: + return {"ok": False, "error": _read_block_message(target, sensitivity)} if not target.exists(): if _is_unsupported_leapflow_config_probe(target): @@ -211,11 +186,31 @@ async def file_read(params: Dict[str, Any]) -> Dict[str, Any]: if not target.is_file(): return {"ok": False, "error": f"Not a file: {path}"} - if _is_sensitive_read(target): - return {"ok": False, "error": f"Sensitive file blocked by security policy: {target.name}"} - - if _is_binary(target): - return {"ok": False, "error": f"Binary file cannot be read as text: {target.name}"} + if sensitivity.requires_approval: + try: + from leapflow.tools.registry_bootstrap import get_file_read_gate + gate = get_file_read_gate() + if gate is None: + return { + "ok": False, + "error": f"Sensitive file read requires approval: {target.name}", + "requires_approval": True, + **_sensitivity_metadata(sensitivity), + } + try: + approved = await gate.check(str(target), mode, _sensitivity_metadata(sensitivity)) + except TypeError: + approved = await gate.check(str(target), mode) + if not approved: + message = str(getattr(gate, "denial_message", "") or f"File read denied by approval gate: {target.name}") + return {"ok": False, "error": message} + except ImportError: + return { + "ok": False, + "error": f"Sensitive file read requires approval: {target.name}", + "requires_approval": True, + **_sensitivity_metadata(sensitivity), + } try: raw, raw_truncated = _read_text_window(target, max_chars=max_chars) @@ -245,7 +240,7 @@ async def file_read(params: Dict[str, Any]) -> Dict[str, Any]: try: from leapflow.security.redact import redact_sensitive_text - content = redact_sensitive_text(content) + content = redact_sensitive_text(content, file_read=sensitivity.redact_on_read) except ImportError: pass @@ -268,6 +263,7 @@ async def file_read(params: Dict[str, Any]) -> Dict[str, Any]: "selected_lines": len(content.splitlines()) if content else 0, "mode": mode, "truncated": raw_truncated or line_truncated, + **_sensitivity_metadata(sensitivity), } except Exception as e: return {"ok": False, "error": str(e)} @@ -283,23 +279,37 @@ async def file_write(params: Dict[str, Any]) -> Dict[str, Any]: return {"ok": False, "error": "Missing required parameter: path"} target = Path(path).expanduser().resolve() + sensitivity = classify_path_sensitivity(target) - if _is_write_blocked(target): - return {"ok": False, "error": f"Write blocked by safety policy: {target}"} - - if _is_sensitive_read(target): - return {"ok": False, "error": f"Sensitive file write blocked by security policy: {target.name}"} + if sensitivity.hardline or not sensitivity.writable: + return {"ok": False, "error": _write_block_message(target, sensitivity)} try: from leapflow.tools.registry_bootstrap import get_file_write_gate gate = get_file_write_gate() if gate is not None: - approved = await gate.check(str(target), content, mode) + try: + approved = await gate.check(str(target), content, mode, _sensitivity_metadata(sensitivity)) + except TypeError: + approved = await gate.check(str(target), content, mode) if not approved: message = str(getattr(gate, "denial_message", "") or f"File write denied by approval gate: {target.name}") return {"ok": False, "error": message} + elif sensitivity.requires_approval: + return { + "ok": False, + "error": f"Sensitive file write requires approval: {target.name}", + "requires_approval": True, + **_sensitivity_metadata(sensitivity), + } except ImportError: - pass + if sensitivity.requires_approval: + return { + "ok": False, + "error": f"Sensitive file write requires approval: {target.name}", + "requires_approval": True, + **_sensitivity_metadata(sensitivity), + } try: from leapflow.security.threat_patterns import scan_for_threats, ThreatScope @@ -318,6 +328,11 @@ async def file_write(params: Dict[str, Any]) -> Dict[str, Any]: f.write(content) else: target.write_text(content) - return {"ok": True, "path": str(target), "bytes_written": len(content.encode())} + return { + "ok": True, + "path": str(target), + "bytes_written": len(content.encode()), + **_sensitivity_metadata(sensitivity), + } except Exception as e: return {"ok": False, "error": str(e)} diff --git a/src/leapflow/tools/gateway_tool.py b/src/leapflow/tools/gateway_tool.py index 9f0ba35..2558db8 100644 --- a/src/leapflow/tools/gateway_tool.py +++ b/src/leapflow/tools/gateway_tool.py @@ -10,6 +10,8 @@ """ from __future__ import annotations +import hashlib +import json as _json import logging from typing import Any, Dict, List, Mapping @@ -19,6 +21,25 @@ _approval_gate: Any = None _pending_app_onboarding: Dict[str, Any] | None = None +# Task-scoped side-effect dedup: tracks completed (platform, action, payload) +# fingerprints within the current user turn. Reset at each engine.run() via +# reset_platform_action_scope(). Prevents cross-turn duplicate execution of +# send/write/execute actions regardless of LLM behavior. +_task_completed_actions: Dict[str, Dict[str, Any]] = {} + +_SIDE_EFFECT_KINDS = frozenset({"send", "write", "execute"}) + + +def reset_platform_action_scope() -> None: + """Reset the task-scoped action dedup state. Called at each turn boundary.""" + _task_completed_actions.clear() + + +def _action_fingerprint(platform: str, action: str, payload: Mapping[str, Any]) -> str: + """Stable fingerprint for dedup: hash of (platform, action, sorted payload).""" + raw = f"{platform}:{action}:{_json.dumps(payload, sort_keys=True, ensure_ascii=False)}" + return hashlib.sha256(raw.encode()).hexdigest()[:24] + # Single source of truth for the platform_connect management namespace. Both # the platform_connect tool schema enum and the platform_action namespace # guard below are generated from this tuple so the two never drift apart. @@ -70,6 +91,7 @@ def build_app_connector_prompt_section() -> str: "For requests about connecting, setting up, configuring, enabling, or managing a supported app, use `platform_connect` first instead of generating SDK/Webhook sample code.", f"`platform_connect.action` is the management namespace: {', '.join(PLATFORM_CONNECT_ACTIONS)}.", "`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.", + "All business fields MUST go inside `payload`; top-level keys are only `platform`, `action`, and `payload`.", "Do not invent platform IDs or platform action names. If the needed action is not listed, ask for discovery/clarification instead of guessing.", "Use `platform_connect` with `action='guide'` and the matching `platform` to start onboarding; use `action='list'` when the app is unclear.", "When a pending onboarding state is present, continue from that state with `platform_connect` instead of asking the user to restate the app.", @@ -79,12 +101,19 @@ def build_app_connector_prompt_section() -> str: backend = dict(getattr(manifest, "backend", {}) or {}) backend_kind = str(backend.get("kind") or "adapter") action_summaries = _platform_action_summaries(platform_id) - action_names = ", ".join(f"`{item['name']}`" for item in action_summaries[:12]) - action_text = f"; platform_action actions={action_names}" if action_names else "; platform_action actions=none registered" - lines.append( - f"- `{platform_id}`: {getattr(manifest, 'display_name', platform_id)} " - f"(category={getattr(manifest, 'category', '')}; backend={backend_kind}{action_text})" - ) + if action_summaries: + lines.append( + f"- `{platform_id}`: {getattr(manifest, 'display_name', platform_id)} " + f"(category={getattr(manifest, 'category', '')}; backend={backend_kind})" + ) + for item in action_summaries: + payload_sig = _format_payload_signature(item) + lines.append(f" - `{item['name']}` {payload_sig}") + else: + lines.append( + f"- `{platform_id}`: {getattr(manifest, 'display_name', platform_id)} " + f"(category={getattr(manifest, 'category', '')}; backend={backend_kind}; platform_action actions=none registered)" + ) if _pending_app_onboarding: state = dict(_pending_app_onboarding) next_actions = state.get("next_actions") or [] @@ -101,6 +130,23 @@ def build_app_connector_prompt_section() -> str: return "\n".join(lines) + "\n" +def _format_payload_signature(summary: Dict[str, Any]) -> str: + """Format a compact payload signature for the capability index. + + Example output: ``payload={chat_id*, text*} [send/high]`` + """ + required_set = frozenset(summary.get("required") or ()) + fields = summary.get("fields") or [] + parts: list[str] = [] + for f in fields: + parts.append(f"{f}*" if f in required_set else f) + payload_str = f"payload={{{', '.join(parts)}}}" if parts else "payload={}" + effect = summary.get("effect") or "" + risk = summary.get("risk_level") or "" + meta = "/".join(filter(None, [effect, risk])) + return f"{payload_str} [{meta}]" if meta else payload_str + + def _manifest_action_specs(platform: str) -> Mapping[str, Any]: """Return action-pack specs declared by a platform manifest.""" manifest = _gateway_server_ref.manifests.get(platform) if _gateway_server_ref is not None else None @@ -338,21 +384,6 @@ async def _action_guide( "actions": dict(manifest.actions), } - required = [c for c in manifest.credentials if c.required] - if required: - labels = " / ".join(c.label for c in required) - result["prompt_hint"] = ( - f"Present the setup steps above, then ask the user to provide " - f"{labels} in a single reply. Do NOT repeat credential values " - f"in your response." - ) - else: - result["prompt_hint"] = ( - "Present the setup steps and current preflight result. If all safe checks are ready, " - "call platform_connect with action='connect'. If a preflight step requires installation, " - "authorization, or credentials, ask only for that missing user action and avoid repeating known details." - ) - return result @@ -392,6 +423,10 @@ async def _action_preflight( if manifest is None: return {"ok": False, "error": f"Unknown platform: {platform}"} preflight = await _run_manifest_preflight(platform, manifest, params) + if preflight.get("ready"): + clear_health = getattr(_gateway_server_ref, "clear_platform_capability_health", None) + if clear_health is not None: + clear_health(platform) state = _remember_app_onboarding(platform, manifest, preflight) return { "ok": bool(preflight.get("ready")), @@ -598,6 +633,11 @@ def _status_entry(s: Any) -> Dict[str, Any]: result = _status_entry(s) result["ok"] = True result["platform"] = result.pop("id") + if result.get("connected"): + clear_health = getattr(_gateway_server_ref, "clear_platform_capability_health", None) + if clear_health is not None: + clear_health(platform) + result["capability_health_refreshed"] = True return result return {"ok": False, "error": f"Platform not found: {platform}"} return {"ok": True, "platforms": [_status_entry(s) for s in statuses]} @@ -710,6 +750,51 @@ def _platform_action_unavailable_response(platform: str, action: str) -> Dict[st return response +def _structured_validation_error( + spec: Any, + validation: Any, + raw_params: Mapping[str, Any], +) -> Dict[str, Any]: + """Build a machine-readable validation error response with recovery metadata.""" + schema = getattr(spec, "schema", {}) or {} + required = schema.get("required") or () + properties = schema.get("properties") or {} + response: Dict[str, Any] = { + "ok": False, + "failure_code": validation.failure_code or "validation_failed", + "error": validation.error, + "action": getattr(spec, "name", ""), + "retryable": True, + "recovery_hint": validation.recovery_hint, + } + if validation.missing_fields: + response["missing_fields"] = list(validation.missing_fields) + response["field_paths"] = [f"payload.{f}" for f in validation.missing_fields] + if validation.type_errors: + response["type_errors"] = list(validation.type_errors) + response["expected_schema"] = { + "required": [str(f) for f in required], + "fields": { + str(k): str((v.get("description") or v.get("type") or "")) + for k, v in properties.items() + if isinstance(v, Mapping) + }, + } + return response + + +def _required_scopes_for_identity(spec: Any) -> List[str]: + auth = getattr(spec, "auth", None) + scopes = dict(getattr(auth, "scopes", {}) or {}) if auth is not None else {} + result: list[str] = [] + for key in ("common", "user", "bot"): + for scope in scopes.get(key, ()): + scope_text = str(scope) + if scope_text and scope_text not in result: + result.append(scope_text) + return result + + # ═══════════════════════════════════════════════════════════════ # gateway_send handler — proactive outbound messaging # ═══════════════════════════════════════════════════════════════ @@ -805,9 +890,6 @@ async def platform_action_handler(params: Dict[str, Any]) -> Dict[str, Any]: return {"ok": False, "error": "Gateway not initialised"} platform = str(params.get("platform") or "") action_name = str(params.get("action") or "") - payload = params.get("payload") or {} - if not isinstance(payload, dict): - return {"ok": False, "error": "payload must be an object"} if not platform: return {"ok": False, "error": "platform is required"} if not action_name: @@ -816,9 +898,75 @@ async def platform_action_handler(params: Dict[str, Any]) -> Dict[str, Any]: spec = _gateway_server_ref.get_platform_action_spec(platform, action_name) if spec is None: return _platform_action_unavailable_response(platform, action_name) + + from leapflow.gateway.connectors.action_registry import normalize_payload, validate_payload + + payload = normalize_payload(spec, params) + validation = validate_payload(spec, payload) + if not validation.ok: + return _structured_validation_error(spec, validation, params) + + # Task-scoped side-effect dedup: block re-execution of identical + # send/write/execute actions within the same user turn. + fp = _action_fingerprint(platform, action_name, payload) + if spec.effect in _SIDE_EFFECT_KINDS and fp in _task_completed_actions: + logger.info( + "side_effect_dedup: blocked duplicate %s.%s fp=%s", + platform, action_name, fp, + ) + original = _task_completed_actions[fp] + return { + "ok": True, + "already_executed": True, + "execution_note": ( + f"This exact action ({platform}.{action_name}) was already executed " + "successfully in this task. Do not re-invoke. Report the original " + "result to the user." + ), + "original_result": original, + "retryable": False, + } + + feasibility = _gateway_server_ref.check_platform_action_feasibility( + platform, action_name, payload + ) + if not feasibility.get("ok"): + return feasibility + + # Resource provenance check: for side-effect actions, verify that + # referenced resource IDs have been observed from a previous successful + # API call. Blocks hallucinated IDs before they reach approval. + provenance_warnings: List[str] = [] + if spec.effect in _SIDE_EFFECT_KINDS and spec.auth.resource_fields: + from leapflow.gateway.resource_provenance import ProvenanceStatus + + provenance_pool = _gateway_server_ref.resource_provenance + results = provenance_pool.check_payload( + platform, spec.auth.resource_fields, payload, + ) + for pr in results: + if pr.status == ProvenanceStatus.UNVERIFIED: + provenance_warnings.append( + f"⚠ {pr.field_name}='{pr.value}' was NOT found in any " + f"previous API response ({pr.known_values_count} known values). " + "This may be a hallucinated identifier." + ) + logger.warning( + "resource_provenance.unverified platform=%s field=%s value=%s known=%d", + platform, pr.field_name, pr.value, pr.known_values_count, + ) + preview = await _gateway_server_ref.preview_platform_action(platform, action_name, payload) if not preview.get("ok"): - return {"ok": False, "error": str(preview.get("error") or "Platform action preview failed")} + failure_info = {k: v for k, v in preview.items() if k not in ("ok",)} + base_err = str(preview.get("error") or "Platform action preview failed") + if preview.get("blocks_approval"): + feasibility_check = _gateway_server_ref.check_platform_action_feasibility( + platform, action_name, payload + ) + if not feasibility_check.get("ok"): + return feasibility_check + return {"ok": False, "error": base_err, **{k: v for k, v in failure_info.items() if k != "error"}} if _approval_gate is not None: try: @@ -834,9 +982,23 @@ async def platform_action_handler(params: Dict[str, Any]) -> Dict[str, Any]: "effect": spec.effect, "risk_level": spec.risk_level, "output_policy": spec.output_policy, + "capability": spec.capability or spec.name, + "required_scopes": _required_scopes_for_identity(spec), "preview": preview.get("summary", ""), + **({"provenance_warnings": provenance_warnings} if provenance_warnings else {}), }, ) + # Build enriched approval detail with provenance context. + approval_detail = str(preview.get("summary") or approval_action.summary) + if provenance_warnings: + approval_detail = ( + approval_detail + "\n\n" + + "\n".join(provenance_warnings) + ) + risk_hint = 0.6 + if provenance_warnings: + risk_hint = 0.9 # Elevate risk when provenance is unverified + if hasattr(_approval_gate, "evaluate"): result = await _approval_gate.evaluate(approval_action) if not getattr(result, "approved", False): @@ -845,13 +1007,16 @@ async def platform_action_handler(params: Dict[str, Any]) -> Dict[str, Any]: else: decision = await _approval_gate.request_approval(ApprovalRequest( category=approval_action.kind, - detail=str(preview.get("summary") or approval_action.summary), - risk_hint=0.6, + detail=approval_detail, + risk_hint=risk_hint, metadata={ "platform": platform, "action": action_name, "backend_kind": spec.backend_kind, "risk_level": spec.risk_level, + "capability": spec.capability or spec.name, + "required_scopes": _required_scopes_for_identity(spec), + **({"provenance_warnings": provenance_warnings} if provenance_warnings else {}), }, action=approval_action, )) @@ -866,7 +1031,16 @@ async def platform_action_handler(params: Dict[str, Any]) -> Dict[str, Any]: logger.debug("platform_action approval check failed", exc_info=True) return {"ok": False, "error": "Platform action approval check failed"} - return await _gateway_server_ref.execute_platform_action(platform, action_name, payload) + result = await _gateway_server_ref.execute_platform_action(platform, action_name, payload) + + # Register successful side-effect actions for dedup. + if result.get("ok") and spec.effect in _SIDE_EFFECT_KINDS: + _task_completed_actions[fp] = { + k: v for k, v in result.items() + if k in ("ok", "resource_id", "data", "action", "platform") + } + + return result # ═══════════════════════════════════════════════════════════════ @@ -881,20 +1055,28 @@ async def platform_action_handler(params: Dict[str, Any]) -> Dict[str, Any]: "description": ( "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/available_action_names and are addressed as domain.operation, " - "e.g. im.send_message or docs.create_markdown. Do not invent action names, do not " - "use management actions such as list/guide/connect/status here, and never pass raw CLI commands or URLs." + "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." ), "parameters": { "type": "object", "properties": { "platform": {"type": "string", "description": "Platform ID, e.g. feishu"}, - "action": {"type": "string", "description": "Exact registered business action from available_action_names, e.g. im.send_message; never list/guide/connect/status"}, - "payload": {"type": "object", "description": "Action payload matching the registered schema"}, + "action": {"type": "string", "description": "Exact registered business action from the Capability Index, e.g. im.send_message"}, + "payload": {"type": "object", "description": "Action payload — all business fields go here (e.g. chat_id, text, query). See Capability Index for required/optional fields per action."}, "backend_kind": {"type": "string", "description": "Optional backend hint: cli/rest/mcp"}, }, "required": ["platform", "action", "payload"], }, + "x_leapflow": { + "category": "gateway", + "risk_level": "high", + "schema_cost": "high", + "requires_approval": True, + }, }, }, { @@ -918,6 +1100,12 @@ async def platform_action_handler(params: Dict[str, Any]) -> Dict[str, Any]: }, "required": ["action"], }, + "x_leapflow": { + "category": "gateway", + "risk_level": "medium", + "schema_cost": "high", + "requires_approval": True, + }, }, }, { @@ -953,6 +1141,12 @@ async def platform_action_handler(params: Dict[str, Any]) -> Dict[str, Any]: }, "required": ["platform", "chat_id", "text"], }, + "x_leapflow": { + "category": "gateway", + "risk_level": "high", + "schema_cost": "high", + "requires_approval": True, + }, }, }, { @@ -1004,6 +1198,12 @@ async def platform_action_handler(params: Dict[str, Any]) -> Dict[str, Any]: }, "required": ["action"], }, + "x_leapflow": { + "category": "gateway", + "risk_level": "medium", + "schema_cost": "high", + "requires_approval": True, + }, }, }, ] diff --git a/src/leapflow/tools/hub_tool.py b/src/leapflow/tools/hub_tool.py index 6ba9dd6..b8ba1f8 100644 --- a/src/leapflow/tools/hub_tool.py +++ b/src/leapflow/tools/hub_tool.py @@ -311,6 +311,12 @@ async def hub_sync_tool( }, "required": ["skill_name"], }, + "x_leapflow": { + "category": "hub", + "risk_level": "medium", + "schema_cost": "high", + "requires_approval": True, + }, }, }, { @@ -332,6 +338,12 @@ async def hub_sync_tool( }, "required": ["repo_id"], }, + "x_leapflow": { + "category": "hub", + "risk_level": "medium", + "schema_cost": "high", + "requires_approval": True, + }, }, }, { @@ -349,6 +361,12 @@ async def hub_sync_tool( }, "required": ["query"], }, + "x_leapflow": { + "category": "hub", + "risk_level": "read_only", + "schema_cost": "high", + "requires_approval": False, + }, }, }, { @@ -370,6 +388,12 @@ async def hub_sync_tool( }, }, }, + "x_leapflow": { + "category": "hub", + "risk_level": "medium", + "schema_cost": "high", + "requires_approval": True, + }, }, }, ] diff --git a/src/leapflow/tools/name_resolver.py b/src/leapflow/tools/name_resolver.py index b9999b4..4f9d36d 100644 --- a/src/leapflow/tools/name_resolver.py +++ b/src/leapflow/tools/name_resolver.py @@ -2,12 +2,19 @@ This module centralizes the Tool Capability Contract: the set of canonical tool names the runtime can actually dispatch, plus structured unknown-tool -feedback when a proposed name is not part of that contract. There is no -alias table and no argument-shape guessing here — a proposed tool name is -either the exact canonical name (optionally normalized for case/separator -formatting or an internal bridge prefix), or it is reported as unknown with -discovery suggestions. It is intentionally execution-free: callers resolve a -tool name here, then execute through their existing dispatch path. +feedback when a proposed name is not part of that contract. + +Resolution priority: +1. Exact canonical name match +2. Formatting normalization (case, separators) +3. Internal bridge prefix (``gp_``) +4. Static alias table — human-verified semantic equivalences for common + LLM naming drift (e.g. ``read_file`` → ``file_read``). These are + auto-executable because each mapping is a proven 1:1 equivalence. +5. Unknown — suggestions + one-shot retry + +No argument-shape guessing or fuzzy matching is performed. The alias table +is the only "cross-name" resolution path and requires explicit declaration. """ from __future__ import annotations @@ -16,7 +23,7 @@ from dataclasses import dataclass, field from typing import Any, Dict, Literal, Mapping, Sequence -ResolutionStatus = Literal["exact", "normalized", "unknown"] +ResolutionStatus = Literal["exact", "normalized", "aliased", "unknown"] ResolutionConfidence = Literal["high", "medium", "low"] RiskLevel = Literal["read_only", "mutating", "external"] @@ -45,6 +52,44 @@ "delegate", ) +# ───────────────────────────────────────────────────────────────────── +# Static Alias Table — common LLM naming drift → canonical name +# ───────────────────────────────────────────────────────────────────── +# Each entry is a human-verified 1:1 semantic equivalence. These are +# the *only* cross-name resolution paths — they are applied after +# formatting normalization and before "unknown" fallback. +# +# Guidelines for adding entries: +# - Only add when a drift pattern is observed repeatedly in production +# - Each alias must map to exactly one canonical tool +# - The alias must never collide with another canonical name +# - Prefer the form: "drifted_name": "canonical_name" +# +# This table is intentionally small. The primary mechanism for correct +# tool naming is the capability schema disclosed to the LLM. +TOOL_NAME_ALIASES: Dict[str, str] = { + # File operations + "read_file": "file_read", + "write_file": "file_write", + "list_files": "file_list", + "list_directory": "file_list", + "ls": "file_list", + # Shell + "execute_command": "shell_run", + "run_command": "shell_run", + "run_terminal": "shell_run", + "exec": "shell_run", + # Search + "grep": "text_search", + "search": "text_search", + "find_in_files": "text_search", + # Text editing + "edit_file": "text_replace", + "replace_in_file": "text_replace", + "sed": "text_replace", +} + + def tool_lookup_key(tool_name: str) -> str: """Return a stable lookup key for exact tool-name identity matching. @@ -107,14 +152,14 @@ class ToolRegistry: """Single source of truth for tool identity and resolution metadata. The registry enforces a strict Tool Capability Contract: a proposed tool - name is only ever executed when it is the exact canonical name, or a - formatting-only variant of it (case, separators, or the internal ``gp_`` - bridge prefix). There is no alias table and no argument-shape guessing; - anything else is reported as unknown so the caller can retry with an - exact name from the disclosed capability list. + name is resolved only through deterministic paths — exact match, formatting + normalization, bridge prefix, or a static alias table of human-verified + semantic equivalences. No fuzzy matching or argument-shape guessing is + performed for auto-execution. """ specs: Mapping[str, ToolSpec] + aliases: Mapping[str, str] = field(default_factory=dict) @classmethod def from_definitions( @@ -123,8 +168,16 @@ def from_definitions( handlers: Mapping[str, Any], *, bridge_tools: Sequence[Mapping[str, Any]] = (), + aliases: Mapping[str, str] | None = None, ) -> "ToolRegistry": - """Build a registry from OpenAI schemas, dispatch handlers, and bridge metadata.""" + """Build a registry from OpenAI schemas, dispatch handlers, and bridge metadata. + + Parameters + ---------- + aliases : mapping of drifted names → canonical names, optional. + Each entry declares a human-verified 1:1 semantic equivalence. + Keys are normalized via ``tool_lookup_key`` before storage. + """ bridge_mutates = { str(tool.get("name", "")).removeprefix("gp_"): bool(tool.get("mutates_state", False)) for tool in bridge_tools @@ -152,7 +205,14 @@ def from_definitions( name=canonical, risk_level=_infer_risk_level(canonical, bridge_mutates.get(canonical, False)), ) - return cls(specs=specs) + + validated_aliases: dict[str, str] = {} + for alias, target in (aliases or {}).items(): + norm_alias = tool_lookup_key(alias) + if target in specs and norm_alias not in specs: + validated_aliases[norm_alias] = target + + return cls(specs=specs, aliases=validated_aliases) @property def tool_names(self) -> tuple[str, ...]: @@ -165,7 +225,15 @@ def normalize_name(self, tool_name: str, arguments: Mapping[str, Any] | None = N return resolution.normalized_name if resolution.auto_executable and resolution.normalized_name else tool_name def resolve(self, tool_name: str, arguments: Mapping[str, Any] | None = None) -> ToolResolution: - """Resolve a proposed tool call against the canonical tool contract.""" + """Resolve a proposed tool call against the canonical tool contract. + + Resolution priority: + 1. Exact canonical match + 2. Formatting normalization (case/separators) + 3. Internal bridge prefix (``gp_``) + 4. Static alias table + 5. Unknown (with suggestions) + """ original_name = str(tool_name or "") args = dict(arguments or {}) key = tool_lookup_key(original_name) @@ -176,6 +244,10 @@ def resolve(self, tool_name: str, arguments: Mapping[str, Any] | None = None) -> if key.startswith("gp_") and key[3:] in self.specs: return self._resolution(original_name, key[3:], "normalized", "high", "internal bridge prefix") + if key in self.aliases: + canonical = self.aliases[key] + return self._resolution(original_name, canonical, "aliased", "high", f"static alias: {original_name} → {canonical}") + suggestions = self._suggestions(original_name, args) return ToolResolution( original_name=original_name, diff --git a/src/leapflow/tools/registry_bootstrap.py b/src/leapflow/tools/registry_bootstrap.py index 0209eb4..8d2b38c 100644 --- a/src/leapflow/tools/registry_bootstrap.py +++ b/src/leapflow/tools/registry_bootstrap.py @@ -24,7 +24,7 @@ GATEWAY_TOOL_HANDLERS, set_gateway_server as set_gateway_server, ) -from leapflow.tools.name_resolver import ToolRegistry +from leapflow.tools.name_resolver import TOOL_NAME_ALIASES, ToolRegistry # ───────────────────────────────────────────────────────────────────── @@ -135,6 +135,16 @@ }, "required": ["text", "pattern"], }, + # Explicit metadata: pure in-memory regex search over caller-supplied + # text, no I/O or state mutation. Declared explicitly rather than + # relying on the "general" keyword fallback, which is intentionally + # non-core by default (fail-closed) for anything not reviewed. + "x_leapflow": { + "category": "general", + "risk_level": "read_only", + "schema_cost": "low", + "requires_approval": False, + }, }, }, { @@ -214,6 +224,38 @@ }, }, }, + # ── Capability discovery (Tier 1 structural gate) ── + # Lets the model expand a heavier tool category (hub/gateway/delegate) into + # full native schemas on demand, instead of the runtime guessing from text + # which categories are "probably" relevant to the current request. + { + "type": "function", + "function": { + "name": "capability_expand", + "description": ( + "Fetch the full callable schema for every tool in a capability category " + "(e.g. 'hub', 'gateway', 'delegate', 'file', 'memory', 'skill'). The compact " + "tool index always lists every registered tool by name and a one-line summary, " + "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 its " + "category first; the matching tools become callable in this turn. Never invent a " + "tool name — expand the category instead." + ), + "parameters": { + "type": "object", + "properties": { + "category": {"type": "string", "description": "Capability category name, e.g. hub, gateway, delegate"}, + }, + "required": ["category"], + }, + "x_leapflow": { + "category": "system", + "risk_level": "read_only", + "schema_cost": "low", + "requires_approval": False, + }, + }, + }, # ── Subagent delegation ── { "type": "function", @@ -447,22 +489,97 @@ async def _delegate_task_handler(params: Dict[str, Any]) -> Dict[str, Any]: TOOL_HANDLERS["gp_delegate_task"] = _delegate_task_handler +# ──────────────────────────────────────────────────────────────── +# Capability discovery (Tier 1 structural gate): expand a category's tools +# into full native schemas on request, so the caller (engine.py) can merge +# them into the current turn's tools_kwarg instead of leaving the model to +# guess a tool name that was never disclosed. +# ──────────────────────────────────────────────────────────────── + +async def _capability_expand_handler(params: Dict[str, Any]) -> Dict[str, Any]: + from leapflow.engine.context_disclosure import build_capability_manifests + + category = str(params.get("category") or "").strip().lower() + if not category: + return {"ok": False, "error": "category is required"} + manifests = build_capability_manifests(TOOL_DEFINITIONS) + matched_names = {m.name for m in manifests if m.category == category} + if not matched_names: + available = sorted({m.category for m in manifests if m.category}) + return { + "ok": False, + "error": f"Unknown capability category: {category}", + "available_categories": available, + } + expanded_tools = [ + td for td in TOOL_DEFINITIONS + if td.get("function", {}).get("name") in matched_names + ] + return {"ok": True, "category": category, "expanded_tools": expanded_tools} + + +def _patch_capability_expand_categories() -> None: + """Inject the real, current non-core category list into capability_expand's + own description, computed from the live tool registry instead of a static + hardcoded example list that would silently drift out of sync. + """ + from leapflow.engine.context_disclosure import build_capability_manifests + + manifests = build_capability_manifests(TOOL_DEFINITIONS) + non_core_categories = sorted({m.category for m in manifests if m.category and not m.is_core}) + for td in TOOL_DEFINITIONS: + func = td.get("function", {}) + if func.get("name") != "capability_expand": + continue + categories_text = ", ".join(non_core_categories) or "none" + func["description"] = ( + "Fetch the full callable schema for every tool in a capability category. " + f"Current non-core categories that require expansion: {categories_text}. " + "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." + ) + break + + +_patch_capability_expand_categories() + + +TOOL_HANDLERS["capability_expand"] = _capability_expand_handler +TOOL_HANDLERS["gp_capability_expand"] = _capability_expand_handler + + TOOL_REGISTRY = ToolRegistry.from_definitions( TOOL_DEFINITIONS, TOOL_HANDLERS, bridge_tools=_BRIDGE_TOOLS, + aliases=TOOL_NAME_ALIASES, ) # ───────────────────────────────────────────────────────────────────── -# File write approval gate (Protocol-based, injectable) +# File access approval gates (Protocol-based, injectable) # ───────────────────────────────────────────────────────────────────── +_file_read_gate: Any = None _file_write_gate: Any = None +def set_file_read_gate(gate: Any) -> None: + """Install a file-read approval gate.""" + global _file_read_gate + _file_read_gate = gate + + +def get_file_read_gate() -> Any: + return _file_read_gate + + def set_file_write_gate(gate: Any) -> None: - """Install a file-write approval gate (independent from shell approval).""" + """Install a file-write approval gate.""" global _file_write_gate _file_write_gate = gate diff --git a/tests/test_agent_execution.py b/tests/test_agent_execution.py index b1818c7..1324fcb 100644 --- a/tests/test_agent_execution.py +++ b/tests/test_agent_execution.py @@ -160,11 +160,15 @@ async def file_list_handler(args): # Case/separator formatting of the *same* canonical name still resolves. assert _normalize_tool_name("File_List") == "file_list" assert _normalize_tool_name("file-list") == "file_list" - # Common LLM tool-name drift is no longer silently rewritten to a - # different canonical tool: it must be reported as unknown. - assert _normalize_tool_name("list_directory") == "list_directory" - assert _normalize_tool_name("execute_command") == "execute_command" - assert _normalize_tool_name("run_terminal") == "run_terminal" + # Known LLM drift patterns resolve via static alias table. + assert _normalize_tool_name("list_directory") == "file_list" + assert _normalize_tool_name("execute_command") == "shell_run" + assert _normalize_tool_name("run_terminal") == "shell_run" + alias_resolution = _resolve_tool_name("list_directory", {"path": "."}) + assert alias_resolution.normalized_name == "file_list" + assert alias_resolution.status == "aliased" + assert alias_resolution.auto_executable is True + # Names NOT in alias table remain unknown. directory_resolution = _resolve_tool_name("directory_scan", {"path": "."}) risky_resolution = _resolve_tool_name("please_do", {"command": "ls -la"}) assert directory_resolution.normalized_name is None @@ -176,7 +180,6 @@ async def file_list_handler(args): assert metadata["original_tool_name"] == "File-List" assert metadata["normalized_tool_name"] == "file_list" assert metadata["resolved_from"] == "File-List" - assert "alias" not in metadata finally: lt.close() @@ -379,8 +382,8 @@ async def test_app_connector_empty_final_uses_onboarding_recovery_state() -> Non @pytest.mark.asyncio -async def test_unknown_tool_in_stream_triggers_structured_retry_not_alias_guess() -> None: - """Text-mode tool calls with a drifted name must surface a structured unknown, never a silent alias rewrite.""" +async def test_aliased_tool_in_stream_resolves_and_executes() -> None: + """Text-mode tool calls with a known drifted name resolve via alias and execute normally.""" tool_reply = '{"name": "list_directory", "arguments": {"path": "."}}' with tempfile.TemporaryDirectory() as td: settings = make_settings(td) @@ -399,8 +402,36 @@ async def test_unknown_tool_in_stream_triggers_structured_retry_not_alias_guess( events = [event async for event in engine.run_stream("List current directory")] tool_events = [event for event in events if event.type in {"tool_start", "tool_complete"}] - assert [event.content for event in tool_events] == ["list_directory", "list_directory"] assert tool_events[0].metadata["original_tool_name"] == "list_directory" + assert tool_events[0].metadata["tool_resolution_status"] == "aliased" + assert tool_events[0].metadata["normalized_tool_name"] == "file_list" + finally: + lt.close() + + +@pytest.mark.asyncio +async def test_unknown_tool_in_stream_triggers_structured_retry() -> None: + """Text-mode tool calls with a truly unknown name surface a structured unknown with suggestions.""" + tool_reply = '{"name": "directory_scan", "arguments": {"path": "."}}' + with tempfile.TemporaryDirectory() as td: + settings = make_settings(td) + from leapflow.platform.mock import MockBridge + + rpc = MockBridge() + llm = StubLLM([tool_reply, "directory checked"]) + wm = WorkingMemoryProvider(max_tokens=1024) + lt = SemanticMemoryProvider(source=settings.duckdb_path) + imm = EpisodicMemoryProvider() + try: + reg = build_default_registry(rpc, llm, wm, lt) + classifier = _FixedClassifier("complex") + engine = AgentEngine(settings, rpc, llm, wm, lt, imm, reg, classifier) + + events = [event async for event in engine.run_stream("List current directory")] + + tool_events = [event for event in events if event.type in {"tool_start", "tool_complete"}] + assert [event.content for event in tool_events] == ["directory_scan", "directory_scan"] + assert tool_events[0].metadata["original_tool_name"] == "directory_scan" assert tool_events[0].metadata["tool_resolution_status"] == "unknown" assert tool_events[1].metadata["ok"] is False assert tool_events[1].metadata["error_type"] == "unknown_tool" @@ -577,9 +608,15 @@ async def achat_stream(self, messages, *, enable_thinking=False, **kwargs): assert out == "I am LeapFlow." assert llm.call_count == 1 - assert "tools" not in llm.kwargs + # CORE disclosure keeps a static low-risk tool whitelist always callable + # (never an empty/contradictory tool contract), but excludes heavy/mutating tools. + core_names = { + tool.get("function", {}).get("name", "") + for tool in llm.kwargs.get("tools", []) + } + assert "shell_run" not in core_names + assert "hub_push" not in core_names assert llm.enable_thinking is False - assert "file_read" not in str(llm.messages[0].get("content", "")) system_prompt = str(llm.messages[0].get("content", "")) assert "## Presentation Style" in system_prompt assert "Avoid redundant tool calls" in system_prompt @@ -595,8 +632,8 @@ async def achat_stream(self, messages, *, enable_thinking=False, **kwargs): assert "~/.leapflow/.env" in system_prompt assert "./.env" in system_prompt snapshot = engine.context_budget_snapshot - assert snapshot["disclosure_level"] == "light" - assert snapshot["disclosure"]["native_tools"] is False + assert snapshot["disclosure_level"] == "core" + assert snapshot["disclosure"]["native_tools"] is True finally: lt.close() @@ -684,7 +721,92 @@ async def achat_stream(self, messages, *, enable_thinking=False, **kwargs): assert "file_read" in names assert "file_list" in names assert "shell_run" not in names - assert engine.context_budget_snapshot["disclosure_level"] == "selected_tools" + # file_read/file_list are part of the static Tier 0.5 core whitelist, so a + # plain file-oriented turn (no prior-turn tool-category continuity, no + # slash command / escalation signal) stays at the CORE floor level. + assert engine.context_budget_snapshot["disclosure_level"] == "core" + finally: + lt.close() + + +@pytest.mark.asyncio +async def test_progressive_disclosure_expands_write_category_after_prior_turn_tool_use() -> None: + """Tier 1 continuity: a native tool_call executed in turn N structurally + opens its capability category for turn N+1 — a purely structural signal, + never a re-reading of user text. Regression guard for the dedicated + ``AgentEngine._last_turn_tool_categories`` state: working memory only + stores a synthetic "[Called: ...]" summary with no structured tool_calls, + so continuity must not be derived from ``wm.as_chat_messages()``. + """ + from leapflow.llm.base import LLMChatResponse, LLMProvider, ToolCallInfo + from leapflow.platform.mock import MockBridge + + class CaptureLLM(LLMProvider): + def __init__(self) -> None: + self.calls: list[dict] = [] + + async def achat(self, messages, *, stream=True, enable_thinking=False, on_chunk=None, **kwargs): + self.calls.append(kwargs) + if len(self.calls) == 1: + return LLMChatResponse( + content="", + tool_calls=[ + ToolCallInfo( + id="tc1", + name="text_replace", + arguments={"text": "a", "old": "a", "new": "b"}, + ) + ], + ) + return LLMChatResponse(content="Turn done") + + async def achat_stream(self, messages, *, enable_thinking=False, **kwargs): + if False: + yield "" + + with tempfile.TemporaryDirectory() as td: + settings = make_settings(td) + settings = settings.__class__( + **{**settings.__dict__, "native_tool_calling_enabled": True} + ) + rpc = MockBridge() + llm = CaptureLLM() + wm = WorkingMemoryProvider(max_tokens=1024) + lt = SemanticMemoryProvider(source=settings.duckdb_path) + imm = EpisodicMemoryProvider() + try: + reg = build_default_registry(rpc, llm, wm, lt) + classifier = _FixedClassifier("chat") + engine = AgentEngine(settings, rpc, llm, wm, lt, imm, reg, classifier) + + await engine.run("Replace a with b in some text") + first_turn_names = { + t.get("function", {}).get("name") for t in llm.calls[0].get("tools", []) + } + # text_replace is not in the static core whitelist and nothing opened + # its category yet, so the model's own tools schema does not include it + # (the mock LLM here bypasses that constraint only to exercise the + # engine's post-execution bookkeeping, not provider-side enforcement). + assert "text_replace" not in first_turn_names + + await engine.run("hi again") + second_turn_names = { + t.get("function", {}).get("name") for t in llm.calls[-1].get("tools", []) + } + assert "text_replace" in second_turn_names + assert "file_write" in second_turn_names # same "write" category opened + assert "memory_add" in second_turn_names + assert engine.context_budget_snapshot["disclosure_level"] == "expanded" + assert "write" in engine.context_budget_snapshot["disclosure"]["expanded_categories"] + + # A third turn with no tool use must not carry the category forever — + # continuity is exactly one turn, not a sticky escalation. + await engine.run("just chatting, no tools needed") + third_turn_names = { + t.get("function", {}).get("name") for t in llm.calls[-1].get("tools", []) + } + assert "text_replace" not in third_turn_names + assert engine.context_budget_snapshot["disclosure_level"] == "core" finally: lt.close() @@ -926,3 +1048,235 @@ def test_last_tool_failures_recovery_message_returns_empty_when_no_failures() -> {"role": "tool", "content": json.dumps({"ok": True, "data": {}})}, ] assert _last_tool_failures_recovery_message(messages) == "" + + +@pytest.mark.asyncio +async def test_permission_failure_hard_stops_text_tool_loop() -> None: + """Authorization failures are terminal business blockers, not retry prompts.""" + tool_reply = '{"name": "platform_action", "arguments": {"platform": "feishu", "action": "im.list_chats", "payload": {}}}' + failure_payload = { + "ok": False, + "platform": "feishu", + "action": "im.list_chats", + "capability": "im.chat.read", + "failure_class": "authorization", + "failure_code": "access_denied", + "missing_scopes": ["im:chat:read"], + "scope_relation": "all_required", + "scope_source": "authoritative", + "recoverability": "admin_required", + "retryable": False, + "blocks_approval": True, + } + + with tempfile.TemporaryDirectory() as td: + settings = make_settings(td) + from leapflow.platform.mock import MockBridge + + rpc = MockBridge() + llm = StubLLM([tool_reply, "SHOULD NOT BE CALLED"]) + wm = WorkingMemoryProvider(max_tokens=1024) + lt = SemanticMemoryProvider(source=settings.duckdb_path) + imm = EpisodicMemoryProvider() + try: + reg = build_default_registry(rpc, llm, wm, lt) + classifier = _FixedClassifier("complex") + engine = AgentEngine(settings, rpc, llm, wm, lt, imm, reg, classifier) + engine._execute_general_tool = AsyncMock(return_value=failure_payload) # type: ignore[method-assign] + + out = await engine.run("列出飞书群聊") + + assert llm.call_count == 1 + engine._execute_general_tool.assert_awaited_once() # type: ignore[attr-defined] + assert "Authorization failed" in out + assert "im:chat:read" in out + assert "Do NOT retry" in out + assert "SHOULD NOT BE CALLED" not in out + finally: + lt.close() + + +@pytest.mark.asyncio +async def test_permission_failure_hard_stops_native_tool_loop() -> None: + """Native tool-calling must also stop immediately on authorization blockers.""" + from leapflow.llm.base import LLMChatResponse, LLMProvider, ToolCallInfo + from leapflow.platform.mock import MockBridge + + failure_payload = { + "ok": False, + "platform": "feishu", + "action": "im.list_chats", + "capability": "im.chat.read", + "failure_class": "authorization", + "failure_code": "access_denied", + "missing_scopes": ["im:chat:read"], + "scope_relation": "all_required", + "scope_source": "authoritative", + "recoverability": "admin_required", + "retryable": False, + "blocks_approval": True, + } + + class PermissionLLM(LLMProvider): + def __init__(self) -> None: + self.call_count = 0 + + async def achat(self, messages, *, stream=True, enable_thinking=False, on_chunk=None, **kwargs): + self.call_count += 1 + if self.call_count == 1: + return LLMChatResponse( + content="", + tool_calls=[ + ToolCallInfo( + id="tc1", + name="platform_action", + arguments={"platform": "feishu", "action": "im.list_chats", "payload": {}}, + ), + ToolCallInfo( + id="tc2", + name="platform_action", + arguments={ + "platform": "feishu", + "action": "im.send_message", + "payload": {"chat_id": "chat-1", "text": "should-not-send"}, + }, + ), + ], + ) + return LLMChatResponse(content="SHOULD NOT BE CALLED") + + async def achat_stream(self, messages, *, enable_thinking=False, **kwargs): + if False: + yield "" + + with tempfile.TemporaryDirectory() as td: + settings = make_settings(td) + settings = settings.__class__(**{**settings.__dict__, "native_tool_calling_enabled": True}) + rpc = MockBridge() + llm = PermissionLLM() + wm = WorkingMemoryProvider(max_tokens=1024) + lt = SemanticMemoryProvider(source=settings.duckdb_path) + imm = EpisodicMemoryProvider() + try: + reg = build_default_registry(rpc, llm, wm, lt) + classifier = _FixedClassifier("complex") + engine = AgentEngine(settings, rpc, llm, wm, lt, imm, reg, classifier) + engine._execute_general_tool = AsyncMock(return_value=failure_payload) # type: ignore[method-assign] + + out = await engine.run("列出飞书群聊") + + assert llm.call_count == 1 + engine._execute_general_tool.assert_awaited_once() # type: ignore[attr-defined] + assert "Authorization failed" in out + assert "im:chat:read" in out + assert "SHOULD NOT BE CALLED" not in out + finally: + lt.close() + + +def test_permission_recovery_text_quotes_only_listed_scopes() -> None: + """The deterministic renderer must never invent or expand scope names.""" + from leapflow.engine.engine import _build_permission_recovery_text + + text = _build_permission_recovery_text({ + "platform": "feishu", + "capability": "im.chat.read", + "failure_class": "authorization", + "failure_code": "missing_scope", + "missing_scopes": ["im:chat:read"], + "scope_relation": "all_required", + "recoverability": "admin_required", + "console_url": "https://open.feishu.cn/app/cli_xxx/auth", + }) + + assert "im:chat:read" in text + assert "one of" not in text.lower() + assert "https://open.feishu.cn/app/cli_xxx/auth" in text + assert "Do NOT retry" in text + + +def test_permission_recovery_text_uses_one_of_only_when_declared() -> None: + """"one of" phrasing only appears when scope_relation explicitly says so.""" + from leapflow.engine.engine import _build_permission_recovery_text + + text = _build_permission_recovery_text({ + "platform": "feishu", + "capability": "im.message.send", + "failure_class": "authorization", + "failure_code": "missing_scope", + "required_scopes": ["im:message.send_as_user", "im:message:send_as_bot"], + "scope_relation": "one_of", + "recoverability": "admin_required", + }) + + assert "ANY ONE" in text + assert "im:message.send_as_user" in text + assert "im:message:send_as_bot" in text + + +def test_permission_override_message_replaces_free_text_after_unresolved_failure() -> None: + """An unresolved permission failure as the turn's last tool signal must + override any free-text LLM answer, preventing scope hallucination.""" + import json + from leapflow.engine.engine import _permission_override_message + + failure_payload = { + "ok": False, + "platform": "feishu", + "capability": "im.chat.read", + "failure_class": "authorization", + "failure_code": "missing_scope", + "missing_scopes": ["im:chat:read"], + "scope_relation": "all_required", + "console_url": "https://open.feishu.cn/app/cli_xxx/auth", + } + messages = [ + {"role": "user", "content": "list my groups"}, + {"role": "assistant", "content": "", "tool_calls": [{"id": "1", "type": "function", "function": {"name": "platform_action", "arguments": "{}"}}]}, + {"role": "tool", "tool_call_id": "1", "content": json.dumps(failure_payload)}, + ] + + override = _permission_override_message(messages) + + assert override + assert "im:chat:read" in override + assert "im:chat.group_info" not in override + + +def test_permission_override_message_empty_after_successful_followup() -> None: + """No override once a later tool call in the same turn succeeded.""" + import json + from leapflow.engine.engine import _permission_override_message + + messages = [ + {"role": "user", "content": "list my groups"}, + {"role": "tool", "content": json.dumps({"ok": False, "failure_class": "authorization", "failure_code": "missing_scope"})}, + {"role": "tool", "content": json.dumps({"ok": True, "data": {}})}, + ] + + assert _permission_override_message(messages) == "" + + +def test_record_tool_call_categories_caches_capability_manifests(monkeypatch) -> None: + """Capability manifests are cached instead of rebuilt on every tool-call round.""" + from types import SimpleNamespace + + import leapflow.engine.engine as engine_module + + calls = 0 + + def fake_build_capability_manifests(tool_definitions): + nonlocal calls + calls += 1 + return [SimpleNamespace(name="text_replace", category="write")] + + monkeypatch.setattr(engine_module, "build_capability_manifests", fake_build_capability_manifests) + engine = object.__new__(AgentEngine) + engine._last_turn_tool_categories = frozenset() + engine._manifests_by_name = None + + engine._record_tool_call_categories([SimpleNamespace(name="text_replace")]) + engine._record_tool_call_categories([SimpleNamespace(name="text_replace")]) + + assert calls == 1 + assert engine._last_turn_tool_categories == frozenset({"write"}) diff --git a/tests/test_app_connector.py b/tests/test_app_connector.py index 331cde8..6af2da5 100644 --- a/tests/test_app_connector.py +++ b/tests/test_app_connector.py @@ -3,8 +3,23 @@ import pytest from leapflow.gateway.adapters.feishu import FeishuAdapter -from leapflow.gateway.connectors.action_registry import ActionRegistry -from leapflow.gateway.connectors.protocol import ActionPreview, ActionResult, BackendStatus +from leapflow.gateway.capability_health import CapabilityHealthLedger +from leapflow.gateway.backends.lark_cli_errors import classify_lark_cli_failure +from leapflow.gateway.connectors.action_registry import ( + ActionRegistry, + ValidationResult, + normalize_payload, + summarize_action_result, + validate_payload, +) +from leapflow.gateway.connectors.protocol import ( + ActionAuthSpec, + ActionFailure, + ActionPreview, + ActionResult, + ActionSpec, + BackendStatus, +) class FakeBackend: @@ -85,3 +100,775 @@ def test_action_registry_loads_standard_action_pack_export() -> None: assert registry.get("im.send_message") is not None assert registry.get("task.create") is not None + + +def test_yaml_action_pack_loads_auth_contract() -> None: + registry = ActionRegistry.from_module("leapflow.gateway.action_packs.feishu") + + list_messages = registry.get("im.list_messages") + send_message = registry.get("im.send_message") + search_messages = registry.get("im.search_messages") + + assert list_messages is not None + assert list_messages.capability == "im.message.read" + assert list_messages.auth.identities == ("user", "bot") + assert "im:message:readonly" in list_messages.auth.scopes["common"] + assert "im:message.group_msg:get_as_user" in list_messages.auth.scopes["user"] + assert "im:message.group_msg" in list_messages.auth.scopes["bot"] + assert list_messages.auth.resource_fields == ("chat_id",) + + assert send_message is not None + assert send_message.capability == "im.message.send" + assert "im:message:send_as_bot" in send_message.auth.scopes["bot"] + + assert search_messages is not None + assert search_messages.auth.identities == ("user",) + assert "search:message" in search_messages.auth.scopes["user"] + + +def test_summarize_action_result_preserves_structured_failure() -> None: + spec = ActionSpec( + name="im.list_messages", + backend_kind="cli", + capability="im.message.read", + output_policy="raw", + ) + failure = ActionFailure( + failure_class="authorization", + failure_code="missing_scope", + message="access denied", + recoverability="admin_required", + retryable=False, + missing_scopes=("im:message.group_msg",), + capability="im.message.read", + blocks_approval=True, + ) + + summary = summarize_action_result( + spec, + ActionResult(ok=False, error="access denied", failure=failure), + ) + + assert summary["ok"] is False + assert summary["failure_class"] == "authorization" + assert summary["failure_code"] == "missing_scope" + assert summary["recoverability"] == "admin_required" + assert summary["blocks_approval"] is True + assert summary["missing_scopes"] == ["im:message.group_msg"] + assert summary["capability"] == "im.message.read" + + +def test_capability_health_ledger_degrades_platform_for_side_effects() -> None: + """Hard auth failure on one capability blocks side-effect actions on the whole platform.""" + ledger = CapabilityHealthLedger() + read_spec = ActionSpec( + name="im.list_messages", + backend_kind="cli", + effect="read", + capability="im.message.read", + auth=ActionAuthSpec(scopes={"common": ("im:message:readonly",)}), + ) + send_spec = ActionSpec( + name="im.send_message", + backend_kind="cli", + effect="send", + capability="im.message.send", + auth=ActionAuthSpec(scopes={"common": ("im:message:send_as_bot",)}), + ) + failure = ActionFailure( + failure_class="authorization", + failure_code="missing_scope", + message="access denied", + recoverability="admin_required", + retryable=False, + missing_scopes=("im:message.group_msg",), + capability="im.message.read", + blocks_approval=True, + ) + + ledger.record_failure("feishu", read_spec.capability, failure) + + blocked_read = ledger.check_feasibility("feishu", read_spec) + blocked_send = ledger.check_feasibility("feishu", send_spec) + + # Read actions pass through so they can revalidate external permission changes. + assert blocked_read["ok"] is True + assert blocked_read["permission_revalidation"] is True + assert blocked_read["previous_failure_code"] == "missing_scope" + assert blocked_read["capability"] == "im.message.read" + + # Platform degradation blocks the side-effect action + assert blocked_send["ok"] is False + assert blocked_send["failure_code"] == "platform_degraded" + assert blocked_send["platform_degraded"] is True + assert "llm_instruction" in blocked_send + + # Unrelated platform is not affected + other_spec = ActionSpec(name="send", backend_kind="cli", effect="send", capability="im.send") + assert ledger.check_feasibility("dingtalk", other_spec) == {"ok": True} + + +def test_lark_cli_error_classifier_from_plain_access_denied() -> None: + spec = ActionSpec(name="docs.create_markdown", backend_kind="cli", capability="docs.create") + + failure = classify_lark_cli_failure( + spec, + "access denied for this operation; possible causes: missing scope", + {}, + binary="lark-cli", + profile="work", + identity="bot", + ) + + assert failure.failure_class == "authorization" + assert failure.failure_code == "access_denied" + assert failure.recoverability == "admin_required" + assert failure.retryable is False + assert failure.blocks_approval is True + assert failure.capability == "docs.create" + + +def test_lark_cli_error_classifier_declares_scopes_from_action_spec() -> None: + """When no authoritative missing_scopes are available, the classifier must + fall back to the action's OWN declared auth.scopes contract — never a + global CLI scope registry or a guessed scope name.""" + spec = ActionSpec( + name="im.list_chats", + backend_kind="cli", + capability="im.chat.read", + auth=ActionAuthSpec(scopes={"common": ("im:chat:read",)}), + ) + + failure = classify_lark_cli_failure( + spec, + "missing scope for this operation", + {}, + binary="lark-cli", + profile="work", + identity="bot", + ) + + assert failure.required_scopes == ("im:chat:read",) + assert failure.scope_source == "declared" + assert failure.scope_relation == "all_required" + assert "im:chat:read" in failure.recovery_hint + + +def test_lark_cli_error_classifier_typed_error_marks_authoritative_scopes() -> None: + """missing_scopes from the upstream typed error is authoritative and must + take priority over the action's declared contract.""" + spec = ActionSpec( + name="im.list_chats", + backend_kind="cli", + capability="im.chat.read", + auth=ActionAuthSpec(scopes={"common": ("im:chat:read",)}), + ) + + failure = classify_lark_cli_failure( + spec, + "", + { + "error": { + "type": "authorization", + "subtype": "missing_scope", + "message": "access denied", + "missing_scopes": ["im:chat:read"], + "console_url": "https://open.feishu.cn/app/cli_xxx/auth", + } + }, + binary="lark-cli", + profile="work", + identity="bot", + ) + + assert failure.missing_scopes == ("im:chat:read",) + assert failure.scope_source == "authoritative" + assert failure.console_url == "https://open.feishu.cn/app/cli_xxx/auth" + + +# ═══════════════════════════════════════════════════════════════ +# Payload normalization tests +# ═══════════════════════════════════════════════════════════════ + +_SEND_SPEC = ActionSpec( + name="im.send_message", + backend_kind="cli", + schema={ + "required": ["chat_id", "text"], + "properties": { + "chat_id": {"type": "string", "description": "Target chat ID."}, + "text": {"type": "string", "description": "Message text."}, + "thread_id": {"type": "string", "description": "Optional thread."}, + }, + }, +) + +_LIST_SPEC = ActionSpec( + name="im.list_messages", + backend_kind="cli", + schema={ + "required": ["chat_id"], + "properties": { + "chat_id": {"type": "string", "description": "Chat ID."}, + }, + }, +) + + +class TestNormalizePayload: + """Tests for normalize_payload — generous input acceptance.""" + + def test_correct_payload_passes_through(self) -> None: + params = { + "platform": "feishu", + "action": "im.send_message", + "payload": {"chat_id": "oc_1", "text": "hello"}, + } + result = normalize_payload(_SEND_SPEC, params) + assert result == {"chat_id": "oc_1", "text": "hello"} + + def test_top_level_fields_lifted_into_payload(self) -> None: + params = { + "platform": "feishu", + "action": "im.list_messages", + "chat_id": "oc_1", + } + result = normalize_payload(_LIST_SPEC, params) + assert result == {"chat_id": "oc_1"} + + def test_top_level_fields_lifted_when_payload_empty(self) -> None: + params = { + "platform": "feishu", + "action": "im.send_message", + "payload": {}, + "chat_id": "oc_1", + "text": "hello", + } + result = normalize_payload(_SEND_SPEC, params) + assert result == {"chat_id": "oc_1", "text": "hello"} + + def test_explicit_payload_takes_precedence(self) -> None: + params = { + "platform": "feishu", + "action": "im.send_message", + "payload": {"chat_id": "oc_correct", "text": "right"}, + "chat_id": "oc_wrong", + "text": "wrong", + } + result = normalize_payload(_SEND_SPEC, params) + assert result["chat_id"] == "oc_correct" + assert result["text"] == "right" + + def test_meta_keys_never_lifted(self) -> None: + params = { + "platform": "feishu", + "action": "im.send_message", + "payload": {"chat_id": "oc_1", "text": "hi"}, + } + result = normalize_payload(_SEND_SPEC, params) + assert "platform" not in result + assert "action" not in result + + def test_missing_payload_key_creates_from_top_level(self) -> None: + params = { + "platform": "feishu", + "action": "im.list_messages", + "chat_id": "oc_1", + } + result = normalize_payload(_LIST_SPEC, params) + assert result == {"chat_id": "oc_1"} + + def test_partial_payload_supplemented_from_top(self) -> None: + params = { + "platform": "feishu", + "action": "im.send_message", + "payload": {"chat_id": "oc_1"}, + "text": "supplemented", + } + result = normalize_payload(_SEND_SPEC, params) + assert result == {"chat_id": "oc_1", "text": "supplemented"} + + +class TestValidatePayloadStructured: + """Tests for enhanced structured ValidationResult.""" + + def test_valid_payload_returns_ok(self) -> None: + result = validate_payload(_SEND_SPEC, {"chat_id": "oc_1", "text": "hi"}) + assert result.ok is True + assert result.failure_code == "" + + def test_missing_fields_returns_structured_error(self) -> None: + result = validate_payload(_SEND_SPEC, {"chat_id": "oc_1"}) + assert result.ok is False + assert result.failure_code == "missing_required_fields" + assert "text" in result.missing_fields + assert "payload.text" in result.recovery_hint + + def test_type_mismatch_returns_structured_error(self) -> None: + spec = ActionSpec( + name="test.action", + backend_kind="cli", + schema={ + "required": ["values"], + "properties": {"values": {"type": "array"}}, + }, + ) + result = validate_payload(spec, {"values": "not-array"}) + assert result.ok is False + assert result.failure_code == "type_mismatch" + assert len(result.type_errors) == 1 + + def test_empty_required_field_counts_as_missing(self) -> None: + result = validate_payload(_SEND_SPEC, {"chat_id": "oc_1", "text": ""}) + assert result.ok is False + assert "text" in result.missing_fields + + +# ═══════════════════════════════════════════════════════════════ +# Task-scoped side-effect dedup tests +# ═══════════════════════════════════════════════════════════════ + +@pytest.mark.asyncio +async def test_side_effect_dedup_blocks_duplicate_send() -> None: + """Second identical send call returns already_executed without execution.""" + from leapflow.gateway.connectors.protocol import ActionPreview, ActionResult, BackendStatus + from leapflow.gateway.adapters.feishu import FeishuAdapter + from leapflow.gateway.server import GatewayServer + from leapflow.tools.gateway_tool import ( + platform_action_handler, + reset_platform_action_scope, + set_gateway_approval_gate, + set_gateway_server, + ) + import tempfile + from pathlib import Path + + class CountingBackend: + kind = "cli" + call_count = 0 + + async def status(self): + return BackendStatus(ok=True, backend_kind=self.kind) + + async def authenticate(self, payload): + return BackendStatus(ok=True, backend_kind=self.kind) + + async def execute(self, spec, payload): + self.call_count += 1 + return ActionResult(ok=True, resource_id=f"msg_{self.call_count}", data=dict(payload)) + + async def preview(self, spec, payload): + return ActionPreview(ok=True, summary=f"preview {spec.name}") + + backend = CountingBackend() + server = GatewayServer(Path(tempfile.mkdtemp())) + server.discover_manifests() + adapter = FeishuAdapter(backend=backend) + server._adapters["feishu"] = adapter + set_gateway_server(server) + set_gateway_approval_gate(None) + reset_platform_action_scope() + + try: + params = { + "platform": "feishu", + "action": "im.send_message", + "payload": {"chat_id": "oc_1", "text": "hello"}, + } + + result1 = await platform_action_handler(params) + result2 = await platform_action_handler(params) + + assert result1.get("ok") is True + assert result2.get("ok") is True + assert result2.get("already_executed") is True + assert "Do not re-invoke" in result2.get("execution_note", "") + assert backend.call_count == 1 + finally: + await server.stop() + set_gateway_approval_gate(None) + set_gateway_server(None) + reset_platform_action_scope() + + +@pytest.mark.asyncio +async def test_side_effect_dedup_allows_read_actions_to_repeat() -> None: + """Read actions (effect=read) are not subject to dedup.""" + from leapflow.gateway.connectors.protocol import ActionPreview, ActionResult, BackendStatus + from leapflow.gateway.adapters.feishu import FeishuAdapter + from leapflow.gateway.server import GatewayServer + from leapflow.tools.gateway_tool import ( + platform_action_handler, + reset_platform_action_scope, + set_gateway_approval_gate, + set_gateway_server, + ) + import tempfile + from pathlib import Path + + class CountingBackend: + kind = "cli" + call_count = 0 + + async def status(self): + return BackendStatus(ok=True, backend_kind=self.kind) + + async def authenticate(self, payload): + return BackendStatus(ok=True, backend_kind=self.kind) + + async def execute(self, spec, payload): + self.call_count += 1 + return ActionResult(ok=True, resource_id=f"r_{self.call_count}", data=dict(payload)) + + async def preview(self, spec, payload): + return ActionPreview(ok=True, summary=f"preview {spec.name}") + + backend = CountingBackend() + server = GatewayServer(Path(tempfile.mkdtemp())) + server.discover_manifests() + adapter = FeishuAdapter(backend=backend) + server._adapters["feishu"] = adapter + set_gateway_server(server) + set_gateway_approval_gate(None) + reset_platform_action_scope() + + try: + params = { + "platform": "feishu", + "action": "im.list_messages", + "payload": {"chat_id": "oc_1"}, + } + + result1 = await platform_action_handler(params) + result2 = await platform_action_handler(params) + + assert result1.get("ok") is True + assert result2.get("ok") is True + assert result2.get("already_executed") is None + assert backend.call_count == 2 + finally: + await server.stop() + set_gateway_approval_gate(None) + set_gateway_server(None) + reset_platform_action_scope() + + +@pytest.mark.asyncio +async def test_side_effect_dedup_resets_across_turns() -> None: + """After reset_platform_action_scope, same action can execute again.""" + from leapflow.gateway.connectors.protocol import ActionPreview, ActionResult, BackendStatus + from leapflow.gateway.adapters.feishu import FeishuAdapter + from leapflow.gateway.server import GatewayServer + from leapflow.tools.gateway_tool import ( + platform_action_handler, + reset_platform_action_scope, + set_gateway_approval_gate, + set_gateway_server, + ) + import tempfile + from pathlib import Path + + class CountingBackend: + kind = "cli" + call_count = 0 + + async def status(self): + return BackendStatus(ok=True, backend_kind=self.kind) + + async def authenticate(self, payload): + return BackendStatus(ok=True, backend_kind=self.kind) + + async def execute(self, spec, payload): + self.call_count += 1 + return ActionResult(ok=True, resource_id=f"msg_{self.call_count}", data=dict(payload)) + + async def preview(self, spec, payload): + return ActionPreview(ok=True, summary=f"preview {spec.name}") + + backend = CountingBackend() + server = GatewayServer(Path(tempfile.mkdtemp())) + server.discover_manifests() + adapter = FeishuAdapter(backend=backend) + server._adapters["feishu"] = adapter + set_gateway_server(server) + set_gateway_approval_gate(None) + reset_platform_action_scope() + + try: + params = { + "platform": "feishu", + "action": "im.send_message", + "payload": {"chat_id": "oc_1", "text": "hello"}, + } + + await platform_action_handler(params) + reset_platform_action_scope() + result2 = await platform_action_handler(params) + + assert result2.get("ok") is True + assert result2.get("already_executed") is None + assert backend.call_count == 2 + finally: + await server.stop() + set_gateway_approval_gate(None) + set_gateway_server(None) + reset_platform_action_scope() + + +# ═══════════════════════════════════════════════════════════════ +# Platform degradation tests +# ═══════════════════════════════════════════════════════════════ + + +def test_platform_degradation_blocks_side_effects() -> None: + """Authorization failure on a read capability degrades the platform for all side-effects.""" + from leapflow.gateway.capability_health import CapabilityHealthLedger + + ledger = CapabilityHealthLedger() + failure = ActionFailure( + failure_class="authorization", + failure_code="access_denied", + message="Missing scope im:chat:read", + recoverability="admin_required", + retryable=False, + blocks_approval=True, + ) + ledger.record_failure("feishu", "im.chat.read", failure) + + assert ledger.is_platform_degraded("feishu") + assert not ledger.is_platform_degraded("dingtalk") + + send_spec = ActionSpec( + name="im.send_message", + backend_kind="cli", + effect="send", + capability="im.message.send", + auth=ActionAuthSpec(resource_fields=("chat_id",)), + ) + result = ledger.check_feasibility("feishu", send_spec) + assert result["ok"] is False + assert result["failure_code"] == "platform_degraded" + assert result["skip_approval"] is True + assert "llm_instruction" in result + + +def test_platform_degradation_allows_read_retry() -> None: + """A read action with a previous auth failure can revalidate live permissions.""" + from leapflow.gateway.capability_health import CapabilityHealthLedger + + ledger = CapabilityHealthLedger() + failure = ActionFailure( + failure_class="authorization", + failure_code="missing_scope", + message="Scope im:chat:read required", + recoverability="admin_required", + retryable=False, + blocks_approval=True, + ) + ledger.record_failure("feishu", "im.chat.read", failure) + + read_spec = ActionSpec( + name="im.list_chats", + backend_kind="cli", + effect="read", + capability="im.chat.read", + ) + result = ledger.check_feasibility("feishu", read_spec) + assert result["ok"] is True + assert result["permission_revalidation"] is True + assert result["previous_failure_code"] == "missing_scope" + + +def test_platform_degradation_record_success_restores_matching_capability() -> None: + """Successful revalidation removes the matching capability failure.""" + from leapflow.gateway.capability_health import CapabilityHealthLedger + + ledger = CapabilityHealthLedger() + failure = ActionFailure( + failure_class="authorization", + failure_code="missing_scope", + message="Missing scope", + recoverability="admin_required", + retryable=False, + blocks_approval=True, + ) + ledger.record_failure("feishu", "im.chat.read", failure) + assert ledger.is_platform_degraded("feishu") + + assert ledger.record_success("feishu", "im.chat.read") is True + assert not ledger.is_platform_degraded("feishu") + assert ledger.summary() == [] + + +def test_platform_degradation_clear_restores_access() -> None: + """Clearing a platform removes degradation status.""" + from leapflow.gateway.capability_health import CapabilityHealthLedger + + ledger = CapabilityHealthLedger() + failure = ActionFailure( + failure_class="authorization", + failure_code="access_denied", + message="Missing scope", + recoverability="admin_required", + retryable=False, + blocks_approval=True, + ) + ledger.record_failure("feishu", "im.chat.read", failure) + assert ledger.is_platform_degraded("feishu") + + ledger.clear("feishu") + assert not ledger.is_platform_degraded("feishu") + + send_spec = ActionSpec( + name="im.send_message", + backend_kind="cli", + effect="send", + capability="im.message.send", + ) + result = ledger.check_feasibility("feishu", send_spec) + assert result["ok"] is True + + +def test_transient_failure_does_not_degrade_platform() -> None: + """Timeout/rate-limit failures do not trigger platform degradation.""" + from leapflow.gateway.capability_health import CapabilityHealthLedger + + ledger = CapabilityHealthLedger() + failure = ActionFailure( + failure_class="timeout", + failure_code="request_timeout", + message="Timed out", + recoverability="retryable", + retryable=True, + blocks_approval=False, + ) + ledger.record_failure("feishu", "im.chat.read", failure) + assert not ledger.is_platform_degraded("feishu") + + +# ═══════════════════════════════════════════════════════════════ +# Resource provenance tests +# ═══════════════════════════════════════════════════════════════ + + +def test_resource_provenance_verified_after_registration() -> None: + """Registered resource IDs are verified in subsequent checks.""" + from leapflow.gateway.resource_provenance import ProvenanceStatus, ResourceProvenancePool + + pool = ResourceProvenancePool() + pool.register("feishu", "chat_id", "oc_real_123") + pool.register("feishu", "chat_id", "oc_real_456") + + assert pool.check("feishu", "chat_id", "oc_real_123").status == ProvenanceStatus.VERIFIED + assert pool.check("feishu", "chat_id", "oc_real_456").status == ProvenanceStatus.VERIFIED + assert pool.check("feishu", "chat_id", "oc_fake").status == ProvenanceStatus.UNVERIFIED + assert pool.check("feishu", "message_id", "msg_1").status == ProvenanceStatus.UNKNOWN + + +def test_resource_provenance_extract_from_nested_result() -> None: + """register_from_result extracts resource IDs from nested API results.""" + from leapflow.gateway.resource_provenance import ProvenanceStatus, ResourceProvenancePool + + pool = ResourceProvenancePool() + result_data = { + "items": [ + {"chat_id": "oc_a", "name": "Group A"}, + {"chat_id": "oc_b", "name": "Group B"}, + ], + "page_token": "", + } + count = pool.register_from_result("feishu", ["chat_id"], result_data) + assert count == 2 + assert pool.check("feishu", "chat_id", "oc_a").status == ProvenanceStatus.VERIFIED + assert pool.check("feishu", "chat_id", "oc_b").status == ProvenanceStatus.VERIFIED + + +def test_resource_provenance_check_payload() -> None: + """check_payload returns results for all resource fields in one call.""" + from leapflow.gateway.resource_provenance import ProvenanceStatus, ResourceProvenancePool + + pool = ResourceProvenancePool() + pool.register("feishu", "chat_id", "oc_valid") + + results = pool.check_payload( + "feishu", ["chat_id", "message_id"], {"chat_id": "oc_valid", "message_id": "msg_1"} + ) + assert len(results) == 2 + assert results[0].status == ProvenanceStatus.VERIFIED + assert results[1].status == ProvenanceStatus.UNKNOWN + + +@pytest.mark.asyncio +async def test_platform_degradation_blocks_send_after_list_fails() -> None: + """End-to-end: list_chats auth failure degrades platform, blocking send_message.""" + from leapflow.gateway.adapters.feishu import FeishuAdapter + from leapflow.gateway.connectors.protocol import ActionPreview, ActionResult, BackendStatus + from leapflow.gateway.server import GatewayServer + from leapflow.tools.gateway_tool import ( + platform_action_handler, + reset_platform_action_scope, + set_gateway_approval_gate, + set_gateway_server, + ) + import tempfile + from pathlib import Path + + class AuthFailBackend: + kind = "cli" + + async def status(self): + return BackendStatus(ok=True, backend_kind=self.kind) + + async def authenticate(self, payload): + return BackendStatus(ok=True, backend_kind=self.kind) + + async def execute(self, spec, payload): + return ActionResult( + ok=False, + error="access denied", + failure=ActionFailure( + failure_class="authorization", + failure_code="access_denied", + message="Missing scope im:chat:read", + recoverability="admin_required", + retryable=False, + blocks_approval=True, + capability="im.chat.read", + ), + ) + + async def preview(self, spec, payload): + return ActionPreview(ok=True, summary=f"preview {spec.name}") + + backend = AuthFailBackend() + server = GatewayServer(Path(tempfile.mkdtemp())) + server.discover_manifests() + adapter = FeishuAdapter(backend=backend) + server._adapters["feishu"] = adapter + set_gateway_server(server) + set_gateway_approval_gate(None) + reset_platform_action_scope() + + try: + # First: list_chats fails with authorization error + list_result = await platform_action_handler({ + "platform": "feishu", + "action": "im.list_chats", + "payload": {"page_size": 20}, + }) + assert list_result["ok"] is False + + # Now: send_message should be blocked by platform degradation + send_result = await platform_action_handler({ + "platform": "feishu", + "action": "im.send_message", + "payload": {"chat_id": "oc_hallucinated", "text": "test"}, + }) + assert send_result["ok"] is False + assert send_result.get("failure_code") == "platform_degraded" + assert "llm_instruction" in send_result + finally: + await server.stop() + set_gateway_approval_gate(None) + set_gateway_server(None) + reset_platform_action_scope() diff --git a/tests/test_approval_layer.py b/tests/test_approval_layer.py index 9c3f23e..08c14a6 100644 --- a/tests/test_approval_layer.py +++ b/tests/test_approval_layer.py @@ -202,7 +202,13 @@ async def test_file_write_returns_gate_denial_message(tmp_path: Path) -> None: class DenyingGate: denial_message = "BLOCKED: User denied this action. Do not retry." - async def check(self, path: str, content: str, mode: str = "overwrite") -> bool: + async def check( + self, + path: str, + content: str, + mode: str = "overwrite", + sensitivity_meta: dict | None = None, + ) -> bool: return False set_file_write_gate(DenyingGate()) @@ -218,3 +224,123 @@ async def check(self, path: str, content: str, mode: str = "overwrite") -> bool: "ok": False, "error": "BLOCKED: User denied this action. Do not retry.", } + + +def test_default_risk_classifier_detects_sensitive_file_read() -> None: + risk = DefaultRiskClassifier().assess( + ActionDescriptor.file_read( + "/Users/example/.leapflow/.env", + metadata={"sensitivity_category": "credential"}, + ), + ) + + assert risk.level == RiskLevel.HIGH + assert risk.reasons == ("credential_file_read",) + assert risk.allow_permanent is False + + +@pytest.mark.asyncio +async def test_sensitive_file_read_requires_approval_without_gate(tmp_path: Path) -> None: + from leapflow.tools.file_operations import file_read + from leapflow.tools.registry_bootstrap import set_file_read_gate + + target = tmp_path / ".env" + target.write_text("API_KEY=sk-secret-value-123456\n", encoding="utf-8") + set_file_read_gate(None) + + result = await file_read({"path": str(target)}) + + assert result["ok"] is False + assert result["requires_approval"] is True + assert result["sensitivity_category"] == "credential" + + +@pytest.mark.asyncio +async def test_sensitive_file_read_approval_redacts_content(tmp_path: Path) -> None: + from leapflow.tools.file_operations import file_read + from leapflow.tools.registry_bootstrap import set_file_read_gate + + class AllowingReadGate: + denial_message = "" + + def __init__(self) -> None: + self.calls = [] + + async def check( + self, + path: str, + mode: str = "raw", + sensitivity_meta: dict | None = None, + ) -> bool: + self.calls.append((path, mode, dict(sensitivity_meta or {}))) + return True + + target = tmp_path / ".env" + target.write_text("API_KEY=sk-secret-value-123456\nPUBLIC_VALUE=ok\n", encoding="utf-8") + gate = AllowingReadGate() + set_file_read_gate(gate) + try: + result = await file_read({"path": str(target)}) + finally: + set_file_read_gate(None) + + assert result["ok"] is True + assert gate.calls[0][2]["sensitivity_category"] == "credential" + assert "sk-secret-value" not in result["content"] + assert "«redacted:" in result["content"] + assert result["redact_on_read"] is True + + +@pytest.mark.asyncio +async def test_sensitive_file_write_uses_approval_gate(tmp_path: Path) -> None: + from leapflow.tools.file_operations import file_write + from leapflow.tools.registry_bootstrap import set_file_write_gate + + class AllowingWriteGate: + denial_message = "" + + def __init__(self) -> None: + self.calls = [] + + async def check( + self, + path: str, + content: str, + mode: str = "overwrite", + sensitivity_meta: dict | None = None, + ) -> bool: + self.calls.append((path, content, mode, dict(sensitivity_meta or {}))) + return True + + target = tmp_path / ".env" + gate = AllowingWriteGate() + set_file_write_gate(gate) + try: + result = await file_write({"path": str(target), "content": "API_KEY=sk-new-value-123456\n"}) + finally: + set_file_write_gate(None) + + assert result["ok"] is True + assert target.read_text(encoding="utf-8") == "API_KEY=sk-new-value-123456\n" + assert gate.calls[0][3]["sensitivity_category"] == "credential" + + +@pytest.mark.asyncio +async def test_runtime_database_read_is_hardline_blocked(tmp_path: Path) -> None: + from leapflow.tools.file_operations import file_read + from leapflow.tools.registry_bootstrap import set_file_read_gate + + class FailingGate: + async def check(self, *_args, **_kwargs) -> bool: + raise AssertionError("runtime database reads must not request approval") + + target = tmp_path / "leap.duckdb" + target.write_bytes(b"not text") + set_file_read_gate(FailingGate()) + try: + result = await file_read({"path": str(target)}) + finally: + set_file_read_gate(None) + + assert result["ok"] is False + assert "Runtime database" in result["error"] diff --git a/tests/test_cli_entrypoint.py b/tests/test_cli_entrypoint.py index b6eaeaf..1cc4556 100644 --- a/tests/test_cli_entrypoint.py +++ b/tests/test_cli_entrypoint.py @@ -837,3 +837,28 @@ async def fake_daemon_main(args): assert cli.main(["hello", "world"]) == 0 assert captured == {"command": "chat", "prompt": "hello world"} + + +@pytest.mark.asyncio +async def test_teach_start_without_session_returns_structured_error() -> None: + from types import SimpleNamespace + + from leapflow.cli.commands.slash_handlers import command_execute + + result = await command_execute(SimpleNamespace(session=None), "teach start", "") + + assert result == {"ok": False, "message": "No active session.", "session_mode": "idle"} + + +@pytest.mark.asyncio +async def test_hub_fallback_message_formats_command_without_literal_strip(monkeypatch) -> None: + from types import SimpleNamespace + + import leapflow.cli.commands.slash_handlers as slash_handlers + + monkeypatch.setitem(__import__("sys").modules, "leapflow.cli.commands.hub", None) + result = await slash_handlers._execute_hub(SimpleNamespace(), "hub", "search demo") + + assert result["ok"] is False + assert "'.strip()'" not in result["message"] + assert "Hub command '/hub search demo'" in result["message"] diff --git a/tests/test_cli_ndjson_event_source.py b/tests/test_cli_ndjson_event_source.py new file mode 100644 index 0000000..d80c6b6 --- /dev/null +++ b/tests/test_cli_ndjson_event_source.py @@ -0,0 +1,265 @@ +"""Tests for CliNdjsonEventSource — generic CLI NDJSON subprocess manager.""" +from __future__ import annotations + +import asyncio +from typing import Any + +import pytest + +from leapflow.gateway.connectors.event_sources import CliEventSourceConfig, CliNdjsonEventSource + + +def _config(**overrides: Any) -> CliEventSourceConfig: + defaults = { + "binary": "fake-cli", + "args": ("event", "consume", "test.event"), + "platform_id": "test", + "ready_pattern": r"\[event\] ready", + "error_pattern": r"\[error\]", + "ready_timeout_s": 2.0, + "restart_backoff_base_s": 0.01, + "max_restart_backoff_s": 0.05, + "max_restarts": 3, + } + defaults.update(overrides) + return CliEventSourceConfig(**defaults) + + +class FakeStreamReader: + """Simulates asyncio.StreamReader for testing.""" + + def __init__(self, lines: list[bytes]) -> None: + self._lines = list(lines) + self._index = 0 + + async def readline(self) -> bytes: + if self._index < len(self._lines): + line = self._lines[self._index] + self._index += 1 + return line + return b"" + + +class FakeStreamWriter: + """Simulates asyncio.StreamWriter for testing.""" + + def __init__(self) -> None: + self.closed = False + + def close(self) -> None: + self.closed = True + + def is_closing(self) -> bool: + return self.closed + + async def drain(self) -> None: + pass + + +class FakeProcess: + """Simulates asyncio.subprocess.Process for NDJSON event source testing.""" + + def __init__( + self, + stdout_lines: list[bytes], + stderr_lines: list[bytes] | None = None, + exit_code: int = 0, + ) -> None: + self.stdout = FakeStreamReader(stdout_lines) + self.stderr = FakeStreamReader( + stderr_lines if stderr_lines is not None + else [b"[event] ready event_key=test.event\n"], + ) + self.stdin = FakeStreamWriter() + self.returncode: int | None = None + self._exit_code = exit_code + + async def wait(self) -> int: + self.returncode = self._exit_code + return self._exit_code + + +@pytest.mark.asyncio +async def test_events_yields_parsed_ndjson(monkeypatch: Any) -> None: + """Valid NDJSON lines are parsed into BackendEvent objects.""" + process = FakeProcess( + stdout_lines=[ + b'{"event_id":"e1","type":"im.message.receive_v1","content":"hello"}\n', + b'{"event_id":"e2","type":"im.message.receive_v1","content":"world"}\n', + ], + ) + + async def fake_exec(*_argv: Any, **_kw: Any) -> FakeProcess: + return process + + monkeypatch.setattr("asyncio.create_subprocess_exec", fake_exec) + + source = CliNdjsonEventSource(_config()) + status = await source.start() + assert status.ok is True + + events = [] + async for event in source.events(): + events.append(event) + if len(events) >= 2: + break + + await source.stop() + + assert len(events) == 2 + assert events[0].event_id == "e1" + assert events[0].event_type == "im.message.receive_v1" + assert events[0].platform_id == "test" + assert events[0].payload["content"] == "hello" + assert events[1].event_id == "e2" + + +@pytest.mark.asyncio +async def test_malformed_lines_skipped(monkeypatch: Any) -> None: + """Non-JSON lines and empty lines are silently skipped.""" + process = FakeProcess( + stdout_lines=[ + b"not json\n", + b"\n", + b'{"event_id":"e1","type":"test.event"}\n', + ], + ) + + async def fake_exec(*_argv: Any, **_kw: Any) -> FakeProcess: + return process + + monkeypatch.setattr("asyncio.create_subprocess_exec", fake_exec) + + source = CliNdjsonEventSource(_config()) + await source.start() + + events = [] + async for event in source.events(): + events.append(event) + + assert len(events) == 1 + assert events[0].event_id == "e1" + + +@pytest.mark.asyncio +async def test_ready_timeout_returns_not_ok(monkeypatch: Any) -> None: + """If stderr ready marker not received within timeout, start returns ok=False.""" + process = FakeProcess( + stdout_lines=[], + stderr_lines=[b"some other log\n"], + ) + + async def fake_exec(*_argv: Any, **_kw: Any) -> FakeProcess: + return process + + monkeypatch.setattr("asyncio.create_subprocess_exec", fake_exec) + + source = CliNdjsonEventSource(_config(ready_timeout_s=0.1)) + status = await source.start() + assert status.ok is False + assert "ready" in status.detail.lower() + + +@pytest.mark.asyncio +async def test_binary_not_found_returns_not_ok(monkeypatch: Any) -> None: + """FileNotFoundError from spawn returns ok=False with descriptive detail.""" + async def fake_exec(*_argv: Any, **_kw: Any) -> FakeProcess: + raise FileNotFoundError("fake-cli not found") + + monkeypatch.setattr("asyncio.create_subprocess_exec", fake_exec) + + source = CliNdjsonEventSource(_config()) + status = await source.start() + assert status.ok is False + assert "not found" in status.detail.lower() + + +@pytest.mark.asyncio +async def test_stderr_json_error_detected(monkeypatch: Any) -> None: + """Structured JSON error on stderr is captured before ready.""" + error_json = b'{"ok":false,"error":{"type":"authentication","message":"missing token","hint":"run auth login"}}\n' + process = FakeProcess( + stdout_lines=[], + stderr_lines=[error_json], + ) + + async def fake_exec(*_argv: Any, **_kw: Any) -> FakeProcess: + return process + + monkeypatch.setattr("asyncio.create_subprocess_exec", fake_exec) + + source = CliNdjsonEventSource(_config(ready_timeout_s=0.5)) + status = await source.start() + assert status.ok is False + assert "missing token" in status.detail + + +@pytest.mark.asyncio +async def test_stop_is_idempotent(monkeypatch: Any) -> None: + """Calling stop multiple times does not raise.""" + process = FakeProcess(stdout_lines=[]) + + async def fake_exec(*_argv: Any, **_kw: Any) -> FakeProcess: + return process + + monkeypatch.setattr("asyncio.create_subprocess_exec", fake_exec) + + source = CliNdjsonEventSource(_config()) + await source.start() + status1 = await source.stop() + status2 = await source.stop() + assert status1.ok is True + assert status2.ok is True + + +@pytest.mark.asyncio +async def test_status_reports_running_state(monkeypatch: Any) -> None: + """Status correctly reflects running/stopped state.""" + process = FakeProcess( + stdout_lines=[b'{"event_id":"e1","type":"test"}\n'], + ) + + async def fake_exec(*_argv: Any, **_kw: Any) -> FakeProcess: + return process + + monkeypatch.setattr("asyncio.create_subprocess_exec", fake_exec) + + source = CliNdjsonEventSource(_config()) + + status_before = await source.status() + assert status_before.ok is False + + await source.start() + status_running = await source.status() + assert status_running.ok is True + assert status_running.metadata["started"] is True + + await source.stop() + status_stopped = await source.status() + assert status_stopped.metadata["started"] is False + + +@pytest.mark.asyncio +async def test_event_type_from_type_field(monkeypatch: Any) -> None: + """lark-cli uses 'type' field; fallback to 'event_type'.""" + process = FakeProcess( + stdout_lines=[ + b'{"event_id":"e1","type":"im.message.receive_v1"}\n', + b'{"event_id":"e2","event_type":"custom.event"}\n', + ], + ) + + async def fake_exec(*_argv: Any, **_kw: Any) -> FakeProcess: + return process + + monkeypatch.setattr("asyncio.create_subprocess_exec", fake_exec) + + source = CliNdjsonEventSource(_config()) + await source.start() + + events = [] + async for event in source.events(): + events.append(event) + + assert events[0].event_type == "im.message.receive_v1" + assert events[1].event_type == "custom.event" diff --git a/tests/test_context_disclosure.py b/tests/test_context_disclosure.py index f848312..5ff4206 100644 --- a/tests/test_context_disclosure.py +++ b/tests/test_context_disclosure.py @@ -5,6 +5,7 @@ DisclosureLevel, DisclosurePlanner, DisclosureRuntimeState, + build_capability_manifests, ) from leapflow.tools.registry_bootstrap import TOOL_DEFINITIONS @@ -16,99 +17,93 @@ def _tool_names(plan) -> set[str]: } -def test_disclosure_planner_keeps_plain_chat_light() -> None: +def test_disclosure_planner_core_is_never_empty_and_excludes_heavy_categories() -> None: + """CORE is the floor: no structural signal -> static Tier 0/0.5 only.""" planner = DisclosurePlanner() plan = planner.plan( - "Hello, who are you?", TOOL_DEFINITIONS, DisclosureRuntimeState(enable_thinking=True, native_tools_enabled=True), ) - assert plan.level == DisclosureLevel.LIGHT - assert plan.native_tools is False - assert plan.tool_definitions == () - assert plan.memory.value == "none" - assert plan.reasoning.value == "off" - - -def test_disclosure_planner_selects_file_tools_without_full_catalog() -> None: - planner = DisclosurePlanner() - - plan = planner.plan( - "Read src/leapflow/engine/engine.py and summarize the relevant section", - TOOL_DEFINITIONS, - DisclosureRuntimeState(native_tools_enabled=True), - ) - names = _tool_names(plan) - assert plan.level == DisclosureLevel.SELECTED_TOOLS + assert plan.level == DisclosureLevel.CORE + assert plan.tool_definitions # never empty + assert plan.catalog_definitions == tuple(TOOL_DEFINITIONS) assert plan.native_tools is True - assert "file_read" in names - assert "file_list" in names - assert "shell_run" not in names - package_plan = planner.plan( - "Read package.json and summarize dependencies", - TOOL_DEFINITIONS, - DisclosureRuntimeState(native_tools_enabled=True), - ) - assert package_plan.level == DisclosureLevel.SELECTED_TOOLS - - -def test_disclosure_planner_selects_project_research_without_full_catalog() -> None: + # Always-on low-risk, cheap-schema tools. + for expected in ("file_list", "file_read", "text_search", "memory_search", "capability_expand"): + assert expected in names + + # Mutating / heavy / sensitive tools must never be in the static core whitelist. + for excluded in ( + "shell_run", + "file_write", + "memory_add", + "delegate_task", + "hub_push", + "hub_pull", + "hub_sync", + "gateway_send", + "gateway_connect", + "platform_action", + "platform_connect", + ): + assert excluded not in names + + +def test_disclosure_planner_expands_via_last_turn_tool_category_continuity() -> None: + """Tier 1 opens strictly from structural continuity, never from user text.""" planner = DisclosurePlanner() plan = planner.plan( - "Read and study this codebase, then generate a system architecture diagram", TOOL_DEFINITIONS, - DisclosureRuntimeState(native_tools_enabled=True, enable_thinking=True), + DisclosureRuntimeState( + native_tools_enabled=True, + last_turn_tool_categories=frozenset({"hub"}), + ), ) names = _tool_names(plan) - assert plan.level == DisclosureLevel.PROJECT_RESEARCH - assert plan.native_tools is True - assert plan.memory.value == "task_retrieval" - assert plan.reasoning.value == "auto" - assert "file_read" in names - assert "file_list" in names - assert "shell_run" not in names - assert not any(name.startswith("hub") for name in names) - - -def test_disclosure_planner_uses_index_for_capability_questions() -> None: - planner = DisclosurePlanner() - - plan = planner.plan( - "What tools can you use?", - TOOL_DEFINITIONS, - DisclosureRuntimeState(native_tools_enabled=True), - ) + assert plan.level == DisclosureLevel.EXPANDED + assert "hub" in plan.expanded_categories + for expected in ("hub_push", "hub_pull", "hub_search", "hub_sync"): + assert expected in names + # Gateway tools were not part of the continuity signal, so they stay closed. + assert "gateway_send" not in names - assert plan.level == DisclosureLevel.INDEXED_CAPABILITIES - assert plan.native_tools is False - assert plan.tool_definitions == () - assert plan.catalog_definitions - assert plan.memory.value == "session_summary" - -def test_disclosure_planner_uses_full_context_for_runtime_escalation() -> None: +def test_disclosure_planner_uses_full_context_for_structural_gates() -> None: planner = DisclosurePlanner() slash_plan = planner.plan( - "/run organize downloads", TOOL_DEFINITIONS, DisclosureRuntimeState(slash_command=True, native_tools_enabled=True), ) failure_plan = planner.plan( - "continue", TOOL_DEFINITIONS, DisclosureRuntimeState(recent_failure=True, native_tools_enabled=True), ) + posture_plan = planner.plan( + TOOL_DEFINITIONS, + DisclosureRuntimeState(context_posture="research", native_tools_enabled=True), + ) assert slash_plan.level == DisclosureLevel.FULL assert slash_plan.native_tools is True + assert slash_plan.tool_definitions == tuple(TOOL_DEFINITIONS) assert failure_plan.level == DisclosureLevel.FULL + assert posture_plan.level == DisclosureLevel.FULL + + +def test_disclosure_planner_never_performs_text_fitting() -> None: + """The planner signature no longer accepts user text at all.""" + import inspect + + signature = inspect.signature(DisclosurePlanner.plan) + assert "user_text" not in signature.parameters + assert list(signature.parameters)[1:] == ["tool_definitions", "runtime"] def test_capability_manifest_prefers_explicit_tool_metadata() -> None: @@ -132,6 +127,38 @@ def test_capability_manifest_prefers_explicit_tool_metadata() -> None: assert manifest.input_signals == ("alert", "notify") assert manifest.requires_approval is True assert manifest.schema_cost == "high" + assert manifest.is_core is False + + +def test_capability_manifest_is_core_property_reflects_risk_and_cost() -> None: + read_only_cheap = CapabilityManifest(name="a", category="general", summary="", risk_level="read_only", schema_cost="medium") + read_only_heavy = CapabilityManifest(name="b", category="hub", summary="", risk_level="read_only", schema_cost="high") + mutating_cheap = CapabilityManifest(name="c", category="write", summary="", risk_level="high", schema_cost="medium") + + assert read_only_cheap.is_core is True + assert read_only_heavy.is_core is False + assert mutating_cheap.is_core is False + + +def test_hub_and_gateway_tools_are_explicitly_classified_as_heavy() -> None: + """Regression guard: hub/gateway tools must declare x_leapflow explicitly. + + Keyword inference over descriptions is unreliable for these tools (e.g. a + hub tool's description mentions "skill", a gateway tool's description may + not literally contain "gateway"), so they must not rely on _infer_category + fallbacks to land in the correct heavy, non-core category. + """ + manifests = {m.name: m for m in build_capability_manifests(TOOL_DEFINITIONS)} + + for name in ("hub_push", "hub_pull", "hub_search", "hub_sync"): + assert manifests[name].category == "hub" + assert manifests[name].schema_cost == "high" + assert manifests[name].is_core is False + + for name in ("platform_action", "platform_connect", "gateway_send", "gateway_connect"): + assert manifests[name].category == "gateway" + assert manifests[name].schema_cost == "high" + assert manifests[name].is_core is False def test_file_read_schema_discourages_workspace_config_probe() -> None: diff --git a/tests/test_context_governance.py b/tests/test_context_governance.py index 119fd26..96037f2 100644 --- a/tests/test_context_governance.py +++ b/tests/test_context_governance.py @@ -483,6 +483,43 @@ def test_evidence_builder_ceiling() -> None: assert huge._max_content_chars <= 8_000 +def test_evidence_builder_preserves_platform_permission_recovery_fields() -> None: + builder = ToolEvidenceBuilder(max_content_chars=240) + + evidence = builder.build( + "platform_action", + {"platform": "feishu", "action": "im.list_chats"}, + { + "ok": False, + "error": "access denied", + "failure_class": "authorization", + "failure_code": "missing_scope", + "recoverability": "admin_required", + "capability": "im.chat.read", + "missing_scopes": ["im:chat:read"], + "required_scopes": ["im:chat:read"], + "scope_relation": "all_required", + "scope_source": "authoritative", + "console_url": "https://open.feishu.cn/app/cli_xxx/auth", + "next_steps": ["Grant missing scopes", "Re-publish the app"], + "recovery_hint": "Grant the missing scope in the developer console.", + "retryable": False, + }, + ) + + assert evidence["ok"] is False + assert evidence["platform"] == "feishu" + assert evidence["action"] == "im.list_chats" + assert evidence["failure_class"] == "authorization" + assert evidence["failure_code"] == "missing_scope" + assert evidence["missing_scopes"] == ["im:chat:read"] + assert evidence["required_scopes"] == ["im:chat:read"] + assert evidence["scope_relation"] == "all_required" + assert evidence["scope_source"] == "authoritative" + assert evidence["console_url"] == "https://open.feishu.cn/app/cli_xxx/auth" + assert evidence["next_steps"] == ["Grant missing scopes", "Re-publish the app"] + + # ── CJK-aware _estimate_tokens in compressor ───────────────────────── diff --git a/tests/test_execution_backends.py b/tests/test_execution_backends.py index d6951b1..ebf6850 100644 --- a/tests/test_execution_backends.py +++ b/tests/test_execution_backends.py @@ -85,6 +85,7 @@ async def fake_exec(*_argv, **_kwargs): spec = ActionSpec( name="im.send_message", backend_kind=BackendKind.CLI.value, + capability="im.message.send", backend_config={"argv": ("im", "+messages-send")}, ) @@ -92,6 +93,53 @@ async def fake_exec(*_argv, **_kwargs): assert result.ok is False assert result.error == "missing scope" + assert result.failure is not None + assert result.failure.failure_code == "missing_scope" + assert result.failure.failure_class == "authorization" + assert result.failure.blocks_approval is True + + +@pytest.mark.asyncio +async def test_cli_backend_preserves_lark_cli_permission_problem(monkeypatch) -> None: + payload = ( + b'{"ok": false, "error": {' + b'"type":"authorization",' + b'"subtype":"missing_scope",' + b'"message":"access denied for this operation",' + b'"hint":"grant scope",' + b'"missing_scopes":["im:message.group_msg"],' + b'"requested_scopes":["im:message:readonly"],' + b'"granted_scopes":[], ' + b'"identity":"bot",' + b'"console_url":"https://console.example"' + b'}}' + ) + + async def fake_exec(*_argv, **_kwargs): + return FakeProcess(1, b"", payload) + + monkeypatch.setattr("asyncio.create_subprocess_exec", fake_exec) + backend = CliBackend(binary="lark-cli", profile="work", identity="bot") + spec = ActionSpec( + name="im.list_messages", + backend_kind=BackendKind.CLI.value, + capability="im.message.read", + backend_config={"argv": ("im", "+chat-messages-list")}, + ) + + result = await backend.execute(spec, {}) + + assert result.ok is False + assert result.failure is not None + assert result.failure.failure_class == "authorization" + assert result.failure.failure_code == "missing_scope" + assert result.failure.recoverability == "admin_required" + assert result.failure.retryable is False + assert result.failure.blocks_approval is True + assert result.failure.missing_scopes == ("im:message.group_msg",) + assert result.failure.requested_scopes == ("im:message:readonly",) + assert result.failure.identity == "bot" + assert result.failure.console_url == "https://console.example" @pytest.mark.asyncio diff --git a/tests/test_feishu_event_normalizer.py b/tests/test_feishu_event_normalizer.py new file mode 100644 index 0000000..460f019 --- /dev/null +++ b/tests/test_feishu_event_normalizer.py @@ -0,0 +1,322 @@ +"""Tests for FeishuEventNormalizer — Feishu event classification and mapping.""" +from __future__ import annotations + +import pytest + +from leapflow.gateway.connectors.protocol import BackendEvent, EventKind +from leapflow.gateway.normalizers.feishu import FeishuEventNormalizer + + +def _make_message_event( + sender_id: str = "ou_user1", + chat_id: str = "oc_chat1", + chat_type: str = "group", + content: str = "hello", + event_id: str = "ev_1", + message_id: str = "om_1", + message_type: str = "text", +) -> BackendEvent: + """Build a flat NDJSON event matching lark-cli im.message.receive_v1 output.""" + return BackendEvent( + event_id=event_id, + event_type="im.message.receive_v1", + platform_id="feishu", + payload={ + "type": "im.message.receive_v1", + "event_id": event_id, + "message_id": message_id, + "chat_id": chat_id, + "chat_type": chat_type, + "message_type": message_type, + "sender_id": sender_id, + "content": content, + "create_time": "1720000000000", + }, + ) + + +def test_classify_text_message() -> None: + """im.message.receive_v1 with text content → MESSAGE + InboundMessage.""" + normalizer = FeishuEventNormalizer(bot_id="ou_bot1") + result = normalizer.classify(_make_message_event()) + + assert result.kind == EventKind.MESSAGE + assert result.message is not None + assert result.message.text == "hello" + assert result.message.source.chat_id == "oc_chat1" + assert result.message.source.chat_type == "group" + assert result.message.source.user_id == "ou_user1" + assert result.message.source.platform == "feishu" + assert result.message.message_id == "om_1" + assert result.message.metadata["event_id"] == "ev_1" + assert result.message.metadata["message_type"] == "text" + + +def test_self_message_ignored() -> None: + """Message from bot's own open_id → IGNORED.""" + normalizer = FeishuEventNormalizer(bot_id="ou_bot1") + result = normalizer.classify(_make_message_event(sender_id="ou_bot1")) + + assert result.kind == EventKind.IGNORED + + +def test_self_message_check_with_empty_bot_id() -> None: + """When bot_id is empty, no messages are filtered as self.""" + normalizer = FeishuEventNormalizer(bot_id="") + result = normalizer.classify(_make_message_event(sender_id="ou_user1")) + + assert result.kind == EventKind.MESSAGE + + +def test_dm_chat_type_mapping() -> None: + """p2p chat_type maps to 'dm'.""" + normalizer = FeishuEventNormalizer(bot_id="ou_bot1") + result = normalizer.classify(_make_message_event(chat_type="p2p")) + + assert result.kind == EventKind.MESSAGE + assert result.message is not None + assert result.message.source.chat_type == "dm" + + +def test_group_chat_type() -> None: + """group chat_type stays 'group'.""" + normalizer = FeishuEventNormalizer(bot_id="ou_bot1") + result = normalizer.classify(_make_message_event(chat_type="group")) + + assert result.message is not None + assert result.message.source.chat_type == "group" + + +def test_empty_content_returns_ignored() -> None: + """Message with empty content → IGNORED (no InboundMessage produced).""" + normalizer = FeishuEventNormalizer(bot_id="ou_bot1") + result = normalizer.classify(_make_message_event(content="")) + + assert result.kind == EventKind.IGNORED + + +def test_reaction_classified_as_signal() -> None: + """im.message.reaction.created_v1 → SIGNAL.""" + event = BackendEvent( + event_id="ev_r1", + event_type="im.message.reaction.created_v1", + platform_id="feishu", + payload={"sender_id": "ou_user1"}, + ) + normalizer = FeishuEventNormalizer(bot_id="ou_bot1") + result = normalizer.classify(event) + + assert result.kind == EventKind.SIGNAL + assert result.raw_event is event + + +def test_lifecycle_event() -> None: + """im.chat.member.bot.added_v1 → LIFECYCLE.""" + event = BackendEvent( + event_id="ev_l1", + event_type="im.chat.member.bot.added_v1", + platform_id="feishu", + payload={}, + ) + normalizer = FeishuEventNormalizer(bot_id="ou_bot1") + result = normalizer.classify(event) + + assert result.kind == EventKind.LIFECYCLE + + +def test_callback_event() -> None: + """card.action.trigger → CALLBACK.""" + event = BackendEvent( + event_id="ev_c1", + event_type="card.action.trigger", + platform_id="feishu", + payload={"sender_id": "ou_user1"}, + ) + normalizer = FeishuEventNormalizer(bot_id="ou_bot1") + result = normalizer.classify(event) + + assert result.kind == EventKind.CALLBACK + assert result.raw_event is event + + +def test_unknown_event_type_becomes_signal() -> None: + """Unrecognized event types become SIGNAL (annotate, don't discard).""" + event = BackendEvent( + event_id="ev_u1", + event_type="some.future.event_v3", + platform_id="feishu", + payload={}, + ) + normalizer = FeishuEventNormalizer(bot_id="ou_bot1") + result = normalizer.classify(event) + + assert result.kind == EventKind.SIGNAL + + +def test_bot_mention_detection() -> None: + """Bot name in content → metadata['bot_mentioned'] = True.""" + normalizer = FeishuEventNormalizer(bot_id="ou_bot1", bot_name="LeapBot") + result = normalizer.classify( + _make_message_event(content="@LeapBot 你好"), + ) + + assert result.kind == EventKind.MESSAGE + assert result.message is not None + assert result.message.metadata.get("bot_mentioned") is True + + +def test_no_mention_without_bot_name() -> None: + """Without bot_name, no mention detection occurs.""" + normalizer = FeishuEventNormalizer(bot_id="ou_bot1") + result = normalizer.classify( + _make_message_event(content="@SomeBot hello"), + ) + + assert result.kind == EventKind.MESSAGE + assert result.message is not None + assert "bot_mentioned" not in result.message.metadata + + +def test_bot_id_setter() -> None: + """bot_id can be updated after construction.""" + normalizer = FeishuEventNormalizer(bot_id="") + result1 = normalizer.classify(_make_message_event(sender_id="ou_bot1")) + assert result1.kind == EventKind.MESSAGE + + normalizer.bot_id = "ou_bot1" + result2 = normalizer.classify(_make_message_event(sender_id="ou_bot1")) + assert result2.kind == EventKind.IGNORED + + +def test_timestamp_parsing() -> None: + """create_time in milliseconds is converted to seconds.""" + normalizer = FeishuEventNormalizer(bot_id="ou_bot1") + result = normalizer.classify(_make_message_event()) + + assert result.message is not None + assert abs(result.message.timestamp - 1720000000.0) < 1.0 + + +def test_is_self_message_standalone() -> None: + """is_self_message works independently of classify.""" + normalizer = FeishuEventNormalizer() + event = _make_message_event(sender_id="ou_bot1") + + assert normalizer.is_self_message(event, "ou_bot1") is True + assert normalizer.is_self_message(event, "ou_other") is False + assert normalizer.is_self_message(event, "") is False + + +def test_at_all_mention_detected() -> None: + """@all / @所有人 is treated as a bot mention.""" + normalizer = FeishuEventNormalizer(bot_id="ou_bot1") + for content in ("@all 紧急通知", "@所有人 开会了", "@All please check"): + result = normalizer.classify(_make_message_event(content=content)) + assert result.kind == EventKind.MESSAGE, f"Failed for: {content}" + assert result.message is not None + assert result.message.metadata.get("bot_mentioned") is True, f"@all not detected in: {content}" + + +def test_at_all_without_bot_name() -> None: + """@all detection works even without bot_name configured.""" + normalizer = FeishuEventNormalizer(bot_id="ou_bot1", bot_name="") + result = normalizer.classify(_make_message_event(content="@all 快看")) + assert result.message is not None + assert result.message.metadata.get("bot_mentioned") is True + + +def test_image_message_produces_media_attachment() -> None: + """Image message type populates MediaAttachment with file_key.""" + event = BackendEvent( + event_id="ev_img", + event_type="im.message.receive_v1", + platform_id="feishu", + payload={ + "type": "im.message.receive_v1", + "message_id": "om_img1", + "chat_id": "oc_1", + "chat_type": "group", + "message_type": "image", + "sender_id": "ou_user1", + "content": "", + "image_key": "img_v2_abcdef", + }, + ) + normalizer = FeishuEventNormalizer(bot_id="ou_bot1") + result = normalizer.classify(event) + assert result.kind == EventKind.MESSAGE + assert result.message is not None + assert len(result.message.media) == 1 + assert result.message.media[0].media_type == "image" + assert "img_v2_abcdef" in result.message.media[0].url + assert result.message.text == "[image]" + + +def test_file_message_produces_media_attachment() -> None: + """File message type populates MediaAttachment.""" + event = BackendEvent( + event_id="ev_file", + event_type="im.message.receive_v1", + platform_id="feishu", + payload={ + "type": "im.message.receive_v1", + "message_id": "om_f1", + "chat_id": "oc_1", + "chat_type": "p2p", + "message_type": "file", + "sender_id": "ou_user1", + "content": "", + "file_key": "file_xyz", + "file_name": "report.pdf", + "file_size": "12345", + }, + ) + normalizer = FeishuEventNormalizer(bot_id="ou_bot1") + result = normalizer.classify(event) + assert result.kind == EventKind.MESSAGE + assert result.message is not None + assert len(result.message.media) == 1 + assert result.message.media[0].filename == "report.pdf" + assert result.message.media[0].size_bytes == 12345 + + +def test_reply_to_id_extracted() -> None: + """parent_id from payload is set as reply_to_id.""" + event = BackendEvent( + event_id="ev_reply", + event_type="im.message.receive_v1", + platform_id="feishu", + payload={ + "type": "im.message.receive_v1", + "message_id": "om_reply1", + "chat_id": "oc_1", + "chat_type": "group", + "message_type": "text", + "sender_id": "ou_user1", + "content": "this is a reply", + "parent_id": "om_parent1", + }, + ) + normalizer = FeishuEventNormalizer(bot_id="ou_bot1") + result = normalizer.classify(event) + assert result.message is not None + assert result.message.reply_to_id == "om_parent1" + + +def test_nested_sender_id_extraction() -> None: + """Nested sender_id structures (V2 envelope fallback) are handled.""" + event = BackendEvent( + event_id="ev_n1", + event_type="im.message.receive_v1", + platform_id="feishu", + payload={ + "sender": {"sender_id": {"open_id": "ou_nested"}}, + "message": { + "chat_id": "oc_1", "chat_type": "group", + "message_type": "text", "content": "nested test", + "message_id": "om_n1", + }, + }, + ) + normalizer = FeishuEventNormalizer(bot_id="ou_nested") + assert normalizer.is_self_message(event, "ou_nested") is True diff --git a/tests/test_gateway_adapters.py b/tests/test_gateway_adapters.py index 6fab544..5a8f6f5 100644 --- a/tests/test_gateway_adapters.py +++ b/tests/test_gateway_adapters.py @@ -186,7 +186,7 @@ async def test_feishu_adapter_uses_cli_backend_for_send() -> None: assert result.ok is True assert result.message_id == "om_1" assert backend.executed == [ - ("im.send_message", {"chat_id": "oc_1", "thread_id": "", "text": "hello feishu"}), + ("im.send_message", {"chat_id": "oc_1", "text": "hello feishu"}), ] @@ -196,7 +196,6 @@ async def test_dingtalk_adapter_connect_send_and_event_normalization() -> None: "/v1.0/oauth2/accessToken": (200, {"errcode": 0, "accessToken": "access-token"}), "/topapi/robot/send": (200, {"errcode": 0, "task_id": "task-1"}), }) - received: list[InboundMessage] = [] adapter = DingTalkAdapter( app_key="key", app_secret="secret", @@ -204,15 +203,19 @@ async def test_dingtalk_adapter_connect_send_and_event_normalization() -> None: port=0, http_client=fake_http, ) - adapter.on_message = received.append await adapter.connect() + source = adapter.event_source() + assert source is not None + try: + await source.start() + result = await adapter.send( SendTarget(platform="dingtalk", chat_id="cid-1"), OutboundContent(text="hello dingtalk"), ) - status, data = await post_json_for_test(adapter.local_url, { + status, data = await post_json_for_test(source._server.url_base + "/dingtalk/events", { "conversationId": "cid-1", "conversationType": "2", "senderStaffId": "staff-1", @@ -221,6 +224,7 @@ async def test_dingtalk_adapter_connect_send_and_event_normalization() -> None: "text": {"content": "incoming dingtalk"}, }) finally: + await source.stop() await adapter.disconnect() assert result.ok is True @@ -228,5 +232,3 @@ async def test_dingtalk_adapter_connect_send_and_event_normalization() -> None: assert data["ok"] is True assert result.message_id == "task-1" assert fake_http.requests[1]["json_body"]["robotCode"] == "robot" - assert received[0].source.chat_type == "group" - assert received[0].text == "incoming dingtalk" diff --git a/tests/test_gateway_consumer_loop.py b/tests/test_gateway_consumer_loop.py new file mode 100644 index 0000000..e9d34eb --- /dev/null +++ b/tests/test_gateway_consumer_loop.py @@ -0,0 +1,381 @@ +"""Tests for GatewayServer consumer loop — BackendEvent routing.""" +from __future__ import annotations + +import asyncio +from typing import Any, AsyncIterator + +import pytest + +from leapflow.gateway.connectors.lark_event_source import BotIdentity +from leapflow.gateway.connectors.protocol import BackendEvent, EventSourceStatus +from leapflow.gateway.normalizers.feishu import FeishuEventNormalizer +from leapflow.gateway.protocol import InboundMessage +from leapflow.gateway.server import GatewayServer +from leapflow.gateway.trigger_policy import TriggerMode, TriggerPolicy + + +class FakeEventSource: + """Fake BackendEventSource that yields pre-configured events.""" + + platform_id = "feishu" + backend_kind = "fake" + + def __init__(self, events: list[BackendEvent]) -> None: + self._events = list(events) + self._started = False + + async def start(self, *, checkpoint: str = "") -> EventSourceStatus: + self._started = True + return EventSourceStatus(ok=True, backend_kind=self.backend_kind) + + async def stop(self) -> EventSourceStatus: + self._started = False + return EventSourceStatus(ok=True, backend_kind=self.backend_kind) + + async def events(self) -> AsyncIterator[BackendEvent]: + for event in self._events: + yield event + + async def status(self) -> EventSourceStatus: + return EventSourceStatus(ok=self._started, backend_kind=self.backend_kind) + + +class FakeAdapter: + """Minimal adapter for consumer loop tests.""" + + platform_id = "feishu" + supports_async_delivery = True + splits_long_messages = False + max_message_length = 4000 + + def __init__( + self, + event_source: FakeEventSource, + bot_identity: BotIdentity | None = None, + ) -> None: + self._event_source = event_source + self.bot_identity = bot_identity or BotIdentity() + self.on_message = None + + def event_source(self) -> FakeEventSource: + return self._event_source + + async def connect(self, *, is_reconnect: bool = False) -> None: + pass + + async def disconnect(self) -> None: + pass + + async def send(self, target: Any, content: Any) -> Any: + pass + + +def _make_feishu_event( + sender_id: str = "ou_user1", + content: str = "hello", + event_id: str = "ev_1", + chat_type: str = "group", +) -> BackendEvent: + return BackendEvent( + event_id=event_id, + event_type="im.message.receive_v1", + platform_id="feishu", + payload={ + "type": "im.message.receive_v1", + "event_id": event_id, + "message_id": f"om_{event_id}", + "chat_id": "oc_chat1", + "chat_type": chat_type, + "message_type": "text", + "sender_id": sender_id, + "content": content, + "create_time": "1720000000000", + }, + ) + + +@pytest.mark.asyncio +async def test_consumer_loop_routes_messages(tmp_path: Any) -> None: + """BackendEvent → normalizer → _on_inbound_message → handler.""" + received: list[InboundMessage] = [] + + async def handler(msg: InboundMessage, session_key: str) -> None: + received.append(msg) + + server = GatewayServer(tmp_path) + server.set_message_handler(handler) + + normalizer = FeishuEventNormalizer(bot_id="ou_bot1") + server.register_normalizer("feishu", normalizer) + server.register_trigger_policy("feishu", TriggerPolicy(mode=TriggerMode.ALL)) + + source = FakeEventSource([_make_feishu_event()]) + adapter = FakeAdapter(source) + server._adapters["feishu"] = adapter + + result = await server.start_platform_events("feishu") + assert result["ok"] is True + + await asyncio.sleep(0.1) + await server.stop_platform_events("feishu") + + assert len(received) == 1 + assert received[0].text == "hello" + assert received[0].source.platform == "feishu" + + +@pytest.mark.asyncio +async def test_consumer_loop_filters_self_messages(tmp_path: Any) -> None: + """Self-message → IGNORED → not routed to handler.""" + received: list[InboundMessage] = [] + + async def handler(msg: InboundMessage, session_key: str) -> None: + received.append(msg) + + server = GatewayServer(tmp_path) + server.set_message_handler(handler) + + normalizer = FeishuEventNormalizer(bot_id="ou_bot1") + server.register_normalizer("feishu", normalizer) + server.register_trigger_policy("feishu", TriggerPolicy(mode=TriggerMode.ALL)) + + source = FakeEventSource([_make_feishu_event(sender_id="ou_bot1")]) + adapter = FakeAdapter(source) + server._adapters["feishu"] = adapter + + await server.start_platform_events("feishu") + await asyncio.sleep(0.1) + await server.stop_platform_events("feishu") + + assert len(received) == 0 + + +@pytest.mark.asyncio +async def test_trigger_policy_mention_only(tmp_path: Any) -> None: + """Non-mention messages in mention_only mode → not routed to handler.""" + received: list[InboundMessage] = [] + emitted_events: list[object] = [] + + async def handler(msg: InboundMessage, session_key: str) -> None: + received.append(msg) + + async def on_event(event: object) -> None: + emitted_events.append(event) + + server = GatewayServer(tmp_path, on_event=on_event) + server.set_message_handler(handler) + + normalizer = FeishuEventNormalizer(bot_id="ou_bot1") + server.register_normalizer("feishu", normalizer) + server.register_trigger_policy("feishu", TriggerPolicy(mode=TriggerMode.MENTION_ONLY)) + + source = FakeEventSource([ + _make_feishu_event(content="regular message in group"), + ]) + adapter = FakeAdapter(source) + server._adapters["feishu"] = adapter + + await server.start_platform_events("feishu") + await asyncio.sleep(0.1) + await server.stop_platform_events("feishu") + + assert len(received) == 0 + assert len(emitted_events) > 0 + + +@pytest.mark.asyncio +async def test_trigger_policy_dm_activates_in_mention_only(tmp_path: Any) -> None: + """DM messages activate even in mention_only mode.""" + received: list[InboundMessage] = [] + + async def handler(msg: InboundMessage, session_key: str) -> None: + received.append(msg) + + server = GatewayServer(tmp_path) + server.set_message_handler(handler) + + normalizer = FeishuEventNormalizer(bot_id="ou_bot1") + server.register_normalizer("feishu", normalizer) + server.register_trigger_policy("feishu", TriggerPolicy(mode=TriggerMode.MENTION_ONLY)) + + source = FakeEventSource([_make_feishu_event(chat_type="p2p")]) + adapter = FakeAdapter(source) + server._adapters["feishu"] = adapter + + await server.start_platform_events("feishu") + await asyncio.sleep(0.1) + await server.stop_platform_events("feishu") + + assert len(received) == 1 + + +@pytest.mark.asyncio +async def test_consumer_task_cancelled_on_stop(tmp_path: Any) -> None: + """stop_platform_events cancels the consumer task cleanly.""" + server = GatewayServer(tmp_path) + + normalizer = FeishuEventNormalizer(bot_id="ou_bot1") + server.register_normalizer("feishu", normalizer) + server.register_trigger_policy("feishu", TriggerPolicy(mode=TriggerMode.ALL)) + + source = FakeEventSource([_make_feishu_event()]) + adapter = FakeAdapter(source) + server._adapters["feishu"] = adapter + + await server.start_platform_events("feishu") + assert "feishu" in server._consumer_tasks + + await server.stop_platform_events("feishu") + assert "feishu" not in server._consumer_tasks + + +@pytest.mark.asyncio +async def test_signal_events_emitted(tmp_path: Any) -> None: + """Signal events (reactions) are emitted but not routed to handler.""" + received: list[InboundMessage] = [] + emitted: list[object] = [] + + async def handler(msg: InboundMessage, session_key: str) -> None: + received.append(msg) + + async def on_event(event: object) -> None: + emitted.append(event) + + server = GatewayServer(tmp_path, on_event=on_event) + server.set_message_handler(handler) + + normalizer = FeishuEventNormalizer(bot_id="ou_bot1") + server.register_normalizer("feishu", normalizer) + server.register_trigger_policy("feishu", TriggerPolicy(mode=TriggerMode.ALL)) + + reaction_event = BackendEvent( + event_id="ev_r1", + event_type="im.message.reaction.created_v1", + platform_id="feishu", + payload={"sender_id": "ou_user1"}, + ) + source = FakeEventSource([reaction_event]) + adapter = FakeAdapter(source) + server._adapters["feishu"] = adapter + + await server.start_platform_events("feishu") + await asyncio.sleep(0.1) + await server.stop_platform_events("feishu") + + assert len(received) == 0 + assert any(isinstance(e, BackendEvent) for e in emitted) + + +@pytest.mark.asyncio +async def test_multiple_events_in_sequence(tmp_path: Any) -> None: + """Multiple valid messages — first passes; rest hit cooldown.""" + received: list[InboundMessage] = [] + + async def handler(msg: InboundMessage, session_key: str) -> None: + received.append(msg) + + server = GatewayServer(tmp_path) + server.set_message_handler(handler) + + normalizer = FeishuEventNormalizer(bot_id="ou_bot1") + server.register_normalizer("feishu", normalizer) + server.register_trigger_policy("feishu", TriggerPolicy(mode=TriggerMode.ALL)) + + events = [ + _make_feishu_event(event_id="ev_1", content="msg1"), + _make_feishu_event(event_id="ev_2", content="msg2"), + _make_feishu_event(event_id="ev_3", content="msg3"), + ] + source = FakeEventSource(events) + adapter = FakeAdapter(source) + server._adapters["feishu"] = adapter + + await server.start_platform_events("feishu") + await asyncio.sleep(0.2) + await server.stop_platform_events("feishu") + + assert len(received) >= 1 + assert received[0].text == "msg1" + + +@pytest.mark.asyncio +async def test_all_events_pass_with_zero_cooldown(tmp_path: Any) -> None: + """With cooldown=0 all rapid events from the same chat are routed.""" + received: list[InboundMessage] = [] + + async def handler(msg: InboundMessage, session_key: str) -> None: + received.append(msg) + + server = GatewayServer(tmp_path) + server.set_message_handler(handler) + + normalizer = FeishuEventNormalizer(bot_id="ou_bot1") + server.register_normalizer("feishu", normalizer) + server.register_trigger_policy( + "feishu", + TriggerPolicy(mode=TriggerMode.ALL, cooldown_per_chat_s=0), + ) + + events = [ + _make_feishu_event(event_id="ev_1", content="msg1"), + _make_feishu_event(event_id="ev_2", content="msg2"), + _make_feishu_event(event_id="ev_3", content="msg3"), + ] + source = FakeEventSource(events) + adapter = FakeAdapter(source) + server._adapters["feishu"] = adapter + + await server.start_platform_events("feishu") + await asyncio.sleep(0.2) + await server.stop_platform_events("feishu") + + assert len(received) == 3 + assert [m.text for m in received] == ["msg1", "msg2", "msg3"] + + +# ── Text chunking and formatting tests ──────────────────────── + + +def test_chunk_text_short_message() -> None: + """Short messages are returned as a single chunk.""" + from leapflow.gateway.server import _chunk_text + + assert _chunk_text("hello", 100) == ["hello"] + assert _chunk_text("", 100) == [] + + +def test_chunk_text_long_message() -> None: + """Long messages are split at paragraph boundaries.""" + from leapflow.gateway.server import _chunk_text + + text = "Paragraph one.\n\nParagraph two.\n\nParagraph three." + chunks = _chunk_text(text, 30) + assert len(chunks) >= 2 + assert "".join(chunks) == text.replace("\n\n", "") # stripped whitespace + + +def test_chunk_text_respects_max_len() -> None: + """Every chunk respects max_len boundary.""" + from leapflow.gateway.server import _chunk_text + + text = "A" * 500 + chunks = _chunk_text(text, 100) + assert all(len(c) <= 100 for c in chunks) + assert "".join(chunks) == text + + +def test_has_rich_formatting_detects_code() -> None: + """Code blocks and inline code trigger rich format detection.""" + from leapflow.gateway.server import _has_rich_formatting + + assert _has_rich_formatting("```python\nprint(1)\n```") is True + assert _has_rich_formatting("Use `foo` and **bar** to do things") is True + assert _has_rich_formatting("just plain text here") is False + + +def test_has_rich_formatting_detects_headers_and_lists() -> None: + """Headers + lists trigger rich format detection.""" + from leapflow.gateway.server import _has_rich_formatting + + md = "# Title\n\n- item one\n- item two" + assert _has_rich_formatting(md) is True diff --git a/tests/test_gateway_tool_e2e.py b/tests/test_gateway_tool_e2e.py index 8f32418..926a749 100644 --- a/tests/test_gateway_tool_e2e.py +++ b/tests/test_gateway_tool_e2e.py @@ -1,10 +1,11 @@ from __future__ import annotations +import asyncio from types import SimpleNamespace import pytest -from leapflow.gateway.connectors.protocol import ActionPreview, ActionResult, ActionSpec, BackendKind +from leapflow.gateway.connectors.protocol import ActionFailure, ActionPreview, ActionResult, ActionSpec, BackendKind from leapflow.gateway.protocol import OutboundContent, SendResult, SendTarget from leapflow.gateway.server import GatewayServer from leapflow.tools.gateway_tool import ( @@ -51,7 +52,12 @@ async def test_app_slash_payloads_reuse_platform_connect_and_manifests(tmp_path) assert status["result"]["platform"] == "feishu" assert actions["ok"] is True assert set(actions["actions"]) == { - "im.send_message", "im.list_chats", "im.search_chats", + "im.send_message", "im.reply_message", "im.update_message", + "im.add_reaction", "im.remove_reaction", "im.update_card", + "im.download_resource", + "im.list_chats", "im.search_chats", + "im.list_messages", "im.get_messages", "im.search_messages", + "im.list_thread_messages", "docs.create_markdown", "calendar.create_event", "drive.search", "sheets.append_row", "mail.search_unread", "task.create", } @@ -388,6 +394,161 @@ class Result: return Result() +class PermissionFailingAdapter(FakeSendAdapter): + def __init__(self) -> None: + super().__init__() + self.read_spec = ActionSpec( + name="im.list_messages", + backend_kind=BackendKind.CLI.value, + effect="read", + capability="im.message.read", + schema={ + "type": "object", + "required": ["chat_id"], + "properties": {"chat_id": {"type": "string"}}, + }, + risk_level="low", + output_policy="raw", + ) + self.execute_calls: list[str] = [] + + def action_spec(self, action: str) -> ActionSpec | None: + if action == self.spec.name: + return self.spec + if action == self.read_spec.name: + return self.read_spec + return None + + def action_specs(self) -> dict[str, ActionSpec]: + return {self.spec.name: self.spec, self.read_spec.name: self.read_spec} + + async def preview_action(self, action: str, payload: dict) -> ActionPreview: + spec = self.action_spec(action) + if spec is None: + return ActionPreview(ok=False, error="unknown action") + return ActionPreview(ok=True, summary=f"preview {action}") + + async def execute_action(self, action: str, payload: dict) -> ActionResult: + self.execute_calls.append(action) + if action == "im.list_messages": + failure = ActionFailure( + failure_class="authorization", + failure_code="missing_scope", + message="access denied for this operation", + recoverability="admin_required", + retryable=False, + missing_scopes=("im:message.group_msg",), + capability="im.message.read", + blocks_approval=True, + ) + return ActionResult( + ok=False, + error="access denied for this operation", + failure=failure, + ) + return await super().execute_action(action, payload) + + +@pytest.mark.asyncio +async def test_platform_action_blocks_known_permission_failure_before_approval(tmp_path) -> None: + """Auth failure degrades side-effects while read actions can revalidate.""" + server = GatewayServer(tmp_path) + adapter = PermissionFailingAdapter() + gate = CaptureGate() + server._adapters["fake"] = adapter + set_gateway_server(server) + set_gateway_approval_gate(gate) + + try: + first = await platform_action_handler({ + "platform": "fake", + "action": "im.list_messages", + "payload": {"chat_id": "chat-1"}, + }) + second = await platform_action_handler({ + "platform": "fake", + "action": "im.list_messages", + "payload": {"chat_id": "chat-1"}, + }) + send = await platform_action_handler({ + "platform": "fake", + "action": "im.send_message", + "payload": {"chat_id": "chat-1", "text": "should be blocked"}, + }) + finally: + set_gateway_approval_gate(None) + set_gateway_server(None) + + # First call executes and fails with auth error + assert first["ok"] is False + assert first["failure_class"] == "authorization" + assert first["blocks_approval"] is True + # Read calls are allowed to revalidate and therefore both reach execution. + assert len(gate.actions) == 2 + assert adapter.execute_calls == ["im.list_messages", "im.list_messages"] + + # Second call still reports the fresh backend failure. + assert second["ok"] is False + assert second["failure_class"] == "authorization" + assert second["failure_code"] == "missing_scope" + assert second["capability"] == "im.message.read" + + # Send blocked by platform degradation (no execution, no approval) + assert send["ok"] is False + assert send["failure_code"] == "platform_degraded" + assert "llm_instruction" in send + + +class RecoveringPermissionAdapter(PermissionFailingAdapter): + def __init__(self) -> None: + super().__init__() + self.fail_next_read = True + + async def execute_action(self, action: str, payload: dict) -> ActionResult: + if action == "im.list_messages" and self.fail_next_read: + self.fail_next_read = False + return await super().execute_action(action, payload) + self.execute_calls.append(action) + return ActionResult(ok=True, resource_id=f"ok_{len(self.execute_calls)}", data=dict(payload)) + + +@pytest.mark.asyncio +async def test_platform_action_success_revalidates_and_clears_permission_failure(tmp_path) -> None: + server = GatewayServer(tmp_path) + adapter = RecoveringPermissionAdapter() + gate = CaptureGate() + server._adapters["fake"] = adapter + set_gateway_server(server) + set_gateway_approval_gate(gate) + + try: + first = await platform_action_handler({ + "platform": "fake", + "action": "im.list_messages", + "payload": {"chat_id": "chat-1"}, + }) + second = await platform_action_handler({ + "platform": "fake", + "action": "im.list_messages", + "payload": {"chat_id": "chat-1"}, + }) + send = await platform_action_handler({ + "platform": "fake", + "action": "im.send_message", + "payload": {"chat_id": "chat-1", "text": "allowed after revalidation"}, + }) + finally: + set_gateway_approval_gate(None) + set_gateway_server(None) + + assert first["ok"] is False + assert first["failure_class"] == "authorization" + assert second["ok"] is True + assert send["ok"] is True + assert send.get("failure_code") != "platform_degraded" + assert adapter.execute_calls == ["im.list_messages", "im.list_messages", "im.send_message"] + + @pytest.mark.asyncio async def test_platform_action_uses_registered_spec_for_approval_and_summary(tmp_path) -> None: server = GatewayServer(tmp_path) @@ -449,8 +610,7 @@ async def execute(self, spec, payload): assert status["ok"] is False assert started["ok"] is False assert status["metadata"]["available"] is False - assert status["metadata"]["configuration_hint"] - assert "inbound events are not enabled" in status["detail"] + assert "not enabled" in status["detail"].lower() @pytest.mark.asyncio @@ -651,3 +811,167 @@ async def test_platform_action_honors_approval_denial(tmp_path) -> None: assert result == {"ok": False, "error": "denied for test"} assert adapter.sent == [] + + +@pytest.mark.asyncio +async def test_gateway_send_reply_returns_success_for_empty_text(tmp_path) -> None: + from leapflow.gateway.protocol import MessageSource + + server = GatewayServer(tmp_path) + adapter = FakeSendAdapter() + server._adapters["fake"] = adapter + + result = await server.send_reply(MessageSource(platform="fake", chat_id="chat-1"), "") + + assert result is not None + assert result.ok is True + assert adapter.sent == [] + + +@pytest.mark.asyncio +async def test_gateway_consumer_saves_checkpoint_and_dedup_on_source_crash(tmp_path) -> None: + from leapflow.gateway.connectors.protocol import EventSourceStatus + + class CrashingSource: + platform_id = "fake" + backend_kind = "test" + + async def start(self, *, checkpoint: str = "") -> EventSourceStatus: + return EventSourceStatus(ok=True, backend_kind=self.backend_kind) + + async def stop(self) -> EventSourceStatus: + return EventSourceStatus(ok=True, backend_kind=self.backend_kind) + + async def status(self) -> EventSourceStatus: + return EventSourceStatus(ok=True, backend_kind=self.backend_kind) + + async def events(self): + raise RuntimeError("source crashed") + if False: + yield None + + server = GatewayServer(tmp_path) + saved: list[str] = [] + server._save_checkpoint = lambda platform_id: saved.append(f"checkpoint:{platform_id}") # type: ignore[method-assign] + server._save_dedup_state = lambda platform_id: saved.append(f"dedup:{platform_id}") # type: ignore[method-assign] + + await server._consume_platform_events("fake", CrashingSource()) # type: ignore[arg-type] + + assert saved == ["checkpoint:fake", "dedup:fake"] + + +@pytest.mark.asyncio +async def test_composite_event_source_cleans_child_tasks_on_cancel() -> None: + from leapflow.gateway.connectors.composite_event_source import CompositeEventSource + from leapflow.gateway.connectors.protocol import EventSourceStatus + + class HangingSource: + platform_id = "fake" + backend_kind = "test" + + def __init__(self) -> None: + self.cancelled = False + + async def start(self, *, checkpoint: str = "") -> EventSourceStatus: + return EventSourceStatus(ok=True, backend_kind=self.backend_kind) + + async def stop(self) -> EventSourceStatus: + return EventSourceStatus(ok=True, backend_kind=self.backend_kind) + + async def status(self) -> EventSourceStatus: + return EventSourceStatus(ok=True, backend_kind=self.backend_kind) + + async def events(self): + try: + await asyncio.Event().wait() + finally: + self.cancelled = True + if False: + yield None + + source = HangingSource() + composite = CompositeEventSource([source], platform_id="fake") + await composite.start() + iterator = composite.events() + task = asyncio.create_task(iterator.__anext__()) + await asyncio.sleep(0) + + task.cancel() + try: + await task + except (asyncio.CancelledError, StopAsyncIteration): + pass + + assert source.cancelled is True + assert composite._tasks == [] + + +@pytest.mark.asyncio +async def test_lark_event_source_kills_identity_subprocess_on_timeout(monkeypatch) -> None: + import leapflow.gateway.connectors.lark_event_source as lark_event_source + + class FakeProcess: + def __init__(self) -> None: + self.returncode = None + self.killed = False + self.waited = False + + async def communicate(self): + return b"{}", b"" + + def kill(self) -> None: + self.killed = True + self.returncode = -9 + + async def wait(self) -> None: + self.waited = True + + proc = FakeProcess() + + async def fake_create_subprocess_exec(*args, **kwargs): + return proc + + async def fake_wait_for(awaitable, timeout): + awaitable.close() + raise asyncio.TimeoutError + + monkeypatch.setattr(lark_event_source.asyncio, "create_subprocess_exec", fake_create_subprocess_exec) + monkeypatch.setattr(lark_event_source.asyncio, "wait_for", fake_wait_for) + + source = lark_event_source.LarkCliEventSource(binary="lark-cli", profile="default") + identity = await source.fetch_bot_identity() + + assert identity.open_id == "" + assert proc.killed is True + assert proc.waited is True + + +def test_duckdb_deduplication_store_uses_batch_insert() -> None: + from leapflow.gateway.checkpoint_store import DuckDBDeduplicationStore + + class FakeConnection: + def __init__(self) -> None: + self.executed: list[tuple[str, object]] = [] + self.executemany_calls: list[tuple[str, list[tuple[str, str, float]]]] = [] + + def execute(self, sql, params=None): + self.executed.append((str(sql), params)) + return self + + def executemany(self, sql, rows): + self.executemany_calls.append((str(sql), list(rows))) + return self + + class Holder: + def __init__(self) -> None: + self.connection = FakeConnection() + + holder = Holder() + store = DuckDBDeduplicationStore(holder) + store.save_batch("feishu", [f"event-{i}" for i in range(1005)]) + + assert len(holder.connection.executemany_calls) == 1 + _, rows = holder.connection.executemany_calls[0] + assert len(rows) == 1000 + assert rows[0][1] == "event-5" + assert rows[-1][1] == "event-1004" diff --git a/tests/test_safety_and_policy.py b/tests/test_safety_and_policy.py index 81debf8..99475ae 100644 --- a/tests/test_safety_and_policy.py +++ b/tests/test_safety_and_policy.py @@ -253,3 +253,55 @@ async def connect_platform(platform_id, credentials, options=None, *, is_reconne monkeypatch.setattr(server, "connect_platform", connect_platform) assert await server.start() == 1 + + +@pytest.mark.asyncio +async def test_file_read_gate_supports_legacy_two_argument_check(tmp_path) -> None: + from leapflow.tools import registry_bootstrap + from leapflow.tools.file_operations import file_read + + class LegacyReadGate: + def __init__(self) -> None: + self.calls: list[tuple[str, str]] = [] + + async def check(self, path: str, mode: str) -> bool: + self.calls.append((path, mode)) + return True + + target = tmp_path / ".env" + target.write_text("SECRET=value", encoding="utf-8") + gate = LegacyReadGate() + registry_bootstrap.set_file_read_gate(gate) + try: + result = await file_read({"path": str(target), "mode": "raw"}) + finally: + registry_bootstrap.set_file_read_gate(None) + + assert result["ok"] is True + assert gate.calls == [(str(target.resolve()), "raw")] + + +@pytest.mark.asyncio +async def test_file_write_gate_supports_legacy_three_argument_check(tmp_path) -> None: + from leapflow.tools import registry_bootstrap + from leapflow.tools.file_operations import file_write + + class LegacyWriteGate: + def __init__(self) -> None: + self.calls: list[tuple[str, str, str]] = [] + + async def check(self, path: str, content: str, mode: str) -> bool: + self.calls.append((path, content, mode)) + return True + + target = tmp_path / ".env" + gate = LegacyWriteGate() + registry_bootstrap.set_file_write_gate(gate) + try: + result = await file_write({"path": str(target), "content": "SECRET=value", "mode": "overwrite"}) + finally: + registry_bootstrap.set_file_write_gate(None) + + assert result["ok"] is True + assert target.read_text(encoding="utf-8") == "SECRET=value" + assert gate.calls == [(str(target.resolve()), "SECRET=value", "overwrite")] diff --git a/tests/test_slash_command_router.py b/tests/test_slash_command_router.py index 1fccac9..72ff43b 100644 --- a/tests/test_slash_command_router.py +++ b/tests/test_slash_command_router.py @@ -16,43 +16,42 @@ def test_command_router_parses_command_args_and_runtime_support() -> None: assert router.unsupported_result(invocation) is None -def test_command_router_parses_app_subcommands_without_daemon_parity() -> None: - in_process_router = CommandRouter("in_process") +def test_command_router_all_commands_supported_in_daemon() -> None: + """All commands are now supported in both runtimes.""" daemon_router = CommandRouter("daemon") - invocation = in_process_router.parse("/app status feishu") - daemon_invocation = daemon_router.parse("/app connect feishu") - - assert invocation is not None - assert invocation.command.name == "app status" - assert invocation.args == "feishu" - assert in_process_router.unsupported_result(invocation) is None - assert daemon_invocation is not None - assert daemon_invocation.command.name == "app connect" - unsupported = daemon_router.unsupported_result(daemon_invocation) - assert unsupported is not None - assert "/app connect" in unsupported.title - - -def test_app_commands_daemon_parity_boundary() -> None: - """Read-only /app commands are available in daemon; write commands are not.""" - daemon_router = CommandRouter("daemon") - - # Commands that MUST be supported in daemon mode - for cmd_text in ("/app", "/app list", "/app status feishu", "/app actions feishu"): + for cmd_text in ( + "/app status feishu", + "/app connect feishu", + "/teach start", + "/skills show demo", + "/hub search test", + "/gateway", + "/arm test_skill 0 * * * *", + "/tasks", + ): inv = daemon_router.parse(cmd_text) assert inv is not None, f"parse failed for {cmd_text}" assert daemon_router.unsupported_result(inv) is None, ( f"{cmd_text} should be supported in daemon mode" ) - # Commands that must NOT be supported in daemon mode - for cmd_text in ("/app connect feishu", "/app disconnect feishu", "/app remove feishu"): - inv = daemon_router.parse(cmd_text) + +def test_command_router_client_local_commands() -> None: + """Client-local commands are marked for direct TUI handling.""" + router = CommandRouter("daemon") + + # Client-local commands + for cmd_text in ("/exit", "/clear", "/help", "/cancel", "/pause", "/resume", "/queue"): + inv = router.parse(cmd_text) assert inv is not None, f"parse failed for {cmd_text}" - unsupported = daemon_router.unsupported_result(inv) - assert unsupported is not None, f"{cmd_text} should be blocked in daemon mode" - assert unsupported.ok is False + assert inv.command.client_local is True, f"{cmd_text} should be client_local" + + # Engine-routed commands + for cmd_text in ("/teach start", "/skills", "/gateway", "/tools", "/model"): + inv = router.parse(cmd_text) + assert inv is not None, f"parse failed for {cmd_text}" + assert inv.command.client_local is False, f"{cmd_text} should NOT be client_local" def test_app_commands_are_registered_for_completion() -> None: @@ -74,14 +73,12 @@ def test_interactive_app_command_boundary_rejects_prefix_collisions() -> None: assert _is_app_command("application status") is False -def test_command_router_returns_standard_unsupported_result() -> None: +def test_command_router_unsupported_always_returns_none() -> None: + """unsupported_result always returns None — all commands are supported.""" router = CommandRouter("daemon") invocation = router.parse("/skills show demo") assert invocation is not None assert invocation.command.name == "skills show" - result = router.unsupported_result(invocation) - assert result is not None - assert result.ok is False - assert "daemon" in result.title + assert router.unsupported_result(invocation) is None diff --git a/tests/test_trigger_policy.py b/tests/test_trigger_policy.py new file mode 100644 index 0000000..64b42f1 --- /dev/null +++ b/tests/test_trigger_policy.py @@ -0,0 +1,141 @@ +"""Tests for TriggerPolicy — inbound message gating.""" +from __future__ import annotations + +import pytest + +from leapflow.gateway.protocol import InboundMessage, MessageSource +from leapflow.gateway.trigger_policy import TriggerMode, TriggerPolicy, _RateTracker + + +def _msg( + text: str = "hello", + chat_type: str = "group", + chat_id: str = "oc_1", + user_id: str = "ou_1", + metadata: dict | None = None, +) -> InboundMessage: + return InboundMessage( + source=MessageSource( + platform="feishu", + chat_id=chat_id, + chat_type=chat_type, + user_id=user_id, + ), + text=text, + message_id="om_1", + metadata=metadata or {}, + ) + + +def test_mention_only_group_no_mention() -> None: + """Group message without mention → not activated.""" + policy = TriggerPolicy(mode=TriggerMode.MENTION_ONLY) + assert policy.should_activate(_msg(text="regular message")) is False + + +def test_mention_only_dm_always_activates() -> None: + """DM messages always activate in mention_only mode.""" + policy = TriggerPolicy(mode=TriggerMode.MENTION_ONLY) + assert policy.should_activate(_msg(chat_type="dm")) is True + assert policy.should_activate(_msg(chat_type="p2p")) is True + assert policy.should_activate(_msg(chat_type="private")) is True + + +def test_mention_only_with_bot_mentioned_metadata() -> None: + """Message with bot_mentioned metadata flag activates in mention_only.""" + policy = TriggerPolicy(mode=TriggerMode.MENTION_ONLY) + assert policy.should_activate( + _msg(text="@LeapBot help", metadata={"bot_mentioned": True}), + ) is True + assert policy.should_activate(_msg(text="hello there")) is False + + +def test_mention_only_with_metadata_flag() -> None: + """Message with bot_mentioned metadata activates.""" + policy = TriggerPolicy(mode=TriggerMode.MENTION_ONLY) + assert policy.should_activate( + _msg(metadata={"bot_mentioned": True}), + ) is True + + +def test_all_mode_always_activates() -> None: + """All mode activates for every message.""" + policy = TriggerPolicy(mode=TriggerMode.ALL) + assert policy.should_activate(_msg()) is True + + +def test_manual_mode_never_activates() -> None: + """Manual mode never activates.""" + policy = TriggerPolicy(mode=TriggerMode.MANUAL) + assert policy.should_activate(_msg()) is False + assert policy.should_activate(_msg(chat_type="dm")) is False + + +def test_keyword_mode() -> None: + """Keyword mode matches case-insensitively.""" + policy = TriggerPolicy(mode=TriggerMode.KEYWORD, keywords=("help", "urgent")) + assert policy.should_activate(_msg(text="I need HELP")) is True + assert policy.should_activate(_msg(text="this is urgent please")) is True + assert policy.should_activate(_msg(text="just chatting")) is False + + +def test_blocked_chats() -> None: + """Messages from blocked chats are rejected.""" + policy = TriggerPolicy(mode=TriggerMode.ALL, blocked_chats=frozenset({"oc_blocked"})) + assert policy.should_activate(_msg(chat_id="oc_blocked")) is False + assert policy.should_activate(_msg(chat_id="oc_allowed")) is True + + +def test_blocked_users() -> None: + """Messages from blocked users are rejected.""" + policy = TriggerPolicy(mode=TriggerMode.ALL, blocked_users=frozenset({"ou_bad"})) + assert policy.should_activate(_msg(user_id="ou_bad")) is False + assert policy.should_activate(_msg(user_id="ou_good")) is True + + +def test_allowed_chats_whitelist() -> None: + """When allowed_chats is set, only those chats pass.""" + policy = TriggerPolicy(mode=TriggerMode.ALL, allowed_chats=frozenset({"oc_vip"})) + assert policy.should_activate(_msg(chat_id="oc_vip")) is True + assert policy.should_activate(_msg(chat_id="oc_other")) is False + + +def test_allowed_users_whitelist() -> None: + """When allowed_users is set, only those users pass.""" + policy = TriggerPolicy(mode=TriggerMode.ALL, allowed_users=frozenset({"ou_vip"})) + assert policy.should_activate(_msg(user_id="ou_vip")) is True + assert policy.should_activate(_msg(user_id="ou_other")) is False + + +def test_allowed_users_empty_means_all_allowed() -> None: + """Empty allowed_users means no user filtering.""" + policy = TriggerPolicy(mode=TriggerMode.ALL, allowed_users=frozenset()) + assert policy.should_activate(_msg(user_id="ou_anyone")) is True + + +def test_string_mode_via_enum_value() -> None: + """TriggerMode can be created from string value.""" + assert TriggerMode("mention_only") is TriggerMode.MENTION_ONLY + assert TriggerMode("all") is TriggerMode.ALL + assert TriggerMode("keyword") is TriggerMode.KEYWORD + assert TriggerMode("manual") is TriggerMode.MANUAL + + +def test_invalid_mode_raises() -> None: + """Invalid mode string raises ValueError.""" + with pytest.raises(ValueError): + TriggerMode("invalid_mode") + + +def test_rate_tracker_cooldown() -> None: + """Rate tracker enforces per-chat cooldown.""" + tracker = _RateTracker() + assert tracker.allow("chat1", max_per_minute=100, cooldown_s=1.0) is True + assert tracker.allow("chat1", max_per_minute=100, cooldown_s=1.0) is False + + +def test_rate_tracker_separate_chats() -> None: + """Rate limits are per-chat, not global.""" + tracker = _RateTracker() + assert tracker.allow("chat1", max_per_minute=100, cooldown_s=1.0) is True + assert tracker.allow("chat2", max_per_minute=100, cooldown_s=1.0) is True diff --git a/tests/test_tui_command_queue.py b/tests/test_tui_command_queue.py index 5952938..e1b2f2e 100644 --- a/tests/test_tui_command_queue.py +++ b/tests/test_tui_command_queue.py @@ -16,7 +16,7 @@ from leapflow.cli.tui_app.console import LeapConsole from leapflow.cli.tui_app.command import TuiCommand, TuiCommandStatus from leapflow.cli.tui_app.input import SlashCommandCompleter -from leapflow.cli.tui_app.stream import StreamRenderer +from leapflow.cli.tui_app.stream import StreamRenderer, _sanitize_final_response from leapflow.cli.tui_app.theme import _LIGHT, resolve_theme @@ -419,6 +419,52 @@ def print(self, renderable) -> None: assert "summarize the current project layout" in body +def test_response_label_merges_done_command_status(monkeypatch) -> None: + class CapturingConsole: + width = 100 + + def __init__(self) -> None: + self.rendered = [] + + def print(self, renderable) -> None: + self.rendered.append(renderable) + + capture = CapturingConsole() + leap_console = LeapConsole(resolve_theme(_LIGHT, terminal_bg="#FFFFFF")) + monkeypatch.setattr(leap_console, "_console", capture) + + command = TuiCommand.create(command_id=1, text="连接飞书并查看可用的群组") + command = command.mark_running().mark_done() + + leap_console.response_label(16.5, tool_count=3, command=command) + + assert len(capture.rendered) == 1 + assert capture.rendered[0].plain == " |-- LEAP #1 done 16.5s 3 tools" + + +def test_response_label_merges_blocked_command_status(monkeypatch) -> None: + class CapturingConsole: + width = 100 + + def __init__(self) -> None: + self.rendered = [] + + def print(self, renderable) -> None: + self.rendered.append(renderable) + + capture = CapturingConsole() + leap_console = LeapConsole(resolve_theme(_LIGHT, terminal_bg="#FFFFFF")) + monkeypatch.setattr(leap_console, "_console", capture) + + command = TuiCommand.create(command_id=2, text="发送消息到飞书") + command = command.mark_running().mark_blocked("missing_scope: im.message.send") + + leap_console.response_label(9.4, tool_count=1, command=command) + + assert len(capture.rendered) == 1 + assert capture.rendered[0].plain == " |-- LEAP #2 blocked 9.4s 1 tool" + + def test_stream_renderer_prints_tool_command_and_success_preview() -> None: class CaptureConsole: def __init__(self) -> None: @@ -433,7 +479,7 @@ def thinking(self, text: str) -> None: def markdown(self, text: str, *, indent: int = 0, margin_top: int = 0) -> None: pass - def response_label(self, elapsed_s: float, *, tool_count: int = 0) -> None: + def response_label(self, elapsed_s: float, *, tool_count: int = 0, command=None) -> None: pass def newline(self) -> None: @@ -468,7 +514,7 @@ def thinking(self, text: str) -> None: def markdown(self, text: str, *, indent: int = 0, margin_top: int = 0) -> None: pass - def response_label(self, elapsed_s: float, *, tool_count: int = 0) -> None: + def response_label(self, elapsed_s: float, *, tool_count: int = 0, command=None) -> None: pass def newline(self) -> None: @@ -509,7 +555,7 @@ def thinking(self, text: str) -> None: def markdown(self, text: str, *, indent: int = 0, margin_top: int = 0) -> None: pass - def response_label(self, elapsed_s: float, *, tool_count: int = 0) -> None: + def response_label(self, elapsed_s: float, *, tool_count: int = 0, command=None) -> None: pass def newline(self) -> None: @@ -527,7 +573,7 @@ def newline(self) -> None: assert len(console.lines) == 1 line = console.lines[0] - assert "❌ shell_run" in line + assert "✗ shell_run" in line assert "$ false" in line assert "→ exit=1 permission denied" in line assert " | " in line @@ -547,7 +593,7 @@ def thinking(self, text: str) -> None: def markdown(self, text: str, *, indent: int = 0, margin_top: int = 0) -> None: pass - def response_label(self, elapsed_s: float, *, tool_count: int = 0) -> None: + def response_label(self, elapsed_s: float, *, tool_count: int = 0, command=None) -> None: pass def newline(self) -> None: @@ -597,7 +643,7 @@ def thinking(self, text: str) -> None: def markdown(self, text: str, *, indent: int = 0, margin_top: int = 0) -> None: pass - def response_label(self, elapsed_s: float, *, tool_count: int = 0) -> None: + def response_label(self, elapsed_s: float, *, tool_count: int = 0, command=None) -> None: pass def newline(self) -> None: @@ -619,8 +665,8 @@ def newline(self) -> None: "compression_reason": "threshold-triggered", "context_posture": "research", "context_guidance": "maintain research ledger and synthesize findings", - "disclosure_level": "selected_tools", - "disclosure_reason": "selected capabilities matched observable task signals", + "disclosure_level": "expanded", + "disclosure_reason": "tier1: continuity(shell)", }, ) @@ -653,7 +699,7 @@ def thinking(self, text: str) -> None: def markdown(self, text: str, *, indent: int = 0, margin_top: int = 0) -> None: pass - def response_label(self, elapsed_s: float, *, tool_count: int = 0) -> None: + def response_label(self, elapsed_s: float, *, tool_count: int = 0, command=None) -> None: pass def newline(self) -> None: @@ -690,6 +736,128 @@ def newline(self) -> None: assert "task requires broad" not in line +def test_final_response_adds_copyable_url_for_markdown_links() -> None: + answer = _sanitize_final_response( + "请打开 [点击申请权限](https://open.feishu.cn/app/cli_xxx/auth) 后继续。" + ) + + assert "[点击申请权限](https://open.feishu.cn/app/cli_xxx/auth)" in answer + assert "复制链接:https://open.feishu.cn/app/cli_xxx/auth" in answer + + +def test_stream_renderer_prints_permission_recovery_card() -> None: + class CaptureConsole: + def __init__(self) -> None: + self.lines: list[str] = [] + self.cards: list[dict[str, object]] = [] + + def print(self, renderable) -> None: + self.lines.append(getattr(renderable, "plain", str(renderable))) + + def permission_recovery_card(self, metadata: dict[str, object]) -> None: + self.cards.append(metadata) + + def thinking(self, text: str) -> None: + pass + + def markdown(self, text: str, *, indent: int = 0, margin_top: int = 0) -> None: + pass + + def response_label(self, elapsed_s: float, *, tool_count: int = 0, command=None) -> None: + pass + + def newline(self) -> None: + pass + + console = CaptureConsole() + renderer = StreamRenderer(console) # type: ignore[arg-type] + renderer.start() + metadata = { + "ok": False, + "platform": "feishu", + "action": "im.list_chats", + "capability": "im.chat.read", + "failure_class": "authorization", + "failure_code": "missing_scope", + "missing_scopes": ["im:chat:read"], + "scope_relation": "all_required", + "scope_source": "authoritative", + "console_url": "https://open.feishu.cn/app/cli_xxx/auth", + "recovery_hint": "Grant the missing scope in the developer console.", + } + + renderer.tool_started("platform_action", metadata={"platform": "feishu", "action": "im.list_chats"}) + renderer.tool_finished("platform_action", metadata=metadata) + + assert len(console.lines) == 1 + assert "✗ platform_action" in console.lines[0] + assert len(console.cards) == 1 + assert console.cards[0]["console_url"] == "https://open.feishu.cn/app/cli_xxx/auth" + assert console.cards[0]["missing_scopes"] == ["im:chat:read"] + assert renderer.permission_blocked is True + assert renderer.permission_block_reason == "missing_scope: im.chat.read" + + +def test_permission_recovery_card_renders_non_none_panel_body(monkeypatch) -> None: + class CapturingConsole: + width = 100 + + def __init__(self) -> None: + self.rendered = [] + + def print(self, renderable) -> None: + self.rendered.append(renderable) + + capture = CapturingConsole() + leap_console = LeapConsole(resolve_theme(_LIGHT, terminal_bg="#FFFFFF")) + monkeypatch.setattr(leap_console, "_console", capture) + + leap_console.permission_recovery_card({ + "ok": False, + "platform": "feishu", + "action": "im.list_chats", + "capability": "im.chat.read", + "failure_class": "authorization", + "failure_code": "access_denied", + "missing_scopes": ["im:chat:read"], + "scope_relation": "all_required", + "scope_source": "authoritative", + }) + + assert len(capture.rendered) == 1 + panel = capture.rendered[0] + assert panel.renderable is not None + assert "im:chat:read" in panel.renderable.plain + + +@pytest.mark.asyncio +async def test_process_loop_does_not_duplicate_done_card_when_response_label_owns_status() -> None: + holder: dict[str, LeapApp] = {} + + async def on_input(_text: str) -> None: + completed = holder["app"].complete_active_command_in_response() + assert completed is not None + assert completed.status is TuiCommandStatus.DONE + + app, console, status = _make_app(on_input=on_input) + holder["app"] = app + app.submit_text("successful command") + + worker = asyncio.create_task(app._process_loop()) + try: + await _wait_for(lambda: app.active_command is None and app._pending_input.qsize() == 0) + finally: + app._should_exit = True + worker.cancel() + with suppress(asyncio.CancelledError): + await worker + + assert [(card.id, card.status) for card in console.cards] == [ + (1, TuiCommandStatus.RUNNING), + ] + assert status.counts[-1] == (0, 0) + + @pytest.mark.asyncio async def test_process_loop_marks_failed_commands_and_recovers_counts() -> None: async def on_input(text: str) -> None: @@ -728,11 +896,12 @@ def test_failed_command_error_is_single_line_and_truncated() -> None: assert failed.error.endswith("…") -def test_cancelled_and_skipped_commands_are_terminal() -> None: +def test_cancelled_skipped_and_blocked_commands_are_terminal() -> None: command = TuiCommand.create(command_id=1, text="long task").mark_running() cancelled = command.mark_cancelled("user pressed cancel") skipped = command.mark_skipped("user skipped") + blocked = command.mark_blocked("missing_scope: im.chat.read") assert cancelled.status is TuiCommandStatus.CANCELLED assert cancelled.error == "user pressed cancel" @@ -740,6 +909,9 @@ def test_cancelled_and_skipped_commands_are_terminal() -> None: assert skipped.status is TuiCommandStatus.SKIPPED assert skipped.error == "user skipped" assert skipped.finished_at > 0 + assert blocked.status is TuiCommandStatus.BLOCKED + assert blocked.error == "missing_scope: im.chat.read" + assert blocked.finished_at > 0 def test_placeholder_processor_indents_hint_after_prompt_space() -> None: diff --git a/tests/test_tui_session_summary.py b/tests/test_tui_session_summary.py index bd480d1..8f02746 100644 --- a/tests/test_tui_session_summary.py +++ b/tests/test_tui_session_summary.py @@ -22,7 +22,7 @@ class _Console: def __init__(self) -> None: self.markdown_calls: list[dict[str, object]] = [] self.thinking_calls: list[str] = [] - self.labels: list[tuple[float, int]] = [] + self.labels: list[tuple[float, int, object | None]] = [] self.answer_labels = 0 self.lines = 0 @@ -47,8 +47,14 @@ def thinking(self, text: str) -> None: def answer_label(self) -> None: self.answer_labels += 1 - def response_label(self, elapsed_s: float, *, tool_count: int = 0) -> None: - self.labels.append((elapsed_s, tool_count)) + def response_label( + self, + elapsed_s: float, + *, + tool_count: int = 0, + command: object | None = None, + ) -> None: + self.labels.append((elapsed_s, tool_count, command)) def newline(self) -> None: self.lines += 1