From c1a104ecd32eb66d32d77fd63201b6171bc8be69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8F=AD=E6=89=AC?= Date: Tue, 14 Jul 2026 11:16:32 +0800 Subject: [PATCH 01/14] =?UTF-8?q?feat(gateway):=20implement=20IM=20bidirec?= =?UTF-8?q?tional=20link=20=E2=80=94=20actions,=20dedup,=20checkpoint,=20s?= =?UTF-8?q?ession=20persistence,=20callbacks,=20normalizers,=20unified=20i?= =?UTF-8?q?nbound?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Close all design gaps for the IM bidirectional message processing architecture: - Add 4 Feishu message reading actions (im.list_messages, im.get_messages, im.search_messages, im.list_thread_messages) to feishu.yaml action pack - Implement EventDeduplicator (LRU-based) in consumer loop for idempotency - Add DuckDB-backed GatewayCheckpointStore for event source resume on restart - Wire ConversationStore into GatewayRouter for persistent session history - Parse card.action.trigger into InboundCallback with full field extraction - Add callback handler pipeline (GatewayServer → GatewayRouter.handle_callback) - Create CompositeEventSource for multi-event-key subscription merging - Update LarkCliEventSource to default-subscribe both im.message.receive_v1 and card.action.trigger via CompositeEventSource - Implement GatewayEventBridge to publish IM signals to EventBus for the learning pipeline (PatternMiner, Copilot) - Add TelegramEventNormalizer and DingTalkEventNormalizer with auto-registration - Create TelegramPollingEventSource and DingTalkWebhookEventSource as BackendEventSource implementations - Unify inbound architecture: remove adapter.on_message direct callback, route all platforms through normalizer → trigger policy consumer loop Co-authored-by: Cursor --- AGENTS.md | 12 +- src/leapflow/cli/context.py | 83 ++++- src/leapflow/gateway/action_packs/feishu.yaml | 97 +++++ src/leapflow/gateway/adapters/dingtalk.py | 25 +- src/leapflow/gateway/adapters/feishu.py | 73 +++- src/leapflow/gateway/adapters/telegram.py | 27 +- src/leapflow/gateway/checkpoint_store.py | 82 +++++ src/leapflow/gateway/connectors/__init__.py | 8 + .../connectors/composite_event_source.py | 143 ++++++++ .../connectors/dingtalk_event_source.py | 118 ++++++ .../gateway/connectors/event_sources.py | 343 ++++++++++++++++++ .../gateway/connectors/lark_event_source.py | 132 +++++++ src/leapflow/gateway/connectors/protocol.py | 68 +++- .../connectors/telegram_event_source.py | 152 ++++++++ src/leapflow/gateway/event_bridge.py | 76 ++++ src/leapflow/gateway/manifests/feishu.yaml | 27 +- src/leapflow/gateway/normalizers/__init__.py | 1 + src/leapflow/gateway/normalizers/dingtalk.py | 141 +++++++ src/leapflow/gateway/normalizers/feishu.py | 233 ++++++++++++ src/leapflow/gateway/normalizers/telegram.py | 143 ++++++++ src/leapflow/gateway/router.py | 119 +++++- src/leapflow/gateway/server.py | 288 ++++++++++++++- src/leapflow/gateway/trigger_policy.py | 117 ++++++ tests/test_cli_ndjson_event_source.py | 265 ++++++++++++++ tests/test_feishu_event_normalizer.py | 226 ++++++++++++ tests/test_gateway_adapters.py | 12 +- tests/test_gateway_consumer_loop.py | 333 +++++++++++++++++ tests/test_gateway_tool_e2e.py | 5 +- tests/test_trigger_policy.py | 128 +++++++ 29 files changed, 3420 insertions(+), 57 deletions(-) create mode 100644 src/leapflow/gateway/checkpoint_store.py create mode 100644 src/leapflow/gateway/connectors/composite_event_source.py create mode 100644 src/leapflow/gateway/connectors/dingtalk_event_source.py create mode 100644 src/leapflow/gateway/connectors/lark_event_source.py create mode 100644 src/leapflow/gateway/connectors/telegram_event_source.py create mode 100644 src/leapflow/gateway/event_bridge.py create mode 100644 src/leapflow/gateway/normalizers/__init__.py create mode 100644 src/leapflow/gateway/normalizers/dingtalk.py create mode 100644 src/leapflow/gateway/normalizers/feishu.py create mode 100644 src/leapflow/gateway/normalizers/telegram.py create mode 100644 src/leapflow/gateway/trigger_policy.py create mode 100644 tests/test_cli_ndjson_event_source.py create mode 100644 tests/test_feishu_event_normalizer.py create mode 100644 tests/test_gateway_consumer_loop.py create mode 100644 tests/test_trigger_policy.py diff --git a/AGENTS.md b/AGENTS.md index 60cee87..a38b7ab 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,9 @@ 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. - **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 +46,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 +55,10 @@ 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. - 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 +86,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 +98,8 @@ 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. - 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/cli/context.py b/src/leapflow/cli/context.py index 1cbecf2..35879df 100644 --- a/src/leapflow/cli/context.py +++ b/src/leapflow/cli/context.py @@ -1521,6 +1521,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 +1531,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,16 +1556,45 @@ 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 + from leapflow.gateway.event_bridge import GatewayEventBridge + + _checkpoint_store = DuckDBCheckpointStore(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, ) 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) @@ -1580,12 +1610,17 @@ 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), ) 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 +1650,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: @@ -2286,6 +2324,47 @@ 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 + gw.register_trigger_policy(platform_id, TriggerPolicy(mode=trigger_mode)) + async def cleanup(self) -> None: # Persist evolution episodes to DuckDB before shutdown evo_store = getattr(self, "_evolution_store", None) diff --git a/src/leapflow/gateway/action_packs/feishu.yaml b/src/leapflow/gateway/action_packs/feishu.yaml index 223f772..27cb730 100644 --- a/src/leapflow/gateway/action_packs/feishu.yaml +++ b/src/leapflow/gateway/action_packs/feishu.yaml @@ -113,6 +113,103 @@ 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 + 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 + 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 + 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 + 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 + # ── Docs ─────────────────────────────────────────────────────────────────── docs.create_markdown: 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..e0810a5 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", }, } 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/checkpoint_store.py b/src/leapflow/gateway/checkpoint_store.py new file mode 100644 index 0000000..4868fc6 --- /dev/null +++ b/src/leapflow/gateway/checkpoint_store.py @@ -0,0 +1,82 @@ +"""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: ... + + +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/composite_event_source.py b/src/leapflow/gateway/connectors/composite_event_source.py new file mode 100644 index 0000000..eb482c4 --- /dev/null +++ b/src/leapflow/gateway/connectors/composite_event_source.py @@ -0,0 +1,143 @@ +"""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 + + 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..5d38f33 --- /dev/null +++ b/src/leapflow/gateway/connectors/lark_event_source.py @@ -0,0 +1,132 @@ +"""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", +) + + +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, + ) + stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=15.0) + 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..65ee0c6 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): @@ -197,3 +202,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..298b68d --- /dev/null +++ b/src/leapflow/gateway/normalizers/dingtalk.py @@ -0,0 +1,141 @@ +"""DingTalk event normalizer. + +Maps DingTalk webhook/stream callback 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, MessageSource + + +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 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 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..11ac71a --- /dev/null +++ b/src/leapflow/gateway/normalizers/feishu.py @@ -0,0 +1,233 @@ +"""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 time +from typing import Any, Mapping + +from leapflow.gateway.connectors.protocol import ( + BackendEvent, + EventClassification, + EventKind, + InboundCallback, +) +from leapflow.gateway.protocol import InboundMessage, MessageSource + +logger = logging.getLogger(__name__) + +_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", +}) + + +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", "")) + if not content: + 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) + + 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() + + metadata: dict[str, Any] = { + "event_id": event.event_id, + "message_type": str(payload.get("message_type", "text")), + } + if bot_mentioned: + metadata["bot_mentioned"] = True + + return InboundMessage( + source=source, + text=content, + message_id=str(payload.get("message_id") or payload.get("id", "")), + metadata=metadata, + timestamp=timestamp, + ) + + 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 in pre-rendered content text. + + lark-cli resolves mentions inline as ``@DisplayName``. + Without raw mention data, we check for the bot's configured + display name. + """ + 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..21dce8a --- /dev/null +++ b/src/leapflow/gateway/normalizers/telegram.py @@ -0,0 +1,143 @@ +"""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, 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 "") + if not text: + 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) + + 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", "")), + metadata=metadata, + timestamp=float(raw_message.get("date", time.time())), + ) + + def _detect_bot_mention(self, raw_message: Mapping[str, Any]) -> bool: + """Detect @bot mention in Telegram message entities.""" + if not self._bot_name: + return False + 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() == self._bot_name.lower(): + return True + return False diff --git a/src/leapflow/gateway/router.py b/src/leapflow/gateway/router.py index a24a210..07425f9 100644 --- a/src/leapflow/gateway/router.py +++ b/src/leapflow/gateway/router.py @@ -23,11 +23,25 @@ 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 + +@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]: ... + logger = logging.getLogger(__name__) SendFn = Callable[[MessageSource, str], Coroutine[Any, Any, None]] @@ -78,6 +92,7 @@ def __init__( allowed_tools: frozenset[str] = SAFE_TOOLS, max_history: int = 50, max_tool_rounds: int = 3, + persistence: Optional[SessionPersistence] = None, ) -> None: self._llm = llm self._system_prompt = system_prompt @@ -86,6 +101,7 @@ 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._tool_defs = [ td for td in tool_definitions @@ -117,12 +133,11 @@ 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}) + self._persist_message(session_key, "user", message.text) self._trim_history(history) reply = await self._llm_with_tools(history) @@ -130,6 +145,7 @@ async def _process( return history.append({"role": "assistant", "content": reply}) + self._persist_message(session_key, "assistant", reply) try: await self._send_fn(message.source, reply) except Exception: @@ -139,6 +155,55 @@ async def _process( exc_info=True, ) + 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 + + 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: + self._persistence.append_message(session_key, role, content) + except Exception: + logger.debug("Failed to persist %s message for %s", role, session_key, exc_info=True) + async def _llm_with_tools( self, history: List[Dict[str, Any]], @@ -211,6 +276,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..337727d 100644 --- a/src/leapflow/gateway/server.py +++ b/src/leapflow/gateway/server.py @@ -19,12 +19,26 @@ import importlib import logging import time +from collections import OrderedDict from pathlib import Path from typing import Any, Callable, Dict, List, Optional +from leapflow.gateway.checkpoint_store import CheckpointStore 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 +62,35 @@ EventCallback = Callable[..., Any] +_DEFAULT_DEDUP_CAPACITY = 10_000 + + +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 + class GatewayServer: """Manages platform adapter lifecycle and message routing.""" @@ -58,17 +101,26 @@ def __init__( *, extra_manifest_dirs: Optional[List[Path]] = None, on_event: Optional[EventCallback] = None, + checkpoint_store: Optional[CheckpointStore] = None, ) -> None: self._profile_dir = profile_dir self._vault = CredentialVault(profile_dir) + self._checkpoint_store = checkpoint_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 @@ -90,6 +142,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 +210,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() @@ -170,8 +244,14 @@ 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) affected = {k for k in self._known_sessions if k.platform == platform_id} @@ -246,6 +326,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 +337,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 @@ -376,7 +484,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 +494,36 @@ 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) status = await source.stop() return self._event_status_dict(status) @@ -531,6 +659,150 @@ 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()) + + 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) + self._save_checkpoint(platform_id) + except Exception: + logger.error( + "Consumer loop crashed for %s", platform_id, exc_info=True, + ) + + 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_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..4e1ca13 --- /dev/null +++ b/src/leapflow/gateway/trigger_policy.py @@ -0,0 +1,117 @@ +"""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() + 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 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/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_feishu_event_normalizer.py b/tests/test_feishu_event_normalizer.py new file mode 100644 index 0000000..3ddb99c --- /dev/null +++ b/tests/test_feishu_event_normalizer.py @@ -0,0 +1,226 @@ +"""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_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..901758a 100644 --- a/tests/test_gateway_adapters.py +++ b/tests/test_gateway_adapters.py @@ -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..854dee1 --- /dev/null +++ b/tests/test_gateway_consumer_loop.py @@ -0,0 +1,333 @@ +"""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"] diff --git a/tests/test_gateway_tool_e2e.py b/tests/test_gateway_tool_e2e.py index 8f32418..c05971e 100644 --- a/tests/test_gateway_tool_e2e.py +++ b/tests/test_gateway_tool_e2e.py @@ -52,6 +52,8 @@ async def test_app_slash_payloads_reuse_platform_connect_and_manifests(tmp_path) assert actions["ok"] is True assert set(actions["actions"]) == { "im.send_message", "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", } @@ -449,8 +451,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 diff --git a/tests/test_trigger_policy.py b/tests/test_trigger_policy.py new file mode 100644 index 0000000..f4030ba --- /dev/null +++ b/tests/test_trigger_policy.py @@ -0,0 +1,128 @@ +"""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_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 From 801b219defa8d5b21072d715e15564df1a4e95cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8F=AD=E6=89=AC?= Date: Tue, 14 Jul 2026 11:38:36 +0800 Subject: [PATCH 02/14] =?UTF-8?q?feat(gateway):=20close=20all=20hermes=20s?= =?UTF-8?q?ubscription=20gaps=20=E2=80=94=20P0/P1/P2=20complete?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P0 — @all mention detection: - Normalizers (Feishu/Telegram/DingTalk) now detect @all/@所有人 and treat it as bot_mentioned for trigger policy routing. P0 — Processing indicators: - GatewayRouter signals start/done/error phases via IndicatorFn. - Context wires it to platform reactions (OnIt emoji). - Fire-and-forget with 5s timeout — never blocks message processing. P0 — Streaming reply: - GatewayRouter supports progressive message editing via StreamSendFn. - Sends initial message, then updates in-place as LLM chunks arrive. - Throttled updates (1.5s interval, 40-char minimum delta). - Falls back to non-streaming when tools are active. P1 — Media inbound (image/file/audio): - Feishu normalizer extracts MediaAttachment with file_key for deferred download via im.download_resource action. - Telegram normalizer extracts photo/document/audio/video/voice/sticker. - Non-text messages produce synthetic [type] text + media tuple. P1 — Extended event keys: - Default Feishu subscription now includes reaction, read receipt, and bot lifecycle events for richer signal observation. P1 — New Feishu actions: - im.reply_message, im.update_message, im.add_reaction, im.remove_reaction, im.update_card, im.download_resource. - FeishuAdapter.send() uses im.reply_message when reply_to_id is set. P2 — User authorization: - TriggerPolicy gains allowed_users field (frozenset whitelist). - Context wires allowed_users/blocked_users/keywords/rate limits from platform options (config-driven, no hardcoding). P2 — Outbound text chunking: - GatewayServer.send_reply() auto-chunks text exceeding adapter.max_message_length at paragraph/sentence boundaries. P2 — Persistent dedup: - DuckDBDeduplicationStore saves/loads event_ids across restarts. - EventDeduplicator gains load_from_store/save_to_store methods. - Consumer loop loads on start, saves on cancellation. - Auto-prunes entries older than 7 days. P2 — Thread parent context: - Feishu normalizer extracts parent_id → reply_to_id. - GatewayRouter fetches parent text via ContextFetchFn and prepends "[Replying to: ...]" context for the LLM. P2 — Markdown/rich format detection: - send_reply() detects markdown formatting (code blocks, headers, lists, bold, links) and sets format_hint metadata. - Adapters can use format_hint to select richer message types. Tests: 467 passed (12 new tests for @all, media, reply_to_id, allowed_users, text chunking, and markdown detection). Co-authored-by: Cursor --- src/leapflow/cli/context.py | 94 ++++++++- src/leapflow/gateway/action_packs/feishu.yaml | 173 ++++++++++++++++ src/leapflow/gateway/adapters/feishu.py | 22 +- src/leapflow/gateway/checkpoint_store.py | 74 +++++++ .../gateway/connectors/lark_event_source.py | 4 + src/leapflow/gateway/normalizers/dingtalk.py | 12 +- src/leapflow/gateway/normalizers/feishu.py | 70 ++++++- src/leapflow/gateway/normalizers/telegram.py | 56 ++++- src/leapflow/gateway/router.py | 191 ++++++++++++++++-- src/leapflow/gateway/server.py | 105 +++++++++- src/leapflow/gateway/trigger_policy.py | 3 + tests/test_feishu_event_normalizer.py | 96 +++++++++ tests/test_gateway_adapters.py | 2 +- tests/test_gateway_consumer_loop.py | 48 +++++ tests/test_gateway_tool_e2e.py | 5 +- tests/test_trigger_policy.py | 13 ++ 16 files changed, 923 insertions(+), 45 deletions(-) diff --git a/src/leapflow/cli/context.py b/src/leapflow/cli/context.py index 35879df..14040ea 100644 --- a/src/leapflow/cli/context.py +++ b/src/leapflow/cli/context.py @@ -1573,10 +1573,11 @@ async def _on_gateway_event(event: object) -> None: }, ) - from leapflow.gateway.checkpoint_store import DuckDBCheckpointStore + 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: @@ -1588,6 +1589,7 @@ async def _on_gateway_event_with_bridge(event: object) -> None: extra_manifest_dirs=[settings.profile_dir / "gateway" / "manifests"], 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) @@ -1598,6 +1600,71 @@ async def _on_gateway_event_with_bridge(event: object) -> None: 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 == "done": + await gw.execute_platform_action( + platform, "im.remove_reaction", + {"message_id": message_id, "emoji_type": "OnIt"}, + ) + elif phase == "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( @@ -1611,6 +1678,9 @@ async def _gateway_send(source: Any, text: str) -> None: 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, @@ -2363,7 +2433,27 @@ def _register_gateway_normalizers(self, settings: Any) -> None: raw_mode, platform_id, ) trigger_mode = TriggerMode.MENTION_ONLY - gw.register_trigger_policy(platform_id, TriggerPolicy(mode=trigger_mode)) + + 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 diff --git a/src/leapflow/gateway/action_packs/feishu.yaml b/src/leapflow/gateway/action_packs/feishu.yaml index 27cb730..f6b96b2 100644 --- a/src/leapflow/gateway/action_packs/feishu.yaml +++ b/src/leapflow/gateway/action_packs/feishu.yaml @@ -210,6 +210,179 @@ actions: - "{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 + 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 + 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 + 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 + 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 + 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 + 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: diff --git a/src/leapflow/gateway/adapters/feishu.py b/src/leapflow/gateway/adapters/feishu.py index e0810a5..3b6f3cf 100644 --- a/src/leapflow/gateway/adapters/feishu.py +++ b/src/leapflow/gateway/adapters/feishu.py @@ -145,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/checkpoint_store.py b/src/leapflow/gateway/checkpoint_store.py index 4868fc6..f7d4c87 100644 --- a/src/leapflow/gateway/checkpoint_store.py +++ b/src/leapflow/gateway/checkpoint_store.py @@ -30,6 +30,80 @@ 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() + for eid in event_ids[-1000:]: + execute_with_retry( + conn, + f""" + INSERT OR IGNORE INTO {_DEDUP_TABLE} + (platform_id, event_id, seen_at) VALUES (?, ?, ?) + """, + [platform_id, eid, now], + ) + 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.""" diff --git a/src/leapflow/gateway/connectors/lark_event_source.py b/src/leapflow/gateway/connectors/lark_event_source.py index 5d38f33..ace80a0 100644 --- a/src/leapflow/gateway/connectors/lark_event_source.py +++ b/src/leapflow/gateway/connectors/lark_event_source.py @@ -15,6 +15,10 @@ _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", ) diff --git a/src/leapflow/gateway/normalizers/dingtalk.py b/src/leapflow/gateway/normalizers/dingtalk.py index 298b68d..3a1b035 100644 --- a/src/leapflow/gateway/normalizers/dingtalk.py +++ b/src/leapflow/gateway/normalizers/dingtalk.py @@ -5,6 +5,7 @@ """ from __future__ import annotations +import re import time from typing import Any, Mapping @@ -16,6 +17,9 @@ from leapflow.gateway.protocol import InboundMessage, MessageSource +_AT_ALL_PATTERN = re.compile(r"@(?:all|所有人|All)\b", re.IGNORECASE) + + class DingTalkEventNormalizer: """Normalizes DingTalk BackendEvents into domain types. @@ -94,7 +98,11 @@ def _to_inbound_message(self, event: BackendEvent, text: str) -> InboundMessage 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 self._detect_bot_mention(text) + 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, @@ -124,6 +132,8 @@ def _to_inbound_message(self, event: BackendEvent, text: str) -> InboundMessage ) 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 diff --git a/src/leapflow/gateway/normalizers/feishu.py b/src/leapflow/gateway/normalizers/feishu.py index 11ac71a..9fe900e 100644 --- a/src/leapflow/gateway/normalizers/feishu.py +++ b/src/leapflow/gateway/normalizers/feishu.py @@ -13,6 +13,7 @@ from __future__ import annotations import logging +import re import time from typing import Any, Mapping @@ -22,10 +23,12 @@ EventKind, InboundCallback, ) -from leapflow.gateway.protocol import InboundMessage, MessageSource +from leapflow.gateway.protocol import InboundMessage, MediaAttachment, MessageSource logger = logging.getLogger(__name__) +_AT_ALL_PATTERN = re.compile(r"@(?:all|所有人|All)\b", re.IGNORECASE) + _MESSAGE_EVENT_TYPES = frozenset({ "im.message.receive_v1", }) @@ -123,13 +126,17 @@ def _to_inbound_message(self, event: BackendEvent) -> InboundMessage | None: """ payload = event.payload content = str(payload.get("content", "")) - if not 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) + bot_mentioned = self._detect_bot_mention(content) if content else False source = MessageSource( platform="feishu", @@ -145,21 +152,66 @@ def _to_inbound_message(self, event: BackendEvent) -> InboundMessage | None: 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": str(payload.get("message_type", "text")), + "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=str(payload.get("message_id") or payload.get("id", "")), + 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", "")) + + _MEDIA_TYPES = {"image", "file", "audio", "video", "media", "sticker"} + if message_type not in _MEDIA_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. @@ -205,12 +257,14 @@ def _to_inbound_callback(self, event: BackendEvent) -> InboundCallback: ) def _detect_bot_mention(self, content: str) -> bool: - """Detect bot @-mention in pre-rendered content text. + """Detect bot @-mention or @all in pre-rendered content text. lark-cli resolves mentions inline as ``@DisplayName``. - Without raw mention data, we check for the bot's configured - display name. + ``@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 diff --git a/src/leapflow/gateway/normalizers/telegram.py b/src/leapflow/gateway/normalizers/telegram.py index 21dce8a..c0d0765 100644 --- a/src/leapflow/gateway/normalizers/telegram.py +++ b/src/leapflow/gateway/normalizers/telegram.py @@ -13,7 +13,7 @@ EventClassification, EventKind, ) -from leapflow.gateway.protocol import InboundMessage, MessageSource +from leapflow.gateway.protocol import InboundMessage, MediaAttachment, MessageSource class TelegramEventNormalizer: @@ -84,7 +84,9 @@ def _to_inbound_message(self, event: BackendEvent) -> InboundMessage | None: return None text = str(raw_message.get("text") or raw_message.get("caption") or "") - if not text: + media = self._extract_media(raw_message) + + if not text and not media: return None chat = raw_message.get("chat", {}) @@ -102,6 +104,9 @@ def _to_inbound_message(self, event: BackendEvent) -> InboundMessage | None: 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", "")), } @@ -119,14 +124,45 @@ def _to_inbound_message(self, event: BackendEvent) -> InboundMessage | None: ), 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 in Telegram message entities.""" - if not self._bot_name: - return False + """Detect @bot mention or @all-equivalent in Telegram message entities.""" entities = raw_message.get("entities", []) if not isinstance(entities, list): return False @@ -138,6 +174,14 @@ def _detect_bot_mention(self, raw_message: Mapping[str, Any]) -> bool: offset = int(ent.get("offset", 0)) length = int(ent.get("length", 0)) mentioned = text[offset:offset + length].lstrip("@") - if mentioned.lower() == self._bot_name.lower(): + 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/router.py b/src/leapflow/gateway/router.py index 07425f9..3e64310 100644 --- a/src/leapflow/gateway/router.py +++ b/src/leapflow/gateway/router.py @@ -42,10 +42,34 @@ 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", @@ -93,6 +117,10 @@ def __init__( 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 @@ -102,6 +130,10 @@ def __init__( 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 @@ -136,24 +168,44 @@ async def _process( history = self._init_session(session_key, message) self._sessions[session_key] = history - history.append({"role": "user", "content": message.text}) - self._persist_message(session_key, "user", message.text) - self._trim_history(history) + 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}" - reply = await self._llm_with_tools(history) - if not reply: - return + history.append({"role": "user", "content": user_content}) + self._persist_message(session_key, "user", user_content) + self._trim_history(history) - history.append({"role": "assistant", "content": reply}) - self._persist_message(session_key, "assistant", reply) + await self._signal_indicator(message.source, message.message_id, IndicatorPhase.START) + phase = IndicatorPhase.ERROR try: - await self._send_fn(message.source, reply) - except Exception: - logger.error( - "Failed to send reply for session %s", - session_key, - exc_info=True, - ) + if self._streaming_enabled and not self._tool_defs: + reply = await self._stream_reply(message.source, history) + else: + 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 self._streaming_enabled or self._tool_defs: + 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, @@ -195,6 +247,35 @@ def _init_session( 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) + 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: @@ -204,6 +285,86 @@ def _persist_message(self, session_key: str, role: str, content: str) -> None: except Exception: 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 if the LLM doesn't support streaming. + """ + assert self._stream_send_fn is not None + + try: + resp = await self._llm.achat(history, stream=True) + except Exception: + logger.error("Streaming LLM call failed, falling back to non-streaming", exc_info=True) + return await self._llm_with_tools(history) + + chunks: list[str] = [] + message_id = "" + last_update = 0.0 + last_sent_len = 0 + + async def _collect_stream() -> None: + nonlocal message_id, last_update, last_sent_len + async for chunk in resp: + delta = getattr(chunk, "content", "") or "" + if not delta: + continue + chunks.append(delta) + now = asyncio.get_event_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) + + 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, history: List[Dict[str, Any]], diff --git a/src/leapflow/gateway/server.py b/src/leapflow/gateway/server.py index 337727d..24d5258 100644 --- a/src/leapflow/gateway/server.py +++ b/src/leapflow/gateway/server.py @@ -23,7 +23,7 @@ from pathlib import Path from typing import Any, Callable, Dict, List, Optional -from leapflow.gateway.checkpoint_store import CheckpointStore +from leapflow.gateway.checkpoint_store import CheckpointStore, DeduplicationStore from leapflow.gateway.config_store import GatewayConfigStore from leapflow.gateway.connectors.action_registry import summarize_action_result from leapflow.gateway.connectors.event_sources import UnavailableEventSource @@ -62,9 +62,60 @@ EventCallback = Callable[..., Any] +import re as _re + +_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. @@ -91,6 +142,26 @@ def is_duplicate(self, event_id: str) -> bool: 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.""" @@ -102,10 +173,12 @@ 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] = {} @@ -598,6 +671,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: @@ -610,8 +684,18 @@ 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) + 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. @@ -695,6 +779,11 @@ async def _consume_platform_events( 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(): @@ -715,6 +804,7 @@ async def _consume_platform_events( except asyncio.CancelledError: logger.info("Consumer task cancelled for %s", platform_id) self._save_checkpoint(platform_id) + self._save_dedup_state(platform_id) except Exception: logger.error( "Consumer loop crashed for %s", platform_id, exc_info=True, @@ -786,6 +876,15 @@ def _inject_bot_identity( 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) diff --git a/src/leapflow/gateway/trigger_policy.py b/src/leapflow/gateway/trigger_policy.py index 4e1ca13..932edc0 100644 --- a/src/leapflow/gateway/trigger_policy.py +++ b/src/leapflow/gateway/trigger_policy.py @@ -32,6 +32,7 @@ class TriggerPolicy: 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 @@ -52,6 +53,8 @@ def should_activate( 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, diff --git a/tests/test_feishu_event_normalizer.py b/tests/test_feishu_event_normalizer.py index 3ddb99c..460f019 100644 --- a/tests/test_feishu_event_normalizer.py +++ b/tests/test_feishu_event_normalizer.py @@ -207,6 +207,102 @@ def test_is_self_message_standalone() -> None: 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( diff --git a/tests/test_gateway_adapters.py b/tests/test_gateway_adapters.py index 901758a..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"}), ] diff --git a/tests/test_gateway_consumer_loop.py b/tests/test_gateway_consumer_loop.py index 854dee1..e9d34eb 100644 --- a/tests/test_gateway_consumer_loop.py +++ b/tests/test_gateway_consumer_loop.py @@ -331,3 +331,51 @@ async def handler(msg: InboundMessage, session_key: str) -> None: 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 c05971e..2a6f67e 100644 --- a/tests/test_gateway_tool_e2e.py +++ b/tests/test_gateway_tool_e2e.py @@ -51,7 +51,10 @@ 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", diff --git a/tests/test_trigger_policy.py b/tests/test_trigger_policy.py index f4030ba..64b42f1 100644 --- a/tests/test_trigger_policy.py +++ b/tests/test_trigger_policy.py @@ -100,6 +100,19 @@ def test_allowed_chats_whitelist() -> None: 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 From 6b21bf7498edd590ecb5eda17c95919cf80efc33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8F=AD=E6=89=AC?= Date: Tue, 14 Jul 2026 13:21:06 +0800 Subject: [PATCH 03/14] feat(gateway): platform action parameter governance and side-effect idempotency Root-cause fix for two classes of platform_action failures: 1. Payload structural errors (fields at wrong level, missing required fields): - Add normalize_payload() that lifts misplaced business fields into payload - Enhance ValidationResult with failure_code, missing_fields, recovery_hint - Enhance Capability Index to show per-action payload signatures with required markers 2. Duplicate side-effect execution (LLM repeating send/write across turns): - Add task-scoped side-effect dedup in platform_action_handler (before approval) - Add completed:true + execution_note in successful send/write/execute results - Add "Side-effect action rule" to system prompt constraining LLM behavior - Add platform_action evidence builder with COMPLETED/ALREADY_EXECUTED markers - Add loop exit bias nudging LLM to stop after side-effect completion Design: defense-in-depth with Prevention (prompt/result signals) + Protection (deterministic dedup guard). Read actions are never subject to dedup. Co-authored-by: Cursor --- src/leapflow/engine/context_control.py | 32 ++ src/leapflow/engine/engine.py | 28 ++ .../gateway/connectors/action_registry.py | 84 ++++- src/leapflow/prompts/templates.py | 12 +- src/leapflow/tools/gateway_tool.py | 146 +++++++- tests/test_app_connector.py | 338 +++++++++++++++++- 6 files changed, 616 insertions(+), 24 deletions(-) diff --git a/src/leapflow/engine/context_control.py b/src/leapflow/engine/context_control.py index 732c869..9205f1e 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,36 @@ 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_code", "missing_fields", "recovery_hint", "expected_schema", "retryable"): + if key in result: + evidence[key] = result[key] + 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, diff --git a/src/leapflow/engine/engine.py b/src/leapflow/engine/engine.py index 2299935..8e9d566 100644 --- a/src/leapflow/engine/engine.py +++ b/src/leapflow/engine/engine.py @@ -222,6 +222,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 [] @@ -1063,6 +1074,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 = ( @@ -2014,6 +2030,12 @@ async def _unified_tool_loop( 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)) @@ -2403,6 +2425,12 @@ async def _unified_tool_loop_stream( 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: diff --git a/src/leapflow/gateway/connectors/action_registry.py b/src/leapflow/gateway/connectors/action_registry.py index dad6f17..2ae1c8b 100644 --- a/src/leapflow/gateway/connectors/action_registry.py +++ b/src/leapflow/gateway/connectors/action_registry.py @@ -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: @@ -195,17 +203,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,10 +272,21 @@ 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) +_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: @@ -231,6 +301,14 @@ def summarize_action_result(spec: ActionSpec, result: ActionResult) -> dict[str, "resource_id": result.resource_id, "output_policy": spec.output_policy, } + + 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/prompts/templates.py b/src/leapflow/prompts/templates.py index 75a336b..d569f3c 100644 --- a/src/leapflow/prompts/templates.py +++ b/src/leapflow/prompts/templates.py @@ -88,10 +88,14 @@ def build_react_system(language: str = "en", skill_catalog: str = "") -> str: 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. +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. ## Guidelines 1. **Direct answers first**: If you already know the answer, respond directly without tools. diff --git a/src/leapflow/tools/gateway_tool.py b/src/leapflow/tools/gateway_tool.py index 9f0ba35..adfa674 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 @@ -710,6 +756,39 @@ 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 + + # ═══════════════════════════════════════════════════════════════ # gateway_send handler — proactive outbound messaging # ═══════════════════════════════════════════════════════════════ @@ -805,9 +884,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,6 +892,35 @@ 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, + } + 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")} @@ -866,7 +971,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,16 +995,18 @@ 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"], diff --git a/tests/test_app_connector.py b/tests/test_app_connector.py index 331cde8..181578b 100644 --- a/tests/test_app_connector.py +++ b/tests/test_app_connector.py @@ -3,8 +3,13 @@ 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.connectors.action_registry import ( + ActionRegistry, + ValidationResult, + normalize_payload, + validate_payload, +) +from leapflow.gateway.connectors.protocol import ActionPreview, ActionResult, ActionSpec, BackendStatus class FakeBackend: @@ -85,3 +90,332 @@ 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 + + +# ═══════════════════════════════════════════════════════════════ +# 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() From 9baf9b0d3a0421a884dbe501be23a999f780f8cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8F=AD=E6=89=AC?= Date: Tue, 14 Jul 2026 14:50:00 +0800 Subject: [PATCH 04/14] feat(gateway): refine message routing and TUI turn markers --- src/leapflow/cli/commands/interactive.py | 8 ++- src/leapflow/cli/context.py | 7 +-- src/leapflow/cli/tui_app/app.py | 12 ++++ src/leapflow/cli/tui_app/console.py | 15 ++++- src/leapflow/cli/tui_app/stream.py | 4 +- src/leapflow/gateway/normalizers/dingtalk.py | 2 +- src/leapflow/gateway/normalizers/feishu.py | 7 ++- src/leapflow/gateway/router.py | 33 +++++++--- src/leapflow/gateway/server.py | 18 +++--- tests/test_tui_command_queue.py | 63 ++++++++++++++++++-- tests/test_tui_session_summary.py | 12 +++- 11 files changed, 137 insertions(+), 44 deletions(-) diff --git a/src/leapflow/cli/commands/interactive.py b/src/leapflow/cli/commands/interactive.py index 64caeaa..15e23e7 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,10 @@ async def _stream_response(prompt_text: str) -> None: renderer.feed(event.content) else: renderer.feed(str(event)) + turn_completed = True finally: - renderer.finish() + command = app.complete_active_command_in_response() if turn_completed else None + renderer.finish(command=command) if renderer.has_output: exit_stats.record_assistant_message() exit_stats.record_tool_calls(renderer.tool_count) @@ -1061,7 +1064,8 @@ async def _stream_response( ) raise finally: - renderer.finish() + command = app.complete_active_command_in_response() if turn_completed else None + renderer.finish(command=command) if renderer.has_output: exit_stats.record_assistant_message() exit_stats.record_tool_calls(renderer.tool_count) diff --git a/src/leapflow/cli/context.py b/src/leapflow/cli/context.py index 14040ea..87e9eea 100644 --- a/src/leapflow/cli/context.py +++ b/src/leapflow/cli/context.py @@ -1613,12 +1613,7 @@ async def _gateway_indicator( platform, "im.add_reaction", {"message_id": message_id, "emoji_type": "OnIt"}, ) - elif phase == "done": - await gw.execute_platform_action( - platform, "im.remove_reaction", - {"message_id": message_id, "emoji_type": "OnIt"}, - ) - elif phase == "error": + elif phase in ("done", "error"): await gw.execute_platform_action( platform, "im.remove_reaction", {"message_id": message_id, "emoji_type": "OnIt"}, diff --git a/src/leapflow/cli/tui_app/app.py b/src/leapflow/cli/tui_app/app.py index 5fb9b2b..d3772f0 100644 --- a/src/leapflow/cli/tui_app/app.py +++ b/src/leapflow/cli/tui_app/app.py @@ -303,6 +303,18 @@ 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 queued_commands(self) -> list[TuiCommand]: """Return a snapshot of pending commands in queue order.""" return self._pending_input.snapshot() diff --git a/src/leapflow/cli/tui_app/console.py b/src/leapflow/cli/tui_app/console.py index 110167f..b8fbd6c 100644 --- a/src/leapflow/cli/tui_app/console.py +++ b/src/leapflow/cli/tui_app/console.py @@ -298,13 +298,22 @@ 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_style = "leap.success" if command.status == TuiCommandStatus.DONE else "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/stream.py b/src/leapflow/cli/tui_app/stream.py index afdb900..7fc29b3 100644 --- a/src/leapflow/cli/tui_app/stream.py +++ b/src/leapflow/cli/tui_app/stream.py @@ -378,7 +378,7 @@ 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.""" if self._thinking_buffer.strip(): self._console.thinking(self._thinking_buffer) @@ -395,5 +395,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/gateway/normalizers/dingtalk.py b/src/leapflow/gateway/normalizers/dingtalk.py index 3a1b035..a14f6d5 100644 --- a/src/leapflow/gateway/normalizers/dingtalk.py +++ b/src/leapflow/gateway/normalizers/dingtalk.py @@ -17,7 +17,7 @@ from leapflow.gateway.protocol import InboundMessage, MessageSource -_AT_ALL_PATTERN = re.compile(r"@(?:all|所有人|All)\b", re.IGNORECASE) +_AT_ALL_PATTERN = re.compile(r"@(?:all(?:\b|$)|所有人)", re.IGNORECASE) class DingTalkEventNormalizer: diff --git a/src/leapflow/gateway/normalizers/feishu.py b/src/leapflow/gateway/normalizers/feishu.py index 9fe900e..fc5de77 100644 --- a/src/leapflow/gateway/normalizers/feishu.py +++ b/src/leapflow/gateway/normalizers/feishu.py @@ -27,7 +27,7 @@ logger = logging.getLogger(__name__) -_AT_ALL_PATTERN = re.compile(r"@(?:all|所有人|All)\b", re.IGNORECASE) +_AT_ALL_PATTERN = re.compile(r"@(?:all(?:\b|$)|所有人)", re.IGNORECASE) _MESSAGE_EVENT_TYPES = frozenset({ "im.message.receive_v1", @@ -50,6 +50,8 @@ "im.chat.disbanded_v1", }) +_MEDIA_MESSAGE_TYPES = frozenset({"image", "file", "audio", "video", "media", "sticker"}) + class FeishuEventNormalizer: """Normalizes Feishu backend events into domain types. @@ -190,8 +192,7 @@ def _extract_media( attachments: list[MediaAttachment] = [] message_id = str(payload.get("message_id") or payload.get("id", "")) - _MEDIA_TYPES = {"image", "file", "audio", "video", "media", "sticker"} - if message_type not in _MEDIA_TYPES: + if message_type not in _MEDIA_MESSAGE_TYPES: return [] file_key = str(payload.get("file_key") or payload.get("image_key") or "") diff --git a/src/leapflow/gateway/router.py b/src/leapflow/gateway/router.py index 3e64310..463a99d 100644 --- a/src/leapflow/gateway/router.py +++ b/src/leapflow/gateway/router.py @@ -182,10 +182,13 @@ async def _process( await self._signal_indicator(message.source, message.message_id, IndicatorPhase.START) phase = IndicatorPhase.ERROR + reply = "" + streamed = False try: - if self._streaming_enabled and not self._tool_defs: + if self._streaming_enabled: reply = await self._stream_reply(message.source, history) - else: + streamed = bool(reply) + if not reply: reply = await self._llm_with_tools(history) if not reply: phase = IndicatorPhase.DONE @@ -194,7 +197,7 @@ async def _process( history.append({"role": "assistant", "content": reply}) self._persist_message(session_key, "assistant", reply) - if not self._streaming_enabled or self._tool_defs: + if not streamed: try: await self._send_fn(message.source, reply) except Exception: @@ -294,29 +297,38 @@ async def _stream_reply( Sends an initial placeholder, then progressively edits the message as chunks arrive from the LLM streaming API. Falls - back to non-streaming if the LLM doesn't support streaming. + 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: - resp = await self._llm.achat(history, stream=True) + 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.error("Streaming LLM call failed, falling back to non-streaming", exc_info=True) - return await self._llm_with_tools(history) + 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 + 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_event_loop().time() + now = asyncio.get_running_loop().time() accumulated = "".join(chunks) should_update = ( @@ -348,6 +360,9 @@ async def _collect_stream() -> None: 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 "" diff --git a/src/leapflow/gateway/server.py b/src/leapflow/gateway/server.py index 24d5258..fab9acc 100644 --- a/src/leapflow/gateway/server.py +++ b/src/leapflow/gateway/server.py @@ -18,6 +18,7 @@ import asyncio import importlib import logging +import re import time from collections import OrderedDict from pathlib import Path @@ -62,16 +63,14 @@ EventCallback = Callable[..., Any] -import re as _re - _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"\[.+?\]\(.+?\)"), + 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"\[.+?\]\(.+?\)"), ) @@ -597,6 +596,7 @@ async def stop_platform_events(self, platform_id: str) -> Dict[str, Any]: 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) diff --git a/tests/test_tui_command_queue.py b/tests/test_tui_command_queue.py index 5952938..40bbf90 100644 --- a/tests/test_tui_command_queue.py +++ b/tests/test_tui_command_queue.py @@ -419,6 +419,29 @@ 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_stream_renderer_prints_tool_command_and_success_preview() -> None: class CaptureConsole: def __init__(self) -> None: @@ -433,7 +456,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 +491,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 +532,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: @@ -547,7 +570,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 +620,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: @@ -653,7 +676,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 +713,34 @@ def newline(self) -> None: assert "task requires broad" not in line +@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: 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 From 9a1a8266ce0bc4706460febe70039642b88edff9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8F=AD=E6=89=AC?= Date: Tue, 14 Jul 2026 15:23:47 +0800 Subject: [PATCH 05/14] feat(tools): static alias table for LLM tool-name drift resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a human-curated alias table as a remedial resolution layer between formatting normalization and "unknown" fallback. This transparently maps common LLM naming drift (read_file → file_read, execute_command → shell_run, etc.) to canonical tool names without requiring an LLM retry round-trip. Design: - Alias table declared in name_resolver.py as TOOL_NAME_ALIASES (Dict[str,str]) - Validated at registry construction: only entries targeting actual specs are kept - Resolution priority: exact > normalized > bridge prefix > alias > unknown - New ResolutionStatus "aliased" for auditability in logs and TUI - auto_executable=True — aliases are proven 1:1 semantic equivalences - Table is intentionally small; primary reliance is on LLM schema adherence Extensibility: - Adding a new alias = one line in TOOL_NAME_ALIASES - Adding a new LLM or tool = no change to resolution logic - Gateway router also uses the alias table for its scoped registry Co-authored-by: Cursor --- src/leapflow/gateway/router.py | 6 +- src/leapflow/tools/name_resolver.py | 102 +++++++++++++++++++---- src/leapflow/tools/registry_bootstrap.py | 3 +- tests/test_agent_execution.py | 49 +++++++++-- 4 files changed, 133 insertions(+), 27 deletions(-) diff --git a/src/leapflow/gateway/router.py b/src/leapflow/gateway/router.py index 463a99d..ea9f029 100644 --- a/src/leapflow/gateway/router.py +++ b/src/leapflow/gateway/router.py @@ -26,7 +26,7 @@ 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 @@ -143,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, 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..4ebb9ea 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 # ───────────────────────────────────────────────────────────────────── @@ -451,6 +451,7 @@ async def _delegate_task_handler(params: Dict[str, Any]) -> Dict[str, Any]: TOOL_DEFINITIONS, TOOL_HANDLERS, bridge_tools=_BRIDGE_TOOLS, + aliases=TOOL_NAME_ALIASES, ) diff --git a/tests/test_agent_execution.py b/tests/test_agent_execution.py index b1818c7..95f0969 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" From 2ff8d96b3b62a1fa00c3bffdb3ed3950c07b02cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8F=AD=E6=89=AC?= Date: Tue, 14 Jul 2026 16:15:30 +0800 Subject: [PATCH 06/14] feat(gateway): add platform authorization gate --- AGENTS.md | 3 + src/leapflow/engine/engine.py | 38 ++- src/leapflow/gateway/action_packs/feishu.yaml | 196 +++++++++++ src/leapflow/gateway/backends/cli_backend.py | 29 +- .../gateway/backends/lark_cli_errors.py | 307 ++++++++++++++++++ src/leapflow/gateway/capability_health.py | 181 +++++++++++ .../gateway/connectors/action_registry.py | 38 ++- src/leapflow/gateway/connectors/protocol.py | 62 ++++ src/leapflow/gateway/server.py | 31 +- src/leapflow/tools/gateway_tool.py | 32 +- tests/test_app_connector.py | 125 ++++++- tests/test_execution_backends.py | 48 +++ tests/test_gateway_tool_e2e.py | 101 +++++- 13 files changed, 1183 insertions(+), 8 deletions(-) create mode 100644 src/leapflow/gateway/backends/lark_cli_errors.py create mode 100644 src/leapflow/gateway/capability_health.py diff --git a/AGENTS.md b/AGENTS.md index a38b7ab..9e5d61b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,6 +39,7 @@ This document is the LeapFlow engineering collaboration contract. It is not only - **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 @@ -59,6 +60,7 @@ This document is the LeapFlow engineering collaboration contract. It is not only - 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 @@ -100,6 +102,7 @@ This document is the LeapFlow engineering collaboration contract. It is not 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/engine/engine.py b/src/leapflow/engine/engine.py index 8e9d566..813480c 100644 --- a/src/leapflow/engine/engine.py +++ b/src/leapflow/engine/engine.py @@ -167,6 +167,11 @@ 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", + "capability", "missing_scopes", "required_scopes", "skip_approval"): + if key in result: + metadata[key] = result[key] for key in ("suggestions", "available_tools"): value = result.get(key) if value: @@ -281,12 +286,43 @@ def _last_tool_failures_recovery_message(messages: List[Dict[str, Any]]) -> str: last = failures[0] failure_code = str(last.get("failure_code") or "") + failure_class = str(last.get("failure_class") or "") error = str(last.get("error") or "") recovery_hint = str(last.get("recovery_hint") or "") + recoverability = str(last.get("recoverability") or "") available_actions: List[str] = list(last.get("available_action_names") or []) + platform = str(last.get("platform") or "") + capability = str(last.get("capability") or "") + missing_scopes: List[str] = list(last.get("missing_scopes") or []) + required_scopes: List[str] = list(last.get("required_scopes") or []) + console_url = str(last.get("console_url") or "") + blocks_approval = bool(last.get("blocks_approval", False)) lines: List[str] = [] - if failure_code == "unknown_platform_action": + + # Authorization / permission failures — deterministic, no retry via LLM + if failure_class in ("authorization", "scope_denied") or failure_code in ("access_denied", "missing_scope"): + where = f"`{platform}.{capability}`" if platform and capability else (capability or platform or "this action") + scope_detail = "" + if missing_scopes: + scope_detail = f" Missing scopes: {', '.join(missing_scopes)}." + elif required_scopes: + scope_detail = f" Required scopes: {', '.join(required_scopes)}." + lines.append( + f"Authorization failed for {where}. " + f"The platform has denied access — this cannot be resolved by retrying.{scope_detail}" + ) + 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 — inform the user that manual authorization is required.") + 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.") diff --git a/src/leapflow/gateway/action_packs/feishu.yaml b/src/leapflow/gateway/action_packs/feishu.yaml index f6b96b2..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: @@ -123,6 +155,20 @@ actions: 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: @@ -146,6 +192,17 @@ actions: 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: @@ -172,6 +229,17 @@ actions: 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: @@ -195,6 +263,17 @@ actions: 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: @@ -218,6 +297,18 @@ 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: [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: @@ -247,6 +338,18 @@ actions: 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: @@ -275,6 +378,16 @@ actions: 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: @@ -302,6 +415,16 @@ actions: 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: @@ -330,6 +453,15 @@ actions: 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: @@ -358,6 +490,16 @@ actions: 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: @@ -390,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: @@ -428,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: @@ -473,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: @@ -498,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: @@ -540,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: @@ -563,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/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..9ddc69c --- /dev/null +++ b/src/leapflow/gateway/backends/lark_cli_errors.py @@ -0,0 +1,307 @@ +"""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 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") + 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}" + ), + 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}", + ), + 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") + 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}'. " + "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}", + ), + 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) + 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 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(missing_scopes)} in the developer console.",) + if missing_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, + requested_scopes=requested_scopes, + granted_scopes=granted_scopes, + identity=err_identity, + console_url=console_url, + capability=capability, + blocks_approval=True, + raw=dict(error_obj), + ) + + 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..f544c5e --- /dev/null +++ b/src/leapflow/gateway/capability_health.py @@ -0,0 +1,181 @@ +"""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 hopeless retries before they reach the approval gate. + +Design principles: +- Platform-neutral: no Feishu/CLI-specific logic here. +- Capability granularity: tracks by (platform, capability) so a read failure + does not block send, and vice versa. +- 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) +- Ledger is session-scoped; cleared between agent turns via clear() if needed. +""" +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__) + +# Recoverability ranks — higher rank means further from automatic recovery. +_RECOVERABILITY_RANK: Dict[str, int] = { + "retryable": 0, + "user_action": 1, + "admin_required": 2, + "non_recoverable": 3, +} + +# Failure classes that should block approval because no amount of user consent +# can fix the underlying platform authorization gap. +_BLOCKS_APPROVAL_CLASSES = frozenset({"authorization", "scope_denied"}) + +# Failure classes that are transient and should NOT block approval. +_TRANSIENT_CLASSES = frozenset({"timeout", "rate_limit", "transient"}) + + +@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 in seconds; 0 = session-scoped (never expires within a session). + 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) -> None: + # Key: (platform, capability) + self._records: Dict[Tuple[str, str], CapabilityStatus] = {} + + def record_failure( + self, + platform: str, + capability: str, + failure: ActionFailure, + *, + ttl_s: float = 0.0, + ) -> None: + """Record a capability failure. + + If a worse (higher-rank) failure already exists for this capability, + the existing entry is replaced only when the new failure has equal or + higher rank. + """ + 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 # Keep the existing worse record. + self._records[key] = CapabilityStatus( + platform=platform, + capability=capability, + failure=failure, + ttl_s=ttl_s, + ) + 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 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] + return None + return status + + def check_feasibility( + self, + platform: str, + spec: ActionSpec, + ) -> dict[str, Any]: + """Return feasibility dict for a platform action. + + Returns ``{"ok": True}`` when the action may proceed to approval. + Returns a structured failure dict when the ledger has a blocking record + so platform_action_handler can short-circuit without touching approval. + """ + capability = spec.capability or spec.name + status = self.get(platform, capability) + if status is None or not status.should_block_approval: + return {"ok": True} + failure = status.failure + 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, # Signal to caller: no approval needed or useful. + } + failure_dict = failure.as_dict() + for key in ("recovery_hint", "next_steps", "missing_scopes", "required_scopes", + "requested_scopes", "granted_scopes", "identity", "console_url"): + if failure_dict.get(key): + response[key] = failure_dict[key] + return response + + def clear(self, platform: str | None = None) -> None: + """Clear ledger entries for a platform, or all entries if platform is None.""" + if platform is None: + self._records.clear() + else: + keys = [k for k in self._records if k[0] == platform] + for key in keys: + del self._records[key] + + def summary(self) -> List[dict[str, Any]]: + """Return a compact summary of all non-expired records.""" + result = [] + 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, + }) + return result diff --git a/src/leapflow/gateway/connectors/action_registry.py b/src/leapflow/gateway/connectors/action_registry.py index 2ae1c8b..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: @@ -126,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")), @@ -135,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) @@ -284,22 +289,51 @@ def validate_payload(spec: ActionSpec, payload: Mapping[str, Any]) -> Validation 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: diff --git a/src/leapflow/gateway/connectors/protocol.py b/src/leapflow/gateway/connectors/protocol.py index 65ee0c6..6804c0c 100644 --- a/src/leapflow/gateway/connectors/protocol.py +++ b/src/leapflow/gateway/connectors/protocol.py @@ -24,6 +24,63 @@ 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) + + 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 + return data + + @dataclass(frozen=True) class ActionSpec: """Declarative description of one platform action.""" @@ -36,6 +93,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) @@ -47,6 +107,7 @@ class ActionResult: error: str = "" resource_id: str = "" raw: Mapping[str, Any] = field(default_factory=dict) + failure: ActionFailure | None = None @dataclass(frozen=True) @@ -57,6 +118,7 @@ class ActionPreview: summary: str = "" data: Mapping[str, Any] = field(default_factory=dict) error: str = "" + failure: ActionFailure | None = None @dataclass(frozen=True) diff --git a/src/leapflow/gateway/server.py b/src/leapflow/gateway/server.py index fab9acc..4176710 100644 --- a/src/leapflow/gateway/server.py +++ b/src/leapflow/gateway/server.py @@ -24,6 +24,7 @@ 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.config_store import GatewayConfigStore from leapflow.gateway.connectors.action_registry import summarize_action_result @@ -195,6 +196,7 @@ def __init__( self._callback_handler: Optional[Callable[..., Any]] = None self._on_event = on_event self._started = False + self._capability_health = CapabilityHealthLedger() # ── Manifest discovery ─────────────────────────────────── @@ -301,6 +303,7 @@ async def connect_platform( return response display = manifest.display_name + self._capability_health.clear(platform_id) response = { "ok": True, "status": "connected", @@ -325,6 +328,7 @@ async def disconnect_platform(self, platform_id: str) -> Dict[str, Any]: 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 @@ -512,12 +516,31 @@ 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 capability_health_summary(self) -> List[Dict[str, Any]]: + """Return compact non-secret capability health diagnostics.""" + return self._capability_health.summary() async def execute_platform_action( self, @@ -536,6 +559,12 @@ 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, + ) summary = summarize_action_result(spec, result) summary.update({"platform": platform_id, "action": action}) return summary diff --git a/src/leapflow/tools/gateway_tool.py b/src/leapflow/tools/gateway_tool.py index adfa674..e01a4ad 100644 --- a/src/leapflow/tools/gateway_tool.py +++ b/src/leapflow/tools/gateway_tool.py @@ -789,6 +789,18 @@ def _structured_validation_error( 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 # ═══════════════════════════════════════════════════════════════ @@ -921,9 +933,23 @@ async def platform_action_handler(params: Dict[str, Any]) -> Dict[str, Any]: "retryable": False, } + feasibility = _gateway_server_ref.check_platform_action_feasibility( + platform, action_name, payload + ) + if not feasibility.get("ok"): + return feasibility + 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: @@ -939,6 +965,8 @@ 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", ""), }, ) @@ -957,6 +985,8 @@ async def platform_action_handler(params: Dict[str, Any]) -> Dict[str, Any]: "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), }, action=approval_action, )) diff --git a/tests/test_app_connector.py b/tests/test_app_connector.py index 181578b..2e2764a 100644 --- a/tests/test_app_connector.py +++ b/tests/test_app_connector.py @@ -3,13 +3,23 @@ import pytest from leapflow.gateway.adapters.feishu import FeishuAdapter +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 ActionPreview, ActionResult, ActionSpec, BackendStatus +from leapflow.gateway.connectors.protocol import ( + ActionAuthSpec, + ActionFailure, + ActionPreview, + ActionResult, + ActionSpec, + BackendStatus, +) class FakeBackend: @@ -92,6 +102,119 @@ def test_action_registry_loads_standard_action_pack_export() -> 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_blocks_only_matching_capability() -> None: + ledger = CapabilityHealthLedger() + read_spec = ActionSpec( + name="im.list_messages", + backend_kind="cli", + capability="im.message.read", + auth=ActionAuthSpec(scopes={"common": ("im:message:readonly",)}), + ) + send_spec = ActionSpec( + name="im.send_message", + backend_kind="cli", + 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 = ledger.check_feasibility("feishu", read_spec) + allowed = ledger.check_feasibility("feishu", send_spec) + + assert blocked["ok"] is False + assert blocked["skip_approval"] is True + assert blocked["failure_code"] == "missing_scope" + assert blocked["capability"] == "im.message.read" + assert allowed == {"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" + + # ═══════════════════════════════════════════════════════════════ # Payload normalization tests # ═══════════════════════════════════════════════════════════════ 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_gateway_tool_e2e.py b/tests/test_gateway_tool_e2e.py index 2a6f67e..48c0a91 100644 --- a/tests/test_gateway_tool_e2e.py +++ b/tests/test_gateway_tool_e2e.py @@ -4,7 +4,7 @@ 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 ( @@ -393,6 +393,105 @@ 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: + 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": "still allowed"}, + }) + finally: + set_gateway_approval_gate(None) + set_gateway_server(None) + + assert first["ok"] is False + assert first["failure_class"] == "authorization" + assert first["blocks_approval"] is True + assert len(gate.actions) == 2 + assert adapter.execute_calls == ["im.list_messages", "im.send_message"] + + assert second["ok"] is False + assert second["skip_approval"] is True + assert second["failure_code"] == "missing_scope" + assert second["capability"] == "im.message.read" + + assert send["ok"] is True + assert send["capability"] == "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) From f45e11a1ed82e88c2538b682be3104370b423ba9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8F=AD=E6=89=AC?= Date: Tue, 14 Jul 2026 16:41:07 +0800 Subject: [PATCH 07/14] =?UTF-8?q?feat(gateway):=20platform=20safety=20?= =?UTF-8?q?=E2=80=94=20degradation,=20resource=20provenance,=20anti-halluc?= =?UTF-8?q?ination?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Defense-in-depth against LLM hallucinating resource IDs after authorization failures. Addresses the critical flow: list_chats fails → model fabricates chat_id → approval passes with insufficient info → send times out. P0 — Platform-Level Degradation: - CapabilityHealthLedger now tracks platform degradation: when a hard authorization failure (admin_required) is recorded, ALL side-effect actions on that platform are blocked by feasibility check, regardless of whether their specific capability has been tested. - Adds llm_instruction field to blocked responses, explicitly instructing the model to stop and report the failure rather than guessing. - Transient failures (timeout, rate-limit) do NOT trigger degradation. - clear(platform) removes degradation when permissions are fixed. P1 — Resource Provenance Tracking: - New ResourceProvenancePool (session-scoped) registers resource IDs (chat_id, message_id, etc.) observed from successful API responses. - Before side-effect actions, resource fields in the payload are checked against the pool: VERIFIED (seen from API), UNVERIFIED (pool populated but ID not found → likely hallucinated), UNKNOWN (pool empty). - UNVERIFIED triggers provenance warnings shown in approval detail and elevates risk_hint to 0.9 for the approval gate. - Pool populated automatically from execute_platform_action results by scanning for fields matching declared resource_fields across specs. P0 — Anti-Hallucination Context Signals: - System prompt: "Resource identifier provenance rule" — explicitly forbids fabricating IDs when read actions have failed. - ToolEvidenceBuilder: propagates llm_instruction and platform_degraded fields to LLM context for auth failure results. - Feasibility responses include stop-signal for LLM consumption. All layers are platform-neutral and auto-extend to new platforms/actions. Co-authored-by: Cursor --- src/leapflow/engine/context_control.py | 5 + src/leapflow/gateway/capability_health.py | 149 ++++++++--- src/leapflow/gateway/resource_provenance.py | 199 +++++++++++++++ src/leapflow/gateway/server.py | 32 +++ src/leapflow/prompts/templates.py | 7 + src/leapflow/tools/gateway_tool.py | 40 ++- tests/test_app_connector.py | 266 +++++++++++++++++++- tests/test_gateway_tool_e2e.py | 16 +- 8 files changed, 662 insertions(+), 52 deletions(-) create mode 100644 src/leapflow/gateway/resource_provenance.py diff --git a/src/leapflow/engine/context_control.py b/src/leapflow/engine/context_control.py index 9205f1e..111fec3 100644 --- a/src/leapflow/engine/context_control.py +++ b/src/leapflow/engine/context_control.py @@ -328,6 +328,11 @@ def _platform_action_evidence(self, arguments: Dict[str, Any], result: Dict[str, for key in ("failure_code", "missing_fields", "recovery_hint", "expected_schema", "retryable"): if key in result: evidence[key] = result[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] = { diff --git a/src/leapflow/gateway/capability_health.py b/src/leapflow/gateway/capability_health.py index f544c5e..6ba0c93 100644 --- a/src/leapflow/gateway/capability_health.py +++ b/src/leapflow/gateway/capability_health.py @@ -8,6 +8,10 @@ - Platform-neutral: no Feishu/CLI-specific logic here. - Capability granularity: tracks by (platform, capability) so a read failure does not block send, and vice versa. +- Platform degradation: when an authorization failure is recorded, the entire + platform is marked as degraded for side-effect actions. This prevents + hallucinated resource IDs from reaching the approval gate when fundamental + permissions are missing. - Recoverability classes: retryable: transient — may succeed on retry (rate-limit, timeout) user_action: user must do something lightweight (re-auth, retry later) @@ -20,13 +24,12 @@ import logging import time from dataclasses import dataclass, field -from typing import Any, Dict, List, Tuple +from typing import Any, Dict, List, Set, Tuple from leapflow.gateway.connectors.protocol import ActionFailure, ActionSpec logger = logging.getLogger(__name__) -# Recoverability ranks — higher rank means further from automatic recovery. _RECOVERABILITY_RANK: Dict[str, int] = { "retryable": 0, "user_action": 1, @@ -34,13 +37,11 @@ "non_recoverable": 3, } -# Failure classes that should block approval because no amount of user consent -# can fix the underlying platform authorization gap. _BLOCKS_APPROVAL_CLASSES = frozenset({"authorization", "scope_denied"}) - -# Failure classes that are transient and should NOT block approval. _TRANSIENT_CLASSES = frozenset({"timeout", "rate_limit", "transient"}) +_SIDE_EFFECT_KINDS = frozenset({"send", "write", "execute"}) + @dataclass class CapabilityStatus: @@ -50,7 +51,6 @@ class CapabilityStatus: capability: str failure: ActionFailure recorded_at: float = field(default_factory=time.monotonic) - # TTL in seconds; 0 = session-scoped (never expires within a session). ttl_s: float = 0.0 @property @@ -75,8 +75,15 @@ class CapabilityHealthLedger: """ def __init__(self) -> None: - # Key: (platform, capability) self._records: Dict[Tuple[str, str], CapabilityStatus] = {} + # 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, @@ -88,9 +95,9 @@ def record_failure( ) -> None: """Record a capability failure. - If a worse (higher-rank) failure already exists for this capability, - the existing entry is replaced only when the new failure has equal or - higher rank. + 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) @@ -98,18 +105,39 @@ def record_failure( 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 # Keep the existing worse record. + return self._records[key] = CapabilityStatus( platform=platform, capability=capability, failure=failure, ttl_s=ttl_s, ) + if ( + failure.failure_class in _BLOCKS_APPROVAL_CLASSES + and failure.recoverability in ("admin_required", "non_recoverable") + ): + reason = ( + f"Authorization failure on {capability}: " + f"{failure.message or failure.failure_code}" + ) + self._degraded_platforms[platform] = reason + logger.info( + "platform_degraded platform=%s capability=%s code=%s", + platform, capability, failure.failure_code, + ) 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 is_platform_degraded(self, platform: str) -> bool: + """Return True if the platform has an unresolved authorization failure.""" + return platform in self._degraded_platforms + + def platform_degradation_reason(self, platform: str) -> str: + """Return the degradation reason, or empty string if not degraded.""" + 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) @@ -127,42 +155,56 @@ def check_feasibility( ) -> dict[str, Any]: """Return feasibility dict for a platform action. - Returns ``{"ok": True}`` when the action may proceed to approval. - Returns a structured failure dict when the ledger has a blocking record - so platform_action_handler can short-circuit without touching approval. + Checks (in order): + 1. Direct capability failure (same as before) + 2. Platform-level degradation for side-effect actions """ capability = spec.capability or spec.name + + # Direct capability failure check. status = self.get(platform, capability) - if status is None or not status.should_block_approval: - return {"ok": True} - failure = status.failure - 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, # Signal to caller: no approval needed or useful. - } - failure_dict = failure.as_dict() - for key in ("recovery_hint", "next_steps", "missing_scopes", "required_scopes", - "requested_scopes", "granted_scopes", "identity", "console_url"): - if failure_dict.get(key): - response[key] = failure_dict[key] - return response + if status is not None and status.should_block_approval: + return self._failure_response(platform, spec, status.failure) + + # Platform degradation blocks side-effect actions even when the + # specific capability has not been tested yet. + 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 for a platform, or all entries if platform is 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.""" @@ -177,5 +219,38 @@ def summary(self) -> List[dict[str, Any]]: "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"): + if failure_dict.get(key): + response[key] = failure_dict[key] + return response 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/server.py b/src/leapflow/gateway/server.py index 4176710..99a1df0 100644 --- a/src/leapflow/gateway/server.py +++ b/src/leapflow/gateway/server.py @@ -26,6 +26,7 @@ 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.event_sources import UnavailableEventSource @@ -197,6 +198,7 @@ def __init__( self._on_event = on_event self._started = False self._capability_health = CapabilityHealthLedger() + self._resource_provenance = ResourceProvenancePool() # ── Manifest discovery ─────────────────────────────────── @@ -542,6 +544,30 @@ 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, platform_id: str, @@ -565,6 +591,12 @@ async def execute_platform_action( spec.capability or spec.name, result.failure, ) + elif result.ok: + 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 diff --git a/src/leapflow/prompts/templates.py b/src/leapflow/prompts/templates.py index d569f3c..6da2ecb 100644 --- a/src/leapflow/prompts/templates.py +++ b/src/leapflow/prompts/templates.py @@ -97,6 +97,13 @@ def build_react_system(language: str = "en", skill_catalog: str = "") -> str: - 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. 2. **Avoid redundant tool calls**: Do not call the same tool with the same arguments more than once in the same user turn. When an existing tool result already answers the user's request, stop calling tools and answer directly. diff --git a/src/leapflow/tools/gateway_tool.py b/src/leapflow/tools/gateway_tool.py index e01a4ad..b035ae8 100644 --- a/src/leapflow/tools/gateway_tool.py +++ b/src/leapflow/tools/gateway_tool.py @@ -939,6 +939,29 @@ async def platform_action_handler(params: Dict[str, Any]) -> Dict[str, Any]: 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"): failure_info = {k: v for k, v in preview.items() if k not in ("ok",)} @@ -968,8 +991,20 @@ async def platform_action_handler(params: Dict[str, Any]) -> Dict[str, Any]: "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): @@ -978,8 +1013,8 @@ 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, @@ -987,6 +1022,7 @@ async def platform_action_handler(params: Dict[str, Any]) -> Dict[str, Any]: "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, )) diff --git a/tests/test_app_connector.py b/tests/test_app_connector.py index 2e2764a..b6bb0ed 100644 --- a/tests/test_app_connector.py +++ b/tests/test_app_connector.py @@ -158,17 +158,20 @@ def test_summarize_action_result_preserves_structured_failure() -> None: assert summary["capability"] == "im.message.read" -def test_capability_health_ledger_blocks_only_matching_capability() -> None: +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",)}), ) @@ -185,14 +188,24 @@ def test_capability_health_ledger_blocks_only_matching_capability() -> None: ledger.record_failure("feishu", read_spec.capability, failure) - blocked = ledger.check_feasibility("feishu", read_spec) - allowed = ledger.check_feasibility("feishu", send_spec) + blocked_read = ledger.check_feasibility("feishu", read_spec) + blocked_send = ledger.check_feasibility("feishu", send_spec) - assert blocked["ok"] is False - assert blocked["skip_approval"] is True - assert blocked["failure_code"] == "missing_scope" - assert blocked["capability"] == "im.message.read" - assert allowed == {"ok": True} + # Direct capability failure blocks the read action + assert blocked_read["ok"] is False + assert blocked_read["skip_approval"] is True + assert blocked_read["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: @@ -542,3 +555,240 @@ async def preview(self, spec, payload): 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 that already failed is blocked directly, not via degradation.""" + 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 False + assert result["failure_code"] == "missing_scope" + + +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_gateway_tool_e2e.py b/tests/test_gateway_tool_e2e.py index 48c0a91..e511753 100644 --- a/tests/test_gateway_tool_e2e.py +++ b/tests/test_gateway_tool_e2e.py @@ -450,6 +450,7 @@ async def execute_action(self, action: str, payload: dict) -> ActionResult: @pytest.mark.asyncio async def test_platform_action_blocks_known_permission_failure_before_approval(tmp_path) -> None: + """Auth failure degrades platform: both retry of same action AND send are blocked.""" server = GatewayServer(tmp_path) adapter = PermissionFailingAdapter() gate = CaptureGate() @@ -471,25 +472,30 @@ async def test_platform_action_blocks_known_permission_failure_before_approval(t send = await platform_action_handler({ "platform": "fake", "action": "im.send_message", - "payload": {"chat_id": "chat-1", "text": "still allowed"}, + "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 - assert len(gate.actions) == 2 - assert adapter.execute_calls == ["im.list_messages", "im.send_message"] + # Only the first call reaches execution; after that, feasibility blocks + assert len(gate.actions) == 1 + assert adapter.execute_calls == ["im.list_messages"] + # Second call blocked by direct capability failure (no execution) assert second["ok"] is False assert second["skip_approval"] is True assert second["failure_code"] == "missing_scope" assert second["capability"] == "im.message.read" - assert send["ok"] is True - assert send["capability"] == "im.send_message" + # 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 @pytest.mark.asyncio From ecaa94cb664c85beff3c6f336a9e6adbadb47744 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8F=AD=E6=89=AC?= Date: Tue, 14 Jul 2026 16:56:22 +0800 Subject: [PATCH 08/14] feat(security): add sensitive file approval gate --- src/leapflow/cli/context.py | 45 +++++- src/leapflow/daemon/service.py | 31 +++- src/leapflow/security/actions.py | 20 +++ src/leapflow/security/path_sensitivity.py | 177 ++++++++++++++++++++++ src/leapflow/security/redact.py | 3 +- src/leapflow/security/risk.py | 34 +++++ src/leapflow/tools/file_operations.py | 153 ++++++++++--------- src/leapflow/tools/registry_bootstrap.py | 15 +- tests/test_approval_layer.py | 128 +++++++++++++++- 9 files changed, 525 insertions(+), 81 deletions(-) create mode 100644 src/leapflow/security/path_sensitivity.py diff --git a/src/leapflow/cli/context.py b/src/leapflow/cli/context.py index 87e9eea..22bc4e9 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( @@ -1907,6 +1911,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 @@ -1920,8 +1954,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 diff --git a/src/leapflow/daemon/service.py b/src/leapflow/daemon/service.py index 97f014b..fcfe1d6 100644 --- a/src/leapflow/daemon/service.py +++ b/src/leapflow/daemon/service.py @@ -384,7 +384,7 @@ def _install_daemon_approval(self, ctx: Any) -> None: 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 +399,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/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/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..439451c 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,28 @@ 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), + } + approved = await gate.check(str(target), mode, _sensitivity_metadata(sensitivity)) + 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 +237,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 +260,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 +276,34 @@ 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) + approved = await gate.check(str(target), content, mode, _sensitivity_metadata(sensitivity)) 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 +322,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/registry_bootstrap.py b/src/leapflow/tools/registry_bootstrap.py index 4ebb9ea..7a93f27 100644 --- a/src/leapflow/tools/registry_bootstrap.py +++ b/src/leapflow/tools/registry_bootstrap.py @@ -456,14 +456,25 @@ async def _delegate_task_handler(params: Dict[str, Any]) -> Dict[str, Any]: # ───────────────────────────────────────────────────────────────────── -# 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_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"] From 090b8eb27317bb78f9256c36c1502fbaac5db847 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8F=AD=E6=89=AC?= Date: Tue, 14 Jul 2026 19:36:51 +0800 Subject: [PATCH 09/14] Rework progressive context disclosure with capability manifests + fail-closed defaults MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Core PCD redesign (context_disclosure.py): - Replace natural-language tool-name guessing with a manifest-driven planner: disclosure decisions now derive only from structural facts (capability manifests, slash commands, context posture, recent failures, prior-turn tool-category continuity). - Three stable levels: CORE (always-on Tier 0 index + Tier 0.5 read-only whitelist), EXPANDED (+ Tier 1 categories opened by structural gates), FULL (safety/compat fallback for high-stakes turns). - CapabilityManifest.is_core is a static, auditable property (read_only risk + non-high schema cost), never a per-turn guess. - Fail-closed unclassified fallback: tools that declare no explicit x_leapflow metadata and match no recognized safe keyword now default to "unclassified"/medium risk (non-core) instead of silently trusting them as "general"/read_only. text_search is explicitly whitelisted to avoid a regression from this tightened default. - CORE-level reasoning stays OFF unless a Tier 1 category is open, to preserve the low-latency pure-chat path. Security-relevant tool classification fixes: - hub_tool.py / gateway_tool.py: add explicit x_leapflow metadata to all hub/platform/gateway tools. Their descriptions previously lacked distinguishing keywords, causing gateway_connect/platform_connect to be keyword-misclassified as "general"/read_only and silently promoted into the always-on core whitelist. Tier 1 continuity fix (engine.py): - _recent_tool_categories() previously tried to recover categories from a synthetic "[Called: ...]" text summary in working memory, which has no structured tool_calls field — Tier 1 continuity never actually fired. Replaced with a dedicated engine-instance state (_last_turn_tool_categories), reset per turn and populated from the turn's actual executed native tool_calls. - _format_tool_catalog now annotates every non-core tool with its exact capability_expand category so the model never has to guess the string. registry_bootstrap.py: - capability_expand's description is now patched at import time with the live, computed set of non-core categories instead of a hardcoded example list that could drift from the real registry. UX regression fixes: - tui_app/stream.py: update the disclosure-tag suppression set from the retired "selected_tools" level to the current "core" default, so normal chat turns no longer show a noisy "disclosure=core" tag. Tests: - Rewrote test_context_disclosure.py against the new manifest/planner API, including a regression test pinning hub/gateway tools as explicitly non-core. - Added an end-to-end regression test for Tier 1 continuity (test_progressive_disclosure_expands_write_category_after_prior_turn_tool_use). - Updated stale assertions in test_agent_execution.py and test_tui_command_queue.py that referenced retired disclosure semantics. 514 tests pass; all modified modules compile and import standalone. --- src/leapflow/cli/tui_app/console.py | 76 +++- src/leapflow/cli/tui_app/stream.py | 58 ++- src/leapflow/engine/context_control.py | 63 ++- src/leapflow/engine/context_disclosure.py | 393 +++++++----------- src/leapflow/engine/engine.py | 325 ++++++++++++--- .../gateway/backends/lark_cli_errors.py | 74 +++- src/leapflow/gateway/connectors/protocol.py | 17 + src/leapflow/prompts/templates.py | 28 +- src/leapflow/tools/gateway_tool.py | 39 +- src/leapflow/tools/hub_tool.py | 24 ++ src/leapflow/tools/registry_bootstrap.py | 105 +++++ tests/test_agent_execution.py | 184 +++++++- tests/test_app_connector.py | 58 +++ tests/test_context_disclosure.py | 147 ++++--- tests/test_context_governance.py | 37 ++ tests/test_tui_command_queue.py | 68 ++- 16 files changed, 1279 insertions(+), 417 deletions(-) diff --git a/src/leapflow/cli/tui_app/console.py b/src/leapflow/cli/tui_app/console.py index b8fbd6c..ca47151 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({ @@ -237,6 +251,66 @@ 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") + + self._console.print(Panel( + body.rstrip(), + 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( diff --git a/src/leapflow/cli/tui_app/stream.py b/src/leapflow/cli/tui_app/stream.py index 7fc29b3..cde885b 100644 --- a/src/leapflow/cli/tui_app/stream.py +++ b/src/leapflow/cli/tui_app/stream.py @@ -34,6 +34,9 @@ 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,24 @@ 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 + failure_class = _metadata_text(metadata, "failure_class") + failure_code = _metadata_text(metadata, "failure_code") + if failure_class in _PERMISSION_FAILURE_CLASSES or failure_code in _PERMISSION_FAILURE_CODES: + 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 +195,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 +244,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"): @@ -369,7 +414,10 @@ 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) and 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") diff --git a/src/leapflow/engine/context_control.py b/src/leapflow/engine/context_control.py index 111fec3..8debe7e 100644 --- a/src/leapflow/engine/context_control.py +++ b/src/leapflow/engine/context_control.py @@ -325,9 +325,17 @@ def _platform_action_evidence(self, arguments: Dict[str, Any], result: Dict[str, """Compact evidence for platform_action with strong completion markers.""" if result.get("ok") is False: evidence = self._compact_error(result) - for key in ("failure_code", "missing_fields", "recovery_hint", "expected_schema", "retryable"): + 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] = result[key] + 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"): @@ -363,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 813480c..9600167 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, @@ -158,6 +159,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)) @@ -168,8 +174,11 @@ def _tool_result_metadata( if key in result: metadata[key] = result[key] # App Connector authorization failure metadata - for key in ("failure_class", "failure_code", "recoverability", "blocks_approval", - "capability", "missing_scopes", "required_scopes", "skip_approval"): + 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"): @@ -256,13 +265,72 @@ 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. +_PERMISSION_FAILURE_CLASSES = frozenset({"authorization", "scope_denied"}) +_PERMISSION_FAILURE_CODES = frozenset({"access_denied", "missing_scope"}) - 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_failure_payload(payload: Dict[str, Any]) -> bool: + """Return whether a tool-result payload represents an unresolved permission failure.""" + if not isinstance(payload, dict) 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 _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() @@ -280,48 +348,80 @@ 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 "" last = failures[0] failure_code = str(last.get("failure_code") or "") - failure_class = str(last.get("failure_class") or "") error = str(last.get("error") or "") recovery_hint = str(last.get("recovery_hint") or "") - recoverability = str(last.get("recoverability") or "") available_actions: List[str] = list(last.get("available_action_names") or []) - platform = str(last.get("platform") or "") - capability = str(last.get("capability") or "") - missing_scopes: List[str] = list(last.get("missing_scopes") or []) - required_scopes: List[str] = list(last.get("required_scopes") or []) - console_url = str(last.get("console_url") or "") - blocks_approval = bool(last.get("blocks_approval", False)) lines: List[str] = [] # Authorization / permission failures — deterministic, no retry via LLM - if failure_class in ("authorization", "scope_denied") or failure_code in ("access_denied", "missing_scope"): - where = f"`{platform}.{capability}`" if platform and capability else (capability or platform or "this action") - scope_detail = "" - if missing_scopes: - scope_detail = f" Missing scopes: {', '.join(missing_scopes)}." - elif required_scopes: - scope_detail = f" Required scopes: {', '.join(required_scopes)}." - lines.append( - f"Authorization failed for {where}. " - f"The platform has denied access — this cannot be resolved by retrying.{scope_detail}" - ) - 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 — inform the user that manual authorization is required.") + 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 "") @@ -758,6 +858,12 @@ 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._healer = MessageHealer() # B2: Prompt cache optimization (None = disabled) @@ -1230,13 +1336,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", @@ -1248,7 +1354,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, @@ -1261,6 +1367,71 @@ 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. + """ + from leapflow.tools.registry_bootstrap import TOOL_DEFINITIONS + + 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 = 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() @@ -1926,6 +2097,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), @@ -2019,7 +2194,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, @@ -2029,11 +2207,13 @@ 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) retryable_unknown = next( ( @@ -2045,6 +2225,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 @@ -2059,7 +2241,7 @@ 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: @@ -2074,17 +2256,21 @@ async def _unified_tool_loop( )) 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") _show_progress("executing", tool_name) result = await self._execute_general_tool(normalized_tool_call, TOOL_HANDLERS) @@ -2139,7 +2325,11 @@ async def _unified_tool_loop( logger.info("turn_usage: %s", self._usage_tracker.format_log_line()) if content: - return content + # Never let a free-text LLM answer paraphrase or expand scope names + # after an unresolved permission failure — force the deterministic + # recovery message instead. + permission_override = _permission_override_message(messages) + return permission_override or content if fatal_error: return fatal_error # Derive a recovery message from the last platform_connect failure if present @@ -2291,6 +2481,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), @@ -2383,7 +2577,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, @@ -2394,7 +2590,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"), ) @@ -2414,6 +2610,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( ( @@ -2445,6 +2643,8 @@ async def _unified_tool_loop_stream( 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: @@ -2455,7 +2655,7 @@ 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( @@ -2554,23 +2754,27 @@ 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.") else: - yield StreamEvent(type="final", content=content) + permission_override = _permission_override_message(messages) + yield StreamEvent(type="final", content=permission_override or content) return - messages.append(build_assistant_message(content)) + # Text-mode preamble exclusion: only store call summary in WM. 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") yield StreamEvent( type="tool_start", @@ -2651,7 +2855,8 @@ 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) + yield StreamEvent(type="final", content=permission_override or content) else: fallback = ( _app_onboarding_recovery_message(messages) @@ -2666,7 +2871,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", {}) @@ -2675,7 +2886,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 diff --git a/src/leapflow/gateway/backends/lark_cli_errors.py b/src/leapflow/gateway/backends/lark_cli_errors.py index 9ddc69c..7d18d7f 100644 --- a/src/leapflow/gateway/backends/lark_cli_errors.py +++ b/src/leapflow/gateway/backends/lark_cli_errors.py @@ -12,6 +12,36 @@ 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, @@ -56,6 +86,11 @@ def classify_lark_cli_failure( 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", @@ -66,7 +101,7 @@ def classify_lark_cli_failure( "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}" + f"the application, then retry. {capability_hint}{scope_line}" ), next_steps=( f"Open the developer console and grant the required scopes for capability '{capability}'.", @@ -74,6 +109,9 @@ def classify_lark_cli_failure( 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, @@ -81,6 +119,11 @@ def classify_lark_cli_failure( 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", @@ -88,7 +131,7 @@ def classify_lark_cli_failure( recoverability="admin_required", retryable=False, recovery_hint=( - f"Missing required scope to execute '{spec.name}'. " + 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=( @@ -96,6 +139,9 @@ def classify_lark_cli_failure( "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, @@ -192,6 +238,18 @@ def _classify_problem_error( 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) @@ -200,6 +258,11 @@ def _classify_problem_error( 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. " @@ -211,8 +274,8 @@ def _classify_problem_error( recovery_hint_parts.append(f"Log ID: {log_id}") next_steps: tuple[str, ...] = ( *( - (f"Grant missing scopes {list(missing_scopes)} in the developer console.",) - if missing_scopes else (f"Grant the required scope for '{capability}' in the developer console.",) + (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}", @@ -228,6 +291,7 @@ def _classify_problem_error( 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, @@ -235,6 +299,8 @@ def _classify_problem_error( capability=capability, blocks_approval=True, raw=dict(error_obj), + scope_relation=scope_relation, + scope_source=scope_source, ) if err_type == "authentication": diff --git a/src/leapflow/gateway/connectors/protocol.py b/src/leapflow/gateway/connectors/protocol.py index 6804c0c..dedeb45 100644 --- a/src/leapflow/gateway/connectors/protocol.py +++ b/src/leapflow/gateway/connectors/protocol.py @@ -54,6 +54,20 @@ class ActionFailure: 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.""" @@ -78,6 +92,9 @@ def as_dict(self) -> dict[str, Any]: 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 diff --git a/src/leapflow/prompts/templates.py b/src/leapflow/prompts/templates.py index 6da2ecb..bc2681b 100644 --- a/src/leapflow/prompts/templates.py +++ b/src/leapflow/prompts/templates.py @@ -73,22 +73,24 @@ 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. +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. diff --git a/src/leapflow/tools/gateway_tool.py b/src/leapflow/tools/gateway_tool.py index b035ae8..9252375 100644 --- a/src/leapflow/tools/gateway_tool.py +++ b/src/leapflow/tools/gateway_tool.py @@ -384,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 @@ -1077,6 +1062,12 @@ async def platform_action_handler(params: Dict[str, Any]) -> Dict[str, Any]: }, "required": ["platform", "action", "payload"], }, + "x_leapflow": { + "category": "gateway", + "risk_level": "high", + "schema_cost": "high", + "requires_approval": True, + }, }, }, { @@ -1100,6 +1091,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, + }, }, }, { @@ -1135,6 +1132,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, + }, }, }, { @@ -1186,6 +1189,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/registry_bootstrap.py b/src/leapflow/tools/registry_bootstrap.py index 7a93f27..8d2b38c 100644 --- a/src/leapflow/tools/registry_bootstrap.py +++ b/src/leapflow/tools/registry_bootstrap.py @@ -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,6 +489,69 @@ 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, diff --git a/tests/test_agent_execution.py b/tests/test_agent_execution.py index 95f0969..f7219f6 100644 --- a/tests/test_agent_execution.py +++ b/tests/test_agent_execution.py @@ -608,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 @@ -626,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() @@ -715,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() @@ -957,3 +1048,86 @@ 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) == "" + + +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) == "" diff --git a/tests/test_app_connector.py b/tests/test_app_connector.py index b6bb0ed..76b250d 100644 --- a/tests/test_app_connector.py +++ b/tests/test_app_connector.py @@ -228,6 +228,64 @@ def test_lark_cli_error_classifier_from_plain_access_denied() -> None: 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 # ═══════════════════════════════════════════════════════════════ 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_tui_command_queue.py b/tests/test_tui_command_queue.py index 40bbf90..5e6cef4 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 @@ -550,7 +550,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 @@ -642,8 +642,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)", }, ) @@ -713,6 +713,66 @@ 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"] + + @pytest.mark.asyncio async def test_process_loop_does_not_duplicate_done_card_when_response_label_owns_status() -> None: holder: dict[str, LeapApp] = {} From 7948fac83413be74e469cdd7e79049965b23cbec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8F=AD=E6=89=AC?= Date: Tue, 14 Jul 2026 21:28:10 +0800 Subject: [PATCH 10/14] Fix platform permission blockers as hard-stop TUI blocked turns - Treat authorization blockers (access_denied, missing_scope, platform_degraded, blocks_approval, admin_required non-retryable failures) as hard-stop tool results across native/text and stream/non-stream engine paths. - Stop remaining sequential native tool calls when a permission blocker is hit; classify platform/gateway/hub stateful tools as never-parallel. - Add shared permission-failure predicates under security/permission_failures.py so engine and TUI use the same authority for permission recovery detection. - Add TuiCommandStatus.BLOCKED and wire local + daemon interactive response labels to render permission-blocked turns as #N blocked instead of failed. - Fix permission recovery card rendering so Rich never receives Panel(None). - Add regression coverage for text/native hard-stop, blocked labels, command terminal state, and permission recovery card renderability. Validation: py_compile modified files; 518 tests pass. --- src/leapflow/cli/commands/interactive.py | 14 ++- src/leapflow/cli/tui_app/app.py | 14 +++ src/leapflow/cli/tui_app/command.py | 10 ++ src/leapflow/cli/tui_app/console.py | 17 ++- src/leapflow/cli/tui_app/stream.py | 56 +++++++-- src/leapflow/engine/engine.py | 83 +++++++++++-- src/leapflow/engine/tool_concurrency.py | 11 +- src/leapflow/security/permission_failures.py | 35 ++++++ tests/test_agent_execution.py | 124 +++++++++++++++++++ tests/test_tui_command_queue.py | 63 +++++++++- 10 files changed, 398 insertions(+), 29 deletions(-) create mode 100644 src/leapflow/security/permission_failures.py diff --git a/src/leapflow/cli/commands/interactive.py b/src/leapflow/cli/commands/interactive.py index 15e23e7..78225fd 100644 --- a/src/leapflow/cli/commands/interactive.py +++ b/src/leapflow/cli/commands/interactive.py @@ -461,7 +461,12 @@ async def _stream_response(prompt_text: str) -> None: renderer.feed(str(event)) turn_completed = True finally: - command = app.complete_active_command_in_response() if turn_completed else None + 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() @@ -1064,7 +1069,12 @@ async def _stream_response( ) raise finally: - command = app.complete_active_command_in_response() if turn_completed else None + 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() diff --git a/src/leapflow/cli/tui_app/app.py b/src/leapflow/cli/tui_app/app.py index d3772f0..506d0ce 100644 --- a/src/leapflow/cli/tui_app/app.py +++ b/src/leapflow/cli/tui_app/app.py @@ -315,6 +315,18 @@ def complete_active_command_in_response(self) -> Optional[TuiCommand]: 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() @@ -407,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: 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 ca47151..c8e9c07 100644 --- a/src/leapflow/cli/tui_app/console.py +++ b/src/leapflow/cli/tui_app/console.py @@ -151,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", @@ -159,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", @@ -303,8 +305,12 @@ def permission_recovery_card(self, metadata: Mapping[str, Any]) -> None: 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.rstrip(), + body, title=Text("🔐 权限恢复", style="bold"), title_align="left", border_style="leap.warning", @@ -386,7 +392,14 @@ def response_label( label.append(" |-- ", style="leap.border") label.append("LEAP", style="leap.accent") if command is not None: - command_style = "leap.success" if command.status == TuiCommandStatus.DONE else "leap.dim" + 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") diff --git a/src/leapflow/cli/tui_app/stream.py b/src/leapflow/cli/tui_app/stream.py index cde885b..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 @@ -35,8 +37,6 @@ re.MULTILINE, ) _MARKDOWN_LINK_RE = re.compile(r"(? bool: """Return whether metadata represents a manual authorization recovery case.""" if not metadata or metadata.get("ok", True) is not False: return False - failure_class = _metadata_text(metadata, "failure_class") - failure_code = _metadata_text(metadata, "failure_code") - if failure_class in _PERMISSION_FAILURE_CLASSES or failure_code in _PERMISSION_FAILURE_CODES: + if is_permission_failure_payload(metadata): return True return bool( _metadata_text(metadata, "console_url") @@ -323,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: @@ -348,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.""" @@ -372,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 @@ -415,8 +436,12 @@ def tool_finished( self._console.print(line) recovery_hint = _metadata_text(metadata, "recovery_hint") recovery_card = getattr(self._console, "permission_recovery_card", None) - if _is_permission_recovery_metadata(metadata) and callable(recovery_card): - recovery_card(metadata) + 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") @@ -428,6 +453,11 @@ def tool_finished( 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) diff --git a/src/leapflow/engine/engine.py b/src/leapflow/engine/engine.py index 9600167..2a304b8 100644 --- a/src/leapflow/engine/engine.py +++ b/src/leapflow/engine/engine.py @@ -69,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 @@ -265,17 +269,23 @@ def _unknown_tool_retry_prompt(result: Dict[str, Any]) -> str: ) -_PERMISSION_FAILURE_CLASSES = frozenset({"authorization", "scope_denied"}) -_PERMISSION_FAILURE_CODES = frozenset({"access_denied", "missing_scope"}) - - def _is_permission_failure_payload(payload: Dict[str, Any]) -> bool: """Return whether a tool-result payload represents an unresolved permission failure.""" - if not isinstance(payload, dict) 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 + return is_permission_failure_payload(payload) + + +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: @@ -2215,6 +2225,15 @@ async def _unified_tool_loop( 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( ( item.get("result") @@ -2294,6 +2313,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))) @@ -2639,6 +2666,15 @@ 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: @@ -2824,6 +2860,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))) @@ -3000,6 +3044,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) @@ -3056,13 +3106,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) @@ -3091,6 +3148,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/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/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/tests/test_agent_execution.py b/tests/test_agent_execution.py index f7219f6..b60de4d 100644 --- a/tests/test_agent_execution.py +++ b/tests/test_agent_execution.py @@ -1050,6 +1050,130 @@ def test_last_tool_failures_recovery_message_returns_empty_when_no_failures() -> 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 diff --git a/tests/test_tui_command_queue.py b/tests/test_tui_command_queue.py index 5e6cef4..e1b2f2e 100644 --- a/tests/test_tui_command_queue.py +++ b/tests/test_tui_command_queue.py @@ -442,6 +442,29 @@ def print(self, renderable) -> None: 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: @@ -771,6 +794,40 @@ def newline(self) -> None: 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 @@ -839,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" @@ -851,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: From b46d20d339e819665121a91183f9bc81a2736fba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8F=AD=E6=89=AC?= Date: Tue, 14 Jul 2026 23:34:15 +0800 Subject: [PATCH 11/14] feat: harden slash commands and permission refresh --- src/leapflow/cli/commands/interactive.py | 181 +++---- src/leapflow/cli/commands/registry.py | 54 +-- src/leapflow/cli/commands/router.py | 16 +- src/leapflow/cli/commands/slash_handlers.py | 510 +++++++++++++++++++- src/leapflow/daemon/client.py | 5 + src/leapflow/daemon/protocol.py | 5 + src/leapflow/daemon/service.py | 6 + src/leapflow/gateway/capability_health.py | 129 +++-- src/leapflow/gateway/server.py | 13 + src/leapflow/tools/gateway_tool.py | 9 + tests/test_app_connector.py | 36 +- tests/test_gateway_tool_e2e.py | 62 ++- tests/test_slash_command_router.py | 63 ++- 13 files changed, 871 insertions(+), 218 deletions(-) diff --git a/src/leapflow/cli/commands/interactive.py b/src/leapflow/cli/commands/interactive.py index 78225fd..71b006c 100644 --- a/src/leapflow/cli/commands/interactive.py +++ b/src/leapflow/cli/commands/interactive.py @@ -829,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, @@ -1092,119 +1093,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}") - return - render_usage_payload(console, payload) - 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}") + if canonical == "clear": + _render_banner() return - render_model_payload(console, payload) + # Task control commands are handled by the existing _handle_task_control + _handle_task_control(text) 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 + + # 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 non-command text as annotation + if daemon_session_mode == "learning": + try: + payload = await bridge.call( + lambda current_client: current_client.command_execute("annotate", text), + description="annotate", + ) + except Exception as exc: + console.warning(f"Annotation failed: {exc}") + return + # If session ended unexpectedly, track it + new_mode = payload.get("session_mode") + if isinstance(new_mode, str): + daemon_session_mode = new_mode + if payload.get("ok"): + console.system(f"(Noted as annotation during learning)") + else: + console.warning(str(payload.get("message") or "Annotation failed.")) return console.rule() @@ -1632,16 +1606,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..65ded88 100644 --- a/src/leapflow/cli/commands/registry.py +++ b/src/leapflow/cli/commands/registry.py @@ -48,49 +48,49 @@ 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]"), @@ -106,8 +106,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 +118,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..3a34bb7 100644 --- a/src/leapflow/cli/commands/slash_handlers.py +++ b/src/leapflow/cli/commands/slash_handlers.py @@ -732,7 +732,515 @@ 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 _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 and 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() + return { + "ok": True, + "message": f"Recording stopped — {result.step_count} steps, {result.duration:.1f}s", + "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": "learning"} + + 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"} + + 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:]) + return {"ok": False, "message": f"Hub command '/{name} {args}'.strip() 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/daemon/client.py b/src/leapflow/daemon/client.py index 6747ee6..4fd3572 100644 --- a/src/leapflow/daemon/client.py +++ b/src/leapflow/daemon/client.py @@ -143,6 +143,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/protocol.py b/src/leapflow/daemon/protocol.py index 22ac851..0cf0f23 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", diff --git a/src/leapflow/daemon/service.py b/src/leapflow/daemon/service.py index fcfe1d6..7e2180e 100644 --- a/src/leapflow/daemon/service.py +++ b/src/leapflow/daemon/service.py @@ -230,6 +230,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()} diff --git a/src/leapflow/gateway/capability_health.py b/src/leapflow/gateway/capability_health.py index 6ba0c93..edd4849 100644 --- a/src/leapflow/gateway/capability_health.py +++ b/src/leapflow/gateway/capability_health.py @@ -2,34 +2,37 @@ Maintains a session-scoped ledger that records authorization failures for platform capabilities and exposes a feasibility check so platform_action_handler -can block hopeless retries before they reach the approval gate. +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 a read failure - does not block send, and vice versa. -- Platform degradation: when an authorization failure is recorded, the entire - platform is marked as degraded for side-effect actions. This prevents - hallucinated resource IDs from reaching the approval gate when fundamental - permissions are missing. +- 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) -- Ledger is session-scoped; cleared between agent turns via clear() if needed. """ from __future__ import annotations import logging import time from dataclasses import dataclass, field -from typing import Any, Dict, List, Set, Tuple +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, @@ -74,8 +77,9 @@ class CapabilityHealthLedger: Thread-safety: single-threaded async usage expected; no locking. """ - def __init__(self) -> None: + 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 @@ -106,36 +110,51 @@ def record_failure( 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=ttl_s, + ttl_s=effective_ttl_s, ) - if ( - failure.failure_class in _BLOCKS_APPROVAL_CLASSES - and failure.recoverability in ("admin_required", "non_recoverable") - ): - reason = ( - f"Authorization failure on {capability}: " - f"{failure.message or failure.failure_code}" - ) - self._degraded_platforms[platform] = reason - logger.info( - "platform_degraded platform=%s capability=%s code=%s", - platform, capability, failure.failure_code, - ) + 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: @@ -145,6 +164,7 @@ def get(self, platform: str, capability: str) -> CapabilityStatus | None: 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 @@ -156,18 +176,29 @@ def check_feasibility( """Return feasibility dict for a platform action. Checks (in order): - 1. Direct capability failure (same as before) + 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 - # Direct capability failure check. 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) - # Platform degradation blocks side-effect actions even when the - # specific capability has not been tested yet. if spec.effect in _SIDE_EFFECT_KINDS and self.is_platform_degraded(platform): reason = self._degraded_platforms[platform] return { @@ -209,6 +240,7 @@ def clear(self, platform: str | None = None) -> 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 @@ -250,7 +282,46 @@ def _failure_response( } failure_dict = failure.as_dict() for key in ("recovery_hint", "next_steps", "missing_scopes", "required_scopes", - "requested_scopes", "granted_scopes", "identity", "console_url"): + "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/server.py b/src/leapflow/gateway/server.py index 99a1df0..9f09dc2 100644 --- a/src/leapflow/gateway/server.py +++ b/src/leapflow/gateway/server.py @@ -540,6 +540,15 @@ def check_platform_action_feasibility( 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() @@ -592,6 +601,10 @@ async def execute_platform_action( 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( diff --git a/src/leapflow/tools/gateway_tool.py b/src/leapflow/tools/gateway_tool.py index 9252375..2558db8 100644 --- a/src/leapflow/tools/gateway_tool.py +++ b/src/leapflow/tools/gateway_tool.py @@ -423,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")), @@ -629,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]} diff --git a/tests/test_app_connector.py b/tests/test_app_connector.py index 76b250d..6af2da5 100644 --- a/tests/test_app_connector.py +++ b/tests/test_app_connector.py @@ -191,10 +191,10 @@ def test_capability_health_ledger_degrades_platform_for_side_effects() -> None: blocked_read = ledger.check_feasibility("feishu", read_spec) blocked_send = ledger.check_feasibility("feishu", send_spec) - # Direct capability failure blocks the read action - assert blocked_read["ok"] is False - assert blocked_read["skip_approval"] is True - assert blocked_read["failure_code"] == "missing_scope" + # 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 @@ -653,7 +653,7 @@ def test_platform_degradation_blocks_side_effects() -> None: def test_platform_degradation_allows_read_retry() -> None: - """A read action that already failed is blocked directly, not via degradation.""" + """A read action with a previous auth failure can revalidate live permissions.""" from leapflow.gateway.capability_health import CapabilityHealthLedger ledger = CapabilityHealthLedger() @@ -674,8 +674,30 @@ def test_platform_degradation_allows_read_retry() -> None: capability="im.chat.read", ) result = ledger.check_feasibility("feishu", read_spec) - assert result["ok"] is False - assert result["failure_code"] == "missing_scope" + 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: diff --git a/tests/test_gateway_tool_e2e.py b/tests/test_gateway_tool_e2e.py index e511753..76bc5a6 100644 --- a/tests/test_gateway_tool_e2e.py +++ b/tests/test_gateway_tool_e2e.py @@ -450,7 +450,7 @@ async def execute_action(self, action: str, payload: dict) -> ActionResult: @pytest.mark.asyncio async def test_platform_action_blocks_known_permission_failure_before_approval(tmp_path) -> None: - """Auth failure degrades platform: both retry of same action AND send are blocked.""" + """Auth failure degrades side-effects while read actions can revalidate.""" server = GatewayServer(tmp_path) adapter = PermissionFailingAdapter() gate = CaptureGate() @@ -482,13 +482,13 @@ async def test_platform_action_blocks_known_permission_failure_before_approval(t assert first["ok"] is False assert first["failure_class"] == "authorization" assert first["blocks_approval"] is True - # Only the first call reaches execution; after that, feasibility blocks - assert len(gate.actions) == 1 - assert adapter.execute_calls == ["im.list_messages"] + # 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 blocked by direct capability failure (no execution) + # Second call still reports the fresh backend failure. assert second["ok"] is False - assert second["skip_approval"] is True + assert second["failure_class"] == "authorization" assert second["failure_code"] == "missing_scope" assert second["capability"] == "im.message.read" @@ -498,6 +498,56 @@ async def test_platform_action_blocks_known_permission_failure_before_approval(t 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) 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 From 67a5ccb221d6357e802face3e4c259ce00507384 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8F=AD=E6=89=AC?= Date: Wed, 15 Jul 2026 01:33:34 +0800 Subject: [PATCH 12/14] fix(teach): resolve daemon-mode teach recording UX and timeout issues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove synchronous await_learning() from teach stop RPC handler to prevent 30s RPC timeout; distillation now runs as background task - Sync app.prompt_mode with session state (learning/paused/idle) so TUI shows "● rec" recording indicator during active teach sessions - Add learning-mode placeholder text with available teach commands - Emit chat interaction events from engine during LEARNING mode for trajectory capture (user messages, tool calls, tool results, responses) - Add CHAT_* ActionTypes to trajectory domain for recording chat demos - Ensure RecordingProfile always activates InputTapObserver during teach - Start ObservationDaemon on-demand when recording begins if not running - Route daemon TUI input through both engine_chat and annotate in learning mode for chat-demonstration recording Co-authored-by: Cursor --- src/leapflow/analysis/pipeline.py | 30 ++++++++ src/leapflow/cli/commands/interactive.py | 26 +++---- src/leapflow/cli/commands/slash_handlers.py | 29 ++++++- src/leapflow/cli/context.py | 8 +- src/leapflow/cli/tui_app/app.py | 2 + src/leapflow/domain/trajectory.py | 8 ++ src/leapflow/engine/engine.py | 84 ++++++++++++++++++--- src/leapflow/recording/recorder.py | 11 +++ 8 files changed, 169 insertions(+), 29 deletions(-) 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 71b006c..9b469ce 100644 --- a/src/leapflow/cli/commands/interactive.py +++ b/src/leapflow/cli/commands/interactive.py @@ -1141,6 +1141,12 @@ async def handle_input(text: str) -> None: 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"): @@ -1161,24 +1167,18 @@ async def handle_input(text: str) -> None: render_command_payload(console, payload) return - # Learning mode: route non-command text as annotation + # Learning mode: route through engine (chat-demonstration recording) + # and also annotate for distillation context. if daemon_session_mode == "learning": try: - payload = await bridge.call( + await bridge.call( lambda current_client: current_client.command_execute("annotate", text), description="annotate", ) - except Exception as exc: - console.warning(f"Annotation failed: {exc}") - return - # If session ended unexpectedly, track it - new_mode = payload.get("session_mode") - if isinstance(new_mode, str): - daemon_session_mode = new_mode - if payload.get("ok"): - console.system(f"(Noted as annotation during learning)") - else: - console.warning(str(payload.get("message") or "Annotation failed.")) + except Exception: + pass + console.rule() + await _stream_response(text) return console.rule() diff --git a/src/leapflow/cli/commands/slash_handlers.py b/src/leapflow/cli/commands/slash_handlers.py index 3a34bb7..8e92130 100644 --- a/src/leapflow/cli/commands/slash_handlers.py +++ b/src/leapflow/cli/commands/slash_handlers.py @@ -889,6 +889,24 @@ async def _execute_host(ctx: "Context", args: str) -> dict[str, Any]: 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. @@ -917,9 +935,16 @@ async def _execute_teach(ctx: "Context", name: str, args: str) -> dict[str, Any] 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 and getattr(ctx.settings, "has_llm_credentials", False): + import asyncio + asyncio.create_task(_distill_background(ctx.session)) + msg += "\nDistillation started in background." + elif result.step_count == 0: + msg += "\nNo steps recorded — nothing to distill." return { "ok": True, - "message": f"Recording stopped — {result.step_count} steps, {result.duration:.1f}s", + "message": msg, "step_count": result.step_count, "duration": result.duration, "session_mode": "idle", @@ -931,7 +956,7 @@ async def _execute_teach(ctx: "Context", name: str, args: str) -> dict[str, Any] 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": "learning"} + return {"ok": True, "message": "Teaching paused.", "session_mode": "paused"} if name == "teach resume": if not ctx.session or ctx.session.mode != SessionMode.LEARNING: diff --git a/src/leapflow/cli/context.py b/src/leapflow/cli/context.py index 22bc4e9..028d56e 100644 --- a/src/leapflow/cli/context.py +++ b/src/leapflow/cli/context.py @@ -127,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() diff --git a/src/leapflow/cli/tui_app/app.py b/src/leapflow/cli/tui_app/app.py index 506d0ce..df093b2 100644 --- a/src/leapflow/cli/tui_app/app.py +++ b/src/leapflow/cli/tui_app/app.py @@ -1009,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 discard · /teach pause · /teach resume · /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/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/engine.py b/src/leapflow/engine/engine.py index 2a304b8..8989145 100644 --- a/src/leapflow/engine/engine.py +++ b/src/leapflow/engine/engine.py @@ -53,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 ( @@ -1137,6 +1137,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 @@ -1629,6 +1655,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: @@ -1666,6 +1693,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: @@ -2291,9 +2319,18 @@ async def _unified_tool_loop( 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, @@ -2352,19 +2389,20 @@ async def _unified_tool_loop( logger.info("turn_usage: %s", self._usage_tracker.format_log_line()) if content: - # Never let a free-text LLM answer paraphrase or expand scope names - # after an unresolved permission failure — force the deterministic - # recovery message instead. permission_override = _permission_override_message(messages) - return permission_override or content + 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 @@ -2798,13 +2836,16 @@ async def _unified_tool_loop_stream( 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: permission_override = _permission_override_message(messages) - yield StreamEvent(type="final", content=permission_override or content) + final_text = permission_override or content + self._emit_chat_event("response", {"content": final_text[:500]}) + yield StreamEvent(type="final", content=final_text) return - # Text-mode preamble exclusion: only store call summary in WM. 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)) @@ -2812,6 +2853,10 @@ async def _unified_tool_loop_stream( 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, @@ -2823,6 +2868,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, @@ -2900,7 +2950,9 @@ async def _unified_tool_loop_stream( if content: permission_override = _permission_override_message(messages) - yield StreamEvent(type="final", content=permission_override or content) + 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) @@ -2908,6 +2960,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) @@ -3025,9 +3078,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, 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", "")) From 4d98674ab48d8ce8c92baa2a411206071b91c1f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8F=AD=E6=89=AC?= Date: Wed, 15 Jul 2026 02:09:25 +0800 Subject: [PATCH 13/14] feat(teach): event-driven distillation progress streaming to TUI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement a push notification system for background task transparency: - Add NotificationBus: lightweight async broadcast with per-subscriber queues, back-pressure handling, and graceful shutdown - Add events.subscribe streaming RPC: long-lived NDJSON connection for daemon-to-TUI push notifications (generalizable to any background task) - Wire session progress/completion callbacks to NotificationBus so distillation emits teach.progress and teach.complete events - TUI background subscription task: renders progress in status bar (⚗ phase pct%) and shows completion inline notification with results - Add /teach status command for on-demand distillation state query - Fix _update_status() overriding prompt_mode during teach session - Add teach.stopped notification for idle-watchdog auto-stop so TUI resets recording indicator when daemon times out the session - Gate distillation on has_pending_distillation property to avoid misleading "started" messages when learnability check skips - Add public SessionController properties: has_pending_distillation, is_distilling, recording_step_count, last_result - Reset stale distill state on notification stream reconnection Co-authored-by: Cursor --- src/leapflow/cli/commands/interactive.py | 67 ++++++++++++++- src/leapflow/cli/commands/registry.py | 1 + src/leapflow/cli/commands/slash_handlers.py | 38 ++++++++- src/leapflow/cli/tui_app/app.py | 2 +- src/leapflow/cli/tui_app/status.py | 17 ++++ src/leapflow/daemon/client.py | 26 ++++++ src/leapflow/daemon/notifications.py | 92 +++++++++++++++++++++ src/leapflow/daemon/protocol.py | 1 + src/leapflow/daemon/server.py | 2 +- src/leapflow/daemon/service.py | 62 ++++++++++++++ src/leapflow/engine/session.py | 21 +++++ 11 files changed, 322 insertions(+), 7 deletions(-) create mode 100644 src/leapflow/daemon/notifications.py diff --git a/src/leapflow/cli/commands/interactive.py b/src/leapflow/cli/commands/interactive.py index 9b469ce..3614fdf 100644 --- a/src/leapflow/cli/commands/interactive.py +++ b/src/leapflow/cli/commands/interactive.py @@ -911,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, @@ -921,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( @@ -1307,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) diff --git a/src/leapflow/cli/commands/registry.py b/src/leapflow/cli/commands/registry.py index 65ded88..e879237 100644 --- a/src/leapflow/cli/commands/registry.py +++ b/src/leapflow/cli/commands/registry.py @@ -95,6 +95,7 @@ def supports_runtime(self, runtime: CommandRuntime) -> bool: # 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"), diff --git a/src/leapflow/cli/commands/slash_handlers.py b/src/leapflow/cli/commands/slash_handlers.py index 8e92130..39efbc4 100644 --- a/src/leapflow/cli/commands/slash_handlers.py +++ b/src/leapflow/cli/commands/slash_handlers.py @@ -936,12 +936,18 @@ async def _execute_teach(ctx: "Context", name: str, args: str) -> dict[str, Any] try: result = await ctx.session.exit_learning() msg = f"Recording stopped — {result.step_count} steps, {result.duration:.1f}s" - if result.step_count > 0 and getattr(ctx.settings, "has_llm_credentials", False): + 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." - elif result.step_count == 0: - msg += "\nNo steps recorded — nothing to distill." + 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, @@ -987,6 +993,32 @@ async def _execute_teach(ctx: "Context", name: str, args: str) -> dict[str, Any] 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}"} diff --git a/src/leapflow/cli/tui_app/app.py b/src/leapflow/cli/tui_app/app.py index df093b2..b1d074c 100644 --- a/src/leapflow/cli/tui_app/app.py +++ b/src/leapflow/cli/tui_app/app.py @@ -1010,7 +1010,7 @@ 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 discard · /teach pause · /teach resume · /teach skip · /annotate " + 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/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/daemon/client.py b/src/leapflow/daemon/client.py index 4fd3572..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") 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 0cf0f23..7efacf5 100644 --- a/src/leapflow/daemon/protocol.py +++ b/src/leapflow/daemon/protocol.py @@ -306,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 7e2180e..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 @@ -384,6 +388,64 @@ 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 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 From 2bc0a52033ad0654105144dacb5271561f1454c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8F=AD=E6=89=AC?= Date: Wed, 15 Jul 2026 02:37:14 +0800 Subject: [PATCH 14/14] Fix PR16 review findings for gateway robustness --- src/leapflow/cli/commands/slash_handlers.py | 12 +- src/leapflow/engine/engine.py | 10 +- src/leapflow/gateway/checkpoint_store.py | 8 +- .../connectors/composite_event_source.py | 7 + .../gateway/connectors/lark_event_source.py | 11 +- src/leapflow/gateway/server.py | 7 +- src/leapflow/tools/file_operations.py | 10 +- tests/test_agent_execution.py | 25 +++ tests/test_cli_entrypoint.py | 25 +++ tests/test_gateway_tool_e2e.py | 165 ++++++++++++++++++ tests/test_safety_and_policy.py | 52 ++++++ 11 files changed, 318 insertions(+), 14 deletions(-) diff --git a/src/leapflow/cli/commands/slash_handlers.py b/src/leapflow/cli/commands/slash_handlers.py index 39efbc4..687e1d9 100644 --- a/src/leapflow/cli/commands/slash_handlers.py +++ b/src/leapflow/cli/commands/slash_handlers.py @@ -917,7 +917,9 @@ async def _execute_teach(ctx: "Context", name: str, args: str) -> dict[str, Any] full_cmd = name + (" " + args if args else "") if full_cmd in ("teach start", "teach") or full_cmd.startswith("teach start "): - if ctx.session and ctx.session.mode == SessionMode.LEARNING: + 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: @@ -1103,7 +1105,13 @@ async def _execute_hub(ctx: "Context", name: str, args: str) -> dict[str, Any]: if not sub: sub = args.split()[0] if args.split() else "" args = " ".join(args.split()[1:]) - return {"ok": False, "message": f"Hub command '/{name} {args}'.strip() is not yet implemented in this runtime."} + 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]: diff --git a/src/leapflow/engine/engine.py b/src/leapflow/engine/engine.py index 8989145..7f57127 100644 --- a/src/leapflow/engine/engine.py +++ b/src/leapflow/engine/engine.py @@ -874,6 +874,7 @@ def __init__( # 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) @@ -1421,13 +1422,16 @@ def _record_tool_call_categories(self, native_calls: list) -> None: 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. """ - from leapflow.tools.registry_bootstrap import TOOL_DEFINITIONS + if self._manifests_by_name is None: + from leapflow.tools.registry_bootstrap import TOOL_DEFINITIONS - manifests_by_name = {m.name: m for m in build_capability_manifests(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 = manifests_by_name.get(name) + 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) diff --git a/src/leapflow/gateway/checkpoint_store.py b/src/leapflow/gateway/checkpoint_store.py index f7d4c87..bbdf1cd 100644 --- a/src/leapflow/gateway/checkpoint_store.py +++ b/src/leapflow/gateway/checkpoint_store.py @@ -71,14 +71,14 @@ def save_batch(self, platform_id: str, event_ids: list[str]) -> None: conn = self._holder.connection # type: ignore[union-attr] now = time.time() - for eid in event_ids[-1000:]: - execute_with_retry( - conn, + 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 (?, ?, ?) """, - [platform_id, eid, now], + rows, ) cutoff = now - 86400 * 7 execute_with_retry( diff --git a/src/leapflow/gateway/connectors/composite_event_source.py b/src/leapflow/gateway/connectors/composite_event_source.py index eb482c4..b366ac4 100644 --- a/src/leapflow/gateway/connectors/composite_event_source.py +++ b/src/leapflow/gateway/connectors/composite_event_source.py @@ -104,6 +104,13 @@ async def events(self) -> AsyncIterator[BackendEvent]: 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.""" diff --git a/src/leapflow/gateway/connectors/lark_event_source.py b/src/leapflow/gateway/connectors/lark_event_source.py index ace80a0..655cf1e 100644 --- a/src/leapflow/gateway/connectors/lark_event_source.py +++ b/src/leapflow/gateway/connectors/lark_event_source.py @@ -113,7 +113,16 @@ async def fetch_bot_identity(self) -> BotIdentity: stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL, ) - stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=15.0) + 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 {} diff --git a/src/leapflow/gateway/server.py b/src/leapflow/gateway/server.py index 9f09dc2..e8c3a8a 100644 --- a/src/leapflow/gateway/server.py +++ b/src/leapflow/gateway/server.py @@ -760,6 +760,8 @@ async def send_reply( ) 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] = {} @@ -877,12 +879,13 @@ async def _consume_platform_events( ) except asyncio.CancelledError: logger.info("Consumer task cancelled for %s", platform_id) - self._save_checkpoint(platform_id) - self._save_dedup_state(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, diff --git a/src/leapflow/tools/file_operations.py b/src/leapflow/tools/file_operations.py index 439451c..efc326b 100644 --- a/src/leapflow/tools/file_operations.py +++ b/src/leapflow/tools/file_operations.py @@ -197,7 +197,10 @@ async def file_read(params: Dict[str, Any]) -> Dict[str, Any]: "requires_approval": True, **_sensitivity_metadata(sensitivity), } - approved = await gate.check(str(target), mode, _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} @@ -285,7 +288,10 @@ async def file_write(params: Dict[str, Any]) -> Dict[str, Any]: 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, _sensitivity_metadata(sensitivity)) + 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} diff --git a/tests/test_agent_execution.py b/tests/test_agent_execution.py index b60de4d..1324fcb 100644 --- a/tests/test_agent_execution.py +++ b/tests/test_agent_execution.py @@ -1255,3 +1255,28 @@ def test_permission_override_message_empty_after_successful_followup() -> None: ] 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_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_gateway_tool_e2e.py b/tests/test_gateway_tool_e2e.py index 76bc5a6..926a749 100644 --- a/tests/test_gateway_tool_e2e.py +++ b/tests/test_gateway_tool_e2e.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio from types import SimpleNamespace import pytest @@ -810,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")]