diff --git a/pyproject.toml b/pyproject.toml index 2cc091e..3db84a7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -73,6 +73,7 @@ include = ["leapflow*"] "leapflow.gateway.action_packs" = ["*.yaml"] "leapflow.dashboard.templates" = ["*.yaml"] "leapflow.dashboard.static" = ["*"] +"leapflow.plugins.dsh" = ["*.js"] [tool.pytest.ini_options] asyncio_mode = "auto" diff --git a/src/leapflow/cli/commands/slash_handlers.py b/src/leapflow/cli/commands/slash_handlers.py index 63be67d..e787882 100644 --- a/src/leapflow/cli/commands/slash_handlers.py +++ b/src/leapflow/cli/commands/slash_handlers.py @@ -1521,6 +1521,22 @@ async def build_plugin_payload(ctx: "Context", args: str) -> dict[str, Any]: "generation": fiber.generation if fiber else None, }, } + descriptor = getattr(plugin, "descriptor", None) + if descriptor is not None and hasattr(descriptor, "to_dict"): + descriptor_data = descriptor.to_dict() + response["dsh"] = { + "source_kind": descriptor_data.get("source_kind"), + "bundle_sha256": descriptor_data.get("bundle_sha256"), + "entry_point": descriptor_data.get("entry_point"), + "verdict": ( + "partial" + if descriptor_data.get("client_components") + else "adaptable" + ), + "limitations": descriptor_data.get("limitations", []), + "client_components": descriptor_data.get("client_components", []), + "runtime": "node", + } # Additive trust info try: from leapflow.learning.plugin_advisor import get_default_advisor @@ -1679,6 +1695,19 @@ def render_plugin_payload(console: "LeapConsole", payload: dict[str, Any]) -> No deps = payload.get("dependencies") or [] if deps: info.append(f"Deps: {', '.join(deps)}\n") + dsh = payload.get("dsh") or {} + if dsh: + info.append(f"Runtime: {dsh.get('runtime', 'node')}\n") + info.append(f"Source: {dsh.get('source_kind', 'unknown')}\n") + info.append(f"Verdict: {dsh.get('verdict', 'adaptable')}\n") + for limitation in dsh.get("limitations") or []: + info.append(f"Limitation: {limitation}\n") + for component in dsh.get("client_components") or []: + info.append( + f"Client: {component.get('name', 'client')} " + f"({component.get('status', 'unsupported')}) — " + f"{component.get('reason', '')}\n" + ) tools = payload.get("tools") or [] if tools: info.append(f"Tools ({len(tools)}):") diff --git a/src/leapflow/config.py b/src/leapflow/config.py index a61603b..642185a 100644 --- a/src/leapflow/config.py +++ b/src/leapflow/config.py @@ -127,6 +127,14 @@ class Settings: # When non-empty, marketplace installs MUST carry a valid signature from # one of these keys; empty tuple -> checksum-only integrity verification. plugin_marketplace_trusted_pubkeys: tuple[str, ...] = () + # Restricted DeepSeek Harness / Cordis bridge runtime. These are cold-path + # process limits; changing them requires rebuilding daemon-owned plugin + # wrappers and therefore takes effect after daemon restart. + plugins_dsh_invoke_timeout_s: float = 30.0 + plugins_dsh_discovery_timeout_s: float = 10.0 + plugins_dsh_max_message_bytes: int = 1_000_000 + plugins_dsh_max_stderr_bytes: int = 64_000 + plugins_dsh_max_memory_mb: int = 128 runtime_dir: Path = field(default_factory=lambda: _bootstrap_profile_layout().runtime_dir) # Audit @@ -921,6 +929,11 @@ def _build_settings_from_env( tools_lint_command = os.getenv("LEAPFLOW_TOOLS_LINT_COMMAND", "").strip() tools_terminal_session_enabled = os.getenv("LEAPFLOW_TOOLS_TERMINAL_SESSION_ENABLED", "1").strip().lower() in ("1", "true", "yes") tools_verify_edits = os.getenv("LEAPFLOW_TOOLS_VERIFY_EDITS", "1").strip().lower() in ("1", "true", "yes") + plugins_dsh_invoke_timeout_s = float(os.getenv("LEAPFLOW_PLUGINS_DSH_INVOKE_TIMEOUT_S", "30")) + plugins_dsh_discovery_timeout_s = float(os.getenv("LEAPFLOW_PLUGINS_DSH_DISCOVERY_TIMEOUT_S", "10")) + plugins_dsh_max_message_bytes = int(os.getenv("LEAPFLOW_PLUGINS_DSH_MAX_MESSAGE_BYTES", "1000000")) + plugins_dsh_max_stderr_bytes = int(os.getenv("LEAPFLOW_PLUGINS_DSH_MAX_STDERR_BYTES", "64000")) + plugins_dsh_max_memory_mb = int(os.getenv("LEAPFLOW_PLUGINS_DSH_MAX_MEMORY_MB", "128")) web_transport = os.getenv("LEAPFLOW_WEB_TRANSPORT", "auto").strip().lower() or "auto" web_timeout_s = float(os.getenv("LEAPFLOW_WEB_TIMEOUT_S", "20")) web_max_bytes = int(os.getenv("LEAPFLOW_WEB_MAX_BYTES", "2000000")) @@ -1277,6 +1290,11 @@ def _tuple_env(key: str, default: tuple) -> tuple: tools_lint_command=tools_lint_command, tools_terminal_session_enabled=tools_terminal_session_enabled, tools_verify_edits=tools_verify_edits, + plugins_dsh_invoke_timeout_s=plugins_dsh_invoke_timeout_s, + plugins_dsh_discovery_timeout_s=plugins_dsh_discovery_timeout_s, + plugins_dsh_max_message_bytes=plugins_dsh_max_message_bytes, + plugins_dsh_max_stderr_bytes=plugins_dsh_max_stderr_bytes, + plugins_dsh_max_memory_mb=plugins_dsh_max_memory_mb, web_transport=web_transport, web_timeout_s=web_timeout_s, web_max_bytes=web_max_bytes, diff --git a/src/leapflow/config_service.py b/src/leapflow/config_service.py index e3f4c67..7e1f868 100644 --- a/src/leapflow/config_service.py +++ b/src/leapflow/config_service.py @@ -184,6 +184,11 @@ class ConfigSnapshot: "signal.noise_path_fragments": "Path fragments treated as monitor/display noise for fs.change events, e.g. OS caches and tool state directories.", "signal.noise_dir_names": "Directory names treated as monitor/display noise for fs.change events.", "signal.noise_suffixes": "Filename suffixes treated as transient fs.change noise, e.g. WAL/SHM/journal/temp/log files.", + "plugins.dsh_invoke_timeout_s": "Maximum seconds for one restricted DSH tool invocation; requires daemon restart.", + "plugins.dsh_discovery_timeout_s": "Maximum seconds for restricted DSH runtime discovery; requires daemon restart.", + "plugins.dsh_max_message_bytes": "Maximum bytes in one DSH worker NDJSON protocol message; requires daemon restart.", + "plugins.dsh_max_stderr_bytes": "Bounded diagnostic stderr tail retained from one DSH worker; requires daemon restart.", + "plugins.dsh_max_memory_mb": "V8 old-space ceiling in megabytes for each DSH worker process; requires daemon restart.", } _SECTION_CATEGORIES = { @@ -197,6 +202,7 @@ class ConfigSnapshot: "attention": "Perception", "recording": "Recording", "video": "Recording", + "plugins": "Plugins", "learnability": "Learning", "learn": "Learning", "skill": "Skills", @@ -241,7 +247,7 @@ class ConfigSnapshot: } _PARTIAL_RELOAD_SECTIONS = frozenset({"runtime", "mock", "gateway", "hub", "scheduler", "observer", "cua", "use", "dashboard"}) -_RESTART_REQUIRED_SECTIONS = frozenset({"daemon"}) +_RESTART_REQUIRED_SECTIONS = frozenset({"daemon", "plugins"}) _PROFILE_FILE_BY_SECTION = { "llm": "llm.yaml", diff --git a/src/leapflow/layout.py b/src/leapflow/layout.py index a5ff082..6c617ef 100644 --- a/src/leapflow/layout.py +++ b/src/leapflow/layout.py @@ -373,6 +373,11 @@ def plugins_dir(self) -> Path: # means installed plugins are per-profile and never mutate site-packages. return self.root / "plugins" + @property + def dsh_plugins_dir(self) -> Path: + """Profile-owned source bundles for restricted DSH bridge plugins.""" + return self.plugins_dir / "dsh" + @property def plugin_proposals_path(self) -> Path: # Profile-scoped review queue for capability-gap → plugin proposals. @@ -467,6 +472,7 @@ def ensure(self) -> None: self.global_memory_dir, self.skills_dir, self.plugins_dir, + self.dsh_plugins_dir, self.audit_dir, self.history_dir, self.runtime_dir, diff --git a/src/leapflow/learning/compatibility/__init__.py b/src/leapflow/learning/compatibility/__init__.py index 6abd533..d322784 100644 --- a/src/leapflow/learning/compatibility/__init__.py +++ b/src/leapflow/learning/compatibility/__init__.py @@ -7,8 +7,31 @@ from leapflow.learning.compatibility.pipeline import assess_plugin from leapflow.learning.compatibility.protocol import ( CompatibilityReport, + ComponentCompatibility, + ComponentKind, + ComponentStatus, + ExecutionPlan, PluginManifestInput, + PluginSourceKind, Verdict, ) +from leapflow.learning.compatibility.source_inspector import ( + SourceInspection, + SourceInspectionError, + inspect_plugin_source, +) -__all__ = ["assess_plugin", "CompatibilityReport", "Verdict", "PluginManifestInput"] +__all__ = [ + "assess_plugin", + "CompatibilityReport", + "ComponentCompatibility", + "ComponentKind", + "ComponentStatus", + "ExecutionPlan", + "PluginManifestInput", + "PluginSourceKind", + "SourceInspection", + "SourceInspectionError", + "Verdict", + "inspect_plugin_source", +] diff --git a/src/leapflow/learning/compatibility/adapter_generator.py b/src/leapflow/learning/compatibility/adapter_generator.py index a366016..7fda356 100644 --- a/src/leapflow/learning/compatibility/adapter_generator.py +++ b/src/leapflow/learning/compatibility/adapter_generator.py @@ -1,34 +1,16 @@ -"""LLM-assisted adapter generator for ADAPTABLE plugins. +"""Deterministic wrapper generation for runtime-discovered DSH plugins. -Produces a Python adapter/bridge wrapper that hosts a foreign (DSH) plugin -inside LeapFlow as a ToolPlugin. Two modes are supported: - -- **Template mode** (:func:`generate_adapter_template`): pure string - formatting from an ``AdapterSpec`` and manifest. No LLM, no I/O, always - works. Produces a valid ToolPlugin skeleton that proxies each declared - interface to a subprocess bridge (reusing the SandboxHost pattern). - -- **LLM-enhanced mode** (:func:`generate_adapter_with_llm`): given an LLM - provider, refines the template with more accurate method mappings derived - from the manifest's declared interfaces. Any failure degrades gracefully - back to template mode. - -File/LLM I/O is confined to the LLM-enhanced path, which is a cold path. +A manifest cannot prove a foreign tool exists or is executable. Wrapper source +is therefore emitted only from a descriptor produced by restricted Node runtime +discovery. LLM-authored runtime adapters are intentionally disabled: an LLM is +not a security authority for schemas, permissions, or process capabilities. """ - from __future__ import annotations -import asyncio -import inspect -import logging -import re -from typing import Any, List +from typing import Any -from leapflow.learning.compatibility.manifest_converter import _normalize_name from leapflow.learning.compatibility.protocol import AdapterSpec, PluginManifestInput -logger = logging.getLogger(__name__) - # ═══════════════════════════════════════════════════════════════════════ # Template mode (no LLM) @@ -38,320 +20,37 @@ def generate_adapter_template( spec: AdapterSpec, manifest: PluginManifestInput ) -> str: - """Generate a Python adapter skeleton from an AdapterSpec (no LLM needed). - - The generated module defines a ToolPlugin class - ``Dsh{PascalCase(name)}BridgePlugin`` with ``plugin_id`` set to - ``dsh_{name}_bridge``. It declares one ToolMetadata per declared - interface (falling back to a single ``invoke`` tool when none are - declared), and each handler delegates to a SandboxHost subprocess call - via JSON-RPC. - - Args: - spec: The AdapterSpec produced by the verdict synthesizer. - manifest: The parsed manifest of the plugin being adapted. - - Returns: - A string of valid Python source implementing the bridge adapter. - """ - dsh_package = manifest.name or "unknown" - snake = _normalize_name(dsh_package) - pascal = _pascal_case(snake) - class_name = f"Dsh{pascal}BridgePlugin" - plugin_id = f"dsh_{snake}_bridge" - entry_point = manifest.raw_manifest.get("main", "") if manifest.raw_manifest else "" - - interfaces = list(manifest.declared_interfaces) or ["invoke"] - - tools_block = _render_tools_block(interfaces, dsh_package) - handlers_block = _render_handlers_block(interfaces) - - # Docstring-safe forms of arbitrary manifest/spec text (never trust input). - doc_package = _escape_for_docstring(dsh_package) - doc_bridge = _escape_for_docstring(str(spec.bridge_type)) - doc_target = _escape_for_docstring(str(spec.target_protocol)) - doc_complexity = _escape_for_docstring(str(spec.estimated_complexity)) - - code = ( - '"""Auto-generated DSH \u2192 LeapFlow bridge adapter.\n' - "\n" - "This module was auto-generated by the LeapFlow Plugin Compatibility\n" - "Assessment Engine (adapter_generator). It wraps the DSH plugin\n" - f"``{doc_package}`` and exposes its declared interfaces as a LeapFlow\n" - "ToolPlugin, proxying each call to a subprocess bridge via JSON-RPC.\n" - "\n" - f"Bridge type: {doc_bridge}\n" - f"Target protocol: {doc_target}\n" - f"Estimated complexity: {doc_complexity}\n" - "\n" - "Do not edit by hand: regenerate from the source manifest instead.\n" - '"""\n' - "\n" - "from __future__ import annotations\n" - "\n" - "from typing import Any, List, Optional\n" - "\n" - "from leapflow.plugins.protocol import ToolMetadata\n" - "from leapflow.plugins.sandbox.sandbox_host import SandboxHost\n" - "\n" - "\n" - f"class {class_name}:\n" - f' """Bridge adapter wrapping the DSH plugin ``{doc_package}``.\n' - "\n" - " Runs the foreign plugin in a subprocess (SandboxHost) and proxies\n" - " each declared interface to it via JSON-RPC.\n" - ' """\n' - "\n" - f" def __init__(self, bridge_module_path: str = {_py_str_literal(entry_point)}) -> None:\n" - " self._bridge_module_path = bridge_module_path\n" - " self._host: Optional[SandboxHost] = None\n" - "\n" - " @property\n" - " def plugin_id(self) -> str:\n" - f" return {_py_str_literal(plugin_id)}\n" - "\n" - " @property\n" - " def category(self) -> str:\n" - ' return "bridge"\n' - "\n" - " @property\n" - " def dependencies(self) -> List[str]:\n" - " return []\n" - "\n" - " def bind_runtime(self, **deps: Any) -> None:\n" - " pass\n" - "\n" - " @property\n" - " def tools(self) -> List[ToolMetadata]:\n" - " return [\n" - f"{tools_block}\n" - " ]\n" - "\n" - f"{handlers_block}\n" - "\n" - " async def _invoke_bridge(self, method: str, arguments: dict) -> dict:\n" - ' """Start the bridge subprocess on first use and proxy one call."""\n' - " if self._host is None:\n" - " self._host = SandboxHost(self._bridge_module_path)\n" - " await self._host.start()\n" - " resp = await self._host.invoke(method, arguments)\n" - " if resp.ok:\n" - ' return {"ok": True, "result": resp.result}\n' - ' return {"ok": False, "error": resp.error}\n' - ) - - # Fail fast at generation time rather than at install time: a template - # bug (e.g. an un-escaped manifest field) surfaces here, not later. - compile(code, "", "exec") - return code - - -def _render_tools_block(interfaces: List[str], dsh_package: str) -> str: - """Render the ToolMetadata entries for the ``tools`` property. - - Interface names and the package name are emitted through safe Python - string literals, so names containing dots, quotes, or other special - characters produce valid source. - """ - entries: List[str] = [] - for iface in interfaces: - ident = _sanitize_identifier(iface) - name_literal = _py_str_literal(iface) - desc_literal = repr(f"Bridged DSH interface '{iface}' from {dsh_package}.") - entries.append( - " ToolMetadata(\n" - f" name={name_literal},\n" - f" description={desc_literal},\n" - ' parameters_schema={"type": "object", "properties": {}},\n' - f" handler=self._handle_{ident},\n" - ' x_leapflow={"category": "bridge", "runtime": ' - '"typescript", "bridge": "json_rpc"},\n' - " )," - ) - return "\n".join(entries) - - -def _render_handlers_block(interfaces: List[str]) -> str: - """Render one async handler method per declared interface. - - The proxied interface name is emitted as a safe string literal and the - docstring reference is escaped, so special characters cannot corrupt the - generated source. - """ - defs: List[str] = [] - for iface in interfaces: - ident = _sanitize_identifier(iface) - doc_iface = _escape_for_docstring(iface) - call_literal = _py_str_literal(iface) - defs.append( - f" async def _handle_{ident}(self, **kwargs: Any) -> dict:\n" - f" \"\"\"Delegate the '{doc_iface}' call to the DSH subprocess bridge.\"\"\"\n" - f" return await self._invoke_bridge({call_literal}, kwargs)" + """Render a validated wrapper from a runtime-discovered DSH descriptor. + + Static manifests are insufficient: the previous template invented tools from + declared interface names, pointed Python ``SandboxHost`` at JavaScript, and + omitted the module-level ``plugin`` required by the actual installer. It + compiled, but could never execute. Runtime discovery is now the authority; + callers pass its descriptor through ``x_leapflow_runtime_descriptor``. + """ + del spec # bridge selection is already encoded by the runtime descriptor + raw = manifest.raw_manifest or {} + descriptor = raw.get("x_leapflow_runtime_descriptor") + if not isinstance(descriptor, dict): + raise ValueError( + "DSH adapter generation requires restricted runtime discovery; " + "use plugin_install(source_path=...)" ) - return "\n\n".join(defs) - - -def _pascal_case(snake: str) -> str: - """Convert a snake_case identifier to PascalCase. - - Each part is stripped of any non-alphanumeric character so the result is - always a valid Python identifier fragment even for unusual inputs. - """ - parts = [re.sub(r"[^0-9a-zA-Z]", "", p) for p in snake.split("_")] - parts = [p for p in parts if p] - return "".join(p[:1].upper() + p[1:] for p in parts) or "Unknown" - - -def _escape_for_docstring(text: str) -> str: - """Escape text for safe embedding inside a triple-double-quoted docstring. - - Backslashes and double quotes are escaped so no stray escape sequence or - ``\"\"\"`` run can terminate or corrupt the surrounding docstring. - """ - return text.replace("\\", "\\\\").replace('"', '\\"') - - -def _py_str_literal(text: str) -> str: - """Return a valid Python string literal for arbitrary text. - - Prefers a double-quoted form for the common case (so simple names read as - ``"name"``); falls back to :func:`repr` for any text containing characters - that need escaping (quotes, backslashes, or non-printable characters). - """ - if text and '"' not in text and "\\" not in text and text.isprintable(): - return f'"{text}"' - return repr(text) - - -def _sanitize_identifier(name: str) -> str: - """Sanitize an arbitrary interface name into a valid Python identifier.""" - ident = re.sub(r"\W", "_", name) - if not ident or ident[0].isdigit(): - ident = "_" + ident - return ident - + from leapflow.plugins.dsh.descriptor import ( + DshPluginDescriptor, + render_python_wrapper, + ) -# ═══════════════════════════════════════════════════════════════════════ -# LLM-enhanced mode (optional) -# ═══════════════════════════════════════════════════════════════════════ + return render_python_wrapper(DshPluginDescriptor.from_dict(descriptor)) def generate_adapter_with_llm( spec: AdapterSpec, manifest: PluginManifestInput, llm_provider: Any ) -> str: - """Generate refined adapter code using an LLM (optional enhancement). - - Falls back to :func:`generate_adapter_template` when ``llm_provider`` is - ``None`` or when any step of the LLM path fails (invocation error, empty - output, or output that does not compile). The template is always a valid, - installable adapter, so degradation never leaves the caller without code. - - Args: - spec: The AdapterSpec produced by the verdict synthesizer. - manifest: The parsed manifest of the plugin being adapted. - llm_provider: A duck-typed provider exposing a text-generation method - (``generate``/``complete``) or an async ``achat`` interface. - - Returns: - Refined adapter Python source, or the template on any failure. - """ - template = generate_adapter_template(spec, manifest) - if llm_provider is None: - return template - - try: - prompt = _build_llm_prompt(spec, manifest, template) - raw = _invoke_llm(llm_provider, prompt) - code = _extract_code(raw) - if not code.strip(): - logger.warning("LLM returned empty adapter; using template") - return template - # Validate the refined code compiles before trusting it. - compile(code, "", "exec") - return code - except Exception as exc: # noqa: BLE001 - optional enhancement degrades - logger.warning( - "LLM adapter refinement failed (%s); falling back to template", exc - ) - return template - - -def _build_llm_prompt( - spec: AdapterSpec, manifest: PluginManifestInput, template: str -) -> str: - """Construct the refinement prompt with full manifest and template context.""" - interfaces = ", ".join(manifest.declared_interfaces) or "(none declared)" - deps = ", ".join(manifest.declared_dependencies) or "(none)" - return ( - "You are refining an auto-generated LeapFlow bridge adapter for a " - "foreign (DSH) plugin.\n\n" - "Target LeapFlow protocol: ToolPlugin — a class exposing " - "`plugin_id`, `category`, `tools` (list[ToolMetadata]), " - "`dependencies`, and `bind_runtime(**deps)`. Each tool handler is an " - "async function proxying to a subprocess bridge (SandboxHost).\n\n" - f"Source plugin: {manifest.name}@{manifest.version}\n" - f"Category: {manifest.category}\n" - f"Source language: {manifest.source_language}\n" - f"Declared interfaces: {interfaces}\n" - f"Declared dependencies: {deps}\n" - f"Bridge type: {spec.bridge_type}\n" - f"Shim methods: {', '.join(spec.shim_methods) or '(none)'}\n\n" - "Improve the method mappings and parameter schemas in the following " - "adapter so each declared interface maps to an accurate handler. " - "Return only a complete, valid Python module (no prose).\n\n" - "--- CURRENT TEMPLATE ---\n" - f"{template}\n" - "--- END TEMPLATE ---\n" - ) - - -def _invoke_llm(provider: Any, prompt: str) -> str: - """Invoke a duck-typed LLM provider and return generated text. + """Return the deterministic runtime-discovered wrapper. - Tries synchronous ``generate``/``complete`` methods first, then an async - ``achat`` interface. Raises TypeError when no supported method exists. + P3 may add LLM suggestions around the deterministic descriptor, but the LLM + must never rewrite the executable security boundary. """ - for method_name in ("generate", "complete"): - method = getattr(provider, method_name, None) - if callable(method): - return _coerce_text(method(prompt)) - - achat = getattr(provider, "achat", None) - if callable(achat): - messages = [{"role": "user", "content": prompt}] - return _coerce_text(achat(messages, stream=False)) - - raise TypeError( - "llm_provider does not expose a supported generation method " - "(generate/complete/achat)" - ) - - -def _coerce_text(result: Any) -> str: - """Coerce an LLM result (awaitable, string, or response object) into text.""" - if inspect.isawaitable(result): - result = asyncio.run(result) - if isinstance(result, str): - return result - for attr in ("content", "text", "message"): - val = getattr(result, attr, None) - if isinstance(val, str): - return val - return str(result) - - -def _extract_code(raw: str) -> str: - """Strip Markdown code fences from an LLM response, if present.""" - text = raw.strip() - if "```" not in text: - return text - parts = text.split("```") - if len(parts) < 2: - return text - block = parts[1] - # Drop an optional language tag on the opening fence line. - for tag in ("python", "py"): - if block.startswith(tag): - block = block[len(tag):] - break - return block.strip() + del llm_provider + return generate_adapter_template(spec, manifest) diff --git a/src/leapflow/learning/compatibility/manifest_converter.py b/src/leapflow/learning/compatibility/manifest_converter.py index 83de899..8c7c5fd 100644 --- a/src/leapflow/learning/compatibility/manifest_converter.py +++ b/src/leapflow/learning/compatibility/manifest_converter.py @@ -1,13 +1,9 @@ -"""DSH package.json → LeapFlow PluginManifest converter. +"""DSH package.json → LeapFlow compatibility descriptor conversion. -Translates a DSH-format manifest dict (as parsed by Stage 1) into a -LeapFlow PluginManifest-compatible dict that could be handed to -``MarketplaceClient.install()``. - -This is a pure, side-effect-free transformation: no file I/O and no -checksum computation. The checksum is intentionally left as ``None`` because -integrity is verified against the actual downloaded source at install time, -not against the manifest at conversion time. +This is metadata for assessment and audit, not a ``MarketplaceClient`` install +manifest. A DSH ``main`` such as ``dist/index.js`` is not a Python entry point; +passing it to the Python-only marketplace would fabricate ``dist/index.js.py``. +Executable DSH bundles must enter through ``plugin_install(source_path=...)``. """ from __future__ import annotations @@ -17,7 +13,7 @@ def convert_dsh_to_leapflow(dsh_manifest: dict) -> dict: - """Convert a DSH manifest dict into a LeapFlow PluginManifest-compatible dict. + """Convert a DSH manifest into a non-installable compatibility descriptor. Field mapping: - ``name`` → stripped of ``@org/`` and ``dsh-`` prefixes, hyphens @@ -57,6 +53,8 @@ def convert_dsh_to_leapflow(dsh_manifest: dict) -> dict: "entry_point": entry_point, "description": description, "plugin_type": "tool", + "source_language": "javascript", + "artifact_type": "dsh_source_bundle", "requires_sandbox": requires_sandbox, "dependencies": dependencies, "checksum_sha256": None, # computed at install time, not conversion diff --git a/src/leapflow/learning/compatibility/pipeline.py b/src/leapflow/learning/compatibility/pipeline.py index eaccc76..ba48283 100644 --- a/src/leapflow/learning/compatibility/pipeline.py +++ b/src/leapflow/learning/compatibility/pipeline.py @@ -16,15 +16,22 @@ from __future__ import annotations import json +from dataclasses import replace from pathlib import Path from typing import Union from leapflow.learning.compatibility.protocol import ( CompatibilityReport, + ComponentStatus, + ExecutionPlan, PluginManifestInput, StageResult, Verdict, ) +from leapflow.learning.compatibility.source_inspector import ( + SourceInspectionError, + inspect_plugin_source, +) from leapflow.learning.compatibility.stages.category_resolver import CategoryResolver from leapflow.learning.compatibility.stages.manifest_parser import ManifestParser @@ -45,30 +52,43 @@ def assess_plugin( stages: list[StageResult] = [] parser = ManifestParser() resolver = CategoryResolver() + execution_plan: ExecutionPlan | None = None - # ── Stage 0: Normalize file-path inputs into a raw manifest dict ── - # A Path object or a path-like string is read from disk as JSON, - # then flows through the standard dict path below. A string that is - # not path-like is an unsupported input format. + # ── Stage 0: Inspect real source bundles before manifest parsing ── + # Directory inputs and package/meta manifests describe executable source, + # not just metadata. Static inspection is side-effect free: it bounds and + # hashes the bundle but never evaluates JavaScript. if isinstance(manifest, (str, Path)): - loaded = _load_manifest_from_path(manifest) - if isinstance(loaded, CompatibilityReport): - return loaded - manifest = loaded + candidate = Path(manifest).expanduser() + if candidate.is_dir() or candidate.name in {"package.json", "meta.json"}: + try: + inspection = inspect_plugin_source(candidate) + except SourceInspectionError as exc: + return _incompatible_report(str(exc)) + manifest = inspection.manifest + execution_plan = inspection.execution_plan + else: + loaded = _load_manifest_from_path(manifest) + if isinstance(loaded, CompatibilityReport): + return loaded + manifest = loaded # ── Stage 1: Parse manifest ────────────────────────────────────── if isinstance(manifest, PluginManifestInput): parsed_manifest = manifest parse_result = parser.assess(parsed_manifest, []) if not parse_result.passed: - return CompatibilityReport( - manifest=parsed_manifest, - stages=[parse_result], - final_verdict=Verdict.INCOMPATIBLE, - target_protocol=None, - rejection_reason=parse_result.details, - adaptation_notes=[], - adapter_spec=None, + return _finalize_source_report( + CompatibilityReport( + manifest=parsed_manifest, + stages=[parse_result], + final_verdict=Verdict.INCOMPATIBLE, + target_protocol=None, + rejection_reason=parse_result.details, + adaptation_notes=[], + adapter_spec=None, + ), + execution_plan, ) elif isinstance(manifest, dict): parse_result = ManifestParser.parse_raw(manifest) @@ -112,12 +132,15 @@ def assess_plugin( # Short-circuit on INCOMPATIBLE if category_result.verdict == Verdict.INCOMPATIBLE: - return CompatibilityReport( - manifest=parsed_manifest, - stages=stages, - final_verdict=Verdict.INCOMPATIBLE, - target_protocol=None, - rejection_reason=category_result.details, + return _finalize_source_report( + CompatibilityReport( + manifest=parsed_manifest, + stages=stages, + final_verdict=Verdict.INCOMPATIBLE, + target_protocol=None, + rejection_reason=category_result.details, + ), + execution_plan, ) # ── Stages 3-6: Deep analysis ─────────────────────────────────── @@ -140,24 +163,30 @@ def assess_plugin( interface_result = InterfaceAnalyzer().assess(parsed_manifest, stages) stages.append(interface_result) if interface_result.verdict == Verdict.INCOMPATIBLE: - return CompatibilityReport( - manifest=parsed_manifest, - stages=stages, - final_verdict=Verdict.INCOMPATIBLE, - target_protocol=category_result.evidence.get("target_protocol"), - rejection_reason=interface_result.details, + return _finalize_source_report( + CompatibilityReport( + manifest=parsed_manifest, + stages=stages, + final_verdict=Verdict.INCOMPATIBLE, + target_protocol=category_result.evidence.get("target_protocol"), + rejection_reason=interface_result.details, + ), + execution_plan, ) # Stage 4: Dependency check dep_result = DependencyChecker().assess(parsed_manifest, stages) stages.append(dep_result) if dep_result.verdict == Verdict.INCOMPATIBLE: - return CompatibilityReport( - manifest=parsed_manifest, - stages=stages, - final_verdict=Verdict.INCOMPATIBLE, - target_protocol=category_result.evidence.get("target_protocol"), - rejection_reason=dep_result.details, + return _finalize_source_report( + CompatibilityReport( + manifest=parsed_manifest, + stages=stages, + final_verdict=Verdict.INCOMPATIBLE, + target_protocol=category_result.evidence.get("target_protocol"), + rejection_reason=dep_result.details, + ), + execution_plan, ) # Stage 5: Execution model analysis @@ -165,28 +194,75 @@ def assess_plugin( stages.append(exec_result) # Execution model never produces INCOMPATIBLE, but defensive check if exec_result.verdict == Verdict.INCOMPATIBLE: - return CompatibilityReport( - manifest=parsed_manifest, - stages=stages, - final_verdict=Verdict.INCOMPATIBLE, - target_protocol=category_result.evidence.get("target_protocol"), - rejection_reason=exec_result.details, + return _finalize_source_report( + CompatibilityReport( + manifest=parsed_manifest, + stages=stages, + final_verdict=Verdict.INCOMPATIBLE, + target_protocol=category_result.evidence.get("target_protocol"), + rejection_reason=exec_result.details, + ), + execution_plan, ) # Stage 6: Security classification security_result = SecurityClassifier().assess(parsed_manifest, stages) stages.append(security_result) if security_result.verdict == Verdict.INCOMPATIBLE: - return CompatibilityReport( - manifest=parsed_manifest, - stages=stages, - final_verdict=Verdict.INCOMPATIBLE, - target_protocol=category_result.evidence.get("target_protocol"), - rejection_reason=security_result.details, + return _finalize_source_report( + CompatibilityReport( + manifest=parsed_manifest, + stages=stages, + final_verdict=Verdict.INCOMPATIBLE, + target_protocol=category_result.evidence.get("target_protocol"), + rejection_reason=security_result.details, + ), + execution_plan, ) # ── Final verdict synthesis ────────────────────────────────────── - return synthesize_verdict(parsed_manifest, stages) + report = synthesize_verdict(parsed_manifest, stages) + if execution_plan is not None: + report = _attach_source_plan(report, execution_plan) + return report + + +def _finalize_source_report( + report: CompatibilityReport, plan: ExecutionPlan | None +) -> CompatibilityReport: + """Preserve source evidence on every verdict, including short-circuit rejection.""" + return _attach_source_plan(report, plan) if plan is not None else report + + +def _attach_source_plan( + report: CompatibilityReport, plan: ExecutionPlan +) -> CompatibilityReport: + """Attach a static execution plan without claiming runtime readiness.""" + if report.final_verdict == Verdict.INCOMPATIBLE: + return replace(report, execution_plan=plan) + if plan.blockers: + return replace( + report, + final_verdict=Verdict.INCOMPATIBLE, + rejection_reason="; ".join(plan.blockers), + adapter_spec=None, + execution_plan=plan, + ) + has_unsupported = any( + component.status == ComponentStatus.UNSUPPORTED + for component in plan.components + ) + final_verdict = Verdict.PARTIAL if has_unsupported else Verdict.ADAPTABLE + notes = list(report.adaptation_notes) + for limitation in plan.limitations: + if limitation not in notes: + notes.append(limitation) + return replace( + report, + final_verdict=final_verdict, + adaptation_notes=notes, + execution_plan=plan, + ) def _incompatible_report(reason: str) -> CompatibilityReport: diff --git a/src/leapflow/learning/compatibility/protocol.py b/src/leapflow/learning/compatibility/protocol.py index ec9cf5e..97c3be8 100644 --- a/src/leapflow/learning/compatibility/protocol.py +++ b/src/leapflow/learning/compatibility/protocol.py @@ -46,6 +46,82 @@ class SecurityRisk(Enum): CRITICAL = "critical" +class PluginSourceKind(Enum): + """Foreign plugin source layouts understood by the compatibility layer.""" + + MANIFEST_ONLY = "manifest_only" + DSH_PACKAGE = "dsh_package" + CORDIS_DYNAMIC_EXPORT = "cordis_dynamic_export" + LEAPFLOW_NATIVE = "leapflow_native" + + +class ComponentKind(Enum): + """Independently assessed pieces of a foreign plugin bundle.""" + + HOST = "host" + CLIENT = "client" + + +class ComponentStatus(Enum): + """Whether one component can execute in the current LeapFlow runtime.""" + + CANDIDATE = "candidate" + RUNTIME_READY = "runtime_ready" + UNSUPPORTED = "unsupported" + BLOCKED = "blocked" + + +@dataclass(frozen=True) +class ComponentCompatibility: + """Compatibility verdict for one source component.""" + + name: str + kind: ComponentKind + status: ComponentStatus + reason: str + entry_point: str = "" + metadata: dict[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True) +class ExecutionPlan: + """Concrete bridge plan attached to a compatibility report. + + Static assessment produces candidate components. Runtime discovery replaces + the host component with RUNTIME_READY only after a restricted Node worker has + loaded the source and returned valid public tool descriptors. + """ + + source_kind: PluginSourceKind + source_root: str = "" + entry_point: str = "" + runtime: str = "" + bundle_sha256: str = "" + source_files: tuple[str, ...] = () + requires_discovery: bool = False + dependencies: tuple[str, ...] = () + permissions: tuple[str, ...] = () + components: tuple[ComponentCompatibility, ...] = () + blockers: tuple[str, ...] = () + limitations: tuple[str, ...] = () + + @property + def runtime_ready(self) -> bool: + return any( + component.kind == ComponentKind.HOST + and component.status == ComponentStatus.RUNTIME_READY + for component in self.components + ) + + @property + def installable_candidate(self) -> bool: + return not self.blockers and any( + component.kind == ComponentKind.HOST + and component.status in {ComponentStatus.CANDIDATE, ComponentStatus.RUNTIME_READY} + for component in self.components + ) + + @dataclass(frozen=True) class PluginManifestInput: """Unified input format for assessment — normalizes DSH package.json @@ -97,9 +173,26 @@ class CompatibilityReport: rejection_reason: Optional[str] = None adaptation_notes: list[str] = field(default_factory=list) adapter_spec: Optional[AdapterSpec] = None + execution_plan: Optional[ExecutionPlan] = None def is_installable(self) -> bool: - """Whether this plugin can be installed (with or without adaptation).""" + """Whether this report proves a plugin is ready for installation. + + Manifest-only reports preserve the historical enum-based answer. Source + bundles are stricter: an untrusted JavaScript bundle becomes installable + only after restricted runtime discovery has produced a real host tool. + Static ADAPTABLE/PARTIAL is a candidate, never proof of executability. + """ + if self.execution_plan is not None: + return ( + self.final_verdict in (Verdict.ADAPTABLE, Verdict.PARTIAL) + and not self.execution_plan.blockers + and self.execution_plan.runtime_ready + ) + if self.manifest.source_format == "dsh": + # A package.json-shaped dict can be classified, but no source has + # been bounded, hashed or executed. It is not an install artifact. + return False return self.final_verdict in ( Verdict.COMPATIBLE, Verdict.ADAPTABLE, diff --git a/src/leapflow/learning/compatibility/source_inspector.py b/src/leapflow/learning/compatibility/source_inspector.py new file mode 100644 index 0000000..b1ec704 --- /dev/null +++ b/src/leapflow/learning/compatibility/source_inspector.py @@ -0,0 +1,402 @@ +"""Inspect real DSH/Cordis source bundles without executing foreign code. + +Static inspection establishes source identity, bounds, integrity and component +shape. It deliberately does not claim runtime compatibility: only the restricted +Node discovery path may promote a host component from CANDIDATE to RUNTIME_READY. +""" +from __future__ import annotations + +import hashlib +import json +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from leapflow.learning.compatibility.protocol import ( + ComponentCompatibility, + ComponentKind, + ComponentStatus, + ExecutionPlan, + PluginManifestInput, + PluginSourceKind, +) + +from leapflow.learning.compatibility.stages.manifest_parser import extract_dsh_category + +_MAX_BUNDLE_FILES = 128 +_MAX_BUNDLE_BYTES = 5_000_000 +_JS_ENTRY_SUFFIXES = frozenset({".js", ".mjs", ".cjs"}) +_TOOL_RE = re.compile(r"(?:harness\.)?registerTool\s*\(") +_HANDLER_RE = re.compile(r"harness\.handle\s*\(\s*(['\"])([^'\"]+)\1") +_SLOT_RE = re.compile(r"slots\.inject\s*\(\s*(['\"])([^'\"]+)\1") +_CTX_SERVICE_RE = re.compile(r"ctx\.get\s*\(\s*(['\"])([^'\"]+)\1") +_INJECT_RE = re.compile(r"\binject\s*[:=]\s*\[(?P[^\]]*)\]", re.DOTALL) +_QUOTED_NAME_RE = re.compile(r"(['\"])(?P[^'\"]+)\1") +_P0_HOST_SERVICES = frozenset({"shell", "tools"}) + + +class SourceInspectionError(ValueError): + """The source bundle is malformed, unsafe, or unsupported in P0.""" + + +@dataclass(frozen=True) +class SourceInspection: + """Static source inspection result consumed by the assessment pipeline.""" + + manifest: PluginManifestInput + execution_plan: ExecutionPlan + + +def inspect_plugin_source(source: str | Path) -> SourceInspection: + """Inspect a DSH package or dynamic Cordis export directory. + + Supported P0 shapes: + - ``package.json`` plus an existing pre-built ``.js/.mjs/.cjs`` entry. + - ``meta.json`` plus ``host.js`` and optional ``client.js`` function bodies. + + Symlinks and nested path escapes are rejected before any source is read. + """ + path = Path(source).expanduser() + if path.is_symlink(): + raise SourceInspectionError(f"Plugin source must not be a symlink: {path}") + if path.is_file(): + if path.name not in {"package.json", "meta.json"}: + raise SourceInspectionError( + "DSH source file must be package.json or meta.json; pass the bundle directory" + ) + root = path.parent + elif path.is_dir(): + root = path + else: + raise SourceInspectionError(f"Plugin source does not exist: {path}") + + root = root.resolve() + files, bundle_bytes, bundle_hash = _bounded_bundle(root) + source_files = tuple(item.relative_to(root).as_posix() for item in files) + file_names = set(source_files) + if "package.json" in file_names: + return _inspect_package(root, bundle_bytes, bundle_hash, source_files) + if "meta.json" in file_names and "host.js" in file_names: + return _inspect_dynamic_export(root, bundle_bytes, bundle_hash, source_files) + raise SourceInspectionError( + "Unrecognized DSH source layout: expected package.json, or meta.json + host.js" + ) + + +def _bounded_bundle(root: Path) -> tuple[list[Path], int, str]: + files: list[Path] = [] + total = 0 + digest = hashlib.sha256() + for item in sorted(root.rglob("*"), key=lambda value: value.as_posix()): + if item.is_symlink(): + raise SourceInspectionError(f"Plugin bundle contains a symlink: {item}") + resolved = item.resolve() + try: + relative = resolved.relative_to(root) + except ValueError as exc: + raise SourceInspectionError(f"Plugin bundle path escapes its root: {item}") from exc + if item.is_dir(): + continue + if not item.is_file(): + raise SourceInspectionError(f"Plugin bundle contains a non-regular file: {item}") + files.append(item) + if len(files) > _MAX_BUNDLE_FILES: + raise SourceInspectionError( + f"Plugin bundle exceeds {_MAX_BUNDLE_FILES} files" + ) + data = item.read_bytes() + total += len(data) + if total > _MAX_BUNDLE_BYTES: + raise SourceInspectionError( + f"Plugin bundle exceeds {_MAX_BUNDLE_BYTES} bytes" + ) + rel_bytes = relative.as_posix().encode("utf-8") + digest.update(len(rel_bytes).to_bytes(4, "big")) + digest.update(rel_bytes) + digest.update(len(data).to_bytes(8, "big")) + digest.update(data) + return files, total, digest.hexdigest() + + +def hash_source_files(root: str | Path, source_files: tuple[str, ...]) -> str: + """Hash the exact original source-file set recorded during inspection.""" + base = Path(root).expanduser().resolve() + digest = hashlib.sha256() + for relative_text in sorted(source_files): + relative = Path(relative_text) + if relative.is_absolute() or ".." in relative.parts: + raise SourceInspectionError(f"Unsafe recorded source path: {relative_text}") + item = base / relative + if item.is_symlink() or not item.is_file(): + raise SourceInspectionError(f"Recorded source file is missing or unsafe: {item}") + data = item.read_bytes() + rel_bytes = relative.as_posix().encode("utf-8") + digest.update(len(rel_bytes).to_bytes(4, "big")) + digest.update(rel_bytes) + digest.update(len(data).to_bytes(8, "big")) + digest.update(data) + return digest.hexdigest() + + +def _read_json_object(path: Path) -> dict[str, Any]: + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc: + raise SourceInspectionError(f"Cannot read {path.name}: {exc}") from exc + if not isinstance(value, dict): + raise SourceInspectionError(f"{path.name} must contain a JSON object") + return value + + +def _dependency_names(raw: dict[str, Any]) -> tuple[str, ...]: + """Return runtime package dependencies from all npm dependency sections.""" + names: set[str] = set() + for field in ("dependencies", "peerDependencies", "optionalDependencies"): + value = raw.get(field) or {} + if not isinstance(value, dict): + raise SourceInspectionError(f"package.json {field} must be an object") + names.update(str(key) for key in value) + return tuple(sorted(names)) + + +def _safe_entry(root: Path, raw_entry: str) -> tuple[Path, str]: + if not raw_entry: + raise SourceInspectionError("DSH package is missing its pre-built JavaScript entry") + entry = (root / raw_entry).resolve() + try: + relative = entry.relative_to(root) + except ValueError as exc: + raise SourceInspectionError(f"DSH entry point escapes the bundle: {raw_entry}") from exc + if entry.is_symlink() or not entry.is_file(): + raise SourceInspectionError(f"DSH entry point does not exist: {raw_entry}") + if entry.suffix.lower() not in _JS_ENTRY_SUFFIXES: + if entry.suffix.lower() in {".ts", ".tsx"}: + raise SourceInspectionError( + "P0 requires a pre-built JavaScript entry; TypeScript build/install is not supported" + ) + raise SourceInspectionError( + f"Unsupported DSH entry suffix {entry.suffix!r}; expected .js/.mjs/.cjs" + ) + return entry, relative.as_posix() + + +def _inspect_package( + root: Path, + bundle_bytes: int, + bundle_hash: str, + source_files: tuple[str, ...], +) -> SourceInspection: + raw = _read_json_object(root / "package.json") + name = str(raw.get("name") or "").strip() + version = str(raw.get("version") or "").strip() + if not name or not version: + raise SourceInspectionError("DSH package.json requires non-empty name and version") + raw_entry = str(raw.get("main") or raw.get("module") or "").strip() + entry, relative_entry = _safe_entry(root, raw_entry) + dependencies = _dependency_names(raw) + blockers: list[str] = [] + if dependencies: + blockers.append( + "P0 does not install npm dependencies; provide a self-contained pre-built bundle" + ) + source = entry.read_text(encoding="utf-8") + services = _extract_services(source) + permissions = _permissions_for_services(services) + blockers.extend(_service_blockers(services)) + metadata = raw.get("dsh", raw.get("leapflow", {})) + category = extract_dsh_category(raw) or "tools" + interfaces = [] + if isinstance(metadata, dict) and isinstance(metadata.get("interfaces"), list): + interfaces = [str(value) for value in metadata["interfaces"]] + components = ( + ComponentCompatibility( + name="host", + kind=ComponentKind.HOST, + status=ComponentStatus.CANDIDATE, + reason="Pre-built JavaScript entry requires restricted Node runtime discovery", + entry_point=relative_entry, + metadata={"services": list(services), "declared_interfaces": interfaces}, + ), + ) + manifest_raw = dict(raw) + manifest_raw["x_leapflow_source"] = _source_metadata( + root, PluginSourceKind.DSH_PACKAGE, relative_entry, bundle_bytes, bundle_hash + ) + manifest = PluginManifestInput( + name=name, + version=version, + category=category, + declared_interfaces=interfaces, + declared_dependencies=list(dependencies), + execution_model="subprocess", + permissions=list(permissions), + source_language="javascript", + raw_manifest=manifest_raw, + source_format="dsh", + ) + plan = ExecutionPlan( + source_kind=PluginSourceKind.DSH_PACKAGE, + source_root=str(root), + entry_point=relative_entry, + runtime="node", + bundle_sha256=bundle_hash, + source_files=source_files, + requires_discovery=True, + dependencies=dependencies, + permissions=permissions, + components=components, + blockers=tuple(blockers), + ) + return SourceInspection(manifest=manifest, execution_plan=plan) + + +def _inspect_dynamic_export( + root: Path, + bundle_bytes: int, + bundle_hash: str, + source_files: tuple[str, ...], +) -> SourceInspection: + meta = _read_json_object(root / "meta.json") + name = str(meta.get("name") or "").strip() + if not name: + raise SourceInspectionError("Cordis export meta.json requires a non-empty name") + version = str(meta.get("version") or "0.0.0+export") + host_source = (root / "host.js").read_text(encoding="utf-8") + services = _extract_services(host_source) + permissions = _permissions_for_services(services) + public_tool_markers = len(_TOOL_RE.findall(host_source)) + handler_names = tuple(match[1] for match in _HANDLER_RE.findall(host_source)) + blockers = _service_blockers(services) + if public_tool_markers == 0: + blockers.append( + "Dynamic export contains no statically visible registerTool call; " + "P0 does not publish private handler channels as LeapFlow tools" + ) + components: list[ComponentCompatibility] = [ + ComponentCompatibility( + name="host", + kind=ComponentKind.HOST, + status=ComponentStatus.CANDIDATE, + reason="Dynamic Cordis host requires restricted runtime discovery", + entry_point="host.js", + metadata={ + "services": list(services), + "public_tool_markers": public_tool_markers, + "handler_channels": list(handler_names), + }, + ) + ] + limitations: list[str] = [] + client_path = root / "client.js" + if client_path.exists(): + client_source = client_path.read_text(encoding="utf-8") + slots = tuple(match[1] for match in _SLOT_RE.findall(client_source)) + components.append( + ComponentCompatibility( + name="client", + kind=ComponentKind.CLIENT, + status=ComponentStatus.UNSUPPORTED, + reason="Cordis React/slots client UI is not executable in LeapFlow P0", + entry_point="client.js", + metadata={"slots": list(slots)}, + ) + ) + limitations.append( + "client.js UI was detected and will be skipped; only safe host tools can be installed" + ) + raw = dict(meta) + raw.update( + { + "main": "host.js", + "keywords": ["tools"], + "dsh": { + "category": "tools", + "interfaces": [], + "permissions": list(permissions), + "execution_model": "subprocess", + }, + "x_leapflow_source": _source_metadata( + root, + PluginSourceKind.CORDIS_DYNAMIC_EXPORT, + "host.js", + bundle_bytes, + bundle_hash, + ), + } + ) + manifest = PluginManifestInput( + name=name, + version=version, + category=extract_dsh_category(meta) or "tools", + declared_interfaces=[], + declared_dependencies=[], + execution_model="subprocess", + permissions=list(permissions), + source_language="javascript", + raw_manifest=raw, + source_format="dsh", + ) + plan = ExecutionPlan( + source_kind=PluginSourceKind.CORDIS_DYNAMIC_EXPORT, + source_root=str(root), + entry_point="host.js", + runtime="node", + bundle_sha256=bundle_hash, + source_files=source_files, + requires_discovery=True, + permissions=permissions, + components=tuple(components), + blockers=tuple(blockers), + limitations=tuple(limitations), + ) + return SourceInspection(manifest=manifest, execution_plan=plan) + + +def _extract_services(source: str) -> tuple[str, ...]: + services = {match[1] for match in _CTX_SERVICE_RE.findall(source)} + for match in _INJECT_RE.finditer(source): + services.update( + quoted.group("name") for quoted in _QUOTED_NAME_RE.finditer(match.group("items")) + ) + return tuple(sorted(services)) + + +def _service_blockers(services: tuple[str, ...]) -> list[str]: + return [ + f"P0 does not expose required DSH host service: {service}" + for service in services + if service not in _P0_HOST_SERVICES + ] + + +def _permissions_for_services(services: tuple[str, ...]) -> tuple[str, ...]: + permissions: set[str] = set() + for service in services: + if service in {"shell", "tools"}: + if service == "shell": + # The P0 shell compatibility surface is a strict curl-to-HTTP shim; + # no raw process execution is exposed to the plugin. + permissions.add("network.outbound") + permissions.add("compat.shell.curl_get") + elif service in {"slots", "layout", "timer"}: + permissions.add(f"client.{service}") + else: + permissions.add(f"unknown.service.{service}") + return tuple(sorted(permissions)) + + +def _source_metadata( + root: Path, + kind: PluginSourceKind, + entry_point: str, + bundle_bytes: int, + bundle_hash: str, +) -> dict[str, Any]: + return { + "source_kind": kind.value, + "source_root": str(root), + "entry_point": entry_point, + "bundle_bytes": bundle_bytes, + "bundle_sha256": bundle_hash, + } diff --git a/src/leapflow/learning/compatibility/stages/dependency_checker.py b/src/leapflow/learning/compatibility/stages/dependency_checker.py index b7162f3..0ab9383 100644 --- a/src/leapflow/learning/compatibility/stages/dependency_checker.py +++ b/src/leapflow/learning/compatibility/stages/dependency_checker.py @@ -19,10 +19,13 @@ ) # ═══════════════════════════════════════════════════════════════════════ -# Known dependency classification patterns. -# Matching is case-insensitive and supports substring matching. +# Known dependency classifications. Names are normalized and matched exactly. # ═══════════════════════════════════════════════════════════════════════ +# Dependencies LeapFlow can actually provide to a foreign runtime in P0. +# npm libraries are intentionally absent: P0 never runs npm install/build, so a +# package depending on node-fetch/axios is not satisfiable merely because Python +# has an HTTP client with a similar purpose. SATISFIABLE_DEPS: set[str] = { # Core runtime services LeapFlow provides "config", @@ -40,14 +43,6 @@ "scheduler", "file_read_gate", "research_ledger", - # Common npm/python packages that are runtime-satisfiable - "node-fetch", - "axios", - "requests", - "aiohttp", - "httpx", - "pydantic", - "asyncio", } SHIMMABLE_DEPS: set[str] = { @@ -79,31 +74,25 @@ } -def _classify_dep(dep: str) -> DependencyFeasibility: - """Classify a single dependency string.""" - dep_lower = dep.lower().strip() +def _classify_dep(dep: str, *, foreign_runtime: bool = False) -> DependencyFeasibility: + """Classify one exact, normalized dependency name. - # Exact match first + Substring matching made unrelated packages inherit privileged classifications + (for example a name containing ``config`` became satisfiable). Dependency + names are stable protocol identifiers, so exact matching is both simpler and + safer. Unknown dependencies from a foreign runtime are blocking in P0 because + LeapFlow neither installs packages nor proves that they are bundled. Native + manifests may still name runtime dependencies injected by the host. + """ + dep_lower = dep.lower().strip() if dep_lower in SATISFIABLE_DEPS: return DependencyFeasibility.SATISFIABLE if dep_lower in SHIMMABLE_DEPS: return DependencyFeasibility.SHIMMABLE if dep_lower in BLOCKING_DEPS: return DependencyFeasibility.BLOCKING - - # Substring/prefix matching for common patterns - for known in SATISFIABLE_DEPS: - if known in dep_lower or dep_lower in known: - return DependencyFeasibility.SATISFIABLE - for known in SHIMMABLE_DEPS: - if known in dep_lower or dep_lower in known: - return DependencyFeasibility.SHIMMABLE - for known in BLOCKING_DEPS: - if known in dep_lower or dep_lower in known: - return DependencyFeasibility.BLOCKING - - # Unknown deps default to satisfiable (benefit of the doubt for - # external libs like npm packages or Python packages) + if foreign_runtime: + return DependencyFeasibility.BLOCKING return DependencyFeasibility.SATISFIABLE @@ -136,8 +125,12 @@ def assess( shimmable: list[str] = [] satisfiable: list[str] = [] + foreign_runtime = manifest.source_format == "dsh" or manifest.source_language.lower() in { + "javascript", + "typescript", + } for dep in deps: - feasibility = _classify_dep(dep) + feasibility = _classify_dep(dep, foreign_runtime=foreign_runtime) classification[dep] = feasibility.value if feasibility == DependencyFeasibility.BLOCKING: blocking.append(dep) @@ -160,8 +153,9 @@ def assess( passed=False, verdict=Verdict.INCOMPATIBLE, details=( - f"Blocking dependencies cannot be satisfied: {blocking}. " - "These require DSH-specific runtime services not available in LeapFlow." + f"Blocking or unavailable dependencies cannot be satisfied in P0: {blocking}. " + "DSH packages must be self-contained pre-built bundles; npm install/build " + "and architecture-bound DSH services are not available." ), evidence=evidence, ) diff --git a/src/leapflow/learning/compatibility/stages/interface_analyzer.py b/src/leapflow/learning/compatibility/stages/interface_analyzer.py index dd3d2ca..da788af 100644 --- a/src/leapflow/learning/compatibility/stages/interface_analyzer.py +++ b/src/leapflow/learning/compatibility/stages/interface_analyzer.py @@ -107,20 +107,32 @@ def assess( declared = manifest.declared_interfaces - # If no interfaces declared, give benefit of the doubt + # Missing interfaces are not evidence of compatibility. JavaScript/Cordis + # plugins often register tools dynamically, so the restricted Node worker + # must discover the real public surface before installation. For native + # manifests, absence is still a partial contract rather than an assumed + # match. if not declared: + requires_discovery = manifest.source_language.lower().strip() in { + "typescript", "javascript", "rust", "go" + } return StageResult( stage_name=self.stage_name, passed=True, - verdict=None, + verdict=Verdict.ADAPTABLE if requires_discovery else None, details=( - f"No interfaces declared; assuming compatibility with {target_protocol} " - "(manifest does not list explicit interfaces)" + f"No interfaces declared for {target_protocol}; restricted runtime " + "discovery is required before the plugin is installable" + if requires_discovery + else f"No interfaces declared; native {target_protocol} validation is deferred to import" ), evidence={ "target_protocol": target_protocol, "declared_interfaces": [], - "match_type": "assumed", + "match_type": ( + "runtime_discovery_required" if requires_discovery else "native_import_required" + ), + "requires_runtime_discovery": requires_discovery, }, ) diff --git a/src/leapflow/learning/compatibility/stages/manifest_parser.py b/src/leapflow/learning/compatibility/stages/manifest_parser.py index 9c65175..37fcb44 100644 --- a/src/leapflow/learning/compatibility/stages/manifest_parser.py +++ b/src/leapflow/learning/compatibility/stages/manifest_parser.py @@ -10,7 +10,7 @@ from typing import Any, List -from leapflow.learning.compatibility.protocol import PluginManifestInput, StageResult, Verdict +from leapflow.learning.compatibility.protocol import PluginManifestInput, StageResult class ManifestParser: @@ -116,7 +116,7 @@ def _parse_dsh(raw: dict[str, Any]) -> StageResult: ) # Extract category from keywords, dsh metadata section, or leapflow section - category = _extract_dsh_category(raw) + category = extract_dsh_category(raw) # Extract dependencies deps_raw = raw.get("dependencies", {}) @@ -231,19 +231,20 @@ def _parse_leapflow(raw: dict[str, Any]) -> StageResult: ) -def _extract_dsh_category(raw: dict[str, Any]) -> str: - """Extract category from DSH manifest using multiple heuristics. +def extract_dsh_category(raw: dict[str, Any]) -> str: + """Extract a category from explicit metadata or a package-name taxonomy match. - Priority: - 1. Explicit category in dsh/leapflow metadata section - 2. First relevant keyword from keywords array - 3. Inferred from package name prefix (dsh--*) - 4. Fallback to empty string + Multi-segment architecture categories such as ``agent-loop`` must stay intact: + reducing ``dsh-agent-loop`` to ``agent`` bypasses the explicit non-pluggable + boundary. Known taxonomy keys therefore win before the legacy first-segment + fallback used for uncatalogued packages. """ # 1. Explicit metadata metadata = raw.get("dsh", raw.get("leapflow", {})) if isinstance(metadata, dict) and metadata.get("category"): - return metadata["category"] + return str(metadata["category"]) + if raw.get("category"): + return str(raw["category"]) # 2. Keywords keywords = raw.get("keywords", []) @@ -254,16 +255,28 @@ def _extract_dsh_category(raw: dict[str, Any]) -> str: return kw return "" - # 3. Package name heuristic + # 3. Package name heuristic. Match the longest known category first so + # architecture boundaries such as agent-loop are not weakened to "agent". name = raw.get("name", "") if isinstance(name, str): - # Strip org prefix like @deepseek-ai/ if "/" in name: name = name.split("/", 1)[1] - # Strip dsh- prefix and take first segment - if name.startswith("dsh-"): - parts = name[4:].split("-", 1) - if parts: + had_dsh_prefix = name.startswith("dsh-") + if had_dsh_prefix: + name = name[4:] + from leapflow.learning.compatibility.taxonomy import PLUGGABILITY_TAXONOMY + + for category in sorted(PLUGGABILITY_TAXONOMY, key=len, reverse=True): + if ( + name == category + or name.startswith(f"{category}-") + or name.endswith(f"-{category}") + or f"-{category}-" in f"-{name}-" + ): + return category + if had_dsh_prefix: + parts = name.split("-", 1) + if parts and parts[0]: return parts[0] return "" diff --git a/src/leapflow/learning/compatibility/stages/security_classifier.py b/src/leapflow/learning/compatibility/stages/security_classifier.py index 8bd1e7d..5e31265 100644 --- a/src/leapflow/learning/compatibility/stages/security_classifier.py +++ b/src/leapflow/learning/compatibility/stages/security_classifier.py @@ -17,10 +17,9 @@ ) # ═══════════════════════════════════════════════════════════════════════ -# Permission → SecurityRisk mapping. -# Uses substring matching for flexibility with varied naming conventions. -# ═══════════════════════════════════════════════════════════════════════ - +# Permission identifiers are a stable protocol boundary and are matched exactly. +# Substring matching made names such as ``read_secrets`` look LOW because they +# contained ``read``. Unknown permissions are HIGH and require review. _PERMISSION_RISK_MAP: dict[str, SecurityRisk] = { # LOW risk — read-only operations "fs.read": SecurityRisk.LOW, @@ -36,8 +35,7 @@ "network.outbound": SecurityRisk.MEDIUM, "network_outbound": SecurityRisk.MEDIUM, "network.connect": SecurityRisk.MEDIUM, - "http": SecurityRisk.MEDIUM, - "net": SecurityRisk.MEDIUM, + "compat.shell.curl_get": SecurityRisk.MEDIUM, # HIGH risk — shell execution and process management "shell.execute": SecurityRisk.HIGH, "shell_execute": SecurityRisk.HIGH, @@ -77,20 +75,10 @@ def _classify_permission(permission: str) -> SecurityRisk: - """Classify a single permission string to a risk level.""" - perm_lower = permission.lower().strip() - - # Exact match - if perm_lower in _PERMISSION_RISK_MAP: - return _PERMISSION_RISK_MAP[perm_lower] - - # Substring match - for known, risk in _PERMISSION_RISK_MAP.items(): - if known in perm_lower or perm_lower in known: - return risk - - # Default: MEDIUM for unknown permissions (conservative) - return SecurityRisk.MEDIUM + """Classify one exact, normalized permission identifier.""" + return _PERMISSION_RISK_MAP.get( + permission.lower().strip(), SecurityRisk.HIGH + ) class SecurityClassifier: @@ -108,17 +96,27 @@ def assess( - Aggregate to highest risk level across all permissions """ permissions = manifest.permissions + is_foreign_runtime = manifest.source_language.lower().strip() in { + "typescript", "javascript", "rust", "go" + } if not permissions: + isolation = "sandbox" if is_foreign_runtime else "in_process" + verdict = Verdict.ADAPTABLE if is_foreign_runtime else None return StageResult( stage_name=self.stage_name, passed=True, - verdict=None, - details="No permissions declared; low risk", + verdict=verdict, + details=( + "No permissions declared; foreign source still requires sandbox isolation" + if is_foreign_runtime + else "No permissions declared; low risk" + ), evidence={ "permissions": [], "risk_level": SecurityRisk.LOW.value, - "isolation": "in_process", + "isolation": isolation, "classification": {}, + "recommendation": isolation, }, ) @@ -131,7 +129,7 @@ def assess( if _RISK_ORDER[risk] > _RISK_ORDER[highest_risk]: highest_risk = risk - isolation = _ISOLATION_RECOMMENDATION[highest_risk] + isolation = "sandbox" if is_foreign_runtime else _ISOLATION_RECOMMENDATION[highest_risk] # Determine if source is untrusted (DSH format without verification) is_untrusted = manifest.source_format == "dsh" @@ -157,14 +155,14 @@ def assess( evidence={**evidence, "recommendation": "reject"}, ) - # HIGH risk → passed but recommend sandbox - if highest_risk in (SecurityRisk.HIGH, SecurityRisk.CRITICAL): + # HIGH risk or any foreign runtime requires sandbox isolation. + if highest_risk in (SecurityRisk.HIGH, SecurityRisk.CRITICAL) or is_foreign_runtime: return StageResult( stage_name=self.stage_name, passed=True, verdict=Verdict.ADAPTABLE, details=( - f"Risk level {highest_risk.value}; recommend sandbox isolation. " + f"Risk level {highest_risk.value}; sandbox isolation is required. " f"Permissions: {permissions}" ), evidence={**evidence, "recommendation": "sandbox"}, diff --git a/src/leapflow/plugins/dsh/__init__.py b/src/leapflow/plugins/dsh/__init__.py new file mode 100644 index 0000000..d287e9a --- /dev/null +++ b/src/leapflow/plugins/dsh/__init__.py @@ -0,0 +1,37 @@ +"""Restricted DeepSeek Harness / Cordis plugin bridge runtime.""" +from leapflow.plugins.dsh.capabilities import ( + CurlGetSpec, + DshCapabilityBroker, + DshCapabilityError, + parse_curl_get, +) +from leapflow.plugins.dsh.descriptor import ( + DshPluginDescriptor, + DshToolDescriptor, + normalize_plugin_id, + render_python_wrapper, +) +from leapflow.plugins.dsh.installer import ( + DshInstallError, + PreparedDshInstallation, + prepare_dsh_installation, +) +from leapflow.plugins.dsh.node_host import DshNodeHost, DshRuntimeUnavailable +from leapflow.plugins.dsh.plugin import DshBridgePlugin + +__all__ = [ + "CurlGetSpec", + "DshBridgePlugin", + "DshCapabilityBroker", + "DshCapabilityError", + "DshInstallError", + "DshNodeHost", + "DshPluginDescriptor", + "DshRuntimeUnavailable", + "DshToolDescriptor", + "PreparedDshInstallation", + "normalize_plugin_id", + "parse_curl_get", + "prepare_dsh_installation", + "render_python_wrapper", +] diff --git a/src/leapflow/plugins/dsh/bundle.py b/src/leapflow/plugins/dsh/bundle.py new file mode 100644 index 0000000..3384f8e --- /dev/null +++ b/src/leapflow/plugins/dsh/bundle.py @@ -0,0 +1,90 @@ +"""Safe copying and runtime preparation for DSH source bundles.""" +from __future__ import annotations + +import os +import shutil +import uuid +from pathlib import Path + +from leapflow.learning.compatibility.protocol import PluginSourceKind +from leapflow.learning.compatibility.source_inspector import ( + SourceInspection, + inspect_plugin_source, +) + + +class DshBundleError(RuntimeError): + """A DSH bundle could not be prepared for restricted execution.""" + + +def stage_runtime_bundle( + inspection: SourceInspection, + dsh_root: str | Path, + plugin_id: str, +) -> tuple[Path, str]: + """Copy one inspected source into a private staging directory. + + Returns ``(staging_root, runtime_entry)``. The caller owns atomic promotion + to the final directory after restricted discovery succeeds. + """ + destination_root = Path(dsh_root).expanduser().resolve() + destination_root.mkdir(parents=True, exist_ok=True) + staging = destination_root / f".{plugin_id}.staging-{uuid.uuid4().hex}" + source = Path(inspection.execution_plan.source_root).resolve() + try: + staging.mkdir(parents=False, exist_ok=False) + _copy_regular_files(source, staging) + # Re-inspect the copied source before adding any generated runtime file. + # This closes the source-inspection/copy race: approval and audit refer to + # the exact bytes that are eventually installed. + copied = inspect_plugin_source(staging) + if copied.execution_plan.bundle_sha256 != inspection.execution_plan.bundle_sha256: + raise DshBundleError("DSH source changed while it was being staged") + if inspection.execution_plan.source_kind == PluginSourceKind.CORDIS_DYNAMIC_EXPORT: + runtime_entry = "host.runtime.cjs" + host_source = (staging / "host.js").read_text(encoding="utf-8") + wrapper = ( + '"use strict";\n' + "module.exports = (function () {\n" + f"{host_source}\n" + "})();\n" + ) + _atomic_write_text(staging / runtime_entry, wrapper) + else: + runtime_entry = inspection.execution_plan.entry_point + return staging, runtime_entry + except Exception: + shutil.rmtree(staging, ignore_errors=True) + raise + + +def promote_staging_bundle(staging: Path, final_root: Path) -> None: + """Atomically make a staged bundle visible; refuse replacement.""" + if final_root.exists(): + raise DshBundleError(f"DSH plugin bundle already exists: {final_root}") + staging.replace(final_root) + + +def _copy_regular_files(source: Path, destination: Path) -> None: + for item in sorted(source.rglob("*"), key=lambda value: value.as_posix()): + if item.is_symlink(): + raise DshBundleError(f"DSH bundle contains a symlink: {item}") + relative = item.resolve().relative_to(source) + target = destination / relative + if item.is_dir(): + target.mkdir(parents=True, exist_ok=True) + continue + if not item.is_file(): + raise DshBundleError(f"DSH bundle contains a non-regular file: {item}") + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(item, target, follow_symlinks=False) + shutil.copymode(item, target, follow_symlinks=False) + + +def _atomic_write_text(path: Path, content: str) -> None: + temp = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp") + try: + temp.write_text(content, encoding="utf-8") + os.replace(temp, path) + finally: + temp.unlink(missing_ok=True) diff --git a/src/leapflow/plugins/dsh/capabilities.py b/src/leapflow/plugins/dsh/capabilities.py new file mode 100644 index 0000000..df1bbe5 --- /dev/null +++ b/src/leapflow/plugins/dsh/capabilities.py @@ -0,0 +1,121 @@ +"""Host-side typed capabilities exposed to restricted DSH workers. + +Foreign code never receives raw shell, filesystem, process or network access. +The only P0 compatibility shim translates one exact legacy curl GET shape into +LeapFlow's governed ``web_fetch`` path. +""" +from __future__ import annotations + +import json +import re +from dataclasses import dataclass +from typing import Any, Awaitable, Callable + +WebFetch = Callable[[dict[str, Any]], Awaitable[dict[str, Any]]] + +# Stable protocol boundary, not natural-language classification. The entire +# command must match; the quoted URL cannot contain another quote, whitespace, +# shell metacharacters, substitutions, redirects or arbitrary pipes. +_CURL_GET = re.compile( + r"^curl -sS -m (?P\d{1,3}) '(?Phttps?://[^'\s$;&|<>()`\\]+)'" + r"(?P \| iconv -f GB18030 -t UTF-8)?$" +) +_MAX_TIMEOUT_S = 120 +_MAX_RESPONSE_BYTES = 4_194_304 + + +class DshCapabilityError(ValueError): + """A plugin requested a capability outside the P0 policy.""" + + +@dataclass(frozen=True) +class CurlGetSpec: + url: str + timeout_s: int + max_bytes: int + decode_gb18030: bool + + +def parse_curl_get(command: str, *, stdout_max_bytes: int = 1_048_576) -> CurlGetSpec: + """Parse the only shell compatibility shape P0 permits. + + Any deviation is rejected; nothing is executed by a shell. In particular, + semicolons, redirection, command substitution and a second pipeline cannot + be represented by this grammar. + """ + match = _CURL_GET.fullmatch(str(command or "")) + if match is None: + raise DshCapabilityError( + "DSH shell compatibility only permits: curl -sS -m <1..120> " + "'' [| iconv -f GB18030 -t UTF-8]" + ) + timeout = int(match.group("timeout")) + if not 1 <= timeout <= _MAX_TIMEOUT_S: + raise DshCapabilityError("curl timeout must be between 1 and 120 seconds") + max_bytes = min(max(1, int(stdout_max_bytes)), _MAX_RESPONSE_BYTES) + return CurlGetSpec( + url=match.group("url"), + timeout_s=timeout, + max_bytes=max_bytes, + decode_gb18030=bool(match.group("iconv")), + ) + + +class DshCapabilityBroker: + """Dispatch the small, deny-by-default capability surface for one worker.""" + + def __init__(self, *, web_fetch: WebFetch | None = None) -> None: + self._web_fetch = web_fetch + + async def dispatch(self, capability: str, arguments: dict[str, Any]) -> Any: + if capability != "compat.shell.run": + raise DshCapabilityError(f"Unsupported DSH capability: {capability}") + return await self._run_legacy_curl(arguments) + + async def _run_legacy_curl(self, arguments: dict[str, Any]) -> dict[str, Any]: + command = str(arguments.get("command") or "") + try: + stdout_limit = int(arguments.get("stdoutMaxBytes") or 1_048_576) + except (TypeError, ValueError) as exc: + raise DshCapabilityError("stdoutMaxBytes must be an integer") from exc + spec = parse_curl_get(command, stdout_max_bytes=stdout_limit) + fetch = self._web_fetch + if fetch is None: + from leapflow.tools.web_fetch import web_fetch + + fetch = web_fetch + result = await fetch( + { + "url": spec.url, + "timeout": spec.timeout_s, + "max_bytes": spec.max_bytes, + "extract": "raw_text", + "encoding": "gb18030" if spec.decode_gb18030 else "", + } + ) + if not isinstance(result, dict) or result.get("ok") is not True: + error = str( + result.get("error") if isinstance(result, dict) else "web fetch failed" + ) + return { + "exitCode": 22, + "stdout": {"text": ""}, + "stderr": {"text": error[:1000]}, + } + raw_text = result.get("text") + if raw_text is None and "data" in result: + raw_text = json.dumps(result["data"], ensure_ascii=False, separators=(",", ":")) + text = str(raw_text or result.get("body_excerpt") or "") + return { + "exitCode": 0, + "stdout": {"text": _truncate_utf8(text, spec.max_bytes)}, + "stderr": {"text": ""}, + } + + +def _truncate_utf8(value: str, max_bytes: int) -> str: + """Return a valid UTF-8 prefix whose encoded size respects the byte cap.""" + encoded = value.encode("utf-8") + if len(encoded) <= max_bytes: + return value + return encoded[:max_bytes].decode("utf-8", errors="ignore") diff --git a/src/leapflow/plugins/dsh/descriptor.py b/src/leapflow/plugins/dsh/descriptor.py new file mode 100644 index 0000000..b1d5d45 --- /dev/null +++ b/src/leapflow/plugins/dsh/descriptor.py @@ -0,0 +1,163 @@ +"""Persistent descriptors and wrapper generation for installed DSH plugins.""" +from __future__ import annotations + +import json +import re +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Any + +_DESCRIPTOR_VERSION = 1 +_PLUGIN_ID_RE = re.compile(r"[^a-z0-9_]+") + + +@dataclass(frozen=True) +class DshToolDescriptor: + name: str + description: str + parameters_schema: dict[str, Any] + + @classmethod + def from_dict(cls, value: dict[str, Any]) -> "DshToolDescriptor": + name = str(value.get("name") or "") + if not name or not name.replace("_", "").isalnum() or name.lower() != name: + raise ValueError(f"Invalid DSH tool name: {name!r}") + description = str(value.get("description") or "").strip() + if not description: + raise ValueError(f"DSH tool {name!r} requires a description") + schema = value.get("parameters_schema") + if not isinstance(schema, dict) or schema.get("type") != "object": + raise ValueError(f"DSH tool {name!r} requires an object parameters schema") + properties = schema.get("properties", {}) + if not isinstance(properties, dict): + raise ValueError(f"DSH tool {name!r} schema.properties must be an object") + required = schema.get("required", []) + if ( + not isinstance(required, list) + or any(not isinstance(item, str) for item in required) + or any(item not in properties for item in required) + ): + raise ValueError( + f"DSH tool {name!r} schema.required must name declared properties" + ) + return cls(name=name, description=description, parameters_schema=dict(schema)) + + +@dataclass(frozen=True) +class DshPluginDescriptor: + plugin_id: str + name: str + source_kind: str + bundle_root: str + entry_point: str + bundle_sha256: str + runtime_sha256: str + source_files: tuple[str, ...] + tools: tuple[DshToolDescriptor, ...] + permissions: tuple[str, ...] = () + limitations: tuple[str, ...] = () + client_components: tuple[dict[str, Any], ...] = () + descriptor_version: int = _DESCRIPTOR_VERSION + category: str = "bridge" + + def to_dict(self) -> dict[str, Any]: + value = asdict(self) + value["source_files"] = list(self.source_files) + value["tools"] = [asdict(tool) for tool in self.tools] + value["permissions"] = list(self.permissions) + value["limitations"] = list(self.limitations) + value["client_components"] = [dict(item) for item in self.client_components] + return value + + def to_json(self) -> str: + return json.dumps(self.to_dict(), indent=2, ensure_ascii=False, sort_keys=True) + + @classmethod + def from_dict(cls, value: dict[str, Any]) -> "DshPluginDescriptor": + if int(value.get("descriptor_version") or 0) != _DESCRIPTOR_VERSION: + raise ValueError("Unsupported DSH plugin descriptor version") + plugin_id = normalize_plugin_id(str(value.get("plugin_id") or "")) + if plugin_id != value.get("plugin_id"): + raise ValueError("DSH descriptor plugin_id is not normalized") + root = Path(str(value.get("bundle_root") or "")).expanduser().resolve() + source_files = tuple(str(item) for item in value.get("source_files") or ()) + if not source_files: + raise ValueError("DSH descriptor has no recorded source files") + expected_hash = str(value.get("bundle_sha256") or "") + expected_runtime_hash = str(value.get("runtime_sha256") or "") + if not expected_hash or not expected_runtime_hash: + raise ValueError("DSH descriptor requires source and runtime hashes") + raw_tools = value.get("tools") + if not isinstance(raw_tools, list) or not raw_tools: + raise ValueError("DSH descriptor must expose at least one public tool") + tools = tuple(DshToolDescriptor.from_dict(dict(item)) for item in raw_tools) + if len({tool.name for tool in tools}) != len(tools): + raise ValueError("DSH descriptor contains duplicate tool names") + descriptor = cls( + plugin_id=plugin_id, + name=str(value.get("name") or plugin_id), + source_kind=str(value.get("source_kind") or ""), + bundle_root=str(root), + entry_point=str(value.get("entry_point") or ""), + bundle_sha256=expected_hash, + runtime_sha256=expected_runtime_hash, + source_files=source_files, + tools=tools, + permissions=tuple(str(item) for item in value.get("permissions") or ()), + limitations=tuple(str(item) for item in value.get("limitations") or ()), + client_components=tuple(dict(item) for item in value.get("client_components") or ()), + descriptor_version=_DESCRIPTOR_VERSION, + category=str(value.get("category") or "bridge"), + ) + descriptor.verify_integrity() + return descriptor + + def verify_integrity(self) -> None: + """Refuse execution when installed source or the runtime wrapper changed.""" + from leapflow.learning.compatibility.source_inspector import hash_source_files + + root = Path(self.bundle_root).expanduser().resolve() + if hash_source_files(root, self.source_files) != self.bundle_sha256: + raise ValueError("DSH source bundle hash does not match its approved descriptor") + entry = (root / self.entry_point).resolve() + try: + entry.relative_to(root) + except ValueError as exc: + raise ValueError("DSH descriptor entry point escapes bundle root") from exc + if entry.is_symlink() or not entry.is_file(): + raise ValueError(f"DSH descriptor entry point is missing or unsafe: {entry}") + if _sha256_file(entry) != self.runtime_sha256: + raise ValueError("DSH runtime entry hash does not match its approved descriptor") + + @classmethod + def from_json_file(cls, path: str | Path) -> "DshPluginDescriptor": + value = json.loads(Path(path).read_text(encoding="utf-8")) + if not isinstance(value, dict): + raise ValueError("DSH descriptor must contain a JSON object") + return cls.from_dict(value) + + +def _sha256_file(path: Path) -> str: + import hashlib + + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def normalize_plugin_id(value: str) -> str: + normalized = _PLUGIN_ID_RE.sub("_", value.lower().replace("-", "_")) + normalized = normalized.strip("_") + if not normalized: + raise ValueError("DSH plugin id normalizes to an empty value") + if normalized[0].isdigit(): + normalized = f"dsh_{normalized}" + return normalized + + +def render_python_wrapper(descriptor: DshPluginDescriptor) -> str: + """Render a deterministic native wrapper discoverable after daemon restart.""" + payload = repr(descriptor.to_dict()) + return ( + '"""Generated LeapFlow wrapper for an installed DSH plugin bundle."""\n' + "from leapflow.plugins.dsh.plugin import DshBridgePlugin\n\n" + f"plugin = DshBridgePlugin({payload})\n" + ) diff --git a/src/leapflow/plugins/dsh/installer.py b/src/leapflow/plugins/dsh/installer.py new file mode 100644 index 0000000..6133c85 --- /dev/null +++ b/src/leapflow/plugins/dsh/installer.py @@ -0,0 +1,209 @@ +"""Prepare and validate DSH plugin installations before registry mutation.""" +from __future__ import annotations + +import hashlib +import shutil +from dataclasses import dataclass, replace +from pathlib import Path +from typing import Any + +from leapflow.learning.compatibility import assess_plugin, inspect_plugin_source +from leapflow.learning.compatibility.protocol import ( + CompatibilityReport, + ComponentCompatibility, + ComponentKind, + ComponentStatus, + Verdict, +) +from leapflow.plugins.dsh.bundle import stage_runtime_bundle +from leapflow.plugins.dsh.capabilities import DshCapabilityBroker +from leapflow.plugins.dsh.descriptor import ( + DshPluginDescriptor, + DshToolDescriptor, + normalize_plugin_id, + render_python_wrapper, +) +from leapflow.plugins.dsh.node_host import DshNodeHost + + +class DshInstallError(RuntimeError): + """A DSH source failed assessment or restricted runtime discovery.""" + + +@dataclass +class PreparedDshInstallation: + plugin_id: str + staging_root: Path + final_root: Path + wrapper_path: Path + descriptor: DshPluginDescriptor + wrapper_source: str + compatibility: CompatibilityReport + + def cleanup(self) -> None: + shutil.rmtree(self.staging_root, ignore_errors=True) + + +async def prepare_dsh_installation( + source_path: str | Path, + *, + plugin_id: str, + plugins_dir: str | Path, + dsh_plugins_dir: str | Path, + broker: DshCapabilityBroker | None = None, + settings: Any = None, +) -> PreparedDshInstallation: + """Inspect, stage and discover a DSH plugin without making it live.""" + inspection = inspect_plugin_source(source_path) + report = assess_plugin(source_path) + plan = report.execution_plan + if plan is None: + raise DshInstallError("DSH assessment did not produce an execution plan") + if report.final_verdict == Verdict.INCOMPATIBLE or plan.blockers: + reason = report.rejection_reason or "; ".join(plan.blockers) + raise DshInstallError(reason or "DSH plugin is incompatible") + + normalized_id = normalize_plugin_id(plugin_id or inspection.manifest.name) + plugins_root = Path(plugins_dir).expanduser().resolve() + dsh_root = Path(dsh_plugins_dir).expanduser().resolve() + final_root = dsh_root / normalized_id + wrapper_path = plugins_root / f"{normalized_id}.py" + if final_root.exists() or wrapper_path.exists(): + raise DshInstallError(f"DSH plugin '{normalized_id}' is already installed") + + staging, runtime_entry = stage_runtime_bundle(inspection, dsh_root, normalized_id) + try: + host = _node_host( + staging, + source_kind=plan.source_kind.value, + entry_point=runtime_entry, + broker=broker, + settings=settings, + ) + try: + response = await host.discover() + if not response.ok: + diagnostic = host.stderr_tail.strip() + suffix = f" Worker stderr: {diagnostic}" if diagnostic else "" + raise DshInstallError( + f"Restricted DSH discovery failed: {response.error}.{suffix}" + ) + discovery = response.result + if not isinstance(discovery, dict): + raise DshInstallError("Restricted DSH discovery returned a non-object result") + raw_tools = discovery.get("tools") + if not isinstance(raw_tools, list) or not raw_tools: + raise DshInstallError( + "DSH source exposed no public registerTool tools; handler channels and client UI " + "are not published as LeapFlow tools in P0" + ) + tools = tuple(DshToolDescriptor.from_dict(dict(item)) for item in raw_tools) + finally: + await host.stop() + + components = [] + for component in plan.components: + if component.kind == ComponentKind.HOST: + components.append( + ComponentCompatibility( + name=component.name, + kind=component.kind, + status=ComponentStatus.RUNTIME_READY, + reason=f"Restricted Node discovery exposed {len(tools)} public tool(s)", + entry_point=runtime_entry, + metadata={ + **component.metadata, + "tools": [tool.name for tool in tools], + "handler_channels": list(discovery.get("handler_channels") or ()), + "node_version": str(discovery.get("node_version") or ""), + }, + ) + ) + else: + components.append(component) + ready_plan = replace( + plan, + source_root=str(final_root), + entry_point=runtime_entry, + requires_discovery=False, + components=tuple(components), + ) + final_verdict = ( + Verdict.PARTIAL + if any(item.status == ComponentStatus.UNSUPPORTED for item in components) + else Verdict.ADAPTABLE + ) + ready_report = replace( + report, + final_verdict=final_verdict, + execution_plan=ready_plan, + ) + descriptor = DshPluginDescriptor( + plugin_id=normalized_id, + name=inspection.manifest.name, + source_kind=plan.source_kind.value, + bundle_root=str(final_root), + entry_point=runtime_entry, + bundle_sha256=plan.bundle_sha256, + runtime_sha256=hashlib.sha256((staging / runtime_entry).read_bytes()).hexdigest(), + source_files=plan.source_files, + tools=tools, + permissions=plan.permissions, + limitations=plan.limitations, + client_components=tuple( + { + "name": item.name, + "status": item.status.value, + "reason": item.reason, + **item.metadata, + } + for item in components + if item.kind == ComponentKind.CLIENT + ), + ) + return PreparedDshInstallation( + plugin_id=normalized_id, + staging_root=staging, + final_root=final_root, + wrapper_path=wrapper_path, + descriptor=descriptor, + wrapper_source=render_python_wrapper(descriptor), + compatibility=ready_report, + ) + except Exception: + # prepare_dsh_installation has no owner to call Prepared.cleanup() until + # it returns. Any discovery/schema/runtime failure must therefore remove + # the private staging copy here. + shutil.rmtree(staging, ignore_errors=True) + raise + + +def _node_host( + source_root: Path, + *, + source_kind: str, + entry_point: str, + broker: DshCapabilityBroker | None, + settings: Any, +) -> DshNodeHost: + return DshNodeHost( + source_root, + source_kind=source_kind, + entry_point=entry_point, + broker=broker, + invoke_timeout_s=float( + getattr(settings, "plugins_dsh_invoke_timeout_s", 30.0) + ), + discovery_timeout_s=float( + getattr(settings, "plugins_dsh_discovery_timeout_s", 10.0) + ), + max_line_bytes=int( + getattr(settings, "plugins_dsh_max_message_bytes", 1_000_000) + ), + max_stderr_bytes=int( + getattr(settings, "plugins_dsh_max_stderr_bytes", 64_000) + ), + max_memory_mb=int( + getattr(settings, "plugins_dsh_max_memory_mb", 128) + ), + ) diff --git a/src/leapflow/plugins/dsh/node_host.py b/src/leapflow/plugins/dsh/node_host.py new file mode 100644 index 0000000..5527686 --- /dev/null +++ b/src/leapflow/plugins/dsh/node_host.py @@ -0,0 +1,357 @@ +"""Restricted Node subprocess host for executable DSH plugin bridges.""" +from __future__ import annotations + +import asyncio +import logging +import os +import re +import shutil +import signal +import sys +import uuid +from collections import deque +from pathlib import Path +from typing import Any + +from leapflow.plugins.dsh.capabilities import DshCapabilityBroker, DshCapabilityError +from leapflow.plugins.dsh.protocol import ( + CapabilityRequest, + DshProtocolError, + DshRequest, + DshResponse, + MAX_LINE_BYTES, + capability_response, + parse_message, +) +from leapflow.utils.process_group import ProcessGroup + +logger = logging.getLogger(__name__) +_NODE_VERSION = re.compile(r"^v(?P\d+)(?:\.\d+){2}$") +_MIN_NODE_MAJOR = 22 + + +class DshRuntimeUnavailable(RuntimeError): + """The restricted Node runtime cannot start in this environment.""" + + +class DshNodeHost: + """Own one restricted Node worker and proxy versioned NDJSON requests. + + The host serializes requests per process. Capability requests are serviced + while the outer tool request is pending, then the matching response resumes. + stdout is protocol-only; stderr is drained independently into a bounded tail. + """ + + def __init__( + self, + source_root: str | Path, + *, + source_kind: str, + entry_point: str, + broker: DshCapabilityBroker | None = None, + invoke_timeout_s: float = 30.0, + discovery_timeout_s: float = 10.0, + max_line_bytes: int = MAX_LINE_BYTES, + max_stderr_bytes: int = 64_000, + max_memory_mb: int = 128, + ) -> None: + self._source_root = Path(source_root).expanduser().resolve() + self._source_kind = str(source_kind) + self._entry_point = str(entry_point) + self._broker = broker or DshCapabilityBroker() + self._invoke_timeout_s = max(0.1, float(invoke_timeout_s)) + self._discovery_timeout_s = max(0.1, float(discovery_timeout_s)) + self._max_line_bytes = max(1024, int(max_line_bytes)) + self._max_stderr_bytes = max(1024, int(max_stderr_bytes)) + self._max_memory_mb = max(32, int(max_memory_mb)) + self._proc: asyncio.subprocess.Process | None = None + self._group: ProcessGroup | None = None + self._lock = asyncio.Lock() + self._stderr_task: asyncio.Task[None] | None = None + self._stderr_chunks: deque[bytes] = deque() + self._stderr_size = 0 + + @property + def stderr_tail(self) -> str: + return b"".join(self._stderr_chunks).decode("utf-8", errors="replace") + + async def start(self) -> None: + if self._proc is not None: + return + node = shutil.which("node") + if not node: + raise DshRuntimeUnavailable("Node.js >=22 is required for DSH plugins") + major = await _node_major(node) + if major < _MIN_NODE_MAJOR: + raise DshRuntimeUnavailable( + f"Node.js >={_MIN_NODE_MAJOR} is required; found major version {major}" + ) + worker = Path(__file__).with_name("node_worker.js").resolve() + if not self._source_root.is_dir(): + raise DshRuntimeUnavailable(f"DSH source root is not a directory: {self._source_root}") + entry = (self._source_root / self._entry_point).resolve() + try: + entry.relative_to(self._source_root) + except ValueError as exc: + raise DshRuntimeUnavailable("DSH entry point escapes the source root") from exc + if not entry.is_file(): + raise DshRuntimeUnavailable(f"DSH runtime entry does not exist: {entry}") + + env = _minimal_environment() + env["LEAPFLOW_DSH_MAX_LINE_BYTES"] = str(self._max_line_bytes) + env["LEAPFLOW_DSH_CAPABILITY_TIMEOUT_MS"] = str( + int(max(self._invoke_timeout_s, 1.0) * 1000) + ) + args = [ + node, + "--permission", + f"--allow-fs-read={worker}", + f"--allow-fs-read={self._source_root}", + "--disable-sigusr1", + f"--max-old-space-size={self._max_memory_mb}", + str(worker), + str(self._source_root), + self._source_kind, + self._entry_point, + ] + kwargs: dict[str, Any] = {} + if sys.platform == "win32": # pragma: no cover - Windows-specific + import subprocess + + kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP + else: + kwargs["start_new_session"] = True + try: + self._proc = await asyncio.create_subprocess_exec( + *args, + cwd=str(self._source_root), + env=env, + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + limit=self._max_line_bytes + 1, + **kwargs, + ) + except OSError as exc: + raise DshRuntimeUnavailable(f"Cannot start restricted Node worker: {exc}") from exc + self._group = ProcessGroup() + self._group.attach(self._proc.pid) + self._stderr_task = asyncio.create_task(self._drain_stderr()) + + async def discover(self) -> DshResponse: + return await self._request("discover", {}, timeout_s=self._discovery_timeout_s) + + async def invoke(self, tool_name: str, arguments: dict[str, Any]) -> DshResponse: + return await self._request( + "invoke", + {"tool_name": tool_name, "arguments": dict(arguments)}, + timeout_s=self._invoke_timeout_s, + ) + + async def _request( + self, method: str, payload: dict[str, Any], *, timeout_s: float + ) -> DshResponse: + if self._proc is None: + await self.start() + proc = self._proc + if proc is None or proc.stdin is None or proc.stdout is None: + return DshResponse(request_id="", ok=False, error="DSH worker is not running") + request_id = uuid.uuid4().hex + request = DshRequest(request_id=request_id, method=method, payload=payload) + encoded_request = (request.to_json() + "\n").encode("utf-8") + if len(encoded_request) > self._max_line_bytes: + return self._failure( + request_id, + "request_too_large", + f"DSH request exceeds {self._max_line_bytes} bytes", + ) + async with self._lock: + try: + proc.stdin.write(encoded_request) + await proc.stdin.drain() + deadline = asyncio.get_running_loop().time() + timeout_s + while True: + remaining = deadline - asyncio.get_running_loop().time() + if remaining <= 0: + raise TimeoutError + line = await asyncio.wait_for(proc.stdout.readline(), timeout=remaining) + if not line: + return self._failure( + request_id, + "worker_closed", + f"DSH worker closed unexpectedly. {self.stderr_tail}".strip(), + ) + message = parse_message(line, max_bytes=self._max_line_bytes) + message_type = message.get("type") + if message_type == "capability_request": + capability = CapabilityRequest.from_message(message) + if capability.parent_request_id != request_id: + raise DshProtocolError( + "capability request does not belong to the active tool invocation" + ) + await asyncio.wait_for( + self._handle_capability(capability, proc), + timeout=remaining, + ) + continue + response = DshResponse.from_message(message) + if response.request_id != request_id: + raise DshProtocolError( + f"response id mismatch: expected {request_id}, got {response.request_id}" + ) + return response + except TimeoutError: + await self._terminate() + return self._failure( + request_id, + "timeout", + f"DSH {method} timed out after {timeout_s}s", + ) + except (BrokenPipeError, ConnectionResetError, OSError) as exc: + await self._terminate() + return self._failure(request_id, "communication_error", str(exc)) + except (DshProtocolError, ValueError) as exc: + await self._terminate() + return self._failure(request_id, "protocol_error", str(exc)) + + async def _handle_capability( + self, + capability: CapabilityRequest, + proc: asyncio.subprocess.Process, + ) -> None: + if proc.stdin is None: + return + try: + result = await self._broker.dispatch( + capability.capability, capability.arguments + ) + encoded = capability_response( + capability.request_id, ok=True, result=result + ) + except DshCapabilityError as exc: + encoded = capability_response( + capability.request_id, + ok=False, + error=str(exc), + error_type="capability_denied", + ) + except Exception as exc: # noqa: BLE001 - capability boundary fails closed + logger.warning("DSH capability failed", exc_info=True) + encoded = capability_response( + capability.request_id, + ok=False, + error=str(exc), + error_type="capability_error", + ) + proc.stdin.write((encoded + "\n").encode("utf-8")) + await proc.stdin.drain() + + async def _drain_stderr(self) -> None: + proc = self._proc + if proc is None or proc.stderr is None: + return + while True: + chunk = await proc.stderr.read(4096) + if not chunk: + return + self._stderr_chunks.append(chunk) + self._stderr_size += len(chunk) + while self._stderr_size > self._max_stderr_bytes and self._stderr_chunks: + overflow = self._stderr_size - self._max_stderr_bytes + oldest = self._stderr_chunks[0] + if len(oldest) <= overflow: + self._stderr_chunks.popleft() + self._stderr_size -= len(oldest) + else: + self._stderr_chunks[0] = oldest[overflow:] + self._stderr_size -= overflow + + async def stop(self) -> None: + proc = self._proc + if proc is None: + return + if proc.returncode is None and proc.stdin is not None and proc.stdout is not None: + try: + await self._request("shutdown", {}, timeout_s=2.0) + await asyncio.wait_for(proc.wait(), timeout=2.0) + except (TimeoutError, OSError): + await self._terminate() + if proc.returncode is None: + await self._terminate() + if self._stderr_task is not None: + try: + await asyncio.wait_for(self._stderr_task, timeout=1.0) + except TimeoutError: + self._stderr_task.cancel() + try: + await self._stderr_task + except asyncio.CancelledError: + pass + except asyncio.CancelledError: + pass + self._proc = None + self._group = None + self._stderr_task = None + + async def _terminate(self) -> None: + proc = self._proc + if proc is None or proc.returncode is not None: + return + group = self._group + if group is not None and group.terminate(signal.SIGTERM): + try: + await asyncio.wait_for(proc.wait(), timeout=1.0) + return + except TimeoutError: + if sys.platform != "win32": + try: + # The worker starts a fresh session, so its pid is also + # the process-group id. Escalate the whole group rather + # than orphaning descendants after a graceful timeout. + os.killpg(proc.pid, signal.SIGKILL) + await proc.wait() + return + except (ProcessLookupError, OSError): + pass + try: + proc.kill() + await proc.wait() + except (ProcessLookupError, OSError): + pass + + def _failure(self, request_id: str, error_type: str, error: str) -> DshResponse: + return DshResponse( + request_id=request_id, + ok=False, + error=error, + error_type=error_type, + ) + + +async def _node_major(node: str) -> int: + proc: asyncio.subprocess.Process | None = None + try: + proc = await asyncio.create_subprocess_exec( + node, + "--version", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=5.0) + except TimeoutError as exc: + if proc is not None and proc.returncode is None: + proc.kill() + await proc.wait() + raise DshRuntimeUnavailable("Node.js version preflight timed out") from exc + except OSError as exc: + raise DshRuntimeUnavailable(f"Cannot run Node.js preflight: {exc}") from exc + version = stdout.decode("utf-8", errors="replace").strip() + match = _NODE_VERSION.fullmatch(version) + if proc.returncode != 0 or match is None: + raise DshRuntimeUnavailable(f"Cannot determine Node.js version: {version!r}") + return int(match.group("major")) + + +def _minimal_environment() -> dict[str, str]: + allowed = ("PATH", "HOME", "TMPDIR", "LANG", "LC_ALL", "SYSTEMROOT") + return {key: os.environ[key] for key in allowed if key in os.environ} diff --git a/src/leapflow/plugins/dsh/node_worker.js b/src/leapflow/plugins/dsh/node_worker.js new file mode 100644 index 0000000..af99bd3 --- /dev/null +++ b/src/leapflow/plugins/dsh/node_worker.js @@ -0,0 +1,304 @@ +"use strict"; + +// Restricted DSH/Cordis worker. stdout is reserved for protocol NDJSON; all +// diagnostics and plugin console output are redirected to stderr. +const path = require("path"); +const readline = require("readline"); +const { AsyncLocalStorage } = require("async_hooks"); +const { pathToFileURL } = require("url"); + +const VERSION = 1; +const MAX_LINE_BYTES = Number(process.env.LEAPFLOW_DSH_MAX_LINE_BYTES || 1000000); +const CAPABILITY_TIMEOUT_MS = Number(process.env.LEAPFLOW_DSH_CAPABILITY_TIMEOUT_MS || 120000); +const sourceRoot = path.resolve(process.argv[2] || ""); +const entryPoint = String(process.argv[4] || ""); + +const tools = new Map(); +const handlers = new Map(); +const pendingCapabilities = new Map(); +const invocationContext = new AsyncLocalStorage(); + +function stderrLine(level, args) { + const rendered = args.map((value) => { + if (typeof value === "string") return value; + try { return JSON.stringify(value); } catch (_) { return String(value); } + }).join(" "); + process.stderr.write(`[dsh:${level}] ${rendered}\n`); +} + +for (const level of ["log", "info", "warn", "error", "debug"]) { + console[level] = (...args) => stderrLine(level, args); +} + +function writeMessage(message) { + let encoded; + try { + encoded = JSON.stringify(message); + } catch (error) { + encoded = JSON.stringify({ + version: VERSION, + type: "response", + request_id: String(message && message.request_id || "serialization-error"), + ok: false, + error: `Result is not JSON serializable: ${String(error && error.message || error)}`, + error_type: "serialization_error", + }); + } + if (Buffer.byteLength(encoded, "utf8") > MAX_LINE_BYTES) { + encoded = JSON.stringify({ + version: VERSION, + type: "response", + request_id: String(message && message.request_id || "oversize"), + ok: false, + error: `Worker response exceeds ${MAX_LINE_BYTES} bytes`, + error_type: "response_too_large", + }); + } + process.stdout.write(encoded + "\n"); +} + +function normalizeSchema(parameters) { + if (parameters === undefined || parameters === null) { + return { type: "object", properties: {} }; + } + if (typeof parameters !== "object" || Array.isArray(parameters)) { + throw new Error("DSH tool parameters must be an object"); + } + const looksLikeJsonSchema = Object.prototype.hasOwnProperty.call(parameters, "type") + || Object.prototype.hasOwnProperty.call(parameters, "properties") + || Object.prototype.hasOwnProperty.call(parameters, "required") + || Object.prototype.hasOwnProperty.call(parameters, "additionalProperties"); + if (looksLikeJsonSchema) { + if (parameters.type !== undefined && parameters.type !== "object") { + throw new Error("DSH tool parameters JSON schema must have type 'object'"); + } + const properties = parameters.properties === undefined ? {} : parameters.properties; + if (!properties || typeof properties !== "object" || Array.isArray(properties)) { + throw new Error("DSH tool parameters schema.properties must be an object"); + } + const required = parameters.required === undefined ? [] : parameters.required; + if (!Array.isArray(required) || required.some((name) => typeof name !== "string")) { + throw new Error("DSH tool parameters schema.required must be an array of strings"); + } + if (required.some((name) => !Object.prototype.hasOwnProperty.call(properties, name))) { + throw new Error("DSH tool parameters schema.required references an unknown property"); + } + return { ...parameters, type: "object", properties, required }; + } + const properties = {}; + const required = []; + for (const [name, spec] of Object.entries(parameters)) { + if (!spec || typeof spec !== "object" || Array.isArray(spec)) { + throw new Error(`DSH tool parameter ${name} must be an object`); + } + const normalized = { ...spec }; + delete normalized.required; + properties[name] = normalized; + if (spec.required === true) required.push(name); + } + const schema = { type: "object", properties }; + if (required.length) schema.required = required; + return schema; +} + +function registerTool(_ctx, tool) { + if (!tool || typeof tool !== "object") throw new Error("registerTool requires a tool object"); + const name = String(tool.name || ""); + if (!/^[a-z][a-z0-9_]*$/.test(name)) { + throw new Error(`DSH tool name must use lowercase snake_case: ${name}`); + } + if (typeof tool.execute !== "function") throw new Error(`DSH tool ${name} has no execute function`); + if (tools.has(name)) throw new Error(`Duplicate DSH tool: ${name}`); + tools.set(name, { + name, + description: String(tool.description || `DSH tool ${name}`), + parameters_schema: normalizeSchema(tool.parameters), + execute: tool.execute, + }); + return () => tools.delete(name); +} + +function capabilityCall(capability, argumentsValue) { + const parent = invocationContext.getStore(); + if (!parent) return Promise.reject(new Error("Capability requested outside a tool invocation")); + const requestId = `${parent}:${Date.now()}:${Math.random().toString(16).slice(2)}`; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + pendingCapabilities.delete(requestId); + reject(new Error(`Capability ${capability} timed out`)); + }, CAPABILITY_TIMEOUT_MS); + pendingCapabilities.set(requestId, { resolve, reject, timer }); + writeMessage({ + version: VERSION, + type: "capability_request", + request_id: requestId, + parent_request_id: parent, + capability, + arguments: argumentsValue || {}, + }); + }); +} + +const shellService = { + resolve(spec) { return { ...(spec || {}) }; }, + async run(spec) { + return capabilityCall("compat.shell.run", { + command: String(spec && spec.command || ""), + timeoutMs: Number(spec && spec.timeoutMs || 20000), + stdoutMaxBytes: Number(spec && spec.stdoutMaxBytes || 1048576), + }); + }, +}; + +const context = { + get(name) { + if (name === "shell") return shellService; + return undefined; + }, + interval() { + throw new Error("Timers are not available to DSH host plugins in P0"); + }, +}; + +const harness = { + handle(name, handler) { + const normalized = String(name || ""); + if (!normalized || typeof handler !== "function") throw new Error("Invalid harness.handle registration"); + handlers.set(normalized, handler); + return () => handlers.delete(normalized); + }, + defineTool(spec) { return spec; }, + registerTool, +}; + +globalThis.harness = harness; + +function unwrapPlugin(value) { + let candidate = value; + if (candidate && typeof candidate === "object" && "default" in candidate) candidate = candidate.default; + if (candidate && typeof candidate === "object" && "plugin" in candidate) candidate = candidate.plugin; + return candidate; +} + +async function loadPlugin() { + const entry = path.resolve(sourceRoot, entryPoint); + if (!entry.startsWith(sourceRoot + path.sep) && entry !== sourceRoot) { + throw new Error("DSH entry point escapes source root"); + } + let candidate = await import(pathToFileURL(entry).href); + candidate = unwrapPlugin(candidate); + if (typeof candidate === "function") { + const result = candidate(context, {}); + if (result && typeof result.then === "function") await result; + return; + } + if (!candidate || typeof candidate.apply !== "function") { + throw new Error("DSH entry must export a function or an object with apply(ctx)"); + } + const result = candidate.apply(context, {}); + if (result && typeof result.then === "function") await result; +} + +function discoveryResult() { + return { + protocol_version: VERSION, + node_version: process.versions.node, + tools: [...tools.values()].map(({ name, description, parameters_schema }) => ({ + name, + description, + parameters_schema, + })), + handler_channels: [...handlers.keys()].sort(), + capabilities: ["compat.shell.run"], + }; +} + +async function handleRequest(message) { + const validObject = message && typeof message === "object" && !Array.isArray(message); + const requestId = validObject ? String(message.request_id || "") : ""; + try { + if (!validObject || message.version !== VERSION || message.type !== "request" || !requestId) { + throw new Error("Invalid DSH bridge request envelope"); + } + if (!message.payload || typeof message.payload !== "object" || Array.isArray(message.payload)) { + throw new Error("DSH bridge request payload must be an object"); + } + if (message.method === "handshake" || message.method === "discover") { + return { version: VERSION, type: "response", request_id: requestId, ok: true, result: discoveryResult() }; + } + if (message.method === "invoke") { + const payload = message.payload || {}; + const tool = tools.get(String(payload.tool_name || "")); + if (!tool) throw new Error(`Tool not found: ${String(payload.tool_name || "")}`); + const args = payload.arguments; + if (!args || typeof args !== "object" || Array.isArray(args)) { + throw new Error("DSH invoke arguments must be an object"); + } + const result = await invocationContext.run(requestId, () => tool.execute(args, { requestId })); + return { version: VERSION, type: "response", request_id: requestId, ok: true, result }; + } + if (message.method === "shutdown") { + setImmediate(() => process.exit(0)); + return { version: VERSION, type: "response", request_id: requestId, ok: true, result: "bye" }; + } + throw new Error(`Unknown DSH bridge method: ${String(message.method || "")}`); + } catch (error) { + return { + version: VERSION, + type: "response", + request_id: requestId || "unknown", + ok: false, + error: String(error && error.message || error), + error_type: String(error && error.name || "Error"), + }; + } +} + +function handleCapabilityResponse(message) { + const pending = pendingCapabilities.get(String(message.request_id || "")); + if (!pending) return; + pendingCapabilities.delete(String(message.request_id)); + clearTimeout(pending.timer); + if (message.ok === true) pending.resolve(message.result); + else pending.reject(new Error(String(message.error || "Capability failed"))); +} + +async function boot() { + await loadPlugin(); + const input = readline.createInterface({ input: process.stdin, crlfDelay: Infinity }); + input.on("line", (line) => { + if (Buffer.byteLength(line, "utf8") > MAX_LINE_BYTES) { + stderrLine("error", [`Input line exceeds ${MAX_LINE_BYTES} bytes`]); + process.exitCode = 64; + input.close(); + return; + } + let message; + try { message = JSON.parse(line); } + catch (error) { + stderrLine("error", ["Invalid protocol JSON", error]); + process.exitCode = 64; + input.close(); + return; + } + if (message.type === "capability_response") { + handleCapabilityResponse(message); + return; + } + Promise.resolve(handleRequest(message)).then(writeMessage).catch((error) => { + writeMessage({ + version: VERSION, + type: "response", + request_id: String(message && message.request_id || "unknown"), + ok: false, + error: String(error && error.message || error), + error_type: String(error && error.name || "Error"), + }); + }); + }); +} + +boot().catch((error) => { + stderrLine("error", [error && error.stack || error]); + process.exitCode = 70; +}); diff --git a/src/leapflow/plugins/dsh/plugin.py b/src/leapflow/plugins/dsh/plugin.py new file mode 100644 index 0000000..09ac72c --- /dev/null +++ b/src/leapflow/plugins/dsh/plugin.py @@ -0,0 +1,141 @@ +"""Native ToolPlugin wrapper for an installed restricted DSH bundle.""" +from __future__ import annotations + +from typing import Any + +from leapflow.plugins.dsh.capabilities import DshCapabilityBroker +from leapflow.plugins.dsh.descriptor import DshPluginDescriptor, DshToolDescriptor +from leapflow.plugins.dsh.node_host import DshNodeHost, DshRuntimeUnavailable +from leapflow.plugins.protocol import ToolMetadata + + +class DshBridgePlugin: + """Expose runtime-discovered DSH tools through LeapFlow's ToolPlugin contract. + + Each invocation gets its own Node worker. That keeps plugin globals isolated + between sessions and avoids an idle untrusted process. P1 may introduce a + supervised per-session pool after lifecycle and resource evidence exists. + """ + + def __init__(self, descriptor: dict[str, Any] | DshPluginDescriptor) -> None: + self._descriptor = ( + descriptor + if isinstance(descriptor, DshPluginDescriptor) + else DshPluginDescriptor.from_dict(descriptor) + ) + self._web_fetch: Any = None + self._tools = [self._metadata(item) for item in self._descriptor.tools] + + @property + def plugin_id(self) -> str: + return self._descriptor.plugin_id + + @property + def category(self) -> str: + return self._descriptor.category + + @property + def dependencies(self) -> list[str]: + return [] + + @property + def tools(self) -> list[ToolMetadata]: + return list(self._tools) + + @property + def descriptor(self) -> DshPluginDescriptor: + return self._descriptor + + def bind_runtime(self, **deps: Any) -> None: + # Tests can inject a hermetic web_fetch; production uses the governed + # implementation lazily through DshCapabilityBroker. + if "web_fetch" in deps: + self._web_fetch = deps["web_fetch"] + + def _metadata(self, tool: DshToolDescriptor) -> ToolMetadata: + async def _handler(**kwargs: Any) -> Any: + return await self._invoke(tool.name, kwargs) + + return ToolMetadata( + name=tool.name, + description=tool.description, + parameters_schema=dict(tool.parameters_schema), + handler=_handler, + x_leapflow={ + "category": "bridge", + "runtime": "node", + "bridge": "dsh_ndjson_v1", + "risk_level": "external" if self._descriptor.permissions else "medium", + "requires_approval": False, + "execution_policy": "parallel_safe", + "source_bundle_sha256": self._descriptor.bundle_sha256, + "limitations": list(self._descriptor.limitations), + }, + mutates_state=False, + ) + + async def _invoke(self, tool_name: str, arguments: dict[str, Any]) -> Any: + try: + self._descriptor.verify_integrity() + except (OSError, ValueError) as exc: + return { + "ok": False, + "error": str(exc), + "error_type": "integrity_error", + "retryable": False, + } + settings = _settings() + broker = DshCapabilityBroker(web_fetch=self._web_fetch) + host = DshNodeHost( + self._descriptor.bundle_root, + source_kind=self._descriptor.source_kind, + entry_point=self._descriptor.entry_point, + broker=broker, + invoke_timeout_s=float( + getattr(settings, "plugins_dsh_invoke_timeout_s", 30.0) + ), + discovery_timeout_s=float( + getattr(settings, "plugins_dsh_discovery_timeout_s", 10.0) + ), + max_line_bytes=int( + getattr(settings, "plugins_dsh_max_message_bytes", 1_000_000) + ), + max_stderr_bytes=int( + getattr(settings, "plugins_dsh_max_stderr_bytes", 64_000) + ), + max_memory_mb=int( + getattr(settings, "plugins_dsh_max_memory_mb", 128) + ), + ) + try: + response = await host.invoke(tool_name, arguments) + except DshRuntimeUnavailable as exc: + return { + "ok": False, + "error": str(exc), + "error_type": "runtime_unavailable", + "retryable": False, + } + finally: + await host.stop() + if not response.ok: + return { + "ok": False, + "error": response.error, + "error_type": response.error_type or "dsh_runtime_error", + "retryable": response.error_type in {"timeout", "worker_closed"}, + } + if isinstance(response.result, dict): + result = dict(response.result) + result.setdefault("ok", True) + return result + return {"ok": True, "result": response.result} + + +def _settings() -> Any: + try: + from leapflow.config import get_settings + + return get_settings() + except (ImportError, RuntimeError): + return object() diff --git a/src/leapflow/plugins/dsh/protocol.py b/src/leapflow/plugins/dsh/protocol.py new file mode 100644 index 0000000..5b33f57 --- /dev/null +++ b/src/leapflow/plugins/dsh/protocol.py @@ -0,0 +1,132 @@ +"""Versioned NDJSON protocol for the restricted DSH Node worker.""" +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from typing import Any + +PROTOCOL_VERSION = 1 +MAX_LINE_BYTES = 1_000_000 + + +class DshProtocolError(ValueError): + """A worker message violated the bridge protocol.""" + + +@dataclass(frozen=True) +class DshRequest: + request_id: str + method: str + payload: dict[str, Any] = field(default_factory=dict) + version: int = PROTOCOL_VERSION + type: str = "request" + + def to_json(self) -> str: + return json.dumps(self.__dict__, separators=(",", ":"), ensure_ascii=False) + + +@dataclass(frozen=True) +class DshResponse: + request_id: str + ok: bool + result: Any = None + error: str = "" + error_type: str = "" + version: int = PROTOCOL_VERSION + type: str = "response" + + @classmethod + def from_message(cls, message: dict[str, Any]) -> "DshResponse": + _validate_common(message, expected_type="response") + if not isinstance(message.get("ok"), bool): + raise DshProtocolError("response.ok must be a boolean") + return cls( + request_id=str(message["request_id"]), + ok=bool(message["ok"]), + result=message.get("result"), + error=str(message.get("error") or ""), + error_type=str(message.get("error_type") or ""), + ) + + +@dataclass(frozen=True) +class CapabilityRequest: + request_id: str + parent_request_id: str + capability: str + arguments: dict[str, Any] + + @classmethod + def from_message(cls, message: dict[str, Any]) -> "CapabilityRequest": + _validate_common(message, expected_type="capability_request") + parent = message.get("parent_request_id") + capability = message.get("capability") + arguments = message.get("arguments") + if not isinstance(parent, str) or not parent: + raise DshProtocolError("capability_request.parent_request_id is required") + if not isinstance(capability, str) or not capability: + raise DshProtocolError("capability_request.capability is required") + if not isinstance(arguments, dict): + raise DshProtocolError("capability_request.arguments must be an object") + return cls( + request_id=str(message["request_id"]), + parent_request_id=parent, + capability=capability, + arguments=arguments, + ) + + +def capability_response( + request_id: str, + *, + ok: bool, + result: Any = None, + error: str = "", + error_type: str = "", +) -> str: + return json.dumps( + { + "version": PROTOCOL_VERSION, + "type": "capability_response", + "request_id": request_id, + "ok": ok, + "result": result, + "error": error, + "error_type": error_type, + }, + separators=(",", ":"), + ensure_ascii=False, + ) + + +def parse_message(raw: bytes, *, max_bytes: int = MAX_LINE_BYTES) -> dict[str, Any]: + if len(raw) > max_bytes: + raise DshProtocolError(f"worker message exceeds {max_bytes} bytes") + try: + decoded = raw.decode("utf-8") + message = json.loads(decoded) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise DshProtocolError(f"invalid worker JSON: {exc}") from exc + if not isinstance(message, dict): + raise DshProtocolError("worker message must be a JSON object") + _validate_common(message) + return message + + +def _validate_common( + message: dict[str, Any], *, expected_type: str | None = None +) -> None: + if message.get("version") != PROTOCOL_VERSION: + raise DshProtocolError( + f"unsupported DSH bridge protocol version: {message.get('version')!r}" + ) + message_type = message.get("type") + if not isinstance(message_type, str) or not message_type: + raise DshProtocolError("worker message.type is required") + if expected_type is not None and message_type != expected_type: + raise DshProtocolError( + f"expected worker message type {expected_type!r}, got {message_type!r}" + ) + request_id = message.get("request_id") + if not isinstance(request_id, str) or not request_id: + raise DshProtocolError("worker message.request_id is required") diff --git a/src/leapflow/plugins/marketplace/client.py b/src/leapflow/plugins/marketplace/client.py index d5ffbb7..aee8390 100644 --- a/src/leapflow/plugins/marketplace/client.py +++ b/src/leapflow/plugins/marketplace/client.py @@ -82,6 +82,13 @@ def discover(self) -> List[PluginManifest]: """List all available plugins from the source.""" return self._source.list_manifests() + def resolve_manifest(self, name: str) -> Dict[str, Any] | None: + """Return one manifest as a plain mapping for compatibility assessment.""" + from dataclasses import asdict + + manifest = next((item for item in self._source.list_manifests() if item.name == name), None) + return asdict(manifest) if manifest is not None else None + def install( self, name: str, diff --git a/src/leapflow/plugins/scoped_registry.py b/src/leapflow/plugins/scoped_registry.py index e353808..8332779 100644 --- a/src/leapflow/plugins/scoped_registry.py +++ b/src/leapflow/plugins/scoped_registry.py @@ -65,8 +65,15 @@ def scoped_register(self, plugin: ToolPlugin, fiber: PluginFiber) -> None: tools when the fiber is disposed. """ plugin_id = plugin.plugin_id - # Track reload metadata so reload() can re-import the plugin later. - self._plugin_modules[plugin_id] = plugin.__class__.__module__ + # Track reload metadata so reload() can re-import the plugin later. A + # generated wrapper may instantiate a class defined in a core module + # (e.g. DshBridgePlugin), so prefer the wrapper module explicitly attached + # by the file loader over plugin.__class__.__module__. Reloading the core + # module from the wrapper path would overwrite sys.modules and make the + # wrapper import itself recursively. + self._plugin_modules[plugin_id] = str( + getattr(plugin, "__leapflow_plugin_module__", plugin.__class__.__module__) + ) plugin_path = getattr(plugin, "__leapflow_plugin_path__", None) if plugin_path: self._plugin_files[plugin_id] = Path(str(plugin_path)) @@ -135,7 +142,9 @@ def adopt_existing_plugins(self) -> None: if plugin_id in self._fibers: continue # already adopted fiber = self.create_fiber(plugin_id) - self._plugin_modules[plugin_id] = plugin.__class__.__module__ + self._plugin_modules[plugin_id] = str( + getattr(plugin, "__leapflow_plugin_module__", plugin.__class__.__module__) + ) plugin_path = getattr(plugin, "__leapflow_plugin_path__", None) if plugin_path: self._plugin_files[plugin_id] = Path(str(plugin_path)) @@ -322,6 +331,7 @@ def _load_fresh_plugin(self, plugin_id: str, module_path: str) -> ToolPlugin: if file_path is not None: try: setattr(plugin, "__leapflow_plugin_path__", str(file_path)) + setattr(plugin, "__leapflow_plugin_module__", module_path) except Exception: logger.debug("Cannot attach plugin file path metadata for %s", plugin_id, exc_info=True) return plugin diff --git a/src/leapflow/plugins/tool_plugins/__init__.py b/src/leapflow/plugins/tool_plugins/__init__.py index 5ce0348..8d28af2 100644 --- a/src/leapflow/plugins/tool_plugins/__init__.py +++ b/src/leapflow/plugins/tool_plugins/__init__.py @@ -93,6 +93,7 @@ def _load_plugin_from_file(path: Path) -> "ToolPlugin | None": return None try: setattr(plugin, "__leapflow_plugin_path__", str(path)) + setattr(plugin, "__leapflow_plugin_module__", module_name) except Exception: logger.debug("Cannot attach plugin source path metadata for %s", path, exc_info=True) return plugin diff --git a/src/leapflow/plugins/tool_plugins/self_management.py b/src/leapflow/plugins/tool_plugins/self_management.py index d262148..8b52e1b 100644 --- a/src/leapflow/plugins/tool_plugins/self_management.py +++ b/src/leapflow/plugins/tool_plugins/self_management.py @@ -396,6 +396,22 @@ async def _plugin_status_handler(self, plugin_id: str, **kwargs: Any) -> Dict[st "generation": fiber.generation if fiber else None, }, } + descriptor = getattr(plugin, "descriptor", None) + if descriptor is not None and hasattr(descriptor, "to_dict"): + descriptor_data = descriptor.to_dict() + response["dsh"] = { + "source_kind": descriptor_data.get("source_kind"), + "bundle_sha256": descriptor_data.get("bundle_sha256"), + "entry_point": descriptor_data.get("entry_point"), + "verdict": ( + "partial" + if descriptor_data.get("client_components") + else "adaptable" + ), + "limitations": descriptor_data.get("limitations", []), + "client_components": descriptor_data.get("client_components", []), + "runtime": "node", + } # Learning-driven trust and recommendation (purely additive) try: @@ -587,22 +603,46 @@ async def _plugin_generate_handler( # ── Compatibility assessment (read-only) ───────────────── async def _assess_compatibility_handler( - self, manifest: dict = None, **kwargs: Any + self, + manifest: dict | None = None, + source_path: str = "", + **kwargs: Any, ) -> Dict[str, Any]: - """Assess whether a foreign plugin manifest is compatible with LeapFlow.""" + """Assess a foreign manifest or real DSH source bundle.""" if manifest is None: manifest = kwargs.get("manifest") - if not manifest or not isinstance(manifest, dict): - return {"ok": False, "error": "manifest parameter is required (dict)"} + source_path = str(source_path or kwargs.get("source_path") or "") + if manifest and source_path: + return {"ok": False, "error": "Provide either manifest or source_path, not both"} + if not source_path and (not manifest or not isinstance(manifest, dict)): + return { + "ok": False, + "error": "manifest (dict) or source_path (DSH bundle directory) is required", + } + if source_path: + from pathlib import Path + + from leapflow.tools.execution_context import require_workspace_access + + scope_error = await require_workspace_access( + Path(source_path).expanduser().resolve(), + operation="assess_compatibility source", + effect="read", + ) + if scope_error: + return scope_error try: from leapflow.learning.compatibility import assess_plugin - report = assess_plugin(manifest) + report = assess_plugin(source_path or manifest) + plan = report.execution_plan return { "ok": True, "final_verdict": report.final_verdict.value, "is_installable": report.is_installable(), + "installable_candidate": bool(plan and plan.installable_candidate), + "runtime_ready": bool(plan and plan.runtime_ready), "target_protocol": report.target_protocol, "rejection_reason": report.rejection_reason, "adaptation_notes": report.adaptation_notes, @@ -615,6 +655,7 @@ async def _assess_compatibility_handler( } if report.adapter_spec else None, + "execution_plan": self._compatibility_plan_payload(plan), "stages": [ { "stage_name": s.stage_name, @@ -627,10 +668,38 @@ async def _assess_compatibility_handler( "manifest_name": report.manifest.name, "manifest_version": report.manifest.version, } - except (ImportError, AttributeError, TypeError, ValueError) as exc: + except (ImportError, AttributeError, OSError, TypeError, ValueError) as exc: logger.warning("assess_compatibility failed: %s", exc, exc_info=True) return {"ok": False, "error": f"Assessment failed: {exc}"} + @staticmethod + def _compatibility_plan_payload(plan: Any) -> Dict[str, Any] | None: + if plan is None: + return None + return { + "source_kind": plan.source_kind.value, + "source_root": plan.source_root, + "entry_point": plan.entry_point, + "runtime": plan.runtime, + "bundle_sha256": plan.bundle_sha256, + "source_files": list(plan.source_files), + "requires_discovery": plan.requires_discovery, + "runtime_ready": plan.runtime_ready, + "blockers": list(plan.blockers), + "limitations": list(plan.limitations), + "components": [ + { + "name": item.name, + "kind": item.kind.value, + "status": item.status.value, + "reason": item.reason, + "entry_point": item.entry_point, + "metadata": dict(item.metadata), + } + for item in plan.components + ], + } + # ── State-mutating (requires approval) ───────────────── async def _plugin_install_handler( @@ -638,32 +707,78 @@ async def _plugin_install_handler( plugin_id: str = "", code: str = "", marketplace_name: str = "", + source_path: str = "", proposal_id: str = "", version_label: str = "", **kwargs: Any, ) -> Dict[str, Any]: - """Install a plugin from validated code or marketplace, then load it. REQUIRES approval. - - Two modes: - - code: install directly from a validated code string (from plugin_generate) - - marketplace_name: install from the configured marketplace - - Installed code is written into the profile-scoped plugins directory - (ProfileLayout.plugins_dir) and loaded dynamically — never into the - read-only Python package directory. Before a plugin is made live it is - smoke-tested in an isolated subprocess (SandboxHost). Any failure path - rolls back cleanly: no half-initialized fiber and no orphaned file. - """ + """Install Python code/marketplace content or a real DSH source bundle.""" proposal = None if proposal_id: proposal = self._proposal_store().get(proposal_id) if proposal is None: return {"ok": False, "error": f"Plugin proposal '{proposal_id}' not found"} plugin_id = plugin_id or proposal.plugin_id + source_path = str(source_path or kwargs.get("source_path") or "") + modes = sum(bool(value) for value in (code, marketplace_name, source_path)) + if modes != 1: + return { + "ok": False, + "error": "Provide exactly one of code, marketplace_name, or source_path", + } + + source_metadata: dict[str, Any] = {} + if source_path: + try: + from pathlib import Path + + from leapflow.learning.compatibility import assess_plugin, inspect_plugin_source + from leapflow.plugins.dsh import normalize_plugin_id + from leapflow.tools.execution_context import require_workspace_access + + scope_error = await require_workspace_access( + Path(source_path).expanduser().resolve(), + operation="plugin_install source", + effect="read", + ) + if scope_error: + return scope_error + inspection = inspect_plugin_source(source_path) + report = assess_plugin(source_path) + plan = report.execution_plan + if plan is None or not plan.installable_candidate: + return { + "ok": False, + "error": report.rejection_reason or "; ".join(plan.blockers if plan else ()), + "verdict": report.final_verdict.value, + } + plugin_id = normalize_plugin_id(plugin_id or inspection.manifest.name) + source_metadata = { + "source_kind": plan.source_kind.value, + "source_path": str(Path(source_path).expanduser().resolve()), + "bundle_sha256": plan.bundle_sha256, + "verdict": report.final_verdict.value, + "permissions": list(plan.permissions), + "limitations": list(plan.limitations), + "components": [ + { + "name": item.name, + "kind": item.kind.value, + "status": item.status.value, + "reason": item.reason, + } + for item in plan.components + ], + } + except (ImportError, OSError, TypeError, ValueError) as exc: + return {"ok": False, "error": f"DSH source assessment failed: {exc}"} + if not plugin_id: - return {"ok": False, "error": "plugin_id is required unless proposal_id is provided"} + return {"ok": False, "error": "plugin_id is required unless source_path or proposal_id is provided"} - approved, denial = await self._check_approval("install", plugin_id, proposal_id=proposal_id) + approved, denial = await self._check_approval( + "install", plugin_id, proposal_id=proposal_id, metadata=source_metadata, + ) if not approved: return {"ok": False, "error": denial, "requires_approval": True} @@ -684,7 +799,14 @@ async def _plugin_install_handler( return {"ok": False, "error": "Provide either code or marketplace_name, not both"} try: - if code: + if source_path: + result = await self._install_from_dsh_source( + plugin_id, + source_path, + version_label=version_label, + expected_bundle_sha256=str(source_metadata.get("bundle_sha256") or ""), + ) + elif code: result = await self._install_from_code( plugin_id, code, proposal=proposal, version_label=version_label ) @@ -692,7 +814,7 @@ async def _plugin_install_handler( # Run compatibility gate for marketplace installs (BLOCKING) result = await self._install_from_marketplace_with_gate(plugin_id, marketplace_name) else: - return {"ok": False, "error": "Must provide either code or marketplace_name"} + return {"ok": False, "error": "Must provide code, marketplace_name, or source_path"} if proposal_id: result["proposal_id"] = proposal_id if result.get("ok"): @@ -823,6 +945,155 @@ async def _install_from_code( ) return result + def _resolve_dsh_install_dir(self) -> "Path": + """Resolve the profile-owned directory for DSH source bundles.""" + from pathlib import Path + + from leapflow.config import get_settings + + if self._plugin_install_dir: + return Path(self._plugin_install_dir) / "dsh" + settings = get_settings() + profile_layout = getattr(settings, "profile_layout", None) + if profile_layout is not None: + return profile_layout.dsh_plugins_dir + raise RuntimeError("profile_layout is required for DSH plugin storage") + + async def _install_from_dsh_source( + self, + plugin_id: str, + source_path: str, + *, + version_label: str = "", + expected_bundle_sha256: str = "", + ) -> Dict[str, Any]: + """Install a real DSH bundle through restricted Node discovery.""" + import os + import shutil + import uuid + + from leapflow.config import get_settings + from leapflow.learning.plugin_generator import PluginValidator + from leapflow.plugins.dsh import prepare_dsh_installation + from leapflow.plugins.dsh.bundle import promote_staging_bundle + + install_dir = self._resolve_install_dir() + dsh_dir = self._resolve_dsh_install_dir() + settings = get_settings() + prepared = await prepare_dsh_installation( + source_path, + plugin_id=plugin_id, + plugins_dir=install_dir, + dsh_plugins_dir=dsh_dir, + settings=settings, + ) + if ( + expected_bundle_sha256 + and prepared.descriptor.bundle_sha256 != expected_bundle_sha256 + ): + prepared.cleanup() + return { + "ok": False, + "error": "DSH source changed after approval; installation was not attempted", + "failure_code": "source_changed_after_approval", + } + promoted = False + committed = False + descriptor_path = prepared.final_root / "descriptor.json" + try: + install_dir.mkdir(parents=True, exist_ok=True) + promote_staging_bundle(prepared.staging_root, prepared.final_root) + promoted = True + descriptor_temp = descriptor_path.with_name( + f".{descriptor_path.name}.{uuid.uuid4().hex}.tmp" + ) + try: + descriptor_temp.write_text(prepared.descriptor.to_json(), encoding="utf-8") + os.replace(descriptor_temp, descriptor_path) + finally: + descriptor_temp.unlink(missing_ok=True) + + validator = PluginValidator() + validation = await validator.validate( + prepared.plugin_id, prepared.wrapper_source + ) + if not validation.ok: + return { + "ok": False, + "error": ( + f"DSH wrapper failed validation at stage '{validation.stage}': " + f"{validation.error}" + ), + } + + temp_wrapper = prepared.wrapper_path.with_name( + f".{prepared.wrapper_path.name}.{uuid.uuid4().hex}.tmp" + ) + try: + temp_wrapper.write_text(prepared.wrapper_source, encoding="utf-8") + os.replace(temp_wrapper, prepared.wrapper_path) + finally: + temp_wrapper.unlink(missing_ok=True) + + result = self._register_inprocess( + prepared.plugin_id, prepared.plugin_id, prepared.wrapper_path + ) + if not result.get("ok"): + return result + try: + version_info = self._version_store().record_source( + prepared.plugin_id, + prepared.wrapper_path, + version=version_label, + metadata={ + "source": "dsh_source", + "bundle_sha256": prepared.descriptor.bundle_sha256, + "source_kind": prepared.descriptor.source_kind, + "descriptor_path": str(descriptor_path), + "verdict": prepared.compatibility.final_verdict.value, + "limitations": list(prepared.descriptor.limitations), + "installed_tools": [ + tool.name for tool in prepared.descriptor.tools + ], + }, + ) + result["version"] = version_info.get("version", "") + except (RuntimeError, OSError, ValueError, AttributeError) as exc: + logger.debug("DSH version recording skipped: %s", exc, exc_info=True) + result.update( + { + "source_kind": prepared.descriptor.source_kind, + "bundle_sha256": prepared.descriptor.bundle_sha256, + "descriptor_path": str(descriptor_path), + "installed_tools": [tool.name for tool in prepared.descriptor.tools], + "verdict": prepared.compatibility.final_verdict.value, + "limitations": list(prepared.descriptor.limitations), + "client_components": list(prepared.descriptor.client_components), + } + ) + committed = True + return result + finally: + prepared.cleanup() + if promoted and not committed: + # Installation is a single transaction from the user's point of + # view. A validation/registration failure must leave neither a + # wrapper nor a managed bundle (and no partially registered fiber). + try: + from leapflow.plugins import get_scoped_registry + + scoped = get_scoped_registry() + if scoped.get_fiber(prepared.plugin_id) is not None: + scoped.dispose_plugin(prepared.plugin_id, prune_metadata=True) + except (ImportError, KeyError, RuntimeError, AttributeError): + logger.debug( + "DSH install rollback found no live fiber for %s", + prepared.plugin_id, + exc_info=True, + ) + self._safe_unlink(prepared.wrapper_path) + shutil.rmtree(prepared.final_root, ignore_errors=True) + async def _install_from_marketplace_with_gate( self, plugin_id: str, marketplace_name: str ) -> Dict[str, Any]: @@ -842,32 +1113,87 @@ async def _install_from_marketplace_with_gate( ), } - # Resolve manifest for compatibility check + # Resolve manifest for compatibility check. Installation never proceeds + # when the decision-bearing manifest is unavailable: a compatibility + # gate that cannot run must not become an open door. try: manifest_data = client.resolve_manifest(marketplace_name) - except (OSError, ValueError, RuntimeError, AttributeError): - manifest_data = None + except (OSError, ValueError, RuntimeError, AttributeError) as exc: + logger.warning( + "Marketplace manifest resolution failed for %s: %s", + marketplace_name, + exc, + exc_info=True, + ) + return { + "ok": False, + "error": ( + f"Compatibility manifest for '{marketplace_name}' could not be resolved; " + "installation was not attempted" + ), + "failure_code": "compatibility_manifest_unavailable", + } + if not isinstance(manifest_data, dict): + return { + "ok": False, + "error": ( + f"Compatibility manifest for '{marketplace_name}' is missing or invalid; " + "installation was not attempted" + ), + "failure_code": "compatibility_manifest_unavailable", + } compatibility_notes: list[str] = [] - if manifest_data and isinstance(manifest_data, dict): - try: - from leapflow.learning.compatibility import assess_plugin + try: + from leapflow.learning.compatibility import assess_plugin - report = assess_plugin(manifest_data) - if not report.is_installable(): - return { - "ok": False, - "error": ( - f"Compatibility gate: plugin '{marketplace_name}' is INCOMPATIBLE " - f"with LeapFlow. Reason: {report.rejection_reason}" - ), - "verdict": report.final_verdict.value, - "rejection_reason": report.rejection_reason, - } - if report.adaptation_notes: - compatibility_notes = list(report.adaptation_notes) - except (ImportError, AttributeError, TypeError, ValueError): - pass # Degrade gracefully — proceed without gate + report = assess_plugin(manifest_data) + if report.final_verdict.value == "incompatible": + return { + "ok": False, + "error": ( + f"Compatibility gate: plugin '{marketplace_name}' is not installable " + f"by the Python marketplace path. Reason: {report.rejection_reason}" + ), + "verdict": report.final_verdict.value, + "rejection_reason": report.rejection_reason, + } + if report.manifest.source_language.lower() in {"javascript", "typescript"}: + return { + "ok": False, + "error": ( + "DSH marketplace bundles are not supported in P0; install a local " + "pre-built source bundle with plugin_install(source_path=...)" + ), + "failure_code": "dsh_marketplace_unsupported", + } + if not report.is_installable(): + return { + "ok": False, + "error": ( + f"Compatibility gate: plugin '{marketplace_name}' is not installable " + f"by the Python marketplace path. Reason: {report.rejection_reason or 'runtime compatibility not proven'}" + ), + "verdict": report.final_verdict.value, + "rejection_reason": report.rejection_reason, + } + if report.adaptation_notes: + compatibility_notes = list(report.adaptation_notes) + except (ImportError, AttributeError, TypeError, ValueError) as exc: + logger.warning( + "Compatibility gate failed closed for %s: %s", + marketplace_name, + exc, + exc_info=True, + ) + return { + "ok": False, + "error": ( + f"Compatibility gate failed for '{marketplace_name}'; " + "installation was not attempted" + ), + "failure_code": "compatibility_gate_failed", + } result = await self._install_from_marketplace(plugin_id, marketplace_name) if compatibility_notes and result.get("ok"): @@ -1125,6 +1451,7 @@ def _load_from_path(self, module_name: str, path: "Path") -> "tuple[Any, str]": return None, "Installed module has no 'plugin' attribute" try: setattr(plugin_obj, "__leapflow_plugin_path__", str(path)) + setattr(plugin_obj, "__leapflow_plugin_module__", module_name) except Exception: logger.debug( "Cannot attach plugin source path metadata for %s", module_name, exc_info=True @@ -1260,6 +1587,24 @@ async def _plugin_rollback_handler( self, plugin_id: str, version: str, **kwargs: Any ) -> Dict[str, Any]: """Rollback a profile plugin to a recorded source snapshot and reload it.""" + try: + from leapflow.plugins.dsh import normalize_plugin_id + + is_dsh = ( + normalize_plugin_id(plugin_id) == plugin_id + and (self._resolve_dsh_install_dir() / plugin_id).is_dir() + ) + except (ImportError, ValueError): + is_dsh = False + if is_dsh: + return { + "ok": False, + "error": ( + "DSH bundle rollback is not supported in P0; reinstall the desired " + "source bundle after removing the current plugin" + ), + "failure_code": "dsh_rollback_unsupported", + } approved, denial = await self._check_approval("rollback", plugin_id) if not approved: return {"ok": False, "error": denial, "requires_approval": True} @@ -1313,7 +1658,12 @@ async def _plugin_enable_handler(self, plugin_id: str, **kwargs: Any) -> Dict[st return {"ok": False, "error": f"Enable failed: {exc}"} async def _check_approval( - self, action: str, plugin_id: str, *, proposal_id: str = "" + self, + action: str, + plugin_id: str, + *, + proposal_id: str = "", + metadata: dict[str, Any] | None = None, ) -> tuple[bool, str]: """Consult the plugin approval gate. Returns (approved, denial_message). @@ -1359,6 +1709,7 @@ async def _check_approval( "risk_level": "high", "category": "self_modification", "proposal_id": proposal_id, + **(metadata or {}), }, ) result = await self._plugin_approval_gate.evaluate(descriptor) @@ -1493,20 +1844,46 @@ async def _plugin_remove_handler( try: import sys - from leapflow.plugins import get_scoped_registry + from leapflow.plugins import get_registry, get_scoped_registry scoped = get_scoped_registry() source_path = scoped.get_plugin_file(plugin_id) module_path = scoped.get_plugin_module(plugin_id) + plugin = get_registry().get_plugin(plugin_id) + dsh_bundle = None + try: + from leapflow.plugins.dsh import normalize_plugin_id + + if normalize_plugin_id(plugin_id) == plugin_id: + dsh_bundle = self._resolve_dsh_install_dir().resolve() / plugin_id + except (ImportError, ValueError): + pass + descriptor = getattr(plugin, "descriptor", None) if plugin is not None else None + if descriptor is not None: + raw_root = str(getattr(descriptor, "bundle_root", "") or "") + if raw_root: + candidate = Path(raw_root).expanduser().resolve() + managed_root = self._resolve_dsh_install_dir().resolve() + try: + candidate.relative_to(managed_root) + except ValueError: + candidate = None + if candidate is not None: + dsh_bundle = candidate fiber = scoped.dispose_plugin(plugin_id, prune_metadata=True) if module_path: sys.modules.pop(module_path, None) source_deleted = False if delete_source: + import shutil + target = source_path or (self._resolve_install_dir() / f"{plugin_id}.py") if target.exists(): target.unlink() source_deleted = True + if dsh_bundle is not None and dsh_bundle.is_dir(): + shutil.rmtree(dsh_bundle) + source_deleted = True return { "ok": True, "action": "remove", @@ -1652,20 +2029,24 @@ def tools(self) -> list[ToolMetadata]: ToolMetadata( name="assess_compatibility", description=( - "Assess whether a foreign plugin manifest is compatible with " - "LeapFlow's plugin architecture. Returns a structured compatibility " - "report with verdict (COMPATIBLE/ADAPTABLE/PARTIAL/INCOMPATIBLE), " - "target protocol mapping, and adaptation notes." + "Assess whether a foreign plugin manifest or real DSH source bundle " + "is compatible with LeapFlow. A source bundle assessment is static: " + "runtime_ready stays false until restricted Node discovery during " + "plugin_install. Returns component-level verdicts and limitations." ), parameters_schema={ "type": "object", "properties": { "manifest": { "type": "object", - "description": "The plugin manifest to assess (LeapFlow or DSH format).", + "description": "Plugin manifest to assess (LeapFlow or package.json-like DSH format). Mutually exclusive with source_path.", + }, + "source_path": { + "type": "string", + "description": "Path to a DSH package directory or dynamic Cordis export (meta.json + host.js). Mutually exclusive with manifest.", }, }, - "required": ["manifest"], + "required": [], }, handler=self._assess_compatibility_handler, x_leapflow={ @@ -1723,13 +2104,12 @@ def tools(self) -> list[ToolMetadata]: ToolMetadata( name="plugin_install", description=( - "Install a plugin either from validated code (produced by " - "plugin_generate) or from the configured marketplace, then " - "load it into the live registry. Writes to the profile-scoped " - "plugins directory (never the read-only package dir), " - "re-validates code, and runs an isolated sandbox smoke test " - "before the plugin is made live. REQUIRES APPROVAL — this " - "mutates the filesystem and the process-global plugin registry." + "Install a plugin from exactly one source: validated Python code, " + "the configured Python marketplace, or a real DSH source bundle. " + "DSH bundles run restricted Node discovery before registration; " + "only public host tools are installed and client UI limitations are " + "reported. Writes profile-scoped state and mutates the process-global " + "registry. REQUIRES APPROVAL." ), parameters_schema={ "type": "object", @@ -1744,7 +2124,11 @@ def tools(self) -> list[ToolMetadata]: }, "marketplace_name": { "type": "string", - "description": "Marketplace entry name to install from. Mutually exclusive with code.", + "description": "Python marketplace entry name. Mutually exclusive with code and source_path.", + }, + "source_path": { + "type": "string", + "description": "Local DSH package or dynamic Cordis export directory. Mutually exclusive with code and marketplace_name.", }, "proposal_id": { "type": "string", @@ -1765,7 +2149,7 @@ def tools(self) -> list[ToolMetadata]: "requires_approval": True, "effect_scope": "persistent", "idempotency_scope": "session", - "summary": "install a plugin from validated code or marketplace (approval required)", + "summary": "install a Python or restricted DSH plugin (approval required)", }, mutates_state=True, provides_capabilities=("plugin.install",), diff --git a/src/leapflow/tools/web_fetch.py b/src/leapflow/tools/web_fetch.py index 6a9d902..b679ba9 100644 --- a/src/leapflow/tools/web_fetch.py +++ b/src/leapflow/tools/web_fetch.py @@ -377,10 +377,14 @@ async def _approve_fetch(target: NetworkTarget) -> str: ) -def _decode_text(body: bytes, content_type: str) -> str: - """Decode a body to text using the declared charset when present.""" +def _decode_text( + body: bytes, content_type: str, *, preferred_encoding: str = "" +) -> str: + """Decode a body using an approved override, then the declared charset.""" + encodings = [preferred_encoding] if preferred_encoding else [] match = re.search(r"charset=([\w\-]+)", content_type or "", re.IGNORECASE) - encodings = [match.group(1)] if match else [] + if match and match.group(1).lower() not in {item.lower() for item in encodings}: + encodings.append(match.group(1)) encodings += ["utf-8", "latin-1"] for encoding in encodings: try: @@ -537,6 +541,14 @@ async def web_fetch(params: Dict[str, Any]) -> Dict[str, Any]: max_bytes = min(int(params.get("max_bytes") or settings.web_max_bytes), _MAX_BYTES_CEILING) except (TypeError, ValueError): max_bytes = int(settings.web_max_bytes) + encoding = str(params.get("encoding") or "").strip().lower() + if encoding not in {"", "gb18030"}: + return { + "ok": False, + "error": "web_fetch encoding override must be 'gb18030'", + "error_type": "invalid_encoding", + "retryable": False, + } max_redirects = max(0, int(getattr(settings, "web_max_redirects", 5))) # Redirects are followed here rather than inside a transport so that every hop @@ -670,12 +682,19 @@ def _build_result( kind = kind_for_content_type(outcome.content_type) result["kind"] = kind + preferred_encoding = str(params.get("encoding") or "") if not result["ok"]: # An HTTP error is the answer, not a crash: name the status and let the # model decide, with a body excerpt because error pages explain why. excerpt = "" if kind != KIND_BINARY: - excerpt = _redact(_decode_text(outcome.body, outcome.content_type))[:600] + excerpt = _redact( + _decode_text( + outcome.body, + outcome.content_type, + preferred_encoding=preferred_encoding, + ) + )[:600] result["error"] = f"HTTP {outcome.status} from {target.origin}" result["error_type"] = "http_error" result["retryable"] = outcome.status in _RETRY_STATUSES @@ -700,6 +719,15 @@ def _build_result( settings=settings, ) + body_text = _decode_text( + outcome.body, + outcome.content_type, + preferred_encoding=preferred_encoding, + ) + if str(params.get("extract") or "").lower() == "raw_text": + result["text"] = _redact(body_text) + return result + if kind == KIND_BINARY: # Binary never enters the transcript. When it was cached, hand back the # path so file-oriented tools can take over instead of a dead end. @@ -719,8 +747,6 @@ def _build_result( ) return result - body_text = _decode_text(outcome.body, outcome.content_type) - if kind == KIND_JSON: data, error = decode_json(body_text) if error: diff --git a/temp/plugin_exp/README.md b/temp/plugin_exp/README.md index ff761c4..84908d8 100644 --- a/temp/plugin_exp/README.md +++ b/temp/plugin_exp/README.md @@ -24,6 +24,56 @@ The script does not call an LLM, network, daemon process, approval modal, or rea plugin installation path. It uses synthetic candidates to stress the resolver and plan logic without coupling this experiment to framework runtime side effects. +## Native DeepSeek Harness Compatibility Experiment + +`temp/plugin_exp/scripts/native_dsh_plugin_exp.py` complements the synthetic +matrix with real artifacts from a local DeepSeek Harness checkout. It extracts the +canonical dynamic `REVERSE_TOOL_CODE` and composition fixtures, copies selected +published package manifests/build outputs, and runs LeapFlow's production path: + +```text +source inspection → compatibility verdict → isolated profile install +→ restricted Node discovery → tool invocation → wrapper rediscovery/reload → remove +``` + +The default matrix covers: + +| Class | Real source | Expected result | +|---|---|---| +| Directly adaptable | `cordis-host-runner` `REVERSE_TOOL_CODE` | Installs, invokes, reloads, and removes. | +| Partial | The same Host tool plus the runner's real Client fixture | Host tool runs; Client half is explicitly skipped. | +| Adapted package | Real dynamic fixture wrapped as a self-contained pre-built package | Standard DSH package path executes end to end. | +| Architecturally unsuitable | `packages/core/agent-loop` | Rejected before approval by the pluggability taxonomy. | +| Relevant but not self-contained | `packages/fs/tool-fs` | Rejected for npm/peer dependencies and unsupported services. | +| Unsupported composition | Real `CONSUMER_CODE` requiring `greeter` | Declared service dependency is rejected statically. | +| UI-only | Real Client fixture with no public Host tool | Rejected as non-executable in P0. | +| Unbuilt or incomplete | Real TypeScript source / package missing its compiled entry | Source inspection rejects the artifact. | +| Capability attack | Dynamic tool requesting `curl ...; id` | Installs, but invocation is denied before `web_fetch`. | + +Run it from the LeapFlow repository root, using the project experiment environment: + +```bash +conda run -n leap python temp/plugin_exp/scripts/native_dsh_plugin_exp.py +``` + +Options: + +```bash +python temp/plugin_exp/scripts/native_dsh_plugin_exp.py \ + --harness-root /path/to/deepseek-harness \ + --user-data-root ~/.leapflow \ + --keep-work +``` + +Each run writes JSON and Markdown evidence to +`temp/plugin_exp/reports/-native-dsh-plugin-exp.*`. Runtime files live +under `temp/plugin_exp/work/native-dsh//` and are deleted unless +`--keep-work` is set. The registry and profile are isolated from the active user +profile. The default profile's cache and LLM configuration are probed with direct +read-only YAML/existence checks; secret references are not resolved, secret values +are never emitted or copied, and this deterministic experiment sends zero LLM or +network requests. + ## P0 Scenario Matrix | Scenario | Purpose | diff --git a/temp/plugin_exp/reports/20260827-173327-native-dsh-plugin-exp.json b/temp/plugin_exp/reports/20260827-173327-native-dsh-plugin-exp.json new file mode 100644 index 0000000..c9e4e64 --- /dev/null +++ b/temp/plugin_exp/reports/20260827-173327-native-dsh-plugin-exp.json @@ -0,0 +1,830 @@ +{ + "experiment": "native_dsh_plugin_compatibility", + "generated_at": "20260827-173327", + "ok": true, + "passed": 10, + "total": 10, + "isolated_profile_root": "/Users/jason/work/github/leapflow/temp/plugin_exp/work/native-dsh/20260827-173327/profile", + "environment": { + "node_path": "/opt/homebrew/bin/node", + "node_version": "v25.2.1", + "harness_root": "/Users/jason/work/github/deepseek-harness", + "harness_git_head": "2df279bfd21e1985ebfa85c3afd21790d918c2ab", + "user_config_probe": { + "data_root": "/Users/jason/.leapflow", + "read_only": true, + "llm_requests": 0, + "loaded": true, + "profile": "default", + "model": "qwen3.7-plus", + "base_url_configured": true, + "credential_reference_present": true, + "config_files": [ + "/Users/jason/.leapflow/config/user.yaml", + "/Users/jason/.leapflow/profiles/default/config/llm.yaml" + ], + "parse_errors": [], + "cache_root": "/Users/jason/.leapflow/profiles/default/cache", + "cache_available": true, + "cache_top_level_entries": 4, + "reuse_policy": "read-only YAML/existence probe; secret references are not resolved or copied" + } + }, + "cases": [ + { + "case_id": "native_dynamic_reverse", + "title": "Real dynamic host tool copied from the DSH runner conformance suite.", + "source": "/Users/jason/work/github/leapflow/temp/plugin_exp/work/native-dsh/20260827-173327/sources/native_dynamic_reverse", + "source_reference": "packages/extensions/cordis-host-runner/tests/helpers.ts#REVERSE_TOOL_CODE", + "expected_verdict": "adaptable", + "expected_error": "", + "lifecycle": "install", + "expected_tool": "reverse_text", + "expected_value": "wolfpael", + "source_sha256": "39ea7a2973a4c2d2105db2deb2dd430fcb3d7c6fbef1db797a0b8e5ef0532789", + "static": { + "source_kind": "cordis_dynamic_export", + "verdict": "adaptable", + "installable": false, + "installable_candidate": true, + "category": "tools", + "dependencies": [], + "permissions": [], + "blockers": [], + "limitations": [], + "components": [ + { + "kind": "host", + "status": "candidate", + "reason": "Dynamic Cordis host requires restricted runtime discovery" + } + ], + "rejection_reason": "" + }, + "lifecycle_evidence": { + "install": { + "ok": true, + "action": "install", + "plugin_id": "native_dynamic_reverse", + "installed_tools": [ + "reverse_text" + ], + "state": "active", + "version": "native-exp-v1", + "source_kind": "cordis_dynamic_export", + "bundle_sha256": "39ea7a2973a4c2d2105db2deb2dd430fcb3d7c6fbef1db797a0b8e5ef0532789", + "descriptor_path": "/Users/jason/work/github/leapflow/temp/plugin_exp/work/native-dsh/20260827-173327/profile/plugins/dsh/native_dynamic_reverse/descriptor.json", + "verdict": "adaptable", + "limitations": [], + "client_components": [] + }, + "invoke": { + "ok": true, + "result": "wolfpael" + }, + "status": { + "ok": true, + "plugin_id": "native_dynamic_reverse", + "category": "bridge", + "dependencies": [], + "tools": [ + { + "name": "reverse_text", + "description": "Reverse a string." + } + ], + "fiber": { + "state": "active", + "generation": 1 + }, + "dsh": { + "source_kind": "cordis_dynamic_export", + "bundle_sha256": "39ea7a2973a4c2d2105db2deb2dd430fcb3d7c6fbef1db797a0b8e5ef0532789", + "entry_point": "host.runtime.cjs", + "verdict": "adaptable", + "limitations": [], + "client_components": [], + "runtime": "node" + } + }, + "restart_invoke": { + "ok": true, + "result": "wolfpael" + }, + "reload": { + "ok": true, + "action": "reload", + "plugin_id": "native_dynamic_reverse", + "new_generation": 2, + "state": "active", + "version": "" + }, + "remove": { + "ok": true, + "action": "remove", + "plugin_id": "native_dynamic_reverse", + "state": "disposed", + "source_path": "/Users/jason/work/github/leapflow/temp/plugin_exp/work/native-dsh/20260827-173327/profile/plugins/native_dynamic_reverse.py", + "source_deleted": true + }, + "cleanup": { + "wrapper_absent": true, + "bundle_absent": true, + "registry_absent": true + } + }, + "approval_requests": 3, + "approval_evidence": [ + { + "kind": "platform.action", + "effect": "write", + "resource": "plugin_management:install", + "bundle_sha256": "39ea7a2973a4c2d2105db2deb2dd430fcb3d7c6fbef1db797a0b8e5ef0532789", + "verdict": "adaptable" + }, + { + "kind": "platform.action", + "effect": "write", + "resource": "plugin_management:reload", + "bundle_sha256": "", + "verdict": "" + }, + { + "kind": "platform.action", + "effect": "write", + "resource": "plugin_management:remove", + "bundle_sha256": "", + "verdict": "" + } + ], + "elapsed_ms": 311.805, + "ok": true, + "failures": [] + }, + { + "case_id": "native_dynamic_reverse_partial", + "title": "Real dynamic host tool with a skipped browser half.", + "source": "/Users/jason/work/github/leapflow/temp/plugin_exp/work/native-dsh/20260827-173327/sources/native_dynamic_reverse_partial", + "source_reference": "packages/extensions/cordis-host-runner/tests/helpers.ts#REVERSE_TOOL_CODE", + "expected_verdict": "partial", + "expected_error": "", + "lifecycle": "install", + "expected_tool": "reverse_text", + "expected_value": "wolfpael", + "source_sha256": "89db83d73e1f6afdb97a721684ec9c427fcd9a04c0ee5f6919af527fa2cd906a", + "static": { + "source_kind": "cordis_dynamic_export", + "verdict": "partial", + "installable": false, + "installable_candidate": true, + "category": "tools", + "dependencies": [], + "permissions": [], + "blockers": [], + "limitations": [ + "client.js UI was detected and will be skipped; only safe host tools can be installed" + ], + "components": [ + { + "kind": "host", + "status": "candidate", + "reason": "Dynamic Cordis host requires restricted runtime discovery" + }, + { + "kind": "client", + "status": "unsupported", + "reason": "Cordis React/slots client UI is not executable in LeapFlow P0" + } + ], + "rejection_reason": "" + }, + "lifecycle_evidence": { + "install": { + "ok": true, + "action": "install", + "plugin_id": "native_dynamic_reverse_partial", + "installed_tools": [ + "reverse_text" + ], + "state": "active", + "version": "native-exp-v1", + "source_kind": "cordis_dynamic_export", + "bundle_sha256": "89db83d73e1f6afdb97a721684ec9c427fcd9a04c0ee5f6919af527fa2cd906a", + "descriptor_path": "/Users/jason/work/github/leapflow/temp/plugin_exp/work/native-dsh/20260827-173327/profile/plugins/dsh/native_dynamic_reverse_partial/descriptor.json", + "verdict": "partial", + "limitations": [ + "client.js UI was detected and will be skipped; only safe host tools can be installed" + ], + "client_components": [ + { + "name": "client", + "status": "unsupported", + "reason": "Cordis React/slots client UI is not executable in LeapFlow P0", + "slots": [] + } + ] + }, + "invoke": { + "ok": true, + "result": "wolfpael" + }, + "status": { + "ok": true, + "plugin_id": "native_dynamic_reverse_partial", + "category": "bridge", + "dependencies": [], + "tools": [ + { + "name": "reverse_text", + "description": "Reverse a string." + } + ], + "fiber": { + "state": "active", + "generation": 3 + }, + "dsh": { + "source_kind": "cordis_dynamic_export", + "bundle_sha256": "89db83d73e1f6afdb97a721684ec9c427fcd9a04c0ee5f6919af527fa2cd906a", + "entry_point": "host.runtime.cjs", + "verdict": "partial", + "limitations": [ + "client.js UI was detected and will be skipped; only safe host tools can be installed" + ], + "client_components": [ + { + "name": "client", + "status": "unsupported", + "reason": "Cordis React/slots client UI is not executable in LeapFlow P0", + "slots": [] + } + ], + "runtime": "node" + } + }, + "restart_invoke": { + "ok": true, + "result": "wolfpael" + }, + "reload": { + "ok": true, + "action": "reload", + "plugin_id": "native_dynamic_reverse_partial", + "new_generation": 4, + "state": "active", + "version": "" + }, + "remove": { + "ok": true, + "action": "remove", + "plugin_id": "native_dynamic_reverse_partial", + "state": "disposed", + "source_path": "/Users/jason/work/github/leapflow/temp/plugin_exp/work/native-dsh/20260827-173327/profile/plugins/native_dynamic_reverse_partial.py", + "source_deleted": true + }, + "cleanup": { + "wrapper_absent": true, + "bundle_absent": true, + "registry_absent": true + } + }, + "approval_requests": 3, + "approval_evidence": [ + { + "kind": "platform.action", + "effect": "write", + "resource": "plugin_management:install", + "bundle_sha256": "89db83d73e1f6afdb97a721684ec9c427fcd9a04c0ee5f6919af527fa2cd906a", + "verdict": "partial" + }, + { + "kind": "platform.action", + "effect": "write", + "resource": "plugin_management:reload", + "bundle_sha256": "", + "verdict": "" + }, + { + "kind": "platform.action", + "effect": "write", + "resource": "plugin_management:remove", + "bundle_sha256": "", + "verdict": "" + } + ], + "elapsed_ms": 299.581, + "ok": true, + "failures": [] + }, + { + "case_id": "adapted_prebuilt_reverse", + "title": "Real dynamic fixture adapted into a self-contained pre-built DSH package.", + "source": "/Users/jason/work/github/leapflow/temp/plugin_exp/work/native-dsh/20260827-173327/sources/adapted_prebuilt_reverse", + "source_reference": "packages/extensions/cordis-host-runner/tests/helpers.ts#REVERSE_TOOL_CODE", + "expected_verdict": "adaptable", + "expected_error": "", + "lifecycle": "install", + "expected_tool": "reverse_text", + "expected_value": "wolfpael", + "source_sha256": "3fa4be60a2bf1393f2fe365975f2bd8029c21e025fa5fbcb7bc701682520fe32", + "static": { + "source_kind": "dsh_package", + "verdict": "adaptable", + "installable": false, + "installable_candidate": true, + "category": "tools", + "dependencies": [], + "permissions": [], + "blockers": [], + "limitations": [], + "components": [ + { + "kind": "host", + "status": "candidate", + "reason": "Pre-built JavaScript entry requires restricted Node runtime discovery" + } + ], + "rejection_reason": "" + }, + "lifecycle_evidence": { + "install": { + "ok": true, + "action": "install", + "plugin_id": "adapted_prebuilt_reverse", + "installed_tools": [ + "reverse_text" + ], + "state": "active", + "version": "native-exp-v1", + "source_kind": "dsh_package", + "bundle_sha256": "3fa4be60a2bf1393f2fe365975f2bd8029c21e025fa5fbcb7bc701682520fe32", + "descriptor_path": "/Users/jason/work/github/leapflow/temp/plugin_exp/work/native-dsh/20260827-173327/profile/plugins/dsh/adapted_prebuilt_reverse/descriptor.json", + "verdict": "adaptable", + "limitations": [], + "client_components": [] + }, + "invoke": { + "ok": true, + "result": "wolfpael" + }, + "status": { + "ok": true, + "plugin_id": "adapted_prebuilt_reverse", + "category": "bridge", + "dependencies": [], + "tools": [ + { + "name": "reverse_text", + "description": "Reverse a string." + } + ], + "fiber": { + "state": "active", + "generation": 5 + }, + "dsh": { + "source_kind": "dsh_package", + "bundle_sha256": "3fa4be60a2bf1393f2fe365975f2bd8029c21e025fa5fbcb7bc701682520fe32", + "entry_point": "index.cjs", + "verdict": "adaptable", + "limitations": [], + "client_components": [], + "runtime": "node" + } + }, + "restart_invoke": { + "ok": true, + "result": "wolfpael" + }, + "reload": { + "ok": true, + "action": "reload", + "plugin_id": "adapted_prebuilt_reverse", + "new_generation": 6, + "state": "active", + "version": "" + }, + "remove": { + "ok": true, + "action": "remove", + "plugin_id": "adapted_prebuilt_reverse", + "state": "disposed", + "source_path": "/Users/jason/work/github/leapflow/temp/plugin_exp/work/native-dsh/20260827-173327/profile/plugins/adapted_prebuilt_reverse.py", + "source_deleted": true + }, + "cleanup": { + "wrapper_absent": true, + "bundle_absent": true, + "registry_absent": true + } + }, + "approval_requests": 3, + "approval_evidence": [ + { + "kind": "platform.action", + "effect": "write", + "resource": "plugin_management:install", + "bundle_sha256": "3fa4be60a2bf1393f2fe365975f2bd8029c21e025fa5fbcb7bc701682520fe32", + "verdict": "adaptable" + }, + { + "kind": "platform.action", + "effect": "write", + "resource": "plugin_management:reload", + "bundle_sha256": "", + "verdict": "" + }, + { + "kind": "platform.action", + "effect": "write", + "resource": "plugin_management:remove", + "bundle_sha256": "", + "verdict": "" + } + ], + "elapsed_ms": 291.955, + "ok": true, + "failures": [] + }, + { + "case_id": "native-agent-loop", + "title": "Native agent-loop package must not replace LeapFlow's OODA loop.", + "source": "/Users/jason/work/github/leapflow/temp/plugin_exp/work/native-dsh/20260827-173327/sources/native-agent-loop", + "source_reference": "packages/core/agent-loop", + "expected_verdict": "incompatible", + "expected_error": "single hardened OODA execution loop", + "lifecycle": "reject", + "expected_tool": "", + "expected_value": null, + "source_sha256": "5ab52a2087cea5bd11747206e5c401a0138c14b92f2d45a9326a12e6ac2e5550", + "static": { + "source_kind": "dsh_package", + "verdict": "incompatible", + "installable": false, + "installable_candidate": false, + "category": "agent-loop", + "dependencies": [ + "@deepseek-ai/cordis", + "@deepseek-ai/dsh-agent", + "@deepseek-ai/dsh-invariants", + "@deepseek-ai/dsh-llm", + "@deepseek-ai/dsh-scope", + "@deepseek-ai/dsh-session", + "@deepseek-ai/dsh-session-persistence", + "@deepseek-ai/dsh-settings", + "@deepseek-ai/dsh-system-prompt", + "@deepseek-ai/dsh-tools", + "@deepseek-ai/schemastery" + ], + "permissions": [ + "unknown.service.agents", + "unknown.service.llm", + "unknown.service.sessionPersistence", + "unknown.service.sessions", + "unknown.service.systemPrompt" + ], + "blockers": [ + "P0 does not install npm dependencies; provide a self-contained pre-built bundle", + "P0 does not expose required DSH host service: agents", + "P0 does not expose required DSH host service: llm", + "P0 does not expose required DSH host service: sessionPersistence", + "P0 does not expose required DSH host service: sessions", + "P0 does not expose required DSH host service: systemPrompt" + ], + "limitations": [], + "components": [ + { + "kind": "host", + "status": "candidate", + "reason": "Pre-built JavaScript entry requires restricted Node runtime discovery" + } + ], + "rejection_reason": "LeapFlow engine is a single hardened OODA execution loop with PCD; replacing it breaks session safety, recovery, and context invariants" + }, + "lifecycle_evidence": { + "install": { + "ok": false, + "error": "LeapFlow engine is a single hardened OODA execution loop with PCD; replacing it breaks session safety, recovery, and context invariants", + "verdict": "incompatible" + } + }, + "approval_requests": 0, + "approval_evidence": [], + "elapsed_ms": 4.953, + "ok": true, + "failures": [] + }, + { + "case_id": "native-tool-fs", + "title": "Tool domain is relevant, but the real package needs npm and Cordis services.", + "source": "/Users/jason/work/github/leapflow/temp/plugin_exp/work/native-dsh/20260827-173327/sources/native-tool-fs", + "source_reference": "packages/fs/tool-fs", + "expected_verdict": "incompatible", + "expected_error": "npm install/build", + "lifecycle": "reject", + "expected_tool": "", + "expected_value": null, + "source_sha256": "a29a724f630f50d218fc216e12e3e43ed1bc17866feb33ac83f23e2976a2f2da", + "static": { + "source_kind": "dsh_package", + "verdict": "incompatible", + "installable": false, + "installable_candidate": false, + "category": "fs", + "dependencies": [ + "@deepseek-ai/cordis", + "@deepseek-ai/dsh-attachment", + "@deepseek-ai/dsh-fs", + "@deepseek-ai/dsh-invariants", + "@deepseek-ai/dsh-llm", + "@deepseek-ai/dsh-sandbox", + "@deepseek-ai/dsh-sandbox-policy", + "@deepseek-ai/dsh-session", + "@deepseek-ai/dsh-system-prompt", + "@deepseek-ai/dsh-tools", + "@deepseek-ai/dsh-user-approval", + "@deepseek-ai/schemastery", + "diff" + ], + "permissions": [ + "unknown.service.approval", + "unknown.service.attachments", + "unknown.service.fs", + "unknown.service.llm", + "unknown.service.sandboxPolicy", + "unknown.service.systemPrompt" + ], + "blockers": [ + "P0 does not install npm dependencies; provide a self-contained pre-built bundle", + "P0 does not expose required DSH host service: approval", + "P0 does not expose required DSH host service: attachments", + "P0 does not expose required DSH host service: fs", + "P0 does not expose required DSH host service: llm", + "P0 does not expose required DSH host service: sandboxPolicy", + "P0 does not expose required DSH host service: systemPrompt" + ], + "limitations": [], + "components": [ + { + "kind": "host", + "status": "candidate", + "reason": "Pre-built JavaScript entry requires restricted Node runtime discovery" + } + ], + "rejection_reason": "Blocking or unavailable dependencies cannot be satisfied in P0: ['@deepseek-ai/cordis', '@deepseek-ai/dsh-attachment', '@deepseek-ai/dsh-fs', '@deepseek-ai/dsh-invariants', '@deepseek-ai/dsh-llm', '@deepseek-ai/dsh-sandbox', '@deepseek-ai/dsh-sandbox-policy', '@deepseek-ai/dsh-session', '@deepseek-ai/dsh-system-prompt', '@deepseek-ai/dsh-tools', '@deepseek-ai/dsh-user-approval', '@deepseek-ai/schemastery', 'diff']. DSH packages must be self-contained pre-built bundles; npm install/build and architecture-bound DSH services are not available." + }, + "lifecycle_evidence": { + "install": { + "ok": false, + "error": "Blocking or unavailable dependencies cannot be satisfied in P0: ['@deepseek-ai/cordis', '@deepseek-ai/dsh-attachment', '@deepseek-ai/dsh-fs', '@deepseek-ai/dsh-invariants', '@deepseek-ai/dsh-llm', '@deepseek-ai/dsh-sandbox', '@deepseek-ai/dsh-sandbox-policy', '@deepseek-ai/dsh-session', '@deepseek-ai/dsh-system-prompt', '@deepseek-ai/dsh-tools', '@deepseek-ai/dsh-user-approval', '@deepseek-ai/schemastery', 'diff']. DSH packages must be self-contained pre-built bundles; npm install/build and architecture-bound DSH services are not available.", + "verdict": "incompatible" + } + }, + "approval_requests": 0, + "approval_evidence": [], + "elapsed_ms": 4.806, + "ok": true, + "failures": [] + }, + { + "case_id": "native_cross_plugin_service_consumer", + "title": "Real DSH composition fixture requiring a greeter service absent in LeapFlow P0.", + "source": "/Users/jason/work/github/leapflow/temp/plugin_exp/work/native-dsh/20260827-173327/sources/native_cross_plugin_service_consumer", + "source_reference": "packages/extensions/cordis-host-runner/tests/helpers.ts#CONSUMER_CODE", + "expected_verdict": "incompatible", + "expected_error": "required DSH host service: greeter", + "lifecycle": "reject", + "expected_tool": "", + "expected_value": null, + "source_sha256": "482b224060958a198ee8b9775c5342e24ff3c4e7a0d842393613d8ce28e98591", + "static": { + "source_kind": "cordis_dynamic_export", + "verdict": "incompatible", + "installable": false, + "installable_candidate": false, + "category": "tools", + "dependencies": [], + "permissions": [ + "unknown.service.greeter" + ], + "blockers": [ + "P0 does not expose required DSH host service: greeter" + ], + "limitations": [], + "components": [ + { + "kind": "host", + "status": "candidate", + "reason": "Dynamic Cordis host requires restricted runtime discovery" + } + ], + "rejection_reason": "P0 does not expose required DSH host service: greeter" + }, + "lifecycle_evidence": { + "install": { + "ok": false, + "error": "P0 does not expose required DSH host service: greeter", + "verdict": "incompatible" + } + }, + "approval_requests": 0, + "approval_evidence": [], + "elapsed_ms": 2.702, + "ok": true, + "failures": [] + }, + { + "case_id": "native_ui_only", + "title": "Real DSH browser-half fixture with no model-visible host tool.", + "source": "/Users/jason/work/github/leapflow/temp/plugin_exp/work/native-dsh/20260827-173327/sources/native_ui_only", + "source_reference": "packages/extensions/cordis-host-runner/tests/helpers.ts#CLIENT_CODE", + "expected_verdict": "incompatible", + "expected_error": "no statically visible registerTool", + "lifecycle": "reject", + "expected_tool": "", + "expected_value": null, + "source_sha256": "45a744f8c13072060d11040eaab2b2c5d3c10e6cfd6ec322e7e17e676e4bb03a", + "static": { + "source_kind": "cordis_dynamic_export", + "verdict": "incompatible", + "installable": false, + "installable_candidate": false, + "category": "tools", + "dependencies": [], + "permissions": [], + "blockers": [ + "Dynamic export contains no statically visible registerTool call; P0 does not publish private handler channels as LeapFlow tools" + ], + "limitations": [ + "client.js UI was detected and will be skipped; only safe host tools can be installed" + ], + "components": [ + { + "kind": "host", + "status": "candidate", + "reason": "Dynamic Cordis host requires restricted runtime discovery" + }, + { + "kind": "client", + "status": "unsupported", + "reason": "Cordis React/slots client UI is not executable in LeapFlow P0" + } + ], + "rejection_reason": "Dynamic export contains no statically visible registerTool call; P0 does not publish private handler channels as LeapFlow tools" + }, + "lifecycle_evidence": { + "install": { + "ok": false, + "error": "Dynamic export contains no statically visible registerTool call; P0 does not publish private handler channels as LeapFlow tools", + "verdict": "incompatible" + } + }, + "approval_requests": 0, + "approval_evidence": [], + "elapsed_ms": 2.925, + "ok": true, + "failures": [] + }, + { + "case_id": "native_unbuilt_typescript", + "title": "Real native TypeScript source without a pre-built JavaScript entry.", + "source": "/Users/jason/work/github/leapflow/temp/plugin_exp/work/native-dsh/20260827-173327/sources/native_unbuilt_typescript", + "source_reference": "packages/context/time-context/src/index.ts", + "expected_verdict": null, + "expected_error": "pre-built JavaScript entry", + "lifecycle": "reject", + "expected_tool": "", + "expected_value": null, + "source_sha256": "f1d124352a2effc97d4ac7e0f7b5ab5dffe1c6c7c147fd632598e822cb59e9cc", + "static": { + "inspection_error": "P0 requires a pre-built JavaScript entry; TypeScript build/install is not supported", + "error_type": "SourceInspectionError" + }, + "lifecycle_evidence": { + "install": { + "ok": false, + "error": "DSH source assessment failed: P0 requires a pre-built JavaScript entry; TypeScript build/install is not supported" + } + }, + "approval_requests": 0, + "approval_evidence": [], + "elapsed_ms": 1.607, + "ok": true, + "failures": [] + }, + { + "case_id": "native_missing_entry", + "title": "Real native package manifest whose compiled entry is absent from the artifact.", + "source": "/Users/jason/work/github/leapflow/temp/plugin_exp/work/native-dsh/20260827-173327/sources/native_missing_entry", + "source_reference": "packages/bundle/base/package.json", + "expected_verdict": null, + "expected_error": "entry point does not exist", + "lifecycle": "reject", + "expected_tool": "", + "expected_value": null, + "source_sha256": "a3eba5d10e8b816570fb63b263771397d45e4c08042eb9e38adf280c15c755c6", + "static": { + "inspection_error": "DSH entry point does not exist: lib/index.js", + "error_type": "SourceInspectionError" + }, + "lifecycle_evidence": { + "install": { + "ok": false, + "error": "DSH source assessment failed: DSH entry point does not exist: lib/index.js" + } + }, + "approval_requests": 0, + "approval_evidence": [], + "elapsed_ms": 0.837, + "ok": true, + "failures": [] + }, + { + "case_id": "dynamic_shell_injection", + "title": "Dynamic host tool whose runtime command violates the strict curl grammar.", + "source": "/Users/jason/work/github/leapflow/temp/plugin_exp/work/native-dsh/20260827-173327/sources/dynamic_shell_injection", + "source_reference": "packages/extensions/cordis-host-runner/src/sandbox.ts#HOST_BUILTIN_INSPECTION", + "expected_verdict": "adaptable", + "expected_error": "", + "lifecycle": "security", + "expected_tool": "unsafe_shell_probe", + "expected_value": null, + "source_sha256": "97b3cbccc4de0ef09f754e5617dd33997679f04256d9d278b903618aabd8bef1", + "static": { + "source_kind": "cordis_dynamic_export", + "verdict": "adaptable", + "installable": false, + "installable_candidate": true, + "category": "tools", + "dependencies": [], + "permissions": [ + "compat.shell.curl_get", + "network.outbound" + ], + "blockers": [], + "limitations": [], + "components": [ + { + "kind": "host", + "status": "candidate", + "reason": "Dynamic Cordis host requires restricted runtime discovery" + } + ], + "rejection_reason": "" + }, + "lifecycle_evidence": { + "install": { + "ok": true, + "action": "install", + "plugin_id": "dynamic_shell_injection", + "installed_tools": [ + "unsafe_shell_probe" + ], + "state": "active", + "version": "native-exp-security", + "source_kind": "cordis_dynamic_export", + "bundle_sha256": "97b3cbccc4de0ef09f754e5617dd33997679f04256d9d278b903618aabd8bef1", + "descriptor_path": "/Users/jason/work/github/leapflow/temp/plugin_exp/work/native-dsh/20260827-173327/profile/plugins/dsh/dynamic_shell_injection/descriptor.json", + "verdict": "adaptable", + "limitations": [], + "client_components": [] + }, + "invoke": { + "ok": false, + "error": "DSH shell compatibility only permits: curl -sS -m <1..120> '' [| iconv -f GB18030 -t UTF-8]", + "error_type": "Error", + "retryable": false + }, + "fetch_count": 0, + "remove": { + "ok": true, + "action": "remove", + "plugin_id": "dynamic_shell_injection", + "state": "disposed", + "source_path": "/Users/jason/work/github/leapflow/temp/plugin_exp/work/native-dsh/20260827-173327/profile/plugins/dynamic_shell_injection.py", + "source_deleted": true + } + }, + "approval_requests": 2, + "approval_evidence": [ + { + "kind": "platform.action", + "effect": "write", + "resource": "plugin_management:install", + "bundle_sha256": "97b3cbccc4de0ef09f754e5617dd33997679f04256d9d278b903618aabd8bef1", + "verdict": "adaptable" + }, + { + "kind": "platform.action", + "effect": "write", + "resource": "plugin_management:remove", + "bundle_sha256": "", + "verdict": "" + } + ], + "elapsed_ms": 201.881, + "ok": true, + "failures": [] + } + ] +} \ No newline at end of file diff --git a/temp/plugin_exp/reports/20260827-173327-native-dsh-plugin-exp.md b/temp/plugin_exp/reports/20260827-173327-native-dsh-plugin-exp.md new file mode 100644 index 0000000..ca5db76 --- /dev/null +++ b/temp/plugin_exp/reports/20260827-173327-native-dsh-plugin-exp.md @@ -0,0 +1,94 @@ +# Native DSH Plugin Compatibility Experiment + +- Result: **PASS** +- Passed: 10/10 +- DeepSeek Harness: `2df279bfd21e1985ebfa85c3afd21790d918c2ab` +- Node: `v25.2.1` +- LLM requests: `0` (configuration and cache were probed read-only) + +## Strategy + +The matrix uses real source and built artifacts from the local DeepSeek Harness checkout. It separates structural rejection, architectural rejection, runtime discovery, lifecycle persistence, and capability enforcement so a static verdict cannot masquerade as execution proof. + +| Case | Expected class | Static verdict | Install | Runtime | Result | +|---|---|---|---|---|---| +| `native_dynamic_reverse` | adaptable | adaptable | ok | ok | PASS | +| `native_dynamic_reverse_partial` | partial | partial | ok | ok | PASS | +| `adapted_prebuilt_reverse` | adaptable | adaptable | ok | ok | PASS | +| `native-agent-loop` | incompatible | incompatible | rejected | n/a | PASS | +| `native-tool-fs` | incompatible | incompatible | rejected | n/a | PASS | +| `native_cross_plugin_service_consumer` | incompatible | incompatible | rejected | n/a | PASS | +| `native_ui_only` | incompatible | incompatible | rejected | n/a | PASS | +| `native_unbuilt_typescript` | inspection error | SourceInspectionError | rejected | n/a | PASS | +| `native_missing_entry` | inspection error | SourceInspectionError | rejected | n/a | PASS | +| `dynamic_shell_injection` | adaptable | adaptable | ok | denied safely | PASS | + +## Evidence + +### native_dynamic_reverse +- Source: `packages/extensions/cordis-host-runner/tests/helpers.ts#REVERSE_TOOL_CODE` +- Purpose: Real dynamic host tool copied from the DSH runner conformance suite. +- Static: `{"source_kind": "cordis_dynamic_export", "verdict": "adaptable", "installable": false, "installable_candidate": true, "category": "tools", "dependencies": [], "permissions": [], "blockers": [], "limitations": [], "components": [{"kind": "host", "status": "candidate", "reason": "Dynamic Cordis host requires restricted runtime discovery"}], "rejection_reason": ""}` +- Lifecycle: `{"install": {"ok": true, "action": "install", "plugin_id": "native_dynamic_reverse", "installed_tools": ["reverse_text"], "state": "active", "version": "native-exp-v1", "source_kind": "cordis_dynamic_export", "bundle_sha256": "39ea7a2973a4c2d2105db2deb2dd430fcb3d7c6fbef1db797a0b8e5ef0532789", "descriptor_path": "/Users/jason/work/github/leapflow/temp/plugin_exp/work/native-dsh/20260827-173327/profile/plugins/dsh/native_dynamic_reverse/descriptor.json", "verdict": "adaptable", "limitations": [], "client_components": []}, "invoke": {"ok": true, "result": "wolfpael"}, "status": {"ok": true, "plugin_id": "native_dynamic_reverse", "category": "bridge", "dependencies": [], "tools": [{"name": "reverse_text", "description": "Reverse a string."}], "fiber": {"state": "active", "generation": 1}, "dsh": {"source_kind": "cordis_dynamic_export", "bundle_sha256": "39ea7a2973a4c2d2105db2deb2dd430fcb3d7c6fbef1db797a0b8e5ef0532789", "entry_point": "host.runtime.cjs", "verdict": "adaptable", "limitations": [], "client_components": [], "runtime": "node"}}, "restart_invoke": {"ok": true, "result": "wolfpael"}, "reload": {"ok": true, "action": "reload", "plugin_id": "native_dynamic_reverse", "new_generation": 2, "state": "active", "version": ""}, "remove": {"ok": true, "action": "remove", "plugin_id": "native_dynamic_reverse", "state": "disposed", "source_path": "/Users/jason/work/github/leapflow/temp/plugin_exp/work/native-dsh/20260827-173327/profile/plugins/native_dynamic_reverse.py", "source_deleted": true}, "cleanup": {"wrapper_absent": true, "bundle_absent": true, "registry_absent": true}}` + +### native_dynamic_reverse_partial +- Source: `packages/extensions/cordis-host-runner/tests/helpers.ts#REVERSE_TOOL_CODE` +- Purpose: Real dynamic host tool with a skipped browser half. +- Static: `{"source_kind": "cordis_dynamic_export", "verdict": "partial", "installable": false, "installable_candidate": true, "category": "tools", "dependencies": [], "permissions": [], "blockers": [], "limitations": ["client.js UI was detected and will be skipped; only safe host tools can be installed"], "components": [{"kind": "host", "status": "candidate", "reason": "Dynamic Cordis host requires restricted runtime discovery"}, {"kind": "client", "status": "unsupported", "reason": "Cordis React/slots client UI is not executable in LeapFlow P0"}], "rejection_reason": ""}` +- Lifecycle: `{"install": {"ok": true, "action": "install", "plugin_id": "native_dynamic_reverse_partial", "installed_tools": ["reverse_text"], "state": "active", "version": "native-exp-v1", "source_kind": "cordis_dynamic_export", "bundle_sha256": "89db83d73e1f6afdb97a721684ec9c427fcd9a04c0ee5f6919af527fa2cd906a", "descriptor_path": "/Users/jason/work/github/leapflow/temp/plugin_exp/work/native-dsh/20260827-173327/profile/plugins/dsh/native_dynamic_reverse_partial/descriptor.json", "verdict": "partial", "limitations": ["client.js UI was detected and will be skipped; only safe host tools can be installed"], "client_components": [{"name": "client", "status": "unsupported", "reason": "Cordis React/slots client UI is not executable in LeapFlow P0", "slots": []}]}, "invoke": {"ok": true, "result": "wolfpael"}, "status": {"ok": true, "plugin_id": "native_dynamic_reverse_partial", "category": "bridge", "dependencies": [], "tools": [{"name": "reverse_text", "description": "Reverse a string."}], "fiber": {"state": "active", "generation": 3}, "dsh": {"source_kind": "cordis_dynamic_export", "bundle_sha256": "89db83d73e1f6afdb97a721684ec9c427fcd9a04c0ee5f6919af527fa2cd906a", "entry_point": "host.runtime.cjs", "verdict": "partial", "limitations": ["client.js UI was detected and will be skipped; only safe host tools can be installed"], "client_components": [{"name": "client", "status": "unsupported", "reason": "Cordis React/slots client UI is not executable in LeapFlow P0", "slots": []}], "runtime": "node"}}, "restart_invoke": {"ok": true, "result": "wolfpael"}, "reload": {"ok": true, "action": "reload", "plugin_id": "native_dynamic_reverse_partial", "new_generation": 4, "state": "active", "version": ""}, "remove": {"ok": true, "action": "remove", "plugin_id": "native_dynamic_reverse_partial", "state": "disposed", "source_path": "/Users/jason/work/github/leapflow/temp/plugin_exp/work/native-dsh/20260827-173327/profile/plugins/native_dynamic_reverse_partial.py", "source_deleted": true}, "cleanup": {"wrapper_absent": true, "bundle_absent": true, "registry_absent": true}}` + +### adapted_prebuilt_reverse +- Source: `packages/extensions/cordis-host-runner/tests/helpers.ts#REVERSE_TOOL_CODE` +- Purpose: Real dynamic fixture adapted into a self-contained pre-built DSH package. +- Static: `{"source_kind": "dsh_package", "verdict": "adaptable", "installable": false, "installable_candidate": true, "category": "tools", "dependencies": [], "permissions": [], "blockers": [], "limitations": [], "components": [{"kind": "host", "status": "candidate", "reason": "Pre-built JavaScript entry requires restricted Node runtime discovery"}], "rejection_reason": ""}` +- Lifecycle: `{"install": {"ok": true, "action": "install", "plugin_id": "adapted_prebuilt_reverse", "installed_tools": ["reverse_text"], "state": "active", "version": "native-exp-v1", "source_kind": "dsh_package", "bundle_sha256": "3fa4be60a2bf1393f2fe365975f2bd8029c21e025fa5fbcb7bc701682520fe32", "descriptor_path": "/Users/jason/work/github/leapflow/temp/plugin_exp/work/native-dsh/20260827-173327/profile/plugins/dsh/adapted_prebuilt_reverse/descriptor.json", "verdict": "adaptable", "limitations": [], "client_components": []}, "invoke": {"ok": true, "result": "wolfpael"}, "status": {"ok": true, "plugin_id": "adapted_prebuilt_reverse", "category": "bridge", "dependencies": [], "tools": [{"name": "reverse_text", "description": "Reverse a string."}], "fiber": {"state": "active", "generation": 5}, "dsh": {"source_kind": "dsh_package", "bundle_sha256": "3fa4be60a2bf1393f2fe365975f2bd8029c21e025fa5fbcb7bc701682520fe32", "entry_point": "index.cjs", "verdict": "adaptable", "limitations": [], "client_components": [], "runtime": "node"}}, "restart_invoke": {"ok": true, "result": "wolfpael"}, "reload": {"ok": true, "action": "reload", "plugin_id": "adapted_prebuilt_reverse", "new_generation": 6, "state": "active", "version": ""}, "remove": {"ok": true, "action": "remove", "plugin_id": "adapted_prebuilt_reverse", "state": "disposed", "source_path": "/Users/jason/work/github/leapflow/temp/plugin_exp/work/native-dsh/20260827-173327/profile/plugins/adapted_prebuilt_reverse.py", "source_deleted": true}, "cleanup": {"wrapper_absent": true, "bundle_absent": true, "registry_absent": true}}` + +### native-agent-loop +- Source: `packages/core/agent-loop` +- Purpose: Native agent-loop package must not replace LeapFlow's OODA loop. +- Static: `{"source_kind": "dsh_package", "verdict": "incompatible", "installable": false, "installable_candidate": false, "category": "agent-loop", "dependencies": ["@deepseek-ai/cordis", "@deepseek-ai/dsh-agent", "@deepseek-ai/dsh-invariants", "@deepseek-ai/dsh-llm", "@deepseek-ai/dsh-scope", "@deepseek-ai/dsh-session", "@deepseek-ai/dsh-session-persistence", "@deepseek-ai/dsh-settings", "@deepseek-ai/dsh-system-prompt", "@deepseek-ai/dsh-tools", "@deepseek-ai/schemastery"], "permissions": ["unknown.service.agents", "unknown.service.llm", "unknown.service.sessionPersistence", "unknown.service.sessions", "unknown.service.systemPrompt"], "blockers": ["P0 does not install npm dependencies; provide a self-contained pre-built bundle", "P0 does not expose required DSH host service: agents", "P0 does not expose required DSH host service: llm", "P0 does not expose required DSH host service: sessionPersistence", "P0 does not expose required DSH host service: sessions", "P0 does not expose required DSH host service: systemPrompt"], "limitations": [], "components": [{"kind": "host", "status": "candidate", "reason": "Pre-built JavaScript entry requires restricted Node runtime discovery"}], "rejection_reason": "LeapFlow engine is a single hardened OODA execution loop with PCD; replacing it breaks session safety, recovery, and context invariants"}` +- Lifecycle: `{"install": {"ok": false, "error": "LeapFlow engine is a single hardened OODA execution loop with PCD; replacing it breaks session safety, recovery, and context invariants", "verdict": "incompatible"}}` + +### native-tool-fs +- Source: `packages/fs/tool-fs` +- Purpose: Tool domain is relevant, but the real package needs npm and Cordis services. +- Static: `{"source_kind": "dsh_package", "verdict": "incompatible", "installable": false, "installable_candidate": false, "category": "fs", "dependencies": ["@deepseek-ai/cordis", "@deepseek-ai/dsh-attachment", "@deepseek-ai/dsh-fs", "@deepseek-ai/dsh-invariants", "@deepseek-ai/dsh-llm", "@deepseek-ai/dsh-sandbox", "@deepseek-ai/dsh-sandbox-policy", "@deepseek-ai/dsh-session", "@deepseek-ai/dsh-system-prompt", "@deepseek-ai/dsh-tools", "@deepseek-ai/dsh-user-approval", "@deepseek-ai/schemastery", "diff"], "permissions": ["unknown.service.approval", "unknown.service.attachments", "unknown.service.fs", "unknown.service.llm", "unknown.service.sandboxPolicy", "unknown.service.systemPrompt"], "blockers": ["P0 does not install npm dependencies; provide a self-contained pre-built bundle", "P0 does not expose required DSH host service: approval", "P0 does not expose required DSH host service: attachments", "P0 does not expose required DSH host service: fs", "P0 does not expose required DSH host service: llm", "P0 does not expose required DSH host service: sandboxPolicy", "P0 does not expose required DSH host service: systemPrompt"], "limitations": [], "components": [{"kind": "host", "status": "candidate", "reason": "Pre-built JavaScript entry requires restricted Node runtime discovery"}], "rejection_reason": "Blocking or unavailable dependencies cannot be satisfied in P0: ['@deepseek-ai/cordis', '@deepseek-ai/dsh-attachment', '@deepseek-ai/dsh-fs', '@deepseek-ai/dsh-invariants', '@deepseek-ai/dsh-llm', '@deepseek-ai/dsh-sandbox', '@deepseek-ai/dsh-sandbox-policy', '@deepseek-ai/dsh-session', '@deepseek-ai/dsh-system-prompt', '@deepseek-ai/dsh-tools', '@deepseek-ai/dsh-user-approval', '@deepseek-ai/schemastery', 'diff']. DSH packages must be self-contained pre-built bundles; npm install/build and architecture-bound DSH services are not available."}` +- Lifecycle: `{"install": {"ok": false, "error": "Blocking or unavailable dependencies cannot be satisfied in P0: ['@deepseek-ai/cordis', '@deepseek-ai/dsh-attachment', '@deepseek-ai/dsh-fs', '@deepseek-ai/dsh-invariants', '@deepseek-ai/dsh-llm', '@deepseek-ai/dsh-sandbox', '@deepseek-ai/dsh-sandbox-policy', '@deepseek-ai/dsh-session', '@deepseek-ai/dsh-system-prompt', '@deepseek-ai/dsh-tools', '@deepseek-ai/dsh-user-approval', '@deepseek-ai/schemastery', 'diff']. DSH packages must be self-contained pre-built bundles; npm install/build and architecture-bound DSH services are not available.", "verdict": "incompatible"}}` + +### native_cross_plugin_service_consumer +- Source: `packages/extensions/cordis-host-runner/tests/helpers.ts#CONSUMER_CODE` +- Purpose: Real DSH composition fixture requiring a greeter service absent in LeapFlow P0. +- Static: `{"source_kind": "cordis_dynamic_export", "verdict": "incompatible", "installable": false, "installable_candidate": false, "category": "tools", "dependencies": [], "permissions": ["unknown.service.greeter"], "blockers": ["P0 does not expose required DSH host service: greeter"], "limitations": [], "components": [{"kind": "host", "status": "candidate", "reason": "Dynamic Cordis host requires restricted runtime discovery"}], "rejection_reason": "P0 does not expose required DSH host service: greeter"}` +- Lifecycle: `{"install": {"ok": false, "error": "P0 does not expose required DSH host service: greeter", "verdict": "incompatible"}}` + +### native_ui_only +- Source: `packages/extensions/cordis-host-runner/tests/helpers.ts#CLIENT_CODE` +- Purpose: Real DSH browser-half fixture with no model-visible host tool. +- Static: `{"source_kind": "cordis_dynamic_export", "verdict": "incompatible", "installable": false, "installable_candidate": false, "category": "tools", "dependencies": [], "permissions": [], "blockers": ["Dynamic export contains no statically visible registerTool call; P0 does not publish private handler channels as LeapFlow tools"], "limitations": ["client.js UI was detected and will be skipped; only safe host tools can be installed"], "components": [{"kind": "host", "status": "candidate", "reason": "Dynamic Cordis host requires restricted runtime discovery"}, {"kind": "client", "status": "unsupported", "reason": "Cordis React/slots client UI is not executable in LeapFlow P0"}], "rejection_reason": "Dynamic export contains no statically visible registerTool call; P0 does not publish private handler channels as LeapFlow tools"}` +- Lifecycle: `{"install": {"ok": false, "error": "Dynamic export contains no statically visible registerTool call; P0 does not publish private handler channels as LeapFlow tools", "verdict": "incompatible"}}` + +### native_unbuilt_typescript +- Source: `packages/context/time-context/src/index.ts` +- Purpose: Real native TypeScript source without a pre-built JavaScript entry. +- Static: `{"inspection_error": "P0 requires a pre-built JavaScript entry; TypeScript build/install is not supported", "error_type": "SourceInspectionError"}` +- Lifecycle: `{"install": {"ok": false, "error": "DSH source assessment failed: P0 requires a pre-built JavaScript entry; TypeScript build/install is not supported"}}` + +### native_missing_entry +- Source: `packages/bundle/base/package.json` +- Purpose: Real native package manifest whose compiled entry is absent from the artifact. +- Static: `{"inspection_error": "DSH entry point does not exist: lib/index.js", "error_type": "SourceInspectionError"}` +- Lifecycle: `{"install": {"ok": false, "error": "DSH source assessment failed: DSH entry point does not exist: lib/index.js"}}` + +### dynamic_shell_injection +- Source: `packages/extensions/cordis-host-runner/src/sandbox.ts#HOST_BUILTIN_INSPECTION` +- Purpose: Dynamic host tool whose runtime command violates the strict curl grammar. +- Static: `{"source_kind": "cordis_dynamic_export", "verdict": "adaptable", "installable": false, "installable_candidate": true, "category": "tools", "dependencies": [], "permissions": ["compat.shell.curl_get", "network.outbound"], "blockers": [], "limitations": [], "components": [{"kind": "host", "status": "candidate", "reason": "Dynamic Cordis host requires restricted runtime discovery"}], "rejection_reason": ""}` +- Lifecycle: `{"install": {"ok": true, "action": "install", "plugin_id": "dynamic_shell_injection", "installed_tools": ["unsafe_shell_probe"], "state": "active", "version": "native-exp-security", "source_kind": "cordis_dynamic_export", "bundle_sha256": "97b3cbccc4de0ef09f754e5617dd33997679f04256d9d278b903618aabd8bef1", "descriptor_path": "/Users/jason/work/github/leapflow/temp/plugin_exp/work/native-dsh/20260827-173327/profile/plugins/dsh/dynamic_shell_injection/descriptor.json", "verdict": "adaptable", "limitations": [], "client_components": []}, "invoke": {"ok": false, "error": "DSH shell compatibility only permits: curl -sS -m <1..120> '' [| iconv -f GB18030 -t UTF-8]", "error_type": "Error", "retryable": false}, "fetch_count": 0, "remove": {"ok": true, "action": "remove", "plugin_id": "dynamic_shell_injection", "state": "disposed", "source_path": "/Users/jason/work/github/leapflow/temp/plugin_exp/work/native-dsh/20260827-173327/profile/plugins/dynamic_shell_injection.py", "source_deleted": true}}` + +## Conclusions + +- A real dynamic `reverse_text` host tool executes through LeapFlow and survives wrapper rediscovery/reload. +- A host+client package is installable only as PARTIAL; the browser half is persisted as skipped metadata. +- Agent-loop replacement, unknown injected services, npm/peer dependencies, UI-only packages, missing builds, and missing entries fail before approval. +- A syntactically valid plugin cannot turn the shell shim into raw command execution; the forbidden command is rejected before `web_fetch`. +- The experiment does not need an LLM. Existing user LLM/cache configuration is probed without resolving secrets, and no credential value is emitted. diff --git a/temp/plugin_exp/scripts/native_dsh_plugin_exp.py b/temp/plugin_exp/scripts/native_dsh_plugin_exp.py new file mode 100644 index 0000000..73ed154 --- /dev/null +++ b/temp/plugin_exp/scripts/native_dsh_plugin_exp.py @@ -0,0 +1,865 @@ +#!/usr/bin/env python3 +"""Run real DeepSeek Harness plugin artifacts through LeapFlow's DSH bridge. + +The experiment uses source material from a local deepseek-harness checkout. It +copies only the files needed for each case into an isolated workspace, then runs +the production inspection, compatibility, installation, invocation, reload, and +removal paths. No network or LLM request is required. +""" +from __future__ import annotations + +import argparse +import asyncio +import hashlib +import json +import shutil +import subprocess +import sys +import time +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Any + +EXP_ROOT = Path(__file__).resolve().parents[1] +REPO_ROOT = EXP_ROOT.parents[1] +SRC_ROOT = REPO_ROOT / "src" +if str(SRC_ROOT) not in sys.path: + sys.path.insert(0, str(SRC_ROOT)) + +from leapflow.learning.compatibility import assess_plugin, inspect_plugin_source # noqa: E402 +from leapflow.plugins.registry import ToolPluginRegistry # noqa: E402 +from leapflow.plugins.scoped_registry import ScopedToolRegistry # noqa: E402 +from leapflow.plugins.tool_plugins import _load_plugin_from_file # noqa: E402 +from leapflow.plugins.tool_plugins.self_management import SelfManagementPlugin # noqa: E402 +from leapflow.storage.plugin_version_store import PluginVersionStore # noqa: E402 +from leapflow.tools.execution_context import ( # noqa: E402 + ToolExecutionContext, + reset_tool_context, + set_tool_context, +) + +DEFAULT_HARNESS_ROOT = Path("/Users/jason/work/github/deepseek-harness") +DEFAULT_USER_DATA_ROOT = Path.home() / ".leapflow" + + +@dataclass(frozen=True) +class ArtifactSpec: + """One materialized experiment source and its expected compatibility class.""" + + case_id: str + title: str + source: Path + source_reference: str + expected_verdict: str | None = None + expected_error: str = "" + lifecycle: str = "reject" + expected_tool: str = "" + expected_value: Any = None + + +class RecordingApprovalGate: + """Approval surface for an isolated experiment registry.""" + + def __init__(self) -> None: + self.actions: list[Any] = [] + + async def evaluate(self, action: Any) -> Any: + self.actions.append(action) + return type("ApprovalResult", (), {"approved": True, "denial_message": ""})() + + +class IsolatedRuntime: + """Own an isolated profile directory and temporary global plugin registries.""" + + def __init__(self, root: Path) -> None: + import leapflow.plugins as plugin_api + + self._plugin_api = plugin_api + self._old_registry = plugin_api._registry + self._old_scoped = plugin_api._scoped_registry + self.registry = ToolPluginRegistry() + self.scoped = ScopedToolRegistry(self.registry) + plugin_api._registry = self.registry + plugin_api._scoped_registry = self.scoped + self.profile_root = root / "profile" + self.install_dir = self.profile_root / "plugins" + self.approval = RecordingApprovalGate() + self.manager = SelfManagementPlugin() + self.manager._plugin_install_dir = str(self.install_dir) + self.manager._plugin_version_store = PluginVersionStore( + self.install_dir / "versions" + ) + self.manager._plugin_approval_gate = self.approval + + def close(self) -> None: + self._plugin_api._registry = self._old_registry + self._plugin_api._scoped_registry = self._old_scoped + + +class ArtifactBuilder: + """Materialize a reproducible matrix from a DeepSeek Harness checkout.""" + + def __init__(self, harness_root: Path, output_root: Path) -> None: + self.harness_root = harness_root.resolve() + self.output_root = output_root.resolve() + self.output_root.mkdir(parents=True, exist_ok=True) + self.helpers = ( + self.harness_root + / "packages/extensions/cordis-host-runner/tests/helpers.ts" + ) + if not self.helpers.is_file(): + raise FileNotFoundError( + f"DeepSeek Harness dynamic-plugin fixture is missing: {self.helpers}" + ) + self.reverse_host = self._extract_template("REVERSE_TOOL_CODE") + self.consumer_host = self._extract_template("CONSUMER_CODE") + self.client_code = self._extract_single_quoted("CLIENT_CODE") + + def build(self) -> list[ArtifactSpec]: + """Build direct, partial, unsuitable, malformed, and security cases.""" + specs = [ + self._dynamic_reverse(with_client=False), + self._dynamic_reverse(with_client=True), + self._package_reverse(), + self._copy_native_package( + "packages/core/agent-loop", + "native-agent-loop", + "Native agent-loop package must not replace LeapFlow's OODA loop.", + expected_verdict="incompatible", + expected_error="single hardened OODA execution loop", + ), + self._copy_native_package( + "packages/fs/tool-fs", + "native-tool-fs", + "Tool domain is relevant, but the real package needs npm and Cordis services.", + expected_verdict="incompatible", + expected_error="npm install/build", + ), + self._dynamic_service_consumer(), + self._ui_only(), + self._unbuilt_typescript(), + self._missing_entry(), + self._malicious_shell(), + ] + return specs + + def _extract_template(self, constant: str) -> str: + text = self.helpers.read_text(encoding="utf-8") + marker = f"export const {constant} = `" + start = text.find(marker) + if start < 0: + raise ValueError(f"Cannot find {constant} in {self.helpers}") + start += len(marker) + end = text.find("\n`", start) + if end < 0: + raise ValueError(f"Cannot find closing template literal for {constant}") + return text[start:end].strip() + "\n" + + def _extract_single_quoted(self, constant: str) -> str: + text = self.helpers.read_text(encoding="utf-8") + marker = f"export const {constant} = '" + start = text.find(marker) + if start < 0: + raise ValueError(f"Cannot find {constant} in {self.helpers}") + start += len(marker) + end = text.find("'", start) + if end < 0: + raise ValueError(f"Cannot find closing quote for {constant}") + return text[start:end] + + def _write_dynamic( + self, + case_id: str, + host: str, + *, + title: str, + source_reference: str, + client: str = "", + expected_verdict: str, + expected_error: str = "", + lifecycle: str = "reject", + expected_tool: str = "", + expected_value: Any = None, + ) -> ArtifactSpec: + root = self.output_root / case_id + root.mkdir(parents=True, exist_ok=False) + (root / "meta.json").write_text( + json.dumps( + { + "name": case_id, + "version": "0.0.0+experiment", + "purpose": title, + "source_reference": source_reference, + }, + indent=2, + ), + encoding="utf-8", + ) + (root / "host.js").write_text(host, encoding="utf-8") + if client: + (root / "client.js").write_text(client, encoding="utf-8") + return ArtifactSpec( + case_id=case_id, + title=title, + source=root, + source_reference=source_reference, + expected_verdict=expected_verdict, + expected_error=expected_error, + lifecycle=lifecycle, + expected_tool=expected_tool, + expected_value=expected_value, + ) + + def _dynamic_reverse(self, *, with_client: bool) -> ArtifactSpec: + case_id = "native_dynamic_reverse_partial" if with_client else "native_dynamic_reverse" + return self._write_dynamic( + case_id, + self.reverse_host, + client=self.client_code if with_client else "", + title=( + "Real dynamic host tool with a skipped browser half." + if with_client + else "Real dynamic host tool copied from the DSH runner conformance suite." + ), + source_reference=( + "packages/extensions/cordis-host-runner/tests/helpers.ts#REVERSE_TOOL_CODE" + ), + expected_verdict="partial" if with_client else "adaptable", + lifecycle="install", + expected_tool="reverse_text", + expected_value="wolfpael", + ) + + def _package_reverse(self) -> ArtifactSpec: + case_id = "adapted_prebuilt_reverse" + root = self.output_root / case_id + root.mkdir(parents=True, exist_ok=False) + (root / "package.json").write_text( + json.dumps( + { + "name": "@deepseek-ai/dsh-reverse-text-experiment", + "version": "0.1.0-experiment", + "type": "commonjs", + "main": "index.cjs", + "keywords": ["tools"], + "dsh": {"category": "tools", "interfaces": ["execute"]}, + }, + indent=2, + ), + encoding="utf-8", + ) + (root / "index.cjs").write_text( + '"use strict";\nmodule.exports = (function () {\n' + + self.reverse_host + + "\n})();\n", + encoding="utf-8", + ) + return ArtifactSpec( + case_id=case_id, + title="Real dynamic fixture adapted into a self-contained pre-built DSH package.", + source=root, + source_reference=( + "packages/extensions/cordis-host-runner/tests/helpers.ts#REVERSE_TOOL_CODE" + ), + expected_verdict="adaptable", + lifecycle="install", + expected_tool="reverse_text", + expected_value="wolfpael", + ) + + def _copy_native_package( + self, + relative: str, + case_id: str, + title: str, + *, + expected_verdict: str, + expected_error: str, + ) -> ArtifactSpec: + source = self.harness_root / relative + root = self.output_root / case_id + root.mkdir(parents=True, exist_ok=False) + manifest = json.loads((source / "package.json").read_text(encoding="utf-8")) + (root / "package.json").write_text( + json.dumps(manifest, indent=2), encoding="utf-8" + ) + entry = str(manifest.get("main") or "") + if entry: + target = root / entry + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(source / entry, target) + return ArtifactSpec( + case_id=case_id, + title=title, + source=root, + source_reference=relative, + expected_verdict=expected_verdict, + expected_error=expected_error, + ) + + def _dynamic_service_consumer(self) -> ArtifactSpec: + return self._write_dynamic( + "native_cross_plugin_service_consumer", + self.consumer_host, + title="Real DSH composition fixture requiring a greeter service absent in LeapFlow P0.", + source_reference=( + "packages/extensions/cordis-host-runner/tests/helpers.ts#CONSUMER_CODE" + ), + expected_verdict="incompatible", + expected_error="required DSH host service: greeter", + ) + + def _ui_only(self) -> ArtifactSpec: + host = "return { name: 'ui-only', apply(ctx) { console.log('host has no tools') } }\n" + return self._write_dynamic( + "native_ui_only", + host, + client=self.client_code, + title="Real DSH browser-half fixture with no model-visible host tool.", + source_reference=( + "packages/extensions/cordis-host-runner/tests/helpers.ts#CLIENT_CODE" + ), + expected_verdict="incompatible", + expected_error="no statically visible registerTool", + ) + + def _unbuilt_typescript(self) -> ArtifactSpec: + source = self.harness_root / "packages/context/time-context" + root = self.output_root / "native_unbuilt_typescript" + root.mkdir(parents=True, exist_ok=False) + manifest = json.loads((source / "package.json").read_text(encoding="utf-8")) + manifest["main"] = "src/index.ts" + (root / "package.json").write_text( + json.dumps(manifest, indent=2), encoding="utf-8" + ) + (root / "src").mkdir() + shutil.copyfile(source / "src/index.ts", root / "src/index.ts") + return ArtifactSpec( + case_id="native_unbuilt_typescript", + title="Real native TypeScript source without a pre-built JavaScript entry.", + source=root, + source_reference="packages/context/time-context/src/index.ts", + expected_error="pre-built JavaScript entry", + ) + + def _missing_entry(self) -> ArtifactSpec: + source = self.harness_root / "packages/bundle/base" + root = self.output_root / "native_missing_entry" + root.mkdir(parents=True, exist_ok=False) + shutil.copyfile(source / "package.json", root / "package.json") + return ArtifactSpec( + case_id="native_missing_entry", + title="Real native package manifest whose compiled entry is absent from the artifact.", + source=root, + source_reference="packages/bundle/base/package.json", + expected_error="entry point does not exist", + ) + + def _malicious_shell(self) -> ArtifactSpec: + host = """return { + name: 'unsafe-shell-probe', + inject: ['tools'], + apply(ctx) { + harness.registerTool(ctx, harness.defineTool({ + name: 'unsafe_shell_probe', + description: 'Verify that arbitrary shell syntax cannot cross the bridge.', + parameters: {}, + async execute() { + const shell = ctx.get('shell') + return shell.run(shell.resolve({ + command: "curl -sS -m 5 'https://example.test/data'; id", + timeoutMs: 5000, + stdoutMaxBytes: 1024, + })) + }, + })) + }, +} +""" + return self._write_dynamic( + "dynamic_shell_injection", + host, + title="Dynamic host tool whose runtime command violates the strict curl grammar.", + source_reference=( + "packages/extensions/cordis-host-runner/src/sandbox.ts#HOST_BUILTIN_INSPECTION" + ), + expected_verdict="adaptable", + lifecycle="security", + expected_tool="unsafe_shell_probe", + ) + + +async def _run_experiment( + specs: list[ArtifactSpec], runtime: IsolatedRuntime +) -> list[dict[str, Any]]: + results: list[dict[str, Any]] = [] + workspace_token = set_tool_context( + ToolExecutionContext.from_strings( + workspace_root=str(specs[0].source.parent), + session_id="native-dsh-plugin-experiment", + ) + ) + try: + for spec in specs: + results.append(await _run_case(spec, runtime)) + finally: + reset_tool_context(workspace_token) + return results + + +async def _run_case(spec: ArtifactSpec, runtime: IsolatedRuntime) -> dict[str, Any]: + started = time.perf_counter() + approval_before = len(runtime.approval.actions) + row: dict[str, Any] = { + **asdict(spec), + "source": str(spec.source), + "source_sha256": _tree_hash(spec.source), + "static": {}, + "lifecycle_evidence": {}, + } + try: + inspection = inspect_plugin_source(spec.source) + report = assess_plugin(spec.source) + plan = report.execution_plan + row["static"] = { + "source_kind": inspection.execution_plan.source_kind.value, + "verdict": report.final_verdict.value, + "installable": report.is_installable(), + "installable_candidate": bool(plan and plan.installable_candidate), + "category": report.manifest.category, + "dependencies": list(plan.dependencies if plan else ()), + "permissions": list(plan.permissions if plan else ()), + "blockers": list(plan.blockers if plan else ()), + "limitations": list(plan.limitations if plan else ()), + "components": [ + { + "kind": item.kind.value, + "status": item.status.value, + "reason": item.reason, + } + for item in (plan.components if plan else ()) + ], + "rejection_reason": report.rejection_reason or "", + } + except (OSError, TypeError, ValueError) as exc: + row["static"] = { + "inspection_error": str(exc), + "error_type": type(exc).__name__, + } + + if spec.lifecycle == "install": + row["lifecycle_evidence"] = await _install_invoke_reload_remove(spec, runtime) + elif spec.lifecycle == "security": + row["lifecycle_evidence"] = await _install_security_probe(spec, runtime) + else: + row["lifecycle_evidence"] = await _attempt_rejected_install(spec, runtime) + + new_approvals = runtime.approval.actions[approval_before:] + row["approval_requests"] = len(new_approvals) + row["approval_evidence"] = [ + { + "kind": action.kind, + "effect": action.effect, + "resource": action.resource, + "bundle_sha256": str(action.metadata.get("bundle_sha256") or ""), + "verdict": str(action.metadata.get("verdict") or ""), + } + for action in new_approvals + ] + row["elapsed_ms"] = round((time.perf_counter() - started) * 1000, 3) + failures = _case_failures(spec, row) + row["ok"] = not failures + row["failures"] = failures + return row + + +async def _install_invoke_reload_remove( + spec: ArtifactSpec, runtime: IsolatedRuntime +) -> dict[str, Any]: + plugin_id = spec.case_id.replace("-", "_") + install = await runtime.manager._plugin_install_handler( + plugin_id=plugin_id, + source_path=str(spec.source), + version_label="native-exp-v1", + ) + evidence: dict[str, Any] = {"install": install} + if not install.get("ok"): + return evidence + plugin = runtime.registry.get_plugin(plugin_id) + if plugin is None: + evidence["invoke"] = {"ok": False, "error": "plugin missing after install"} + return evidence + invoke = await plugin.tools[0].handler(text="leapflow") + evidence["invoke"] = invoke + status = await runtime.manager._plugin_status_handler(plugin_id) + evidence["status"] = status + + wrapper = runtime.install_dir / f"{plugin_id}.py" + restarted = _load_plugin_from_file(wrapper) + if restarted is None: + evidence["restart_invoke"] = {"ok": False, "error": "wrapper rediscovery failed"} + else: + evidence["restart_invoke"] = await restarted.tools[0].handler(text="leapflow") + + reload_result = await runtime.manager._plugin_reload_handler(plugin_id) + evidence["reload"] = reload_result + remove = await runtime.manager._plugin_remove_handler(plugin_id) + evidence["remove"] = remove + evidence["cleanup"] = { + "wrapper_absent": not wrapper.exists(), + "bundle_absent": not (runtime.install_dir / "dsh" / plugin_id).exists(), + "registry_absent": runtime.registry.get_plugin(plugin_id) is None, + } + return evidence + + +async def _install_security_probe( + spec: ArtifactSpec, runtime: IsolatedRuntime +) -> dict[str, Any]: + plugin_id = spec.case_id + install = await runtime.manager._plugin_install_handler( + plugin_id=plugin_id, + source_path=str(spec.source), + version_label="native-exp-security", + ) + evidence: dict[str, Any] = {"install": install} + fetches: list[dict[str, Any]] = [] + + async def forbidden_fetch(params: dict[str, Any]) -> dict[str, Any]: + fetches.append(dict(params)) + return {"ok": True, "text": "unexpected"} + + plugin = runtime.registry.get_plugin(plugin_id) + if plugin is not None: + plugin.bind_runtime(web_fetch=forbidden_fetch) + evidence["invoke"] = await plugin.tools[0].handler() + evidence["fetch_count"] = len(fetches) + if plugin is not None: + evidence["remove"] = await runtime.manager._plugin_remove_handler(plugin_id) + return evidence + + +async def _attempt_rejected_install( + spec: ArtifactSpec, runtime: IsolatedRuntime +) -> dict[str, Any]: + result = await runtime.manager._plugin_install_handler( + plugin_id=spec.case_id, + source_path=str(spec.source), + ) + return {"install": result} + + +def _case_failures(spec: ArtifactSpec, row: dict[str, Any]) -> list[str]: + failures: list[str] = [] + static = row["static"] + lifecycle = row["lifecycle_evidence"] + if spec.expected_verdict is not None: + if static.get("verdict") != spec.expected_verdict: + failures.append( + f"expected verdict {spec.expected_verdict}, got {static.get('verdict')}" + ) + if spec.expected_error: + observed = " ".join( + [ + str(static.get("inspection_error") or ""), + str(static.get("rejection_reason") or ""), + str((lifecycle.get("install") or {}).get("error") or ""), + ] + ) + if spec.expected_error not in observed: + failures.append(f"expected error evidence containing {spec.expected_error!r}") + if spec.lifecycle == "reject": + if (lifecycle.get("install") or {}).get("ok") is not False: + failures.append("rejected case unexpectedly installed") + if row["approval_requests"] != 0: + failures.append("invalid case reached plugin approval before validation") + elif spec.lifecycle == "install": + install = lifecycle.get("install") or {} + for phase in ("install", "invoke", "restart_invoke", "reload", "remove"): + if (lifecycle.get(phase) or {}).get("ok") is not True: + failures.append(f"lifecycle phase {phase} failed") + if spec.expected_tool not in (install.get("installed_tools") or []): + failures.append(f"expected discovered tool {spec.expected_tool!r}") + value = (lifecycle.get("invoke") or {}).get("result") + restarted_value = (lifecycle.get("restart_invoke") or {}).get("result") + if value != spec.expected_value or restarted_value != spec.expected_value: + failures.append( + f"expected invocation value {spec.expected_value!r}, got {value!r}/{restarted_value!r}" + ) + if not all((lifecycle.get("cleanup") or {}).values()): + failures.append("installed wrapper, bundle, or registry entry survived removal") + if row["approval_requests"] < 3: + failures.append("install, reload, and remove did not all reach approval") + elif spec.lifecycle == "security": + install = lifecycle.get("install") or {} + invoke = lifecycle.get("invoke") or {} + if install.get("ok") is not True: + failures.append("security probe did not install") + if invoke.get("ok") is not False or "only permits" not in str(invoke.get("error")): + failures.append("unsafe shell command was not rejected by the typed capability") + if lifecycle.get("fetch_count") != 0: + failures.append("unsafe shell command reached web_fetch") + if (lifecycle.get("remove") or {}).get("ok") is not True: + failures.append("security probe cleanup failed") + if row["approval_requests"] < 2: + failures.append("security probe install/remove did not reach approval") + return failures + + +def _tree_hash(root: Path) -> str: + digest = hashlib.sha256() + for path in sorted(item for item in root.rglob("*") if item.is_file()): + relative = path.relative_to(root).as_posix().encode("utf-8") + data = path.read_bytes() + digest.update(len(relative).to_bytes(4, "big")) + digest.update(relative) + digest.update(len(data).to_bytes(8, "big")) + digest.update(data) + return digest.hexdigest() + + +def _environment_evidence(user_data_root: Path, harness_root: Path) -> dict[str, Any]: + node = shutil.which("node") + node_version = "" + if node: + completed = subprocess.run( + [node, "--version"], + check=False, + capture_output=True, + text=True, + timeout=5, + ) + node_version = completed.stdout.strip() + evidence: dict[str, Any] = { + "node_path": node or "", + "node_version": node_version, + "harness_root": str(harness_root), + "harness_git_head": _git_head(harness_root), + "user_config_probe": { + "data_root": str(user_data_root), + "read_only": True, + "llm_requests": 0, + }, + } + try: + import yaml + + from leapflow.layout import PathLayout + + layout = PathLayout(user_data_root) + profile = layout.profile("default") + llm: dict[str, Any] = {} + config_files: list[str] = [] + parse_errors: list[str] = [] + for config_path in (layout.user_config_path, profile.llm_config_path): + if not config_path.is_file(): + continue + config_files.append(str(config_path)) + try: + parsed = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {} + except (OSError, UnicodeDecodeError, yaml.YAMLError) as exc: + parse_errors.append(f"{config_path.name}: {type(exc).__name__}") + continue + if isinstance(parsed, dict) and isinstance(parsed.get("llm"), dict): + llm.update(parsed["llm"]) + elif isinstance(parsed, dict) and config_path == profile.llm_config_path: + llm.update(parsed) + cache_root = profile.cache.root + evidence["user_config_probe"].update( + { + "loaded": bool(config_files) and not parse_errors, + "profile": "default", + "model": str(llm.get("model") or ""), + "base_url_configured": bool(llm.get("base_url")), + "credential_reference_present": _has_credential_reference(llm), + "config_files": config_files, + "parse_errors": parse_errors, + "cache_root": str(cache_root), + "cache_available": cache_root.is_dir(), + "cache_top_level_entries": len(list(cache_root.iterdir())) + if cache_root.is_dir() + else 0, + "reuse_policy": ( + "read-only YAML/existence probe; secret references are not resolved or copied" + ), + } + ) + except (ImportError, OSError, RuntimeError, TypeError, ValueError) as exc: + evidence["user_config_probe"].update( + {"loaded": False, "error_type": type(exc).__name__, "error": str(exc)} + ) + return evidence + + +def _has_credential_reference(value: Any) -> bool: + """Detect credential configuration without resolving or returning its value.""" + if isinstance(value, dict): + for key, item in value.items(): + normalized = str(key).lower().replace("-", "_") + if normalized in {"api_key", "api_key_ref", "credential", "credential_ref"}: + if bool(item): + return True + if _has_credential_reference(item): + return True + return False + if isinstance(value, (list, tuple)): + return any(_has_credential_reference(item) for item in value) + return isinstance(value, str) and value.startswith("secret://") + + +def _git_head(root: Path) -> str: + try: + completed = subprocess.run( + ["git", "-C", str(root), "rev-parse", "HEAD"], + check=False, + capture_output=True, + text=True, + timeout=5, + ) + return completed.stdout.strip() if completed.returncode == 0 else "" + except (OSError, subprocess.SubprocessError): + return "" + + +def _render_markdown(payload: dict[str, Any]) -> str: + lines = [ + "# Native DSH Plugin Compatibility Experiment", + "", + f"- Result: **{'PASS' if payload['ok'] else 'FAIL'}**", + f"- Passed: {payload['passed']}/{payload['total']}", + f"- DeepSeek Harness: `{payload['environment']['harness_git_head'] or 'unknown'}`", + f"- Node: `{payload['environment']['node_version'] or 'unavailable'}`", + "- LLM requests: `0` (configuration and cache were probed read-only)", + "", + "## Strategy", + "", + "The matrix uses real source and built artifacts from the local DeepSeek Harness checkout. " + "It separates structural rejection, architectural rejection, runtime discovery, lifecycle " + "persistence, and capability enforcement so a static verdict cannot masquerade as execution proof.", + "", + "| Case | Expected class | Static verdict | Install | Runtime | Result |", + "|---|---|---|---|---|---|", + ] + for row in payload["cases"]: + static = row["static"] + lifecycle = row["lifecycle_evidence"] + install = lifecycle.get("install") or {} + invoke = lifecycle.get("invoke") or {} + expected = row.get("expected_verdict") or "inspection error" + install_label = "ok" if install.get("ok") is True else "rejected" + runtime_label = ( + "denied safely" + if row["lifecycle"] == "security" and invoke.get("ok") is False + else "ok" + if invoke.get("ok") is True + else "n/a" + ) + lines.append( + f"| `{row['case_id']}` | {expected} | " + f"{static.get('verdict') or static.get('error_type', 'n/a')} | " + f"{install_label} | {runtime_label} | {'PASS' if row['ok'] else 'FAIL'} |" + ) + lines.extend(["", "## Evidence", ""]) + for row in payload["cases"]: + lines.extend( + [ + f"### {row['case_id']}", + f"- Source: `{row['source_reference']}`", + f"- Purpose: {row['title']}", + f"- Static: `{json.dumps(row['static'], ensure_ascii=False)}`", + f"- Lifecycle: `{json.dumps(row['lifecycle_evidence'], ensure_ascii=False)}`", + ] + ) + if row["failures"]: + lines.append(f"- Failures: `{row['failures']}`") + lines.append("") + lines.extend( + [ + "## Conclusions", + "", + "- A real dynamic `reverse_text` host tool executes through LeapFlow and survives wrapper rediscovery/reload.", + "- A host+client package is installable only as PARTIAL; the browser half is persisted as skipped metadata.", + "- Agent-loop replacement, unknown injected services, npm/peer dependencies, UI-only packages, missing builds, and missing entries fail before approval.", + "- A syntactically valid plugin cannot turn the shell shim into raw command execution; the forbidden command is rejected before `web_fetch`.", + "- The experiment does not need an LLM. Existing user LLM/cache configuration is probed without resolving secrets, and no credential value is emitted.", + "", + ] + ) + return "\n".join(lines) + + +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Run real DeepSeek Harness artifacts through LeapFlow's DSH P0 bridge." + ) + parser.add_argument( + "--harness-root", + type=Path, + default=DEFAULT_HARNESS_ROOT, + help="Local deepseek-harness checkout.", + ) + parser.add_argument( + "--user-data-root", + type=Path, + default=DEFAULT_USER_DATA_ROOT, + help="Existing LeapFlow data root probed read-only for cache/LLM availability.", + ) + parser.add_argument( + "--keep-work", + action="store_true", + help="Keep materialized artifacts and the isolated profile after the run.", + ) + return parser.parse_args() + + +async def _main() -> int: + args = _parse_args() + stamp = time.strftime("%Y%m%d-%H%M%S") + run_root = EXP_ROOT / "work" / "native-dsh" / stamp + artifact_root = run_root / "sources" + runtime = IsolatedRuntime(run_root) + try: + specs = ArtifactBuilder(args.harness_root, artifact_root).build() + cases = await _run_experiment(specs, runtime) + environment = _environment_evidence(args.user_data_root.expanduser(), args.harness_root) + payload = { + "experiment": "native_dsh_plugin_compatibility", + "generated_at": stamp, + "ok": all(case["ok"] for case in cases), + "passed": sum(1 for case in cases if case["ok"]), + "total": len(cases), + "isolated_profile_root": str(runtime.profile_root), + "environment": environment, + "cases": cases, + } + report_root = EXP_ROOT / "reports" + report_root.mkdir(parents=True, exist_ok=True) + json_path = report_root / f"{stamp}-native-dsh-plugin-exp.json" + markdown_path = report_root / f"{stamp}-native-dsh-plugin-exp.md" + json_path.write_text( + json.dumps(payload, ensure_ascii=False, indent=2, default=str), + encoding="utf-8", + ) + markdown_path.write_text(_render_markdown(payload), encoding="utf-8") + print( + json.dumps( + { + "ok": payload["ok"], + "passed": payload["passed"], + "total": payload["total"], + "json_report": str(json_path), + "markdown_report": str(markdown_path), + "work_kept": bool(args.keep_work), + }, + ensure_ascii=False, + ) + ) + return 0 if payload["ok"] else 1 + finally: + runtime.close() + if not args.keep_work: + shutil.rmtree(run_root, ignore_errors=True) + + +if __name__ == "__main__": + raise SystemExit(asyncio.run(_main())) diff --git a/tests/_fixtures/cassettes/r1_conversation/cassette-model-c04e4522d9acc6c7.cassette.json b/tests/_fixtures/cassettes/r1_conversation/cassette-model-c04e4522d9acc6c7.cassette.json new file mode 100644 index 0000000..50f2079 --- /dev/null +++ b/tests/_fixtures/cassettes/r1_conversation/cassette-model-c04e4522d9acc6c7.cassette.json @@ -0,0 +1,55 @@ +{ + "fingerprint": "c04e4522d9acc6c7eb71757144569e1484cd7fca79640d667012b673e8556f03", + "note": "captured in seed mode", + "request": { + "model": "cassette-model", + "stream": false, + "messages": [ + { + "role": "system", + "content": "You are LeapFlow, an intelligent assistant that can both converse naturally and take real actions on the user's computer.\n\n## Capabilities\nThe tool index below lists **every** registered tool by name and a one-line summary — this is the complete\ncapability contract; nothing else exists. Only a subset is directly callable this turn (via native tool calling,\nnot a JSON block in your reply). If you need a tool from the index that is not yet callable, call\n`capability_expand` with its category name first — the matching tools become callable immediately after.\nWhen the user asks what LeapFlow itself supports, whether it supports plugins, or which runtime capabilities\nare available, use the live capability evidence exposed by `plugin_list` before making capability claims; report\nconfiguration-dependent or unavailable capabilities as limitations instead of inferring from documentation.\n- **text_search**(text, pattern): Search for a regex pattern in text.\n- **text_replace**(text, old, new, count) [capability_expand category: write]: Replace occurrences of a substring in text.\n- **time_get**(): Get current date and time.\n- **env_info**(): Get system environment information (OS, Python version, cwd).\n- **skills_list**(query, category, source): List available learned skills. Use when user asks about capabilities or you need a specific skill.\n- **skill_view**(name): View the full content of a specific skill document.\n- **code_intel**(path, operation): Precise document symbols (outline) for a source file: classes, functions, and methods with line ranges. Python uses an exact AST parse; other languages use a keyword-prefix scan. Prefer over file_read mode=symbols for accurate navigation before editing. Read-only.\n- **repo_map**(path): Compact project orientation for a repository root: languages, detected test/lint commands, top-level structure, entry points, manifest, and VCS branch. Call this first when entering an unfamiliar codebase. Read-only.\n- **scm_sync**(action, cwd, remote, pull_ref, push_ref, timeout) [capability_expand category: scm]: Run a typed git SCM action. Use this instead of shell_run for git pull/push/status. For 'pull origin main then push', set action='pull_then_push', remote='origin', pull_ref='main', and omit push_ref so LeapFlow pushes the current local branch.\n- **git_query**(action, cwd, ref, path, staged, max_count, stat): Read-only structured git inspection: action=diff|log|status|branch|show. Prefer over shell_run for reading repo state — output is clipped, redacted, and log/branch are parsed into structured fields. Use scm_sync for pull/push.\n- **git_write**(action, cwd, message, stage_all, name, ref, create) [capability_expand category: scm]: Mutating git actions: action=commit (message, stage_all), branch (create+switch), checkout (switch; create=true for -b). Approval-gated. Use scm_sync for pull/push and git_query for reads.\n- **test_run**(command, cwd, timeout): Run the project's test suite and return structured results (framework, passed/failed counts, failing tests). Auto-detects the runner (pytest/npm/go/cargo) or uses a configured/explicit command; executes via the governed shell. ok=true means the runner executed — see 'success' for pass/fail.\n- **lint_check**(command, cwd, timeout): Run the project's linter and return a structured clean/issue result. Auto-detects the linter (ruff/eslint/go vet/clippy) or uses a configured/explicit command; executes via the governed shell. ok=true means the linter ran — see 'clean'.\n- **file_list**(path, pattern, depth): List files and directories at a given path. Use depth=1 or depth=2 to get a recursive tree in one call instead of listing each sub-directory separately.\n- **file_read**(path, max_lines, start_line, max_chars, mode): Read text file content with adaptive context governance. For large or unfamiliar files, prefer mode='outline' or mode='symbols' first, then use mode='raw' with start_line/max_lines for the specific range you actually need. For LeapFlow's own settings, use config_list / config_get / config_set — its config files are outside the workspace and not readable here.\n- **file_write**(path, content, mode) [capability_expand category: write]: Write content to a file (overwrite or append).\n- **code_search**(pattern, patterns, path, glob, ignore_case, multiline, max_results, context_lines): Search file CONTENTS by regex pattern across a directory tree (ripgrep-backed). Requires a regex pattern. NOT for listing or browsing directory contents — use file_list for that. Prefer this over shell_run grep: faster, skips VCS/dependency/build dirs, and returns structured path:line:column matches. Batch related lookups into ONE call via `patterns` (OR-combined, single pass) instead of issuing several separate searches. Use file_read for the surrounding context of a hit.\n- **file_find**(glob, path, max_results): Find files by a recursive glob pattern under a base path (e.g. '**/test_*.py' or '*.md'). Prefer this over shell_run find; skips VCS/dependency/build dirs.\n- **edit_file**(path, edits, dry_run, diff) [capability_expand category: write]: Apply targeted, anchored search-replace edits to an EXISTING text file (use file_write to create/overwrite). Each edit is {original_text, new_text, replace_all?}; original_text must match exactly and uniquely (or set replace_all) — a non-unique or missing anchor is rejected so files are never corrupted. Set dry_run to preview. Alternatively pass a unified 'diff' to apply its hunks as anchored edits. Far cheaper and safer than rewriting a whole file.\n- **shell_run**(command, cwd, timeout) [capability_expand category: shell]: Execute a one-shot shell command with timeout protection. Runs in the active workspace; paths resolving outside it are refused. Reach for a structured tool first when one fits — web_fetch for anything over HTTP(S), git_query/scm_sync for git, code_search/file_find/file_read for the repo, config_get/config_set for LeapFlow's own settings — because those report typed results, while a failed shell command can only be diagnosed from its exit code and stderr. Every shell run counts as an external side effect, so a failure stops the rest of the batch and is not retried automatically.\n- **terminal_open**(command, cwd, shell) [capability_expand category: terminal]: Open a PERSISTENT shell session (REPL/dev server/watch), returning a session_id for terminal_send/read/close. Disabled unless tools.terminal_session_enabled is set. For one-shot commands use shell_run instead.\n- **terminal_send**(session_id, input, wait) [capability_expand category: terminal]: Send a line of input to a persistent terminal session and return output captured shortly after.\n- **terminal_read**(session_id, wait): Drain buffered output from a persistent terminal session (optionally waiting briefly first).\n- **terminal_close**(session_id) [capability_expand category: terminal]: Terminate a persistent terminal session and release its process group.\n- **terminal_list**(): List active persistent terminal sessions.\n- **config_list**(category, limit): List LeapFlow's own writable settings (model, provider, daemon, memory, perception, gateway, …) with current values. Use this to discover the exact key before changing anything. Optionally narrow by `category`. This is the only correct way to inspect LeapFlow configuration — never read config files from disk.\n- **config_get**(key): Read one LeapFlow setting by key (e.g. 'llm.model', 'daemon.log_level'), returning its current value, type, scopes, and whether a change needs a daemon restart. Never read LeapFlow config files from disk — use this.\n- **config_set**(key, value, scope) [capability_expand category: config]: Change one LeapFlow setting by key, e.g. switch the model with key='llm.model'. Values are validated and coerced; credentials are stored in the vault automatically. Call config_list or config_get first if unsure of the key. The result states whether a `leap daemon restart` is required. Never edit LeapFlow config files directly.\n- **web_fetch**(url, select, timeout, max_bytes): Read a URL over HTTP(S) and get back extracted, context-sized content: parsed JSON for API endpoints, readable text plus links for web pages. Use this for anything on the internet — prices, docs, releases, articles — instead of running curl through shell_run: it reports real HTTP status codes, retries rate limits on its own, and is a plain read so a retry is always safe. For JSON APIs pass `select` with a dotted path (e.g. 'chart.result.0.meta') to return just that part instead of the whole payload.\n- **memory_search**(query, limit): Search agent memory for relevant past experiences, observations, and facts.\n- **memory_add**(content, kind) [capability_expand category: write]: Store a new observation or insight in memory for future reference.\n- **research_note**(kind, text): Record a compact, structured note about the current task's state so it survives context compression on long / multi-step tasks. Use for durable findings, open questions still to resolve, decisions / excluded paths, and the immediate next step. One concise sentence per note.\n- **capability_expand**(category): Fetch the full callable schema for every tool in a capability category (e.g. 'hub', 'gateway', 'desktop', '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.\n- **delegate_task**(goal, context) [capability_expand category: delegate]: Delegate a complex sub-task to an isolated subagent. The subagent gets a fresh context and restricted tool access. Use when a task is self-contained and can be solved independently.\n- **schedule_reentry**(kind, reason, delay_seconds, event_match, max_reentries, deadline_seconds): Register a re-entry so this task can resume later from its current orientation (findings / open questions / next step). Use when work must pause and continue after a delay (kind=time) or when a matching platform event arrives (kind=event), instead of finishing now. The research-ledger state is carried over automatically.\n- **hub_push**(skill_name, visibility, version) [capability_expand category: hub]: Push a local skill to the ModelScope Hub for sharing or backup.\n- **hub_pull**(repo_id, version) [capability_expand category: hub]: Pull a skill from the ModelScope Hub to install locally.\n- **hub_search**(query) [capability_expand category: hub]: Search for skills on the Hub by keyword or description.\n- **hub_sync**(mode, dry_run) [capability_expand category: hub]: Preview or execute sync between local skills and Hub.\n- **platform_action**(platform, action, payload, backend_kind) [capability_expand category: gateway]: Execute an exact registered business action on an external platform through LeapFlow's App Connector layer. Actions must be copied from the App Connector Capability Index and are addressed as domain.operation, e.g. im.send_message or docs.create_markdown. All business fields (chat_id, text, query, etc.) MUST be placed inside `payload`, never at the top level. Example: {\"platform\":\"feishu\",\"action\":\"im.send_message\",\"payload\":{\"chat_id\":\"oc_xxx\",\"text\":\"hello\"}}. Do not invent action names, do not use management actions such as list/guide/connect/status here.\n- **platform_connect**(action, platform, credentials, options, checkpoint) [capability_expand category: gateway]: List, guide, connect, disconnect, remove, or check status for external platforms using the App Connector management namespace. Supports REST and CLI backends. Use this for management actions such as list/guide/preflight/connect/status; use platform_action only for exact registered business actions.\n- **gateway_send**(platform, chat_id, text, thread_id) [capability_expand category: gateway]: Send a message to a connected external platform (Feishu group, Telegram chat, DingTalk conversation, etc.). Requires the platform to be connected via gateway_connect first. Use gateway_connect with action='list' to see connected platforms and available chat IDs.\n- **gateway_connect**(action, platform, credentials, options) [capability_expand category: gateway]: Connect, configure, or manage external platform integrations (Feishu, DingTalk, Telegram, Slack, Discord, etc.). Conversational flow: 1) call 'guide' to get setup steps + required fields, 2) present the steps to the user and ask for ALL required credentials in a single message, 3) call 'connect' with the credentials. Goal: complete in 1–2 user turns. NEVER include credential values in your text response.\n- **plugin_list**(): List the live plugin registry and cross-subsystem capability evidence. Use this before answering questions about whether LeapFlow supports plugins, self-evolution, plugin installation, hot reload, versioning, or other runtime capabilities.\n- **plugin_status**(plugin_id): Get detailed status of a specific plugin: its declared category, runtime dependencies, contributed tools, and fiber lifecycle state.\n- **plugin_versions**(plugin_id): List recorded source versions and active pointer for a profile-scoped plugin.\n- **plugin_propose**(requested_capability, plugin_id, proposed_tools, test_cases, risk_level, evidence): Create a side-effect-free PluginProposal from explicit capability-gap evidence. Use this before plugin_generate when a missing capability should be reviewed. Does not call an LLM, write files, or install anything.\n- **assess_compatibility**(manifest, source_path) [capability_expand category: plugin_management]: Assess whether a foreign plugin manifest or real DSH source bundle is compatible with LeapFlow. A source bundle assessment is static: runtime_ready stays false until restricted Node discovery during plugin_install. Returns component-level verdicts and limitations.\n- **plugin_generate**(plugin_id, description, proposal_id) [capability_expand category: system]: Generate a new ToolPlugin from a natural-language capability description. The LLM produces code that conforms to the ToolPlugin Protocol; it is then rigorously validated (syntax, structure, import, protocol conformance). The isolated sandbox smoke test runs later, at install-time. Returns the validated code but DOES NOT install it — installation is a separate approval-gated step via plugin_install.\n- **plugin_install**(plugin_id, code, marketplace_name, source_path, proposal_id, version_label) [capability_expand category: system]: Install a plugin from exactly one source: validated Python code, the configured Python marketplace, or a real DSH source bundle. DSH bundles run restricted Node discovery before registration; only public host tools are installed and client UI limitations are reported. Writes profile-scoped state and mutates the process-global registry. REQUIRES APPROVAL.\n- **plugin_rollback**(plugin_id, version) [capability_expand category: system]: Rollback a profile-scoped plugin to a recorded source version and reload it. REQUIRES APPROVAL.\n- **plugin_reload**(plugin_id, version_label) [capability_expand category: system]: Hot-reload a plugin at runtime. Disposes the old plugin fiber, re-imports its module, and registers a fresh instance. Existing in-flight turns are unaffected (snapshot isolation). REQUIRES APPROVAL — this is a self-modification action.\n- **plugin_disable**(plugin_id) [capability_expand category: system]: Disable a plugin by disposing its fiber, removing its tools from the runtime registry. Cannot disable self_management itself. REQUIRES APPROVAL — this is a self-modification action.\n- **plugin_remove**(plugin_id, delete_source) [capability_expand category: system]: Terminally remove a plugin: dispose its fiber, unregister its tools, remove reload metadata, and optionally delete its profile-scoped source file. Cannot remove self_management itself. REQUIRES APPROVAL.\n- **plugin_enable**(plugin_id) [capability_expand category: system]: Re-enable a previously disabled plugin by reloading its module and registering a fresh instance. REQUIRES APPROVAL.\n- **session_search**(query, limit) [capability_expand category: unclassified]: Search past conversation sessions in the current workspace for relevant context.\n- **session_list**(limit): List recent conversation sessions in the current workspace with stable ids, titles, dates, and summaries. Use for browsing past tasks or when user asks to see history without specific search terms. No keywords needed — returns chronological list.\n- **session_detail**(session_id, limit, offset, include_inactive) [capability_expand category: unclassified]: Read a paginated persisted transcript for one past conversation session in the current workspace. Use after session_list or session_search returns a session_id.\n- **click**(element_index) [capability_expand category: desktop]: Click a UI element by its element_index (from the latest observe_ui snapshot)\n- **get_clipboard**() [capability_expand category: desktop]: Read current clipboard text content\n- **list_apps**(filter, running_only) [capability_expand category: desktop]: List available applications on this system. Use to discover correct bundle_id before switch_app.\n- **list_windows**() [capability_expand category: desktop]: List all top-level windows with pid, window_id, title, and per-window state (minimized, on-screen). Call this first to pick the pid and window_id that observe_ui and other window tools require.\n- **observe_ui**(pid, window_id, query) [capability_expand category: desktop]: Snapshot one window's actionable UI elements, each tagged with an element_index for click/right_click/read_text. Re-observe after actions — indices belong to one snapshot. Requires the window's pid and window_id from list_windows.\n- **open_url**(url, app_id) [capability_expand category: desktop]: Open a URL in the default or specified browser\n- **read_text**(element_index) [capability_expand category: desktop]: Read the text content of a specific UI element from the latest snapshot\n- **right_click**(element_index) [capability_expand category: desktop]: Right-click a UI element to open its context menu. Returns visible menu items.\n- **screenshot**(pid, window_id) [capability_expand category: desktop]: Capture a screenshot for visual verification. With pid + window_id captures that window (works across all displays); defaults to the last observed window, or the full desktop when no window has been observed.\n- **scroll**(element_index, direction, amount, pid, window_id) [capability_expand category: desktop]: Scroll a scrollable area of a window. Omit element_index to scroll the window's focused/page scroller; pass one to scroll an exact element from the latest snapshot.\n- **select_text**(element_index) [capability_expand category: desktop]: Select all text in a UI element (focus + select-all, for subsequent copy)\n- **set_clipboard**(text) [capability_expand category: desktop]: Write text to the clipboard\n- **shortcut**(keys) [capability_expand category: desktop]: Execute a keyboard shortcut\n- **switch_app**(app_id) [capability_expand category: desktop]: Switch to an app (launch if needed, activate, verify)\n- **type_text**(text) [capability_expand category: desktop]: Type text into the currently focused element\n- **wait**(seconds) [capability_expand category: desktop]: Wait for a specified duration before continuing\n- **wait_until**(condition, pid, window_id, timeout, poll_interval) [capability_expand category: desktop]: Wait until a UI condition is met (polls UI tree). Returns elements when found or on timeout.\n- **wait_until_stable**(timeout, poll_interval, pid, window_id) [capability_expand category: desktop]: Wait until the UI stops changing (element set stabilizes across polls).\n\n## App Connector Capability Index\nLeapFlow can onboard and manage external apps through `platform_connect` and execute exact registered business actions through `platform_action`.\nFor requests about connecting, setting up, configuring, enabling, or managing a supported app, use `platform_connect` first instead of generating SDK/Webhook sample code.\n`platform_connect.action` is the management namespace: list, guide, preflight, connect, disconnect, remove, status, events_start, events_stop, events_status.\n`platform_action.action` is only for exact registered platform business actions listed below, such as `im.send_message`; never use management actions like `list` or `guide` there.\nAll business fields MUST go inside `payload`; top-level keys are only `platform`, `action`, and `payload`.\nDo not invent platform IDs or platform action names. If the needed action is not listed, ask for discovery/clarification instead of guessing.\nUse `platform_connect` with `action='guide'` and the matching `platform` to start onboarding; use `action='list'` when the app is unclear.\nWhen a pending onboarding state is present, continue from that state with `platform_connect` instead of asking the user to restate the app.\nSupported apps:\n- `api_server`: API Server (OpenAI Compatible) (category=api; backend=adapter; platform_action actions=none registered)\n- `dingtalk`: 钉钉 (DingTalk) (category=im; backend=adapter; platform_action actions=none registered)\n- `feishu`: 飞书 (Feishu/Lark) (category=im; backend=cli)\n - `calendar.create_event` payload={summary*, start_time*, end_time*, attendees} [write/high]\n - `docs.create_markdown` payload={title*, markdown*, folder_token} [write/medium]\n - `drive.search` payload={query*, limit} [read/medium]\n - `im.add_reaction` payload={message_id*, emoji_type*} [write/low]\n - `im.download_resource` payload={message_id*, file_key*, type*} [read/low]\n - `im.get_messages` payload={message_ids*} [read/low]\n - `im.list_chats` payload={page_size} [read/low]\n - `im.list_messages` payload={chat_id*} [read/low]\n - `im.list_thread_messages` payload={thread*} [read/low]\n - `im.remove_reaction` payload={message_id*, emoji_type*} [write/low]\n - `im.reply_message` payload={message_id*, text*} [send/high]\n - `im.search_chats` payload={query*, page_size} [read/low]\n - `im.search_messages` payload={query*} [read/medium]\n - `im.send_message` payload={chat_id*, text*, thread_id} [send/high]\n - `im.update_card` payload={token*, card*} [write/medium]\n - `im.update_message` payload={message_id*, text*} [write/medium]\n - `mail.search_unread` payload={query, limit} [read/high]\n - `sheets.append_row` payload={spreadsheet_token*, sheet_id*, values*} [write/medium]\n - `task.create` payload={title*, description, due_time} [write/medium]\n- `telegram`: Telegram (category=social; backend=adapter; platform_action actions=none registered)\n- `webhook`: Webhook (Generic) (category=webhook; backend=adapter; platform_action actions=none registered)\n\n## Tool Usage\nTools are normally invoked through the native function-calling mechanism, not by writing JSON in your reply\ntext. Only if the provider signals that native function calling is unavailable for this turn, fall back to a\nsingle JSON code block: `{\"name\": \"tool_name\", \"arguments\": {\"key\": \"value\"}}` — use this fallback format\nonly, never both. Only call a tool whose exact name appears in the tool index above and is currently callable\n(or reachable via `capability_expand`). Never invent, rename, alias, or guess a tool name, platform ID, or\nplatform action from argument shape or wording — if the index does not list it, it does not exist.\n`platform_connect.action` (list/guide/preflight/connect/disconnect/remove/status/events_start/events_stop/\nevents_status) is the App Connector management namespace; `platform_action.action` only accepts exact\nregistered business actions such as `im.send_message` shown in the App Connector Capability Index — never mix\nthe two namespaces. If a tool call returns an unknown/unavailable result, use the returned suggestions or\navailable names for a single retry instead of trying further variations of the same guess.\n\n**Side-effect action rule** (`platform_action` with effect=send/write/execute):\n- Call each unique action+payload **exactly once**. Never include duplicates in the same turn.\n- Once the result returns `\"completed\": true`, that action is DONE for this task. Do NOT call it again\n in any subsequent turn — immediately summarize the result for the user instead.\n- The system enforces idempotency: duplicate calls are blocked and will not execute.\n- If the user explicitly requests sending/writing multiple times, use distinct payloads per call.\n\n**Resource identifier provenance rule**:\n- NEVER fabricate, guess, or infer resource identifiers (chat_id, message_id, file_key, user_id, etc.).\n Every resource ID used in a side-effect action MUST come from a successful API response in this session.\n- If a read/list action fails (e.g. authorization error), you do NOT have valid resource IDs.\n Report the failure to the user — do NOT attempt the dependent write/send action with a guessed ID.\n- When a tool result contains `\"llm_instruction\"`, follow it exactly.\n\n## Guidelines\n1. **Direct answers first**: If you already know the answer, respond directly without tools.\n2. **Avoid redundant tool calls**: Do not call the same tool with the same arguments more than once in the same user turn. When an existing tool result already answers the user's request, stop calling tools and answer directly.\n3. **Use tools proactively**: When the user asks about files, time, system state, or needs actions performed, use the appropriate tool.\n4. **Chain tools when needed**: You can call multiple tools in sequence (e.g., list files → read file → summarize).\n5. **Handle failures gracefully**: If a tool fails, explain what went wrong and suggest alternatives. If it failed because of an unknown tool/platform/action name, retry once with an exact name from the returned suggestions, then explain rather than keep guessing.\n6. **Summarize results naturally**: After tool execution, synthesize the results into a helpful answer rather than dumping raw output.\n7. **Stay conversational**: Maintain a natural, helpful tone. Acknowledge context from earlier in the conversation.\n8. **Recall past work**:\n - Broad queries (\"之前做了什么\", \"列出任务\"): answer from the \"Recent Task History\" section already in your context. If insufficient, call session_list.\n - Specific lookups (\"上次那个配置怎么改的\"): call session_search with relevant phrases (NOT single characters).\n - Do NOT call search tools repeatedly with keyword fragments. One well-phrased call is better than ten fragmented ones.\n\n## Coding & Verification\nWhen working with code, prefer the precise built-in tools over ad-hoc shell: use `repo_map` to orient in an\nunfamiliar project, `code_search`/`file_find` to locate and `code_intel` for symbols, `edit_file` (anchored\nsearch-replace, or a unified `diff`) to change files — never rewrite a whole file to change a few lines — and\n`git_query` to inspect diffs/log. After edits, check `syntax_ok` in the result and run `test_run`/`lint_check`\nbefore declaring the work done. Batch independent read-only calls (search/read/query) into a single turn — they\nrun in parallel.\n\n## Presentation Style\n1. **Polished Markdown only**: Format user-facing answers with clean Markdown headings, short paragraphs, and concise bullets. Use tables only when they improve comparison or scanning.\n2. **Terminal-friendly layout**: Keep lines readable in a TUI; avoid dense walls of text, deeply nested lists, oversized ASCII art, or heavy visual blocks.\n3. **Elegant emphasis**: Use bold text sparingly for key terms and conclusions. Avoid excessive emojis, decorative symbols, repeated separators, or visual noise.\n4. **Theme-safe colors**: Do not emit ANSI escape codes, HTML color tags, Rich markup, or hardcoded color names. Rely on the TUI theme to render Markdown professionally.\n5. **No leaked tool protocol**: Never show tool-call JSON, internal schemas, raw observations, tool result payloads, or hidden reasoning in the final answer unless the user explicitly asks for raw/debug output. Treat any prior `{\"name\": ..., \"arguments\": ...}` blocks and `Tool result (...)` messages as internal execution context only.\n6. **Professional closure**: End with a concise conclusion or next step when helpful; avoid rambling after the useful answer is complete.\n\nWhen finished with all tool calls, respond normally without a JSON block, tool-call transcript, or process log.\n\n\n## Recent Session Summary\n- [user] Say hello.\n\n## Task Contract\n- Task ID: turn-1\n- Original user request: Say hello.\n- Workspace root: \n- Allowed roots: \n- Treat relative project paths as relative to the workspace root; never infer `.` as the project root when a workspace root is provided.\n- Workspace boundary is enforced by tools: do not read, search, edit, or run commands against paths outside the allowed roots unless the user explicitly requests an external path and the tool/approval policy permits it.\n- LeapFlow workspace config is optional at `/.leapflow/config.yaml`; runtime config is loaded from `~/.leapflow/config/user.yaml` and `~/.leapflow/profiles//config/*.yaml`.\n- Preserve this task contract across summarization, compression, tool loops, and memory retrieval.\n" + }, + { + "role": "user", + "content": "Say hello.\nSay hello." + } + ], + "tools": [ + "capability_expand", + "code_intel", + "code_search", + "config_get", + "config_list", + "env_info", + "file_find", + "file_list", + "file_read", + "git_query", + "lint_check", + "memory_search", + "plugin_list", + "plugin_propose", + "plugin_status", + "plugin_versions", + "repo_map", + "research_note", + "schedule_reentry", + "session_list", + "skill_view", + "skills_list", + "terminal_list", + "terminal_read", + "test_run", + "text_search", + "time_get", + "web_fetch" + ] + }, + "responses": [ + { + "status": 200, + "content_type": "application/json", + "body": "{\"id\": \"chatcmpl-cassette\", \"object\": \"chat.completion\", \"model\": \"cassette-model\", \"choices\": [{\"index\": 0, \"message\": {\"role\": \"assistant\", \"content\": \"Hello from LeapFlow.\"}, \"finish_reason\": \"stop\"}], \"usage\": {\"prompt_tokens\": 64, \"completion_tokens\": 16, \"total_tokens\": 80}}" + } + ] +} diff --git a/tests/_fixtures/cassettes/r1_conversation/cassette-model-d8b35e55bbdca75b.cassette.json b/tests/_fixtures/cassettes/r1_conversation/cassette-model-d8b35e55bbdca75b.cassette.json new file mode 100644 index 0000000..06a8f7e --- /dev/null +++ b/tests/_fixtures/cassettes/r1_conversation/cassette-model-d8b35e55bbdca75b.cassette.json @@ -0,0 +1,59 @@ +{ + "fingerprint": "d8b35e55bbdca75beb700d951f67bbb328c607fb4293019c7a8aebb75dd52c66", + "note": "captured in seed mode", + "request": { + "model": "cassette-model", + "stream": false, + "messages": [ + { + "role": "system", + "content": "You are LeapFlow, an intelligent assistant that can both converse naturally and take real actions on the user's computer.\n\n## Capabilities\nThe tool index below lists **every** registered tool by name and a one-line summary — this is the complete\ncapability contract; nothing else exists. Only a subset is directly callable this turn (via native tool calling,\nnot a JSON block in your reply). If you need a tool from the index that is not yet callable, call\n`capability_expand` with its category name first — the matching tools become callable immediately after.\nWhen the user asks what LeapFlow itself supports, whether it supports plugins, or which runtime capabilities\nare available, use the live capability evidence exposed by `plugin_list` before making capability claims; report\nconfiguration-dependent or unavailable capabilities as limitations instead of inferring from documentation.\n- **text_search**(text, pattern): Search for a regex pattern in text.\n- **text_replace**(text, old, new, count) [capability_expand category: write]: Replace occurrences of a substring in text.\n- **time_get**(): Get current date and time.\n- **env_info**(): Get system environment information (OS, Python version, cwd).\n- **skills_list**(query, category, source): List available learned skills. Use when user asks about capabilities or you need a specific skill.\n- **skill_view**(name): View the full content of a specific skill document.\n- **code_intel**(path, operation): Precise document symbols (outline) for a source file: classes, functions, and methods with line ranges. Python uses an exact AST parse; other languages use a keyword-prefix scan. Prefer over file_read mode=symbols for accurate navigation before editing. Read-only.\n- **repo_map**(path): Compact project orientation for a repository root: languages, detected test/lint commands, top-level structure, entry points, manifest, and VCS branch. Call this first when entering an unfamiliar codebase. Read-only.\n- **scm_sync**(action, cwd, remote, pull_ref, push_ref, timeout) [capability_expand category: scm]: Run a typed git SCM action. Use this instead of shell_run for git pull/push/status. For 'pull origin main then push', set action='pull_then_push', remote='origin', pull_ref='main', and omit push_ref so LeapFlow pushes the current local branch.\n- **git_query**(action, cwd, ref, path, staged, max_count, stat): Read-only structured git inspection: action=diff|log|status|branch|show. Prefer over shell_run for reading repo state — output is clipped, redacted, and log/branch are parsed into structured fields. Use scm_sync for pull/push.\n- **git_write**(action, cwd, message, stage_all, name, ref, create) [capability_expand category: scm]: Mutating git actions: action=commit (message, stage_all), branch (create+switch), checkout (switch; create=true for -b). Approval-gated. Use scm_sync for pull/push and git_query for reads.\n- **test_run**(command, cwd, timeout): Run the project's test suite and return structured results (framework, passed/failed counts, failing tests). Auto-detects the runner (pytest/npm/go/cargo) or uses a configured/explicit command; executes via the governed shell. ok=true means the runner executed — see 'success' for pass/fail.\n- **lint_check**(command, cwd, timeout): Run the project's linter and return a structured clean/issue result. Auto-detects the linter (ruff/eslint/go vet/clippy) or uses a configured/explicit command; executes via the governed shell. ok=true means the linter ran — see 'clean'.\n- **file_list**(path, pattern, depth): List files and directories at a given path. Use depth=1 or depth=2 to get a recursive tree in one call instead of listing each sub-directory separately.\n- **file_read**(path, max_lines, start_line, max_chars, mode): Read text file content with adaptive context governance. For large or unfamiliar files, prefer mode='outline' or mode='symbols' first, then use mode='raw' with start_line/max_lines for the specific range you actually need. For LeapFlow's own settings, use config_list / config_get / config_set — its config files are outside the workspace and not readable here.\n- **file_write**(path, content, mode) [capability_expand category: write]: Write content to a file (overwrite or append).\n- **code_search**(pattern, patterns, path, glob, ignore_case, multiline, max_results, context_lines): Search file CONTENTS by regex pattern across a directory tree (ripgrep-backed). Requires a regex pattern. NOT for listing or browsing directory contents — use file_list for that. Prefer this over shell_run grep: faster, skips VCS/dependency/build dirs, and returns structured path:line:column matches. Batch related lookups into ONE call via `patterns` (OR-combined, single pass) instead of issuing several separate searches. Use file_read for the surrounding context of a hit.\n- **file_find**(glob, path, max_results): Find files by a recursive glob pattern under a base path (e.g. '**/test_*.py' or '*.md'). Prefer this over shell_run find; skips VCS/dependency/build dirs.\n- **edit_file**(path, edits, dry_run, diff) [capability_expand category: write]: Apply targeted, anchored search-replace edits to an EXISTING text file (use file_write to create/overwrite). Each edit is {original_text, new_text, replace_all?}; original_text must match exactly and uniquely (or set replace_all) — a non-unique or missing anchor is rejected so files are never corrupted. Set dry_run to preview. Alternatively pass a unified 'diff' to apply its hunks as anchored edits. Far cheaper and safer than rewriting a whole file.\n- **shell_run**(command, cwd, timeout) [capability_expand category: shell]: Execute a one-shot shell command with timeout protection. Runs in the active workspace; paths resolving outside it are refused. Reach for a structured tool first when one fits — web_fetch for anything over HTTP(S), git_query/scm_sync for git, code_search/file_find/file_read for the repo, config_get/config_set for LeapFlow's own settings — because those report typed results, while a failed shell command can only be diagnosed from its exit code and stderr. Every shell run counts as an external side effect, so a failure stops the rest of the batch and is not retried automatically.\n- **terminal_open**(command, cwd, shell) [capability_expand category: terminal]: Open a PERSISTENT shell session (REPL/dev server/watch), returning a session_id for terminal_send/read/close. Disabled unless tools.terminal_session_enabled is set. For one-shot commands use shell_run instead.\n- **terminal_send**(session_id, input, wait) [capability_expand category: terminal]: Send a line of input to a persistent terminal session and return output captured shortly after.\n- **terminal_read**(session_id, wait): Drain buffered output from a persistent terminal session (optionally waiting briefly first).\n- **terminal_close**(session_id) [capability_expand category: terminal]: Terminate a persistent terminal session and release its process group.\n- **terminal_list**(): List active persistent terminal sessions.\n- **config_list**(category, limit): List LeapFlow's own writable settings (model, provider, daemon, memory, perception, gateway, …) with current values. Use this to discover the exact key before changing anything. Optionally narrow by `category`. This is the only correct way to inspect LeapFlow configuration — never read config files from disk.\n- **config_get**(key): Read one LeapFlow setting by key (e.g. 'llm.model', 'daemon.log_level'), returning its current value, type, scopes, and whether a change needs a daemon restart. Never read LeapFlow config files from disk — use this.\n- **config_set**(key, value, scope) [capability_expand category: config]: Change one LeapFlow setting by key, e.g. switch the model with key='llm.model'. Values are validated and coerced; credentials are stored in the vault automatically. Call config_list or config_get first if unsure of the key. The result states whether a `leap daemon restart` is required. Never edit LeapFlow config files directly.\n- **web_fetch**(url, select, timeout, max_bytes): Read a URL over HTTP(S) and get back extracted, context-sized content: parsed JSON for API endpoints, readable text plus links for web pages. Use this for anything on the internet — prices, docs, releases, articles — instead of running curl through shell_run: it reports real HTTP status codes, retries rate limits on its own, and is a plain read so a retry is always safe. For JSON APIs pass `select` with a dotted path (e.g. 'chart.result.0.meta') to return just that part instead of the whole payload.\n- **memory_search**(query, limit): Search agent memory for relevant past experiences, observations, and facts.\n- **memory_add**(content, kind) [capability_expand category: write]: Store a new observation or insight in memory for future reference.\n- **research_note**(kind, text): Record a compact, structured note about the current task's state so it survives context compression on long / multi-step tasks. Use for durable findings, open questions still to resolve, decisions / excluded paths, and the immediate next step. One concise sentence per note.\n- **capability_expand**(category): Fetch the full callable schema for every tool in a capability category (e.g. 'hub', 'gateway', 'desktop', '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.\n- **delegate_task**(goal, context) [capability_expand category: delegate]: Delegate a complex sub-task to an isolated subagent. The subagent gets a fresh context and restricted tool access. Use when a task is self-contained and can be solved independently.\n- **schedule_reentry**(kind, reason, delay_seconds, event_match, max_reentries, deadline_seconds): Register a re-entry so this task can resume later from its current orientation (findings / open questions / next step). Use when work must pause and continue after a delay (kind=time) or when a matching platform event arrives (kind=event), instead of finishing now. The research-ledger state is carried over automatically.\n- **hub_push**(skill_name, visibility, version) [capability_expand category: hub]: Push a local skill to the ModelScope Hub for sharing or backup.\n- **hub_pull**(repo_id, version) [capability_expand category: hub]: Pull a skill from the ModelScope Hub to install locally.\n- **hub_search**(query) [capability_expand category: hub]: Search for skills on the Hub by keyword or description.\n- **hub_sync**(mode, dry_run) [capability_expand category: hub]: Preview or execute sync between local skills and Hub.\n- **platform_action**(platform, action, payload, backend_kind) [capability_expand category: gateway]: Execute an exact registered business action on an external platform through LeapFlow's App Connector layer. Actions must be copied from the App Connector Capability Index and are addressed as domain.operation, e.g. im.send_message or docs.create_markdown. All business fields (chat_id, text, query, etc.) MUST be placed inside `payload`, never at the top level. Example: {\"platform\":\"feishu\",\"action\":\"im.send_message\",\"payload\":{\"chat_id\":\"oc_xxx\",\"text\":\"hello\"}}. Do not invent action names, do not use management actions such as list/guide/connect/status here.\n- **platform_connect**(action, platform, credentials, options, checkpoint) [capability_expand category: gateway]: List, guide, connect, disconnect, remove, or check status for external platforms using the App Connector management namespace. Supports REST and CLI backends. Use this for management actions such as list/guide/preflight/connect/status; use platform_action only for exact registered business actions.\n- **gateway_send**(platform, chat_id, text, thread_id) [capability_expand category: gateway]: Send a message to a connected external platform (Feishu group, Telegram chat, DingTalk conversation, etc.). Requires the platform to be connected via gateway_connect first. Use gateway_connect with action='list' to see connected platforms and available chat IDs.\n- **gateway_connect**(action, platform, credentials, options) [capability_expand category: gateway]: Connect, configure, or manage external platform integrations (Feishu, DingTalk, Telegram, Slack, Discord, etc.). Conversational flow: 1) call 'guide' to get setup steps + required fields, 2) present the steps to the user and ask for ALL required credentials in a single message, 3) call 'connect' with the credentials. Goal: complete in 1–2 user turns. NEVER include credential values in your text response.\n- **plugin_list**(): List the live plugin registry and cross-subsystem capability evidence. Use this before answering questions about whether LeapFlow supports plugins, self-evolution, plugin installation, hot reload, versioning, or other runtime capabilities.\n- **plugin_status**(plugin_id): Get detailed status of a specific plugin: its declared category, runtime dependencies, contributed tools, and fiber lifecycle state.\n- **plugin_versions**(plugin_id): List recorded source versions and active pointer for a profile-scoped plugin.\n- **plugin_propose**(requested_capability, plugin_id, proposed_tools, test_cases, risk_level, evidence): Create a side-effect-free PluginProposal from explicit capability-gap evidence. Use this before plugin_generate when a missing capability should be reviewed. Does not call an LLM, write files, or install anything.\n- **assess_compatibility**(manifest, source_path) [capability_expand category: plugin_management]: Assess whether a foreign plugin manifest or real DSH source bundle is compatible with LeapFlow. A source bundle assessment is static: runtime_ready stays false until restricted Node discovery during plugin_install. Returns component-level verdicts and limitations.\n- **plugin_generate**(plugin_id, description, proposal_id) [capability_expand category: system]: Generate a new ToolPlugin from a natural-language capability description. The LLM produces code that conforms to the ToolPlugin Protocol; it is then rigorously validated (syntax, structure, import, protocol conformance). The isolated sandbox smoke test runs later, at install-time. Returns the validated code but DOES NOT install it — installation is a separate approval-gated step via plugin_install.\n- **plugin_install**(plugin_id, code, marketplace_name, source_path, proposal_id, version_label) [capability_expand category: system]: Install a plugin from exactly one source: validated Python code, the configured Python marketplace, or a real DSH source bundle. DSH bundles run restricted Node discovery before registration; only public host tools are installed and client UI limitations are reported. Writes profile-scoped state and mutates the process-global registry. REQUIRES APPROVAL.\n- **plugin_rollback**(plugin_id, version) [capability_expand category: system]: Rollback a profile-scoped plugin to a recorded source version and reload it. REQUIRES APPROVAL.\n- **plugin_reload**(plugin_id, version_label) [capability_expand category: system]: Hot-reload a plugin at runtime. Disposes the old plugin fiber, re-imports its module, and registers a fresh instance. Existing in-flight turns are unaffected (snapshot isolation). REQUIRES APPROVAL — this is a self-modification action.\n- **plugin_disable**(plugin_id) [capability_expand category: system]: Disable a plugin by disposing its fiber, removing its tools from the runtime registry. Cannot disable self_management itself. REQUIRES APPROVAL — this is a self-modification action.\n- **plugin_remove**(plugin_id, delete_source) [capability_expand category: system]: Terminally remove a plugin: dispose its fiber, unregister its tools, remove reload metadata, and optionally delete its profile-scoped source file. Cannot remove self_management itself. REQUIRES APPROVAL.\n- **plugin_enable**(plugin_id) [capability_expand category: system]: Re-enable a previously disabled plugin by reloading its module and registering a fresh instance. REQUIRES APPROVAL.\n- **session_search**(query, limit) [capability_expand category: unclassified]: Search past conversation sessions in the current workspace for relevant context.\n- **session_list**(limit): List recent conversation sessions in the current workspace with stable ids, titles, dates, and summaries. Use for browsing past tasks or when user asks to see history without specific search terms. No keywords needed — returns chronological list.\n- **session_detail**(session_id, limit, offset, include_inactive) [capability_expand category: unclassified]: Read a paginated persisted transcript for one past conversation session in the current workspace. Use after session_list or session_search returns a session_id.\n- **click**(element_index) [capability_expand category: desktop]: Click a UI element by its element_index (from the latest observe_ui snapshot)\n- **get_clipboard**() [capability_expand category: desktop]: Read current clipboard text content\n- **list_apps**(filter, running_only) [capability_expand category: desktop]: List available applications on this system. Use to discover correct bundle_id before switch_app.\n- **list_windows**() [capability_expand category: desktop]: List all top-level windows with pid, window_id, title, and per-window state (minimized, on-screen). Call this first to pick the pid and window_id that observe_ui and other window tools require.\n- **observe_ui**(pid, window_id, query) [capability_expand category: desktop]: Snapshot one window's actionable UI elements, each tagged with an element_index for click/right_click/read_text. Re-observe after actions — indices belong to one snapshot. Requires the window's pid and window_id from list_windows.\n- **open_url**(url, app_id) [capability_expand category: desktop]: Open a URL in the default or specified browser\n- **read_text**(element_index) [capability_expand category: desktop]: Read the text content of a specific UI element from the latest snapshot\n- **right_click**(element_index) [capability_expand category: desktop]: Right-click a UI element to open its context menu. Returns visible menu items.\n- **screenshot**(pid, window_id) [capability_expand category: desktop]: Capture a screenshot for visual verification. With pid + window_id captures that window (works across all displays); defaults to the last observed window, or the full desktop when no window has been observed.\n- **scroll**(element_index, direction, amount, pid, window_id) [capability_expand category: desktop]: Scroll a scrollable area of a window. Omit element_index to scroll the window's focused/page scroller; pass one to scroll an exact element from the latest snapshot.\n- **select_text**(element_index) [capability_expand category: desktop]: Select all text in a UI element (focus + select-all, for subsequent copy)\n- **set_clipboard**(text) [capability_expand category: desktop]: Write text to the clipboard\n- **shortcut**(keys) [capability_expand category: desktop]: Execute a keyboard shortcut\n- **switch_app**(app_id) [capability_expand category: desktop]: Switch to an app (launch if needed, activate, verify)\n- **type_text**(text) [capability_expand category: desktop]: Type text into the currently focused element\n- **wait**(seconds) [capability_expand category: desktop]: Wait for a specified duration before continuing\n- **wait_until**(condition, pid, window_id, timeout, poll_interval) [capability_expand category: desktop]: Wait until a UI condition is met (polls UI tree). Returns elements when found or on timeout.\n- **wait_until_stable**(timeout, poll_interval, pid, window_id) [capability_expand category: desktop]: Wait until the UI stops changing (element set stabilizes across polls).\n\n## App Connector Capability Index\nLeapFlow can onboard and manage external apps through `platform_connect` and execute exact registered business actions through `platform_action`.\nFor requests about connecting, setting up, configuring, enabling, or managing a supported app, use `platform_connect` first instead of generating SDK/Webhook sample code.\n`platform_connect.action` is the management namespace: list, guide, preflight, connect, disconnect, remove, status, events_start, events_stop, events_status.\n`platform_action.action` is only for exact registered platform business actions listed below, such as `im.send_message`; never use management actions like `list` or `guide` there.\nAll business fields MUST go inside `payload`; top-level keys are only `platform`, `action`, and `payload`.\nDo not invent platform IDs or platform action names. If the needed action is not listed, ask for discovery/clarification instead of guessing.\nUse `platform_connect` with `action='guide'` and the matching `platform` to start onboarding; use `action='list'` when the app is unclear.\nWhen a pending onboarding state is present, continue from that state with `platform_connect` instead of asking the user to restate the app.\nSupported apps:\n- `api_server`: API Server (OpenAI Compatible) (category=api; backend=adapter; platform_action actions=none registered)\n- `dingtalk`: 钉钉 (DingTalk) (category=im; backend=adapter; platform_action actions=none registered)\n- `feishu`: 飞书 (Feishu/Lark) (category=im; backend=cli)\n - `calendar.create_event` payload={summary*, start_time*, end_time*, attendees} [write/high]\n - `docs.create_markdown` payload={title*, markdown*, folder_token} [write/medium]\n - `drive.search` payload={query*, limit} [read/medium]\n - `im.add_reaction` payload={message_id*, emoji_type*} [write/low]\n - `im.download_resource` payload={message_id*, file_key*, type*} [read/low]\n - `im.get_messages` payload={message_ids*} [read/low]\n - `im.list_chats` payload={page_size} [read/low]\n - `im.list_messages` payload={chat_id*} [read/low]\n - `im.list_thread_messages` payload={thread*} [read/low]\n - `im.remove_reaction` payload={message_id*, emoji_type*} [write/low]\n - `im.reply_message` payload={message_id*, text*} [send/high]\n - `im.search_chats` payload={query*, page_size} [read/low]\n - `im.search_messages` payload={query*} [read/medium]\n - `im.send_message` payload={chat_id*, text*, thread_id} [send/high]\n - `im.update_card` payload={token*, card*} [write/medium]\n - `im.update_message` payload={message_id*, text*} [write/medium]\n - `mail.search_unread` payload={query, limit} [read/high]\n - `sheets.append_row` payload={spreadsheet_token*, sheet_id*, values*} [write/medium]\n - `task.create` payload={title*, description, due_time} [write/medium]\n- `telegram`: Telegram (category=social; backend=adapter; platform_action actions=none registered)\n- `webhook`: Webhook (Generic) (category=webhook; backend=adapter; platform_action actions=none registered)\n\n## Tool Usage\nTools are normally invoked through the native function-calling mechanism, not by writing JSON in your reply\ntext. Only if the provider signals that native function calling is unavailable for this turn, fall back to a\nsingle JSON code block: `{\"name\": \"tool_name\", \"arguments\": {\"key\": \"value\"}}` — use this fallback format\nonly, never both. Only call a tool whose exact name appears in the tool index above and is currently callable\n(or reachable via `capability_expand`). Never invent, rename, alias, or guess a tool name, platform ID, or\nplatform action from argument shape or wording — if the index does not list it, it does not exist.\n`platform_connect.action` (list/guide/preflight/connect/disconnect/remove/status/events_start/events_stop/\nevents_status) is the App Connector management namespace; `platform_action.action` only accepts exact\nregistered business actions such as `im.send_message` shown in the App Connector Capability Index — never mix\nthe two namespaces. If a tool call returns an unknown/unavailable result, use the returned suggestions or\navailable names for a single retry instead of trying further variations of the same guess.\n\n**Side-effect action rule** (`platform_action` with effect=send/write/execute):\n- Call each unique action+payload **exactly once**. Never include duplicates in the same turn.\n- Once the result returns `\"completed\": true`, that action is DONE for this task. Do NOT call it again\n in any subsequent turn — immediately summarize the result for the user instead.\n- The system enforces idempotency: duplicate calls are blocked and will not execute.\n- If the user explicitly requests sending/writing multiple times, use distinct payloads per call.\n\n**Resource identifier provenance rule**:\n- NEVER fabricate, guess, or infer resource identifiers (chat_id, message_id, file_key, user_id, etc.).\n Every resource ID used in a side-effect action MUST come from a successful API response in this session.\n- If a read/list action fails (e.g. authorization error), you do NOT have valid resource IDs.\n Report the failure to the user — do NOT attempt the dependent write/send action with a guessed ID.\n- When a tool result contains `\"llm_instruction\"`, follow it exactly.\n\n## Guidelines\n1. **Direct answers first**: If you already know the answer, respond directly without tools.\n2. **Avoid redundant tool calls**: Do not call the same tool with the same arguments more than once in the same user turn. When an existing tool result already answers the user's request, stop calling tools and answer directly.\n3. **Use tools proactively**: When the user asks about files, time, system state, or needs actions performed, use the appropriate tool.\n4. **Chain tools when needed**: You can call multiple tools in sequence (e.g., list files → read file → summarize).\n5. **Handle failures gracefully**: If a tool fails, explain what went wrong and suggest alternatives. If it failed because of an unknown tool/platform/action name, retry once with an exact name from the returned suggestions, then explain rather than keep guessing.\n6. **Summarize results naturally**: After tool execution, synthesize the results into a helpful answer rather than dumping raw output.\n7. **Stay conversational**: Maintain a natural, helpful tone. Acknowledge context from earlier in the conversation.\n8. **Recall past work**:\n - Broad queries (\"之前做了什么\", \"列出任务\"): answer from the \"Recent Task History\" section already in your context. If insufficient, call session_list.\n - Specific lookups (\"上次那个配置怎么改的\"): call session_search with relevant phrases (NOT single characters).\n - Do NOT call search tools repeatedly with keyword fragments. One well-phrased call is better than ten fragmented ones.\n\n## Coding & Verification\nWhen working with code, prefer the precise built-in tools over ad-hoc shell: use `repo_map` to orient in an\nunfamiliar project, `code_search`/`file_find` to locate and `code_intel` for symbols, `edit_file` (anchored\nsearch-replace, or a unified `diff`) to change files — never rewrite a whole file to change a few lines — and\n`git_query` to inspect diffs/log. After edits, check `syntax_ok` in the result and run `test_run`/`lint_check`\nbefore declaring the work done. Batch independent read-only calls (search/read/query) into a single turn — they\nrun in parallel.\n\n## Presentation Style\n1. **Polished Markdown only**: Format user-facing answers with clean Markdown headings, short paragraphs, and concise bullets. Use tables only when they improve comparison or scanning.\n2. **Terminal-friendly layout**: Keep lines readable in a TUI; avoid dense walls of text, deeply nested lists, oversized ASCII art, or heavy visual blocks.\n3. **Elegant emphasis**: Use bold text sparingly for key terms and conclusions. Avoid excessive emojis, decorative symbols, repeated separators, or visual noise.\n4. **Theme-safe colors**: Do not emit ANSI escape codes, HTML color tags, Rich markup, or hardcoded color names. Rely on the TUI theme to render Markdown professionally.\n5. **No leaked tool protocol**: Never show tool-call JSON, internal schemas, raw observations, tool result payloads, or hidden reasoning in the final answer unless the user explicitly asks for raw/debug output. Treat any prior `{\"name\": ..., \"arguments\": ...}` blocks and `Tool result (...)` messages as internal execution context only.\n6. **Professional closure**: End with a concise conclusion or next step when helpful; avoid rambling after the useful answer is complete.\n\nWhen finished with all tool calls, respond normally without a JSON block, tool-call transcript, or process log.\n\n\n## Recent Session Summary\n- [assistant] Hello from LeapFlow.\n- [user] Use the file_read tool on invoice.txt and report the total.\n\n## Task Contract\n- Task ID: turn-2\n- Original user request: Use the file_read tool on invoice.txt and report the total.\n- Workspace root: \n- Allowed roots: \n- Treat relative project paths as relative to the workspace root; never infer `.` as the project root when a workspace root is provided.\n- Workspace boundary is enforced by tools: do not read, search, edit, or run commands against paths outside the allowed roots unless the user explicitly requests an external path and the tool/approval policy permits it.\n- LeapFlow workspace config is optional at `/.leapflow/config.yaml`; runtime config is loaded from `~/.leapflow/config/user.yaml` and `~/.leapflow/profiles//config/*.yaml`.\n- Preserve this task contract across summarization, compression, tool loops, and memory retrieval.\n" + }, + { + "role": "assistant", + "content": "Hello from LeapFlow." + }, + { + "role": "user", + "content": "Use the file_read tool on invoice.txt and report the total.\nUse the file_read tool on invoice.txt and report the total." + } + ], + "tools": [ + "capability_expand", + "code_intel", + "code_search", + "config_get", + "config_list", + "env_info", + "file_find", + "file_list", + "file_read", + "git_query", + "lint_check", + "memory_search", + "plugin_list", + "plugin_propose", + "plugin_status", + "plugin_versions", + "repo_map", + "research_note", + "schedule_reentry", + "session_list", + "skill_view", + "skills_list", + "terminal_list", + "terminal_read", + "test_run", + "text_search", + "time_get", + "web_fetch" + ] + }, + "responses": [ + { + "status": 200, + "content_type": "application/json", + "body": "{\"id\": \"chatcmpl-cassette\", \"object\": \"chat.completion\", \"model\": \"cassette-model\", \"choices\": [{\"index\": 0, \"message\": {\"role\": \"assistant\", \"content\": \"\", \"tool_calls\": [{\"id\": \"call_1\", \"type\": \"function\", \"function\": {\"name\": \"file_read\", \"arguments\": \"{\\\"path\\\": \\\"invoice.txt\\\"}\"}}]}, \"finish_reason\": \"tool_calls\"}], \"usage\": {\"prompt_tokens\": 64, \"completion_tokens\": 16, \"total_tokens\": 80}}" + } + ] +} diff --git a/tests/_fixtures/cassettes/r1_conversation/cassette-model-e79beeea35840f83.cassette.json b/tests/_fixtures/cassettes/r1_conversation/cassette-model-e79beeea35840f83.cassette.json new file mode 100644 index 0000000..af924e0 --- /dev/null +++ b/tests/_fixtures/cassettes/r1_conversation/cassette-model-e79beeea35840f83.cassette.json @@ -0,0 +1,75 @@ +{ + "fingerprint": "e79beeea35840f8386d0c680fdcd24bde9f80786d8fdb5674c93ea302f04604c", + "note": "captured in seed mode", + "request": { + "model": "cassette-model", + "stream": false, + "messages": [ + { + "role": "system", + "content": "You are LeapFlow, an intelligent assistant that can both converse naturally and take real actions on the user's computer.\n\n## Capabilities\nThe tool index below lists **every** registered tool by name and a one-line summary — this is the complete\ncapability contract; nothing else exists. Only a subset is directly callable this turn (via native tool calling,\nnot a JSON block in your reply). If you need a tool from the index that is not yet callable, call\n`capability_expand` with its category name first — the matching tools become callable immediately after.\nWhen the user asks what LeapFlow itself supports, whether it supports plugins, or which runtime capabilities\nare available, use the live capability evidence exposed by `plugin_list` before making capability claims; report\nconfiguration-dependent or unavailable capabilities as limitations instead of inferring from documentation.\n- **text_search**(text, pattern): Search for a regex pattern in text.\n- **text_replace**(text, old, new, count) [capability_expand category: write]: Replace occurrences of a substring in text.\n- **time_get**(): Get current date and time.\n- **env_info**(): Get system environment information (OS, Python version, cwd).\n- **skills_list**(query, category, source): List available learned skills. Use when user asks about capabilities or you need a specific skill.\n- **skill_view**(name): View the full content of a specific skill document.\n- **code_intel**(path, operation): Precise document symbols (outline) for a source file: classes, functions, and methods with line ranges. Python uses an exact AST parse; other languages use a keyword-prefix scan. Prefer over file_read mode=symbols for accurate navigation before editing. Read-only.\n- **repo_map**(path): Compact project orientation for a repository root: languages, detected test/lint commands, top-level structure, entry points, manifest, and VCS branch. Call this first when entering an unfamiliar codebase. Read-only.\n- **scm_sync**(action, cwd, remote, pull_ref, push_ref, timeout) [capability_expand category: scm]: Run a typed git SCM action. Use this instead of shell_run for git pull/push/status. For 'pull origin main then push', set action='pull_then_push', remote='origin', pull_ref='main', and omit push_ref so LeapFlow pushes the current local branch.\n- **git_query**(action, cwd, ref, path, staged, max_count, stat): Read-only structured git inspection: action=diff|log|status|branch|show. Prefer over shell_run for reading repo state — output is clipped, redacted, and log/branch are parsed into structured fields. Use scm_sync for pull/push.\n- **git_write**(action, cwd, message, stage_all, name, ref, create) [capability_expand category: scm]: Mutating git actions: action=commit (message, stage_all), branch (create+switch), checkout (switch; create=true for -b). Approval-gated. Use scm_sync for pull/push and git_query for reads.\n- **test_run**(command, cwd, timeout): Run the project's test suite and return structured results (framework, passed/failed counts, failing tests). Auto-detects the runner (pytest/npm/go/cargo) or uses a configured/explicit command; executes via the governed shell. ok=true means the runner executed — see 'success' for pass/fail.\n- **lint_check**(command, cwd, timeout): Run the project's linter and return a structured clean/issue result. Auto-detects the linter (ruff/eslint/go vet/clippy) or uses a configured/explicit command; executes via the governed shell. ok=true means the linter ran — see 'clean'.\n- **file_list**(path, pattern, depth): List files and directories at a given path. Use depth=1 or depth=2 to get a recursive tree in one call instead of listing each sub-directory separately.\n- **file_read**(path, max_lines, start_line, max_chars, mode): Read text file content with adaptive context governance. For large or unfamiliar files, prefer mode='outline' or mode='symbols' first, then use mode='raw' with start_line/max_lines for the specific range you actually need. For LeapFlow's own settings, use config_list / config_get / config_set — its config files are outside the workspace and not readable here.\n- **file_write**(path, content, mode) [capability_expand category: write]: Write content to a file (overwrite or append).\n- **code_search**(pattern, patterns, path, glob, ignore_case, multiline, max_results, context_lines): Search file CONTENTS by regex pattern across a directory tree (ripgrep-backed). Requires a regex pattern. NOT for listing or browsing directory contents — use file_list for that. Prefer this over shell_run grep: faster, skips VCS/dependency/build dirs, and returns structured path:line:column matches. Batch related lookups into ONE call via `patterns` (OR-combined, single pass) instead of issuing several separate searches. Use file_read for the surrounding context of a hit.\n- **file_find**(glob, path, max_results): Find files by a recursive glob pattern under a base path (e.g. '**/test_*.py' or '*.md'). Prefer this over shell_run find; skips VCS/dependency/build dirs.\n- **edit_file**(path, edits, dry_run, diff) [capability_expand category: write]: Apply targeted, anchored search-replace edits to an EXISTING text file (use file_write to create/overwrite). Each edit is {original_text, new_text, replace_all?}; original_text must match exactly and uniquely (or set replace_all) — a non-unique or missing anchor is rejected so files are never corrupted. Set dry_run to preview. Alternatively pass a unified 'diff' to apply its hunks as anchored edits. Far cheaper and safer than rewriting a whole file.\n- **shell_run**(command, cwd, timeout) [capability_expand category: shell]: Execute a one-shot shell command with timeout protection. Runs in the active workspace; paths resolving outside it are refused. Reach for a structured tool first when one fits — web_fetch for anything over HTTP(S), git_query/scm_sync for git, code_search/file_find/file_read for the repo, config_get/config_set for LeapFlow's own settings — because those report typed results, while a failed shell command can only be diagnosed from its exit code and stderr. Every shell run counts as an external side effect, so a failure stops the rest of the batch and is not retried automatically.\n- **terminal_open**(command, cwd, shell) [capability_expand category: terminal]: Open a PERSISTENT shell session (REPL/dev server/watch), returning a session_id for terminal_send/read/close. Disabled unless tools.terminal_session_enabled is set. For one-shot commands use shell_run instead.\n- **terminal_send**(session_id, input, wait) [capability_expand category: terminal]: Send a line of input to a persistent terminal session and return output captured shortly after.\n- **terminal_read**(session_id, wait): Drain buffered output from a persistent terminal session (optionally waiting briefly first).\n- **terminal_close**(session_id) [capability_expand category: terminal]: Terminate a persistent terminal session and release its process group.\n- **terminal_list**(): List active persistent terminal sessions.\n- **config_list**(category, limit): List LeapFlow's own writable settings (model, provider, daemon, memory, perception, gateway, …) with current values. Use this to discover the exact key before changing anything. Optionally narrow by `category`. This is the only correct way to inspect LeapFlow configuration — never read config files from disk.\n- **config_get**(key): Read one LeapFlow setting by key (e.g. 'llm.model', 'daemon.log_level'), returning its current value, type, scopes, and whether a change needs a daemon restart. Never read LeapFlow config files from disk — use this.\n- **config_set**(key, value, scope) [capability_expand category: config]: Change one LeapFlow setting by key, e.g. switch the model with key='llm.model'. Values are validated and coerced; credentials are stored in the vault automatically. Call config_list or config_get first if unsure of the key. The result states whether a `leap daemon restart` is required. Never edit LeapFlow config files directly.\n- **web_fetch**(url, select, timeout, max_bytes): Read a URL over HTTP(S) and get back extracted, context-sized content: parsed JSON for API endpoints, readable text plus links for web pages. Use this for anything on the internet — prices, docs, releases, articles — instead of running curl through shell_run: it reports real HTTP status codes, retries rate limits on its own, and is a plain read so a retry is always safe. For JSON APIs pass `select` with a dotted path (e.g. 'chart.result.0.meta') to return just that part instead of the whole payload.\n- **memory_search**(query, limit): Search agent memory for relevant past experiences, observations, and facts.\n- **memory_add**(content, kind) [capability_expand category: write]: Store a new observation or insight in memory for future reference.\n- **research_note**(kind, text): Record a compact, structured note about the current task's state so it survives context compression on long / multi-step tasks. Use for durable findings, open questions still to resolve, decisions / excluded paths, and the immediate next step. One concise sentence per note.\n- **capability_expand**(category): Fetch the full callable schema for every tool in a capability category (e.g. 'hub', 'gateway', 'desktop', '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.\n- **delegate_task**(goal, context) [capability_expand category: delegate]: Delegate a complex sub-task to an isolated subagent. The subagent gets a fresh context and restricted tool access. Use when a task is self-contained and can be solved independently.\n- **schedule_reentry**(kind, reason, delay_seconds, event_match, max_reentries, deadline_seconds): Register a re-entry so this task can resume later from its current orientation (findings / open questions / next step). Use when work must pause and continue after a delay (kind=time) or when a matching platform event arrives (kind=event), instead of finishing now. The research-ledger state is carried over automatically.\n- **hub_push**(skill_name, visibility, version) [capability_expand category: hub]: Push a local skill to the ModelScope Hub for sharing or backup.\n- **hub_pull**(repo_id, version) [capability_expand category: hub]: Pull a skill from the ModelScope Hub to install locally.\n- **hub_search**(query) [capability_expand category: hub]: Search for skills on the Hub by keyword or description.\n- **hub_sync**(mode, dry_run) [capability_expand category: hub]: Preview or execute sync between local skills and Hub.\n- **platform_action**(platform, action, payload, backend_kind) [capability_expand category: gateway]: Execute an exact registered business action on an external platform through LeapFlow's App Connector layer. Actions must be copied from the App Connector Capability Index and are addressed as domain.operation, e.g. im.send_message or docs.create_markdown. All business fields (chat_id, text, query, etc.) MUST be placed inside `payload`, never at the top level. Example: {\"platform\":\"feishu\",\"action\":\"im.send_message\",\"payload\":{\"chat_id\":\"oc_xxx\",\"text\":\"hello\"}}. Do not invent action names, do not use management actions such as list/guide/connect/status here.\n- **platform_connect**(action, platform, credentials, options, checkpoint) [capability_expand category: gateway]: List, guide, connect, disconnect, remove, or check status for external platforms using the App Connector management namespace. Supports REST and CLI backends. Use this for management actions such as list/guide/preflight/connect/status; use platform_action only for exact registered business actions.\n- **gateway_send**(platform, chat_id, text, thread_id) [capability_expand category: gateway]: Send a message to a connected external platform (Feishu group, Telegram chat, DingTalk conversation, etc.). Requires the platform to be connected via gateway_connect first. Use gateway_connect with action='list' to see connected platforms and available chat IDs.\n- **gateway_connect**(action, platform, credentials, options) [capability_expand category: gateway]: Connect, configure, or manage external platform integrations (Feishu, DingTalk, Telegram, Slack, Discord, etc.). Conversational flow: 1) call 'guide' to get setup steps + required fields, 2) present the steps to the user and ask for ALL required credentials in a single message, 3) call 'connect' with the credentials. Goal: complete in 1–2 user turns. NEVER include credential values in your text response.\n- **plugin_list**(): List the live plugin registry and cross-subsystem capability evidence. Use this before answering questions about whether LeapFlow supports plugins, self-evolution, plugin installation, hot reload, versioning, or other runtime capabilities.\n- **plugin_status**(plugin_id): Get detailed status of a specific plugin: its declared category, runtime dependencies, contributed tools, and fiber lifecycle state.\n- **plugin_versions**(plugin_id): List recorded source versions and active pointer for a profile-scoped plugin.\n- **plugin_propose**(requested_capability, plugin_id, proposed_tools, test_cases, risk_level, evidence): Create a side-effect-free PluginProposal from explicit capability-gap evidence. Use this before plugin_generate when a missing capability should be reviewed. Does not call an LLM, write files, or install anything.\n- **assess_compatibility**(manifest, source_path) [capability_expand category: plugin_management]: Assess whether a foreign plugin manifest or real DSH source bundle is compatible with LeapFlow. A source bundle assessment is static: runtime_ready stays false until restricted Node discovery during plugin_install. Returns component-level verdicts and limitations.\n- **plugin_generate**(plugin_id, description, proposal_id) [capability_expand category: system]: Generate a new ToolPlugin from a natural-language capability description. The LLM produces code that conforms to the ToolPlugin Protocol; it is then rigorously validated (syntax, structure, import, protocol conformance). The isolated sandbox smoke test runs later, at install-time. Returns the validated code but DOES NOT install it — installation is a separate approval-gated step via plugin_install.\n- **plugin_install**(plugin_id, code, marketplace_name, source_path, proposal_id, version_label) [capability_expand category: system]: Install a plugin from exactly one source: validated Python code, the configured Python marketplace, or a real DSH source bundle. DSH bundles run restricted Node discovery before registration; only public host tools are installed and client UI limitations are reported. Writes profile-scoped state and mutates the process-global registry. REQUIRES APPROVAL.\n- **plugin_rollback**(plugin_id, version) [capability_expand category: system]: Rollback a profile-scoped plugin to a recorded source version and reload it. REQUIRES APPROVAL.\n- **plugin_reload**(plugin_id, version_label) [capability_expand category: system]: Hot-reload a plugin at runtime. Disposes the old plugin fiber, re-imports its module, and registers a fresh instance. Existing in-flight turns are unaffected (snapshot isolation). REQUIRES APPROVAL — this is a self-modification action.\n- **plugin_disable**(plugin_id) [capability_expand category: system]: Disable a plugin by disposing its fiber, removing its tools from the runtime registry. Cannot disable self_management itself. REQUIRES APPROVAL — this is a self-modification action.\n- **plugin_remove**(plugin_id, delete_source) [capability_expand category: system]: Terminally remove a plugin: dispose its fiber, unregister its tools, remove reload metadata, and optionally delete its profile-scoped source file. Cannot remove self_management itself. REQUIRES APPROVAL.\n- **plugin_enable**(plugin_id) [capability_expand category: system]: Re-enable a previously disabled plugin by reloading its module and registering a fresh instance. REQUIRES APPROVAL.\n- **session_search**(query, limit) [capability_expand category: unclassified]: Search past conversation sessions in the current workspace for relevant context.\n- **session_list**(limit): List recent conversation sessions in the current workspace with stable ids, titles, dates, and summaries. Use for browsing past tasks or when user asks to see history without specific search terms. No keywords needed — returns chronological list.\n- **session_detail**(session_id, limit, offset, include_inactive) [capability_expand category: unclassified]: Read a paginated persisted transcript for one past conversation session in the current workspace. Use after session_list or session_search returns a session_id.\n- **click**(element_index) [capability_expand category: desktop]: Click a UI element by its element_index (from the latest observe_ui snapshot)\n- **get_clipboard**() [capability_expand category: desktop]: Read current clipboard text content\n- **list_apps**(filter, running_only) [capability_expand category: desktop]: List available applications on this system. Use to discover correct bundle_id before switch_app.\n- **list_windows**() [capability_expand category: desktop]: List all top-level windows with pid, window_id, title, and per-window state (minimized, on-screen). Call this first to pick the pid and window_id that observe_ui and other window tools require.\n- **observe_ui**(pid, window_id, query) [capability_expand category: desktop]: Snapshot one window's actionable UI elements, each tagged with an element_index for click/right_click/read_text. Re-observe after actions — indices belong to one snapshot. Requires the window's pid and window_id from list_windows.\n- **open_url**(url, app_id) [capability_expand category: desktop]: Open a URL in the default or specified browser\n- **read_text**(element_index) [capability_expand category: desktop]: Read the text content of a specific UI element from the latest snapshot\n- **right_click**(element_index) [capability_expand category: desktop]: Right-click a UI element to open its context menu. Returns visible menu items.\n- **screenshot**(pid, window_id) [capability_expand category: desktop]: Capture a screenshot for visual verification. With pid + window_id captures that window (works across all displays); defaults to the last observed window, or the full desktop when no window has been observed.\n- **scroll**(element_index, direction, amount, pid, window_id) [capability_expand category: desktop]: Scroll a scrollable area of a window. Omit element_index to scroll the window's focused/page scroller; pass one to scroll an exact element from the latest snapshot.\n- **select_text**(element_index) [capability_expand category: desktop]: Select all text in a UI element (focus + select-all, for subsequent copy)\n- **set_clipboard**(text) [capability_expand category: desktop]: Write text to the clipboard\n- **shortcut**(keys) [capability_expand category: desktop]: Execute a keyboard shortcut\n- **switch_app**(app_id) [capability_expand category: desktop]: Switch to an app (launch if needed, activate, verify)\n- **type_text**(text) [capability_expand category: desktop]: Type text into the currently focused element\n- **wait**(seconds) [capability_expand category: desktop]: Wait for a specified duration before continuing\n- **wait_until**(condition, pid, window_id, timeout, poll_interval) [capability_expand category: desktop]: Wait until a UI condition is met (polls UI tree). Returns elements when found or on timeout.\n- **wait_until_stable**(timeout, poll_interval, pid, window_id) [capability_expand category: desktop]: Wait until the UI stops changing (element set stabilizes across polls).\n\n## App Connector Capability Index\nLeapFlow can onboard and manage external apps through `platform_connect` and execute exact registered business actions through `platform_action`.\nFor requests about connecting, setting up, configuring, enabling, or managing a supported app, use `platform_connect` first instead of generating SDK/Webhook sample code.\n`platform_connect.action` is the management namespace: list, guide, preflight, connect, disconnect, remove, status, events_start, events_stop, events_status.\n`platform_action.action` is only for exact registered platform business actions listed below, such as `im.send_message`; never use management actions like `list` or `guide` there.\nAll business fields MUST go inside `payload`; top-level keys are only `platform`, `action`, and `payload`.\nDo not invent platform IDs or platform action names. If the needed action is not listed, ask for discovery/clarification instead of guessing.\nUse `platform_connect` with `action='guide'` and the matching `platform` to start onboarding; use `action='list'` when the app is unclear.\nWhen a pending onboarding state is present, continue from that state with `platform_connect` instead of asking the user to restate the app.\nSupported apps:\n- `api_server`: API Server (OpenAI Compatible) (category=api; backend=adapter; platform_action actions=none registered)\n- `dingtalk`: 钉钉 (DingTalk) (category=im; backend=adapter; platform_action actions=none registered)\n- `feishu`: 飞书 (Feishu/Lark) (category=im; backend=cli)\n - `calendar.create_event` payload={summary*, start_time*, end_time*, attendees} [write/high]\n - `docs.create_markdown` payload={title*, markdown*, folder_token} [write/medium]\n - `drive.search` payload={query*, limit} [read/medium]\n - `im.add_reaction` payload={message_id*, emoji_type*} [write/low]\n - `im.download_resource` payload={message_id*, file_key*, type*} [read/low]\n - `im.get_messages` payload={message_ids*} [read/low]\n - `im.list_chats` payload={page_size} [read/low]\n - `im.list_messages` payload={chat_id*} [read/low]\n - `im.list_thread_messages` payload={thread*} [read/low]\n - `im.remove_reaction` payload={message_id*, emoji_type*} [write/low]\n - `im.reply_message` payload={message_id*, text*} [send/high]\n - `im.search_chats` payload={query*, page_size} [read/low]\n - `im.search_messages` payload={query*} [read/medium]\n - `im.send_message` payload={chat_id*, text*, thread_id} [send/high]\n - `im.update_card` payload={token*, card*} [write/medium]\n - `im.update_message` payload={message_id*, text*} [write/medium]\n - `mail.search_unread` payload={query, limit} [read/high]\n - `sheets.append_row` payload={spreadsheet_token*, sheet_id*, values*} [write/medium]\n - `task.create` payload={title*, description, due_time} [write/medium]\n- `telegram`: Telegram (category=social; backend=adapter; platform_action actions=none registered)\n- `webhook`: Webhook (Generic) (category=webhook; backend=adapter; platform_action actions=none registered)\n\n## Tool Usage\nTools are normally invoked through the native function-calling mechanism, not by writing JSON in your reply\ntext. Only if the provider signals that native function calling is unavailable for this turn, fall back to a\nsingle JSON code block: `{\"name\": \"tool_name\", \"arguments\": {\"key\": \"value\"}}` — use this fallback format\nonly, never both. Only call a tool whose exact name appears in the tool index above and is currently callable\n(or reachable via `capability_expand`). Never invent, rename, alias, or guess a tool name, platform ID, or\nplatform action from argument shape or wording — if the index does not list it, it does not exist.\n`platform_connect.action` (list/guide/preflight/connect/disconnect/remove/status/events_start/events_stop/\nevents_status) is the App Connector management namespace; `platform_action.action` only accepts exact\nregistered business actions such as `im.send_message` shown in the App Connector Capability Index — never mix\nthe two namespaces. If a tool call returns an unknown/unavailable result, use the returned suggestions or\navailable names for a single retry instead of trying further variations of the same guess.\n\n**Side-effect action rule** (`platform_action` with effect=send/write/execute):\n- Call each unique action+payload **exactly once**. Never include duplicates in the same turn.\n- Once the result returns `\"completed\": true`, that action is DONE for this task. Do NOT call it again\n in any subsequent turn — immediately summarize the result for the user instead.\n- The system enforces idempotency: duplicate calls are blocked and will not execute.\n- If the user explicitly requests sending/writing multiple times, use distinct payloads per call.\n\n**Resource identifier provenance rule**:\n- NEVER fabricate, guess, or infer resource identifiers (chat_id, message_id, file_key, user_id, etc.).\n Every resource ID used in a side-effect action MUST come from a successful API response in this session.\n- If a read/list action fails (e.g. authorization error), you do NOT have valid resource IDs.\n Report the failure to the user — do NOT attempt the dependent write/send action with a guessed ID.\n- When a tool result contains `\"llm_instruction\"`, follow it exactly.\n\n## Guidelines\n1. **Direct answers first**: If you already know the answer, respond directly without tools.\n2. **Avoid redundant tool calls**: Do not call the same tool with the same arguments more than once in the same user turn. When an existing tool result already answers the user's request, stop calling tools and answer directly.\n3. **Use tools proactively**: When the user asks about files, time, system state, or needs actions performed, use the appropriate tool.\n4. **Chain tools when needed**: You can call multiple tools in sequence (e.g., list files → read file → summarize).\n5. **Handle failures gracefully**: If a tool fails, explain what went wrong and suggest alternatives. If it failed because of an unknown tool/platform/action name, retry once with an exact name from the returned suggestions, then explain rather than keep guessing.\n6. **Summarize results naturally**: After tool execution, synthesize the results into a helpful answer rather than dumping raw output.\n7. **Stay conversational**: Maintain a natural, helpful tone. Acknowledge context from earlier in the conversation.\n8. **Recall past work**:\n - Broad queries (\"之前做了什么\", \"列出任务\"): answer from the \"Recent Task History\" section already in your context. If insufficient, call session_list.\n - Specific lookups (\"上次那个配置怎么改的\"): call session_search with relevant phrases (NOT single characters).\n - Do NOT call search tools repeatedly with keyword fragments. One well-phrased call is better than ten fragmented ones.\n\n## Coding & Verification\nWhen working with code, prefer the precise built-in tools over ad-hoc shell: use `repo_map` to orient in an\nunfamiliar project, `code_search`/`file_find` to locate and `code_intel` for symbols, `edit_file` (anchored\nsearch-replace, or a unified `diff`) to change files — never rewrite a whole file to change a few lines — and\n`git_query` to inspect diffs/log. After edits, check `syntax_ok` in the result and run `test_run`/`lint_check`\nbefore declaring the work done. Batch independent read-only calls (search/read/query) into a single turn — they\nrun in parallel.\n\n## Presentation Style\n1. **Polished Markdown only**: Format user-facing answers with clean Markdown headings, short paragraphs, and concise bullets. Use tables only when they improve comparison or scanning.\n2. **Terminal-friendly layout**: Keep lines readable in a TUI; avoid dense walls of text, deeply nested lists, oversized ASCII art, or heavy visual blocks.\n3. **Elegant emphasis**: Use bold text sparingly for key terms and conclusions. Avoid excessive emojis, decorative symbols, repeated separators, or visual noise.\n4. **Theme-safe colors**: Do not emit ANSI escape codes, HTML color tags, Rich markup, or hardcoded color names. Rely on the TUI theme to render Markdown professionally.\n5. **No leaked tool protocol**: Never show tool-call JSON, internal schemas, raw observations, tool result payloads, or hidden reasoning in the final answer unless the user explicitly asks for raw/debug output. Treat any prior `{\"name\": ..., \"arguments\": ...}` blocks and `Tool result (...)` messages as internal execution context only.\n6. **Professional closure**: End with a concise conclusion or next step when helpful; avoid rambling after the useful answer is complete.\n\nWhen finished with all tool calls, respond normally without a JSON block, tool-call transcript, or process log.\n\n\n## Recent Session Summary\n- [assistant] Hello from LeapFlow.\n- [user] Use the file_read tool on invoice.txt and report the total.\n\n## Task Contract\n- Task ID: turn-2\n- Original user request: Use the file_read tool on invoice.txt and report the total.\n- Workspace root: \n- Allowed roots: \n- Treat relative project paths as relative to the workspace root; never infer `.` as the project root when a workspace root is provided.\n- Workspace boundary is enforced by tools: do not read, search, edit, or run commands against paths outside the allowed roots unless the user explicitly requests an external path and the tool/approval policy permits it.\n- LeapFlow workspace config is optional at `/.leapflow/config.yaml`; runtime config is loaded from `~/.leapflow/config/user.yaml` and `~/.leapflow/profiles//config/*.yaml`.\n- Preserve this task contract across summarization, compression, tool loops, and memory retrieval.\n" + }, + { + "role": "assistant", + "content": "Hello from LeapFlow." + }, + { + "role": "user", + "content": "Use the file_read tool on invoice.txt and report the total.\nUse the file_read tool on invoice.txt and report the total." + }, + { + "role": "assistant", + "content": "", + "tool_calls": [ + "file_read" + ] + }, + { + "role": "tool", + "content": "{\"ok\": true, \"kind\": \"file_read_evidence\", \"path\": \"\", \"lines\": 2, \"truncated\": false, \"mode\": \"raw\", \"excerpt\": \"Invoice 42\\nTotal: 128.50 USD\", \"start_line\": 1, \"end_line\": 2, \"selected_lines\": 2}", + "tool_result": true + }, + { + "role": "assistant", + "content": "Operation interrupted. Continuing..." + } + ], + "tools": [ + "capability_expand", + "code_intel", + "code_search", + "config_get", + "config_list", + "env_info", + "file_find", + "file_list", + "file_read", + "git_query", + "lint_check", + "memory_search", + "plugin_list", + "plugin_propose", + "plugin_status", + "plugin_versions", + "repo_map", + "research_note", + "schedule_reentry", + "session_list", + "skill_view", + "skills_list", + "terminal_list", + "terminal_read", + "test_run", + "text_search", + "time_get", + "web_fetch" + ] + }, + "responses": [ + { + "status": 200, + "content_type": "application/json", + "body": "{\"id\": \"chatcmpl-cassette\", \"object\": \"chat.completion\", \"model\": \"cassette-model\", \"choices\": [{\"index\": 0, \"message\": {\"role\": \"assistant\", \"content\": \"The invoice total is 128.50 USD.\"}, \"finish_reason\": \"stop\"}], \"usage\": {\"prompt_tokens\": 64, \"completion_tokens\": 16, \"total_tokens\": 80}}" + } + ] +} diff --git a/tests/_fixtures/cassettes/r1_conversation/cassette-model-eb4859b1818bc0e1.cassette.json b/tests/_fixtures/cassettes/r1_conversation/cassette-model-eb4859b1818bc0e1.cassette.json new file mode 100644 index 0000000..98a0147 --- /dev/null +++ b/tests/_fixtures/cassettes/r1_conversation/cassette-model-eb4859b1818bc0e1.cassette.json @@ -0,0 +1,59 @@ +{ + "fingerprint": "eb4859b1818bc0e11fd276cce9f6882dd6bead1bc5bffaa24301b2d091c7549c", + "note": "captured in seed mode", + "request": { + "model": "cassette-model", + "stream": false, + "messages": [ + { + "role": "system", + "content": "You are LeapFlow, an intelligent assistant that can both converse naturally and take real actions on the user's computer.\n\n## Capabilities\nThe tool index below lists **every** registered tool by name and a one-line summary — this is the complete\ncapability contract; nothing else exists. Only a subset is directly callable this turn (via native tool calling,\nnot a JSON block in your reply). If you need a tool from the index that is not yet callable, call\n`capability_expand` with its category name first — the matching tools become callable immediately after.\nWhen the user asks what LeapFlow itself supports, whether it supports plugins, or which runtime capabilities\nare available, use the live capability evidence exposed by `plugin_list` before making capability claims; report\nconfiguration-dependent or unavailable capabilities as limitations instead of inferring from documentation.\n- **text_search**(text, pattern): Search for a regex pattern in text.\n- **text_replace**(text, old, new, count) [capability_expand category: write]: Replace occurrences of a substring in text.\n- **time_get**(): Get current date and time.\n- **env_info**(): Get system environment information (OS, Python version, cwd).\n- **skills_list**(query, category, source): List available learned skills. Use when user asks about capabilities or you need a specific skill.\n- **skill_view**(name): View the full content of a specific skill document.\n- **code_intel**(path, operation): Precise document symbols (outline) for a source file: classes, functions, and methods with line ranges. Python uses an exact AST parse; other languages use a keyword-prefix scan. Prefer over file_read mode=symbols for accurate navigation before editing. Read-only.\n- **repo_map**(path): Compact project orientation for a repository root: languages, detected test/lint commands, top-level structure, entry points, manifest, and VCS branch. Call this first when entering an unfamiliar codebase. Read-only.\n- **scm_sync**(action, cwd, remote, pull_ref, push_ref, timeout) [capability_expand category: scm]: Run a typed git SCM action. Use this instead of shell_run for git pull/push/status. For 'pull origin main then push', set action='pull_then_push', remote='origin', pull_ref='main', and omit push_ref so LeapFlow pushes the current local branch.\n- **git_query**(action, cwd, ref, path, staged, max_count, stat): Read-only structured git inspection: action=diff|log|status|branch|show. Prefer over shell_run for reading repo state — output is clipped, redacted, and log/branch are parsed into structured fields. Use scm_sync for pull/push.\n- **git_write**(action, cwd, message, stage_all, name, ref, create) [capability_expand category: scm]: Mutating git actions: action=commit (message, stage_all), branch (create+switch), checkout (switch; create=true for -b). Approval-gated. Use scm_sync for pull/push and git_query for reads.\n- **test_run**(command, cwd, timeout): Run the project's test suite and return structured results (framework, passed/failed counts, failing tests). Auto-detects the runner (pytest/npm/go/cargo) or uses a configured/explicit command; executes via the governed shell. ok=true means the runner executed — see 'success' for pass/fail.\n- **lint_check**(command, cwd, timeout): Run the project's linter and return a structured clean/issue result. Auto-detects the linter (ruff/eslint/go vet/clippy) or uses a configured/explicit command; executes via the governed shell. ok=true means the linter ran — see 'clean'.\n- **file_list**(path, pattern, depth): List files and directories at a given path. Use depth=1 or depth=2 to get a recursive tree in one call instead of listing each sub-directory separately.\n- **file_read**(path, max_lines, start_line, max_chars, mode): Read text file content with adaptive context governance. For large or unfamiliar files, prefer mode='outline' or mode='symbols' first, then use mode='raw' with start_line/max_lines for the specific range you actually need. For LeapFlow's own settings, use config_list / config_get / config_set — its config files are outside the workspace and not readable here.\n- **file_write**(path, content, mode) [capability_expand category: write]: Write content to a file (overwrite or append).\n- **code_search**(pattern, patterns, path, glob, ignore_case, multiline, max_results, context_lines): Search file CONTENTS by regex pattern across a directory tree (ripgrep-backed). Requires a regex pattern. NOT for listing or browsing directory contents — use file_list for that. Prefer this over shell_run grep: faster, skips VCS/dependency/build dirs, and returns structured path:line:column matches. Batch related lookups into ONE call via `patterns` (OR-combined, single pass) instead of issuing several separate searches. Use file_read for the surrounding context of a hit.\n- **file_find**(glob, path, max_results): Find files by a recursive glob pattern under a base path (e.g. '**/test_*.py' or '*.md'). Prefer this over shell_run find; skips VCS/dependency/build dirs.\n- **edit_file**(path, edits, dry_run, diff) [capability_expand category: write]: Apply targeted, anchored search-replace edits to an EXISTING text file (use file_write to create/overwrite). Each edit is {original_text, new_text, replace_all?}; original_text must match exactly and uniquely (or set replace_all) — a non-unique or missing anchor is rejected so files are never corrupted. Set dry_run to preview. Alternatively pass a unified 'diff' to apply its hunks as anchored edits. Far cheaper and safer than rewriting a whole file.\n- **shell_run**(command, cwd, timeout) [capability_expand category: shell]: Execute a one-shot shell command with timeout protection. Runs in the active workspace; paths resolving outside it are refused. Reach for a structured tool first when one fits — web_fetch for anything over HTTP(S), git_query/scm_sync for git, code_search/file_find/file_read for the repo, config_get/config_set for LeapFlow's own settings — because those report typed results, while a failed shell command can only be diagnosed from its exit code and stderr. Every shell run counts as an external side effect, so a failure stops the rest of the batch and is not retried automatically.\n- **terminal_open**(command, cwd, shell) [capability_expand category: terminal]: Open a PERSISTENT shell session (REPL/dev server/watch), returning a session_id for terminal_send/read/close. Disabled unless tools.terminal_session_enabled is set. For one-shot commands use shell_run instead.\n- **terminal_send**(session_id, input, wait) [capability_expand category: terminal]: Send a line of input to a persistent terminal session and return output captured shortly after.\n- **terminal_read**(session_id, wait): Drain buffered output from a persistent terminal session (optionally waiting briefly first).\n- **terminal_close**(session_id) [capability_expand category: terminal]: Terminate a persistent terminal session and release its process group.\n- **terminal_list**(): List active persistent terminal sessions.\n- **config_list**(category, limit): List LeapFlow's own writable settings (model, provider, daemon, memory, perception, gateway, …) with current values. Use this to discover the exact key before changing anything. Optionally narrow by `category`. This is the only correct way to inspect LeapFlow configuration — never read config files from disk.\n- **config_get**(key): Read one LeapFlow setting by key (e.g. 'llm.model', 'daemon.log_level'), returning its current value, type, scopes, and whether a change needs a daemon restart. Never read LeapFlow config files from disk — use this.\n- **config_set**(key, value, scope) [capability_expand category: config]: Change one LeapFlow setting by key, e.g. switch the model with key='llm.model'. Values are validated and coerced; credentials are stored in the vault automatically. Call config_list or config_get first if unsure of the key. The result states whether a `leap daemon restart` is required. Never edit LeapFlow config files directly.\n- **web_fetch**(url, select, timeout, max_bytes): Read a URL over HTTP(S) and get back extracted, context-sized content: parsed JSON for API endpoints, readable text plus links for web pages. Use this for anything on the internet — prices, docs, releases, articles — instead of running curl through shell_run: it reports real HTTP status codes, retries rate limits on its own, and is a plain read so a retry is always safe. For JSON APIs pass `select` with a dotted path (e.g. 'chart.result.0.meta') to return just that part instead of the whole payload.\n- **memory_search**(query, limit): Search agent memory for relevant past experiences, observations, and facts.\n- **memory_add**(content, kind) [capability_expand category: write]: Store a new observation or insight in memory for future reference.\n- **research_note**(kind, text): Record a compact, structured note about the current task's state so it survives context compression on long / multi-step tasks. Use for durable findings, open questions still to resolve, decisions / excluded paths, and the immediate next step. One concise sentence per note.\n- **capability_expand**(category): Fetch the full callable schema for every tool in a capability category (e.g. 'hub', 'gateway', 'desktop', '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.\n- **delegate_task**(goal, context) [capability_expand category: delegate]: Delegate a complex sub-task to an isolated subagent. The subagent gets a fresh context and restricted tool access. Use when a task is self-contained and can be solved independently.\n- **schedule_reentry**(kind, reason, delay_seconds, event_match, max_reentries, deadline_seconds): Register a re-entry so this task can resume later from its current orientation (findings / open questions / next step). Use when work must pause and continue after a delay (kind=time) or when a matching platform event arrives (kind=event), instead of finishing now. The research-ledger state is carried over automatically.\n- **hub_push**(skill_name, visibility, version) [capability_expand category: hub]: Push a local skill to the ModelScope Hub for sharing or backup.\n- **hub_pull**(repo_id, version) [capability_expand category: hub]: Pull a skill from the ModelScope Hub to install locally.\n- **hub_search**(query) [capability_expand category: hub]: Search for skills on the Hub by keyword or description.\n- **hub_sync**(mode, dry_run) [capability_expand category: hub]: Preview or execute sync between local skills and Hub.\n- **platform_action**(platform, action, payload, backend_kind) [capability_expand category: gateway]: Execute an exact registered business action on an external platform through LeapFlow's App Connector layer. Actions must be copied from the App Connector Capability Index and are addressed as domain.operation, e.g. im.send_message or docs.create_markdown. All business fields (chat_id, text, query, etc.) MUST be placed inside `payload`, never at the top level. Example: {\"platform\":\"feishu\",\"action\":\"im.send_message\",\"payload\":{\"chat_id\":\"oc_xxx\",\"text\":\"hello\"}}. Do not invent action names, do not use management actions such as list/guide/connect/status here.\n- **platform_connect**(action, platform, credentials, options, checkpoint) [capability_expand category: gateway]: List, guide, connect, disconnect, remove, or check status for external platforms using the App Connector management namespace. Supports REST and CLI backends. Use this for management actions such as list/guide/preflight/connect/status; use platform_action only for exact registered business actions.\n- **gateway_send**(platform, chat_id, text, thread_id) [capability_expand category: gateway]: Send a message to a connected external platform (Feishu group, Telegram chat, DingTalk conversation, etc.). Requires the platform to be connected via gateway_connect first. Use gateway_connect with action='list' to see connected platforms and available chat IDs.\n- **gateway_connect**(action, platform, credentials, options) [capability_expand category: gateway]: Connect, configure, or manage external platform integrations (Feishu, DingTalk, Telegram, Slack, Discord, etc.). Conversational flow: 1) call 'guide' to get setup steps + required fields, 2) present the steps to the user and ask for ALL required credentials in a single message, 3) call 'connect' with the credentials. Goal: complete in 1–2 user turns. NEVER include credential values in your text response.\n- **plugin_list**(): List the live plugin registry and cross-subsystem capability evidence. Use this before answering questions about whether LeapFlow supports plugins, self-evolution, plugin installation, hot reload, versioning, or other runtime capabilities.\n- **plugin_status**(plugin_id): Get detailed status of a specific plugin: its declared category, runtime dependencies, contributed tools, and fiber lifecycle state.\n- **plugin_versions**(plugin_id): List recorded source versions and active pointer for a profile-scoped plugin.\n- **plugin_propose**(requested_capability, plugin_id, proposed_tools, test_cases, risk_level, evidence): Create a side-effect-free PluginProposal from explicit capability-gap evidence. Use this before plugin_generate when a missing capability should be reviewed. Does not call an LLM, write files, or install anything.\n- **assess_compatibility**(manifest, source_path) [capability_expand category: plugin_management]: Assess whether a foreign plugin manifest or real DSH source bundle is compatible with LeapFlow. A source bundle assessment is static: runtime_ready stays false until restricted Node discovery during plugin_install. Returns component-level verdicts and limitations.\n- **plugin_generate**(plugin_id, description, proposal_id) [capability_expand category: system]: Generate a new ToolPlugin from a natural-language capability description. The LLM produces code that conforms to the ToolPlugin Protocol; it is then rigorously validated (syntax, structure, import, protocol conformance). The isolated sandbox smoke test runs later, at install-time. Returns the validated code but DOES NOT install it — installation is a separate approval-gated step via plugin_install.\n- **plugin_install**(plugin_id, code, marketplace_name, source_path, proposal_id, version_label) [capability_expand category: system]: Install a plugin from exactly one source: validated Python code, the configured Python marketplace, or a real DSH source bundle. DSH bundles run restricted Node discovery before registration; only public host tools are installed and client UI limitations are reported. Writes profile-scoped state and mutates the process-global registry. REQUIRES APPROVAL.\n- **plugin_rollback**(plugin_id, version) [capability_expand category: system]: Rollback a profile-scoped plugin to a recorded source version and reload it. REQUIRES APPROVAL.\n- **plugin_reload**(plugin_id, version_label) [capability_expand category: system]: Hot-reload a plugin at runtime. Disposes the old plugin fiber, re-imports its module, and registers a fresh instance. Existing in-flight turns are unaffected (snapshot isolation). REQUIRES APPROVAL — this is a self-modification action.\n- **plugin_disable**(plugin_id) [capability_expand category: system]: Disable a plugin by disposing its fiber, removing its tools from the runtime registry. Cannot disable self_management itself. REQUIRES APPROVAL — this is a self-modification action.\n- **plugin_remove**(plugin_id, delete_source) [capability_expand category: system]: Terminally remove a plugin: dispose its fiber, unregister its tools, remove reload metadata, and optionally delete its profile-scoped source file. Cannot remove self_management itself. REQUIRES APPROVAL.\n- **plugin_enable**(plugin_id) [capability_expand category: system]: Re-enable a previously disabled plugin by reloading its module and registering a fresh instance. REQUIRES APPROVAL.\n- **session_search**(query, limit) [capability_expand category: unclassified]: Search past conversation sessions in the current workspace for relevant context.\n- **session_list**(limit): List recent conversation sessions in the current workspace with stable ids, titles, dates, and summaries. Use for browsing past tasks or when user asks to see history without specific search terms. No keywords needed — returns chronological list.\n- **session_detail**(session_id, limit, offset, include_inactive) [capability_expand category: unclassified]: Read a paginated persisted transcript for one past conversation session in the current workspace. Use after session_list or session_search returns a session_id.\n- **click**(element_index) [capability_expand category: desktop]: Click a UI element by its element_index (from the latest observe_ui snapshot)\n- **get_clipboard**() [capability_expand category: desktop]: Read current clipboard text content\n- **list_apps**(filter, running_only) [capability_expand category: desktop]: List available applications on this system. Use to discover correct bundle_id before switch_app.\n- **list_windows**() [capability_expand category: desktop]: List all top-level windows with pid, window_id, title, and per-window state (minimized, on-screen). Call this first to pick the pid and window_id that observe_ui and other window tools require.\n- **observe_ui**(pid, window_id, query) [capability_expand category: desktop]: Snapshot one window's actionable UI elements, each tagged with an element_index for click/right_click/read_text. Re-observe after actions — indices belong to one snapshot. Requires the window's pid and window_id from list_windows.\n- **open_url**(url, app_id) [capability_expand category: desktop]: Open a URL in the default or specified browser\n- **read_text**(element_index) [capability_expand category: desktop]: Read the text content of a specific UI element from the latest snapshot\n- **right_click**(element_index) [capability_expand category: desktop]: Right-click a UI element to open its context menu. Returns visible menu items.\n- **screenshot**(pid, window_id) [capability_expand category: desktop]: Capture a screenshot for visual verification. With pid + window_id captures that window (works across all displays); defaults to the last observed window, or the full desktop when no window has been observed.\n- **scroll**(element_index, direction, amount, pid, window_id) [capability_expand category: desktop]: Scroll a scrollable area of a window. Omit element_index to scroll the window's focused/page scroller; pass one to scroll an exact element from the latest snapshot.\n- **select_text**(element_index) [capability_expand category: desktop]: Select all text in a UI element (focus + select-all, for subsequent copy)\n- **set_clipboard**(text) [capability_expand category: desktop]: Write text to the clipboard\n- **shortcut**(keys) [capability_expand category: desktop]: Execute a keyboard shortcut\n- **switch_app**(app_id) [capability_expand category: desktop]: Switch to an app (launch if needed, activate, verify)\n- **type_text**(text) [capability_expand category: desktop]: Type text into the currently focused element\n- **wait**(seconds) [capability_expand category: desktop]: Wait for a specified duration before continuing\n- **wait_until**(condition, pid, window_id, timeout, poll_interval) [capability_expand category: desktop]: Wait until a UI condition is met (polls UI tree). Returns elements when found or on timeout.\n- **wait_until_stable**(timeout, poll_interval, pid, window_id) [capability_expand category: desktop]: Wait until the UI stops changing (element set stabilizes across polls).\n\n## App Connector Capability Index\nLeapFlow can onboard and manage external apps through `platform_connect` and execute exact registered business actions through `platform_action`.\nFor requests about connecting, setting up, configuring, enabling, or managing a supported app, use `platform_connect` first instead of generating SDK/Webhook sample code.\n`platform_connect.action` is the management namespace: list, guide, preflight, connect, disconnect, remove, status, events_start, events_stop, events_status.\n`platform_action.action` is only for exact registered platform business actions listed below, such as `im.send_message`; never use management actions like `list` or `guide` there.\nAll business fields MUST go inside `payload`; top-level keys are only `platform`, `action`, and `payload`.\nDo not invent platform IDs or platform action names. If the needed action is not listed, ask for discovery/clarification instead of guessing.\nUse `platform_connect` with `action='guide'` and the matching `platform` to start onboarding; use `action='list'` when the app is unclear.\nWhen a pending onboarding state is present, continue from that state with `platform_connect` instead of asking the user to restate the app.\nSupported apps:\n- `api_server`: API Server (OpenAI Compatible) (category=api; backend=adapter; platform_action actions=none registered)\n- `dingtalk`: 钉钉 (DingTalk) (category=im; backend=adapter; platform_action actions=none registered)\n- `feishu`: 飞书 (Feishu/Lark) (category=im; backend=cli)\n - `calendar.create_event` payload={summary*, start_time*, end_time*, attendees} [write/high]\n - `docs.create_markdown` payload={title*, markdown*, folder_token} [write/medium]\n - `drive.search` payload={query*, limit} [read/medium]\n - `im.add_reaction` payload={message_id*, emoji_type*} [write/low]\n - `im.download_resource` payload={message_id*, file_key*, type*} [read/low]\n - `im.get_messages` payload={message_ids*} [read/low]\n - `im.list_chats` payload={page_size} [read/low]\n - `im.list_messages` payload={chat_id*} [read/low]\n - `im.list_thread_messages` payload={thread*} [read/low]\n - `im.remove_reaction` payload={message_id*, emoji_type*} [write/low]\n - `im.reply_message` payload={message_id*, text*} [send/high]\n - `im.search_chats` payload={query*, page_size} [read/low]\n - `im.search_messages` payload={query*} [read/medium]\n - `im.send_message` payload={chat_id*, text*, thread_id} [send/high]\n - `im.update_card` payload={token*, card*} [write/medium]\n - `im.update_message` payload={message_id*, text*} [write/medium]\n - `mail.search_unread` payload={query, limit} [read/high]\n - `sheets.append_row` payload={spreadsheet_token*, sheet_id*, values*} [write/medium]\n - `task.create` payload={title*, description, due_time} [write/medium]\n- `telegram`: Telegram (category=social; backend=adapter; platform_action actions=none registered)\n- `webhook`: Webhook (Generic) (category=webhook; backend=adapter; platform_action actions=none registered)\n\n## Tool Usage\nTools are normally invoked through the native function-calling mechanism, not by writing JSON in your reply\ntext. Only if the provider signals that native function calling is unavailable for this turn, fall back to a\nsingle JSON code block: `{\"name\": \"tool_name\", \"arguments\": {\"key\": \"value\"}}` — use this fallback format\nonly, never both. Only call a tool whose exact name appears in the tool index above and is currently callable\n(or reachable via `capability_expand`). Never invent, rename, alias, or guess a tool name, platform ID, or\nplatform action from argument shape or wording — if the index does not list it, it does not exist.\n`platform_connect.action` (list/guide/preflight/connect/disconnect/remove/status/events_start/events_stop/\nevents_status) is the App Connector management namespace; `platform_action.action` only accepts exact\nregistered business actions such as `im.send_message` shown in the App Connector Capability Index — never mix\nthe two namespaces. If a tool call returns an unknown/unavailable result, use the returned suggestions or\navailable names for a single retry instead of trying further variations of the same guess.\n\n**Side-effect action rule** (`platform_action` with effect=send/write/execute):\n- Call each unique action+payload **exactly once**. Never include duplicates in the same turn.\n- Once the result returns `\"completed\": true`, that action is DONE for this task. Do NOT call it again\n in any subsequent turn — immediately summarize the result for the user instead.\n- The system enforces idempotency: duplicate calls are blocked and will not execute.\n- If the user explicitly requests sending/writing multiple times, use distinct payloads per call.\n\n**Resource identifier provenance rule**:\n- NEVER fabricate, guess, or infer resource identifiers (chat_id, message_id, file_key, user_id, etc.).\n Every resource ID used in a side-effect action MUST come from a successful API response in this session.\n- If a read/list action fails (e.g. authorization error), you do NOT have valid resource IDs.\n Report the failure to the user — do NOT attempt the dependent write/send action with a guessed ID.\n- When a tool result contains `\"llm_instruction\"`, follow it exactly.\n\n## Guidelines\n1. **Direct answers first**: If you already know the answer, respond directly without tools.\n2. **Avoid redundant tool calls**: Do not call the same tool with the same arguments more than once in the same user turn. When an existing tool result already answers the user's request, stop calling tools and answer directly.\n3. **Use tools proactively**: When the user asks about files, time, system state, or needs actions performed, use the appropriate tool.\n4. **Chain tools when needed**: You can call multiple tools in sequence (e.g., list files → read file → summarize).\n5. **Handle failures gracefully**: If a tool fails, explain what went wrong and suggest alternatives. If it failed because of an unknown tool/platform/action name, retry once with an exact name from the returned suggestions, then explain rather than keep guessing.\n6. **Summarize results naturally**: After tool execution, synthesize the results into a helpful answer rather than dumping raw output.\n7. **Stay conversational**: Maintain a natural, helpful tone. Acknowledge context from earlier in the conversation.\n8. **Recall past work**:\n - Broad queries (\"之前做了什么\", \"列出任务\"): answer from the \"Recent Task History\" section already in your context. If insufficient, call session_list.\n - Specific lookups (\"上次那个配置怎么改的\"): call session_search with relevant phrases (NOT single characters).\n - Do NOT call search tools repeatedly with keyword fragments. One well-phrased call is better than ten fragmented ones.\n\n## Coding & Verification\nWhen working with code, prefer the precise built-in tools over ad-hoc shell: use `repo_map` to orient in an\nunfamiliar project, `code_search`/`file_find` to locate and `code_intel` for symbols, `edit_file` (anchored\nsearch-replace, or a unified `diff`) to change files — never rewrite a whole file to change a few lines — and\n`git_query` to inspect diffs/log. After edits, check `syntax_ok` in the result and run `test_run`/`lint_check`\nbefore declaring the work done. Batch independent read-only calls (search/read/query) into a single turn — they\nrun in parallel.\n\n## Presentation Style\n1. **Polished Markdown only**: Format user-facing answers with clean Markdown headings, short paragraphs, and concise bullets. Use tables only when they improve comparison or scanning.\n2. **Terminal-friendly layout**: Keep lines readable in a TUI; avoid dense walls of text, deeply nested lists, oversized ASCII art, or heavy visual blocks.\n3. **Elegant emphasis**: Use bold text sparingly for key terms and conclusions. Avoid excessive emojis, decorative symbols, repeated separators, or visual noise.\n4. **Theme-safe colors**: Do not emit ANSI escape codes, HTML color tags, Rich markup, or hardcoded color names. Rely on the TUI theme to render Markdown professionally.\n5. **No leaked tool protocol**: Never show tool-call JSON, internal schemas, raw observations, tool result payloads, or hidden reasoning in the final answer unless the user explicitly asks for raw/debug output. Treat any prior `{\"name\": ..., \"arguments\": ...}` blocks and `Tool result (...)` messages as internal execution context only.\n6. **Professional closure**: End with a concise conclusion or next step when helpful; avoid rambling after the useful answer is complete.\n\nWhen finished with all tool calls, respond normally without a JSON block, tool-call transcript, or process log.\n\n## Semantic Focus Plane\nCurrent task focus:\n- Target: invoice.txt\n- Type: file\n- Evidence refs: file_read:invoice.txt\nResolved user reference:\n- file -> invoice.txt (task_semantic, confidence=0.92)\nUse Current task focus for deictic task references such as 'the above paper'; use control-plane events only when the user explicitly asks about runtime settings.\n\n\n## Recent Session Summary\n- [assistant] The invoice total is 128.50 USD.\n- [user] Is that the same invoice?\n\n## Task Contract\n- Task ID: turn-3\n- Original user request: Is that the same invoice?\n- Workspace root: \n- Allowed roots: \n- Treat relative project paths as relative to the workspace root; never infer `.` as the project root when a workspace root is provided.\n- Workspace boundary is enforced by tools: do not read, search, edit, or run commands against paths outside the allowed roots unless the user explicitly requests an external path and the tool/approval policy permits it.\n- LeapFlow workspace config is optional at `/.leapflow/config.yaml`; runtime config is loaded from `~/.leapflow/config/user.yaml` and `~/.leapflow/profiles//config/*.yaml`.\n- Preserve this task contract across summarization, compression, tool loops, and memory retrieval.\n" + }, + { + "role": "assistant", + "content": "The invoice total is 128.50 USD." + }, + { + "role": "user", + "content": "Is that the same invoice?\nIs that the same invoice?" + } + ], + "tools": [ + "capability_expand", + "code_intel", + "code_search", + "config_get", + "config_list", + "env_info", + "file_find", + "file_list", + "file_read", + "git_query", + "lint_check", + "memory_search", + "plugin_list", + "plugin_propose", + "plugin_status", + "plugin_versions", + "repo_map", + "research_note", + "schedule_reentry", + "session_list", + "skill_view", + "skills_list", + "terminal_list", + "terminal_read", + "test_run", + "text_search", + "time_get", + "web_fetch" + ] + }, + "responses": [ + { + "status": 200, + "content_type": "application/json", + "body": "{\"id\": \"chatcmpl-cassette\", \"object\": \"chat.completion\", \"model\": \"cassette-model\", \"choices\": [{\"index\": 0, \"message\": {\"role\": \"assistant\", \"content\": \"Yes, that is the same invoice.\"}, \"finish_reason\": \"stop\"}], \"usage\": {\"prompt_tokens\": 64, \"completion_tokens\": 16, \"total_tokens\": 80}}" + } + ] +} diff --git a/tests/_fixtures/cassettes/r2_isolation/cassette-model-013c7d05b3942c25.cassette.json b/tests/_fixtures/cassettes/r2_isolation/cassette-model-013c7d05b3942c25.cassette.json new file mode 100644 index 0000000..427fe85 --- /dev/null +++ b/tests/_fixtures/cassettes/r2_isolation/cassette-model-013c7d05b3942c25.cassette.json @@ -0,0 +1,59 @@ +{ + "fingerprint": "013c7d05b3942c256d1ee912d2d2bda2d9c3e45e0243f22ee21d0ee2a431eb70", + "note": "captured in seed mode", + "request": { + "model": "cassette-model", + "stream": false, + "messages": [ + { + "role": "system", + "content": "You are LeapFlow, an intelligent assistant that can both converse naturally and take real actions on the user's computer.\n\n## Capabilities\nThe tool index below lists **every** registered tool by name and a one-line summary — this is the complete\ncapability contract; nothing else exists. Only a subset is directly callable this turn (via native tool calling,\nnot a JSON block in your reply). If you need a tool from the index that is not yet callable, call\n`capability_expand` with its category name first — the matching tools become callable immediately after.\nWhen the user asks what LeapFlow itself supports, whether it supports plugins, or which runtime capabilities\nare available, use the live capability evidence exposed by `plugin_list` before making capability claims; report\nconfiguration-dependent or unavailable capabilities as limitations instead of inferring from documentation.\n- **text_search**(text, pattern): Search for a regex pattern in text.\n- **text_replace**(text, old, new, count) [capability_expand category: write]: Replace occurrences of a substring in text.\n- **time_get**(): Get current date and time.\n- **env_info**(): Get system environment information (OS, Python version, cwd).\n- **skills_list**(query, category, source): List available learned skills. Use when user asks about capabilities or you need a specific skill.\n- **skill_view**(name): View the full content of a specific skill document.\n- **code_intel**(path, operation): Precise document symbols (outline) for a source file: classes, functions, and methods with line ranges. Python uses an exact AST parse; other languages use a keyword-prefix scan. Prefer over file_read mode=symbols for accurate navigation before editing. Read-only.\n- **repo_map**(path): Compact project orientation for a repository root: languages, detected test/lint commands, top-level structure, entry points, manifest, and VCS branch. Call this first when entering an unfamiliar codebase. Read-only.\n- **scm_sync**(action, cwd, remote, pull_ref, push_ref, timeout) [capability_expand category: scm]: Run a typed git SCM action. Use this instead of shell_run for git pull/push/status. For 'pull origin main then push', set action='pull_then_push', remote='origin', pull_ref='main', and omit push_ref so LeapFlow pushes the current local branch.\n- **git_query**(action, cwd, ref, path, staged, max_count, stat): Read-only structured git inspection: action=diff|log|status|branch|show. Prefer over shell_run for reading repo state — output is clipped, redacted, and log/branch are parsed into structured fields. Use scm_sync for pull/push.\n- **git_write**(action, cwd, message, stage_all, name, ref, create) [capability_expand category: scm]: Mutating git actions: action=commit (message, stage_all), branch (create+switch), checkout (switch; create=true for -b). Approval-gated. Use scm_sync for pull/push and git_query for reads.\n- **test_run**(command, cwd, timeout): Run the project's test suite and return structured results (framework, passed/failed counts, failing tests). Auto-detects the runner (pytest/npm/go/cargo) or uses a configured/explicit command; executes via the governed shell. ok=true means the runner executed — see 'success' for pass/fail.\n- **lint_check**(command, cwd, timeout): Run the project's linter and return a structured clean/issue result. Auto-detects the linter (ruff/eslint/go vet/clippy) or uses a configured/explicit command; executes via the governed shell. ok=true means the linter ran — see 'clean'.\n- **file_list**(path, pattern, depth): List files and directories at a given path. Use depth=1 or depth=2 to get a recursive tree in one call instead of listing each sub-directory separately.\n- **file_read**(path, max_lines, start_line, max_chars, mode): Read text file content with adaptive context governance. For large or unfamiliar files, prefer mode='outline' or mode='symbols' first, then use mode='raw' with start_line/max_lines for the specific range you actually need. For LeapFlow's own settings, use config_list / config_get / config_set — its config files are outside the workspace and not readable here.\n- **file_write**(path, content, mode) [capability_expand category: write]: Write content to a file (overwrite or append).\n- **code_search**(pattern, patterns, path, glob, ignore_case, multiline, max_results, context_lines): Search file CONTENTS by regex pattern across a directory tree (ripgrep-backed). Requires a regex pattern. NOT for listing or browsing directory contents — use file_list for that. Prefer this over shell_run grep: faster, skips VCS/dependency/build dirs, and returns structured path:line:column matches. Batch related lookups into ONE call via `patterns` (OR-combined, single pass) instead of issuing several separate searches. Use file_read for the surrounding context of a hit.\n- **file_find**(glob, path, max_results): Find files by a recursive glob pattern under a base path (e.g. '**/test_*.py' or '*.md'). Prefer this over shell_run find; skips VCS/dependency/build dirs.\n- **edit_file**(path, edits, dry_run, diff) [capability_expand category: write]: Apply targeted, anchored search-replace edits to an EXISTING text file (use file_write to create/overwrite). Each edit is {original_text, new_text, replace_all?}; original_text must match exactly and uniquely (or set replace_all) — a non-unique or missing anchor is rejected so files are never corrupted. Set dry_run to preview. Alternatively pass a unified 'diff' to apply its hunks as anchored edits. Far cheaper and safer than rewriting a whole file.\n- **shell_run**(command, cwd, timeout) [capability_expand category: shell]: Execute a one-shot shell command with timeout protection. Runs in the active workspace; paths resolving outside it are refused. Reach for a structured tool first when one fits — web_fetch for anything over HTTP(S), git_query/scm_sync for git, code_search/file_find/file_read for the repo, config_get/config_set for LeapFlow's own settings — because those report typed results, while a failed shell command can only be diagnosed from its exit code and stderr. Every shell run counts as an external side effect, so a failure stops the rest of the batch and is not retried automatically.\n- **terminal_open**(command, cwd, shell) [capability_expand category: terminal]: Open a PERSISTENT shell session (REPL/dev server/watch), returning a session_id for terminal_send/read/close. Disabled unless tools.terminal_session_enabled is set. For one-shot commands use shell_run instead.\n- **terminal_send**(session_id, input, wait) [capability_expand category: terminal]: Send a line of input to a persistent terminal session and return output captured shortly after.\n- **terminal_read**(session_id, wait): Drain buffered output from a persistent terminal session (optionally waiting briefly first).\n- **terminal_close**(session_id) [capability_expand category: terminal]: Terminate a persistent terminal session and release its process group.\n- **terminal_list**(): List active persistent terminal sessions.\n- **config_list**(category, limit): List LeapFlow's own writable settings (model, provider, daemon, memory, perception, gateway, …) with current values. Use this to discover the exact key before changing anything. Optionally narrow by `category`. This is the only correct way to inspect LeapFlow configuration — never read config files from disk.\n- **config_get**(key): Read one LeapFlow setting by key (e.g. 'llm.model', 'daemon.log_level'), returning its current value, type, scopes, and whether a change needs a daemon restart. Never read LeapFlow config files from disk — use this.\n- **config_set**(key, value, scope) [capability_expand category: config]: Change one LeapFlow setting by key, e.g. switch the model with key='llm.model'. Values are validated and coerced; credentials are stored in the vault automatically. Call config_list or config_get first if unsure of the key. The result states whether a `leap daemon restart` is required. Never edit LeapFlow config files directly.\n- **web_fetch**(url, select, timeout, max_bytes): Read a URL over HTTP(S) and get back extracted, context-sized content: parsed JSON for API endpoints, readable text plus links for web pages. Use this for anything on the internet — prices, docs, releases, articles — instead of running curl through shell_run: it reports real HTTP status codes, retries rate limits on its own, and is a plain read so a retry is always safe. For JSON APIs pass `select` with a dotted path (e.g. 'chart.result.0.meta') to return just that part instead of the whole payload.\n- **memory_search**(query, limit): Search agent memory for relevant past experiences, observations, and facts.\n- **memory_add**(content, kind) [capability_expand category: write]: Store a new observation or insight in memory for future reference.\n- **research_note**(kind, text): Record a compact, structured note about the current task's state so it survives context compression on long / multi-step tasks. Use for durable findings, open questions still to resolve, decisions / excluded paths, and the immediate next step. One concise sentence per note.\n- **capability_expand**(category): Fetch the full callable schema for every tool in a capability category (e.g. 'hub', 'gateway', 'desktop', '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.\n- **delegate_task**(goal, context) [capability_expand category: delegate]: Delegate a complex sub-task to an isolated subagent. The subagent gets a fresh context and restricted tool access. Use when a task is self-contained and can be solved independently.\n- **schedule_reentry**(kind, reason, delay_seconds, event_match, max_reentries, deadline_seconds): Register a re-entry so this task can resume later from its current orientation (findings / open questions / next step). Use when work must pause and continue after a delay (kind=time) or when a matching platform event arrives (kind=event), instead of finishing now. The research-ledger state is carried over automatically.\n- **hub_push**(skill_name, visibility, version) [capability_expand category: hub]: Push a local skill to the ModelScope Hub for sharing or backup.\n- **hub_pull**(repo_id, version) [capability_expand category: hub]: Pull a skill from the ModelScope Hub to install locally.\n- **hub_search**(query) [capability_expand category: hub]: Search for skills on the Hub by keyword or description.\n- **hub_sync**(mode, dry_run) [capability_expand category: hub]: Preview or execute sync between local skills and Hub.\n- **platform_action**(platform, action, payload, backend_kind) [capability_expand category: gateway]: Execute an exact registered business action on an external platform through LeapFlow's App Connector layer. Actions must be copied from the App Connector Capability Index and are addressed as domain.operation, e.g. im.send_message or docs.create_markdown. All business fields (chat_id, text, query, etc.) MUST be placed inside `payload`, never at the top level. Example: {\"platform\":\"feishu\",\"action\":\"im.send_message\",\"payload\":{\"chat_id\":\"oc_xxx\",\"text\":\"hello\"}}. Do not invent action names, do not use management actions such as list/guide/connect/status here.\n- **platform_connect**(action, platform, credentials, options, checkpoint) [capability_expand category: gateway]: List, guide, connect, disconnect, remove, or check status for external platforms using the App Connector management namespace. Supports REST and CLI backends. Use this for management actions such as list/guide/preflight/connect/status; use platform_action only for exact registered business actions.\n- **gateway_send**(platform, chat_id, text, thread_id) [capability_expand category: gateway]: Send a message to a connected external platform (Feishu group, Telegram chat, DingTalk conversation, etc.). Requires the platform to be connected via gateway_connect first. Use gateway_connect with action='list' to see connected platforms and available chat IDs.\n- **gateway_connect**(action, platform, credentials, options) [capability_expand category: gateway]: Connect, configure, or manage external platform integrations (Feishu, DingTalk, Telegram, Slack, Discord, etc.). Conversational flow: 1) call 'guide' to get setup steps + required fields, 2) present the steps to the user and ask for ALL required credentials in a single message, 3) call 'connect' with the credentials. Goal: complete in 1–2 user turns. NEVER include credential values in your text response.\n- **plugin_list**(): List the live plugin registry and cross-subsystem capability evidence. Use this before answering questions about whether LeapFlow supports plugins, self-evolution, plugin installation, hot reload, versioning, or other runtime capabilities.\n- **plugin_status**(plugin_id): Get detailed status of a specific plugin: its declared category, runtime dependencies, contributed tools, and fiber lifecycle state.\n- **plugin_versions**(plugin_id): List recorded source versions and active pointer for a profile-scoped plugin.\n- **plugin_propose**(requested_capability, plugin_id, proposed_tools, test_cases, risk_level, evidence): Create a side-effect-free PluginProposal from explicit capability-gap evidence. Use this before plugin_generate when a missing capability should be reviewed. Does not call an LLM, write files, or install anything.\n- **assess_compatibility**(manifest, source_path) [capability_expand category: plugin_management]: Assess whether a foreign plugin manifest or real DSH source bundle is compatible with LeapFlow. A source bundle assessment is static: runtime_ready stays false until restricted Node discovery during plugin_install. Returns component-level verdicts and limitations.\n- **plugin_generate**(plugin_id, description, proposal_id) [capability_expand category: system]: Generate a new ToolPlugin from a natural-language capability description. The LLM produces code that conforms to the ToolPlugin Protocol; it is then rigorously validated (syntax, structure, import, protocol conformance). The isolated sandbox smoke test runs later, at install-time. Returns the validated code but DOES NOT install it — installation is a separate approval-gated step via plugin_install.\n- **plugin_install**(plugin_id, code, marketplace_name, source_path, proposal_id, version_label) [capability_expand category: system]: Install a plugin from exactly one source: validated Python code, the configured Python marketplace, or a real DSH source bundle. DSH bundles run restricted Node discovery before registration; only public host tools are installed and client UI limitations are reported. Writes profile-scoped state and mutates the process-global registry. REQUIRES APPROVAL.\n- **plugin_rollback**(plugin_id, version) [capability_expand category: system]: Rollback a profile-scoped plugin to a recorded source version and reload it. REQUIRES APPROVAL.\n- **plugin_reload**(plugin_id, version_label) [capability_expand category: system]: Hot-reload a plugin at runtime. Disposes the old plugin fiber, re-imports its module, and registers a fresh instance. Existing in-flight turns are unaffected (snapshot isolation). REQUIRES APPROVAL — this is a self-modification action.\n- **plugin_disable**(plugin_id) [capability_expand category: system]: Disable a plugin by disposing its fiber, removing its tools from the runtime registry. Cannot disable self_management itself. REQUIRES APPROVAL — this is a self-modification action.\n- **plugin_remove**(plugin_id, delete_source) [capability_expand category: system]: Terminally remove a plugin: dispose its fiber, unregister its tools, remove reload metadata, and optionally delete its profile-scoped source file. Cannot remove self_management itself. REQUIRES APPROVAL.\n- **plugin_enable**(plugin_id) [capability_expand category: system]: Re-enable a previously disabled plugin by reloading its module and registering a fresh instance. REQUIRES APPROVAL.\n- **session_search**(query, limit) [capability_expand category: unclassified]: Search past conversation sessions in the current workspace for relevant context.\n- **session_list**(limit): List recent conversation sessions in the current workspace with stable ids, titles, dates, and summaries. Use for browsing past tasks or when user asks to see history without specific search terms. No keywords needed — returns chronological list.\n- **session_detail**(session_id, limit, offset, include_inactive) [capability_expand category: unclassified]: Read a paginated persisted transcript for one past conversation session in the current workspace. Use after session_list or session_search returns a session_id.\n- **click**(element_index) [capability_expand category: desktop]: Click a UI element by its element_index (from the latest observe_ui snapshot)\n- **get_clipboard**() [capability_expand category: desktop]: Read current clipboard text content\n- **list_apps**(filter, running_only) [capability_expand category: desktop]: List available applications on this system. Use to discover correct bundle_id before switch_app.\n- **list_windows**() [capability_expand category: desktop]: List all top-level windows with pid, window_id, title, and per-window state (minimized, on-screen). Call this first to pick the pid and window_id that observe_ui and other window tools require.\n- **observe_ui**(pid, window_id, query) [capability_expand category: desktop]: Snapshot one window's actionable UI elements, each tagged with an element_index for click/right_click/read_text. Re-observe after actions — indices belong to one snapshot. Requires the window's pid and window_id from list_windows.\n- **open_url**(url, app_id) [capability_expand category: desktop]: Open a URL in the default or specified browser\n- **read_text**(element_index) [capability_expand category: desktop]: Read the text content of a specific UI element from the latest snapshot\n- **right_click**(element_index) [capability_expand category: desktop]: Right-click a UI element to open its context menu. Returns visible menu items.\n- **screenshot**(pid, window_id) [capability_expand category: desktop]: Capture a screenshot for visual verification. With pid + window_id captures that window (works across all displays); defaults to the last observed window, or the full desktop when no window has been observed.\n- **scroll**(element_index, direction, amount, pid, window_id) [capability_expand category: desktop]: Scroll a scrollable area of a window. Omit element_index to scroll the window's focused/page scroller; pass one to scroll an exact element from the latest snapshot.\n- **select_text**(element_index) [capability_expand category: desktop]: Select all text in a UI element (focus + select-all, for subsequent copy)\n- **set_clipboard**(text) [capability_expand category: desktop]: Write text to the clipboard\n- **shortcut**(keys) [capability_expand category: desktop]: Execute a keyboard shortcut\n- **switch_app**(app_id) [capability_expand category: desktop]: Switch to an app (launch if needed, activate, verify)\n- **type_text**(text) [capability_expand category: desktop]: Type text into the currently focused element\n- **wait**(seconds) [capability_expand category: desktop]: Wait for a specified duration before continuing\n- **wait_until**(condition, pid, window_id, timeout, poll_interval) [capability_expand category: desktop]: Wait until a UI condition is met (polls UI tree). Returns elements when found or on timeout.\n- **wait_until_stable**(timeout, poll_interval, pid, window_id) [capability_expand category: desktop]: Wait until the UI stops changing (element set stabilizes across polls).\n\n## App Connector Capability Index\nLeapFlow can onboard and manage external apps through `platform_connect` and execute exact registered business actions through `platform_action`.\nFor requests about connecting, setting up, configuring, enabling, or managing a supported app, use `platform_connect` first instead of generating SDK/Webhook sample code.\n`platform_connect.action` is the management namespace: list, guide, preflight, connect, disconnect, remove, status, events_start, events_stop, events_status.\n`platform_action.action` is only for exact registered platform business actions listed below, such as `im.send_message`; never use management actions like `list` or `guide` there.\nAll business fields MUST go inside `payload`; top-level keys are only `platform`, `action`, and `payload`.\nDo not invent platform IDs or platform action names. If the needed action is not listed, ask for discovery/clarification instead of guessing.\nUse `platform_connect` with `action='guide'` and the matching `platform` to start onboarding; use `action='list'` when the app is unclear.\nWhen a pending onboarding state is present, continue from that state with `platform_connect` instead of asking the user to restate the app.\nSupported apps:\n- `api_server`: API Server (OpenAI Compatible) (category=api; backend=adapter; platform_action actions=none registered)\n- `dingtalk`: 钉钉 (DingTalk) (category=im; backend=adapter; platform_action actions=none registered)\n- `feishu`: 飞书 (Feishu/Lark) (category=im; backend=cli)\n - `calendar.create_event` payload={summary*, start_time*, end_time*, attendees} [write/high]\n - `docs.create_markdown` payload={title*, markdown*, folder_token} [write/medium]\n - `drive.search` payload={query*, limit} [read/medium]\n - `im.add_reaction` payload={message_id*, emoji_type*} [write/low]\n - `im.download_resource` payload={message_id*, file_key*, type*} [read/low]\n - `im.get_messages` payload={message_ids*} [read/low]\n - `im.list_chats` payload={page_size} [read/low]\n - `im.list_messages` payload={chat_id*} [read/low]\n - `im.list_thread_messages` payload={thread*} [read/low]\n - `im.remove_reaction` payload={message_id*, emoji_type*} [write/low]\n - `im.reply_message` payload={message_id*, text*} [send/high]\n - `im.search_chats` payload={query*, page_size} [read/low]\n - `im.search_messages` payload={query*} [read/medium]\n - `im.send_message` payload={chat_id*, text*, thread_id} [send/high]\n - `im.update_card` payload={token*, card*} [write/medium]\n - `im.update_message` payload={message_id*, text*} [write/medium]\n - `mail.search_unread` payload={query, limit} [read/high]\n - `sheets.append_row` payload={spreadsheet_token*, sheet_id*, values*} [write/medium]\n - `task.create` payload={title*, description, due_time} [write/medium]\n- `telegram`: Telegram (category=social; backend=adapter; platform_action actions=none registered)\n- `webhook`: Webhook (Generic) (category=webhook; backend=adapter; platform_action actions=none registered)\n\n## Tool Usage\nTools are normally invoked through the native function-calling mechanism, not by writing JSON in your reply\ntext. Only if the provider signals that native function calling is unavailable for this turn, fall back to a\nsingle JSON code block: `{\"name\": \"tool_name\", \"arguments\": {\"key\": \"value\"}}` — use this fallback format\nonly, never both. Only call a tool whose exact name appears in the tool index above and is currently callable\n(or reachable via `capability_expand`). Never invent, rename, alias, or guess a tool name, platform ID, or\nplatform action from argument shape or wording — if the index does not list it, it does not exist.\n`platform_connect.action` (list/guide/preflight/connect/disconnect/remove/status/events_start/events_stop/\nevents_status) is the App Connector management namespace; `platform_action.action` only accepts exact\nregistered business actions such as `im.send_message` shown in the App Connector Capability Index — never mix\nthe two namespaces. If a tool call returns an unknown/unavailable result, use the returned suggestions or\navailable names for a single retry instead of trying further variations of the same guess.\n\n**Side-effect action rule** (`platform_action` with effect=send/write/execute):\n- Call each unique action+payload **exactly once**. Never include duplicates in the same turn.\n- Once the result returns `\"completed\": true`, that action is DONE for this task. Do NOT call it again\n in any subsequent turn — immediately summarize the result for the user instead.\n- The system enforces idempotency: duplicate calls are blocked and will not execute.\n- If the user explicitly requests sending/writing multiple times, use distinct payloads per call.\n\n**Resource identifier provenance rule**:\n- NEVER fabricate, guess, or infer resource identifiers (chat_id, message_id, file_key, user_id, etc.).\n Every resource ID used in a side-effect action MUST come from a successful API response in this session.\n- If a read/list action fails (e.g. authorization error), you do NOT have valid resource IDs.\n Report the failure to the user — do NOT attempt the dependent write/send action with a guessed ID.\n- When a tool result contains `\"llm_instruction\"`, follow it exactly.\n\n## Guidelines\n1. **Direct answers first**: If you already know the answer, respond directly without tools.\n2. **Avoid redundant tool calls**: Do not call the same tool with the same arguments more than once in the same user turn. When an existing tool result already answers the user's request, stop calling tools and answer directly.\n3. **Use tools proactively**: When the user asks about files, time, system state, or needs actions performed, use the appropriate tool.\n4. **Chain tools when needed**: You can call multiple tools in sequence (e.g., list files → read file → summarize).\n5. **Handle failures gracefully**: If a tool fails, explain what went wrong and suggest alternatives. If it failed because of an unknown tool/platform/action name, retry once with an exact name from the returned suggestions, then explain rather than keep guessing.\n6. **Summarize results naturally**: After tool execution, synthesize the results into a helpful answer rather than dumping raw output.\n7. **Stay conversational**: Maintain a natural, helpful tone. Acknowledge context from earlier in the conversation.\n8. **Recall past work**:\n - Broad queries (\"之前做了什么\", \"列出任务\"): answer from the \"Recent Task History\" section already in your context. If insufficient, call session_list.\n - Specific lookups (\"上次那个配置怎么改的\"): call session_search with relevant phrases (NOT single characters).\n - Do NOT call search tools repeatedly with keyword fragments. One well-phrased call is better than ten fragmented ones.\n\n## Coding & Verification\nWhen working with code, prefer the precise built-in tools over ad-hoc shell: use `repo_map` to orient in an\nunfamiliar project, `code_search`/`file_find` to locate and `code_intel` for symbols, `edit_file` (anchored\nsearch-replace, or a unified `diff`) to change files — never rewrite a whole file to change a few lines — and\n`git_query` to inspect diffs/log. After edits, check `syntax_ok` in the result and run `test_run`/`lint_check`\nbefore declaring the work done. Batch independent read-only calls (search/read/query) into a single turn — they\nrun in parallel.\n\n## Presentation Style\n1. **Polished Markdown only**: Format user-facing answers with clean Markdown headings, short paragraphs, and concise bullets. Use tables only when they improve comparison or scanning.\n2. **Terminal-friendly layout**: Keep lines readable in a TUI; avoid dense walls of text, deeply nested lists, oversized ASCII art, or heavy visual blocks.\n3. **Elegant emphasis**: Use bold text sparingly for key terms and conclusions. Avoid excessive emojis, decorative symbols, repeated separators, or visual noise.\n4. **Theme-safe colors**: Do not emit ANSI escape codes, HTML color tags, Rich markup, or hardcoded color names. Rely on the TUI theme to render Markdown professionally.\n5. **No leaked tool protocol**: Never show tool-call JSON, internal schemas, raw observations, tool result payloads, or hidden reasoning in the final answer unless the user explicitly asks for raw/debug output. Treat any prior `{\"name\": ..., \"arguments\": ...}` blocks and `Tool result (...)` messages as internal execution context only.\n6. **Professional closure**: End with a concise conclusion or next step when helpful; avoid rambling after the useful answer is complete.\n\nWhen finished with all tool calls, respond normally without a JSON block, tool-call transcript, or process log.\n\n\n## Recent Session Summary\n- [assistant] Workspace B acknowledged.\n- [user] Second B turn.\n\n## Task Contract\n- Task ID: turn-2\n- Original user request: Second B turn.\n- Workspace root: \n- Allowed roots: \n- Treat relative project paths as relative to the workspace root; never infer `.` as the project root when a workspace root is provided.\n- Workspace boundary is enforced by tools: do not read, search, edit, or run commands against paths outside the allowed roots unless the user explicitly requests an external path and the tool/approval policy permits it.\n- LeapFlow workspace config is optional at `/.leapflow/config.yaml`; runtime config is loaded from `~/.leapflow/config/user.yaml` and `~/.leapflow/profiles//config/*.yaml`.\n- Preserve this task contract across summarization, compression, tool loops, and memory retrieval.\n" + }, + { + "role": "assistant", + "content": "Workspace B acknowledged." + }, + { + "role": "user", + "content": "Second B turn.\nSecond B turn." + } + ], + "tools": [ + "capability_expand", + "code_intel", + "code_search", + "config_get", + "config_list", + "env_info", + "file_find", + "file_list", + "file_read", + "git_query", + "lint_check", + "memory_search", + "plugin_list", + "plugin_propose", + "plugin_status", + "plugin_versions", + "repo_map", + "research_note", + "schedule_reentry", + "session_list", + "skill_view", + "skills_list", + "terminal_list", + "terminal_read", + "test_run", + "text_search", + "time_get", + "web_fetch" + ] + }, + "responses": [ + { + "status": 200, + "content_type": "application/json", + "body": "{\"id\": \"chatcmpl-cassette\", \"object\": \"chat.completion\", \"model\": \"cassette-model\", \"choices\": [{\"index\": 0, \"message\": {\"role\": \"assistant\", \"content\": \"Still workspace B.\"}, \"finish_reason\": \"stop\"}], \"usage\": {\"prompt_tokens\": 64, \"completion_tokens\": 16, \"total_tokens\": 80}}" + } + ] +} diff --git a/tests/_fixtures/cassettes/r2_isolation/cassette-model-61568b0c7c41a446.cassette.json b/tests/_fixtures/cassettes/r2_isolation/cassette-model-61568b0c7c41a446.cassette.json new file mode 100644 index 0000000..679898b --- /dev/null +++ b/tests/_fixtures/cassettes/r2_isolation/cassette-model-61568b0c7c41a446.cassette.json @@ -0,0 +1,55 @@ +{ + "fingerprint": "61568b0c7c41a4468a92b1889fbcb032e6f2407366b0d2570c3867d159a8f1d8", + "note": "captured in seed mode", + "request": { + "model": "cassette-model", + "stream": false, + "messages": [ + { + "role": "system", + "content": "You are LeapFlow, an intelligent assistant that can both converse naturally and take real actions on the user's computer.\n\n## Capabilities\nThe tool index below lists **every** registered tool by name and a one-line summary — this is the complete\ncapability contract; nothing else exists. Only a subset is directly callable this turn (via native tool calling,\nnot a JSON block in your reply). If you need a tool from the index that is not yet callable, call\n`capability_expand` with its category name first — the matching tools become callable immediately after.\nWhen the user asks what LeapFlow itself supports, whether it supports plugins, or which runtime capabilities\nare available, use the live capability evidence exposed by `plugin_list` before making capability claims; report\nconfiguration-dependent or unavailable capabilities as limitations instead of inferring from documentation.\n- **text_search**(text, pattern): Search for a regex pattern in text.\n- **text_replace**(text, old, new, count) [capability_expand category: write]: Replace occurrences of a substring in text.\n- **time_get**(): Get current date and time.\n- **env_info**(): Get system environment information (OS, Python version, cwd).\n- **skills_list**(query, category, source): List available learned skills. Use when user asks about capabilities or you need a specific skill.\n- **skill_view**(name): View the full content of a specific skill document.\n- **code_intel**(path, operation): Precise document symbols (outline) for a source file: classes, functions, and methods with line ranges. Python uses an exact AST parse; other languages use a keyword-prefix scan. Prefer over file_read mode=symbols for accurate navigation before editing. Read-only.\n- **repo_map**(path): Compact project orientation for a repository root: languages, detected test/lint commands, top-level structure, entry points, manifest, and VCS branch. Call this first when entering an unfamiliar codebase. Read-only.\n- **scm_sync**(action, cwd, remote, pull_ref, push_ref, timeout) [capability_expand category: scm]: Run a typed git SCM action. Use this instead of shell_run for git pull/push/status. For 'pull origin main then push', set action='pull_then_push', remote='origin', pull_ref='main', and omit push_ref so LeapFlow pushes the current local branch.\n- **git_query**(action, cwd, ref, path, staged, max_count, stat): Read-only structured git inspection: action=diff|log|status|branch|show. Prefer over shell_run for reading repo state — output is clipped, redacted, and log/branch are parsed into structured fields. Use scm_sync for pull/push.\n- **git_write**(action, cwd, message, stage_all, name, ref, create) [capability_expand category: scm]: Mutating git actions: action=commit (message, stage_all), branch (create+switch), checkout (switch; create=true for -b). Approval-gated. Use scm_sync for pull/push and git_query for reads.\n- **test_run**(command, cwd, timeout): Run the project's test suite and return structured results (framework, passed/failed counts, failing tests). Auto-detects the runner (pytest/npm/go/cargo) or uses a configured/explicit command; executes via the governed shell. ok=true means the runner executed — see 'success' for pass/fail.\n- **lint_check**(command, cwd, timeout): Run the project's linter and return a structured clean/issue result. Auto-detects the linter (ruff/eslint/go vet/clippy) or uses a configured/explicit command; executes via the governed shell. ok=true means the linter ran — see 'clean'.\n- **file_list**(path, pattern, depth): List files and directories at a given path. Use depth=1 or depth=2 to get a recursive tree in one call instead of listing each sub-directory separately.\n- **file_read**(path, max_lines, start_line, max_chars, mode): Read text file content with adaptive context governance. For large or unfamiliar files, prefer mode='outline' or mode='symbols' first, then use mode='raw' with start_line/max_lines for the specific range you actually need. For LeapFlow's own settings, use config_list / config_get / config_set — its config files are outside the workspace and not readable here.\n- **file_write**(path, content, mode) [capability_expand category: write]: Write content to a file (overwrite or append).\n- **code_search**(pattern, patterns, path, glob, ignore_case, multiline, max_results, context_lines): Search file CONTENTS by regex pattern across a directory tree (ripgrep-backed). Requires a regex pattern. NOT for listing or browsing directory contents — use file_list for that. Prefer this over shell_run grep: faster, skips VCS/dependency/build dirs, and returns structured path:line:column matches. Batch related lookups into ONE call via `patterns` (OR-combined, single pass) instead of issuing several separate searches. Use file_read for the surrounding context of a hit.\n- **file_find**(glob, path, max_results): Find files by a recursive glob pattern under a base path (e.g. '**/test_*.py' or '*.md'). Prefer this over shell_run find; skips VCS/dependency/build dirs.\n- **edit_file**(path, edits, dry_run, diff) [capability_expand category: write]: Apply targeted, anchored search-replace edits to an EXISTING text file (use file_write to create/overwrite). Each edit is {original_text, new_text, replace_all?}; original_text must match exactly and uniquely (or set replace_all) — a non-unique or missing anchor is rejected so files are never corrupted. Set dry_run to preview. Alternatively pass a unified 'diff' to apply its hunks as anchored edits. Far cheaper and safer than rewriting a whole file.\n- **shell_run**(command, cwd, timeout) [capability_expand category: shell]: Execute a one-shot shell command with timeout protection. Runs in the active workspace; paths resolving outside it are refused. Reach for a structured tool first when one fits — web_fetch for anything over HTTP(S), git_query/scm_sync for git, code_search/file_find/file_read for the repo, config_get/config_set for LeapFlow's own settings — because those report typed results, while a failed shell command can only be diagnosed from its exit code and stderr. Every shell run counts as an external side effect, so a failure stops the rest of the batch and is not retried automatically.\n- **terminal_open**(command, cwd, shell) [capability_expand category: terminal]: Open a PERSISTENT shell session (REPL/dev server/watch), returning a session_id for terminal_send/read/close. Disabled unless tools.terminal_session_enabled is set. For one-shot commands use shell_run instead.\n- **terminal_send**(session_id, input, wait) [capability_expand category: terminal]: Send a line of input to a persistent terminal session and return output captured shortly after.\n- **terminal_read**(session_id, wait): Drain buffered output from a persistent terminal session (optionally waiting briefly first).\n- **terminal_close**(session_id) [capability_expand category: terminal]: Terminate a persistent terminal session and release its process group.\n- **terminal_list**(): List active persistent terminal sessions.\n- **config_list**(category, limit): List LeapFlow's own writable settings (model, provider, daemon, memory, perception, gateway, …) with current values. Use this to discover the exact key before changing anything. Optionally narrow by `category`. This is the only correct way to inspect LeapFlow configuration — never read config files from disk.\n- **config_get**(key): Read one LeapFlow setting by key (e.g. 'llm.model', 'daemon.log_level'), returning its current value, type, scopes, and whether a change needs a daemon restart. Never read LeapFlow config files from disk — use this.\n- **config_set**(key, value, scope) [capability_expand category: config]: Change one LeapFlow setting by key, e.g. switch the model with key='llm.model'. Values are validated and coerced; credentials are stored in the vault automatically. Call config_list or config_get first if unsure of the key. The result states whether a `leap daemon restart` is required. Never edit LeapFlow config files directly.\n- **web_fetch**(url, select, timeout, max_bytes): Read a URL over HTTP(S) and get back extracted, context-sized content: parsed JSON for API endpoints, readable text plus links for web pages. Use this for anything on the internet — prices, docs, releases, articles — instead of running curl through shell_run: it reports real HTTP status codes, retries rate limits on its own, and is a plain read so a retry is always safe. For JSON APIs pass `select` with a dotted path (e.g. 'chart.result.0.meta') to return just that part instead of the whole payload.\n- **memory_search**(query, limit): Search agent memory for relevant past experiences, observations, and facts.\n- **memory_add**(content, kind) [capability_expand category: write]: Store a new observation or insight in memory for future reference.\n- **research_note**(kind, text): Record a compact, structured note about the current task's state so it survives context compression on long / multi-step tasks. Use for durable findings, open questions still to resolve, decisions / excluded paths, and the immediate next step. One concise sentence per note.\n- **capability_expand**(category): Fetch the full callable schema for every tool in a capability category (e.g. 'hub', 'gateway', 'desktop', '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.\n- **delegate_task**(goal, context) [capability_expand category: delegate]: Delegate a complex sub-task to an isolated subagent. The subagent gets a fresh context and restricted tool access. Use when a task is self-contained and can be solved independently.\n- **schedule_reentry**(kind, reason, delay_seconds, event_match, max_reentries, deadline_seconds): Register a re-entry so this task can resume later from its current orientation (findings / open questions / next step). Use when work must pause and continue after a delay (kind=time) or when a matching platform event arrives (kind=event), instead of finishing now. The research-ledger state is carried over automatically.\n- **hub_push**(skill_name, visibility, version) [capability_expand category: hub]: Push a local skill to the ModelScope Hub for sharing or backup.\n- **hub_pull**(repo_id, version) [capability_expand category: hub]: Pull a skill from the ModelScope Hub to install locally.\n- **hub_search**(query) [capability_expand category: hub]: Search for skills on the Hub by keyword or description.\n- **hub_sync**(mode, dry_run) [capability_expand category: hub]: Preview or execute sync between local skills and Hub.\n- **platform_action**(platform, action, payload, backend_kind) [capability_expand category: gateway]: Execute an exact registered business action on an external platform through LeapFlow's App Connector layer. Actions must be copied from the App Connector Capability Index and are addressed as domain.operation, e.g. im.send_message or docs.create_markdown. All business fields (chat_id, text, query, etc.) MUST be placed inside `payload`, never at the top level. Example: {\"platform\":\"feishu\",\"action\":\"im.send_message\",\"payload\":{\"chat_id\":\"oc_xxx\",\"text\":\"hello\"}}. Do not invent action names, do not use management actions such as list/guide/connect/status here.\n- **platform_connect**(action, platform, credentials, options, checkpoint) [capability_expand category: gateway]: List, guide, connect, disconnect, remove, or check status for external platforms using the App Connector management namespace. Supports REST and CLI backends. Use this for management actions such as list/guide/preflight/connect/status; use platform_action only for exact registered business actions.\n- **gateway_send**(platform, chat_id, text, thread_id) [capability_expand category: gateway]: Send a message to a connected external platform (Feishu group, Telegram chat, DingTalk conversation, etc.). Requires the platform to be connected via gateway_connect first. Use gateway_connect with action='list' to see connected platforms and available chat IDs.\n- **gateway_connect**(action, platform, credentials, options) [capability_expand category: gateway]: Connect, configure, or manage external platform integrations (Feishu, DingTalk, Telegram, Slack, Discord, etc.). Conversational flow: 1) call 'guide' to get setup steps + required fields, 2) present the steps to the user and ask for ALL required credentials in a single message, 3) call 'connect' with the credentials. Goal: complete in 1–2 user turns. NEVER include credential values in your text response.\n- **plugin_list**(): List the live plugin registry and cross-subsystem capability evidence. Use this before answering questions about whether LeapFlow supports plugins, self-evolution, plugin installation, hot reload, versioning, or other runtime capabilities.\n- **plugin_status**(plugin_id): Get detailed status of a specific plugin: its declared category, runtime dependencies, contributed tools, and fiber lifecycle state.\n- **plugin_versions**(plugin_id): List recorded source versions and active pointer for a profile-scoped plugin.\n- **plugin_propose**(requested_capability, plugin_id, proposed_tools, test_cases, risk_level, evidence): Create a side-effect-free PluginProposal from explicit capability-gap evidence. Use this before plugin_generate when a missing capability should be reviewed. Does not call an LLM, write files, or install anything.\n- **assess_compatibility**(manifest, source_path) [capability_expand category: plugin_management]: Assess whether a foreign plugin manifest or real DSH source bundle is compatible with LeapFlow. A source bundle assessment is static: runtime_ready stays false until restricted Node discovery during plugin_install. Returns component-level verdicts and limitations.\n- **plugin_generate**(plugin_id, description, proposal_id) [capability_expand category: system]: Generate a new ToolPlugin from a natural-language capability description. The LLM produces code that conforms to the ToolPlugin Protocol; it is then rigorously validated (syntax, structure, import, protocol conformance). The isolated sandbox smoke test runs later, at install-time. Returns the validated code but DOES NOT install it — installation is a separate approval-gated step via plugin_install.\n- **plugin_install**(plugin_id, code, marketplace_name, source_path, proposal_id, version_label) [capability_expand category: system]: Install a plugin from exactly one source: validated Python code, the configured Python marketplace, or a real DSH source bundle. DSH bundles run restricted Node discovery before registration; only public host tools are installed and client UI limitations are reported. Writes profile-scoped state and mutates the process-global registry. REQUIRES APPROVAL.\n- **plugin_rollback**(plugin_id, version) [capability_expand category: system]: Rollback a profile-scoped plugin to a recorded source version and reload it. REQUIRES APPROVAL.\n- **plugin_reload**(plugin_id, version_label) [capability_expand category: system]: Hot-reload a plugin at runtime. Disposes the old plugin fiber, re-imports its module, and registers a fresh instance. Existing in-flight turns are unaffected (snapshot isolation). REQUIRES APPROVAL — this is a self-modification action.\n- **plugin_disable**(plugin_id) [capability_expand category: system]: Disable a plugin by disposing its fiber, removing its tools from the runtime registry. Cannot disable self_management itself. REQUIRES APPROVAL — this is a self-modification action.\n- **plugin_remove**(plugin_id, delete_source) [capability_expand category: system]: Terminally remove a plugin: dispose its fiber, unregister its tools, remove reload metadata, and optionally delete its profile-scoped source file. Cannot remove self_management itself. REQUIRES APPROVAL.\n- **plugin_enable**(plugin_id) [capability_expand category: system]: Re-enable a previously disabled plugin by reloading its module and registering a fresh instance. REQUIRES APPROVAL.\n- **session_search**(query, limit) [capability_expand category: unclassified]: Search past conversation sessions in the current workspace for relevant context.\n- **session_list**(limit): List recent conversation sessions in the current workspace with stable ids, titles, dates, and summaries. Use for browsing past tasks or when user asks to see history without specific search terms. No keywords needed — returns chronological list.\n- **session_detail**(session_id, limit, offset, include_inactive) [capability_expand category: unclassified]: Read a paginated persisted transcript for one past conversation session in the current workspace. Use after session_list or session_search returns a session_id.\n- **click**(element_index) [capability_expand category: desktop]: Click a UI element by its element_index (from the latest observe_ui snapshot)\n- **get_clipboard**() [capability_expand category: desktop]: Read current clipboard text content\n- **list_apps**(filter, running_only) [capability_expand category: desktop]: List available applications on this system. Use to discover correct bundle_id before switch_app.\n- **list_windows**() [capability_expand category: desktop]: List all top-level windows with pid, window_id, title, and per-window state (minimized, on-screen). Call this first to pick the pid and window_id that observe_ui and other window tools require.\n- **observe_ui**(pid, window_id, query) [capability_expand category: desktop]: Snapshot one window's actionable UI elements, each tagged with an element_index for click/right_click/read_text. Re-observe after actions — indices belong to one snapshot. Requires the window's pid and window_id from list_windows.\n- **open_url**(url, app_id) [capability_expand category: desktop]: Open a URL in the default or specified browser\n- **read_text**(element_index) [capability_expand category: desktop]: Read the text content of a specific UI element from the latest snapshot\n- **right_click**(element_index) [capability_expand category: desktop]: Right-click a UI element to open its context menu. Returns visible menu items.\n- **screenshot**(pid, window_id) [capability_expand category: desktop]: Capture a screenshot for visual verification. With pid + window_id captures that window (works across all displays); defaults to the last observed window, or the full desktop when no window has been observed.\n- **scroll**(element_index, direction, amount, pid, window_id) [capability_expand category: desktop]: Scroll a scrollable area of a window. Omit element_index to scroll the window's focused/page scroller; pass one to scroll an exact element from the latest snapshot.\n- **select_text**(element_index) [capability_expand category: desktop]: Select all text in a UI element (focus + select-all, for subsequent copy)\n- **set_clipboard**(text) [capability_expand category: desktop]: Write text to the clipboard\n- **shortcut**(keys) [capability_expand category: desktop]: Execute a keyboard shortcut\n- **switch_app**(app_id) [capability_expand category: desktop]: Switch to an app (launch if needed, activate, verify)\n- **type_text**(text) [capability_expand category: desktop]: Type text into the currently focused element\n- **wait**(seconds) [capability_expand category: desktop]: Wait for a specified duration before continuing\n- **wait_until**(condition, pid, window_id, timeout, poll_interval) [capability_expand category: desktop]: Wait until a UI condition is met (polls UI tree). Returns elements when found or on timeout.\n- **wait_until_stable**(timeout, poll_interval, pid, window_id) [capability_expand category: desktop]: Wait until the UI stops changing (element set stabilizes across polls).\n\n## App Connector Capability Index\nLeapFlow can onboard and manage external apps through `platform_connect` and execute exact registered business actions through `platform_action`.\nFor requests about connecting, setting up, configuring, enabling, or managing a supported app, use `platform_connect` first instead of generating SDK/Webhook sample code.\n`platform_connect.action` is the management namespace: list, guide, preflight, connect, disconnect, remove, status, events_start, events_stop, events_status.\n`platform_action.action` is only for exact registered platform business actions listed below, such as `im.send_message`; never use management actions like `list` or `guide` there.\nAll business fields MUST go inside `payload`; top-level keys are only `platform`, `action`, and `payload`.\nDo not invent platform IDs or platform action names. If the needed action is not listed, ask for discovery/clarification instead of guessing.\nUse `platform_connect` with `action='guide'` and the matching `platform` to start onboarding; use `action='list'` when the app is unclear.\nWhen a pending onboarding state is present, continue from that state with `platform_connect` instead of asking the user to restate the app.\nSupported apps:\n- `api_server`: API Server (OpenAI Compatible) (category=api; backend=adapter; platform_action actions=none registered)\n- `dingtalk`: 钉钉 (DingTalk) (category=im; backend=adapter; platform_action actions=none registered)\n- `feishu`: 飞书 (Feishu/Lark) (category=im; backend=cli)\n - `calendar.create_event` payload={summary*, start_time*, end_time*, attendees} [write/high]\n - `docs.create_markdown` payload={title*, markdown*, folder_token} [write/medium]\n - `drive.search` payload={query*, limit} [read/medium]\n - `im.add_reaction` payload={message_id*, emoji_type*} [write/low]\n - `im.download_resource` payload={message_id*, file_key*, type*} [read/low]\n - `im.get_messages` payload={message_ids*} [read/low]\n - `im.list_chats` payload={page_size} [read/low]\n - `im.list_messages` payload={chat_id*} [read/low]\n - `im.list_thread_messages` payload={thread*} [read/low]\n - `im.remove_reaction` payload={message_id*, emoji_type*} [write/low]\n - `im.reply_message` payload={message_id*, text*} [send/high]\n - `im.search_chats` payload={query*, page_size} [read/low]\n - `im.search_messages` payload={query*} [read/medium]\n - `im.send_message` payload={chat_id*, text*, thread_id} [send/high]\n - `im.update_card` payload={token*, card*} [write/medium]\n - `im.update_message` payload={message_id*, text*} [write/medium]\n - `mail.search_unread` payload={query, limit} [read/high]\n - `sheets.append_row` payload={spreadsheet_token*, sheet_id*, values*} [write/medium]\n - `task.create` payload={title*, description, due_time} [write/medium]\n- `telegram`: Telegram (category=social; backend=adapter; platform_action actions=none registered)\n- `webhook`: Webhook (Generic) (category=webhook; backend=adapter; platform_action actions=none registered)\n\n## Tool Usage\nTools are normally invoked through the native function-calling mechanism, not by writing JSON in your reply\ntext. Only if the provider signals that native function calling is unavailable for this turn, fall back to a\nsingle JSON code block: `{\"name\": \"tool_name\", \"arguments\": {\"key\": \"value\"}}` — use this fallback format\nonly, never both. Only call a tool whose exact name appears in the tool index above and is currently callable\n(or reachable via `capability_expand`). Never invent, rename, alias, or guess a tool name, platform ID, or\nplatform action from argument shape or wording — if the index does not list it, it does not exist.\n`platform_connect.action` (list/guide/preflight/connect/disconnect/remove/status/events_start/events_stop/\nevents_status) is the App Connector management namespace; `platform_action.action` only accepts exact\nregistered business actions such as `im.send_message` shown in the App Connector Capability Index — never mix\nthe two namespaces. If a tool call returns an unknown/unavailable result, use the returned suggestions or\navailable names for a single retry instead of trying further variations of the same guess.\n\n**Side-effect action rule** (`platform_action` with effect=send/write/execute):\n- Call each unique action+payload **exactly once**. Never include duplicates in the same turn.\n- Once the result returns `\"completed\": true`, that action is DONE for this task. Do NOT call it again\n in any subsequent turn — immediately summarize the result for the user instead.\n- The system enforces idempotency: duplicate calls are blocked and will not execute.\n- If the user explicitly requests sending/writing multiple times, use distinct payloads per call.\n\n**Resource identifier provenance rule**:\n- NEVER fabricate, guess, or infer resource identifiers (chat_id, message_id, file_key, user_id, etc.).\n Every resource ID used in a side-effect action MUST come from a successful API response in this session.\n- If a read/list action fails (e.g. authorization error), you do NOT have valid resource IDs.\n Report the failure to the user — do NOT attempt the dependent write/send action with a guessed ID.\n- When a tool result contains `\"llm_instruction\"`, follow it exactly.\n\n## Guidelines\n1. **Direct answers first**: If you already know the answer, respond directly without tools.\n2. **Avoid redundant tool calls**: Do not call the same tool with the same arguments more than once in the same user turn. When an existing tool result already answers the user's request, stop calling tools and answer directly.\n3. **Use tools proactively**: When the user asks about files, time, system state, or needs actions performed, use the appropriate tool.\n4. **Chain tools when needed**: You can call multiple tools in sequence (e.g., list files → read file → summarize).\n5. **Handle failures gracefully**: If a tool fails, explain what went wrong and suggest alternatives. If it failed because of an unknown tool/platform/action name, retry once with an exact name from the returned suggestions, then explain rather than keep guessing.\n6. **Summarize results naturally**: After tool execution, synthesize the results into a helpful answer rather than dumping raw output.\n7. **Stay conversational**: Maintain a natural, helpful tone. Acknowledge context from earlier in the conversation.\n8. **Recall past work**:\n - Broad queries (\"之前做了什么\", \"列出任务\"): answer from the \"Recent Task History\" section already in your context. If insufficient, call session_list.\n - Specific lookups (\"上次那个配置怎么改的\"): call session_search with relevant phrases (NOT single characters).\n - Do NOT call search tools repeatedly with keyword fragments. One well-phrased call is better than ten fragmented ones.\n\n## Coding & Verification\nWhen working with code, prefer the precise built-in tools over ad-hoc shell: use `repo_map` to orient in an\nunfamiliar project, `code_search`/`file_find` to locate and `code_intel` for symbols, `edit_file` (anchored\nsearch-replace, or a unified `diff`) to change files — never rewrite a whole file to change a few lines — and\n`git_query` to inspect diffs/log. After edits, check `syntax_ok` in the result and run `test_run`/`lint_check`\nbefore declaring the work done. Batch independent read-only calls (search/read/query) into a single turn — they\nrun in parallel.\n\n## Presentation Style\n1. **Polished Markdown only**: Format user-facing answers with clean Markdown headings, short paragraphs, and concise bullets. Use tables only when they improve comparison or scanning.\n2. **Terminal-friendly layout**: Keep lines readable in a TUI; avoid dense walls of text, deeply nested lists, oversized ASCII art, or heavy visual blocks.\n3. **Elegant emphasis**: Use bold text sparingly for key terms and conclusions. Avoid excessive emojis, decorative symbols, repeated separators, or visual noise.\n4. **Theme-safe colors**: Do not emit ANSI escape codes, HTML color tags, Rich markup, or hardcoded color names. Rely on the TUI theme to render Markdown professionally.\n5. **No leaked tool protocol**: Never show tool-call JSON, internal schemas, raw observations, tool result payloads, or hidden reasoning in the final answer unless the user explicitly asks for raw/debug output. Treat any prior `{\"name\": ..., \"arguments\": ...}` blocks and `Tool result (...)` messages as internal execution context only.\n6. **Professional closure**: End with a concise conclusion or next step when helpful; avoid rambling after the useful answer is complete.\n\nWhen finished with all tool calls, respond normally without a JSON block, tool-call transcript, or process log.\n\n\n## Recent Session Summary\n- [user] Hello from A.\n\n## Task Contract\n- Task ID: turn-1\n- Original user request: Hello from A.\n- Workspace root: \n- Allowed roots: \n- Treat relative project paths as relative to the workspace root; never infer `.` as the project root when a workspace root is provided.\n- Workspace boundary is enforced by tools: do not read, search, edit, or run commands against paths outside the allowed roots unless the user explicitly requests an external path and the tool/approval policy permits it.\n- LeapFlow workspace config is optional at `/.leapflow/config.yaml`; runtime config is loaded from `~/.leapflow/config/user.yaml` and `~/.leapflow/profiles//config/*.yaml`.\n- Preserve this task contract across summarization, compression, tool loops, and memory retrieval.\n" + }, + { + "role": "user", + "content": "Hello from A.\nHello from A." + } + ], + "tools": [ + "capability_expand", + "code_intel", + "code_search", + "config_get", + "config_list", + "env_info", + "file_find", + "file_list", + "file_read", + "git_query", + "lint_check", + "memory_search", + "plugin_list", + "plugin_propose", + "plugin_status", + "plugin_versions", + "repo_map", + "research_note", + "schedule_reentry", + "session_list", + "skill_view", + "skills_list", + "terminal_list", + "terminal_read", + "test_run", + "text_search", + "time_get", + "web_fetch" + ] + }, + "responses": [ + { + "status": 200, + "content_type": "application/json", + "body": "{\"id\": \"chatcmpl-cassette\", \"object\": \"chat.completion\", \"model\": \"cassette-model\", \"choices\": [{\"index\": 0, \"message\": {\"role\": \"assistant\", \"content\": \"Workspace A acknowledged.\"}, \"finish_reason\": \"stop\"}], \"usage\": {\"prompt_tokens\": 64, \"completion_tokens\": 16, \"total_tokens\": 80}}" + } + ] +} diff --git a/tests/_fixtures/cassettes/r2_isolation/cassette-model-80a3a94339d2aa90.cassette.json b/tests/_fixtures/cassettes/r2_isolation/cassette-model-80a3a94339d2aa90.cassette.json new file mode 100644 index 0000000..a4640e0 --- /dev/null +++ b/tests/_fixtures/cassettes/r2_isolation/cassette-model-80a3a94339d2aa90.cassette.json @@ -0,0 +1,59 @@ +{ + "fingerprint": "80a3a94339d2aa90331b71ca649fdc5de55c8f24dad0bda295bb3b3e30cded16", + "note": "captured in seed mode", + "request": { + "model": "cassette-model", + "stream": false, + "messages": [ + { + "role": "system", + "content": "You are LeapFlow, an intelligent assistant that can both converse naturally and take real actions on the user's computer.\n\n## Capabilities\nThe tool index below lists **every** registered tool by name and a one-line summary — this is the complete\ncapability contract; nothing else exists. Only a subset is directly callable this turn (via native tool calling,\nnot a JSON block in your reply). If you need a tool from the index that is not yet callable, call\n`capability_expand` with its category name first — the matching tools become callable immediately after.\nWhen the user asks what LeapFlow itself supports, whether it supports plugins, or which runtime capabilities\nare available, use the live capability evidence exposed by `plugin_list` before making capability claims; report\nconfiguration-dependent or unavailable capabilities as limitations instead of inferring from documentation.\n- **text_search**(text, pattern): Search for a regex pattern in text.\n- **text_replace**(text, old, new, count) [capability_expand category: write]: Replace occurrences of a substring in text.\n- **time_get**(): Get current date and time.\n- **env_info**(): Get system environment information (OS, Python version, cwd).\n- **skills_list**(query, category, source): List available learned skills. Use when user asks about capabilities or you need a specific skill.\n- **skill_view**(name): View the full content of a specific skill document.\n- **code_intel**(path, operation): Precise document symbols (outline) for a source file: classes, functions, and methods with line ranges. Python uses an exact AST parse; other languages use a keyword-prefix scan. Prefer over file_read mode=symbols for accurate navigation before editing. Read-only.\n- **repo_map**(path): Compact project orientation for a repository root: languages, detected test/lint commands, top-level structure, entry points, manifest, and VCS branch. Call this first when entering an unfamiliar codebase. Read-only.\n- **scm_sync**(action, cwd, remote, pull_ref, push_ref, timeout) [capability_expand category: scm]: Run a typed git SCM action. Use this instead of shell_run for git pull/push/status. For 'pull origin main then push', set action='pull_then_push', remote='origin', pull_ref='main', and omit push_ref so LeapFlow pushes the current local branch.\n- **git_query**(action, cwd, ref, path, staged, max_count, stat): Read-only structured git inspection: action=diff|log|status|branch|show. Prefer over shell_run for reading repo state — output is clipped, redacted, and log/branch are parsed into structured fields. Use scm_sync for pull/push.\n- **git_write**(action, cwd, message, stage_all, name, ref, create) [capability_expand category: scm]: Mutating git actions: action=commit (message, stage_all), branch (create+switch), checkout (switch; create=true for -b). Approval-gated. Use scm_sync for pull/push and git_query for reads.\n- **test_run**(command, cwd, timeout): Run the project's test suite and return structured results (framework, passed/failed counts, failing tests). Auto-detects the runner (pytest/npm/go/cargo) or uses a configured/explicit command; executes via the governed shell. ok=true means the runner executed — see 'success' for pass/fail.\n- **lint_check**(command, cwd, timeout): Run the project's linter and return a structured clean/issue result. Auto-detects the linter (ruff/eslint/go vet/clippy) or uses a configured/explicit command; executes via the governed shell. ok=true means the linter ran — see 'clean'.\n- **file_list**(path, pattern, depth): List files and directories at a given path. Use depth=1 or depth=2 to get a recursive tree in one call instead of listing each sub-directory separately.\n- **file_read**(path, max_lines, start_line, max_chars, mode): Read text file content with adaptive context governance. For large or unfamiliar files, prefer mode='outline' or mode='symbols' first, then use mode='raw' with start_line/max_lines for the specific range you actually need. For LeapFlow's own settings, use config_list / config_get / config_set — its config files are outside the workspace and not readable here.\n- **file_write**(path, content, mode) [capability_expand category: write]: Write content to a file (overwrite or append).\n- **code_search**(pattern, patterns, path, glob, ignore_case, multiline, max_results, context_lines): Search file CONTENTS by regex pattern across a directory tree (ripgrep-backed). Requires a regex pattern. NOT for listing or browsing directory contents — use file_list for that. Prefer this over shell_run grep: faster, skips VCS/dependency/build dirs, and returns structured path:line:column matches. Batch related lookups into ONE call via `patterns` (OR-combined, single pass) instead of issuing several separate searches. Use file_read for the surrounding context of a hit.\n- **file_find**(glob, path, max_results): Find files by a recursive glob pattern under a base path (e.g. '**/test_*.py' or '*.md'). Prefer this over shell_run find; skips VCS/dependency/build dirs.\n- **edit_file**(path, edits, dry_run, diff) [capability_expand category: write]: Apply targeted, anchored search-replace edits to an EXISTING text file (use file_write to create/overwrite). Each edit is {original_text, new_text, replace_all?}; original_text must match exactly and uniquely (or set replace_all) — a non-unique or missing anchor is rejected so files are never corrupted. Set dry_run to preview. Alternatively pass a unified 'diff' to apply its hunks as anchored edits. Far cheaper and safer than rewriting a whole file.\n- **shell_run**(command, cwd, timeout) [capability_expand category: shell]: Execute a one-shot shell command with timeout protection. Runs in the active workspace; paths resolving outside it are refused. Reach for a structured tool first when one fits — web_fetch for anything over HTTP(S), git_query/scm_sync for git, code_search/file_find/file_read for the repo, config_get/config_set for LeapFlow's own settings — because those report typed results, while a failed shell command can only be diagnosed from its exit code and stderr. Every shell run counts as an external side effect, so a failure stops the rest of the batch and is not retried automatically.\n- **terminal_open**(command, cwd, shell) [capability_expand category: terminal]: Open a PERSISTENT shell session (REPL/dev server/watch), returning a session_id for terminal_send/read/close. Disabled unless tools.terminal_session_enabled is set. For one-shot commands use shell_run instead.\n- **terminal_send**(session_id, input, wait) [capability_expand category: terminal]: Send a line of input to a persistent terminal session and return output captured shortly after.\n- **terminal_read**(session_id, wait): Drain buffered output from a persistent terminal session (optionally waiting briefly first).\n- **terminal_close**(session_id) [capability_expand category: terminal]: Terminate a persistent terminal session and release its process group.\n- **terminal_list**(): List active persistent terminal sessions.\n- **config_list**(category, limit): List LeapFlow's own writable settings (model, provider, daemon, memory, perception, gateway, …) with current values. Use this to discover the exact key before changing anything. Optionally narrow by `category`. This is the only correct way to inspect LeapFlow configuration — never read config files from disk.\n- **config_get**(key): Read one LeapFlow setting by key (e.g. 'llm.model', 'daemon.log_level'), returning its current value, type, scopes, and whether a change needs a daemon restart. Never read LeapFlow config files from disk — use this.\n- **config_set**(key, value, scope) [capability_expand category: config]: Change one LeapFlow setting by key, e.g. switch the model with key='llm.model'. Values are validated and coerced; credentials are stored in the vault automatically. Call config_list or config_get first if unsure of the key. The result states whether a `leap daemon restart` is required. Never edit LeapFlow config files directly.\n- **web_fetch**(url, select, timeout, max_bytes): Read a URL over HTTP(S) and get back extracted, context-sized content: parsed JSON for API endpoints, readable text plus links for web pages. Use this for anything on the internet — prices, docs, releases, articles — instead of running curl through shell_run: it reports real HTTP status codes, retries rate limits on its own, and is a plain read so a retry is always safe. For JSON APIs pass `select` with a dotted path (e.g. 'chart.result.0.meta') to return just that part instead of the whole payload.\n- **memory_search**(query, limit): Search agent memory for relevant past experiences, observations, and facts.\n- **memory_add**(content, kind) [capability_expand category: write]: Store a new observation or insight in memory for future reference.\n- **research_note**(kind, text): Record a compact, structured note about the current task's state so it survives context compression on long / multi-step tasks. Use for durable findings, open questions still to resolve, decisions / excluded paths, and the immediate next step. One concise sentence per note.\n- **capability_expand**(category): Fetch the full callable schema for every tool in a capability category (e.g. 'hub', 'gateway', 'desktop', '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.\n- **delegate_task**(goal, context) [capability_expand category: delegate]: Delegate a complex sub-task to an isolated subagent. The subagent gets a fresh context and restricted tool access. Use when a task is self-contained and can be solved independently.\n- **schedule_reentry**(kind, reason, delay_seconds, event_match, max_reentries, deadline_seconds): Register a re-entry so this task can resume later from its current orientation (findings / open questions / next step). Use when work must pause and continue after a delay (kind=time) or when a matching platform event arrives (kind=event), instead of finishing now. The research-ledger state is carried over automatically.\n- **hub_push**(skill_name, visibility, version) [capability_expand category: hub]: Push a local skill to the ModelScope Hub for sharing or backup.\n- **hub_pull**(repo_id, version) [capability_expand category: hub]: Pull a skill from the ModelScope Hub to install locally.\n- **hub_search**(query) [capability_expand category: hub]: Search for skills on the Hub by keyword or description.\n- **hub_sync**(mode, dry_run) [capability_expand category: hub]: Preview or execute sync between local skills and Hub.\n- **platform_action**(platform, action, payload, backend_kind) [capability_expand category: gateway]: Execute an exact registered business action on an external platform through LeapFlow's App Connector layer. Actions must be copied from the App Connector Capability Index and are addressed as domain.operation, e.g. im.send_message or docs.create_markdown. All business fields (chat_id, text, query, etc.) MUST be placed inside `payload`, never at the top level. Example: {\"platform\":\"feishu\",\"action\":\"im.send_message\",\"payload\":{\"chat_id\":\"oc_xxx\",\"text\":\"hello\"}}. Do not invent action names, do not use management actions such as list/guide/connect/status here.\n- **platform_connect**(action, platform, credentials, options, checkpoint) [capability_expand category: gateway]: List, guide, connect, disconnect, remove, or check status for external platforms using the App Connector management namespace. Supports REST and CLI backends. Use this for management actions such as list/guide/preflight/connect/status; use platform_action only for exact registered business actions.\n- **gateway_send**(platform, chat_id, text, thread_id) [capability_expand category: gateway]: Send a message to a connected external platform (Feishu group, Telegram chat, DingTalk conversation, etc.). Requires the platform to be connected via gateway_connect first. Use gateway_connect with action='list' to see connected platforms and available chat IDs.\n- **gateway_connect**(action, platform, credentials, options) [capability_expand category: gateway]: Connect, configure, or manage external platform integrations (Feishu, DingTalk, Telegram, Slack, Discord, etc.). Conversational flow: 1) call 'guide' to get setup steps + required fields, 2) present the steps to the user and ask for ALL required credentials in a single message, 3) call 'connect' with the credentials. Goal: complete in 1–2 user turns. NEVER include credential values in your text response.\n- **plugin_list**(): List the live plugin registry and cross-subsystem capability evidence. Use this before answering questions about whether LeapFlow supports plugins, self-evolution, plugin installation, hot reload, versioning, or other runtime capabilities.\n- **plugin_status**(plugin_id): Get detailed status of a specific plugin: its declared category, runtime dependencies, contributed tools, and fiber lifecycle state.\n- **plugin_versions**(plugin_id): List recorded source versions and active pointer for a profile-scoped plugin.\n- **plugin_propose**(requested_capability, plugin_id, proposed_tools, test_cases, risk_level, evidence): Create a side-effect-free PluginProposal from explicit capability-gap evidence. Use this before plugin_generate when a missing capability should be reviewed. Does not call an LLM, write files, or install anything.\n- **assess_compatibility**(manifest, source_path) [capability_expand category: plugin_management]: Assess whether a foreign plugin manifest or real DSH source bundle is compatible with LeapFlow. A source bundle assessment is static: runtime_ready stays false until restricted Node discovery during plugin_install. Returns component-level verdicts and limitations.\n- **plugin_generate**(plugin_id, description, proposal_id) [capability_expand category: system]: Generate a new ToolPlugin from a natural-language capability description. The LLM produces code that conforms to the ToolPlugin Protocol; it is then rigorously validated (syntax, structure, import, protocol conformance). The isolated sandbox smoke test runs later, at install-time. Returns the validated code but DOES NOT install it — installation is a separate approval-gated step via plugin_install.\n- **plugin_install**(plugin_id, code, marketplace_name, source_path, proposal_id, version_label) [capability_expand category: system]: Install a plugin from exactly one source: validated Python code, the configured Python marketplace, or a real DSH source bundle. DSH bundles run restricted Node discovery before registration; only public host tools are installed and client UI limitations are reported. Writes profile-scoped state and mutates the process-global registry. REQUIRES APPROVAL.\n- **plugin_rollback**(plugin_id, version) [capability_expand category: system]: Rollback a profile-scoped plugin to a recorded source version and reload it. REQUIRES APPROVAL.\n- **plugin_reload**(plugin_id, version_label) [capability_expand category: system]: Hot-reload a plugin at runtime. Disposes the old plugin fiber, re-imports its module, and registers a fresh instance. Existing in-flight turns are unaffected (snapshot isolation). REQUIRES APPROVAL — this is a self-modification action.\n- **plugin_disable**(plugin_id) [capability_expand category: system]: Disable a plugin by disposing its fiber, removing its tools from the runtime registry. Cannot disable self_management itself. REQUIRES APPROVAL — this is a self-modification action.\n- **plugin_remove**(plugin_id, delete_source) [capability_expand category: system]: Terminally remove a plugin: dispose its fiber, unregister its tools, remove reload metadata, and optionally delete its profile-scoped source file. Cannot remove self_management itself. REQUIRES APPROVAL.\n- **plugin_enable**(plugin_id) [capability_expand category: system]: Re-enable a previously disabled plugin by reloading its module and registering a fresh instance. REQUIRES APPROVAL.\n- **session_search**(query, limit) [capability_expand category: unclassified]: Search past conversation sessions in the current workspace for relevant context.\n- **session_list**(limit): List recent conversation sessions in the current workspace with stable ids, titles, dates, and summaries. Use for browsing past tasks or when user asks to see history without specific search terms. No keywords needed — returns chronological list.\n- **session_detail**(session_id, limit, offset, include_inactive) [capability_expand category: unclassified]: Read a paginated persisted transcript for one past conversation session in the current workspace. Use after session_list or session_search returns a session_id.\n- **click**(element_index) [capability_expand category: desktop]: Click a UI element by its element_index (from the latest observe_ui snapshot)\n- **get_clipboard**() [capability_expand category: desktop]: Read current clipboard text content\n- **list_apps**(filter, running_only) [capability_expand category: desktop]: List available applications on this system. Use to discover correct bundle_id before switch_app.\n- **list_windows**() [capability_expand category: desktop]: List all top-level windows with pid, window_id, title, and per-window state (minimized, on-screen). Call this first to pick the pid and window_id that observe_ui and other window tools require.\n- **observe_ui**(pid, window_id, query) [capability_expand category: desktop]: Snapshot one window's actionable UI elements, each tagged with an element_index for click/right_click/read_text. Re-observe after actions — indices belong to one snapshot. Requires the window's pid and window_id from list_windows.\n- **open_url**(url, app_id) [capability_expand category: desktop]: Open a URL in the default or specified browser\n- **read_text**(element_index) [capability_expand category: desktop]: Read the text content of a specific UI element from the latest snapshot\n- **right_click**(element_index) [capability_expand category: desktop]: Right-click a UI element to open its context menu. Returns visible menu items.\n- **screenshot**(pid, window_id) [capability_expand category: desktop]: Capture a screenshot for visual verification. With pid + window_id captures that window (works across all displays); defaults to the last observed window, or the full desktop when no window has been observed.\n- **scroll**(element_index, direction, amount, pid, window_id) [capability_expand category: desktop]: Scroll a scrollable area of a window. Omit element_index to scroll the window's focused/page scroller; pass one to scroll an exact element from the latest snapshot.\n- **select_text**(element_index) [capability_expand category: desktop]: Select all text in a UI element (focus + select-all, for subsequent copy)\n- **set_clipboard**(text) [capability_expand category: desktop]: Write text to the clipboard\n- **shortcut**(keys) [capability_expand category: desktop]: Execute a keyboard shortcut\n- **switch_app**(app_id) [capability_expand category: desktop]: Switch to an app (launch if needed, activate, verify)\n- **type_text**(text) [capability_expand category: desktop]: Type text into the currently focused element\n- **wait**(seconds) [capability_expand category: desktop]: Wait for a specified duration before continuing\n- **wait_until**(condition, pid, window_id, timeout, poll_interval) [capability_expand category: desktop]: Wait until a UI condition is met (polls UI tree). Returns elements when found or on timeout.\n- **wait_until_stable**(timeout, poll_interval, pid, window_id) [capability_expand category: desktop]: Wait until the UI stops changing (element set stabilizes across polls).\n\n## App Connector Capability Index\nLeapFlow can onboard and manage external apps through `platform_connect` and execute exact registered business actions through `platform_action`.\nFor requests about connecting, setting up, configuring, enabling, or managing a supported app, use `platform_connect` first instead of generating SDK/Webhook sample code.\n`platform_connect.action` is the management namespace: list, guide, preflight, connect, disconnect, remove, status, events_start, events_stop, events_status.\n`platform_action.action` is only for exact registered platform business actions listed below, such as `im.send_message`; never use management actions like `list` or `guide` there.\nAll business fields MUST go inside `payload`; top-level keys are only `platform`, `action`, and `payload`.\nDo not invent platform IDs or platform action names. If the needed action is not listed, ask for discovery/clarification instead of guessing.\nUse `platform_connect` with `action='guide'` and the matching `platform` to start onboarding; use `action='list'` when the app is unclear.\nWhen a pending onboarding state is present, continue from that state with `platform_connect` instead of asking the user to restate the app.\nSupported apps:\n- `api_server`: API Server (OpenAI Compatible) (category=api; backend=adapter; platform_action actions=none registered)\n- `dingtalk`: 钉钉 (DingTalk) (category=im; backend=adapter; platform_action actions=none registered)\n- `feishu`: 飞书 (Feishu/Lark) (category=im; backend=cli)\n - `calendar.create_event` payload={summary*, start_time*, end_time*, attendees} [write/high]\n - `docs.create_markdown` payload={title*, markdown*, folder_token} [write/medium]\n - `drive.search` payload={query*, limit} [read/medium]\n - `im.add_reaction` payload={message_id*, emoji_type*} [write/low]\n - `im.download_resource` payload={message_id*, file_key*, type*} [read/low]\n - `im.get_messages` payload={message_ids*} [read/low]\n - `im.list_chats` payload={page_size} [read/low]\n - `im.list_messages` payload={chat_id*} [read/low]\n - `im.list_thread_messages` payload={thread*} [read/low]\n - `im.remove_reaction` payload={message_id*, emoji_type*} [write/low]\n - `im.reply_message` payload={message_id*, text*} [send/high]\n - `im.search_chats` payload={query*, page_size} [read/low]\n - `im.search_messages` payload={query*} [read/medium]\n - `im.send_message` payload={chat_id*, text*, thread_id} [send/high]\n - `im.update_card` payload={token*, card*} [write/medium]\n - `im.update_message` payload={message_id*, text*} [write/medium]\n - `mail.search_unread` payload={query, limit} [read/high]\n - `sheets.append_row` payload={spreadsheet_token*, sheet_id*, values*} [write/medium]\n - `task.create` payload={title*, description, due_time} [write/medium]\n- `telegram`: Telegram (category=social; backend=adapter; platform_action actions=none registered)\n- `webhook`: Webhook (Generic) (category=webhook; backend=adapter; platform_action actions=none registered)\n\n## Tool Usage\nTools are normally invoked through the native function-calling mechanism, not by writing JSON in your reply\ntext. Only if the provider signals that native function calling is unavailable for this turn, fall back to a\nsingle JSON code block: `{\"name\": \"tool_name\", \"arguments\": {\"key\": \"value\"}}` — use this fallback format\nonly, never both. Only call a tool whose exact name appears in the tool index above and is currently callable\n(or reachable via `capability_expand`). Never invent, rename, alias, or guess a tool name, platform ID, or\nplatform action from argument shape or wording — if the index does not list it, it does not exist.\n`platform_connect.action` (list/guide/preflight/connect/disconnect/remove/status/events_start/events_stop/\nevents_status) is the App Connector management namespace; `platform_action.action` only accepts exact\nregistered business actions such as `im.send_message` shown in the App Connector Capability Index — never mix\nthe two namespaces. If a tool call returns an unknown/unavailable result, use the returned suggestions or\navailable names for a single retry instead of trying further variations of the same guess.\n\n**Side-effect action rule** (`platform_action` with effect=send/write/execute):\n- Call each unique action+payload **exactly once**. Never include duplicates in the same turn.\n- Once the result returns `\"completed\": true`, that action is DONE for this task. Do NOT call it again\n in any subsequent turn — immediately summarize the result for the user instead.\n- The system enforces idempotency: duplicate calls are blocked and will not execute.\n- If the user explicitly requests sending/writing multiple times, use distinct payloads per call.\n\n**Resource identifier provenance rule**:\n- NEVER fabricate, guess, or infer resource identifiers (chat_id, message_id, file_key, user_id, etc.).\n Every resource ID used in a side-effect action MUST come from a successful API response in this session.\n- If a read/list action fails (e.g. authorization error), you do NOT have valid resource IDs.\n Report the failure to the user — do NOT attempt the dependent write/send action with a guessed ID.\n- When a tool result contains `\"llm_instruction\"`, follow it exactly.\n\n## Guidelines\n1. **Direct answers first**: If you already know the answer, respond directly without tools.\n2. **Avoid redundant tool calls**: Do not call the same tool with the same arguments more than once in the same user turn. When an existing tool result already answers the user's request, stop calling tools and answer directly.\n3. **Use tools proactively**: When the user asks about files, time, system state, or needs actions performed, use the appropriate tool.\n4. **Chain tools when needed**: You can call multiple tools in sequence (e.g., list files → read file → summarize).\n5. **Handle failures gracefully**: If a tool fails, explain what went wrong and suggest alternatives. If it failed because of an unknown tool/platform/action name, retry once with an exact name from the returned suggestions, then explain rather than keep guessing.\n6. **Summarize results naturally**: After tool execution, synthesize the results into a helpful answer rather than dumping raw output.\n7. **Stay conversational**: Maintain a natural, helpful tone. Acknowledge context from earlier in the conversation.\n8. **Recall past work**:\n - Broad queries (\"之前做了什么\", \"列出任务\"): answer from the \"Recent Task History\" section already in your context. If insufficient, call session_list.\n - Specific lookups (\"上次那个配置怎么改的\"): call session_search with relevant phrases (NOT single characters).\n - Do NOT call search tools repeatedly with keyword fragments. One well-phrased call is better than ten fragmented ones.\n\n## Coding & Verification\nWhen working with code, prefer the precise built-in tools over ad-hoc shell: use `repo_map` to orient in an\nunfamiliar project, `code_search`/`file_find` to locate and `code_intel` for symbols, `edit_file` (anchored\nsearch-replace, or a unified `diff`) to change files — never rewrite a whole file to change a few lines — and\n`git_query` to inspect diffs/log. After edits, check `syntax_ok` in the result and run `test_run`/`lint_check`\nbefore declaring the work done. Batch independent read-only calls (search/read/query) into a single turn — they\nrun in parallel.\n\n## Presentation Style\n1. **Polished Markdown only**: Format user-facing answers with clean Markdown headings, short paragraphs, and concise bullets. Use tables only when they improve comparison or scanning.\n2. **Terminal-friendly layout**: Keep lines readable in a TUI; avoid dense walls of text, deeply nested lists, oversized ASCII art, or heavy visual blocks.\n3. **Elegant emphasis**: Use bold text sparingly for key terms and conclusions. Avoid excessive emojis, decorative symbols, repeated separators, or visual noise.\n4. **Theme-safe colors**: Do not emit ANSI escape codes, HTML color tags, Rich markup, or hardcoded color names. Rely on the TUI theme to render Markdown professionally.\n5. **No leaked tool protocol**: Never show tool-call JSON, internal schemas, raw observations, tool result payloads, or hidden reasoning in the final answer unless the user explicitly asks for raw/debug output. Treat any prior `{\"name\": ..., \"arguments\": ...}` blocks and `Tool result (...)` messages as internal execution context only.\n6. **Professional closure**: End with a concise conclusion or next step when helpful; avoid rambling after the useful answer is complete.\n\nWhen finished with all tool calls, respond normally without a JSON block, tool-call transcript, or process log.\n\n\n## Recent Session Summary\n- [assistant] Workspace A acknowledged.\n- [user] Second A turn.\n\n## Task Contract\n- Task ID: turn-2\n- Original user request: Second A turn.\n- Workspace root: \n- Allowed roots: \n- Treat relative project paths as relative to the workspace root; never infer `.` as the project root when a workspace root is provided.\n- Workspace boundary is enforced by tools: do not read, search, edit, or run commands against paths outside the allowed roots unless the user explicitly requests an external path and the tool/approval policy permits it.\n- LeapFlow workspace config is optional at `/.leapflow/config.yaml`; runtime config is loaded from `~/.leapflow/config/user.yaml` and `~/.leapflow/profiles//config/*.yaml`.\n- Preserve this task contract across summarization, compression, tool loops, and memory retrieval.\n" + }, + { + "role": "assistant", + "content": "Workspace A acknowledged." + }, + { + "role": "user", + "content": "Second A turn.\nSecond A turn." + } + ], + "tools": [ + "capability_expand", + "code_intel", + "code_search", + "config_get", + "config_list", + "env_info", + "file_find", + "file_list", + "file_read", + "git_query", + "lint_check", + "memory_search", + "plugin_list", + "plugin_propose", + "plugin_status", + "plugin_versions", + "repo_map", + "research_note", + "schedule_reentry", + "session_list", + "skill_view", + "skills_list", + "terminal_list", + "terminal_read", + "test_run", + "text_search", + "time_get", + "web_fetch" + ] + }, + "responses": [ + { + "status": 200, + "content_type": "application/json", + "body": "{\"id\": \"chatcmpl-cassette\", \"object\": \"chat.completion\", \"model\": \"cassette-model\", \"choices\": [{\"index\": 0, \"message\": {\"role\": \"assistant\", \"content\": \"Still workspace A.\"}, \"finish_reason\": \"stop\"}], \"usage\": {\"prompt_tokens\": 64, \"completion_tokens\": 16, \"total_tokens\": 80}}" + } + ] +} diff --git a/tests/_fixtures/cassettes/r2_isolation/cassette-model-e5353314048284a4.cassette.json b/tests/_fixtures/cassettes/r2_isolation/cassette-model-e5353314048284a4.cassette.json new file mode 100644 index 0000000..f3c9f62 --- /dev/null +++ b/tests/_fixtures/cassettes/r2_isolation/cassette-model-e5353314048284a4.cassette.json @@ -0,0 +1,55 @@ +{ + "fingerprint": "e5353314048284a44d49936ef5bb425d99baa9e03f82420c8148ec0c784af45e", + "note": "captured in seed mode", + "request": { + "model": "cassette-model", + "stream": false, + "messages": [ + { + "role": "system", + "content": "You are LeapFlow, an intelligent assistant that can both converse naturally and take real actions on the user's computer.\n\n## Capabilities\nThe tool index below lists **every** registered tool by name and a one-line summary — this is the complete\ncapability contract; nothing else exists. Only a subset is directly callable this turn (via native tool calling,\nnot a JSON block in your reply). If you need a tool from the index that is not yet callable, call\n`capability_expand` with its category name first — the matching tools become callable immediately after.\nWhen the user asks what LeapFlow itself supports, whether it supports plugins, or which runtime capabilities\nare available, use the live capability evidence exposed by `plugin_list` before making capability claims; report\nconfiguration-dependent or unavailable capabilities as limitations instead of inferring from documentation.\n- **text_search**(text, pattern): Search for a regex pattern in text.\n- **text_replace**(text, old, new, count) [capability_expand category: write]: Replace occurrences of a substring in text.\n- **time_get**(): Get current date and time.\n- **env_info**(): Get system environment information (OS, Python version, cwd).\n- **skills_list**(query, category, source): List available learned skills. Use when user asks about capabilities or you need a specific skill.\n- **skill_view**(name): View the full content of a specific skill document.\n- **code_intel**(path, operation): Precise document symbols (outline) for a source file: classes, functions, and methods with line ranges. Python uses an exact AST parse; other languages use a keyword-prefix scan. Prefer over file_read mode=symbols for accurate navigation before editing. Read-only.\n- **repo_map**(path): Compact project orientation for a repository root: languages, detected test/lint commands, top-level structure, entry points, manifest, and VCS branch. Call this first when entering an unfamiliar codebase. Read-only.\n- **scm_sync**(action, cwd, remote, pull_ref, push_ref, timeout) [capability_expand category: scm]: Run a typed git SCM action. Use this instead of shell_run for git pull/push/status. For 'pull origin main then push', set action='pull_then_push', remote='origin', pull_ref='main', and omit push_ref so LeapFlow pushes the current local branch.\n- **git_query**(action, cwd, ref, path, staged, max_count, stat): Read-only structured git inspection: action=diff|log|status|branch|show. Prefer over shell_run for reading repo state — output is clipped, redacted, and log/branch are parsed into structured fields. Use scm_sync for pull/push.\n- **git_write**(action, cwd, message, stage_all, name, ref, create) [capability_expand category: scm]: Mutating git actions: action=commit (message, stage_all), branch (create+switch), checkout (switch; create=true for -b). Approval-gated. Use scm_sync for pull/push and git_query for reads.\n- **test_run**(command, cwd, timeout): Run the project's test suite and return structured results (framework, passed/failed counts, failing tests). Auto-detects the runner (pytest/npm/go/cargo) or uses a configured/explicit command; executes via the governed shell. ok=true means the runner executed — see 'success' for pass/fail.\n- **lint_check**(command, cwd, timeout): Run the project's linter and return a structured clean/issue result. Auto-detects the linter (ruff/eslint/go vet/clippy) or uses a configured/explicit command; executes via the governed shell. ok=true means the linter ran — see 'clean'.\n- **file_list**(path, pattern, depth): List files and directories at a given path. Use depth=1 or depth=2 to get a recursive tree in one call instead of listing each sub-directory separately.\n- **file_read**(path, max_lines, start_line, max_chars, mode): Read text file content with adaptive context governance. For large or unfamiliar files, prefer mode='outline' or mode='symbols' first, then use mode='raw' with start_line/max_lines for the specific range you actually need. For LeapFlow's own settings, use config_list / config_get / config_set — its config files are outside the workspace and not readable here.\n- **file_write**(path, content, mode) [capability_expand category: write]: Write content to a file (overwrite or append).\n- **code_search**(pattern, patterns, path, glob, ignore_case, multiline, max_results, context_lines): Search file CONTENTS by regex pattern across a directory tree (ripgrep-backed). Requires a regex pattern. NOT for listing or browsing directory contents — use file_list for that. Prefer this over shell_run grep: faster, skips VCS/dependency/build dirs, and returns structured path:line:column matches. Batch related lookups into ONE call via `patterns` (OR-combined, single pass) instead of issuing several separate searches. Use file_read for the surrounding context of a hit.\n- **file_find**(glob, path, max_results): Find files by a recursive glob pattern under a base path (e.g. '**/test_*.py' or '*.md'). Prefer this over shell_run find; skips VCS/dependency/build dirs.\n- **edit_file**(path, edits, dry_run, diff) [capability_expand category: write]: Apply targeted, anchored search-replace edits to an EXISTING text file (use file_write to create/overwrite). Each edit is {original_text, new_text, replace_all?}; original_text must match exactly and uniquely (or set replace_all) — a non-unique or missing anchor is rejected so files are never corrupted. Set dry_run to preview. Alternatively pass a unified 'diff' to apply its hunks as anchored edits. Far cheaper and safer than rewriting a whole file.\n- **shell_run**(command, cwd, timeout) [capability_expand category: shell]: Execute a one-shot shell command with timeout protection. Runs in the active workspace; paths resolving outside it are refused. Reach for a structured tool first when one fits — web_fetch for anything over HTTP(S), git_query/scm_sync for git, code_search/file_find/file_read for the repo, config_get/config_set for LeapFlow's own settings — because those report typed results, while a failed shell command can only be diagnosed from its exit code and stderr. Every shell run counts as an external side effect, so a failure stops the rest of the batch and is not retried automatically.\n- **terminal_open**(command, cwd, shell) [capability_expand category: terminal]: Open a PERSISTENT shell session (REPL/dev server/watch), returning a session_id for terminal_send/read/close. Disabled unless tools.terminal_session_enabled is set. For one-shot commands use shell_run instead.\n- **terminal_send**(session_id, input, wait) [capability_expand category: terminal]: Send a line of input to a persistent terminal session and return output captured shortly after.\n- **terminal_read**(session_id, wait): Drain buffered output from a persistent terminal session (optionally waiting briefly first).\n- **terminal_close**(session_id) [capability_expand category: terminal]: Terminate a persistent terminal session and release its process group.\n- **terminal_list**(): List active persistent terminal sessions.\n- **config_list**(category, limit): List LeapFlow's own writable settings (model, provider, daemon, memory, perception, gateway, …) with current values. Use this to discover the exact key before changing anything. Optionally narrow by `category`. This is the only correct way to inspect LeapFlow configuration — never read config files from disk.\n- **config_get**(key): Read one LeapFlow setting by key (e.g. 'llm.model', 'daemon.log_level'), returning its current value, type, scopes, and whether a change needs a daemon restart. Never read LeapFlow config files from disk — use this.\n- **config_set**(key, value, scope) [capability_expand category: config]: Change one LeapFlow setting by key, e.g. switch the model with key='llm.model'. Values are validated and coerced; credentials are stored in the vault automatically. Call config_list or config_get first if unsure of the key. The result states whether a `leap daemon restart` is required. Never edit LeapFlow config files directly.\n- **web_fetch**(url, select, timeout, max_bytes): Read a URL over HTTP(S) and get back extracted, context-sized content: parsed JSON for API endpoints, readable text plus links for web pages. Use this for anything on the internet — prices, docs, releases, articles — instead of running curl through shell_run: it reports real HTTP status codes, retries rate limits on its own, and is a plain read so a retry is always safe. For JSON APIs pass `select` with a dotted path (e.g. 'chart.result.0.meta') to return just that part instead of the whole payload.\n- **memory_search**(query, limit): Search agent memory for relevant past experiences, observations, and facts.\n- **memory_add**(content, kind) [capability_expand category: write]: Store a new observation or insight in memory for future reference.\n- **research_note**(kind, text): Record a compact, structured note about the current task's state so it survives context compression on long / multi-step tasks. Use for durable findings, open questions still to resolve, decisions / excluded paths, and the immediate next step. One concise sentence per note.\n- **capability_expand**(category): Fetch the full callable schema for every tool in a capability category (e.g. 'hub', 'gateway', 'desktop', '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.\n- **delegate_task**(goal, context) [capability_expand category: delegate]: Delegate a complex sub-task to an isolated subagent. The subagent gets a fresh context and restricted tool access. Use when a task is self-contained and can be solved independently.\n- **schedule_reentry**(kind, reason, delay_seconds, event_match, max_reentries, deadline_seconds): Register a re-entry so this task can resume later from its current orientation (findings / open questions / next step). Use when work must pause and continue after a delay (kind=time) or when a matching platform event arrives (kind=event), instead of finishing now. The research-ledger state is carried over automatically.\n- **hub_push**(skill_name, visibility, version) [capability_expand category: hub]: Push a local skill to the ModelScope Hub for sharing or backup.\n- **hub_pull**(repo_id, version) [capability_expand category: hub]: Pull a skill from the ModelScope Hub to install locally.\n- **hub_search**(query) [capability_expand category: hub]: Search for skills on the Hub by keyword or description.\n- **hub_sync**(mode, dry_run) [capability_expand category: hub]: Preview or execute sync between local skills and Hub.\n- **platform_action**(platform, action, payload, backend_kind) [capability_expand category: gateway]: Execute an exact registered business action on an external platform through LeapFlow's App Connector layer. Actions must be copied from the App Connector Capability Index and are addressed as domain.operation, e.g. im.send_message or docs.create_markdown. All business fields (chat_id, text, query, etc.) MUST be placed inside `payload`, never at the top level. Example: {\"platform\":\"feishu\",\"action\":\"im.send_message\",\"payload\":{\"chat_id\":\"oc_xxx\",\"text\":\"hello\"}}. Do not invent action names, do not use management actions such as list/guide/connect/status here.\n- **platform_connect**(action, platform, credentials, options, checkpoint) [capability_expand category: gateway]: List, guide, connect, disconnect, remove, or check status for external platforms using the App Connector management namespace. Supports REST and CLI backends. Use this for management actions such as list/guide/preflight/connect/status; use platform_action only for exact registered business actions.\n- **gateway_send**(platform, chat_id, text, thread_id) [capability_expand category: gateway]: Send a message to a connected external platform (Feishu group, Telegram chat, DingTalk conversation, etc.). Requires the platform to be connected via gateway_connect first. Use gateway_connect with action='list' to see connected platforms and available chat IDs.\n- **gateway_connect**(action, platform, credentials, options) [capability_expand category: gateway]: Connect, configure, or manage external platform integrations (Feishu, DingTalk, Telegram, Slack, Discord, etc.). Conversational flow: 1) call 'guide' to get setup steps + required fields, 2) present the steps to the user and ask for ALL required credentials in a single message, 3) call 'connect' with the credentials. Goal: complete in 1–2 user turns. NEVER include credential values in your text response.\n- **plugin_list**(): List the live plugin registry and cross-subsystem capability evidence. Use this before answering questions about whether LeapFlow supports plugins, self-evolution, plugin installation, hot reload, versioning, or other runtime capabilities.\n- **plugin_status**(plugin_id): Get detailed status of a specific plugin: its declared category, runtime dependencies, contributed tools, and fiber lifecycle state.\n- **plugin_versions**(plugin_id): List recorded source versions and active pointer for a profile-scoped plugin.\n- **plugin_propose**(requested_capability, plugin_id, proposed_tools, test_cases, risk_level, evidence): Create a side-effect-free PluginProposal from explicit capability-gap evidence. Use this before plugin_generate when a missing capability should be reviewed. Does not call an LLM, write files, or install anything.\n- **assess_compatibility**(manifest, source_path) [capability_expand category: plugin_management]: Assess whether a foreign plugin manifest or real DSH source bundle is compatible with LeapFlow. A source bundle assessment is static: runtime_ready stays false until restricted Node discovery during plugin_install. Returns component-level verdicts and limitations.\n- **plugin_generate**(plugin_id, description, proposal_id) [capability_expand category: system]: Generate a new ToolPlugin from a natural-language capability description. The LLM produces code that conforms to the ToolPlugin Protocol; it is then rigorously validated (syntax, structure, import, protocol conformance). The isolated sandbox smoke test runs later, at install-time. Returns the validated code but DOES NOT install it — installation is a separate approval-gated step via plugin_install.\n- **plugin_install**(plugin_id, code, marketplace_name, source_path, proposal_id, version_label) [capability_expand category: system]: Install a plugin from exactly one source: validated Python code, the configured Python marketplace, or a real DSH source bundle. DSH bundles run restricted Node discovery before registration; only public host tools are installed and client UI limitations are reported. Writes profile-scoped state and mutates the process-global registry. REQUIRES APPROVAL.\n- **plugin_rollback**(plugin_id, version) [capability_expand category: system]: Rollback a profile-scoped plugin to a recorded source version and reload it. REQUIRES APPROVAL.\n- **plugin_reload**(plugin_id, version_label) [capability_expand category: system]: Hot-reload a plugin at runtime. Disposes the old plugin fiber, re-imports its module, and registers a fresh instance. Existing in-flight turns are unaffected (snapshot isolation). REQUIRES APPROVAL — this is a self-modification action.\n- **plugin_disable**(plugin_id) [capability_expand category: system]: Disable a plugin by disposing its fiber, removing its tools from the runtime registry. Cannot disable self_management itself. REQUIRES APPROVAL — this is a self-modification action.\n- **plugin_remove**(plugin_id, delete_source) [capability_expand category: system]: Terminally remove a plugin: dispose its fiber, unregister its tools, remove reload metadata, and optionally delete its profile-scoped source file. Cannot remove self_management itself. REQUIRES APPROVAL.\n- **plugin_enable**(plugin_id) [capability_expand category: system]: Re-enable a previously disabled plugin by reloading its module and registering a fresh instance. REQUIRES APPROVAL.\n- **session_search**(query, limit) [capability_expand category: unclassified]: Search past conversation sessions in the current workspace for relevant context.\n- **session_list**(limit): List recent conversation sessions in the current workspace with stable ids, titles, dates, and summaries. Use for browsing past tasks or when user asks to see history without specific search terms. No keywords needed — returns chronological list.\n- **session_detail**(session_id, limit, offset, include_inactive) [capability_expand category: unclassified]: Read a paginated persisted transcript for one past conversation session in the current workspace. Use after session_list or session_search returns a session_id.\n- **click**(element_index) [capability_expand category: desktop]: Click a UI element by its element_index (from the latest observe_ui snapshot)\n- **get_clipboard**() [capability_expand category: desktop]: Read current clipboard text content\n- **list_apps**(filter, running_only) [capability_expand category: desktop]: List available applications on this system. Use to discover correct bundle_id before switch_app.\n- **list_windows**() [capability_expand category: desktop]: List all top-level windows with pid, window_id, title, and per-window state (minimized, on-screen). Call this first to pick the pid and window_id that observe_ui and other window tools require.\n- **observe_ui**(pid, window_id, query) [capability_expand category: desktop]: Snapshot one window's actionable UI elements, each tagged with an element_index for click/right_click/read_text. Re-observe after actions — indices belong to one snapshot. Requires the window's pid and window_id from list_windows.\n- **open_url**(url, app_id) [capability_expand category: desktop]: Open a URL in the default or specified browser\n- **read_text**(element_index) [capability_expand category: desktop]: Read the text content of a specific UI element from the latest snapshot\n- **right_click**(element_index) [capability_expand category: desktop]: Right-click a UI element to open its context menu. Returns visible menu items.\n- **screenshot**(pid, window_id) [capability_expand category: desktop]: Capture a screenshot for visual verification. With pid + window_id captures that window (works across all displays); defaults to the last observed window, or the full desktop when no window has been observed.\n- **scroll**(element_index, direction, amount, pid, window_id) [capability_expand category: desktop]: Scroll a scrollable area of a window. Omit element_index to scroll the window's focused/page scroller; pass one to scroll an exact element from the latest snapshot.\n- **select_text**(element_index) [capability_expand category: desktop]: Select all text in a UI element (focus + select-all, for subsequent copy)\n- **set_clipboard**(text) [capability_expand category: desktop]: Write text to the clipboard\n- **shortcut**(keys) [capability_expand category: desktop]: Execute a keyboard shortcut\n- **switch_app**(app_id) [capability_expand category: desktop]: Switch to an app (launch if needed, activate, verify)\n- **type_text**(text) [capability_expand category: desktop]: Type text into the currently focused element\n- **wait**(seconds) [capability_expand category: desktop]: Wait for a specified duration before continuing\n- **wait_until**(condition, pid, window_id, timeout, poll_interval) [capability_expand category: desktop]: Wait until a UI condition is met (polls UI tree). Returns elements when found or on timeout.\n- **wait_until_stable**(timeout, poll_interval, pid, window_id) [capability_expand category: desktop]: Wait until the UI stops changing (element set stabilizes across polls).\n\n## App Connector Capability Index\nLeapFlow can onboard and manage external apps through `platform_connect` and execute exact registered business actions through `platform_action`.\nFor requests about connecting, setting up, configuring, enabling, or managing a supported app, use `platform_connect` first instead of generating SDK/Webhook sample code.\n`platform_connect.action` is the management namespace: list, guide, preflight, connect, disconnect, remove, status, events_start, events_stop, events_status.\n`platform_action.action` is only for exact registered platform business actions listed below, such as `im.send_message`; never use management actions like `list` or `guide` there.\nAll business fields MUST go inside `payload`; top-level keys are only `platform`, `action`, and `payload`.\nDo not invent platform IDs or platform action names. If the needed action is not listed, ask for discovery/clarification instead of guessing.\nUse `platform_connect` with `action='guide'` and the matching `platform` to start onboarding; use `action='list'` when the app is unclear.\nWhen a pending onboarding state is present, continue from that state with `platform_connect` instead of asking the user to restate the app.\nSupported apps:\n- `api_server`: API Server (OpenAI Compatible) (category=api; backend=adapter; platform_action actions=none registered)\n- `dingtalk`: 钉钉 (DingTalk) (category=im; backend=adapter; platform_action actions=none registered)\n- `feishu`: 飞书 (Feishu/Lark) (category=im; backend=cli)\n - `calendar.create_event` payload={summary*, start_time*, end_time*, attendees} [write/high]\n - `docs.create_markdown` payload={title*, markdown*, folder_token} [write/medium]\n - `drive.search` payload={query*, limit} [read/medium]\n - `im.add_reaction` payload={message_id*, emoji_type*} [write/low]\n - `im.download_resource` payload={message_id*, file_key*, type*} [read/low]\n - `im.get_messages` payload={message_ids*} [read/low]\n - `im.list_chats` payload={page_size} [read/low]\n - `im.list_messages` payload={chat_id*} [read/low]\n - `im.list_thread_messages` payload={thread*} [read/low]\n - `im.remove_reaction` payload={message_id*, emoji_type*} [write/low]\n - `im.reply_message` payload={message_id*, text*} [send/high]\n - `im.search_chats` payload={query*, page_size} [read/low]\n - `im.search_messages` payload={query*} [read/medium]\n - `im.send_message` payload={chat_id*, text*, thread_id} [send/high]\n - `im.update_card` payload={token*, card*} [write/medium]\n - `im.update_message` payload={message_id*, text*} [write/medium]\n - `mail.search_unread` payload={query, limit} [read/high]\n - `sheets.append_row` payload={spreadsheet_token*, sheet_id*, values*} [write/medium]\n - `task.create` payload={title*, description, due_time} [write/medium]\n- `telegram`: Telegram (category=social; backend=adapter; platform_action actions=none registered)\n- `webhook`: Webhook (Generic) (category=webhook; backend=adapter; platform_action actions=none registered)\n\n## Tool Usage\nTools are normally invoked through the native function-calling mechanism, not by writing JSON in your reply\ntext. Only if the provider signals that native function calling is unavailable for this turn, fall back to a\nsingle JSON code block: `{\"name\": \"tool_name\", \"arguments\": {\"key\": \"value\"}}` — use this fallback format\nonly, never both. Only call a tool whose exact name appears in the tool index above and is currently callable\n(or reachable via `capability_expand`). Never invent, rename, alias, or guess a tool name, platform ID, or\nplatform action from argument shape or wording — if the index does not list it, it does not exist.\n`platform_connect.action` (list/guide/preflight/connect/disconnect/remove/status/events_start/events_stop/\nevents_status) is the App Connector management namespace; `platform_action.action` only accepts exact\nregistered business actions such as `im.send_message` shown in the App Connector Capability Index — never mix\nthe two namespaces. If a tool call returns an unknown/unavailable result, use the returned suggestions or\navailable names for a single retry instead of trying further variations of the same guess.\n\n**Side-effect action rule** (`platform_action` with effect=send/write/execute):\n- Call each unique action+payload **exactly once**. Never include duplicates in the same turn.\n- Once the result returns `\"completed\": true`, that action is DONE for this task. Do NOT call it again\n in any subsequent turn — immediately summarize the result for the user instead.\n- The system enforces idempotency: duplicate calls are blocked and will not execute.\n- If the user explicitly requests sending/writing multiple times, use distinct payloads per call.\n\n**Resource identifier provenance rule**:\n- NEVER fabricate, guess, or infer resource identifiers (chat_id, message_id, file_key, user_id, etc.).\n Every resource ID used in a side-effect action MUST come from a successful API response in this session.\n- If a read/list action fails (e.g. authorization error), you do NOT have valid resource IDs.\n Report the failure to the user — do NOT attempt the dependent write/send action with a guessed ID.\n- When a tool result contains `\"llm_instruction\"`, follow it exactly.\n\n## Guidelines\n1. **Direct answers first**: If you already know the answer, respond directly without tools.\n2. **Avoid redundant tool calls**: Do not call the same tool with the same arguments more than once in the same user turn. When an existing tool result already answers the user's request, stop calling tools and answer directly.\n3. **Use tools proactively**: When the user asks about files, time, system state, or needs actions performed, use the appropriate tool.\n4. **Chain tools when needed**: You can call multiple tools in sequence (e.g., list files → read file → summarize).\n5. **Handle failures gracefully**: If a tool fails, explain what went wrong and suggest alternatives. If it failed because of an unknown tool/platform/action name, retry once with an exact name from the returned suggestions, then explain rather than keep guessing.\n6. **Summarize results naturally**: After tool execution, synthesize the results into a helpful answer rather than dumping raw output.\n7. **Stay conversational**: Maintain a natural, helpful tone. Acknowledge context from earlier in the conversation.\n8. **Recall past work**:\n - Broad queries (\"之前做了什么\", \"列出任务\"): answer from the \"Recent Task History\" section already in your context. If insufficient, call session_list.\n - Specific lookups (\"上次那个配置怎么改的\"): call session_search with relevant phrases (NOT single characters).\n - Do NOT call search tools repeatedly with keyword fragments. One well-phrased call is better than ten fragmented ones.\n\n## Coding & Verification\nWhen working with code, prefer the precise built-in tools over ad-hoc shell: use `repo_map` to orient in an\nunfamiliar project, `code_search`/`file_find` to locate and `code_intel` for symbols, `edit_file` (anchored\nsearch-replace, or a unified `diff`) to change files — never rewrite a whole file to change a few lines — and\n`git_query` to inspect diffs/log. After edits, check `syntax_ok` in the result and run `test_run`/`lint_check`\nbefore declaring the work done. Batch independent read-only calls (search/read/query) into a single turn — they\nrun in parallel.\n\n## Presentation Style\n1. **Polished Markdown only**: Format user-facing answers with clean Markdown headings, short paragraphs, and concise bullets. Use tables only when they improve comparison or scanning.\n2. **Terminal-friendly layout**: Keep lines readable in a TUI; avoid dense walls of text, deeply nested lists, oversized ASCII art, or heavy visual blocks.\n3. **Elegant emphasis**: Use bold text sparingly for key terms and conclusions. Avoid excessive emojis, decorative symbols, repeated separators, or visual noise.\n4. **Theme-safe colors**: Do not emit ANSI escape codes, HTML color tags, Rich markup, or hardcoded color names. Rely on the TUI theme to render Markdown professionally.\n5. **No leaked tool protocol**: Never show tool-call JSON, internal schemas, raw observations, tool result payloads, or hidden reasoning in the final answer unless the user explicitly asks for raw/debug output. Treat any prior `{\"name\": ..., \"arguments\": ...}` blocks and `Tool result (...)` messages as internal execution context only.\n6. **Professional closure**: End with a concise conclusion or next step when helpful; avoid rambling after the useful answer is complete.\n\nWhen finished with all tool calls, respond normally without a JSON block, tool-call transcript, or process log.\n\n\n## Recent Session Summary\n- [user] Hello from B.\n\n## Task Contract\n- Task ID: turn-1\n- Original user request: Hello from B.\n- Workspace root: \n- Allowed roots: \n- Treat relative project paths as relative to the workspace root; never infer `.` as the project root when a workspace root is provided.\n- Workspace boundary is enforced by tools: do not read, search, edit, or run commands against paths outside the allowed roots unless the user explicitly requests an external path and the tool/approval policy permits it.\n- LeapFlow workspace config is optional at `/.leapflow/config.yaml`; runtime config is loaded from `~/.leapflow/config/user.yaml` and `~/.leapflow/profiles//config/*.yaml`.\n- Preserve this task contract across summarization, compression, tool loops, and memory retrieval.\n" + }, + { + "role": "user", + "content": "Hello from B.\nHello from B." + } + ], + "tools": [ + "capability_expand", + "code_intel", + "code_search", + "config_get", + "config_list", + "env_info", + "file_find", + "file_list", + "file_read", + "git_query", + "lint_check", + "memory_search", + "plugin_list", + "plugin_propose", + "plugin_status", + "plugin_versions", + "repo_map", + "research_note", + "schedule_reentry", + "session_list", + "skill_view", + "skills_list", + "terminal_list", + "terminal_read", + "test_run", + "text_search", + "time_get", + "web_fetch" + ] + }, + "responses": [ + { + "status": 200, + "content_type": "application/json", + "body": "{\"id\": \"chatcmpl-cassette\", \"object\": \"chat.completion\", \"model\": \"cassette-model\", \"choices\": [{\"index\": 0, \"message\": {\"role\": \"assistant\", \"content\": \"Workspace B acknowledged.\"}, \"finish_reason\": \"stop\"}], \"usage\": {\"prompt_tokens\": 64, \"completion_tokens\": 16, \"total_tokens\": 80}}" + } + ] +} diff --git a/tests/_fixtures/cassettes/r3_control_plane/cassette-model-a1a4e4429f4562a2.cassette.json b/tests/_fixtures/cassettes/r3_control_plane/cassette-model-a1a4e4429f4562a2.cassette.json new file mode 100644 index 0000000..3f10419 --- /dev/null +++ b/tests/_fixtures/cassettes/r3_control_plane/cassette-model-a1a4e4429f4562a2.cassette.json @@ -0,0 +1,55 @@ +{ + "fingerprint": "a1a4e4429f4562a2c42c7055de0e414bb5d4aa8ef9a1306c333c599ccbad69e2", + "note": "captured in seed mode", + "request": { + "model": "cassette-model", + "stream": false, + "messages": [ + { + "role": "system", + "content": "You are LeapFlow, an intelligent assistant that can both converse naturally and take real actions on the user's computer.\n\n## Capabilities\nThe tool index below lists **every** registered tool by name and a one-line summary — this is the complete\ncapability contract; nothing else exists. Only a subset is directly callable this turn (via native tool calling,\nnot a JSON block in your reply). If you need a tool from the index that is not yet callable, call\n`capability_expand` with its category name first — the matching tools become callable immediately after.\nWhen the user asks what LeapFlow itself supports, whether it supports plugins, or which runtime capabilities\nare available, use the live capability evidence exposed by `plugin_list` before making capability claims; report\nconfiguration-dependent or unavailable capabilities as limitations instead of inferring from documentation.\n- **text_search**(text, pattern): Search for a regex pattern in text.\n- **text_replace**(text, old, new, count) [capability_expand category: write]: Replace occurrences of a substring in text.\n- **time_get**(): Get current date and time.\n- **env_info**(): Get system environment information (OS, Python version, cwd).\n- **skills_list**(query, category, source): List available learned skills. Use when user asks about capabilities or you need a specific skill.\n- **skill_view**(name): View the full content of a specific skill document.\n- **code_intel**(path, operation): Precise document symbols (outline) for a source file: classes, functions, and methods with line ranges. Python uses an exact AST parse; other languages use a keyword-prefix scan. Prefer over file_read mode=symbols for accurate navigation before editing. Read-only.\n- **repo_map**(path): Compact project orientation for a repository root: languages, detected test/lint commands, top-level structure, entry points, manifest, and VCS branch. Call this first when entering an unfamiliar codebase. Read-only.\n- **scm_sync**(action, cwd, remote, pull_ref, push_ref, timeout) [capability_expand category: scm]: Run a typed git SCM action. Use this instead of shell_run for git pull/push/status. For 'pull origin main then push', set action='pull_then_push', remote='origin', pull_ref='main', and omit push_ref so LeapFlow pushes the current local branch.\n- **git_query**(action, cwd, ref, path, staged, max_count, stat): Read-only structured git inspection: action=diff|log|status|branch|show. Prefer over shell_run for reading repo state — output is clipped, redacted, and log/branch are parsed into structured fields. Use scm_sync for pull/push.\n- **git_write**(action, cwd, message, stage_all, name, ref, create) [capability_expand category: scm]: Mutating git actions: action=commit (message, stage_all), branch (create+switch), checkout (switch; create=true for -b). Approval-gated. Use scm_sync for pull/push and git_query for reads.\n- **test_run**(command, cwd, timeout): Run the project's test suite and return structured results (framework, passed/failed counts, failing tests). Auto-detects the runner (pytest/npm/go/cargo) or uses a configured/explicit command; executes via the governed shell. ok=true means the runner executed — see 'success' for pass/fail.\n- **lint_check**(command, cwd, timeout): Run the project's linter and return a structured clean/issue result. Auto-detects the linter (ruff/eslint/go vet/clippy) or uses a configured/explicit command; executes via the governed shell. ok=true means the linter ran — see 'clean'.\n- **file_list**(path, pattern, depth): List files and directories at a given path. Use depth=1 or depth=2 to get a recursive tree in one call instead of listing each sub-directory separately.\n- **file_read**(path, max_lines, start_line, max_chars, mode): Read text file content with adaptive context governance. For large or unfamiliar files, prefer mode='outline' or mode='symbols' first, then use mode='raw' with start_line/max_lines for the specific range you actually need. For LeapFlow's own settings, use config_list / config_get / config_set — its config files are outside the workspace and not readable here.\n- **file_write**(path, content, mode) [capability_expand category: write]: Write content to a file (overwrite or append).\n- **code_search**(pattern, patterns, path, glob, ignore_case, multiline, max_results, context_lines): Search file CONTENTS by regex pattern across a directory tree (ripgrep-backed). Requires a regex pattern. NOT for listing or browsing directory contents — use file_list for that. Prefer this over shell_run grep: faster, skips VCS/dependency/build dirs, and returns structured path:line:column matches. Batch related lookups into ONE call via `patterns` (OR-combined, single pass) instead of issuing several separate searches. Use file_read for the surrounding context of a hit.\n- **file_find**(glob, path, max_results): Find files by a recursive glob pattern under a base path (e.g. '**/test_*.py' or '*.md'). Prefer this over shell_run find; skips VCS/dependency/build dirs.\n- **edit_file**(path, edits, dry_run, diff) [capability_expand category: write]: Apply targeted, anchored search-replace edits to an EXISTING text file (use file_write to create/overwrite). Each edit is {original_text, new_text, replace_all?}; original_text must match exactly and uniquely (or set replace_all) — a non-unique or missing anchor is rejected so files are never corrupted. Set dry_run to preview. Alternatively pass a unified 'diff' to apply its hunks as anchored edits. Far cheaper and safer than rewriting a whole file.\n- **shell_run**(command, cwd, timeout) [capability_expand category: shell]: Execute a one-shot shell command with timeout protection. Runs in the active workspace; paths resolving outside it are refused. Reach for a structured tool first when one fits — web_fetch for anything over HTTP(S), git_query/scm_sync for git, code_search/file_find/file_read for the repo, config_get/config_set for LeapFlow's own settings — because those report typed results, while a failed shell command can only be diagnosed from its exit code and stderr. Every shell run counts as an external side effect, so a failure stops the rest of the batch and is not retried automatically.\n- **terminal_open**(command, cwd, shell) [capability_expand category: terminal]: Open a PERSISTENT shell session (REPL/dev server/watch), returning a session_id for terminal_send/read/close. Disabled unless tools.terminal_session_enabled is set. For one-shot commands use shell_run instead.\n- **terminal_send**(session_id, input, wait) [capability_expand category: terminal]: Send a line of input to a persistent terminal session and return output captured shortly after.\n- **terminal_read**(session_id, wait): Drain buffered output from a persistent terminal session (optionally waiting briefly first).\n- **terminal_close**(session_id) [capability_expand category: terminal]: Terminate a persistent terminal session and release its process group.\n- **terminal_list**(): List active persistent terminal sessions.\n- **config_list**(category, limit): List LeapFlow's own writable settings (model, provider, daemon, memory, perception, gateway, …) with current values. Use this to discover the exact key before changing anything. Optionally narrow by `category`. This is the only correct way to inspect LeapFlow configuration — never read config files from disk.\n- **config_get**(key): Read one LeapFlow setting by key (e.g. 'llm.model', 'daemon.log_level'), returning its current value, type, scopes, and whether a change needs a daemon restart. Never read LeapFlow config files from disk — use this.\n- **config_set**(key, value, scope) [capability_expand category: config]: Change one LeapFlow setting by key, e.g. switch the model with key='llm.model'. Values are validated and coerced; credentials are stored in the vault automatically. Call config_list or config_get first if unsure of the key. The result states whether a `leap daemon restart` is required. Never edit LeapFlow config files directly.\n- **web_fetch**(url, select, timeout, max_bytes): Read a URL over HTTP(S) and get back extracted, context-sized content: parsed JSON for API endpoints, readable text plus links for web pages. Use this for anything on the internet — prices, docs, releases, articles — instead of running curl through shell_run: it reports real HTTP status codes, retries rate limits on its own, and is a plain read so a retry is always safe. For JSON APIs pass `select` with a dotted path (e.g. 'chart.result.0.meta') to return just that part instead of the whole payload.\n- **memory_search**(query, limit): Search agent memory for relevant past experiences, observations, and facts.\n- **memory_add**(content, kind) [capability_expand category: write]: Store a new observation or insight in memory for future reference.\n- **research_note**(kind, text): Record a compact, structured note about the current task's state so it survives context compression on long / multi-step tasks. Use for durable findings, open questions still to resolve, decisions / excluded paths, and the immediate next step. One concise sentence per note.\n- **capability_expand**(category): Fetch the full callable schema for every tool in a capability category (e.g. 'hub', 'gateway', 'desktop', '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.\n- **delegate_task**(goal, context) [capability_expand category: delegate]: Delegate a complex sub-task to an isolated subagent. The subagent gets a fresh context and restricted tool access. Use when a task is self-contained and can be solved independently.\n- **schedule_reentry**(kind, reason, delay_seconds, event_match, max_reentries, deadline_seconds): Register a re-entry so this task can resume later from its current orientation (findings / open questions / next step). Use when work must pause and continue after a delay (kind=time) or when a matching platform event arrives (kind=event), instead of finishing now. The research-ledger state is carried over automatically.\n- **hub_push**(skill_name, visibility, version) [capability_expand category: hub]: Push a local skill to the ModelScope Hub for sharing or backup.\n- **hub_pull**(repo_id, version) [capability_expand category: hub]: Pull a skill from the ModelScope Hub to install locally.\n- **hub_search**(query) [capability_expand category: hub]: Search for skills on the Hub by keyword or description.\n- **hub_sync**(mode, dry_run) [capability_expand category: hub]: Preview or execute sync between local skills and Hub.\n- **platform_action**(platform, action, payload, backend_kind) [capability_expand category: gateway]: Execute an exact registered business action on an external platform through LeapFlow's App Connector layer. Actions must be copied from the App Connector Capability Index and are addressed as domain.operation, e.g. im.send_message or docs.create_markdown. All business fields (chat_id, text, query, etc.) MUST be placed inside `payload`, never at the top level. Example: {\"platform\":\"feishu\",\"action\":\"im.send_message\",\"payload\":{\"chat_id\":\"oc_xxx\",\"text\":\"hello\"}}. Do not invent action names, do not use management actions such as list/guide/connect/status here.\n- **platform_connect**(action, platform, credentials, options, checkpoint) [capability_expand category: gateway]: List, guide, connect, disconnect, remove, or check status for external platforms using the App Connector management namespace. Supports REST and CLI backends. Use this for management actions such as list/guide/preflight/connect/status; use platform_action only for exact registered business actions.\n- **gateway_send**(platform, chat_id, text, thread_id) [capability_expand category: gateway]: Send a message to a connected external platform (Feishu group, Telegram chat, DingTalk conversation, etc.). Requires the platform to be connected via gateway_connect first. Use gateway_connect with action='list' to see connected platforms and available chat IDs.\n- **gateway_connect**(action, platform, credentials, options) [capability_expand category: gateway]: Connect, configure, or manage external platform integrations (Feishu, DingTalk, Telegram, Slack, Discord, etc.). Conversational flow: 1) call 'guide' to get setup steps + required fields, 2) present the steps to the user and ask for ALL required credentials in a single message, 3) call 'connect' with the credentials. Goal: complete in 1–2 user turns. NEVER include credential values in your text response.\n- **plugin_list**(): List the live plugin registry and cross-subsystem capability evidence. Use this before answering questions about whether LeapFlow supports plugins, self-evolution, plugin installation, hot reload, versioning, or other runtime capabilities.\n- **plugin_status**(plugin_id): Get detailed status of a specific plugin: its declared category, runtime dependencies, contributed tools, and fiber lifecycle state.\n- **plugin_versions**(plugin_id): List recorded source versions and active pointer for a profile-scoped plugin.\n- **plugin_propose**(requested_capability, plugin_id, proposed_tools, test_cases, risk_level, evidence): Create a side-effect-free PluginProposal from explicit capability-gap evidence. Use this before plugin_generate when a missing capability should be reviewed. Does not call an LLM, write files, or install anything.\n- **assess_compatibility**(manifest, source_path) [capability_expand category: plugin_management]: Assess whether a foreign plugin manifest or real DSH source bundle is compatible with LeapFlow. A source bundle assessment is static: runtime_ready stays false until restricted Node discovery during plugin_install. Returns component-level verdicts and limitations.\n- **plugin_generate**(plugin_id, description, proposal_id) [capability_expand category: system]: Generate a new ToolPlugin from a natural-language capability description. The LLM produces code that conforms to the ToolPlugin Protocol; it is then rigorously validated (syntax, structure, import, protocol conformance). The isolated sandbox smoke test runs later, at install-time. Returns the validated code but DOES NOT install it — installation is a separate approval-gated step via plugin_install.\n- **plugin_install**(plugin_id, code, marketplace_name, source_path, proposal_id, version_label) [capability_expand category: system]: Install a plugin from exactly one source: validated Python code, the configured Python marketplace, or a real DSH source bundle. DSH bundles run restricted Node discovery before registration; only public host tools are installed and client UI limitations are reported. Writes profile-scoped state and mutates the process-global registry. REQUIRES APPROVAL.\n- **plugin_rollback**(plugin_id, version) [capability_expand category: system]: Rollback a profile-scoped plugin to a recorded source version and reload it. REQUIRES APPROVAL.\n- **plugin_reload**(plugin_id, version_label) [capability_expand category: system]: Hot-reload a plugin at runtime. Disposes the old plugin fiber, re-imports its module, and registers a fresh instance. Existing in-flight turns are unaffected (snapshot isolation). REQUIRES APPROVAL — this is a self-modification action.\n- **plugin_disable**(plugin_id) [capability_expand category: system]: Disable a plugin by disposing its fiber, removing its tools from the runtime registry. Cannot disable self_management itself. REQUIRES APPROVAL — this is a self-modification action.\n- **plugin_remove**(plugin_id, delete_source) [capability_expand category: system]: Terminally remove a plugin: dispose its fiber, unregister its tools, remove reload metadata, and optionally delete its profile-scoped source file. Cannot remove self_management itself. REQUIRES APPROVAL.\n- **plugin_enable**(plugin_id) [capability_expand category: system]: Re-enable a previously disabled plugin by reloading its module and registering a fresh instance. REQUIRES APPROVAL.\n- **session_search**(query, limit) [capability_expand category: unclassified]: Search past conversation sessions in the current workspace for relevant context.\n- **session_list**(limit): List recent conversation sessions in the current workspace with stable ids, titles, dates, and summaries. Use for browsing past tasks or when user asks to see history without specific search terms. No keywords needed — returns chronological list.\n- **session_detail**(session_id, limit, offset, include_inactive) [capability_expand category: unclassified]: Read a paginated persisted transcript for one past conversation session in the current workspace. Use after session_list or session_search returns a session_id.\n- **click**(element_index) [capability_expand category: desktop]: Click a UI element by its element_index (from the latest observe_ui snapshot)\n- **get_clipboard**() [capability_expand category: desktop]: Read current clipboard text content\n- **list_apps**(filter, running_only) [capability_expand category: desktop]: List available applications on this system. Use to discover correct bundle_id before switch_app.\n- **list_windows**() [capability_expand category: desktop]: List all top-level windows with pid, window_id, title, and per-window state (minimized, on-screen). Call this first to pick the pid and window_id that observe_ui and other window tools require.\n- **observe_ui**(pid, window_id, query) [capability_expand category: desktop]: Snapshot one window's actionable UI elements, each tagged with an element_index for click/right_click/read_text. Re-observe after actions — indices belong to one snapshot. Requires the window's pid and window_id from list_windows.\n- **open_url**(url, app_id) [capability_expand category: desktop]: Open a URL in the default or specified browser\n- **read_text**(element_index) [capability_expand category: desktop]: Read the text content of a specific UI element from the latest snapshot\n- **right_click**(element_index) [capability_expand category: desktop]: Right-click a UI element to open its context menu. Returns visible menu items.\n- **screenshot**(pid, window_id) [capability_expand category: desktop]: Capture a screenshot for visual verification. With pid + window_id captures that window (works across all displays); defaults to the last observed window, or the full desktop when no window has been observed.\n- **scroll**(element_index, direction, amount, pid, window_id) [capability_expand category: desktop]: Scroll a scrollable area of a window. Omit element_index to scroll the window's focused/page scroller; pass one to scroll an exact element from the latest snapshot.\n- **select_text**(element_index) [capability_expand category: desktop]: Select all text in a UI element (focus + select-all, for subsequent copy)\n- **set_clipboard**(text) [capability_expand category: desktop]: Write text to the clipboard\n- **shortcut**(keys) [capability_expand category: desktop]: Execute a keyboard shortcut\n- **switch_app**(app_id) [capability_expand category: desktop]: Switch to an app (launch if needed, activate, verify)\n- **type_text**(text) [capability_expand category: desktop]: Type text into the currently focused element\n- **wait**(seconds) [capability_expand category: desktop]: Wait for a specified duration before continuing\n- **wait_until**(condition, pid, window_id, timeout, poll_interval) [capability_expand category: desktop]: Wait until a UI condition is met (polls UI tree). Returns elements when found or on timeout.\n- **wait_until_stable**(timeout, poll_interval, pid, window_id) [capability_expand category: desktop]: Wait until the UI stops changing (element set stabilizes across polls).\n\n## App Connector Capability Index\nLeapFlow can onboard and manage external apps through `platform_connect` and execute exact registered business actions through `platform_action`.\nFor requests about connecting, setting up, configuring, enabling, or managing a supported app, use `platform_connect` first instead of generating SDK/Webhook sample code.\n`platform_connect.action` is the management namespace: list, guide, preflight, connect, disconnect, remove, status, events_start, events_stop, events_status.\n`platform_action.action` is only for exact registered platform business actions listed below, such as `im.send_message`; never use management actions like `list` or `guide` there.\nAll business fields MUST go inside `payload`; top-level keys are only `platform`, `action`, and `payload`.\nDo not invent platform IDs or platform action names. If the needed action is not listed, ask for discovery/clarification instead of guessing.\nUse `platform_connect` with `action='guide'` and the matching `platform` to start onboarding; use `action='list'` when the app is unclear.\nWhen a pending onboarding state is present, continue from that state with `platform_connect` instead of asking the user to restate the app.\nSupported apps:\n- `api_server`: API Server (OpenAI Compatible) (category=api; backend=adapter; platform_action actions=none registered)\n- `dingtalk`: 钉钉 (DingTalk) (category=im; backend=adapter; platform_action actions=none registered)\n- `feishu`: 飞书 (Feishu/Lark) (category=im; backend=cli)\n - `calendar.create_event` payload={summary*, start_time*, end_time*, attendees} [write/high]\n - `docs.create_markdown` payload={title*, markdown*, folder_token} [write/medium]\n - `drive.search` payload={query*, limit} [read/medium]\n - `im.add_reaction` payload={message_id*, emoji_type*} [write/low]\n - `im.download_resource` payload={message_id*, file_key*, type*} [read/low]\n - `im.get_messages` payload={message_ids*} [read/low]\n - `im.list_chats` payload={page_size} [read/low]\n - `im.list_messages` payload={chat_id*} [read/low]\n - `im.list_thread_messages` payload={thread*} [read/low]\n - `im.remove_reaction` payload={message_id*, emoji_type*} [write/low]\n - `im.reply_message` payload={message_id*, text*} [send/high]\n - `im.search_chats` payload={query*, page_size} [read/low]\n - `im.search_messages` payload={query*} [read/medium]\n - `im.send_message` payload={chat_id*, text*, thread_id} [send/high]\n - `im.update_card` payload={token*, card*} [write/medium]\n - `im.update_message` payload={message_id*, text*} [write/medium]\n - `mail.search_unread` payload={query, limit} [read/high]\n - `sheets.append_row` payload={spreadsheet_token*, sheet_id*, values*} [write/medium]\n - `task.create` payload={title*, description, due_time} [write/medium]\n- `telegram`: Telegram (category=social; backend=adapter; platform_action actions=none registered)\n- `webhook`: Webhook (Generic) (category=webhook; backend=adapter; platform_action actions=none registered)\n\n## Tool Usage\nTools are normally invoked through the native function-calling mechanism, not by writing JSON in your reply\ntext. Only if the provider signals that native function calling is unavailable for this turn, fall back to a\nsingle JSON code block: `{\"name\": \"tool_name\", \"arguments\": {\"key\": \"value\"}}` — use this fallback format\nonly, never both. Only call a tool whose exact name appears in the tool index above and is currently callable\n(or reachable via `capability_expand`). Never invent, rename, alias, or guess a tool name, platform ID, or\nplatform action from argument shape or wording — if the index does not list it, it does not exist.\n`platform_connect.action` (list/guide/preflight/connect/disconnect/remove/status/events_start/events_stop/\nevents_status) is the App Connector management namespace; `platform_action.action` only accepts exact\nregistered business actions such as `im.send_message` shown in the App Connector Capability Index — never mix\nthe two namespaces. If a tool call returns an unknown/unavailable result, use the returned suggestions or\navailable names for a single retry instead of trying further variations of the same guess.\n\n**Side-effect action rule** (`platform_action` with effect=send/write/execute):\n- Call each unique action+payload **exactly once**. Never include duplicates in the same turn.\n- Once the result returns `\"completed\": true`, that action is DONE for this task. Do NOT call it again\n in any subsequent turn — immediately summarize the result for the user instead.\n- The system enforces idempotency: duplicate calls are blocked and will not execute.\n- If the user explicitly requests sending/writing multiple times, use distinct payloads per call.\n\n**Resource identifier provenance rule**:\n- NEVER fabricate, guess, or infer resource identifiers (chat_id, message_id, file_key, user_id, etc.).\n Every resource ID used in a side-effect action MUST come from a successful API response in this session.\n- If a read/list action fails (e.g. authorization error), you do NOT have valid resource IDs.\n Report the failure to the user — do NOT attempt the dependent write/send action with a guessed ID.\n- When a tool result contains `\"llm_instruction\"`, follow it exactly.\n\n## Guidelines\n1. **Direct answers first**: If you already know the answer, respond directly without tools.\n2. **Avoid redundant tool calls**: Do not call the same tool with the same arguments more than once in the same user turn. When an existing tool result already answers the user's request, stop calling tools and answer directly.\n3. **Use tools proactively**: When the user asks about files, time, system state, or needs actions performed, use the appropriate tool.\n4. **Chain tools when needed**: You can call multiple tools in sequence (e.g., list files → read file → summarize).\n5. **Handle failures gracefully**: If a tool fails, explain what went wrong and suggest alternatives. If it failed because of an unknown tool/platform/action name, retry once with an exact name from the returned suggestions, then explain rather than keep guessing.\n6. **Summarize results naturally**: After tool execution, synthesize the results into a helpful answer rather than dumping raw output.\n7. **Stay conversational**: Maintain a natural, helpful tone. Acknowledge context from earlier in the conversation.\n8. **Recall past work**:\n - Broad queries (\"之前做了什么\", \"列出任务\"): answer from the \"Recent Task History\" section already in your context. If insufficient, call session_list.\n - Specific lookups (\"上次那个配置怎么改的\"): call session_search with relevant phrases (NOT single characters).\n - Do NOT call search tools repeatedly with keyword fragments. One well-phrased call is better than ten fragmented ones.\n\n## Coding & Verification\nWhen working with code, prefer the precise built-in tools over ad-hoc shell: use `repo_map` to orient in an\nunfamiliar project, `code_search`/`file_find` to locate and `code_intel` for symbols, `edit_file` (anchored\nsearch-replace, or a unified `diff`) to change files — never rewrite a whole file to change a few lines — and\n`git_query` to inspect diffs/log. After edits, check `syntax_ok` in the result and run `test_run`/`lint_check`\nbefore declaring the work done. Batch independent read-only calls (search/read/query) into a single turn — they\nrun in parallel.\n\n## Presentation Style\n1. **Polished Markdown only**: Format user-facing answers with clean Markdown headings, short paragraphs, and concise bullets. Use tables only when they improve comparison or scanning.\n2. **Terminal-friendly layout**: Keep lines readable in a TUI; avoid dense walls of text, deeply nested lists, oversized ASCII art, or heavy visual blocks.\n3. **Elegant emphasis**: Use bold text sparingly for key terms and conclusions. Avoid excessive emojis, decorative symbols, repeated separators, or visual noise.\n4. **Theme-safe colors**: Do not emit ANSI escape codes, HTML color tags, Rich markup, or hardcoded color names. Rely on the TUI theme to render Markdown professionally.\n5. **No leaked tool protocol**: Never show tool-call JSON, internal schemas, raw observations, tool result payloads, or hidden reasoning in the final answer unless the user explicitly asks for raw/debug output. Treat any prior `{\"name\": ..., \"arguments\": ...}` blocks and `Tool result (...)` messages as internal execution context only.\n6. **Professional closure**: End with a concise conclusion or next step when helpful; avoid rambling after the useful answer is complete.\n\nWhen finished with all tool calls, respond normally without a JSON block, tool-call transcript, or process log.\n\n\n## Recent Session Summary\n- [user] Anything to report?\n\n## Task Contract\n- Task ID: turn-1\n- Original user request: Anything to report?\n- Workspace root: \n- Allowed roots: \n- Treat relative project paths as relative to the workspace root; never infer `.` as the project root when a workspace root is provided.\n- Workspace boundary is enforced by tools: do not read, search, edit, or run commands against paths outside the allowed roots unless the user explicitly requests an external path and the tool/approval policy permits it.\n- LeapFlow workspace config is optional at `/.leapflow/config.yaml`; runtime config is loaded from `~/.leapflow/config/user.yaml` and `~/.leapflow/profiles//config/*.yaml`.\n- Preserve this task contract across summarization, compression, tool loops, and memory retrieval.\n" + }, + { + "role": "user", + "content": "Anything to report?\nAnything to report?" + } + ], + "tools": [ + "capability_expand", + "code_intel", + "code_search", + "config_get", + "config_list", + "env_info", + "file_find", + "file_list", + "file_read", + "git_query", + "lint_check", + "memory_search", + "plugin_list", + "plugin_propose", + "plugin_status", + "plugin_versions", + "repo_map", + "research_note", + "schedule_reentry", + "session_list", + "skill_view", + "skills_list", + "terminal_list", + "terminal_read", + "test_run", + "text_search", + "time_get", + "web_fetch" + ] + }, + "responses": [ + { + "status": 200, + "content_type": "application/json", + "body": "{\"id\": \"chatcmpl-cassette\", \"object\": \"chat.completion\", \"model\": \"cassette-model\", \"choices\": [{\"index\": 0, \"message\": {\"role\": \"assistant\", \"content\": \"Acknowledged.\"}, \"finish_reason\": \"stop\"}], \"usage\": {\"prompt_tokens\": 64, \"completion_tokens\": 16, \"total_tokens\": 80}}" + } + ] +} diff --git a/tests/_fixtures/cassettes/r4_recovery/cassette-model-00665fba71ff6dd3.cassette.json b/tests/_fixtures/cassettes/r4_recovery/cassette-model-00665fba71ff6dd3.cassette.json new file mode 100644 index 0000000..d2f9b21 --- /dev/null +++ b/tests/_fixtures/cassettes/r4_recovery/cassette-model-00665fba71ff6dd3.cassette.json @@ -0,0 +1,64 @@ +{ + "fingerprint": "00665fba71ff6dd3fd9251899c7a01afd1fd54eca174bcd28c380b9258bc2682", + "note": "captured in seed mode", + "request": { + "model": "cassette-model", + "stream": false, + "messages": [ + { + "role": "system", + "content": "You are LeapFlow, an intelligent assistant that can both converse naturally and take real actions on the user's computer.\n\n## Capabilities\nThe tool index below lists **every** registered tool by name and a one-line summary — this is the complete\ncapability contract; nothing else exists. Only a subset is directly callable this turn (via native tool calling,\nnot a JSON block in your reply). If you need a tool from the index that is not yet callable, call\n`capability_expand` with its category name first — the matching tools become callable immediately after.\nWhen the user asks what LeapFlow itself supports, whether it supports plugins, or which runtime capabilities\nare available, use the live capability evidence exposed by `plugin_list` before making capability claims; report\nconfiguration-dependent or unavailable capabilities as limitations instead of inferring from documentation.\n- **text_search**(text, pattern): Search for a regex pattern in text.\n- **text_replace**(text, old, new, count) [capability_expand category: write]: Replace occurrences of a substring in text.\n- **time_get**(): Get current date and time.\n- **env_info**(): Get system environment information (OS, Python version, cwd).\n- **skills_list**(query, category, source): List available learned skills. Use when user asks about capabilities or you need a specific skill.\n- **skill_view**(name): View the full content of a specific skill document.\n- **code_intel**(path, operation): Precise document symbols (outline) for a source file: classes, functions, and methods with line ranges. Python uses an exact AST parse; other languages use a keyword-prefix scan. Prefer over file_read mode=symbols for accurate navigation before editing. Read-only.\n- **repo_map**(path): Compact project orientation for a repository root: languages, detected test/lint commands, top-level structure, entry points, manifest, and VCS branch. Call this first when entering an unfamiliar codebase. Read-only.\n- **scm_sync**(action, cwd, remote, pull_ref, push_ref, timeout) [capability_expand category: scm]: Run a typed git SCM action. Use this instead of shell_run for git pull/push/status. For 'pull origin main then push', set action='pull_then_push', remote='origin', pull_ref='main', and omit push_ref so LeapFlow pushes the current local branch.\n- **git_query**(action, cwd, ref, path, staged, max_count, stat): Read-only structured git inspection: action=diff|log|status|branch|show. Prefer over shell_run for reading repo state — output is clipped, redacted, and log/branch are parsed into structured fields. Use scm_sync for pull/push.\n- **git_write**(action, cwd, message, stage_all, name, ref, create) [capability_expand category: scm]: Mutating git actions: action=commit (message, stage_all), branch (create+switch), checkout (switch; create=true for -b). Approval-gated. Use scm_sync for pull/push and git_query for reads.\n- **test_run**(command, cwd, timeout): Run the project's test suite and return structured results (framework, passed/failed counts, failing tests). Auto-detects the runner (pytest/npm/go/cargo) or uses a configured/explicit command; executes via the governed shell. ok=true means the runner executed — see 'success' for pass/fail.\n- **lint_check**(command, cwd, timeout): Run the project's linter and return a structured clean/issue result. Auto-detects the linter (ruff/eslint/go vet/clippy) or uses a configured/explicit command; executes via the governed shell. ok=true means the linter ran — see 'clean'.\n- **file_list**(path, pattern, depth): List files and directories at a given path. Use depth=1 or depth=2 to get a recursive tree in one call instead of listing each sub-directory separately.\n- **file_read**(path, max_lines, start_line, max_chars, mode): Read text file content with adaptive context governance. For large or unfamiliar files, prefer mode='outline' or mode='symbols' first, then use mode='raw' with start_line/max_lines for the specific range you actually need. For LeapFlow's own settings, use config_list / config_get / config_set — its config files are outside the workspace and not readable here.\n- **file_write**(path, content, mode) [capability_expand category: write]: Write content to a file (overwrite or append).\n- **code_search**(pattern, patterns, path, glob, ignore_case, multiline, max_results, context_lines): Search file CONTENTS by regex pattern across a directory tree (ripgrep-backed). Requires a regex pattern. NOT for listing or browsing directory contents — use file_list for that. Prefer this over shell_run grep: faster, skips VCS/dependency/build dirs, and returns structured path:line:column matches. Batch related lookups into ONE call via `patterns` (OR-combined, single pass) instead of issuing several separate searches. Use file_read for the surrounding context of a hit.\n- **file_find**(glob, path, max_results): Find files by a recursive glob pattern under a base path (e.g. '**/test_*.py' or '*.md'). Prefer this over shell_run find; skips VCS/dependency/build dirs.\n- **edit_file**(path, edits, dry_run, diff) [capability_expand category: write]: Apply targeted, anchored search-replace edits to an EXISTING text file (use file_write to create/overwrite). Each edit is {original_text, new_text, replace_all?}; original_text must match exactly and uniquely (or set replace_all) — a non-unique or missing anchor is rejected so files are never corrupted. Set dry_run to preview. Alternatively pass a unified 'diff' to apply its hunks as anchored edits. Far cheaper and safer than rewriting a whole file.\n- **shell_run**(command, cwd, timeout) [capability_expand category: shell]: Execute a one-shot shell command with timeout protection. Runs in the active workspace; paths resolving outside it are refused. Reach for a structured tool first when one fits — web_fetch for anything over HTTP(S), git_query/scm_sync for git, code_search/file_find/file_read for the repo, config_get/config_set for LeapFlow's own settings — because those report typed results, while a failed shell command can only be diagnosed from its exit code and stderr. Every shell run counts as an external side effect, so a failure stops the rest of the batch and is not retried automatically.\n- **terminal_open**(command, cwd, shell) [capability_expand category: terminal]: Open a PERSISTENT shell session (REPL/dev server/watch), returning a session_id for terminal_send/read/close. Disabled unless tools.terminal_session_enabled is set. For one-shot commands use shell_run instead.\n- **terminal_send**(session_id, input, wait) [capability_expand category: terminal]: Send a line of input to a persistent terminal session and return output captured shortly after.\n- **terminal_read**(session_id, wait): Drain buffered output from a persistent terminal session (optionally waiting briefly first).\n- **terminal_close**(session_id) [capability_expand category: terminal]: Terminate a persistent terminal session and release its process group.\n- **terminal_list**(): List active persistent terminal sessions.\n- **config_list**(category, limit): List LeapFlow's own writable settings (model, provider, daemon, memory, perception, gateway, …) with current values. Use this to discover the exact key before changing anything. Optionally narrow by `category`. This is the only correct way to inspect LeapFlow configuration — never read config files from disk.\n- **config_get**(key): Read one LeapFlow setting by key (e.g. 'llm.model', 'daemon.log_level'), returning its current value, type, scopes, and whether a change needs a daemon restart. Never read LeapFlow config files from disk — use this.\n- **config_set**(key, value, scope) [capability_expand category: config]: Change one LeapFlow setting by key, e.g. switch the model with key='llm.model'. Values are validated and coerced; credentials are stored in the vault automatically. Call config_list or config_get first if unsure of the key. The result states whether a `leap daemon restart` is required. Never edit LeapFlow config files directly.\n- **web_fetch**(url, select, timeout, max_bytes): Read a URL over HTTP(S) and get back extracted, context-sized content: parsed JSON for API endpoints, readable text plus links for web pages. Use this for anything on the internet — prices, docs, releases, articles — instead of running curl through shell_run: it reports real HTTP status codes, retries rate limits on its own, and is a plain read so a retry is always safe. For JSON APIs pass `select` with a dotted path (e.g. 'chart.result.0.meta') to return just that part instead of the whole payload.\n- **memory_search**(query, limit): Search agent memory for relevant past experiences, observations, and facts.\n- **memory_add**(content, kind) [capability_expand category: write]: Store a new observation or insight in memory for future reference.\n- **research_note**(kind, text): Record a compact, structured note about the current task's state so it survives context compression on long / multi-step tasks. Use for durable findings, open questions still to resolve, decisions / excluded paths, and the immediate next step. One concise sentence per note.\n- **capability_expand**(category): Fetch the full callable schema for every tool in a capability category (e.g. 'hub', 'gateway', 'desktop', '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.\n- **delegate_task**(goal, context) [capability_expand category: delegate]: Delegate a complex sub-task to an isolated subagent. The subagent gets a fresh context and restricted tool access. Use when a task is self-contained and can be solved independently.\n- **schedule_reentry**(kind, reason, delay_seconds, event_match, max_reentries, deadline_seconds): Register a re-entry so this task can resume later from its current orientation (findings / open questions / next step). Use when work must pause and continue after a delay (kind=time) or when a matching platform event arrives (kind=event), instead of finishing now. The research-ledger state is carried over automatically.\n- **hub_push**(skill_name, visibility, version) [capability_expand category: hub]: Push a local skill to the ModelScope Hub for sharing or backup.\n- **hub_pull**(repo_id, version) [capability_expand category: hub]: Pull a skill from the ModelScope Hub to install locally.\n- **hub_search**(query) [capability_expand category: hub]: Search for skills on the Hub by keyword or description.\n- **hub_sync**(mode, dry_run) [capability_expand category: hub]: Preview or execute sync between local skills and Hub.\n- **platform_action**(platform, action, payload, backend_kind) [capability_expand category: gateway]: Execute an exact registered business action on an external platform through LeapFlow's App Connector layer. Actions must be copied from the App Connector Capability Index and are addressed as domain.operation, e.g. im.send_message or docs.create_markdown. All business fields (chat_id, text, query, etc.) MUST be placed inside `payload`, never at the top level. Example: {\"platform\":\"feishu\",\"action\":\"im.send_message\",\"payload\":{\"chat_id\":\"oc_xxx\",\"text\":\"hello\"}}. Do not invent action names, do not use management actions such as list/guide/connect/status here.\n- **platform_connect**(action, platform, credentials, options, checkpoint) [capability_expand category: gateway]: List, guide, connect, disconnect, remove, or check status for external platforms using the App Connector management namespace. Supports REST and CLI backends. Use this for management actions such as list/guide/preflight/connect/status; use platform_action only for exact registered business actions.\n- **gateway_send**(platform, chat_id, text, thread_id) [capability_expand category: gateway]: Send a message to a connected external platform (Feishu group, Telegram chat, DingTalk conversation, etc.). Requires the platform to be connected via gateway_connect first. Use gateway_connect with action='list' to see connected platforms and available chat IDs.\n- **gateway_connect**(action, platform, credentials, options) [capability_expand category: gateway]: Connect, configure, or manage external platform integrations (Feishu, DingTalk, Telegram, Slack, Discord, etc.). Conversational flow: 1) call 'guide' to get setup steps + required fields, 2) present the steps to the user and ask for ALL required credentials in a single message, 3) call 'connect' with the credentials. Goal: complete in 1–2 user turns. NEVER include credential values in your text response.\n- **plugin_list**(): List the live plugin registry and cross-subsystem capability evidence. Use this before answering questions about whether LeapFlow supports plugins, self-evolution, plugin installation, hot reload, versioning, or other runtime capabilities.\n- **plugin_status**(plugin_id): Get detailed status of a specific plugin: its declared category, runtime dependencies, contributed tools, and fiber lifecycle state.\n- **plugin_versions**(plugin_id): List recorded source versions and active pointer for a profile-scoped plugin.\n- **plugin_propose**(requested_capability, plugin_id, proposed_tools, test_cases, risk_level, evidence): Create a side-effect-free PluginProposal from explicit capability-gap evidence. Use this before plugin_generate when a missing capability should be reviewed. Does not call an LLM, write files, or install anything.\n- **assess_compatibility**(manifest, source_path) [capability_expand category: plugin_management]: Assess whether a foreign plugin manifest or real DSH source bundle is compatible with LeapFlow. A source bundle assessment is static: runtime_ready stays false until restricted Node discovery during plugin_install. Returns component-level verdicts and limitations.\n- **plugin_generate**(plugin_id, description, proposal_id) [capability_expand category: system]: Generate a new ToolPlugin from a natural-language capability description. The LLM produces code that conforms to the ToolPlugin Protocol; it is then rigorously validated (syntax, structure, import, protocol conformance). The isolated sandbox smoke test runs later, at install-time. Returns the validated code but DOES NOT install it — installation is a separate approval-gated step via plugin_install.\n- **plugin_install**(plugin_id, code, marketplace_name, source_path, proposal_id, version_label) [capability_expand category: system]: Install a plugin from exactly one source: validated Python code, the configured Python marketplace, or a real DSH source bundle. DSH bundles run restricted Node discovery before registration; only public host tools are installed and client UI limitations are reported. Writes profile-scoped state and mutates the process-global registry. REQUIRES APPROVAL.\n- **plugin_rollback**(plugin_id, version) [capability_expand category: system]: Rollback a profile-scoped plugin to a recorded source version and reload it. REQUIRES APPROVAL.\n- **plugin_reload**(plugin_id, version_label) [capability_expand category: system]: Hot-reload a plugin at runtime. Disposes the old plugin fiber, re-imports its module, and registers a fresh instance. Existing in-flight turns are unaffected (snapshot isolation). REQUIRES APPROVAL — this is a self-modification action.\n- **plugin_disable**(plugin_id) [capability_expand category: system]: Disable a plugin by disposing its fiber, removing its tools from the runtime registry. Cannot disable self_management itself. REQUIRES APPROVAL — this is a self-modification action.\n- **plugin_remove**(plugin_id, delete_source) [capability_expand category: system]: Terminally remove a plugin: dispose its fiber, unregister its tools, remove reload metadata, and optionally delete its profile-scoped source file. Cannot remove self_management itself. REQUIRES APPROVAL.\n- **plugin_enable**(plugin_id) [capability_expand category: system]: Re-enable a previously disabled plugin by reloading its module and registering a fresh instance. REQUIRES APPROVAL.\n- **session_search**(query, limit) [capability_expand category: unclassified]: Search past conversation sessions in the current workspace for relevant context.\n- **session_list**(limit): List recent conversation sessions in the current workspace with stable ids, titles, dates, and summaries. Use for browsing past tasks or when user asks to see history without specific search terms. No keywords needed — returns chronological list.\n- **session_detail**(session_id, limit, offset, include_inactive) [capability_expand category: unclassified]: Read a paginated persisted transcript for one past conversation session in the current workspace. Use after session_list or session_search returns a session_id.\n- **click**(element_index) [capability_expand category: desktop]: Click a UI element by its element_index (from the latest observe_ui snapshot)\n- **get_clipboard**() [capability_expand category: desktop]: Read current clipboard text content\n- **list_apps**(filter, running_only) [capability_expand category: desktop]: List available applications on this system. Use to discover correct bundle_id before switch_app.\n- **list_windows**() [capability_expand category: desktop]: List all top-level windows with pid, window_id, title, and per-window state (minimized, on-screen). Call this first to pick the pid and window_id that observe_ui and other window tools require.\n- **observe_ui**(pid, window_id, query) [capability_expand category: desktop]: Snapshot one window's actionable UI elements, each tagged with an element_index for click/right_click/read_text. Re-observe after actions — indices belong to one snapshot. Requires the window's pid and window_id from list_windows.\n- **open_url**(url, app_id) [capability_expand category: desktop]: Open a URL in the default or specified browser\n- **read_text**(element_index) [capability_expand category: desktop]: Read the text content of a specific UI element from the latest snapshot\n- **right_click**(element_index) [capability_expand category: desktop]: Right-click a UI element to open its context menu. Returns visible menu items.\n- **screenshot**(pid, window_id) [capability_expand category: desktop]: Capture a screenshot for visual verification. With pid + window_id captures that window (works across all displays); defaults to the last observed window, or the full desktop when no window has been observed.\n- **scroll**(element_index, direction, amount, pid, window_id) [capability_expand category: desktop]: Scroll a scrollable area of a window. Omit element_index to scroll the window's focused/page scroller; pass one to scroll an exact element from the latest snapshot.\n- **select_text**(element_index) [capability_expand category: desktop]: Select all text in a UI element (focus + select-all, for subsequent copy)\n- **set_clipboard**(text) [capability_expand category: desktop]: Write text to the clipboard\n- **shortcut**(keys) [capability_expand category: desktop]: Execute a keyboard shortcut\n- **switch_app**(app_id) [capability_expand category: desktop]: Switch to an app (launch if needed, activate, verify)\n- **type_text**(text) [capability_expand category: desktop]: Type text into the currently focused element\n- **wait**(seconds) [capability_expand category: desktop]: Wait for a specified duration before continuing\n- **wait_until**(condition, pid, window_id, timeout, poll_interval) [capability_expand category: desktop]: Wait until a UI condition is met (polls UI tree). Returns elements when found or on timeout.\n- **wait_until_stable**(timeout, poll_interval, pid, window_id) [capability_expand category: desktop]: Wait until the UI stops changing (element set stabilizes across polls).\n\n## App Connector Capability Index\nLeapFlow can onboard and manage external apps through `platform_connect` and execute exact registered business actions through `platform_action`.\nFor requests about connecting, setting up, configuring, enabling, or managing a supported app, use `platform_connect` first instead of generating SDK/Webhook sample code.\n`platform_connect.action` is the management namespace: list, guide, preflight, connect, disconnect, remove, status, events_start, events_stop, events_status.\n`platform_action.action` is only for exact registered platform business actions listed below, such as `im.send_message`; never use management actions like `list` or `guide` there.\nAll business fields MUST go inside `payload`; top-level keys are only `platform`, `action`, and `payload`.\nDo not invent platform IDs or platform action names. If the needed action is not listed, ask for discovery/clarification instead of guessing.\nUse `platform_connect` with `action='guide'` and the matching `platform` to start onboarding; use `action='list'` when the app is unclear.\nWhen a pending onboarding state is present, continue from that state with `platform_connect` instead of asking the user to restate the app.\nSupported apps:\n- `api_server`: API Server (OpenAI Compatible) (category=api; backend=adapter; platform_action actions=none registered)\n- `dingtalk`: 钉钉 (DingTalk) (category=im; backend=adapter; platform_action actions=none registered)\n- `feishu`: 飞书 (Feishu/Lark) (category=im; backend=cli)\n - `calendar.create_event` payload={summary*, start_time*, end_time*, attendees} [write/high]\n - `docs.create_markdown` payload={title*, markdown*, folder_token} [write/medium]\n - `drive.search` payload={query*, limit} [read/medium]\n - `im.add_reaction` payload={message_id*, emoji_type*} [write/low]\n - `im.download_resource` payload={message_id*, file_key*, type*} [read/low]\n - `im.get_messages` payload={message_ids*} [read/low]\n - `im.list_chats` payload={page_size} [read/low]\n - `im.list_messages` payload={chat_id*} [read/low]\n - `im.list_thread_messages` payload={thread*} [read/low]\n - `im.remove_reaction` payload={message_id*, emoji_type*} [write/low]\n - `im.reply_message` payload={message_id*, text*} [send/high]\n - `im.search_chats` payload={query*, page_size} [read/low]\n - `im.search_messages` payload={query*} [read/medium]\n - `im.send_message` payload={chat_id*, text*, thread_id} [send/high]\n - `im.update_card` payload={token*, card*} [write/medium]\n - `im.update_message` payload={message_id*, text*} [write/medium]\n - `mail.search_unread` payload={query, limit} [read/high]\n - `sheets.append_row` payload={spreadsheet_token*, sheet_id*, values*} [write/medium]\n - `task.create` payload={title*, description, due_time} [write/medium]\n- `telegram`: Telegram (category=social; backend=adapter; platform_action actions=none registered)\n- `webhook`: Webhook (Generic) (category=webhook; backend=adapter; platform_action actions=none registered)\n\n## Tool Usage\nTools are normally invoked through the native function-calling mechanism, not by writing JSON in your reply\ntext. Only if the provider signals that native function calling is unavailable for this turn, fall back to a\nsingle JSON code block: `{\"name\": \"tool_name\", \"arguments\": {\"key\": \"value\"}}` — use this fallback format\nonly, never both. Only call a tool whose exact name appears in the tool index above and is currently callable\n(or reachable via `capability_expand`). Never invent, rename, alias, or guess a tool name, platform ID, or\nplatform action from argument shape or wording — if the index does not list it, it does not exist.\n`platform_connect.action` (list/guide/preflight/connect/disconnect/remove/status/events_start/events_stop/\nevents_status) is the App Connector management namespace; `platform_action.action` only accepts exact\nregistered business actions such as `im.send_message` shown in the App Connector Capability Index — never mix\nthe two namespaces. If a tool call returns an unknown/unavailable result, use the returned suggestions or\navailable names for a single retry instead of trying further variations of the same guess.\n\n**Side-effect action rule** (`platform_action` with effect=send/write/execute):\n- Call each unique action+payload **exactly once**. Never include duplicates in the same turn.\n- Once the result returns `\"completed\": true`, that action is DONE for this task. Do NOT call it again\n in any subsequent turn — immediately summarize the result for the user instead.\n- The system enforces idempotency: duplicate calls are blocked and will not execute.\n- If the user explicitly requests sending/writing multiple times, use distinct payloads per call.\n\n**Resource identifier provenance rule**:\n- NEVER fabricate, guess, or infer resource identifiers (chat_id, message_id, file_key, user_id, etc.).\n Every resource ID used in a side-effect action MUST come from a successful API response in this session.\n- If a read/list action fails (e.g. authorization error), you do NOT have valid resource IDs.\n Report the failure to the user — do NOT attempt the dependent write/send action with a guessed ID.\n- When a tool result contains `\"llm_instruction\"`, follow it exactly.\n\n## Guidelines\n1. **Direct answers first**: If you already know the answer, respond directly without tools.\n2. **Avoid redundant tool calls**: Do not call the same tool with the same arguments more than once in the same user turn. When an existing tool result already answers the user's request, stop calling tools and answer directly.\n3. **Use tools proactively**: When the user asks about files, time, system state, or needs actions performed, use the appropriate tool.\n4. **Chain tools when needed**: You can call multiple tools in sequence (e.g., list files → read file → summarize).\n5. **Handle failures gracefully**: If a tool fails, explain what went wrong and suggest alternatives. If it failed because of an unknown tool/platform/action name, retry once with an exact name from the returned suggestions, then explain rather than keep guessing.\n6. **Summarize results naturally**: After tool execution, synthesize the results into a helpful answer rather than dumping raw output.\n7. **Stay conversational**: Maintain a natural, helpful tone. Acknowledge context from earlier in the conversation.\n8. **Recall past work**:\n - Broad queries (\"之前做了什么\", \"列出任务\"): answer from the \"Recent Task History\" section already in your context. If insufficient, call session_list.\n - Specific lookups (\"上次那个配置怎么改的\"): call session_search with relevant phrases (NOT single characters).\n - Do NOT call search tools repeatedly with keyword fragments. One well-phrased call is better than ten fragmented ones.\n\n## Coding & Verification\nWhen working with code, prefer the precise built-in tools over ad-hoc shell: use `repo_map` to orient in an\nunfamiliar project, `code_search`/`file_find` to locate and `code_intel` for symbols, `edit_file` (anchored\nsearch-replace, or a unified `diff`) to change files — never rewrite a whole file to change a few lines — and\n`git_query` to inspect diffs/log. After edits, check `syntax_ok` in the result and run `test_run`/`lint_check`\nbefore declaring the work done. Batch independent read-only calls (search/read/query) into a single turn — they\nrun in parallel.\n\n## Presentation Style\n1. **Polished Markdown only**: Format user-facing answers with clean Markdown headings, short paragraphs, and concise bullets. Use tables only when they improve comparison or scanning.\n2. **Terminal-friendly layout**: Keep lines readable in a TUI; avoid dense walls of text, deeply nested lists, oversized ASCII art, or heavy visual blocks.\n3. **Elegant emphasis**: Use bold text sparingly for key terms and conclusions. Avoid excessive emojis, decorative symbols, repeated separators, or visual noise.\n4. **Theme-safe colors**: Do not emit ANSI escape codes, HTML color tags, Rich markup, or hardcoded color names. Rely on the TUI theme to render Markdown professionally.\n5. **No leaked tool protocol**: Never show tool-call JSON, internal schemas, raw observations, tool result payloads, or hidden reasoning in the final answer unless the user explicitly asks for raw/debug output. Treat any prior `{\"name\": ..., \"arguments\": ...}` blocks and `Tool result (...)` messages as internal execution context only.\n6. **Professional closure**: End with a concise conclusion or next step when helpful; avoid rambling after the useful answer is complete.\n\nWhen finished with all tool calls, respond normally without a JSON block, tool-call transcript, or process log.\n\n\n## Recent Session Summary\n- [assistant] Recovered after a server error.\n- [user] Keep going with more context.\n\n## Task Contract\n- Task ID: turn-3\n- Original user request: Keep going with more context.\n- Workspace root: \n- Allowed roots: \n- Treat relative project paths as relative to the workspace root; never infer `.` as the project root when a workspace root is provided.\n- Workspace boundary is enforced by tools: do not read, search, edit, or run commands against paths outside the allowed roots unless the user explicitly requests an external path and the tool/approval policy permits it.\n- LeapFlow workspace config is optional at `/.leapflow/config.yaml`; runtime config is loaded from `~/.leapflow/config/user.yaml` and `~/.leapflow/profiles//config/*.yaml`.\n- Preserve this task contract across summarization, compression, tool loops, and memory retrieval.\n" + }, + { + "role": "assistant", + "content": "Recovered after a server error." + }, + { + "role": "user", + "content": "Keep going with more context.\nKeep going with more context." + } + ], + "tools": [ + "capability_expand", + "code_intel", + "code_search", + "config_get", + "config_list", + "env_info", + "file_find", + "file_list", + "file_read", + "git_query", + "lint_check", + "memory_search", + "plugin_list", + "plugin_propose", + "plugin_status", + "plugin_versions", + "repo_map", + "research_note", + "schedule_reentry", + "session_list", + "skill_view", + "skills_list", + "terminal_list", + "terminal_read", + "test_run", + "text_search", + "time_get", + "web_fetch" + ] + }, + "responses": [ + { + "status": 400, + "content_type": "application/json", + "body": "{\"error\": {\"message\": \"This model's maximum context length is 8192 tokens. However, your messages resulted in 9001 tokens.\", \"type\": \"invalid_request_error\", \"code\": \"context_length_exceeded\"}}" + }, + { + "status": 200, + "content_type": "application/json", + "body": "{\"id\": \"chatcmpl-cassette\", \"object\": \"chat.completion\", \"model\": \"cassette-model\", \"choices\": [{\"index\": 0, \"message\": {\"role\": \"assistant\", \"content\": \"Recovered after compressing context.\"}, \"finish_reason\": \"stop\"}], \"usage\": {\"prompt_tokens\": 64, \"completion_tokens\": 16, \"total_tokens\": 80}}" + } + ] +} diff --git a/tests/_fixtures/cassettes/r4_recovery/cassette-model-5e0f7ffde6f3ea00.cassette.json b/tests/_fixtures/cassettes/r4_recovery/cassette-model-5e0f7ffde6f3ea00.cassette.json new file mode 100644 index 0000000..aaa97e9 --- /dev/null +++ b/tests/_fixtures/cassettes/r4_recovery/cassette-model-5e0f7ffde6f3ea00.cassette.json @@ -0,0 +1,64 @@ +{ + "fingerprint": "5e0f7ffde6f3ea0059bdb1d3be26059577ec42993d395d93156cf72b6201ddc0", + "note": "captured in seed mode", + "request": { + "model": "cassette-model", + "stream": false, + "messages": [ + { + "role": "system", + "content": "You are LeapFlow, an intelligent assistant that can both converse naturally and take real actions on the user's computer.\n\n## Capabilities\nThe tool index below lists **every** registered tool by name and a one-line summary — this is the complete\ncapability contract; nothing else exists. Only a subset is directly callable this turn (via native tool calling,\nnot a JSON block in your reply). If you need a tool from the index that is not yet callable, call\n`capability_expand` with its category name first — the matching tools become callable immediately after.\nWhen the user asks what LeapFlow itself supports, whether it supports plugins, or which runtime capabilities\nare available, use the live capability evidence exposed by `plugin_list` before making capability claims; report\nconfiguration-dependent or unavailable capabilities as limitations instead of inferring from documentation.\n- **text_search**(text, pattern): Search for a regex pattern in text.\n- **text_replace**(text, old, new, count) [capability_expand category: write]: Replace occurrences of a substring in text.\n- **time_get**(): Get current date and time.\n- **env_info**(): Get system environment information (OS, Python version, cwd).\n- **skills_list**(query, category, source): List available learned skills. Use when user asks about capabilities or you need a specific skill.\n- **skill_view**(name): View the full content of a specific skill document.\n- **code_intel**(path, operation): Precise document symbols (outline) for a source file: classes, functions, and methods with line ranges. Python uses an exact AST parse; other languages use a keyword-prefix scan. Prefer over file_read mode=symbols for accurate navigation before editing. Read-only.\n- **repo_map**(path): Compact project orientation for a repository root: languages, detected test/lint commands, top-level structure, entry points, manifest, and VCS branch. Call this first when entering an unfamiliar codebase. Read-only.\n- **scm_sync**(action, cwd, remote, pull_ref, push_ref, timeout) [capability_expand category: scm]: Run a typed git SCM action. Use this instead of shell_run for git pull/push/status. For 'pull origin main then push', set action='pull_then_push', remote='origin', pull_ref='main', and omit push_ref so LeapFlow pushes the current local branch.\n- **git_query**(action, cwd, ref, path, staged, max_count, stat): Read-only structured git inspection: action=diff|log|status|branch|show. Prefer over shell_run for reading repo state — output is clipped, redacted, and log/branch are parsed into structured fields. Use scm_sync for pull/push.\n- **git_write**(action, cwd, message, stage_all, name, ref, create) [capability_expand category: scm]: Mutating git actions: action=commit (message, stage_all), branch (create+switch), checkout (switch; create=true for -b). Approval-gated. Use scm_sync for pull/push and git_query for reads.\n- **test_run**(command, cwd, timeout): Run the project's test suite and return structured results (framework, passed/failed counts, failing tests). Auto-detects the runner (pytest/npm/go/cargo) or uses a configured/explicit command; executes via the governed shell. ok=true means the runner executed — see 'success' for pass/fail.\n- **lint_check**(command, cwd, timeout): Run the project's linter and return a structured clean/issue result. Auto-detects the linter (ruff/eslint/go vet/clippy) or uses a configured/explicit command; executes via the governed shell. ok=true means the linter ran — see 'clean'.\n- **file_list**(path, pattern, depth): List files and directories at a given path. Use depth=1 or depth=2 to get a recursive tree in one call instead of listing each sub-directory separately.\n- **file_read**(path, max_lines, start_line, max_chars, mode): Read text file content with adaptive context governance. For large or unfamiliar files, prefer mode='outline' or mode='symbols' first, then use mode='raw' with start_line/max_lines for the specific range you actually need. For LeapFlow's own settings, use config_list / config_get / config_set — its config files are outside the workspace and not readable here.\n- **file_write**(path, content, mode) [capability_expand category: write]: Write content to a file (overwrite or append).\n- **code_search**(pattern, patterns, path, glob, ignore_case, multiline, max_results, context_lines): Search file CONTENTS by regex pattern across a directory tree (ripgrep-backed). Requires a regex pattern. NOT for listing or browsing directory contents — use file_list for that. Prefer this over shell_run grep: faster, skips VCS/dependency/build dirs, and returns structured path:line:column matches. Batch related lookups into ONE call via `patterns` (OR-combined, single pass) instead of issuing several separate searches. Use file_read for the surrounding context of a hit.\n- **file_find**(glob, path, max_results): Find files by a recursive glob pattern under a base path (e.g. '**/test_*.py' or '*.md'). Prefer this over shell_run find; skips VCS/dependency/build dirs.\n- **edit_file**(path, edits, dry_run, diff) [capability_expand category: write]: Apply targeted, anchored search-replace edits to an EXISTING text file (use file_write to create/overwrite). Each edit is {original_text, new_text, replace_all?}; original_text must match exactly and uniquely (or set replace_all) — a non-unique or missing anchor is rejected so files are never corrupted. Set dry_run to preview. Alternatively pass a unified 'diff' to apply its hunks as anchored edits. Far cheaper and safer than rewriting a whole file.\n- **shell_run**(command, cwd, timeout) [capability_expand category: shell]: Execute a one-shot shell command with timeout protection. Runs in the active workspace; paths resolving outside it are refused. Reach for a structured tool first when one fits — web_fetch for anything over HTTP(S), git_query/scm_sync for git, code_search/file_find/file_read for the repo, config_get/config_set for LeapFlow's own settings — because those report typed results, while a failed shell command can only be diagnosed from its exit code and stderr. Every shell run counts as an external side effect, so a failure stops the rest of the batch and is not retried automatically.\n- **terminal_open**(command, cwd, shell) [capability_expand category: terminal]: Open a PERSISTENT shell session (REPL/dev server/watch), returning a session_id for terminal_send/read/close. Disabled unless tools.terminal_session_enabled is set. For one-shot commands use shell_run instead.\n- **terminal_send**(session_id, input, wait) [capability_expand category: terminal]: Send a line of input to a persistent terminal session and return output captured shortly after.\n- **terminal_read**(session_id, wait): Drain buffered output from a persistent terminal session (optionally waiting briefly first).\n- **terminal_close**(session_id) [capability_expand category: terminal]: Terminate a persistent terminal session and release its process group.\n- **terminal_list**(): List active persistent terminal sessions.\n- **config_list**(category, limit): List LeapFlow's own writable settings (model, provider, daemon, memory, perception, gateway, …) with current values. Use this to discover the exact key before changing anything. Optionally narrow by `category`. This is the only correct way to inspect LeapFlow configuration — never read config files from disk.\n- **config_get**(key): Read one LeapFlow setting by key (e.g. 'llm.model', 'daemon.log_level'), returning its current value, type, scopes, and whether a change needs a daemon restart. Never read LeapFlow config files from disk — use this.\n- **config_set**(key, value, scope) [capability_expand category: config]: Change one LeapFlow setting by key, e.g. switch the model with key='llm.model'. Values are validated and coerced; credentials are stored in the vault automatically. Call config_list or config_get first if unsure of the key. The result states whether a `leap daemon restart` is required. Never edit LeapFlow config files directly.\n- **web_fetch**(url, select, timeout, max_bytes): Read a URL over HTTP(S) and get back extracted, context-sized content: parsed JSON for API endpoints, readable text plus links for web pages. Use this for anything on the internet — prices, docs, releases, articles — instead of running curl through shell_run: it reports real HTTP status codes, retries rate limits on its own, and is a plain read so a retry is always safe. For JSON APIs pass `select` with a dotted path (e.g. 'chart.result.0.meta') to return just that part instead of the whole payload.\n- **memory_search**(query, limit): Search agent memory for relevant past experiences, observations, and facts.\n- **memory_add**(content, kind) [capability_expand category: write]: Store a new observation or insight in memory for future reference.\n- **research_note**(kind, text): Record a compact, structured note about the current task's state so it survives context compression on long / multi-step tasks. Use for durable findings, open questions still to resolve, decisions / excluded paths, and the immediate next step. One concise sentence per note.\n- **capability_expand**(category): Fetch the full callable schema for every tool in a capability category (e.g. 'hub', 'gateway', 'desktop', '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.\n- **delegate_task**(goal, context) [capability_expand category: delegate]: Delegate a complex sub-task to an isolated subagent. The subagent gets a fresh context and restricted tool access. Use when a task is self-contained and can be solved independently.\n- **schedule_reentry**(kind, reason, delay_seconds, event_match, max_reentries, deadline_seconds): Register a re-entry so this task can resume later from its current orientation (findings / open questions / next step). Use when work must pause and continue after a delay (kind=time) or when a matching platform event arrives (kind=event), instead of finishing now. The research-ledger state is carried over automatically.\n- **hub_push**(skill_name, visibility, version) [capability_expand category: hub]: Push a local skill to the ModelScope Hub for sharing or backup.\n- **hub_pull**(repo_id, version) [capability_expand category: hub]: Pull a skill from the ModelScope Hub to install locally.\n- **hub_search**(query) [capability_expand category: hub]: Search for skills on the Hub by keyword or description.\n- **hub_sync**(mode, dry_run) [capability_expand category: hub]: Preview or execute sync between local skills and Hub.\n- **platform_action**(platform, action, payload, backend_kind) [capability_expand category: gateway]: Execute an exact registered business action on an external platform through LeapFlow's App Connector layer. Actions must be copied from the App Connector Capability Index and are addressed as domain.operation, e.g. im.send_message or docs.create_markdown. All business fields (chat_id, text, query, etc.) MUST be placed inside `payload`, never at the top level. Example: {\"platform\":\"feishu\",\"action\":\"im.send_message\",\"payload\":{\"chat_id\":\"oc_xxx\",\"text\":\"hello\"}}. Do not invent action names, do not use management actions such as list/guide/connect/status here.\n- **platform_connect**(action, platform, credentials, options, checkpoint) [capability_expand category: gateway]: List, guide, connect, disconnect, remove, or check status for external platforms using the App Connector management namespace. Supports REST and CLI backends. Use this for management actions such as list/guide/preflight/connect/status; use platform_action only for exact registered business actions.\n- **gateway_send**(platform, chat_id, text, thread_id) [capability_expand category: gateway]: Send a message to a connected external platform (Feishu group, Telegram chat, DingTalk conversation, etc.). Requires the platform to be connected via gateway_connect first. Use gateway_connect with action='list' to see connected platforms and available chat IDs.\n- **gateway_connect**(action, platform, credentials, options) [capability_expand category: gateway]: Connect, configure, or manage external platform integrations (Feishu, DingTalk, Telegram, Slack, Discord, etc.). Conversational flow: 1) call 'guide' to get setup steps + required fields, 2) present the steps to the user and ask for ALL required credentials in a single message, 3) call 'connect' with the credentials. Goal: complete in 1–2 user turns. NEVER include credential values in your text response.\n- **plugin_list**(): List the live plugin registry and cross-subsystem capability evidence. Use this before answering questions about whether LeapFlow supports plugins, self-evolution, plugin installation, hot reload, versioning, or other runtime capabilities.\n- **plugin_status**(plugin_id): Get detailed status of a specific plugin: its declared category, runtime dependencies, contributed tools, and fiber lifecycle state.\n- **plugin_versions**(plugin_id): List recorded source versions and active pointer for a profile-scoped plugin.\n- **plugin_propose**(requested_capability, plugin_id, proposed_tools, test_cases, risk_level, evidence): Create a side-effect-free PluginProposal from explicit capability-gap evidence. Use this before plugin_generate when a missing capability should be reviewed. Does not call an LLM, write files, or install anything.\n- **assess_compatibility**(manifest, source_path) [capability_expand category: plugin_management]: Assess whether a foreign plugin manifest or real DSH source bundle is compatible with LeapFlow. A source bundle assessment is static: runtime_ready stays false until restricted Node discovery during plugin_install. Returns component-level verdicts and limitations.\n- **plugin_generate**(plugin_id, description, proposal_id) [capability_expand category: system]: Generate a new ToolPlugin from a natural-language capability description. The LLM produces code that conforms to the ToolPlugin Protocol; it is then rigorously validated (syntax, structure, import, protocol conformance). The isolated sandbox smoke test runs later, at install-time. Returns the validated code but DOES NOT install it — installation is a separate approval-gated step via plugin_install.\n- **plugin_install**(plugin_id, code, marketplace_name, source_path, proposal_id, version_label) [capability_expand category: system]: Install a plugin from exactly one source: validated Python code, the configured Python marketplace, or a real DSH source bundle. DSH bundles run restricted Node discovery before registration; only public host tools are installed and client UI limitations are reported. Writes profile-scoped state and mutates the process-global registry. REQUIRES APPROVAL.\n- **plugin_rollback**(plugin_id, version) [capability_expand category: system]: Rollback a profile-scoped plugin to a recorded source version and reload it. REQUIRES APPROVAL.\n- **plugin_reload**(plugin_id, version_label) [capability_expand category: system]: Hot-reload a plugin at runtime. Disposes the old plugin fiber, re-imports its module, and registers a fresh instance. Existing in-flight turns are unaffected (snapshot isolation). REQUIRES APPROVAL — this is a self-modification action.\n- **plugin_disable**(plugin_id) [capability_expand category: system]: Disable a plugin by disposing its fiber, removing its tools from the runtime registry. Cannot disable self_management itself. REQUIRES APPROVAL — this is a self-modification action.\n- **plugin_remove**(plugin_id, delete_source) [capability_expand category: system]: Terminally remove a plugin: dispose its fiber, unregister its tools, remove reload metadata, and optionally delete its profile-scoped source file. Cannot remove self_management itself. REQUIRES APPROVAL.\n- **plugin_enable**(plugin_id) [capability_expand category: system]: Re-enable a previously disabled plugin by reloading its module and registering a fresh instance. REQUIRES APPROVAL.\n- **session_search**(query, limit) [capability_expand category: unclassified]: Search past conversation sessions in the current workspace for relevant context.\n- **session_list**(limit): List recent conversation sessions in the current workspace with stable ids, titles, dates, and summaries. Use for browsing past tasks or when user asks to see history without specific search terms. No keywords needed — returns chronological list.\n- **session_detail**(session_id, limit, offset, include_inactive) [capability_expand category: unclassified]: Read a paginated persisted transcript for one past conversation session in the current workspace. Use after session_list or session_search returns a session_id.\n- **click**(element_index) [capability_expand category: desktop]: Click a UI element by its element_index (from the latest observe_ui snapshot)\n- **get_clipboard**() [capability_expand category: desktop]: Read current clipboard text content\n- **list_apps**(filter, running_only) [capability_expand category: desktop]: List available applications on this system. Use to discover correct bundle_id before switch_app.\n- **list_windows**() [capability_expand category: desktop]: List all top-level windows with pid, window_id, title, and per-window state (minimized, on-screen). Call this first to pick the pid and window_id that observe_ui and other window tools require.\n- **observe_ui**(pid, window_id, query) [capability_expand category: desktop]: Snapshot one window's actionable UI elements, each tagged with an element_index for click/right_click/read_text. Re-observe after actions — indices belong to one snapshot. Requires the window's pid and window_id from list_windows.\n- **open_url**(url, app_id) [capability_expand category: desktop]: Open a URL in the default or specified browser\n- **read_text**(element_index) [capability_expand category: desktop]: Read the text content of a specific UI element from the latest snapshot\n- **right_click**(element_index) [capability_expand category: desktop]: Right-click a UI element to open its context menu. Returns visible menu items.\n- **screenshot**(pid, window_id) [capability_expand category: desktop]: Capture a screenshot for visual verification. With pid + window_id captures that window (works across all displays); defaults to the last observed window, or the full desktop when no window has been observed.\n- **scroll**(element_index, direction, amount, pid, window_id) [capability_expand category: desktop]: Scroll a scrollable area of a window. Omit element_index to scroll the window's focused/page scroller; pass one to scroll an exact element from the latest snapshot.\n- **select_text**(element_index) [capability_expand category: desktop]: Select all text in a UI element (focus + select-all, for subsequent copy)\n- **set_clipboard**(text) [capability_expand category: desktop]: Write text to the clipboard\n- **shortcut**(keys) [capability_expand category: desktop]: Execute a keyboard shortcut\n- **switch_app**(app_id) [capability_expand category: desktop]: Switch to an app (launch if needed, activate, verify)\n- **type_text**(text) [capability_expand category: desktop]: Type text into the currently focused element\n- **wait**(seconds) [capability_expand category: desktop]: Wait for a specified duration before continuing\n- **wait_until**(condition, pid, window_id, timeout, poll_interval) [capability_expand category: desktop]: Wait until a UI condition is met (polls UI tree). Returns elements when found or on timeout.\n- **wait_until_stable**(timeout, poll_interval, pid, window_id) [capability_expand category: desktop]: Wait until the UI stops changing (element set stabilizes across polls).\n\n## App Connector Capability Index\nLeapFlow can onboard and manage external apps through `platform_connect` and execute exact registered business actions through `platform_action`.\nFor requests about connecting, setting up, configuring, enabling, or managing a supported app, use `platform_connect` first instead of generating SDK/Webhook sample code.\n`platform_connect.action` is the management namespace: list, guide, preflight, connect, disconnect, remove, status, events_start, events_stop, events_status.\n`platform_action.action` is only for exact registered platform business actions listed below, such as `im.send_message`; never use management actions like `list` or `guide` there.\nAll business fields MUST go inside `payload`; top-level keys are only `platform`, `action`, and `payload`.\nDo not invent platform IDs or platform action names. If the needed action is not listed, ask for discovery/clarification instead of guessing.\nUse `platform_connect` with `action='guide'` and the matching `platform` to start onboarding; use `action='list'` when the app is unclear.\nWhen a pending onboarding state is present, continue from that state with `platform_connect` instead of asking the user to restate the app.\nSupported apps:\n- `api_server`: API Server (OpenAI Compatible) (category=api; backend=adapter; platform_action actions=none registered)\n- `dingtalk`: 钉钉 (DingTalk) (category=im; backend=adapter; platform_action actions=none registered)\n- `feishu`: 飞书 (Feishu/Lark) (category=im; backend=cli)\n - `calendar.create_event` payload={summary*, start_time*, end_time*, attendees} [write/high]\n - `docs.create_markdown` payload={title*, markdown*, folder_token} [write/medium]\n - `drive.search` payload={query*, limit} [read/medium]\n - `im.add_reaction` payload={message_id*, emoji_type*} [write/low]\n - `im.download_resource` payload={message_id*, file_key*, type*} [read/low]\n - `im.get_messages` payload={message_ids*} [read/low]\n - `im.list_chats` payload={page_size} [read/low]\n - `im.list_messages` payload={chat_id*} [read/low]\n - `im.list_thread_messages` payload={thread*} [read/low]\n - `im.remove_reaction` payload={message_id*, emoji_type*} [write/low]\n - `im.reply_message` payload={message_id*, text*} [send/high]\n - `im.search_chats` payload={query*, page_size} [read/low]\n - `im.search_messages` payload={query*} [read/medium]\n - `im.send_message` payload={chat_id*, text*, thread_id} [send/high]\n - `im.update_card` payload={token*, card*} [write/medium]\n - `im.update_message` payload={message_id*, text*} [write/medium]\n - `mail.search_unread` payload={query, limit} [read/high]\n - `sheets.append_row` payload={spreadsheet_token*, sheet_id*, values*} [write/medium]\n - `task.create` payload={title*, description, due_time} [write/medium]\n- `telegram`: Telegram (category=social; backend=adapter; platform_action actions=none registered)\n- `webhook`: Webhook (Generic) (category=webhook; backend=adapter; platform_action actions=none registered)\n\n## Tool Usage\nTools are normally invoked through the native function-calling mechanism, not by writing JSON in your reply\ntext. Only if the provider signals that native function calling is unavailable for this turn, fall back to a\nsingle JSON code block: `{\"name\": \"tool_name\", \"arguments\": {\"key\": \"value\"}}` — use this fallback format\nonly, never both. Only call a tool whose exact name appears in the tool index above and is currently callable\n(or reachable via `capability_expand`). Never invent, rename, alias, or guess a tool name, platform ID, or\nplatform action from argument shape or wording — if the index does not list it, it does not exist.\n`platform_connect.action` (list/guide/preflight/connect/disconnect/remove/status/events_start/events_stop/\nevents_status) is the App Connector management namespace; `platform_action.action` only accepts exact\nregistered business actions such as `im.send_message` shown in the App Connector Capability Index — never mix\nthe two namespaces. If a tool call returns an unknown/unavailable result, use the returned suggestions or\navailable names for a single retry instead of trying further variations of the same guess.\n\n**Side-effect action rule** (`platform_action` with effect=send/write/execute):\n- Call each unique action+payload **exactly once**. Never include duplicates in the same turn.\n- Once the result returns `\"completed\": true`, that action is DONE for this task. Do NOT call it again\n in any subsequent turn — immediately summarize the result for the user instead.\n- The system enforces idempotency: duplicate calls are blocked and will not execute.\n- If the user explicitly requests sending/writing multiple times, use distinct payloads per call.\n\n**Resource identifier provenance rule**:\n- NEVER fabricate, guess, or infer resource identifiers (chat_id, message_id, file_key, user_id, etc.).\n Every resource ID used in a side-effect action MUST come from a successful API response in this session.\n- If a read/list action fails (e.g. authorization error), you do NOT have valid resource IDs.\n Report the failure to the user — do NOT attempt the dependent write/send action with a guessed ID.\n- When a tool result contains `\"llm_instruction\"`, follow it exactly.\n\n## Guidelines\n1. **Direct answers first**: If you already know the answer, respond directly without tools.\n2. **Avoid redundant tool calls**: Do not call the same tool with the same arguments more than once in the same user turn. When an existing tool result already answers the user's request, stop calling tools and answer directly.\n3. **Use tools proactively**: When the user asks about files, time, system state, or needs actions performed, use the appropriate tool.\n4. **Chain tools when needed**: You can call multiple tools in sequence (e.g., list files → read file → summarize).\n5. **Handle failures gracefully**: If a tool fails, explain what went wrong and suggest alternatives. If it failed because of an unknown tool/platform/action name, retry once with an exact name from the returned suggestions, then explain rather than keep guessing.\n6. **Summarize results naturally**: After tool execution, synthesize the results into a helpful answer rather than dumping raw output.\n7. **Stay conversational**: Maintain a natural, helpful tone. Acknowledge context from earlier in the conversation.\n8. **Recall past work**:\n - Broad queries (\"之前做了什么\", \"列出任务\"): answer from the \"Recent Task History\" section already in your context. If insufficient, call session_list.\n - Specific lookups (\"上次那个配置怎么改的\"): call session_search with relevant phrases (NOT single characters).\n - Do NOT call search tools repeatedly with keyword fragments. One well-phrased call is better than ten fragmented ones.\n\n## Coding & Verification\nWhen working with code, prefer the precise built-in tools over ad-hoc shell: use `repo_map` to orient in an\nunfamiliar project, `code_search`/`file_find` to locate and `code_intel` for symbols, `edit_file` (anchored\nsearch-replace, or a unified `diff`) to change files — never rewrite a whole file to change a few lines — and\n`git_query` to inspect diffs/log. After edits, check `syntax_ok` in the result and run `test_run`/`lint_check`\nbefore declaring the work done. Batch independent read-only calls (search/read/query) into a single turn — they\nrun in parallel.\n\n## Presentation Style\n1. **Polished Markdown only**: Format user-facing answers with clean Markdown headings, short paragraphs, and concise bullets. Use tables only when they improve comparison or scanning.\n2. **Terminal-friendly layout**: Keep lines readable in a TUI; avoid dense walls of text, deeply nested lists, oversized ASCII art, or heavy visual blocks.\n3. **Elegant emphasis**: Use bold text sparingly for key terms and conclusions. Avoid excessive emojis, decorative symbols, repeated separators, or visual noise.\n4. **Theme-safe colors**: Do not emit ANSI escape codes, HTML color tags, Rich markup, or hardcoded color names. Rely on the TUI theme to render Markdown professionally.\n5. **No leaked tool protocol**: Never show tool-call JSON, internal schemas, raw observations, tool result payloads, or hidden reasoning in the final answer unless the user explicitly asks for raw/debug output. Treat any prior `{\"name\": ..., \"arguments\": ...}` blocks and `Tool result (...)` messages as internal execution context only.\n6. **Professional closure**: End with a concise conclusion or next step when helpful; avoid rambling after the useful answer is complete.\n\nWhen finished with all tool calls, respond normally without a JSON block, tool-call transcript, or process log.\n\n\n## Recent Session Summary\n- [assistant] Recovered after compressing context.\n- [user] Do the impossible thing.\n\n## Task Contract\n- Task ID: turn-4\n- Original user request: Do the impossible thing.\n- Workspace root: \n- Allowed roots: \n- Treat relative project paths as relative to the workspace root; never infer `.` as the project root when a workspace root is provided.\n- Workspace boundary is enforced by tools: do not read, search, edit, or run commands against paths outside the allowed roots unless the user explicitly requests an external path and the tool/approval policy permits it.\n- LeapFlow workspace config is optional at `/.leapflow/config.yaml`; runtime config is loaded from `~/.leapflow/config/user.yaml` and `~/.leapflow/profiles//config/*.yaml`.\n- Preserve this task contract across summarization, compression, tool loops, and memory retrieval.\n" + }, + { + "role": "assistant", + "content": "Recovered after compressing context." + }, + { + "role": "user", + "content": "Do the impossible thing.\nDo the impossible thing." + } + ], + "tools": [ + "capability_expand", + "code_intel", + "code_search", + "config_get", + "config_list", + "env_info", + "file_find", + "file_list", + "file_read", + "git_query", + "lint_check", + "memory_search", + "plugin_list", + "plugin_propose", + "plugin_status", + "plugin_versions", + "repo_map", + "research_note", + "schedule_reentry", + "session_list", + "skill_view", + "skills_list", + "terminal_list", + "terminal_read", + "test_run", + "text_search", + "time_get", + "web_fetch" + ] + }, + "responses": [ + { + "status": 400, + "content_type": "application/json", + "body": "{\"error\": {\"message\": \"The requested configuration is not supported by this model\", \"type\": \"invalid_request_error\", \"code\": \"unsupported_value\"}}" + }, + { + "status": 400, + "content_type": "application/json", + "body": "{\"error\": {\"message\": \"The requested configuration is not supported by this model\", \"type\": \"invalid_request_error\", \"code\": \"unsupported_value\"}}" + } + ] +} diff --git a/tests/_fixtures/cassettes/r4_recovery/cassette-model-6cc28bd558c4aee3.cassette.json b/tests/_fixtures/cassettes/r4_recovery/cassette-model-6cc28bd558c4aee3.cassette.json new file mode 100644 index 0000000..9921703 --- /dev/null +++ b/tests/_fixtures/cassettes/r4_recovery/cassette-model-6cc28bd558c4aee3.cassette.json @@ -0,0 +1,60 @@ +{ + "fingerprint": "6cc28bd558c4aee3aa6d6fd0716e99bd82e1a28b203d5a9c93f6080de324dc64", + "note": "captured in seed mode", + "request": { + "model": "cassette-model", + "stream": false, + "messages": [ + { + "role": "system", + "content": "You are LeapFlow, an intelligent assistant that can both converse naturally and take real actions on the user's computer.\n\n## Capabilities\nThe tool index below lists **every** registered tool by name and a one-line summary — this is the complete\ncapability contract; nothing else exists. Only a subset is directly callable this turn (via native tool calling,\nnot a JSON block in your reply). If you need a tool from the index that is not yet callable, call\n`capability_expand` with its category name first — the matching tools become callable immediately after.\nWhen the user asks what LeapFlow itself supports, whether it supports plugins, or which runtime capabilities\nare available, use the live capability evidence exposed by `plugin_list` before making capability claims; report\nconfiguration-dependent or unavailable capabilities as limitations instead of inferring from documentation.\n- **text_search**(text, pattern): Search for a regex pattern in text.\n- **text_replace**(text, old, new, count) [capability_expand category: write]: Replace occurrences of a substring in text.\n- **time_get**(): Get current date and time.\n- **env_info**(): Get system environment information (OS, Python version, cwd).\n- **skills_list**(query, category, source): List available learned skills. Use when user asks about capabilities or you need a specific skill.\n- **skill_view**(name): View the full content of a specific skill document.\n- **code_intel**(path, operation): Precise document symbols (outline) for a source file: classes, functions, and methods with line ranges. Python uses an exact AST parse; other languages use a keyword-prefix scan. Prefer over file_read mode=symbols for accurate navigation before editing. Read-only.\n- **repo_map**(path): Compact project orientation for a repository root: languages, detected test/lint commands, top-level structure, entry points, manifest, and VCS branch. Call this first when entering an unfamiliar codebase. Read-only.\n- **scm_sync**(action, cwd, remote, pull_ref, push_ref, timeout) [capability_expand category: scm]: Run a typed git SCM action. Use this instead of shell_run for git pull/push/status. For 'pull origin main then push', set action='pull_then_push', remote='origin', pull_ref='main', and omit push_ref so LeapFlow pushes the current local branch.\n- **git_query**(action, cwd, ref, path, staged, max_count, stat): Read-only structured git inspection: action=diff|log|status|branch|show. Prefer over shell_run for reading repo state — output is clipped, redacted, and log/branch are parsed into structured fields. Use scm_sync for pull/push.\n- **git_write**(action, cwd, message, stage_all, name, ref, create) [capability_expand category: scm]: Mutating git actions: action=commit (message, stage_all), branch (create+switch), checkout (switch; create=true for -b). Approval-gated. Use scm_sync for pull/push and git_query for reads.\n- **test_run**(command, cwd, timeout): Run the project's test suite and return structured results (framework, passed/failed counts, failing tests). Auto-detects the runner (pytest/npm/go/cargo) or uses a configured/explicit command; executes via the governed shell. ok=true means the runner executed — see 'success' for pass/fail.\n- **lint_check**(command, cwd, timeout): Run the project's linter and return a structured clean/issue result. Auto-detects the linter (ruff/eslint/go vet/clippy) or uses a configured/explicit command; executes via the governed shell. ok=true means the linter ran — see 'clean'.\n- **file_list**(path, pattern, depth): List files and directories at a given path. Use depth=1 or depth=2 to get a recursive tree in one call instead of listing each sub-directory separately.\n- **file_read**(path, max_lines, start_line, max_chars, mode): Read text file content with adaptive context governance. For large or unfamiliar files, prefer mode='outline' or mode='symbols' first, then use mode='raw' with start_line/max_lines for the specific range you actually need. For LeapFlow's own settings, use config_list / config_get / config_set — its config files are outside the workspace and not readable here.\n- **file_write**(path, content, mode) [capability_expand category: write]: Write content to a file (overwrite or append).\n- **code_search**(pattern, patterns, path, glob, ignore_case, multiline, max_results, context_lines): Search file CONTENTS by regex pattern across a directory tree (ripgrep-backed). Requires a regex pattern. NOT for listing or browsing directory contents — use file_list for that. Prefer this over shell_run grep: faster, skips VCS/dependency/build dirs, and returns structured path:line:column matches. Batch related lookups into ONE call via `patterns` (OR-combined, single pass) instead of issuing several separate searches. Use file_read for the surrounding context of a hit.\n- **file_find**(glob, path, max_results): Find files by a recursive glob pattern under a base path (e.g. '**/test_*.py' or '*.md'). Prefer this over shell_run find; skips VCS/dependency/build dirs.\n- **edit_file**(path, edits, dry_run, diff) [capability_expand category: write]: Apply targeted, anchored search-replace edits to an EXISTING text file (use file_write to create/overwrite). Each edit is {original_text, new_text, replace_all?}; original_text must match exactly and uniquely (or set replace_all) — a non-unique or missing anchor is rejected so files are never corrupted. Set dry_run to preview. Alternatively pass a unified 'diff' to apply its hunks as anchored edits. Far cheaper and safer than rewriting a whole file.\n- **shell_run**(command, cwd, timeout) [capability_expand category: shell]: Execute a one-shot shell command with timeout protection. Runs in the active workspace; paths resolving outside it are refused. Reach for a structured tool first when one fits — web_fetch for anything over HTTP(S), git_query/scm_sync for git, code_search/file_find/file_read for the repo, config_get/config_set for LeapFlow's own settings — because those report typed results, while a failed shell command can only be diagnosed from its exit code and stderr. Every shell run counts as an external side effect, so a failure stops the rest of the batch and is not retried automatically.\n- **terminal_open**(command, cwd, shell) [capability_expand category: terminal]: Open a PERSISTENT shell session (REPL/dev server/watch), returning a session_id for terminal_send/read/close. Disabled unless tools.terminal_session_enabled is set. For one-shot commands use shell_run instead.\n- **terminal_send**(session_id, input, wait) [capability_expand category: terminal]: Send a line of input to a persistent terminal session and return output captured shortly after.\n- **terminal_read**(session_id, wait): Drain buffered output from a persistent terminal session (optionally waiting briefly first).\n- **terminal_close**(session_id) [capability_expand category: terminal]: Terminate a persistent terminal session and release its process group.\n- **terminal_list**(): List active persistent terminal sessions.\n- **config_list**(category, limit): List LeapFlow's own writable settings (model, provider, daemon, memory, perception, gateway, …) with current values. Use this to discover the exact key before changing anything. Optionally narrow by `category`. This is the only correct way to inspect LeapFlow configuration — never read config files from disk.\n- **config_get**(key): Read one LeapFlow setting by key (e.g. 'llm.model', 'daemon.log_level'), returning its current value, type, scopes, and whether a change needs a daemon restart. Never read LeapFlow config files from disk — use this.\n- **config_set**(key, value, scope) [capability_expand category: config]: Change one LeapFlow setting by key, e.g. switch the model with key='llm.model'. Values are validated and coerced; credentials are stored in the vault automatically. Call config_list or config_get first if unsure of the key. The result states whether a `leap daemon restart` is required. Never edit LeapFlow config files directly.\n- **web_fetch**(url, select, timeout, max_bytes): Read a URL over HTTP(S) and get back extracted, context-sized content: parsed JSON for API endpoints, readable text plus links for web pages. Use this for anything on the internet — prices, docs, releases, articles — instead of running curl through shell_run: it reports real HTTP status codes, retries rate limits on its own, and is a plain read so a retry is always safe. For JSON APIs pass `select` with a dotted path (e.g. 'chart.result.0.meta') to return just that part instead of the whole payload.\n- **memory_search**(query, limit): Search agent memory for relevant past experiences, observations, and facts.\n- **memory_add**(content, kind) [capability_expand category: write]: Store a new observation or insight in memory for future reference.\n- **research_note**(kind, text): Record a compact, structured note about the current task's state so it survives context compression on long / multi-step tasks. Use for durable findings, open questions still to resolve, decisions / excluded paths, and the immediate next step. One concise sentence per note.\n- **capability_expand**(category): Fetch the full callable schema for every tool in a capability category (e.g. 'hub', 'gateway', 'desktop', '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.\n- **delegate_task**(goal, context) [capability_expand category: delegate]: Delegate a complex sub-task to an isolated subagent. The subagent gets a fresh context and restricted tool access. Use when a task is self-contained and can be solved independently.\n- **schedule_reentry**(kind, reason, delay_seconds, event_match, max_reentries, deadline_seconds): Register a re-entry so this task can resume later from its current orientation (findings / open questions / next step). Use when work must pause and continue after a delay (kind=time) or when a matching platform event arrives (kind=event), instead of finishing now. The research-ledger state is carried over automatically.\n- **hub_push**(skill_name, visibility, version) [capability_expand category: hub]: Push a local skill to the ModelScope Hub for sharing or backup.\n- **hub_pull**(repo_id, version) [capability_expand category: hub]: Pull a skill from the ModelScope Hub to install locally.\n- **hub_search**(query) [capability_expand category: hub]: Search for skills on the Hub by keyword or description.\n- **hub_sync**(mode, dry_run) [capability_expand category: hub]: Preview or execute sync between local skills and Hub.\n- **platform_action**(platform, action, payload, backend_kind) [capability_expand category: gateway]: Execute an exact registered business action on an external platform through LeapFlow's App Connector layer. Actions must be copied from the App Connector Capability Index and are addressed as domain.operation, e.g. im.send_message or docs.create_markdown. All business fields (chat_id, text, query, etc.) MUST be placed inside `payload`, never at the top level. Example: {\"platform\":\"feishu\",\"action\":\"im.send_message\",\"payload\":{\"chat_id\":\"oc_xxx\",\"text\":\"hello\"}}. Do not invent action names, do not use management actions such as list/guide/connect/status here.\n- **platform_connect**(action, platform, credentials, options, checkpoint) [capability_expand category: gateway]: List, guide, connect, disconnect, remove, or check status for external platforms using the App Connector management namespace. Supports REST and CLI backends. Use this for management actions such as list/guide/preflight/connect/status; use platform_action only for exact registered business actions.\n- **gateway_send**(platform, chat_id, text, thread_id) [capability_expand category: gateway]: Send a message to a connected external platform (Feishu group, Telegram chat, DingTalk conversation, etc.). Requires the platform to be connected via gateway_connect first. Use gateway_connect with action='list' to see connected platforms and available chat IDs.\n- **gateway_connect**(action, platform, credentials, options) [capability_expand category: gateway]: Connect, configure, or manage external platform integrations (Feishu, DingTalk, Telegram, Slack, Discord, etc.). Conversational flow: 1) call 'guide' to get setup steps + required fields, 2) present the steps to the user and ask for ALL required credentials in a single message, 3) call 'connect' with the credentials. Goal: complete in 1–2 user turns. NEVER include credential values in your text response.\n- **plugin_list**(): List the live plugin registry and cross-subsystem capability evidence. Use this before answering questions about whether LeapFlow supports plugins, self-evolution, plugin installation, hot reload, versioning, or other runtime capabilities.\n- **plugin_status**(plugin_id): Get detailed status of a specific plugin: its declared category, runtime dependencies, contributed tools, and fiber lifecycle state.\n- **plugin_versions**(plugin_id): List recorded source versions and active pointer for a profile-scoped plugin.\n- **plugin_propose**(requested_capability, plugin_id, proposed_tools, test_cases, risk_level, evidence): Create a side-effect-free PluginProposal from explicit capability-gap evidence. Use this before plugin_generate when a missing capability should be reviewed. Does not call an LLM, write files, or install anything.\n- **assess_compatibility**(manifest, source_path) [capability_expand category: plugin_management]: Assess whether a foreign plugin manifest or real DSH source bundle is compatible with LeapFlow. A source bundle assessment is static: runtime_ready stays false until restricted Node discovery during plugin_install. Returns component-level verdicts and limitations.\n- **plugin_generate**(plugin_id, description, proposal_id) [capability_expand category: system]: Generate a new ToolPlugin from a natural-language capability description. The LLM produces code that conforms to the ToolPlugin Protocol; it is then rigorously validated (syntax, structure, import, protocol conformance). The isolated sandbox smoke test runs later, at install-time. Returns the validated code but DOES NOT install it — installation is a separate approval-gated step via plugin_install.\n- **plugin_install**(plugin_id, code, marketplace_name, source_path, proposal_id, version_label) [capability_expand category: system]: Install a plugin from exactly one source: validated Python code, the configured Python marketplace, or a real DSH source bundle. DSH bundles run restricted Node discovery before registration; only public host tools are installed and client UI limitations are reported. Writes profile-scoped state and mutates the process-global registry. REQUIRES APPROVAL.\n- **plugin_rollback**(plugin_id, version) [capability_expand category: system]: Rollback a profile-scoped plugin to a recorded source version and reload it. REQUIRES APPROVAL.\n- **plugin_reload**(plugin_id, version_label) [capability_expand category: system]: Hot-reload a plugin at runtime. Disposes the old plugin fiber, re-imports its module, and registers a fresh instance. Existing in-flight turns are unaffected (snapshot isolation). REQUIRES APPROVAL — this is a self-modification action.\n- **plugin_disable**(plugin_id) [capability_expand category: system]: Disable a plugin by disposing its fiber, removing its tools from the runtime registry. Cannot disable self_management itself. REQUIRES APPROVAL — this is a self-modification action.\n- **plugin_remove**(plugin_id, delete_source) [capability_expand category: system]: Terminally remove a plugin: dispose its fiber, unregister its tools, remove reload metadata, and optionally delete its profile-scoped source file. Cannot remove self_management itself. REQUIRES APPROVAL.\n- **plugin_enable**(plugin_id) [capability_expand category: system]: Re-enable a previously disabled plugin by reloading its module and registering a fresh instance. REQUIRES APPROVAL.\n- **session_search**(query, limit) [capability_expand category: unclassified]: Search past conversation sessions in the current workspace for relevant context.\n- **session_list**(limit): List recent conversation sessions in the current workspace with stable ids, titles, dates, and summaries. Use for browsing past tasks or when user asks to see history without specific search terms. No keywords needed — returns chronological list.\n- **session_detail**(session_id, limit, offset, include_inactive) [capability_expand category: unclassified]: Read a paginated persisted transcript for one past conversation session in the current workspace. Use after session_list or session_search returns a session_id.\n- **click**(element_index) [capability_expand category: desktop]: Click a UI element by its element_index (from the latest observe_ui snapshot)\n- **get_clipboard**() [capability_expand category: desktop]: Read current clipboard text content\n- **list_apps**(filter, running_only) [capability_expand category: desktop]: List available applications on this system. Use to discover correct bundle_id before switch_app.\n- **list_windows**() [capability_expand category: desktop]: List all top-level windows with pid, window_id, title, and per-window state (minimized, on-screen). Call this first to pick the pid and window_id that observe_ui and other window tools require.\n- **observe_ui**(pid, window_id, query) [capability_expand category: desktop]: Snapshot one window's actionable UI elements, each tagged with an element_index for click/right_click/read_text. Re-observe after actions — indices belong to one snapshot. Requires the window's pid and window_id from list_windows.\n- **open_url**(url, app_id) [capability_expand category: desktop]: Open a URL in the default or specified browser\n- **read_text**(element_index) [capability_expand category: desktop]: Read the text content of a specific UI element from the latest snapshot\n- **right_click**(element_index) [capability_expand category: desktop]: Right-click a UI element to open its context menu. Returns visible menu items.\n- **screenshot**(pid, window_id) [capability_expand category: desktop]: Capture a screenshot for visual verification. With pid + window_id captures that window (works across all displays); defaults to the last observed window, or the full desktop when no window has been observed.\n- **scroll**(element_index, direction, amount, pid, window_id) [capability_expand category: desktop]: Scroll a scrollable area of a window. Omit element_index to scroll the window's focused/page scroller; pass one to scroll an exact element from the latest snapshot.\n- **select_text**(element_index) [capability_expand category: desktop]: Select all text in a UI element (focus + select-all, for subsequent copy)\n- **set_clipboard**(text) [capability_expand category: desktop]: Write text to the clipboard\n- **shortcut**(keys) [capability_expand category: desktop]: Execute a keyboard shortcut\n- **switch_app**(app_id) [capability_expand category: desktop]: Switch to an app (launch if needed, activate, verify)\n- **type_text**(text) [capability_expand category: desktop]: Type text into the currently focused element\n- **wait**(seconds) [capability_expand category: desktop]: Wait for a specified duration before continuing\n- **wait_until**(condition, pid, window_id, timeout, poll_interval) [capability_expand category: desktop]: Wait until a UI condition is met (polls UI tree). Returns elements when found or on timeout.\n- **wait_until_stable**(timeout, poll_interval, pid, window_id) [capability_expand category: desktop]: Wait until the UI stops changing (element set stabilizes across polls).\n\n## App Connector Capability Index\nLeapFlow can onboard and manage external apps through `platform_connect` and execute exact registered business actions through `platform_action`.\nFor requests about connecting, setting up, configuring, enabling, or managing a supported app, use `platform_connect` first instead of generating SDK/Webhook sample code.\n`platform_connect.action` is the management namespace: list, guide, preflight, connect, disconnect, remove, status, events_start, events_stop, events_status.\n`platform_action.action` is only for exact registered platform business actions listed below, such as `im.send_message`; never use management actions like `list` or `guide` there.\nAll business fields MUST go inside `payload`; top-level keys are only `platform`, `action`, and `payload`.\nDo not invent platform IDs or platform action names. If the needed action is not listed, ask for discovery/clarification instead of guessing.\nUse `platform_connect` with `action='guide'` and the matching `platform` to start onboarding; use `action='list'` when the app is unclear.\nWhen a pending onboarding state is present, continue from that state with `platform_connect` instead of asking the user to restate the app.\nSupported apps:\n- `api_server`: API Server (OpenAI Compatible) (category=api; backend=adapter; platform_action actions=none registered)\n- `dingtalk`: 钉钉 (DingTalk) (category=im; backend=adapter; platform_action actions=none registered)\n- `feishu`: 飞书 (Feishu/Lark) (category=im; backend=cli)\n - `calendar.create_event` payload={summary*, start_time*, end_time*, attendees} [write/high]\n - `docs.create_markdown` payload={title*, markdown*, folder_token} [write/medium]\n - `drive.search` payload={query*, limit} [read/medium]\n - `im.add_reaction` payload={message_id*, emoji_type*} [write/low]\n - `im.download_resource` payload={message_id*, file_key*, type*} [read/low]\n - `im.get_messages` payload={message_ids*} [read/low]\n - `im.list_chats` payload={page_size} [read/low]\n - `im.list_messages` payload={chat_id*} [read/low]\n - `im.list_thread_messages` payload={thread*} [read/low]\n - `im.remove_reaction` payload={message_id*, emoji_type*} [write/low]\n - `im.reply_message` payload={message_id*, text*} [send/high]\n - `im.search_chats` payload={query*, page_size} [read/low]\n - `im.search_messages` payload={query*} [read/medium]\n - `im.send_message` payload={chat_id*, text*, thread_id} [send/high]\n - `im.update_card` payload={token*, card*} [write/medium]\n - `im.update_message` payload={message_id*, text*} [write/medium]\n - `mail.search_unread` payload={query, limit} [read/high]\n - `sheets.append_row` payload={spreadsheet_token*, sheet_id*, values*} [write/medium]\n - `task.create` payload={title*, description, due_time} [write/medium]\n- `telegram`: Telegram (category=social; backend=adapter; platform_action actions=none registered)\n- `webhook`: Webhook (Generic) (category=webhook; backend=adapter; platform_action actions=none registered)\n\n## Tool Usage\nTools are normally invoked through the native function-calling mechanism, not by writing JSON in your reply\ntext. Only if the provider signals that native function calling is unavailable for this turn, fall back to a\nsingle JSON code block: `{\"name\": \"tool_name\", \"arguments\": {\"key\": \"value\"}}` — use this fallback format\nonly, never both. Only call a tool whose exact name appears in the tool index above and is currently callable\n(or reachable via `capability_expand`). Never invent, rename, alias, or guess a tool name, platform ID, or\nplatform action from argument shape or wording — if the index does not list it, it does not exist.\n`platform_connect.action` (list/guide/preflight/connect/disconnect/remove/status/events_start/events_stop/\nevents_status) is the App Connector management namespace; `platform_action.action` only accepts exact\nregistered business actions such as `im.send_message` shown in the App Connector Capability Index — never mix\nthe two namespaces. If a tool call returns an unknown/unavailable result, use the returned suggestions or\navailable names for a single retry instead of trying further variations of the same guess.\n\n**Side-effect action rule** (`platform_action` with effect=send/write/execute):\n- Call each unique action+payload **exactly once**. Never include duplicates in the same turn.\n- Once the result returns `\"completed\": true`, that action is DONE for this task. Do NOT call it again\n in any subsequent turn — immediately summarize the result for the user instead.\n- The system enforces idempotency: duplicate calls are blocked and will not execute.\n- If the user explicitly requests sending/writing multiple times, use distinct payloads per call.\n\n**Resource identifier provenance rule**:\n- NEVER fabricate, guess, or infer resource identifiers (chat_id, message_id, file_key, user_id, etc.).\n Every resource ID used in a side-effect action MUST come from a successful API response in this session.\n- If a read/list action fails (e.g. authorization error), you do NOT have valid resource IDs.\n Report the failure to the user — do NOT attempt the dependent write/send action with a guessed ID.\n- When a tool result contains `\"llm_instruction\"`, follow it exactly.\n\n## Guidelines\n1. **Direct answers first**: If you already know the answer, respond directly without tools.\n2. **Avoid redundant tool calls**: Do not call the same tool with the same arguments more than once in the same user turn. When an existing tool result already answers the user's request, stop calling tools and answer directly.\n3. **Use tools proactively**: When the user asks about files, time, system state, or needs actions performed, use the appropriate tool.\n4. **Chain tools when needed**: You can call multiple tools in sequence (e.g., list files → read file → summarize).\n5. **Handle failures gracefully**: If a tool fails, explain what went wrong and suggest alternatives. If it failed because of an unknown tool/platform/action name, retry once with an exact name from the returned suggestions, then explain rather than keep guessing.\n6. **Summarize results naturally**: After tool execution, synthesize the results into a helpful answer rather than dumping raw output.\n7. **Stay conversational**: Maintain a natural, helpful tone. Acknowledge context from earlier in the conversation.\n8. **Recall past work**:\n - Broad queries (\"之前做了什么\", \"列出任务\"): answer from the \"Recent Task History\" section already in your context. If insufficient, call session_list.\n - Specific lookups (\"上次那个配置怎么改的\"): call session_search with relevant phrases (NOT single characters).\n - Do NOT call search tools repeatedly with keyword fragments. One well-phrased call is better than ten fragmented ones.\n\n## Coding & Verification\nWhen working with code, prefer the precise built-in tools over ad-hoc shell: use `repo_map` to orient in an\nunfamiliar project, `code_search`/`file_find` to locate and `code_intel` for symbols, `edit_file` (anchored\nsearch-replace, or a unified `diff`) to change files — never rewrite a whole file to change a few lines — and\n`git_query` to inspect diffs/log. After edits, check `syntax_ok` in the result and run `test_run`/`lint_check`\nbefore declaring the work done. Batch independent read-only calls (search/read/query) into a single turn — they\nrun in parallel.\n\n## Presentation Style\n1. **Polished Markdown only**: Format user-facing answers with clean Markdown headings, short paragraphs, and concise bullets. Use tables only when they improve comparison or scanning.\n2. **Terminal-friendly layout**: Keep lines readable in a TUI; avoid dense walls of text, deeply nested lists, oversized ASCII art, or heavy visual blocks.\n3. **Elegant emphasis**: Use bold text sparingly for key terms and conclusions. Avoid excessive emojis, decorative symbols, repeated separators, or visual noise.\n4. **Theme-safe colors**: Do not emit ANSI escape codes, HTML color tags, Rich markup, or hardcoded color names. Rely on the TUI theme to render Markdown professionally.\n5. **No leaked tool protocol**: Never show tool-call JSON, internal schemas, raw observations, tool result payloads, or hidden reasoning in the final answer unless the user explicitly asks for raw/debug output. Treat any prior `{\"name\": ..., \"arguments\": ...}` blocks and `Tool result (...)` messages as internal execution context only.\n6. **Professional closure**: End with a concise conclusion or next step when helpful; avoid rambling after the useful answer is complete.\n\nWhen finished with all tool calls, respond normally without a JSON block, tool-call transcript, or process log.\n\n\n## Recent Session Summary\n- [user] Summarize the situation.\n\n## Task Contract\n- Task ID: turn-1\n- Original user request: Summarize the situation.\n- Workspace root: \n- Allowed roots: \n- Treat relative project paths as relative to the workspace root; never infer `.` as the project root when a workspace root is provided.\n- Workspace boundary is enforced by tools: do not read, search, edit, or run commands against paths outside the allowed roots unless the user explicitly requests an external path and the tool/approval policy permits it.\n- LeapFlow workspace config is optional at `/.leapflow/config.yaml`; runtime config is loaded from `~/.leapflow/config/user.yaml` and `~/.leapflow/profiles//config/*.yaml`.\n- Preserve this task contract across summarization, compression, tool loops, and memory retrieval.\n" + }, + { + "role": "user", + "content": "Summarize the situation.\nSummarize the situation." + } + ], + "tools": [ + "capability_expand", + "code_intel", + "code_search", + "config_get", + "config_list", + "env_info", + "file_find", + "file_list", + "file_read", + "git_query", + "lint_check", + "memory_search", + "plugin_list", + "plugin_propose", + "plugin_status", + "plugin_versions", + "repo_map", + "research_note", + "schedule_reentry", + "session_list", + "skill_view", + "skills_list", + "terminal_list", + "terminal_read", + "test_run", + "text_search", + "time_get", + "web_fetch" + ] + }, + "responses": [ + { + "status": 429, + "content_type": "application/json", + "body": "{\"error\": {\"message\": \"Rate limit reached for requests\", \"type\": \"rate_limit_error\", \"code\": \"rate_limit_exceeded\"}}" + }, + { + "status": 200, + "content_type": "application/json", + "body": "{\"id\": \"chatcmpl-cassette\", \"object\": \"chat.completion\", \"model\": \"cassette-model\", \"choices\": [{\"index\": 0, \"message\": {\"role\": \"assistant\", \"content\": \"Recovered after a rate limit.\"}, \"finish_reason\": \"stop\"}], \"usage\": {\"prompt_tokens\": 64, \"completion_tokens\": 16, \"total_tokens\": 80}}" + } + ] +} diff --git a/tests/_fixtures/cassettes/r4_recovery/cassette-model-73ea75042e30a61a.cassette.json b/tests/_fixtures/cassettes/r4_recovery/cassette-model-73ea75042e30a61a.cassette.json new file mode 100644 index 0000000..3a43a1e --- /dev/null +++ b/tests/_fixtures/cassettes/r4_recovery/cassette-model-73ea75042e30a61a.cassette.json @@ -0,0 +1,64 @@ +{ + "fingerprint": "73ea75042e30a61a13b02a1defe7fa5b0abc41e4d373f9ff2df06e7215029ca6", + "note": "captured in seed mode", + "request": { + "model": "cassette-model", + "stream": false, + "messages": [ + { + "role": "system", + "content": "You are LeapFlow, an intelligent assistant that can both converse naturally and take real actions on the user's computer.\n\n## Capabilities\nThe tool index below lists **every** registered tool by name and a one-line summary — this is the complete\ncapability contract; nothing else exists. Only a subset is directly callable this turn (via native tool calling,\nnot a JSON block in your reply). If you need a tool from the index that is not yet callable, call\n`capability_expand` with its category name first — the matching tools become callable immediately after.\nWhen the user asks what LeapFlow itself supports, whether it supports plugins, or which runtime capabilities\nare available, use the live capability evidence exposed by `plugin_list` before making capability claims; report\nconfiguration-dependent or unavailable capabilities as limitations instead of inferring from documentation.\n- **text_search**(text, pattern): Search for a regex pattern in text.\n- **text_replace**(text, old, new, count) [capability_expand category: write]: Replace occurrences of a substring in text.\n- **time_get**(): Get current date and time.\n- **env_info**(): Get system environment information (OS, Python version, cwd).\n- **skills_list**(query, category, source): List available learned skills. Use when user asks about capabilities or you need a specific skill.\n- **skill_view**(name): View the full content of a specific skill document.\n- **code_intel**(path, operation): Precise document symbols (outline) for a source file: classes, functions, and methods with line ranges. Python uses an exact AST parse; other languages use a keyword-prefix scan. Prefer over file_read mode=symbols for accurate navigation before editing. Read-only.\n- **repo_map**(path): Compact project orientation for a repository root: languages, detected test/lint commands, top-level structure, entry points, manifest, and VCS branch. Call this first when entering an unfamiliar codebase. Read-only.\n- **scm_sync**(action, cwd, remote, pull_ref, push_ref, timeout) [capability_expand category: scm]: Run a typed git SCM action. Use this instead of shell_run for git pull/push/status. For 'pull origin main then push', set action='pull_then_push', remote='origin', pull_ref='main', and omit push_ref so LeapFlow pushes the current local branch.\n- **git_query**(action, cwd, ref, path, staged, max_count, stat): Read-only structured git inspection: action=diff|log|status|branch|show. Prefer over shell_run for reading repo state — output is clipped, redacted, and log/branch are parsed into structured fields. Use scm_sync for pull/push.\n- **git_write**(action, cwd, message, stage_all, name, ref, create) [capability_expand category: scm]: Mutating git actions: action=commit (message, stage_all), branch (create+switch), checkout (switch; create=true for -b). Approval-gated. Use scm_sync for pull/push and git_query for reads.\n- **test_run**(command, cwd, timeout): Run the project's test suite and return structured results (framework, passed/failed counts, failing tests). Auto-detects the runner (pytest/npm/go/cargo) or uses a configured/explicit command; executes via the governed shell. ok=true means the runner executed — see 'success' for pass/fail.\n- **lint_check**(command, cwd, timeout): Run the project's linter and return a structured clean/issue result. Auto-detects the linter (ruff/eslint/go vet/clippy) or uses a configured/explicit command; executes via the governed shell. ok=true means the linter ran — see 'clean'.\n- **file_list**(path, pattern, depth): List files and directories at a given path. Use depth=1 or depth=2 to get a recursive tree in one call instead of listing each sub-directory separately.\n- **file_read**(path, max_lines, start_line, max_chars, mode): Read text file content with adaptive context governance. For large or unfamiliar files, prefer mode='outline' or mode='symbols' first, then use mode='raw' with start_line/max_lines for the specific range you actually need. For LeapFlow's own settings, use config_list / config_get / config_set — its config files are outside the workspace and not readable here.\n- **file_write**(path, content, mode) [capability_expand category: write]: Write content to a file (overwrite or append).\n- **code_search**(pattern, patterns, path, glob, ignore_case, multiline, max_results, context_lines): Search file CONTENTS by regex pattern across a directory tree (ripgrep-backed). Requires a regex pattern. NOT for listing or browsing directory contents — use file_list for that. Prefer this over shell_run grep: faster, skips VCS/dependency/build dirs, and returns structured path:line:column matches. Batch related lookups into ONE call via `patterns` (OR-combined, single pass) instead of issuing several separate searches. Use file_read for the surrounding context of a hit.\n- **file_find**(glob, path, max_results): Find files by a recursive glob pattern under a base path (e.g. '**/test_*.py' or '*.md'). Prefer this over shell_run find; skips VCS/dependency/build dirs.\n- **edit_file**(path, edits, dry_run, diff) [capability_expand category: write]: Apply targeted, anchored search-replace edits to an EXISTING text file (use file_write to create/overwrite). Each edit is {original_text, new_text, replace_all?}; original_text must match exactly and uniquely (or set replace_all) — a non-unique or missing anchor is rejected so files are never corrupted. Set dry_run to preview. Alternatively pass a unified 'diff' to apply its hunks as anchored edits. Far cheaper and safer than rewriting a whole file.\n- **shell_run**(command, cwd, timeout) [capability_expand category: shell]: Execute a one-shot shell command with timeout protection. Runs in the active workspace; paths resolving outside it are refused. Reach for a structured tool first when one fits — web_fetch for anything over HTTP(S), git_query/scm_sync for git, code_search/file_find/file_read for the repo, config_get/config_set for LeapFlow's own settings — because those report typed results, while a failed shell command can only be diagnosed from its exit code and stderr. Every shell run counts as an external side effect, so a failure stops the rest of the batch and is not retried automatically.\n- **terminal_open**(command, cwd, shell) [capability_expand category: terminal]: Open a PERSISTENT shell session (REPL/dev server/watch), returning a session_id for terminal_send/read/close. Disabled unless tools.terminal_session_enabled is set. For one-shot commands use shell_run instead.\n- **terminal_send**(session_id, input, wait) [capability_expand category: terminal]: Send a line of input to a persistent terminal session and return output captured shortly after.\n- **terminal_read**(session_id, wait): Drain buffered output from a persistent terminal session (optionally waiting briefly first).\n- **terminal_close**(session_id) [capability_expand category: terminal]: Terminate a persistent terminal session and release its process group.\n- **terminal_list**(): List active persistent terminal sessions.\n- **config_list**(category, limit): List LeapFlow's own writable settings (model, provider, daemon, memory, perception, gateway, …) with current values. Use this to discover the exact key before changing anything. Optionally narrow by `category`. This is the only correct way to inspect LeapFlow configuration — never read config files from disk.\n- **config_get**(key): Read one LeapFlow setting by key (e.g. 'llm.model', 'daemon.log_level'), returning its current value, type, scopes, and whether a change needs a daemon restart. Never read LeapFlow config files from disk — use this.\n- **config_set**(key, value, scope) [capability_expand category: config]: Change one LeapFlow setting by key, e.g. switch the model with key='llm.model'. Values are validated and coerced; credentials are stored in the vault automatically. Call config_list or config_get first if unsure of the key. The result states whether a `leap daemon restart` is required. Never edit LeapFlow config files directly.\n- **web_fetch**(url, select, timeout, max_bytes): Read a URL over HTTP(S) and get back extracted, context-sized content: parsed JSON for API endpoints, readable text plus links for web pages. Use this for anything on the internet — prices, docs, releases, articles — instead of running curl through shell_run: it reports real HTTP status codes, retries rate limits on its own, and is a plain read so a retry is always safe. For JSON APIs pass `select` with a dotted path (e.g. 'chart.result.0.meta') to return just that part instead of the whole payload.\n- **memory_search**(query, limit): Search agent memory for relevant past experiences, observations, and facts.\n- **memory_add**(content, kind) [capability_expand category: write]: Store a new observation or insight in memory for future reference.\n- **research_note**(kind, text): Record a compact, structured note about the current task's state so it survives context compression on long / multi-step tasks. Use for durable findings, open questions still to resolve, decisions / excluded paths, and the immediate next step. One concise sentence per note.\n- **capability_expand**(category): Fetch the full callable schema for every tool in a capability category (e.g. 'hub', 'gateway', 'desktop', '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.\n- **delegate_task**(goal, context) [capability_expand category: delegate]: Delegate a complex sub-task to an isolated subagent. The subagent gets a fresh context and restricted tool access. Use when a task is self-contained and can be solved independently.\n- **schedule_reentry**(kind, reason, delay_seconds, event_match, max_reentries, deadline_seconds): Register a re-entry so this task can resume later from its current orientation (findings / open questions / next step). Use when work must pause and continue after a delay (kind=time) or when a matching platform event arrives (kind=event), instead of finishing now. The research-ledger state is carried over automatically.\n- **hub_push**(skill_name, visibility, version) [capability_expand category: hub]: Push a local skill to the ModelScope Hub for sharing or backup.\n- **hub_pull**(repo_id, version) [capability_expand category: hub]: Pull a skill from the ModelScope Hub to install locally.\n- **hub_search**(query) [capability_expand category: hub]: Search for skills on the Hub by keyword or description.\n- **hub_sync**(mode, dry_run) [capability_expand category: hub]: Preview or execute sync between local skills and Hub.\n- **platform_action**(platform, action, payload, backend_kind) [capability_expand category: gateway]: Execute an exact registered business action on an external platform through LeapFlow's App Connector layer. Actions must be copied from the App Connector Capability Index and are addressed as domain.operation, e.g. im.send_message or docs.create_markdown. All business fields (chat_id, text, query, etc.) MUST be placed inside `payload`, never at the top level. Example: {\"platform\":\"feishu\",\"action\":\"im.send_message\",\"payload\":{\"chat_id\":\"oc_xxx\",\"text\":\"hello\"}}. Do not invent action names, do not use management actions such as list/guide/connect/status here.\n- **platform_connect**(action, platform, credentials, options, checkpoint) [capability_expand category: gateway]: List, guide, connect, disconnect, remove, or check status for external platforms using the App Connector management namespace. Supports REST and CLI backends. Use this for management actions such as list/guide/preflight/connect/status; use platform_action only for exact registered business actions.\n- **gateway_send**(platform, chat_id, text, thread_id) [capability_expand category: gateway]: Send a message to a connected external platform (Feishu group, Telegram chat, DingTalk conversation, etc.). Requires the platform to be connected via gateway_connect first. Use gateway_connect with action='list' to see connected platforms and available chat IDs.\n- **gateway_connect**(action, platform, credentials, options) [capability_expand category: gateway]: Connect, configure, or manage external platform integrations (Feishu, DingTalk, Telegram, Slack, Discord, etc.). Conversational flow: 1) call 'guide' to get setup steps + required fields, 2) present the steps to the user and ask for ALL required credentials in a single message, 3) call 'connect' with the credentials. Goal: complete in 1–2 user turns. NEVER include credential values in your text response.\n- **plugin_list**(): List the live plugin registry and cross-subsystem capability evidence. Use this before answering questions about whether LeapFlow supports plugins, self-evolution, plugin installation, hot reload, versioning, or other runtime capabilities.\n- **plugin_status**(plugin_id): Get detailed status of a specific plugin: its declared category, runtime dependencies, contributed tools, and fiber lifecycle state.\n- **plugin_versions**(plugin_id): List recorded source versions and active pointer for a profile-scoped plugin.\n- **plugin_propose**(requested_capability, plugin_id, proposed_tools, test_cases, risk_level, evidence): Create a side-effect-free PluginProposal from explicit capability-gap evidence. Use this before plugin_generate when a missing capability should be reviewed. Does not call an LLM, write files, or install anything.\n- **assess_compatibility**(manifest, source_path) [capability_expand category: plugin_management]: Assess whether a foreign plugin manifest or real DSH source bundle is compatible with LeapFlow. A source bundle assessment is static: runtime_ready stays false until restricted Node discovery during plugin_install. Returns component-level verdicts and limitations.\n- **plugin_generate**(plugin_id, description, proposal_id) [capability_expand category: system]: Generate a new ToolPlugin from a natural-language capability description. The LLM produces code that conforms to the ToolPlugin Protocol; it is then rigorously validated (syntax, structure, import, protocol conformance). The isolated sandbox smoke test runs later, at install-time. Returns the validated code but DOES NOT install it — installation is a separate approval-gated step via plugin_install.\n- **plugin_install**(plugin_id, code, marketplace_name, source_path, proposal_id, version_label) [capability_expand category: system]: Install a plugin from exactly one source: validated Python code, the configured Python marketplace, or a real DSH source bundle. DSH bundles run restricted Node discovery before registration; only public host tools are installed and client UI limitations are reported. Writes profile-scoped state and mutates the process-global registry. REQUIRES APPROVAL.\n- **plugin_rollback**(plugin_id, version) [capability_expand category: system]: Rollback a profile-scoped plugin to a recorded source version and reload it. REQUIRES APPROVAL.\n- **plugin_reload**(plugin_id, version_label) [capability_expand category: system]: Hot-reload a plugin at runtime. Disposes the old plugin fiber, re-imports its module, and registers a fresh instance. Existing in-flight turns are unaffected (snapshot isolation). REQUIRES APPROVAL — this is a self-modification action.\n- **plugin_disable**(plugin_id) [capability_expand category: system]: Disable a plugin by disposing its fiber, removing its tools from the runtime registry. Cannot disable self_management itself. REQUIRES APPROVAL — this is a self-modification action.\n- **plugin_remove**(plugin_id, delete_source) [capability_expand category: system]: Terminally remove a plugin: dispose its fiber, unregister its tools, remove reload metadata, and optionally delete its profile-scoped source file. Cannot remove self_management itself. REQUIRES APPROVAL.\n- **plugin_enable**(plugin_id) [capability_expand category: system]: Re-enable a previously disabled plugin by reloading its module and registering a fresh instance. REQUIRES APPROVAL.\n- **session_search**(query, limit) [capability_expand category: unclassified]: Search past conversation sessions in the current workspace for relevant context.\n- **session_list**(limit): List recent conversation sessions in the current workspace with stable ids, titles, dates, and summaries. Use for browsing past tasks or when user asks to see history without specific search terms. No keywords needed — returns chronological list.\n- **session_detail**(session_id, limit, offset, include_inactive) [capability_expand category: unclassified]: Read a paginated persisted transcript for one past conversation session in the current workspace. Use after session_list or session_search returns a session_id.\n- **click**(element_index) [capability_expand category: desktop]: Click a UI element by its element_index (from the latest observe_ui snapshot)\n- **get_clipboard**() [capability_expand category: desktop]: Read current clipboard text content\n- **list_apps**(filter, running_only) [capability_expand category: desktop]: List available applications on this system. Use to discover correct bundle_id before switch_app.\n- **list_windows**() [capability_expand category: desktop]: List all top-level windows with pid, window_id, title, and per-window state (minimized, on-screen). Call this first to pick the pid and window_id that observe_ui and other window tools require.\n- **observe_ui**(pid, window_id, query) [capability_expand category: desktop]: Snapshot one window's actionable UI elements, each tagged with an element_index for click/right_click/read_text. Re-observe after actions — indices belong to one snapshot. Requires the window's pid and window_id from list_windows.\n- **open_url**(url, app_id) [capability_expand category: desktop]: Open a URL in the default or specified browser\n- **read_text**(element_index) [capability_expand category: desktop]: Read the text content of a specific UI element from the latest snapshot\n- **right_click**(element_index) [capability_expand category: desktop]: Right-click a UI element to open its context menu. Returns visible menu items.\n- **screenshot**(pid, window_id) [capability_expand category: desktop]: Capture a screenshot for visual verification. With pid + window_id captures that window (works across all displays); defaults to the last observed window, or the full desktop when no window has been observed.\n- **scroll**(element_index, direction, amount, pid, window_id) [capability_expand category: desktop]: Scroll a scrollable area of a window. Omit element_index to scroll the window's focused/page scroller; pass one to scroll an exact element from the latest snapshot.\n- **select_text**(element_index) [capability_expand category: desktop]: Select all text in a UI element (focus + select-all, for subsequent copy)\n- **set_clipboard**(text) [capability_expand category: desktop]: Write text to the clipboard\n- **shortcut**(keys) [capability_expand category: desktop]: Execute a keyboard shortcut\n- **switch_app**(app_id) [capability_expand category: desktop]: Switch to an app (launch if needed, activate, verify)\n- **type_text**(text) [capability_expand category: desktop]: Type text into the currently focused element\n- **wait**(seconds) [capability_expand category: desktop]: Wait for a specified duration before continuing\n- **wait_until**(condition, pid, window_id, timeout, poll_interval) [capability_expand category: desktop]: Wait until a UI condition is met (polls UI tree). Returns elements when found or on timeout.\n- **wait_until_stable**(timeout, poll_interval, pid, window_id) [capability_expand category: desktop]: Wait until the UI stops changing (element set stabilizes across polls).\n\n## App Connector Capability Index\nLeapFlow can onboard and manage external apps through `platform_connect` and execute exact registered business actions through `platform_action`.\nFor requests about connecting, setting up, configuring, enabling, or managing a supported app, use `platform_connect` first instead of generating SDK/Webhook sample code.\n`platform_connect.action` is the management namespace: list, guide, preflight, connect, disconnect, remove, status, events_start, events_stop, events_status.\n`platform_action.action` is only for exact registered platform business actions listed below, such as `im.send_message`; never use management actions like `list` or `guide` there.\nAll business fields MUST go inside `payload`; top-level keys are only `platform`, `action`, and `payload`.\nDo not invent platform IDs or platform action names. If the needed action is not listed, ask for discovery/clarification instead of guessing.\nUse `platform_connect` with `action='guide'` and the matching `platform` to start onboarding; use `action='list'` when the app is unclear.\nWhen a pending onboarding state is present, continue from that state with `platform_connect` instead of asking the user to restate the app.\nSupported apps:\n- `api_server`: API Server (OpenAI Compatible) (category=api; backend=adapter; platform_action actions=none registered)\n- `dingtalk`: 钉钉 (DingTalk) (category=im; backend=adapter; platform_action actions=none registered)\n- `feishu`: 飞书 (Feishu/Lark) (category=im; backend=cli)\n - `calendar.create_event` payload={summary*, start_time*, end_time*, attendees} [write/high]\n - `docs.create_markdown` payload={title*, markdown*, folder_token} [write/medium]\n - `drive.search` payload={query*, limit} [read/medium]\n - `im.add_reaction` payload={message_id*, emoji_type*} [write/low]\n - `im.download_resource` payload={message_id*, file_key*, type*} [read/low]\n - `im.get_messages` payload={message_ids*} [read/low]\n - `im.list_chats` payload={page_size} [read/low]\n - `im.list_messages` payload={chat_id*} [read/low]\n - `im.list_thread_messages` payload={thread*} [read/low]\n - `im.remove_reaction` payload={message_id*, emoji_type*} [write/low]\n - `im.reply_message` payload={message_id*, text*} [send/high]\n - `im.search_chats` payload={query*, page_size} [read/low]\n - `im.search_messages` payload={query*} [read/medium]\n - `im.send_message` payload={chat_id*, text*, thread_id} [send/high]\n - `im.update_card` payload={token*, card*} [write/medium]\n - `im.update_message` payload={message_id*, text*} [write/medium]\n - `mail.search_unread` payload={query, limit} [read/high]\n - `sheets.append_row` payload={spreadsheet_token*, sheet_id*, values*} [write/medium]\n - `task.create` payload={title*, description, due_time} [write/medium]\n- `telegram`: Telegram (category=social; backend=adapter; platform_action actions=none registered)\n- `webhook`: Webhook (Generic) (category=webhook; backend=adapter; platform_action actions=none registered)\n\n## Tool Usage\nTools are normally invoked through the native function-calling mechanism, not by writing JSON in your reply\ntext. Only if the provider signals that native function calling is unavailable for this turn, fall back to a\nsingle JSON code block: `{\"name\": \"tool_name\", \"arguments\": {\"key\": \"value\"}}` — use this fallback format\nonly, never both. Only call a tool whose exact name appears in the tool index above and is currently callable\n(or reachable via `capability_expand`). Never invent, rename, alias, or guess a tool name, platform ID, or\nplatform action from argument shape or wording — if the index does not list it, it does not exist.\n`platform_connect.action` (list/guide/preflight/connect/disconnect/remove/status/events_start/events_stop/\nevents_status) is the App Connector management namespace; `platform_action.action` only accepts exact\nregistered business actions such as `im.send_message` shown in the App Connector Capability Index — never mix\nthe two namespaces. If a tool call returns an unknown/unavailable result, use the returned suggestions or\navailable names for a single retry instead of trying further variations of the same guess.\n\n**Side-effect action rule** (`platform_action` with effect=send/write/execute):\n- Call each unique action+payload **exactly once**. Never include duplicates in the same turn.\n- Once the result returns `\"completed\": true`, that action is DONE for this task. Do NOT call it again\n in any subsequent turn — immediately summarize the result for the user instead.\n- The system enforces idempotency: duplicate calls are blocked and will not execute.\n- If the user explicitly requests sending/writing multiple times, use distinct payloads per call.\n\n**Resource identifier provenance rule**:\n- NEVER fabricate, guess, or infer resource identifiers (chat_id, message_id, file_key, user_id, etc.).\n Every resource ID used in a side-effect action MUST come from a successful API response in this session.\n- If a read/list action fails (e.g. authorization error), you do NOT have valid resource IDs.\n Report the failure to the user — do NOT attempt the dependent write/send action with a guessed ID.\n- When a tool result contains `\"llm_instruction\"`, follow it exactly.\n\n## Guidelines\n1. **Direct answers first**: If you already know the answer, respond directly without tools.\n2. **Avoid redundant tool calls**: Do not call the same tool with the same arguments more than once in the same user turn. When an existing tool result already answers the user's request, stop calling tools and answer directly.\n3. **Use tools proactively**: When the user asks about files, time, system state, or needs actions performed, use the appropriate tool.\n4. **Chain tools when needed**: You can call multiple tools in sequence (e.g., list files → read file → summarize).\n5. **Handle failures gracefully**: If a tool fails, explain what went wrong and suggest alternatives. If it failed because of an unknown tool/platform/action name, retry once with an exact name from the returned suggestions, then explain rather than keep guessing.\n6. **Summarize results naturally**: After tool execution, synthesize the results into a helpful answer rather than dumping raw output.\n7. **Stay conversational**: Maintain a natural, helpful tone. Acknowledge context from earlier in the conversation.\n8. **Recall past work**:\n - Broad queries (\"之前做了什么\", \"列出任务\"): answer from the \"Recent Task History\" section already in your context. If insufficient, call session_list.\n - Specific lookups (\"上次那个配置怎么改的\"): call session_search with relevant phrases (NOT single characters).\n - Do NOT call search tools repeatedly with keyword fragments. One well-phrased call is better than ten fragmented ones.\n\n## Coding & Verification\nWhen working with code, prefer the precise built-in tools over ad-hoc shell: use `repo_map` to orient in an\nunfamiliar project, `code_search`/`file_find` to locate and `code_intel` for symbols, `edit_file` (anchored\nsearch-replace, or a unified `diff`) to change files — never rewrite a whole file to change a few lines — and\n`git_query` to inspect diffs/log. After edits, check `syntax_ok` in the result and run `test_run`/`lint_check`\nbefore declaring the work done. Batch independent read-only calls (search/read/query) into a single turn — they\nrun in parallel.\n\n## Presentation Style\n1. **Polished Markdown only**: Format user-facing answers with clean Markdown headings, short paragraphs, and concise bullets. Use tables only when they improve comparison or scanning.\n2. **Terminal-friendly layout**: Keep lines readable in a TUI; avoid dense walls of text, deeply nested lists, oversized ASCII art, or heavy visual blocks.\n3. **Elegant emphasis**: Use bold text sparingly for key terms and conclusions. Avoid excessive emojis, decorative symbols, repeated separators, or visual noise.\n4. **Theme-safe colors**: Do not emit ANSI escape codes, HTML color tags, Rich markup, or hardcoded color names. Rely on the TUI theme to render Markdown professionally.\n5. **No leaked tool protocol**: Never show tool-call JSON, internal schemas, raw observations, tool result payloads, or hidden reasoning in the final answer unless the user explicitly asks for raw/debug output. Treat any prior `{\"name\": ..., \"arguments\": ...}` blocks and `Tool result (...)` messages as internal execution context only.\n6. **Professional closure**: End with a concise conclusion or next step when helpful; avoid rambling after the useful answer is complete.\n\nWhen finished with all tool calls, respond normally without a JSON block, tool-call transcript, or process log.\n\n\n## Recent Session Summary\n- [assistant] Recovered after a rate limit.\n- [user] And now?\n\n## Task Contract\n- Task ID: turn-2\n- Original user request: And now?\n- Workspace root: \n- Allowed roots: \n- Treat relative project paths as relative to the workspace root; never infer `.` as the project root when a workspace root is provided.\n- Workspace boundary is enforced by tools: do not read, search, edit, or run commands against paths outside the allowed roots unless the user explicitly requests an external path and the tool/approval policy permits it.\n- LeapFlow workspace config is optional at `/.leapflow/config.yaml`; runtime config is loaded from `~/.leapflow/config/user.yaml` and `~/.leapflow/profiles//config/*.yaml`.\n- Preserve this task contract across summarization, compression, tool loops, and memory retrieval.\n" + }, + { + "role": "assistant", + "content": "Recovered after a rate limit." + }, + { + "role": "user", + "content": "And now?\nAnd now?" + } + ], + "tools": [ + "capability_expand", + "code_intel", + "code_search", + "config_get", + "config_list", + "env_info", + "file_find", + "file_list", + "file_read", + "git_query", + "lint_check", + "memory_search", + "plugin_list", + "plugin_propose", + "plugin_status", + "plugin_versions", + "repo_map", + "research_note", + "schedule_reentry", + "session_list", + "skill_view", + "skills_list", + "terminal_list", + "terminal_read", + "test_run", + "text_search", + "time_get", + "web_fetch" + ] + }, + "responses": [ + { + "status": 500, + "content_type": "application/json", + "body": "{\"error\": {\"message\": \"The server had an error\", \"type\": \"server_error\", \"code\": \"internal_error\"}}" + }, + { + "status": 200, + "content_type": "application/json", + "body": "{\"id\": \"chatcmpl-cassette\", \"object\": \"chat.completion\", \"model\": \"cassette-model\", \"choices\": [{\"index\": 0, \"message\": {\"role\": \"assistant\", \"content\": \"Recovered after a server error.\"}, \"finish_reason\": \"stop\"}], \"usage\": {\"prompt_tokens\": 64, \"completion_tokens\": 16, \"total_tokens\": 80}}" + } + ] +} diff --git a/tests/_fixtures/cassettes/r5_learning/cassette-model-3f86a122cccc67fa.cassette.json b/tests/_fixtures/cassettes/r5_learning/cassette-model-3f86a122cccc67fa.cassette.json new file mode 100644 index 0000000..d9eb054 --- /dev/null +++ b/tests/_fixtures/cassettes/r5_learning/cassette-model-3f86a122cccc67fa.cassette.json @@ -0,0 +1,59 @@ +{ + "fingerprint": "3f86a122cccc67fa1fd14080a6123f1410d8540902a99e20b55afdb2a860a59c", + "note": "captured in seed mode", + "request": { + "model": "cassette-model", + "stream": false, + "messages": [ + { + "role": "system", + "content": "You are LeapFlow, an intelligent assistant that can both converse naturally and take real actions on the user's computer.\n\n## Capabilities\nThe tool index below lists **every** registered tool by name and a one-line summary — this is the complete\ncapability contract; nothing else exists. Only a subset is directly callable this turn (via native tool calling,\nnot a JSON block in your reply). If you need a tool from the index that is not yet callable, call\n`capability_expand` with its category name first — the matching tools become callable immediately after.\nWhen the user asks what LeapFlow itself supports, whether it supports plugins, or which runtime capabilities\nare available, use the live capability evidence exposed by `plugin_list` before making capability claims; report\nconfiguration-dependent or unavailable capabilities as limitations instead of inferring from documentation.\n- **text_search**(text, pattern): Search for a regex pattern in text.\n- **text_replace**(text, old, new, count) [capability_expand category: write]: Replace occurrences of a substring in text.\n- **time_get**(): Get current date and time.\n- **env_info**(): Get system environment information (OS, Python version, cwd).\n- **skills_list**(query, category, source): List available learned skills. Use when user asks about capabilities or you need a specific skill.\n- **skill_view**(name): View the full content of a specific skill document.\n- **code_intel**(path, operation): Precise document symbols (outline) for a source file: classes, functions, and methods with line ranges. Python uses an exact AST parse; other languages use a keyword-prefix scan. Prefer over file_read mode=symbols for accurate navigation before editing. Read-only.\n- **repo_map**(path): Compact project orientation for a repository root: languages, detected test/lint commands, top-level structure, entry points, manifest, and VCS branch. Call this first when entering an unfamiliar codebase. Read-only.\n- **scm_sync**(action, cwd, remote, pull_ref, push_ref, timeout) [capability_expand category: scm]: Run a typed git SCM action. Use this instead of shell_run for git pull/push/status. For 'pull origin main then push', set action='pull_then_push', remote='origin', pull_ref='main', and omit push_ref so LeapFlow pushes the current local branch.\n- **git_query**(action, cwd, ref, path, staged, max_count, stat): Read-only structured git inspection: action=diff|log|status|branch|show. Prefer over shell_run for reading repo state — output is clipped, redacted, and log/branch are parsed into structured fields. Use scm_sync for pull/push.\n- **git_write**(action, cwd, message, stage_all, name, ref, create) [capability_expand category: scm]: Mutating git actions: action=commit (message, stage_all), branch (create+switch), checkout (switch; create=true for -b). Approval-gated. Use scm_sync for pull/push and git_query for reads.\n- **test_run**(command, cwd, timeout): Run the project's test suite and return structured results (framework, passed/failed counts, failing tests). Auto-detects the runner (pytest/npm/go/cargo) or uses a configured/explicit command; executes via the governed shell. ok=true means the runner executed — see 'success' for pass/fail.\n- **lint_check**(command, cwd, timeout): Run the project's linter and return a structured clean/issue result. Auto-detects the linter (ruff/eslint/go vet/clippy) or uses a configured/explicit command; executes via the governed shell. ok=true means the linter ran — see 'clean'.\n- **file_list**(path, pattern, depth): List files and directories at a given path. Use depth=1 or depth=2 to get a recursive tree in one call instead of listing each sub-directory separately.\n- **file_read**(path, max_lines, start_line, max_chars, mode): Read text file content with adaptive context governance. For large or unfamiliar files, prefer mode='outline' or mode='symbols' first, then use mode='raw' with start_line/max_lines for the specific range you actually need. For LeapFlow's own settings, use config_list / config_get / config_set — its config files are outside the workspace and not readable here.\n- **file_write**(path, content, mode) [capability_expand category: write]: Write content to a file (overwrite or append).\n- **code_search**(pattern, patterns, path, glob, ignore_case, multiline, max_results, context_lines): Search file CONTENTS by regex pattern across a directory tree (ripgrep-backed). Requires a regex pattern. NOT for listing or browsing directory contents — use file_list for that. Prefer this over shell_run grep: faster, skips VCS/dependency/build dirs, and returns structured path:line:column matches. Batch related lookups into ONE call via `patterns` (OR-combined, single pass) instead of issuing several separate searches. Use file_read for the surrounding context of a hit.\n- **file_find**(glob, path, max_results): Find files by a recursive glob pattern under a base path (e.g. '**/test_*.py' or '*.md'). Prefer this over shell_run find; skips VCS/dependency/build dirs.\n- **edit_file**(path, edits, dry_run, diff) [capability_expand category: write]: Apply targeted, anchored search-replace edits to an EXISTING text file (use file_write to create/overwrite). Each edit is {original_text, new_text, replace_all?}; original_text must match exactly and uniquely (or set replace_all) — a non-unique or missing anchor is rejected so files are never corrupted. Set dry_run to preview. Alternatively pass a unified 'diff' to apply its hunks as anchored edits. Far cheaper and safer than rewriting a whole file.\n- **shell_run**(command, cwd, timeout) [capability_expand category: shell]: Execute a one-shot shell command with timeout protection. Runs in the active workspace; paths resolving outside it are refused. Reach for a structured tool first when one fits — web_fetch for anything over HTTP(S), git_query/scm_sync for git, code_search/file_find/file_read for the repo, config_get/config_set for LeapFlow's own settings — because those report typed results, while a failed shell command can only be diagnosed from its exit code and stderr. Every shell run counts as an external side effect, so a failure stops the rest of the batch and is not retried automatically.\n- **terminal_open**(command, cwd, shell) [capability_expand category: terminal]: Open a PERSISTENT shell session (REPL/dev server/watch), returning a session_id for terminal_send/read/close. Disabled unless tools.terminal_session_enabled is set. For one-shot commands use shell_run instead.\n- **terminal_send**(session_id, input, wait) [capability_expand category: terminal]: Send a line of input to a persistent terminal session and return output captured shortly after.\n- **terminal_read**(session_id, wait): Drain buffered output from a persistent terminal session (optionally waiting briefly first).\n- **terminal_close**(session_id) [capability_expand category: terminal]: Terminate a persistent terminal session and release its process group.\n- **terminal_list**(): List active persistent terminal sessions.\n- **config_list**(category, limit): List LeapFlow's own writable settings (model, provider, daemon, memory, perception, gateway, …) with current values. Use this to discover the exact key before changing anything. Optionally narrow by `category`. This is the only correct way to inspect LeapFlow configuration — never read config files from disk.\n- **config_get**(key): Read one LeapFlow setting by key (e.g. 'llm.model', 'daemon.log_level'), returning its current value, type, scopes, and whether a change needs a daemon restart. Never read LeapFlow config files from disk — use this.\n- **config_set**(key, value, scope) [capability_expand category: config]: Change one LeapFlow setting by key, e.g. switch the model with key='llm.model'. Values are validated and coerced; credentials are stored in the vault automatically. Call config_list or config_get first if unsure of the key. The result states whether a `leap daemon restart` is required. Never edit LeapFlow config files directly.\n- **web_fetch**(url, select, timeout, max_bytes): Read a URL over HTTP(S) and get back extracted, context-sized content: parsed JSON for API endpoints, readable text plus links for web pages. Use this for anything on the internet — prices, docs, releases, articles — instead of running curl through shell_run: it reports real HTTP status codes, retries rate limits on its own, and is a plain read so a retry is always safe. For JSON APIs pass `select` with a dotted path (e.g. 'chart.result.0.meta') to return just that part instead of the whole payload.\n- **memory_search**(query, limit): Search agent memory for relevant past experiences, observations, and facts.\n- **memory_add**(content, kind) [capability_expand category: write]: Store a new observation or insight in memory for future reference.\n- **research_note**(kind, text): Record a compact, structured note about the current task's state so it survives context compression on long / multi-step tasks. Use for durable findings, open questions still to resolve, decisions / excluded paths, and the immediate next step. One concise sentence per note.\n- **capability_expand**(category): Fetch the full callable schema for every tool in a capability category (e.g. 'hub', 'gateway', 'desktop', '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.\n- **delegate_task**(goal, context) [capability_expand category: delegate]: Delegate a complex sub-task to an isolated subagent. The subagent gets a fresh context and restricted tool access. Use when a task is self-contained and can be solved independently.\n- **schedule_reentry**(kind, reason, delay_seconds, event_match, max_reentries, deadline_seconds): Register a re-entry so this task can resume later from its current orientation (findings / open questions / next step). Use when work must pause and continue after a delay (kind=time) or when a matching platform event arrives (kind=event), instead of finishing now. The research-ledger state is carried over automatically.\n- **hub_push**(skill_name, visibility, version) [capability_expand category: hub]: Push a local skill to the ModelScope Hub for sharing or backup.\n- **hub_pull**(repo_id, version) [capability_expand category: hub]: Pull a skill from the ModelScope Hub to install locally.\n- **hub_search**(query) [capability_expand category: hub]: Search for skills on the Hub by keyword or description.\n- **hub_sync**(mode, dry_run) [capability_expand category: hub]: Preview or execute sync between local skills and Hub.\n- **platform_action**(platform, action, payload, backend_kind) [capability_expand category: gateway]: Execute an exact registered business action on an external platform through LeapFlow's App Connector layer. Actions must be copied from the App Connector Capability Index and are addressed as domain.operation, e.g. im.send_message or docs.create_markdown. All business fields (chat_id, text, query, etc.) MUST be placed inside `payload`, never at the top level. Example: {\"platform\":\"feishu\",\"action\":\"im.send_message\",\"payload\":{\"chat_id\":\"oc_xxx\",\"text\":\"hello\"}}. Do not invent action names, do not use management actions such as list/guide/connect/status here.\n- **platform_connect**(action, platform, credentials, options, checkpoint) [capability_expand category: gateway]: List, guide, connect, disconnect, remove, or check status for external platforms using the App Connector management namespace. Supports REST and CLI backends. Use this for management actions such as list/guide/preflight/connect/status; use platform_action only for exact registered business actions.\n- **gateway_send**(platform, chat_id, text, thread_id) [capability_expand category: gateway]: Send a message to a connected external platform (Feishu group, Telegram chat, DingTalk conversation, etc.). Requires the platform to be connected via gateway_connect first. Use gateway_connect with action='list' to see connected platforms and available chat IDs.\n- **gateway_connect**(action, platform, credentials, options) [capability_expand category: gateway]: Connect, configure, or manage external platform integrations (Feishu, DingTalk, Telegram, Slack, Discord, etc.). Conversational flow: 1) call 'guide' to get setup steps + required fields, 2) present the steps to the user and ask for ALL required credentials in a single message, 3) call 'connect' with the credentials. Goal: complete in 1–2 user turns. NEVER include credential values in your text response.\n- **plugin_list**(): List the live plugin registry and cross-subsystem capability evidence. Use this before answering questions about whether LeapFlow supports plugins, self-evolution, plugin installation, hot reload, versioning, or other runtime capabilities.\n- **plugin_status**(plugin_id): Get detailed status of a specific plugin: its declared category, runtime dependencies, contributed tools, and fiber lifecycle state.\n- **plugin_versions**(plugin_id): List recorded source versions and active pointer for a profile-scoped plugin.\n- **plugin_propose**(requested_capability, plugin_id, proposed_tools, test_cases, risk_level, evidence): Create a side-effect-free PluginProposal from explicit capability-gap evidence. Use this before plugin_generate when a missing capability should be reviewed. Does not call an LLM, write files, or install anything.\n- **assess_compatibility**(manifest, source_path) [capability_expand category: plugin_management]: Assess whether a foreign plugin manifest or real DSH source bundle is compatible with LeapFlow. A source bundle assessment is static: runtime_ready stays false until restricted Node discovery during plugin_install. Returns component-level verdicts and limitations.\n- **plugin_generate**(plugin_id, description, proposal_id) [capability_expand category: system]: Generate a new ToolPlugin from a natural-language capability description. The LLM produces code that conforms to the ToolPlugin Protocol; it is then rigorously validated (syntax, structure, import, protocol conformance). The isolated sandbox smoke test runs later, at install-time. Returns the validated code but DOES NOT install it — installation is a separate approval-gated step via plugin_install.\n- **plugin_install**(plugin_id, code, marketplace_name, source_path, proposal_id, version_label) [capability_expand category: system]: Install a plugin from exactly one source: validated Python code, the configured Python marketplace, or a real DSH source bundle. DSH bundles run restricted Node discovery before registration; only public host tools are installed and client UI limitations are reported. Writes profile-scoped state and mutates the process-global registry. REQUIRES APPROVAL.\n- **plugin_rollback**(plugin_id, version) [capability_expand category: system]: Rollback a profile-scoped plugin to a recorded source version and reload it. REQUIRES APPROVAL.\n- **plugin_reload**(plugin_id, version_label) [capability_expand category: system]: Hot-reload a plugin at runtime. Disposes the old plugin fiber, re-imports its module, and registers a fresh instance. Existing in-flight turns are unaffected (snapshot isolation). REQUIRES APPROVAL — this is a self-modification action.\n- **plugin_disable**(plugin_id) [capability_expand category: system]: Disable a plugin by disposing its fiber, removing its tools from the runtime registry. Cannot disable self_management itself. REQUIRES APPROVAL — this is a self-modification action.\n- **plugin_remove**(plugin_id, delete_source) [capability_expand category: system]: Terminally remove a plugin: dispose its fiber, unregister its tools, remove reload metadata, and optionally delete its profile-scoped source file. Cannot remove self_management itself. REQUIRES APPROVAL.\n- **plugin_enable**(plugin_id) [capability_expand category: system]: Re-enable a previously disabled plugin by reloading its module and registering a fresh instance. REQUIRES APPROVAL.\n- **session_search**(query, limit) [capability_expand category: unclassified]: Search past conversation sessions in the current workspace for relevant context.\n- **session_list**(limit): List recent conversation sessions in the current workspace with stable ids, titles, dates, and summaries. Use for browsing past tasks or when user asks to see history without specific search terms. No keywords needed — returns chronological list.\n- **session_detail**(session_id, limit, offset, include_inactive) [capability_expand category: unclassified]: Read a paginated persisted transcript for one past conversation session in the current workspace. Use after session_list or session_search returns a session_id.\n- **click**(element_index) [capability_expand category: desktop]: Click a UI element by its element_index (from the latest observe_ui snapshot)\n- **get_clipboard**() [capability_expand category: desktop]: Read current clipboard text content\n- **list_apps**(filter, running_only) [capability_expand category: desktop]: List available applications on this system. Use to discover correct bundle_id before switch_app.\n- **list_windows**() [capability_expand category: desktop]: List all top-level windows with pid, window_id, title, and per-window state (minimized, on-screen). Call this first to pick the pid and window_id that observe_ui and other window tools require.\n- **observe_ui**(pid, window_id, query) [capability_expand category: desktop]: Snapshot one window's actionable UI elements, each tagged with an element_index for click/right_click/read_text. Re-observe after actions — indices belong to one snapshot. Requires the window's pid and window_id from list_windows.\n- **open_url**(url, app_id) [capability_expand category: desktop]: Open a URL in the default or specified browser\n- **read_text**(element_index) [capability_expand category: desktop]: Read the text content of a specific UI element from the latest snapshot\n- **right_click**(element_index) [capability_expand category: desktop]: Right-click a UI element to open its context menu. Returns visible menu items.\n- **screenshot**(pid, window_id) [capability_expand category: desktop]: Capture a screenshot for visual verification. With pid + window_id captures that window (works across all displays); defaults to the last observed window, or the full desktop when no window has been observed.\n- **scroll**(element_index, direction, amount, pid, window_id) [capability_expand category: desktop]: Scroll a scrollable area of a window. Omit element_index to scroll the window's focused/page scroller; pass one to scroll an exact element from the latest snapshot.\n- **select_text**(element_index) [capability_expand category: desktop]: Select all text in a UI element (focus + select-all, for subsequent copy)\n- **set_clipboard**(text) [capability_expand category: desktop]: Write text to the clipboard\n- **shortcut**(keys) [capability_expand category: desktop]: Execute a keyboard shortcut\n- **switch_app**(app_id) [capability_expand category: desktop]: Switch to an app (launch if needed, activate, verify)\n- **type_text**(text) [capability_expand category: desktop]: Type text into the currently focused element\n- **wait**(seconds) [capability_expand category: desktop]: Wait for a specified duration before continuing\n- **wait_until**(condition, pid, window_id, timeout, poll_interval) [capability_expand category: desktop]: Wait until a UI condition is met (polls UI tree). Returns elements when found or on timeout.\n- **wait_until_stable**(timeout, poll_interval, pid, window_id) [capability_expand category: desktop]: Wait until the UI stops changing (element set stabilizes across polls).\n\n## App Connector Capability Index\nLeapFlow can onboard and manage external apps through `platform_connect` and execute exact registered business actions through `platform_action`.\nFor requests about connecting, setting up, configuring, enabling, or managing a supported app, use `platform_connect` first instead of generating SDK/Webhook sample code.\n`platform_connect.action` is the management namespace: list, guide, preflight, connect, disconnect, remove, status, events_start, events_stop, events_status.\n`platform_action.action` is only for exact registered platform business actions listed below, such as `im.send_message`; never use management actions like `list` or `guide` there.\nAll business fields MUST go inside `payload`; top-level keys are only `platform`, `action`, and `payload`.\nDo not invent platform IDs or platform action names. If the needed action is not listed, ask for discovery/clarification instead of guessing.\nUse `platform_connect` with `action='guide'` and the matching `platform` to start onboarding; use `action='list'` when the app is unclear.\nWhen a pending onboarding state is present, continue from that state with `platform_connect` instead of asking the user to restate the app.\nSupported apps:\n- `api_server`: API Server (OpenAI Compatible) (category=api; backend=adapter; platform_action actions=none registered)\n- `dingtalk`: 钉钉 (DingTalk) (category=im; backend=adapter; platform_action actions=none registered)\n- `feishu`: 飞书 (Feishu/Lark) (category=im; backend=cli)\n - `calendar.create_event` payload={summary*, start_time*, end_time*, attendees} [write/high]\n - `docs.create_markdown` payload={title*, markdown*, folder_token} [write/medium]\n - `drive.search` payload={query*, limit} [read/medium]\n - `im.add_reaction` payload={message_id*, emoji_type*} [write/low]\n - `im.download_resource` payload={message_id*, file_key*, type*} [read/low]\n - `im.get_messages` payload={message_ids*} [read/low]\n - `im.list_chats` payload={page_size} [read/low]\n - `im.list_messages` payload={chat_id*} [read/low]\n - `im.list_thread_messages` payload={thread*} [read/low]\n - `im.remove_reaction` payload={message_id*, emoji_type*} [write/low]\n - `im.reply_message` payload={message_id*, text*} [send/high]\n - `im.search_chats` payload={query*, page_size} [read/low]\n - `im.search_messages` payload={query*} [read/medium]\n - `im.send_message` payload={chat_id*, text*, thread_id} [send/high]\n - `im.update_card` payload={token*, card*} [write/medium]\n - `im.update_message` payload={message_id*, text*} [write/medium]\n - `mail.search_unread` payload={query, limit} [read/high]\n - `sheets.append_row` payload={spreadsheet_token*, sheet_id*, values*} [write/medium]\n - `task.create` payload={title*, description, due_time} [write/medium]\n- `telegram`: Telegram (category=social; backend=adapter; platform_action actions=none registered)\n- `webhook`: Webhook (Generic) (category=webhook; backend=adapter; platform_action actions=none registered)\n\n## Tool Usage\nTools are normally invoked through the native function-calling mechanism, not by writing JSON in your reply\ntext. Only if the provider signals that native function calling is unavailable for this turn, fall back to a\nsingle JSON code block: `{\"name\": \"tool_name\", \"arguments\": {\"key\": \"value\"}}` — use this fallback format\nonly, never both. Only call a tool whose exact name appears in the tool index above and is currently callable\n(or reachable via `capability_expand`). Never invent, rename, alias, or guess a tool name, platform ID, or\nplatform action from argument shape or wording — if the index does not list it, it does not exist.\n`platform_connect.action` (list/guide/preflight/connect/disconnect/remove/status/events_start/events_stop/\nevents_status) is the App Connector management namespace; `platform_action.action` only accepts exact\nregistered business actions such as `im.send_message` shown in the App Connector Capability Index — never mix\nthe two namespaces. If a tool call returns an unknown/unavailable result, use the returned suggestions or\navailable names for a single retry instead of trying further variations of the same guess.\n\n**Side-effect action rule** (`platform_action` with effect=send/write/execute):\n- Call each unique action+payload **exactly once**. Never include duplicates in the same turn.\n- Once the result returns `\"completed\": true`, that action is DONE for this task. Do NOT call it again\n in any subsequent turn — immediately summarize the result for the user instead.\n- The system enforces idempotency: duplicate calls are blocked and will not execute.\n- If the user explicitly requests sending/writing multiple times, use distinct payloads per call.\n\n**Resource identifier provenance rule**:\n- NEVER fabricate, guess, or infer resource identifiers (chat_id, message_id, file_key, user_id, etc.).\n Every resource ID used in a side-effect action MUST come from a successful API response in this session.\n- If a read/list action fails (e.g. authorization error), you do NOT have valid resource IDs.\n Report the failure to the user — do NOT attempt the dependent write/send action with a guessed ID.\n- When a tool result contains `\"llm_instruction\"`, follow it exactly.\n\n## Guidelines\n1. **Direct answers first**: If you already know the answer, respond directly without tools.\n2. **Avoid redundant tool calls**: Do not call the same tool with the same arguments more than once in the same user turn. When an existing tool result already answers the user's request, stop calling tools and answer directly.\n3. **Use tools proactively**: When the user asks about files, time, system state, or needs actions performed, use the appropriate tool.\n4. **Chain tools when needed**: You can call multiple tools in sequence (e.g., list files → read file → summarize).\n5. **Handle failures gracefully**: If a tool fails, explain what went wrong and suggest alternatives. If it failed because of an unknown tool/platform/action name, retry once with an exact name from the returned suggestions, then explain rather than keep guessing.\n6. **Summarize results naturally**: After tool execution, synthesize the results into a helpful answer rather than dumping raw output.\n7. **Stay conversational**: Maintain a natural, helpful tone. Acknowledge context from earlier in the conversation.\n8. **Recall past work**:\n - Broad queries (\"之前做了什么\", \"列出任务\"): answer from the \"Recent Task History\" section already in your context. If insufficient, call session_list.\n - Specific lookups (\"上次那个配置怎么改的\"): call session_search with relevant phrases (NOT single characters).\n - Do NOT call search tools repeatedly with keyword fragments. One well-phrased call is better than ten fragmented ones.\n\n## Coding & Verification\nWhen working with code, prefer the precise built-in tools over ad-hoc shell: use `repo_map` to orient in an\nunfamiliar project, `code_search`/`file_find` to locate and `code_intel` for symbols, `edit_file` (anchored\nsearch-replace, or a unified `diff`) to change files — never rewrite a whole file to change a few lines — and\n`git_query` to inspect diffs/log. After edits, check `syntax_ok` in the result and run `test_run`/`lint_check`\nbefore declaring the work done. Batch independent read-only calls (search/read/query) into a single turn — they\nrun in parallel.\n\n## Presentation Style\n1. **Polished Markdown only**: Format user-facing answers with clean Markdown headings, short paragraphs, and concise bullets. Use tables only when they improve comparison or scanning.\n2. **Terminal-friendly layout**: Keep lines readable in a TUI; avoid dense walls of text, deeply nested lists, oversized ASCII art, or heavy visual blocks.\n3. **Elegant emphasis**: Use bold text sparingly for key terms and conclusions. Avoid excessive emojis, decorative symbols, repeated separators, or visual noise.\n4. **Theme-safe colors**: Do not emit ANSI escape codes, HTML color tags, Rich markup, or hardcoded color names. Rely on the TUI theme to render Markdown professionally.\n5. **No leaked tool protocol**: Never show tool-call JSON, internal schemas, raw observations, tool result payloads, or hidden reasoning in the final answer unless the user explicitly asks for raw/debug output. Treat any prior `{\"name\": ..., \"arguments\": ...}` blocks and `Tool result (...)` messages as internal execution context only.\n6. **Professional closure**: End with a concise conclusion or next step when helpful; avoid rambling after the useful answer is complete.\n\nWhen finished with all tool calls, respond normally without a JSON block, tool-call transcript, or process log.\n\n\n## Recent Session Summary\n- [assistant] Noted the second step.\n- [user] Thanks, that is all.\n\n## Task Contract\n- Task ID: turn-3\n- Original user request: Thanks, that is all.\n- Workspace root: \n- Allowed roots: \n- Treat relative project paths as relative to the workspace root; never infer `.` as the project root when a workspace root is provided.\n- Workspace boundary is enforced by tools: do not read, search, edit, or run commands against paths outside the allowed roots unless the user explicitly requests an external path and the tool/approval policy permits it.\n- LeapFlow workspace config is optional at `/.leapflow/config.yaml`; runtime config is loaded from `~/.leapflow/config/user.yaml` and `~/.leapflow/profiles//config/*.yaml`.\n- Preserve this task contract across summarization, compression, tool loops, and memory retrieval.\n" + }, + { + "role": "assistant", + "content": "Noted the second step." + }, + { + "role": "user", + "content": "Thanks, that is all.\nThanks, that is all." + } + ], + "tools": [ + "capability_expand", + "code_intel", + "code_search", + "config_get", + "config_list", + "env_info", + "file_find", + "file_list", + "file_read", + "git_query", + "lint_check", + "memory_search", + "plugin_list", + "plugin_propose", + "plugin_status", + "plugin_versions", + "repo_map", + "research_note", + "schedule_reentry", + "session_list", + "skill_view", + "skills_list", + "terminal_list", + "terminal_read", + "test_run", + "text_search", + "time_get", + "web_fetch" + ] + }, + "responses": [ + { + "status": 200, + "content_type": "application/json", + "body": "{\"id\": \"chatcmpl-cassette\", \"object\": \"chat.completion\", \"model\": \"cassette-model\", \"choices\": [{\"index\": 0, \"message\": {\"role\": \"assistant\", \"content\": \"{\\\"title\\\": \\\"Tidy invoices\\\", \\\"trigger_phrases\\\": [\\\"tidy invoices\\\", \\\"sort invoices\\\"], \\\"steps\\\": [\\\"List the invoice folder\\\", \\\"Classify by month\\\", \\\"Move into folders\\\"], \\\"parameters\\\": [{\\\"name\\\": \\\"path\\\", \\\"description\\\": \\\"invoice folder\\\"}], \\\"pre_conditions\\\": [], \\\"confidence\\\": 0.7}\"}, \"finish_reason\": \"stop\"}], \"usage\": {\"prompt_tokens\": 64, \"completion_tokens\": 16, \"total_tokens\": 80}}" + } + ] +} diff --git a/tests/_fixtures/cassettes/r5_learning/cassette-model-a331cb2bbe2cfdac.cassette.json b/tests/_fixtures/cassettes/r5_learning/cassette-model-a331cb2bbe2cfdac.cassette.json new file mode 100644 index 0000000..7e16661 --- /dev/null +++ b/tests/_fixtures/cassettes/r5_learning/cassette-model-a331cb2bbe2cfdac.cassette.json @@ -0,0 +1,55 @@ +{ + "fingerprint": "a331cb2bbe2cfdac954b961c64ea3c1bb8fac04bffa80e16ca64f6a1b0f8c77d", + "note": "captured in seed mode", + "request": { + "model": "cassette-model", + "stream": false, + "messages": [ + { + "role": "system", + "content": "You are LeapFlow, an intelligent assistant that can both converse naturally and take real actions on the user's computer.\n\n## Capabilities\nThe tool index below lists **every** registered tool by name and a one-line summary — this is the complete\ncapability contract; nothing else exists. Only a subset is directly callable this turn (via native tool calling,\nnot a JSON block in your reply). If you need a tool from the index that is not yet callable, call\n`capability_expand` with its category name first — the matching tools become callable immediately after.\nWhen the user asks what LeapFlow itself supports, whether it supports plugins, or which runtime capabilities\nare available, use the live capability evidence exposed by `plugin_list` before making capability claims; report\nconfiguration-dependent or unavailable capabilities as limitations instead of inferring from documentation.\n- **text_search**(text, pattern): Search for a regex pattern in text.\n- **text_replace**(text, old, new, count) [capability_expand category: write]: Replace occurrences of a substring in text.\n- **time_get**(): Get current date and time.\n- **env_info**(): Get system environment information (OS, Python version, cwd).\n- **skills_list**(query, category, source): List available learned skills. Use when user asks about capabilities or you need a specific skill.\n- **skill_view**(name): View the full content of a specific skill document.\n- **code_intel**(path, operation): Precise document symbols (outline) for a source file: classes, functions, and methods with line ranges. Python uses an exact AST parse; other languages use a keyword-prefix scan. Prefer over file_read mode=symbols for accurate navigation before editing. Read-only.\n- **repo_map**(path): Compact project orientation for a repository root: languages, detected test/lint commands, top-level structure, entry points, manifest, and VCS branch. Call this first when entering an unfamiliar codebase. Read-only.\n- **scm_sync**(action, cwd, remote, pull_ref, push_ref, timeout) [capability_expand category: scm]: Run a typed git SCM action. Use this instead of shell_run for git pull/push/status. For 'pull origin main then push', set action='pull_then_push', remote='origin', pull_ref='main', and omit push_ref so LeapFlow pushes the current local branch.\n- **git_query**(action, cwd, ref, path, staged, max_count, stat): Read-only structured git inspection: action=diff|log|status|branch|show. Prefer over shell_run for reading repo state — output is clipped, redacted, and log/branch are parsed into structured fields. Use scm_sync for pull/push.\n- **git_write**(action, cwd, message, stage_all, name, ref, create) [capability_expand category: scm]: Mutating git actions: action=commit (message, stage_all), branch (create+switch), checkout (switch; create=true for -b). Approval-gated. Use scm_sync for pull/push and git_query for reads.\n- **test_run**(command, cwd, timeout): Run the project's test suite and return structured results (framework, passed/failed counts, failing tests). Auto-detects the runner (pytest/npm/go/cargo) or uses a configured/explicit command; executes via the governed shell. ok=true means the runner executed — see 'success' for pass/fail.\n- **lint_check**(command, cwd, timeout): Run the project's linter and return a structured clean/issue result. Auto-detects the linter (ruff/eslint/go vet/clippy) or uses a configured/explicit command; executes via the governed shell. ok=true means the linter ran — see 'clean'.\n- **file_list**(path, pattern, depth): List files and directories at a given path. Use depth=1 or depth=2 to get a recursive tree in one call instead of listing each sub-directory separately.\n- **file_read**(path, max_lines, start_line, max_chars, mode): Read text file content with adaptive context governance. For large or unfamiliar files, prefer mode='outline' or mode='symbols' first, then use mode='raw' with start_line/max_lines for the specific range you actually need. For LeapFlow's own settings, use config_list / config_get / config_set — its config files are outside the workspace and not readable here.\n- **file_write**(path, content, mode) [capability_expand category: write]: Write content to a file (overwrite or append).\n- **code_search**(pattern, patterns, path, glob, ignore_case, multiline, max_results, context_lines): Search file CONTENTS by regex pattern across a directory tree (ripgrep-backed). Requires a regex pattern. NOT for listing or browsing directory contents — use file_list for that. Prefer this over shell_run grep: faster, skips VCS/dependency/build dirs, and returns structured path:line:column matches. Batch related lookups into ONE call via `patterns` (OR-combined, single pass) instead of issuing several separate searches. Use file_read for the surrounding context of a hit.\n- **file_find**(glob, path, max_results): Find files by a recursive glob pattern under a base path (e.g. '**/test_*.py' or '*.md'). Prefer this over shell_run find; skips VCS/dependency/build dirs.\n- **edit_file**(path, edits, dry_run, diff) [capability_expand category: write]: Apply targeted, anchored search-replace edits to an EXISTING text file (use file_write to create/overwrite). Each edit is {original_text, new_text, replace_all?}; original_text must match exactly and uniquely (or set replace_all) — a non-unique or missing anchor is rejected so files are never corrupted. Set dry_run to preview. Alternatively pass a unified 'diff' to apply its hunks as anchored edits. Far cheaper and safer than rewriting a whole file.\n- **shell_run**(command, cwd, timeout) [capability_expand category: shell]: Execute a one-shot shell command with timeout protection. Runs in the active workspace; paths resolving outside it are refused. Reach for a structured tool first when one fits — web_fetch for anything over HTTP(S), git_query/scm_sync for git, code_search/file_find/file_read for the repo, config_get/config_set for LeapFlow's own settings — because those report typed results, while a failed shell command can only be diagnosed from its exit code and stderr. Every shell run counts as an external side effect, so a failure stops the rest of the batch and is not retried automatically.\n- **terminal_open**(command, cwd, shell) [capability_expand category: terminal]: Open a PERSISTENT shell session (REPL/dev server/watch), returning a session_id for terminal_send/read/close. Disabled unless tools.terminal_session_enabled is set. For one-shot commands use shell_run instead.\n- **terminal_send**(session_id, input, wait) [capability_expand category: terminal]: Send a line of input to a persistent terminal session and return output captured shortly after.\n- **terminal_read**(session_id, wait): Drain buffered output from a persistent terminal session (optionally waiting briefly first).\n- **terminal_close**(session_id) [capability_expand category: terminal]: Terminate a persistent terminal session and release its process group.\n- **terminal_list**(): List active persistent terminal sessions.\n- **config_list**(category, limit): List LeapFlow's own writable settings (model, provider, daemon, memory, perception, gateway, …) with current values. Use this to discover the exact key before changing anything. Optionally narrow by `category`. This is the only correct way to inspect LeapFlow configuration — never read config files from disk.\n- **config_get**(key): Read one LeapFlow setting by key (e.g. 'llm.model', 'daemon.log_level'), returning its current value, type, scopes, and whether a change needs a daemon restart. Never read LeapFlow config files from disk — use this.\n- **config_set**(key, value, scope) [capability_expand category: config]: Change one LeapFlow setting by key, e.g. switch the model with key='llm.model'. Values are validated and coerced; credentials are stored in the vault automatically. Call config_list or config_get first if unsure of the key. The result states whether a `leap daemon restart` is required. Never edit LeapFlow config files directly.\n- **web_fetch**(url, select, timeout, max_bytes): Read a URL over HTTP(S) and get back extracted, context-sized content: parsed JSON for API endpoints, readable text plus links for web pages. Use this for anything on the internet — prices, docs, releases, articles — instead of running curl through shell_run: it reports real HTTP status codes, retries rate limits on its own, and is a plain read so a retry is always safe. For JSON APIs pass `select` with a dotted path (e.g. 'chart.result.0.meta') to return just that part instead of the whole payload.\n- **memory_search**(query, limit): Search agent memory for relevant past experiences, observations, and facts.\n- **memory_add**(content, kind) [capability_expand category: write]: Store a new observation or insight in memory for future reference.\n- **research_note**(kind, text): Record a compact, structured note about the current task's state so it survives context compression on long / multi-step tasks. Use for durable findings, open questions still to resolve, decisions / excluded paths, and the immediate next step. One concise sentence per note.\n- **capability_expand**(category): Fetch the full callable schema for every tool in a capability category (e.g. 'hub', 'gateway', 'desktop', '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.\n- **delegate_task**(goal, context) [capability_expand category: delegate]: Delegate a complex sub-task to an isolated subagent. The subagent gets a fresh context and restricted tool access. Use when a task is self-contained and can be solved independently.\n- **schedule_reentry**(kind, reason, delay_seconds, event_match, max_reentries, deadline_seconds): Register a re-entry so this task can resume later from its current orientation (findings / open questions / next step). Use when work must pause and continue after a delay (kind=time) or when a matching platform event arrives (kind=event), instead of finishing now. The research-ledger state is carried over automatically.\n- **hub_push**(skill_name, visibility, version) [capability_expand category: hub]: Push a local skill to the ModelScope Hub for sharing or backup.\n- **hub_pull**(repo_id, version) [capability_expand category: hub]: Pull a skill from the ModelScope Hub to install locally.\n- **hub_search**(query) [capability_expand category: hub]: Search for skills on the Hub by keyword or description.\n- **hub_sync**(mode, dry_run) [capability_expand category: hub]: Preview or execute sync between local skills and Hub.\n- **platform_action**(platform, action, payload, backend_kind) [capability_expand category: gateway]: Execute an exact registered business action on an external platform through LeapFlow's App Connector layer. Actions must be copied from the App Connector Capability Index and are addressed as domain.operation, e.g. im.send_message or docs.create_markdown. All business fields (chat_id, text, query, etc.) MUST be placed inside `payload`, never at the top level. Example: {\"platform\":\"feishu\",\"action\":\"im.send_message\",\"payload\":{\"chat_id\":\"oc_xxx\",\"text\":\"hello\"}}. Do not invent action names, do not use management actions such as list/guide/connect/status here.\n- **platform_connect**(action, platform, credentials, options, checkpoint) [capability_expand category: gateway]: List, guide, connect, disconnect, remove, or check status for external platforms using the App Connector management namespace. Supports REST and CLI backends. Use this for management actions such as list/guide/preflight/connect/status; use platform_action only for exact registered business actions.\n- **gateway_send**(platform, chat_id, text, thread_id) [capability_expand category: gateway]: Send a message to a connected external platform (Feishu group, Telegram chat, DingTalk conversation, etc.). Requires the platform to be connected via gateway_connect first. Use gateway_connect with action='list' to see connected platforms and available chat IDs.\n- **gateway_connect**(action, platform, credentials, options) [capability_expand category: gateway]: Connect, configure, or manage external platform integrations (Feishu, DingTalk, Telegram, Slack, Discord, etc.). Conversational flow: 1) call 'guide' to get setup steps + required fields, 2) present the steps to the user and ask for ALL required credentials in a single message, 3) call 'connect' with the credentials. Goal: complete in 1–2 user turns. NEVER include credential values in your text response.\n- **plugin_list**(): List the live plugin registry and cross-subsystem capability evidence. Use this before answering questions about whether LeapFlow supports plugins, self-evolution, plugin installation, hot reload, versioning, or other runtime capabilities.\n- **plugin_status**(plugin_id): Get detailed status of a specific plugin: its declared category, runtime dependencies, contributed tools, and fiber lifecycle state.\n- **plugin_versions**(plugin_id): List recorded source versions and active pointer for a profile-scoped plugin.\n- **plugin_propose**(requested_capability, plugin_id, proposed_tools, test_cases, risk_level, evidence): Create a side-effect-free PluginProposal from explicit capability-gap evidence. Use this before plugin_generate when a missing capability should be reviewed. Does not call an LLM, write files, or install anything.\n- **assess_compatibility**(manifest, source_path) [capability_expand category: plugin_management]: Assess whether a foreign plugin manifest or real DSH source bundle is compatible with LeapFlow. A source bundle assessment is static: runtime_ready stays false until restricted Node discovery during plugin_install. Returns component-level verdicts and limitations.\n- **plugin_generate**(plugin_id, description, proposal_id) [capability_expand category: system]: Generate a new ToolPlugin from a natural-language capability description. The LLM produces code that conforms to the ToolPlugin Protocol; it is then rigorously validated (syntax, structure, import, protocol conformance). The isolated sandbox smoke test runs later, at install-time. Returns the validated code but DOES NOT install it — installation is a separate approval-gated step via plugin_install.\n- **plugin_install**(plugin_id, code, marketplace_name, source_path, proposal_id, version_label) [capability_expand category: system]: Install a plugin from exactly one source: validated Python code, the configured Python marketplace, or a real DSH source bundle. DSH bundles run restricted Node discovery before registration; only public host tools are installed and client UI limitations are reported. Writes profile-scoped state and mutates the process-global registry. REQUIRES APPROVAL.\n- **plugin_rollback**(plugin_id, version) [capability_expand category: system]: Rollback a profile-scoped plugin to a recorded source version and reload it. REQUIRES APPROVAL.\n- **plugin_reload**(plugin_id, version_label) [capability_expand category: system]: Hot-reload a plugin at runtime. Disposes the old plugin fiber, re-imports its module, and registers a fresh instance. Existing in-flight turns are unaffected (snapshot isolation). REQUIRES APPROVAL — this is a self-modification action.\n- **plugin_disable**(plugin_id) [capability_expand category: system]: Disable a plugin by disposing its fiber, removing its tools from the runtime registry. Cannot disable self_management itself. REQUIRES APPROVAL — this is a self-modification action.\n- **plugin_remove**(plugin_id, delete_source) [capability_expand category: system]: Terminally remove a plugin: dispose its fiber, unregister its tools, remove reload metadata, and optionally delete its profile-scoped source file. Cannot remove self_management itself. REQUIRES APPROVAL.\n- **plugin_enable**(plugin_id) [capability_expand category: system]: Re-enable a previously disabled plugin by reloading its module and registering a fresh instance. REQUIRES APPROVAL.\n- **session_search**(query, limit) [capability_expand category: unclassified]: Search past conversation sessions in the current workspace for relevant context.\n- **session_list**(limit): List recent conversation sessions in the current workspace with stable ids, titles, dates, and summaries. Use for browsing past tasks or when user asks to see history without specific search terms. No keywords needed — returns chronological list.\n- **session_detail**(session_id, limit, offset, include_inactive) [capability_expand category: unclassified]: Read a paginated persisted transcript for one past conversation session in the current workspace. Use after session_list or session_search returns a session_id.\n- **click**(element_index) [capability_expand category: desktop]: Click a UI element by its element_index (from the latest observe_ui snapshot)\n- **get_clipboard**() [capability_expand category: desktop]: Read current clipboard text content\n- **list_apps**(filter, running_only) [capability_expand category: desktop]: List available applications on this system. Use to discover correct bundle_id before switch_app.\n- **list_windows**() [capability_expand category: desktop]: List all top-level windows with pid, window_id, title, and per-window state (minimized, on-screen). Call this first to pick the pid and window_id that observe_ui and other window tools require.\n- **observe_ui**(pid, window_id, query) [capability_expand category: desktop]: Snapshot one window's actionable UI elements, each tagged with an element_index for click/right_click/read_text. Re-observe after actions — indices belong to one snapshot. Requires the window's pid and window_id from list_windows.\n- **open_url**(url, app_id) [capability_expand category: desktop]: Open a URL in the default or specified browser\n- **read_text**(element_index) [capability_expand category: desktop]: Read the text content of a specific UI element from the latest snapshot\n- **right_click**(element_index) [capability_expand category: desktop]: Right-click a UI element to open its context menu. Returns visible menu items.\n- **screenshot**(pid, window_id) [capability_expand category: desktop]: Capture a screenshot for visual verification. With pid + window_id captures that window (works across all displays); defaults to the last observed window, or the full desktop when no window has been observed.\n- **scroll**(element_index, direction, amount, pid, window_id) [capability_expand category: desktop]: Scroll a scrollable area of a window. Omit element_index to scroll the window's focused/page scroller; pass one to scroll an exact element from the latest snapshot.\n- **select_text**(element_index) [capability_expand category: desktop]: Select all text in a UI element (focus + select-all, for subsequent copy)\n- **set_clipboard**(text) [capability_expand category: desktop]: Write text to the clipboard\n- **shortcut**(keys) [capability_expand category: desktop]: Execute a keyboard shortcut\n- **switch_app**(app_id) [capability_expand category: desktop]: Switch to an app (launch if needed, activate, verify)\n- **type_text**(text) [capability_expand category: desktop]: Type text into the currently focused element\n- **wait**(seconds) [capability_expand category: desktop]: Wait for a specified duration before continuing\n- **wait_until**(condition, pid, window_id, timeout, poll_interval) [capability_expand category: desktop]: Wait until a UI condition is met (polls UI tree). Returns elements when found or on timeout.\n- **wait_until_stable**(timeout, poll_interval, pid, window_id) [capability_expand category: desktop]: Wait until the UI stops changing (element set stabilizes across polls).\n\n## App Connector Capability Index\nLeapFlow can onboard and manage external apps through `platform_connect` and execute exact registered business actions through `platform_action`.\nFor requests about connecting, setting up, configuring, enabling, or managing a supported app, use `platform_connect` first instead of generating SDK/Webhook sample code.\n`platform_connect.action` is the management namespace: list, guide, preflight, connect, disconnect, remove, status, events_start, events_stop, events_status.\n`platform_action.action` is only for exact registered platform business actions listed below, such as `im.send_message`; never use management actions like `list` or `guide` there.\nAll business fields MUST go inside `payload`; top-level keys are only `platform`, `action`, and `payload`.\nDo not invent platform IDs or platform action names. If the needed action is not listed, ask for discovery/clarification instead of guessing.\nUse `platform_connect` with `action='guide'` and the matching `platform` to start onboarding; use `action='list'` when the app is unclear.\nWhen a pending onboarding state is present, continue from that state with `platform_connect` instead of asking the user to restate the app.\nSupported apps:\n- `api_server`: API Server (OpenAI Compatible) (category=api; backend=adapter; platform_action actions=none registered)\n- `dingtalk`: 钉钉 (DingTalk) (category=im; backend=adapter; platform_action actions=none registered)\n- `feishu`: 飞书 (Feishu/Lark) (category=im; backend=cli)\n - `calendar.create_event` payload={summary*, start_time*, end_time*, attendees} [write/high]\n - `docs.create_markdown` payload={title*, markdown*, folder_token} [write/medium]\n - `drive.search` payload={query*, limit} [read/medium]\n - `im.add_reaction` payload={message_id*, emoji_type*} [write/low]\n - `im.download_resource` payload={message_id*, file_key*, type*} [read/low]\n - `im.get_messages` payload={message_ids*} [read/low]\n - `im.list_chats` payload={page_size} [read/low]\n - `im.list_messages` payload={chat_id*} [read/low]\n - `im.list_thread_messages` payload={thread*} [read/low]\n - `im.remove_reaction` payload={message_id*, emoji_type*} [write/low]\n - `im.reply_message` payload={message_id*, text*} [send/high]\n - `im.search_chats` payload={query*, page_size} [read/low]\n - `im.search_messages` payload={query*} [read/medium]\n - `im.send_message` payload={chat_id*, text*, thread_id} [send/high]\n - `im.update_card` payload={token*, card*} [write/medium]\n - `im.update_message` payload={message_id*, text*} [write/medium]\n - `mail.search_unread` payload={query, limit} [read/high]\n - `sheets.append_row` payload={spreadsheet_token*, sheet_id*, values*} [write/medium]\n - `task.create` payload={title*, description, due_time} [write/medium]\n- `telegram`: Telegram (category=social; backend=adapter; platform_action actions=none registered)\n- `webhook`: Webhook (Generic) (category=webhook; backend=adapter; platform_action actions=none registered)\n\n## Tool Usage\nTools are normally invoked through the native function-calling mechanism, not by writing JSON in your reply\ntext. Only if the provider signals that native function calling is unavailable for this turn, fall back to a\nsingle JSON code block: `{\"name\": \"tool_name\", \"arguments\": {\"key\": \"value\"}}` — use this fallback format\nonly, never both. Only call a tool whose exact name appears in the tool index above and is currently callable\n(or reachable via `capability_expand`). Never invent, rename, alias, or guess a tool name, platform ID, or\nplatform action from argument shape or wording — if the index does not list it, it does not exist.\n`platform_connect.action` (list/guide/preflight/connect/disconnect/remove/status/events_start/events_stop/\nevents_status) is the App Connector management namespace; `platform_action.action` only accepts exact\nregistered business actions such as `im.send_message` shown in the App Connector Capability Index — never mix\nthe two namespaces. If a tool call returns an unknown/unavailable result, use the returned suggestions or\navailable names for a single retry instead of trying further variations of the same guess.\n\n**Side-effect action rule** (`platform_action` with effect=send/write/execute):\n- Call each unique action+payload **exactly once**. Never include duplicates in the same turn.\n- Once the result returns `\"completed\": true`, that action is DONE for this task. Do NOT call it again\n in any subsequent turn — immediately summarize the result for the user instead.\n- The system enforces idempotency: duplicate calls are blocked and will not execute.\n- If the user explicitly requests sending/writing multiple times, use distinct payloads per call.\n\n**Resource identifier provenance rule**:\n- NEVER fabricate, guess, or infer resource identifiers (chat_id, message_id, file_key, user_id, etc.).\n Every resource ID used in a side-effect action MUST come from a successful API response in this session.\n- If a read/list action fails (e.g. authorization error), you do NOT have valid resource IDs.\n Report the failure to the user — do NOT attempt the dependent write/send action with a guessed ID.\n- When a tool result contains `\"llm_instruction\"`, follow it exactly.\n\n## Guidelines\n1. **Direct answers first**: If you already know the answer, respond directly without tools.\n2. **Avoid redundant tool calls**: Do not call the same tool with the same arguments more than once in the same user turn. When an existing tool result already answers the user's request, stop calling tools and answer directly.\n3. **Use tools proactively**: When the user asks about files, time, system state, or needs actions performed, use the appropriate tool.\n4. **Chain tools when needed**: You can call multiple tools in sequence (e.g., list files → read file → summarize).\n5. **Handle failures gracefully**: If a tool fails, explain what went wrong and suggest alternatives. If it failed because of an unknown tool/platform/action name, retry once with an exact name from the returned suggestions, then explain rather than keep guessing.\n6. **Summarize results naturally**: After tool execution, synthesize the results into a helpful answer rather than dumping raw output.\n7. **Stay conversational**: Maintain a natural, helpful tone. Acknowledge context from earlier in the conversation.\n8. **Recall past work**:\n - Broad queries (\"之前做了什么\", \"列出任务\"): answer from the \"Recent Task History\" section already in your context. If insufficient, call session_list.\n - Specific lookups (\"上次那个配置怎么改的\"): call session_search with relevant phrases (NOT single characters).\n - Do NOT call search tools repeatedly with keyword fragments. One well-phrased call is better than ten fragmented ones.\n\n## Coding & Verification\nWhen working with code, prefer the precise built-in tools over ad-hoc shell: use `repo_map` to orient in an\nunfamiliar project, `code_search`/`file_find` to locate and `code_intel` for symbols, `edit_file` (anchored\nsearch-replace, or a unified `diff`) to change files — never rewrite a whole file to change a few lines — and\n`git_query` to inspect diffs/log. After edits, check `syntax_ok` in the result and run `test_run`/`lint_check`\nbefore declaring the work done. Batch independent read-only calls (search/read/query) into a single turn — they\nrun in parallel.\n\n## Presentation Style\n1. **Polished Markdown only**: Format user-facing answers with clean Markdown headings, short paragraphs, and concise bullets. Use tables only when they improve comparison or scanning.\n2. **Terminal-friendly layout**: Keep lines readable in a TUI; avoid dense walls of text, deeply nested lists, oversized ASCII art, or heavy visual blocks.\n3. **Elegant emphasis**: Use bold text sparingly for key terms and conclusions. Avoid excessive emojis, decorative symbols, repeated separators, or visual noise.\n4. **Theme-safe colors**: Do not emit ANSI escape codes, HTML color tags, Rich markup, or hardcoded color names. Rely on the TUI theme to render Markdown professionally.\n5. **No leaked tool protocol**: Never show tool-call JSON, internal schemas, raw observations, tool result payloads, or hidden reasoning in the final answer unless the user explicitly asks for raw/debug output. Treat any prior `{\"name\": ..., \"arguments\": ...}` blocks and `Tool result (...)` messages as internal execution context only.\n6. **Professional closure**: End with a concise conclusion or next step when helpful; avoid rambling after the useful answer is complete.\n\nWhen finished with all tool calls, respond normally without a JSON block, tool-call transcript, or process log.\n\n\n## Recent Session Summary\n- [user] Let me show you something.\n\n## Task Contract\n- Task ID: turn-1\n- Original user request: Let me show you something.\n- Workspace root: \n- Allowed roots: \n- Treat relative project paths as relative to the workspace root; never infer `.` as the project root when a workspace root is provided.\n- Workspace boundary is enforced by tools: do not read, search, edit, or run commands against paths outside the allowed roots unless the user explicitly requests an external path and the tool/approval policy permits it.\n- LeapFlow workspace config is optional at `/.leapflow/config.yaml`; runtime config is loaded from `~/.leapflow/config/user.yaml` and `~/.leapflow/profiles//config/*.yaml`.\n- Preserve this task contract across summarization, compression, tool loops, and memory retrieval.\n" + }, + { + "role": "user", + "content": "Let me show you something.\nLet me show you something." + } + ], + "tools": [ + "capability_expand", + "code_intel", + "code_search", + "config_get", + "config_list", + "env_info", + "file_find", + "file_list", + "file_read", + "git_query", + "lint_check", + "memory_search", + "plugin_list", + "plugin_propose", + "plugin_status", + "plugin_versions", + "repo_map", + "research_note", + "schedule_reentry", + "session_list", + "skill_view", + "skills_list", + "terminal_list", + "terminal_read", + "test_run", + "text_search", + "time_get", + "web_fetch" + ] + }, + "responses": [ + { + "status": 200, + "content_type": "application/json", + "body": "{\"id\": \"chatcmpl-cassette\", \"object\": \"chat.completion\", \"model\": \"cassette-model\", \"choices\": [{\"index\": 0, \"message\": {\"role\": \"assistant\", \"content\": \"Noted the first step.\"}, \"finish_reason\": \"stop\"}], \"usage\": {\"prompt_tokens\": 64, \"completion_tokens\": 16, \"total_tokens\": 80}}" + } + ] +} diff --git a/tests/_fixtures/cassettes/r5_learning/cassette-model-ecc85e333775a41c.cassette.json b/tests/_fixtures/cassettes/r5_learning/cassette-model-ecc85e333775a41c.cassette.json new file mode 100644 index 0000000..ec9508e --- /dev/null +++ b/tests/_fixtures/cassettes/r5_learning/cassette-model-ecc85e333775a41c.cassette.json @@ -0,0 +1,59 @@ +{ + "fingerprint": "ecc85e333775a41c473e9c31be7cfe0a16bc6cd961ced1d3769f87ee3d530b42", + "note": "captured in seed mode", + "request": { + "model": "cassette-model", + "stream": false, + "messages": [ + { + "role": "system", + "content": "You are LeapFlow, an intelligent assistant that can both converse naturally and take real actions on the user's computer.\n\n## Capabilities\nThe tool index below lists **every** registered tool by name and a one-line summary — this is the complete\ncapability contract; nothing else exists. Only a subset is directly callable this turn (via native tool calling,\nnot a JSON block in your reply). If you need a tool from the index that is not yet callable, call\n`capability_expand` with its category name first — the matching tools become callable immediately after.\nWhen the user asks what LeapFlow itself supports, whether it supports plugins, or which runtime capabilities\nare available, use the live capability evidence exposed by `plugin_list` before making capability claims; report\nconfiguration-dependent or unavailable capabilities as limitations instead of inferring from documentation.\n- **text_search**(text, pattern): Search for a regex pattern in text.\n- **text_replace**(text, old, new, count) [capability_expand category: write]: Replace occurrences of a substring in text.\n- **time_get**(): Get current date and time.\n- **env_info**(): Get system environment information (OS, Python version, cwd).\n- **skills_list**(query, category, source): List available learned skills. Use when user asks about capabilities or you need a specific skill.\n- **skill_view**(name): View the full content of a specific skill document.\n- **code_intel**(path, operation): Precise document symbols (outline) for a source file: classes, functions, and methods with line ranges. Python uses an exact AST parse; other languages use a keyword-prefix scan. Prefer over file_read mode=symbols for accurate navigation before editing. Read-only.\n- **repo_map**(path): Compact project orientation for a repository root: languages, detected test/lint commands, top-level structure, entry points, manifest, and VCS branch. Call this first when entering an unfamiliar codebase. Read-only.\n- **scm_sync**(action, cwd, remote, pull_ref, push_ref, timeout) [capability_expand category: scm]: Run a typed git SCM action. Use this instead of shell_run for git pull/push/status. For 'pull origin main then push', set action='pull_then_push', remote='origin', pull_ref='main', and omit push_ref so LeapFlow pushes the current local branch.\n- **git_query**(action, cwd, ref, path, staged, max_count, stat): Read-only structured git inspection: action=diff|log|status|branch|show. Prefer over shell_run for reading repo state — output is clipped, redacted, and log/branch are parsed into structured fields. Use scm_sync for pull/push.\n- **git_write**(action, cwd, message, stage_all, name, ref, create) [capability_expand category: scm]: Mutating git actions: action=commit (message, stage_all), branch (create+switch), checkout (switch; create=true for -b). Approval-gated. Use scm_sync for pull/push and git_query for reads.\n- **test_run**(command, cwd, timeout): Run the project's test suite and return structured results (framework, passed/failed counts, failing tests). Auto-detects the runner (pytest/npm/go/cargo) or uses a configured/explicit command; executes via the governed shell. ok=true means the runner executed — see 'success' for pass/fail.\n- **lint_check**(command, cwd, timeout): Run the project's linter and return a structured clean/issue result. Auto-detects the linter (ruff/eslint/go vet/clippy) or uses a configured/explicit command; executes via the governed shell. ok=true means the linter ran — see 'clean'.\n- **file_list**(path, pattern, depth): List files and directories at a given path. Use depth=1 or depth=2 to get a recursive tree in one call instead of listing each sub-directory separately.\n- **file_read**(path, max_lines, start_line, max_chars, mode): Read text file content with adaptive context governance. For large or unfamiliar files, prefer mode='outline' or mode='symbols' first, then use mode='raw' with start_line/max_lines for the specific range you actually need. For LeapFlow's own settings, use config_list / config_get / config_set — its config files are outside the workspace and not readable here.\n- **file_write**(path, content, mode) [capability_expand category: write]: Write content to a file (overwrite or append).\n- **code_search**(pattern, patterns, path, glob, ignore_case, multiline, max_results, context_lines): Search file CONTENTS by regex pattern across a directory tree (ripgrep-backed). Requires a regex pattern. NOT for listing or browsing directory contents — use file_list for that. Prefer this over shell_run grep: faster, skips VCS/dependency/build dirs, and returns structured path:line:column matches. Batch related lookups into ONE call via `patterns` (OR-combined, single pass) instead of issuing several separate searches. Use file_read for the surrounding context of a hit.\n- **file_find**(glob, path, max_results): Find files by a recursive glob pattern under a base path (e.g. '**/test_*.py' or '*.md'). Prefer this over shell_run find; skips VCS/dependency/build dirs.\n- **edit_file**(path, edits, dry_run, diff) [capability_expand category: write]: Apply targeted, anchored search-replace edits to an EXISTING text file (use file_write to create/overwrite). Each edit is {original_text, new_text, replace_all?}; original_text must match exactly and uniquely (or set replace_all) — a non-unique or missing anchor is rejected so files are never corrupted. Set dry_run to preview. Alternatively pass a unified 'diff' to apply its hunks as anchored edits. Far cheaper and safer than rewriting a whole file.\n- **shell_run**(command, cwd, timeout) [capability_expand category: shell]: Execute a one-shot shell command with timeout protection. Runs in the active workspace; paths resolving outside it are refused. Reach for a structured tool first when one fits — web_fetch for anything over HTTP(S), git_query/scm_sync for git, code_search/file_find/file_read for the repo, config_get/config_set for LeapFlow's own settings — because those report typed results, while a failed shell command can only be diagnosed from its exit code and stderr. Every shell run counts as an external side effect, so a failure stops the rest of the batch and is not retried automatically.\n- **terminal_open**(command, cwd, shell) [capability_expand category: terminal]: Open a PERSISTENT shell session (REPL/dev server/watch), returning a session_id for terminal_send/read/close. Disabled unless tools.terminal_session_enabled is set. For one-shot commands use shell_run instead.\n- **terminal_send**(session_id, input, wait) [capability_expand category: terminal]: Send a line of input to a persistent terminal session and return output captured shortly after.\n- **terminal_read**(session_id, wait): Drain buffered output from a persistent terminal session (optionally waiting briefly first).\n- **terminal_close**(session_id) [capability_expand category: terminal]: Terminate a persistent terminal session and release its process group.\n- **terminal_list**(): List active persistent terminal sessions.\n- **config_list**(category, limit): List LeapFlow's own writable settings (model, provider, daemon, memory, perception, gateway, …) with current values. Use this to discover the exact key before changing anything. Optionally narrow by `category`. This is the only correct way to inspect LeapFlow configuration — never read config files from disk.\n- **config_get**(key): Read one LeapFlow setting by key (e.g. 'llm.model', 'daemon.log_level'), returning its current value, type, scopes, and whether a change needs a daemon restart. Never read LeapFlow config files from disk — use this.\n- **config_set**(key, value, scope) [capability_expand category: config]: Change one LeapFlow setting by key, e.g. switch the model with key='llm.model'. Values are validated and coerced; credentials are stored in the vault automatically. Call config_list or config_get first if unsure of the key. The result states whether a `leap daemon restart` is required. Never edit LeapFlow config files directly.\n- **web_fetch**(url, select, timeout, max_bytes): Read a URL over HTTP(S) and get back extracted, context-sized content: parsed JSON for API endpoints, readable text plus links for web pages. Use this for anything on the internet — prices, docs, releases, articles — instead of running curl through shell_run: it reports real HTTP status codes, retries rate limits on its own, and is a plain read so a retry is always safe. For JSON APIs pass `select` with a dotted path (e.g. 'chart.result.0.meta') to return just that part instead of the whole payload.\n- **memory_search**(query, limit): Search agent memory for relevant past experiences, observations, and facts.\n- **memory_add**(content, kind) [capability_expand category: write]: Store a new observation or insight in memory for future reference.\n- **research_note**(kind, text): Record a compact, structured note about the current task's state so it survives context compression on long / multi-step tasks. Use for durable findings, open questions still to resolve, decisions / excluded paths, and the immediate next step. One concise sentence per note.\n- **capability_expand**(category): Fetch the full callable schema for every tool in a capability category (e.g. 'hub', 'gateway', 'desktop', '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.\n- **delegate_task**(goal, context) [capability_expand category: delegate]: Delegate a complex sub-task to an isolated subagent. The subagent gets a fresh context and restricted tool access. Use when a task is self-contained and can be solved independently.\n- **schedule_reentry**(kind, reason, delay_seconds, event_match, max_reentries, deadline_seconds): Register a re-entry so this task can resume later from its current orientation (findings / open questions / next step). Use when work must pause and continue after a delay (kind=time) or when a matching platform event arrives (kind=event), instead of finishing now. The research-ledger state is carried over automatically.\n- **hub_push**(skill_name, visibility, version) [capability_expand category: hub]: Push a local skill to the ModelScope Hub for sharing or backup.\n- **hub_pull**(repo_id, version) [capability_expand category: hub]: Pull a skill from the ModelScope Hub to install locally.\n- **hub_search**(query) [capability_expand category: hub]: Search for skills on the Hub by keyword or description.\n- **hub_sync**(mode, dry_run) [capability_expand category: hub]: Preview or execute sync between local skills and Hub.\n- **platform_action**(platform, action, payload, backend_kind) [capability_expand category: gateway]: Execute an exact registered business action on an external platform through LeapFlow's App Connector layer. Actions must be copied from the App Connector Capability Index and are addressed as domain.operation, e.g. im.send_message or docs.create_markdown. All business fields (chat_id, text, query, etc.) MUST be placed inside `payload`, never at the top level. Example: {\"platform\":\"feishu\",\"action\":\"im.send_message\",\"payload\":{\"chat_id\":\"oc_xxx\",\"text\":\"hello\"}}. Do not invent action names, do not use management actions such as list/guide/connect/status here.\n- **platform_connect**(action, platform, credentials, options, checkpoint) [capability_expand category: gateway]: List, guide, connect, disconnect, remove, or check status for external platforms using the App Connector management namespace. Supports REST and CLI backends. Use this for management actions such as list/guide/preflight/connect/status; use platform_action only for exact registered business actions.\n- **gateway_send**(platform, chat_id, text, thread_id) [capability_expand category: gateway]: Send a message to a connected external platform (Feishu group, Telegram chat, DingTalk conversation, etc.). Requires the platform to be connected via gateway_connect first. Use gateway_connect with action='list' to see connected platforms and available chat IDs.\n- **gateway_connect**(action, platform, credentials, options) [capability_expand category: gateway]: Connect, configure, or manage external platform integrations (Feishu, DingTalk, Telegram, Slack, Discord, etc.). Conversational flow: 1) call 'guide' to get setup steps + required fields, 2) present the steps to the user and ask for ALL required credentials in a single message, 3) call 'connect' with the credentials. Goal: complete in 1–2 user turns. NEVER include credential values in your text response.\n- **plugin_list**(): List the live plugin registry and cross-subsystem capability evidence. Use this before answering questions about whether LeapFlow supports plugins, self-evolution, plugin installation, hot reload, versioning, or other runtime capabilities.\n- **plugin_status**(plugin_id): Get detailed status of a specific plugin: its declared category, runtime dependencies, contributed tools, and fiber lifecycle state.\n- **plugin_versions**(plugin_id): List recorded source versions and active pointer for a profile-scoped plugin.\n- **plugin_propose**(requested_capability, plugin_id, proposed_tools, test_cases, risk_level, evidence): Create a side-effect-free PluginProposal from explicit capability-gap evidence. Use this before plugin_generate when a missing capability should be reviewed. Does not call an LLM, write files, or install anything.\n- **assess_compatibility**(manifest, source_path) [capability_expand category: plugin_management]: Assess whether a foreign plugin manifest or real DSH source bundle is compatible with LeapFlow. A source bundle assessment is static: runtime_ready stays false until restricted Node discovery during plugin_install. Returns component-level verdicts and limitations.\n- **plugin_generate**(plugin_id, description, proposal_id) [capability_expand category: system]: Generate a new ToolPlugin from a natural-language capability description. The LLM produces code that conforms to the ToolPlugin Protocol; it is then rigorously validated (syntax, structure, import, protocol conformance). The isolated sandbox smoke test runs later, at install-time. Returns the validated code but DOES NOT install it — installation is a separate approval-gated step via plugin_install.\n- **plugin_install**(plugin_id, code, marketplace_name, source_path, proposal_id, version_label) [capability_expand category: system]: Install a plugin from exactly one source: validated Python code, the configured Python marketplace, or a real DSH source bundle. DSH bundles run restricted Node discovery before registration; only public host tools are installed and client UI limitations are reported. Writes profile-scoped state and mutates the process-global registry. REQUIRES APPROVAL.\n- **plugin_rollback**(plugin_id, version) [capability_expand category: system]: Rollback a profile-scoped plugin to a recorded source version and reload it. REQUIRES APPROVAL.\n- **plugin_reload**(plugin_id, version_label) [capability_expand category: system]: Hot-reload a plugin at runtime. Disposes the old plugin fiber, re-imports its module, and registers a fresh instance. Existing in-flight turns are unaffected (snapshot isolation). REQUIRES APPROVAL — this is a self-modification action.\n- **plugin_disable**(plugin_id) [capability_expand category: system]: Disable a plugin by disposing its fiber, removing its tools from the runtime registry. Cannot disable self_management itself. REQUIRES APPROVAL — this is a self-modification action.\n- **plugin_remove**(plugin_id, delete_source) [capability_expand category: system]: Terminally remove a plugin: dispose its fiber, unregister its tools, remove reload metadata, and optionally delete its profile-scoped source file. Cannot remove self_management itself. REQUIRES APPROVAL.\n- **plugin_enable**(plugin_id) [capability_expand category: system]: Re-enable a previously disabled plugin by reloading its module and registering a fresh instance. REQUIRES APPROVAL.\n- **session_search**(query, limit) [capability_expand category: unclassified]: Search past conversation sessions in the current workspace for relevant context.\n- **session_list**(limit): List recent conversation sessions in the current workspace with stable ids, titles, dates, and summaries. Use for browsing past tasks or when user asks to see history without specific search terms. No keywords needed — returns chronological list.\n- **session_detail**(session_id, limit, offset, include_inactive) [capability_expand category: unclassified]: Read a paginated persisted transcript for one past conversation session in the current workspace. Use after session_list or session_search returns a session_id.\n- **click**(element_index) [capability_expand category: desktop]: Click a UI element by its element_index (from the latest observe_ui snapshot)\n- **get_clipboard**() [capability_expand category: desktop]: Read current clipboard text content\n- **list_apps**(filter, running_only) [capability_expand category: desktop]: List available applications on this system. Use to discover correct bundle_id before switch_app.\n- **list_windows**() [capability_expand category: desktop]: List all top-level windows with pid, window_id, title, and per-window state (minimized, on-screen). Call this first to pick the pid and window_id that observe_ui and other window tools require.\n- **observe_ui**(pid, window_id, query) [capability_expand category: desktop]: Snapshot one window's actionable UI elements, each tagged with an element_index for click/right_click/read_text. Re-observe after actions — indices belong to one snapshot. Requires the window's pid and window_id from list_windows.\n- **open_url**(url, app_id) [capability_expand category: desktop]: Open a URL in the default or specified browser\n- **read_text**(element_index) [capability_expand category: desktop]: Read the text content of a specific UI element from the latest snapshot\n- **right_click**(element_index) [capability_expand category: desktop]: Right-click a UI element to open its context menu. Returns visible menu items.\n- **screenshot**(pid, window_id) [capability_expand category: desktop]: Capture a screenshot for visual verification. With pid + window_id captures that window (works across all displays); defaults to the last observed window, or the full desktop when no window has been observed.\n- **scroll**(element_index, direction, amount, pid, window_id) [capability_expand category: desktop]: Scroll a scrollable area of a window. Omit element_index to scroll the window's focused/page scroller; pass one to scroll an exact element from the latest snapshot.\n- **select_text**(element_index) [capability_expand category: desktop]: Select all text in a UI element (focus + select-all, for subsequent copy)\n- **set_clipboard**(text) [capability_expand category: desktop]: Write text to the clipboard\n- **shortcut**(keys) [capability_expand category: desktop]: Execute a keyboard shortcut\n- **switch_app**(app_id) [capability_expand category: desktop]: Switch to an app (launch if needed, activate, verify)\n- **type_text**(text) [capability_expand category: desktop]: Type text into the currently focused element\n- **wait**(seconds) [capability_expand category: desktop]: Wait for a specified duration before continuing\n- **wait_until**(condition, pid, window_id, timeout, poll_interval) [capability_expand category: desktop]: Wait until a UI condition is met (polls UI tree). Returns elements when found or on timeout.\n- **wait_until_stable**(timeout, poll_interval, pid, window_id) [capability_expand category: desktop]: Wait until the UI stops changing (element set stabilizes across polls).\n\n## App Connector Capability Index\nLeapFlow can onboard and manage external apps through `platform_connect` and execute exact registered business actions through `platform_action`.\nFor requests about connecting, setting up, configuring, enabling, or managing a supported app, use `platform_connect` first instead of generating SDK/Webhook sample code.\n`platform_connect.action` is the management namespace: list, guide, preflight, connect, disconnect, remove, status, events_start, events_stop, events_status.\n`platform_action.action` is only for exact registered platform business actions listed below, such as `im.send_message`; never use management actions like `list` or `guide` there.\nAll business fields MUST go inside `payload`; top-level keys are only `platform`, `action`, and `payload`.\nDo not invent platform IDs or platform action names. If the needed action is not listed, ask for discovery/clarification instead of guessing.\nUse `platform_connect` with `action='guide'` and the matching `platform` to start onboarding; use `action='list'` when the app is unclear.\nWhen a pending onboarding state is present, continue from that state with `platform_connect` instead of asking the user to restate the app.\nSupported apps:\n- `api_server`: API Server (OpenAI Compatible) (category=api; backend=adapter; platform_action actions=none registered)\n- `dingtalk`: 钉钉 (DingTalk) (category=im; backend=adapter; platform_action actions=none registered)\n- `feishu`: 飞书 (Feishu/Lark) (category=im; backend=cli)\n - `calendar.create_event` payload={summary*, start_time*, end_time*, attendees} [write/high]\n - `docs.create_markdown` payload={title*, markdown*, folder_token} [write/medium]\n - `drive.search` payload={query*, limit} [read/medium]\n - `im.add_reaction` payload={message_id*, emoji_type*} [write/low]\n - `im.download_resource` payload={message_id*, file_key*, type*} [read/low]\n - `im.get_messages` payload={message_ids*} [read/low]\n - `im.list_chats` payload={page_size} [read/low]\n - `im.list_messages` payload={chat_id*} [read/low]\n - `im.list_thread_messages` payload={thread*} [read/low]\n - `im.remove_reaction` payload={message_id*, emoji_type*} [write/low]\n - `im.reply_message` payload={message_id*, text*} [send/high]\n - `im.search_chats` payload={query*, page_size} [read/low]\n - `im.search_messages` payload={query*} [read/medium]\n - `im.send_message` payload={chat_id*, text*, thread_id} [send/high]\n - `im.update_card` payload={token*, card*} [write/medium]\n - `im.update_message` payload={message_id*, text*} [write/medium]\n - `mail.search_unread` payload={query, limit} [read/high]\n - `sheets.append_row` payload={spreadsheet_token*, sheet_id*, values*} [write/medium]\n - `task.create` payload={title*, description, due_time} [write/medium]\n- `telegram`: Telegram (category=social; backend=adapter; platform_action actions=none registered)\n- `webhook`: Webhook (Generic) (category=webhook; backend=adapter; platform_action actions=none registered)\n\n## Tool Usage\nTools are normally invoked through the native function-calling mechanism, not by writing JSON in your reply\ntext. Only if the provider signals that native function calling is unavailable for this turn, fall back to a\nsingle JSON code block: `{\"name\": \"tool_name\", \"arguments\": {\"key\": \"value\"}}` — use this fallback format\nonly, never both. Only call a tool whose exact name appears in the tool index above and is currently callable\n(or reachable via `capability_expand`). Never invent, rename, alias, or guess a tool name, platform ID, or\nplatform action from argument shape or wording — if the index does not list it, it does not exist.\n`platform_connect.action` (list/guide/preflight/connect/disconnect/remove/status/events_start/events_stop/\nevents_status) is the App Connector management namespace; `platform_action.action` only accepts exact\nregistered business actions such as `im.send_message` shown in the App Connector Capability Index — never mix\nthe two namespaces. If a tool call returns an unknown/unavailable result, use the returned suggestions or\navailable names for a single retry instead of trying further variations of the same guess.\n\n**Side-effect action rule** (`platform_action` with effect=send/write/execute):\n- Call each unique action+payload **exactly once**. Never include duplicates in the same turn.\n- Once the result returns `\"completed\": true`, that action is DONE for this task. Do NOT call it again\n in any subsequent turn — immediately summarize the result for the user instead.\n- The system enforces idempotency: duplicate calls are blocked and will not execute.\n- If the user explicitly requests sending/writing multiple times, use distinct payloads per call.\n\n**Resource identifier provenance rule**:\n- NEVER fabricate, guess, or infer resource identifiers (chat_id, message_id, file_key, user_id, etc.).\n Every resource ID used in a side-effect action MUST come from a successful API response in this session.\n- If a read/list action fails (e.g. authorization error), you do NOT have valid resource IDs.\n Report the failure to the user — do NOT attempt the dependent write/send action with a guessed ID.\n- When a tool result contains `\"llm_instruction\"`, follow it exactly.\n\n## Guidelines\n1. **Direct answers first**: If you already know the answer, respond directly without tools.\n2. **Avoid redundant tool calls**: Do not call the same tool with the same arguments more than once in the same user turn. When an existing tool result already answers the user's request, stop calling tools and answer directly.\n3. **Use tools proactively**: When the user asks about files, time, system state, or needs actions performed, use the appropriate tool.\n4. **Chain tools when needed**: You can call multiple tools in sequence (e.g., list files → read file → summarize).\n5. **Handle failures gracefully**: If a tool fails, explain what went wrong and suggest alternatives. If it failed because of an unknown tool/platform/action name, retry once with an exact name from the returned suggestions, then explain rather than keep guessing.\n6. **Summarize results naturally**: After tool execution, synthesize the results into a helpful answer rather than dumping raw output.\n7. **Stay conversational**: Maintain a natural, helpful tone. Acknowledge context from earlier in the conversation.\n8. **Recall past work**:\n - Broad queries (\"之前做了什么\", \"列出任务\"): answer from the \"Recent Task History\" section already in your context. If insufficient, call session_list.\n - Specific lookups (\"上次那个配置怎么改的\"): call session_search with relevant phrases (NOT single characters).\n - Do NOT call search tools repeatedly with keyword fragments. One well-phrased call is better than ten fragmented ones.\n\n## Coding & Verification\nWhen working with code, prefer the precise built-in tools over ad-hoc shell: use `repo_map` to orient in an\nunfamiliar project, `code_search`/`file_find` to locate and `code_intel` for symbols, `edit_file` (anchored\nsearch-replace, or a unified `diff`) to change files — never rewrite a whole file to change a few lines — and\n`git_query` to inspect diffs/log. After edits, check `syntax_ok` in the result and run `test_run`/`lint_check`\nbefore declaring the work done. Batch independent read-only calls (search/read/query) into a single turn — they\nrun in parallel.\n\n## Presentation Style\n1. **Polished Markdown only**: Format user-facing answers with clean Markdown headings, short paragraphs, and concise bullets. Use tables only when they improve comparison or scanning.\n2. **Terminal-friendly layout**: Keep lines readable in a TUI; avoid dense walls of text, deeply nested lists, oversized ASCII art, or heavy visual blocks.\n3. **Elegant emphasis**: Use bold text sparingly for key terms and conclusions. Avoid excessive emojis, decorative symbols, repeated separators, or visual noise.\n4. **Theme-safe colors**: Do not emit ANSI escape codes, HTML color tags, Rich markup, or hardcoded color names. Rely on the TUI theme to render Markdown professionally.\n5. **No leaked tool protocol**: Never show tool-call JSON, internal schemas, raw observations, tool result payloads, or hidden reasoning in the final answer unless the user explicitly asks for raw/debug output. Treat any prior `{\"name\": ..., \"arguments\": ...}` blocks and `Tool result (...)` messages as internal execution context only.\n6. **Professional closure**: End with a concise conclusion or next step when helpful; avoid rambling after the useful answer is complete.\n\nWhen finished with all tool calls, respond normally without a JSON block, tool-call transcript, or process log.\n\n\n## Recent Session Summary\n- [assistant] Noted the first step.\n- [user] Now sort them by month.\n\n## Task Contract\n- Task ID: turn-2\n- Original user request: Now sort them by month.\n- Workspace root: \n- Allowed roots: \n- Treat relative project paths as relative to the workspace root; never infer `.` as the project root when a workspace root is provided.\n- Workspace boundary is enforced by tools: do not read, search, edit, or run commands against paths outside the allowed roots unless the user explicitly requests an external path and the tool/approval policy permits it.\n- LeapFlow workspace config is optional at `/.leapflow/config.yaml`; runtime config is loaded from `~/.leapflow/config/user.yaml` and `~/.leapflow/profiles//config/*.yaml`.\n- Preserve this task contract across summarization, compression, tool loops, and memory retrieval.\n" + }, + { + "role": "assistant", + "content": "Noted the first step." + }, + { + "role": "user", + "content": "Now sort them by month.\nNow sort them by month." + } + ], + "tools": [ + "capability_expand", + "code_intel", + "code_search", + "config_get", + "config_list", + "env_info", + "file_find", + "file_list", + "file_read", + "git_query", + "lint_check", + "memory_search", + "plugin_list", + "plugin_propose", + "plugin_status", + "plugin_versions", + "repo_map", + "research_note", + "schedule_reentry", + "session_list", + "skill_view", + "skills_list", + "terminal_list", + "terminal_read", + "test_run", + "text_search", + "time_get", + "web_fetch" + ] + }, + "responses": [ + { + "status": 200, + "content_type": "application/json", + "body": "{\"id\": \"chatcmpl-cassette\", \"object\": \"chat.completion\", \"model\": \"cassette-model\", \"choices\": [{\"index\": 0, \"message\": {\"role\": \"assistant\", \"content\": \"Noted the second step.\"}, \"finish_reason\": \"stop\"}], \"usage\": {\"prompt_tokens\": 64, \"completion_tokens\": 16, \"total_tokens\": 80}}" + } + ] +} diff --git a/tests/_fixtures/cassettes/r5_learning/cassette-model-f1c7c63e9e8be4d5.cassette.json b/tests/_fixtures/cassettes/r5_learning/cassette-model-f1c7c63e9e8be4d5.cassette.json new file mode 100644 index 0000000..b96c764 --- /dev/null +++ b/tests/_fixtures/cassettes/r5_learning/cassette-model-f1c7c63e9e8be4d5.cassette.json @@ -0,0 +1,67 @@ +{ + "fingerprint": "f1c7c63e9e8be4d52021778181c5662846c70afb6850bbdb46371c8751b2c395", + "note": "captured in seed mode", + "request": { + "model": "cassette-model", + "stream": false, + "messages": [ + { + "role": "system", + "content": "You are LeapFlow, an intelligent assistant that can both converse naturally and take real actions on the user's computer.\n\n## Capabilities\nThe tool index below lists **every** registered tool by name and a one-line summary — this is the complete\ncapability contract; nothing else exists. Only a subset is directly callable this turn (via native tool calling,\nnot a JSON block in your reply). If you need a tool from the index that is not yet callable, call\n`capability_expand` with its category name first — the matching tools become callable immediately after.\nWhen the user asks what LeapFlow itself supports, whether it supports plugins, or which runtime capabilities\nare available, use the live capability evidence exposed by `plugin_list` before making capability claims; report\nconfiguration-dependent or unavailable capabilities as limitations instead of inferring from documentation.\n- **text_search**(text, pattern): Search for a regex pattern in text.\n- **text_replace**(text, old, new, count) [capability_expand category: write]: Replace occurrences of a substring in text.\n- **time_get**(): Get current date and time.\n- **env_info**(): Get system environment information (OS, Python version, cwd).\n- **skills_list**(query, category, source): List available learned skills. Use when user asks about capabilities or you need a specific skill.\n- **skill_view**(name): View the full content of a specific skill document.\n- **code_intel**(path, operation): Precise document symbols (outline) for a source file: classes, functions, and methods with line ranges. Python uses an exact AST parse; other languages use a keyword-prefix scan. Prefer over file_read mode=symbols for accurate navigation before editing. Read-only.\n- **repo_map**(path): Compact project orientation for a repository root: languages, detected test/lint commands, top-level structure, entry points, manifest, and VCS branch. Call this first when entering an unfamiliar codebase. Read-only.\n- **scm_sync**(action, cwd, remote, pull_ref, push_ref, timeout) [capability_expand category: scm]: Run a typed git SCM action. Use this instead of shell_run for git pull/push/status. For 'pull origin main then push', set action='pull_then_push', remote='origin', pull_ref='main', and omit push_ref so LeapFlow pushes the current local branch.\n- **git_query**(action, cwd, ref, path, staged, max_count, stat): Read-only structured git inspection: action=diff|log|status|branch|show. Prefer over shell_run for reading repo state — output is clipped, redacted, and log/branch are parsed into structured fields. Use scm_sync for pull/push.\n- **git_write**(action, cwd, message, stage_all, name, ref, create) [capability_expand category: scm]: Mutating git actions: action=commit (message, stage_all), branch (create+switch), checkout (switch; create=true for -b). Approval-gated. Use scm_sync for pull/push and git_query for reads.\n- **test_run**(command, cwd, timeout): Run the project's test suite and return structured results (framework, passed/failed counts, failing tests). Auto-detects the runner (pytest/npm/go/cargo) or uses a configured/explicit command; executes via the governed shell. ok=true means the runner executed — see 'success' for pass/fail.\n- **lint_check**(command, cwd, timeout): Run the project's linter and return a structured clean/issue result. Auto-detects the linter (ruff/eslint/go vet/clippy) or uses a configured/explicit command; executes via the governed shell. ok=true means the linter ran — see 'clean'.\n- **file_list**(path, pattern, depth): List files and directories at a given path. Use depth=1 or depth=2 to get a recursive tree in one call instead of listing each sub-directory separately.\n- **file_read**(path, max_lines, start_line, max_chars, mode): Read text file content with adaptive context governance. For large or unfamiliar files, prefer mode='outline' or mode='symbols' first, then use mode='raw' with start_line/max_lines for the specific range you actually need. For LeapFlow's own settings, use config_list / config_get / config_set — its config files are outside the workspace and not readable here.\n- **file_write**(path, content, mode) [capability_expand category: write]: Write content to a file (overwrite or append).\n- **code_search**(pattern, patterns, path, glob, ignore_case, multiline, max_results, context_lines): Search file CONTENTS by regex pattern across a directory tree (ripgrep-backed). Requires a regex pattern. NOT for listing or browsing directory contents — use file_list for that. Prefer this over shell_run grep: faster, skips VCS/dependency/build dirs, and returns structured path:line:column matches. Batch related lookups into ONE call via `patterns` (OR-combined, single pass) instead of issuing several separate searches. Use file_read for the surrounding context of a hit.\n- **file_find**(glob, path, max_results): Find files by a recursive glob pattern under a base path (e.g. '**/test_*.py' or '*.md'). Prefer this over shell_run find; skips VCS/dependency/build dirs.\n- **edit_file**(path, edits, dry_run, diff) [capability_expand category: write]: Apply targeted, anchored search-replace edits to an EXISTING text file (use file_write to create/overwrite). Each edit is {original_text, new_text, replace_all?}; original_text must match exactly and uniquely (or set replace_all) — a non-unique or missing anchor is rejected so files are never corrupted. Set dry_run to preview. Alternatively pass a unified 'diff' to apply its hunks as anchored edits. Far cheaper and safer than rewriting a whole file.\n- **shell_run**(command, cwd, timeout) [capability_expand category: shell]: Execute a one-shot shell command with timeout protection. Runs in the active workspace; paths resolving outside it are refused. Reach for a structured tool first when one fits — web_fetch for anything over HTTP(S), git_query/scm_sync for git, code_search/file_find/file_read for the repo, config_get/config_set for LeapFlow's own settings — because those report typed results, while a failed shell command can only be diagnosed from its exit code and stderr. Every shell run counts as an external side effect, so a failure stops the rest of the batch and is not retried automatically.\n- **terminal_open**(command, cwd, shell) [capability_expand category: terminal]: Open a PERSISTENT shell session (REPL/dev server/watch), returning a session_id for terminal_send/read/close. Disabled unless tools.terminal_session_enabled is set. For one-shot commands use shell_run instead.\n- **terminal_send**(session_id, input, wait) [capability_expand category: terminal]: Send a line of input to a persistent terminal session and return output captured shortly after.\n- **terminal_read**(session_id, wait): Drain buffered output from a persistent terminal session (optionally waiting briefly first).\n- **terminal_close**(session_id) [capability_expand category: terminal]: Terminate a persistent terminal session and release its process group.\n- **terminal_list**(): List active persistent terminal sessions.\n- **config_list**(category, limit): List LeapFlow's own writable settings (model, provider, daemon, memory, perception, gateway, …) with current values. Use this to discover the exact key before changing anything. Optionally narrow by `category`. This is the only correct way to inspect LeapFlow configuration — never read config files from disk.\n- **config_get**(key): Read one LeapFlow setting by key (e.g. 'llm.model', 'daemon.log_level'), returning its current value, type, scopes, and whether a change needs a daemon restart. Never read LeapFlow config files from disk — use this.\n- **config_set**(key, value, scope) [capability_expand category: config]: Change one LeapFlow setting by key, e.g. switch the model with key='llm.model'. Values are validated and coerced; credentials are stored in the vault automatically. Call config_list or config_get first if unsure of the key. The result states whether a `leap daemon restart` is required. Never edit LeapFlow config files directly.\n- **web_fetch**(url, select, timeout, max_bytes): Read a URL over HTTP(S) and get back extracted, context-sized content: parsed JSON for API endpoints, readable text plus links for web pages. Use this for anything on the internet — prices, docs, releases, articles — instead of running curl through shell_run: it reports real HTTP status codes, retries rate limits on its own, and is a plain read so a retry is always safe. For JSON APIs pass `select` with a dotted path (e.g. 'chart.result.0.meta') to return just that part instead of the whole payload.\n- **memory_search**(query, limit): Search agent memory for relevant past experiences, observations, and facts.\n- **memory_add**(content, kind) [capability_expand category: write]: Store a new observation or insight in memory for future reference.\n- **research_note**(kind, text): Record a compact, structured note about the current task's state so it survives context compression on long / multi-step tasks. Use for durable findings, open questions still to resolve, decisions / excluded paths, and the immediate next step. One concise sentence per note.\n- **capability_expand**(category): Fetch the full callable schema for every tool in a capability category (e.g. 'hub', 'gateway', 'desktop', '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.\n- **delegate_task**(goal, context) [capability_expand category: delegate]: Delegate a complex sub-task to an isolated subagent. The subagent gets a fresh context and restricted tool access. Use when a task is self-contained and can be solved independently.\n- **schedule_reentry**(kind, reason, delay_seconds, event_match, max_reentries, deadline_seconds): Register a re-entry so this task can resume later from its current orientation (findings / open questions / next step). Use when work must pause and continue after a delay (kind=time) or when a matching platform event arrives (kind=event), instead of finishing now. The research-ledger state is carried over automatically.\n- **hub_push**(skill_name, visibility, version) [capability_expand category: hub]: Push a local skill to the ModelScope Hub for sharing or backup.\n- **hub_pull**(repo_id, version) [capability_expand category: hub]: Pull a skill from the ModelScope Hub to install locally.\n- **hub_search**(query) [capability_expand category: hub]: Search for skills on the Hub by keyword or description.\n- **hub_sync**(mode, dry_run) [capability_expand category: hub]: Preview or execute sync between local skills and Hub.\n- **platform_action**(platform, action, payload, backend_kind) [capability_expand category: gateway]: Execute an exact registered business action on an external platform through LeapFlow's App Connector layer. Actions must be copied from the App Connector Capability Index and are addressed as domain.operation, e.g. im.send_message or docs.create_markdown. All business fields (chat_id, text, query, etc.) MUST be placed inside `payload`, never at the top level. Example: {\"platform\":\"feishu\",\"action\":\"im.send_message\",\"payload\":{\"chat_id\":\"oc_xxx\",\"text\":\"hello\"}}. Do not invent action names, do not use management actions such as list/guide/connect/status here.\n- **platform_connect**(action, platform, credentials, options, checkpoint) [capability_expand category: gateway]: List, guide, connect, disconnect, remove, or check status for external platforms using the App Connector management namespace. Supports REST and CLI backends. Use this for management actions such as list/guide/preflight/connect/status; use platform_action only for exact registered business actions.\n- **gateway_send**(platform, chat_id, text, thread_id) [capability_expand category: gateway]: Send a message to a connected external platform (Feishu group, Telegram chat, DingTalk conversation, etc.). Requires the platform to be connected via gateway_connect first. Use gateway_connect with action='list' to see connected platforms and available chat IDs.\n- **gateway_connect**(action, platform, credentials, options) [capability_expand category: gateway]: Connect, configure, or manage external platform integrations (Feishu, DingTalk, Telegram, Slack, Discord, etc.). Conversational flow: 1) call 'guide' to get setup steps + required fields, 2) present the steps to the user and ask for ALL required credentials in a single message, 3) call 'connect' with the credentials. Goal: complete in 1–2 user turns. NEVER include credential values in your text response.\n- **plugin_list**(): List the live plugin registry and cross-subsystem capability evidence. Use this before answering questions about whether LeapFlow supports plugins, self-evolution, plugin installation, hot reload, versioning, or other runtime capabilities.\n- **plugin_status**(plugin_id): Get detailed status of a specific plugin: its declared category, runtime dependencies, contributed tools, and fiber lifecycle state.\n- **plugin_versions**(plugin_id): List recorded source versions and active pointer for a profile-scoped plugin.\n- **plugin_propose**(requested_capability, plugin_id, proposed_tools, test_cases, risk_level, evidence): Create a side-effect-free PluginProposal from explicit capability-gap evidence. Use this before plugin_generate when a missing capability should be reviewed. Does not call an LLM, write files, or install anything.\n- **assess_compatibility**(manifest, source_path) [capability_expand category: plugin_management]: Assess whether a foreign plugin manifest or real DSH source bundle is compatible with LeapFlow. A source bundle assessment is static: runtime_ready stays false until restricted Node discovery during plugin_install. Returns component-level verdicts and limitations.\n- **plugin_generate**(plugin_id, description, proposal_id) [capability_expand category: system]: Generate a new ToolPlugin from a natural-language capability description. The LLM produces code that conforms to the ToolPlugin Protocol; it is then rigorously validated (syntax, structure, import, protocol conformance). The isolated sandbox smoke test runs later, at install-time. Returns the validated code but DOES NOT install it — installation is a separate approval-gated step via plugin_install.\n- **plugin_install**(plugin_id, code, marketplace_name, source_path, proposal_id, version_label) [capability_expand category: system]: Install a plugin from exactly one source: validated Python code, the configured Python marketplace, or a real DSH source bundle. DSH bundles run restricted Node discovery before registration; only public host tools are installed and client UI limitations are reported. Writes profile-scoped state and mutates the process-global registry. REQUIRES APPROVAL.\n- **plugin_rollback**(plugin_id, version) [capability_expand category: system]: Rollback a profile-scoped plugin to a recorded source version and reload it. REQUIRES APPROVAL.\n- **plugin_reload**(plugin_id, version_label) [capability_expand category: system]: Hot-reload a plugin at runtime. Disposes the old plugin fiber, re-imports its module, and registers a fresh instance. Existing in-flight turns are unaffected (snapshot isolation). REQUIRES APPROVAL — this is a self-modification action.\n- **plugin_disable**(plugin_id) [capability_expand category: system]: Disable a plugin by disposing its fiber, removing its tools from the runtime registry. Cannot disable self_management itself. REQUIRES APPROVAL — this is a self-modification action.\n- **plugin_remove**(plugin_id, delete_source) [capability_expand category: system]: Terminally remove a plugin: dispose its fiber, unregister its tools, remove reload metadata, and optionally delete its profile-scoped source file. Cannot remove self_management itself. REQUIRES APPROVAL.\n- **plugin_enable**(plugin_id) [capability_expand category: system]: Re-enable a previously disabled plugin by reloading its module and registering a fresh instance. REQUIRES APPROVAL.\n- **session_search**(query, limit) [capability_expand category: unclassified]: Search past conversation sessions in the current workspace for relevant context.\n- **session_list**(limit): List recent conversation sessions in the current workspace with stable ids, titles, dates, and summaries. Use for browsing past tasks or when user asks to see history without specific search terms. No keywords needed — returns chronological list.\n- **session_detail**(session_id, limit, offset, include_inactive) [capability_expand category: unclassified]: Read a paginated persisted transcript for one past conversation session in the current workspace. Use after session_list or session_search returns a session_id.\n- **click**(element_index) [capability_expand category: desktop]: Click a UI element by its element_index (from the latest observe_ui snapshot)\n- **get_clipboard**() [capability_expand category: desktop]: Read current clipboard text content\n- **list_apps**(filter, running_only) [capability_expand category: desktop]: List available applications on this system. Use to discover correct bundle_id before switch_app.\n- **list_windows**() [capability_expand category: desktop]: List all top-level windows with pid, window_id, title, and per-window state (minimized, on-screen). Call this first to pick the pid and window_id that observe_ui and other window tools require.\n- **observe_ui**(pid, window_id, query) [capability_expand category: desktop]: Snapshot one window's actionable UI elements, each tagged with an element_index for click/right_click/read_text. Re-observe after actions — indices belong to one snapshot. Requires the window's pid and window_id from list_windows.\n- **open_url**(url, app_id) [capability_expand category: desktop]: Open a URL in the default or specified browser\n- **read_text**(element_index) [capability_expand category: desktop]: Read the text content of a specific UI element from the latest snapshot\n- **right_click**(element_index) [capability_expand category: desktop]: Right-click a UI element to open its context menu. Returns visible menu items.\n- **screenshot**(pid, window_id) [capability_expand category: desktop]: Capture a screenshot for visual verification. With pid + window_id captures that window (works across all displays); defaults to the last observed window, or the full desktop when no window has been observed.\n- **scroll**(element_index, direction, amount, pid, window_id) [capability_expand category: desktop]: Scroll a scrollable area of a window. Omit element_index to scroll the window's focused/page scroller; pass one to scroll an exact element from the latest snapshot.\n- **select_text**(element_index) [capability_expand category: desktop]: Select all text in a UI element (focus + select-all, for subsequent copy)\n- **set_clipboard**(text) [capability_expand category: desktop]: Write text to the clipboard\n- **shortcut**(keys) [capability_expand category: desktop]: Execute a keyboard shortcut\n- **switch_app**(app_id) [capability_expand category: desktop]: Switch to an app (launch if needed, activate, verify)\n- **type_text**(text) [capability_expand category: desktop]: Type text into the currently focused element\n- **wait**(seconds) [capability_expand category: desktop]: Wait for a specified duration before continuing\n- **wait_until**(condition, pid, window_id, timeout, poll_interval) [capability_expand category: desktop]: Wait until a UI condition is met (polls UI tree). Returns elements when found or on timeout.\n- **wait_until_stable**(timeout, poll_interval, pid, window_id) [capability_expand category: desktop]: Wait until the UI stops changing (element set stabilizes across polls).\n\n## App Connector Capability Index\nLeapFlow can onboard and manage external apps through `platform_connect` and execute exact registered business actions through `platform_action`.\nFor requests about connecting, setting up, configuring, enabling, or managing a supported app, use `platform_connect` first instead of generating SDK/Webhook sample code.\n`platform_connect.action` is the management namespace: list, guide, preflight, connect, disconnect, remove, status, events_start, events_stop, events_status.\n`platform_action.action` is only for exact registered platform business actions listed below, such as `im.send_message`; never use management actions like `list` or `guide` there.\nAll business fields MUST go inside `payload`; top-level keys are only `platform`, `action`, and `payload`.\nDo not invent platform IDs or platform action names. If the needed action is not listed, ask for discovery/clarification instead of guessing.\nUse `platform_connect` with `action='guide'` and the matching `platform` to start onboarding; use `action='list'` when the app is unclear.\nWhen a pending onboarding state is present, continue from that state with `platform_connect` instead of asking the user to restate the app.\nSupported apps:\n- `api_server`: API Server (OpenAI Compatible) (category=api; backend=adapter; platform_action actions=none registered)\n- `dingtalk`: 钉钉 (DingTalk) (category=im; backend=adapter; platform_action actions=none registered)\n- `feishu`: 飞书 (Feishu/Lark) (category=im; backend=cli)\n - `calendar.create_event` payload={summary*, start_time*, end_time*, attendees} [write/high]\n - `docs.create_markdown` payload={title*, markdown*, folder_token} [write/medium]\n - `drive.search` payload={query*, limit} [read/medium]\n - `im.add_reaction` payload={message_id*, emoji_type*} [write/low]\n - `im.download_resource` payload={message_id*, file_key*, type*} [read/low]\n - `im.get_messages` payload={message_ids*} [read/low]\n - `im.list_chats` payload={page_size} [read/low]\n - `im.list_messages` payload={chat_id*} [read/low]\n - `im.list_thread_messages` payload={thread*} [read/low]\n - `im.remove_reaction` payload={message_id*, emoji_type*} [write/low]\n - `im.reply_message` payload={message_id*, text*} [send/high]\n - `im.search_chats` payload={query*, page_size} [read/low]\n - `im.search_messages` payload={query*} [read/medium]\n - `im.send_message` payload={chat_id*, text*, thread_id} [send/high]\n - `im.update_card` payload={token*, card*} [write/medium]\n - `im.update_message` payload={message_id*, text*} [write/medium]\n - `mail.search_unread` payload={query, limit} [read/high]\n - `sheets.append_row` payload={spreadsheet_token*, sheet_id*, values*} [write/medium]\n - `task.create` payload={title*, description, due_time} [write/medium]\n- `telegram`: Telegram (category=social; backend=adapter; platform_action actions=none registered)\n- `webhook`: Webhook (Generic) (category=webhook; backend=adapter; platform_action actions=none registered)\n\n## Tool Usage\nTools are normally invoked through the native function-calling mechanism, not by writing JSON in your reply\ntext. Only if the provider signals that native function calling is unavailable for this turn, fall back to a\nsingle JSON code block: `{\"name\": \"tool_name\", \"arguments\": {\"key\": \"value\"}}` — use this fallback format\nonly, never both. Only call a tool whose exact name appears in the tool index above and is currently callable\n(or reachable via `capability_expand`). Never invent, rename, alias, or guess a tool name, platform ID, or\nplatform action from argument shape or wording — if the index does not list it, it does not exist.\n`platform_connect.action` (list/guide/preflight/connect/disconnect/remove/status/events_start/events_stop/\nevents_status) is the App Connector management namespace; `platform_action.action` only accepts exact\nregistered business actions such as `im.send_message` shown in the App Connector Capability Index — never mix\nthe two namespaces. If a tool call returns an unknown/unavailable result, use the returned suggestions or\navailable names for a single retry instead of trying further variations of the same guess.\n\n**Side-effect action rule** (`platform_action` with effect=send/write/execute):\n- Call each unique action+payload **exactly once**. Never include duplicates in the same turn.\n- Once the result returns `\"completed\": true`, that action is DONE for this task. Do NOT call it again\n in any subsequent turn — immediately summarize the result for the user instead.\n- The system enforces idempotency: duplicate calls are blocked and will not execute.\n- If the user explicitly requests sending/writing multiple times, use distinct payloads per call.\n\n**Resource identifier provenance rule**:\n- NEVER fabricate, guess, or infer resource identifiers (chat_id, message_id, file_key, user_id, etc.).\n Every resource ID used in a side-effect action MUST come from a successful API response in this session.\n- If a read/list action fails (e.g. authorization error), you do NOT have valid resource IDs.\n Report the failure to the user — do NOT attempt the dependent write/send action with a guessed ID.\n- When a tool result contains `\"llm_instruction\"`, follow it exactly.\n\n## Guidelines\n1. **Direct answers first**: If you already know the answer, respond directly without tools.\n2. **Avoid redundant tool calls**: Do not call the same tool with the same arguments more than once in the same user turn. When an existing tool result already answers the user's request, stop calling tools and answer directly.\n3. **Use tools proactively**: When the user asks about files, time, system state, or needs actions performed, use the appropriate tool.\n4. **Chain tools when needed**: You can call multiple tools in sequence (e.g., list files → read file → summarize).\n5. **Handle failures gracefully**: If a tool fails, explain what went wrong and suggest alternatives. If it failed because of an unknown tool/platform/action name, retry once with an exact name from the returned suggestions, then explain rather than keep guessing.\n6. **Summarize results naturally**: After tool execution, synthesize the results into a helpful answer rather than dumping raw output.\n7. **Stay conversational**: Maintain a natural, helpful tone. Acknowledge context from earlier in the conversation.\n8. **Recall past work**:\n - Broad queries (\"之前做了什么\", \"列出任务\"): answer from the \"Recent Task History\" section already in your context. If insufficient, call session_list.\n - Specific lookups (\"上次那个配置怎么改的\"): call session_search with relevant phrases (NOT single characters).\n - Do NOT call search tools repeatedly with keyword fragments. One well-phrased call is better than ten fragmented ones.\n\n## Coding & Verification\nWhen working with code, prefer the precise built-in tools over ad-hoc shell: use `repo_map` to orient in an\nunfamiliar project, `code_search`/`file_find` to locate and `code_intel` for symbols, `edit_file` (anchored\nsearch-replace, or a unified `diff`) to change files — never rewrite a whole file to change a few lines — and\n`git_query` to inspect diffs/log. After edits, check `syntax_ok` in the result and run `test_run`/`lint_check`\nbefore declaring the work done. Batch independent read-only calls (search/read/query) into a single turn — they\nrun in parallel.\n\n## Presentation Style\n1. **Polished Markdown only**: Format user-facing answers with clean Markdown headings, short paragraphs, and concise bullets. Use tables only when they improve comparison or scanning.\n2. **Terminal-friendly layout**: Keep lines readable in a TUI; avoid dense walls of text, deeply nested lists, oversized ASCII art, or heavy visual blocks.\n3. **Elegant emphasis**: Use bold text sparingly for key terms and conclusions. Avoid excessive emojis, decorative symbols, repeated separators, or visual noise.\n4. **Theme-safe colors**: Do not emit ANSI escape codes, HTML color tags, Rich markup, or hardcoded color names. Rely on the TUI theme to render Markdown professionally.\n5. **No leaked tool protocol**: Never show tool-call JSON, internal schemas, raw observations, tool result payloads, or hidden reasoning in the final answer unless the user explicitly asks for raw/debug output. Treat any prior `{\"name\": ..., \"arguments\": ...}` blocks and `Tool result (...)` messages as internal execution context only.\n6. **Professional closure**: End with a concise conclusion or next step when helpful; avoid rambling after the useful answer is complete.\n\nWhen finished with all tool calls, respond normally without a JSON block, tool-call transcript, or process log.\n\n\n## Recent Session Summary\n- [assistant] Noted the second step.\n- [user] Thanks, that is all.\n\n## Task Contract\n- Task ID: turn-3\n- Original user request: Thanks, that is all.\n- Workspace root: \n- Allowed roots: \n- Treat relative project paths as relative to the workspace root; never infer `.` as the project root when a workspace root is provided.\n- Workspace boundary is enforced by tools: do not read, search, edit, or run commands against paths outside the allowed roots unless the user explicitly requests an external path and the tool/approval policy permits it.\n- LeapFlow workspace config is optional at `/.leapflow/config.yaml`; runtime config is loaded from `~/.leapflow/config/user.yaml` and `~/.leapflow/profiles//config/*.yaml`.\n- Preserve this task contract across summarization, compression, tool loops, and memory retrieval.\n" + }, + { + "role": "assistant", + "content": "Noted the second step." + }, + { + "role": "user", + "content": "Thanks, that is all.\nThanks, that is all." + }, + { + "role": "assistant", + "content": "{\"title\": \"Tidy invoices\", \"trigger_phrases\": [\"tidy invoices\", \"sort invoices\"], \"steps\": [\"List the invoice folder\", \"Classify by month\", \"Move into folders\"], \"parameters\": [{\"name\": \"path\", \"description\": \"invoice folder\"}], \"pre_conditions\": [], \"confidence\": 0.7}" + }, + { + "role": "user", + "content": "Tool result (path):\n{\"ok\": false, \"error\": \"Unknown tool: path\", \"error_type\": \"unknown_tool\", \"retryable\": true}\nSYSTEM: The previous tool call used an unavailable tool name. Original tool: path. Resolution: unknown (no exact canonical tool name match). Suggested canonical tools: assess_compatibility, capability_expand, code_intel, code_search, config_get. Available tools include: assess_compatibility, capability_expand, code_intel, code_search, config_get, config_list, config_set, delegate_task, edit_file, env_info, file_find, file_list. Retry once using an exact canonical tool name from the available list and valid arguments. Do not invent tool names, use aliases, or infer a tool from argument shape; answer without a tool if no exact tool fits." + } + ], + "tools": [ + "capability_expand", + "code_intel", + "code_search", + "config_get", + "config_list", + "env_info", + "file_find", + "file_list", + "file_read", + "git_query", + "lint_check", + "memory_search", + "plugin_list", + "plugin_propose", + "plugin_status", + "plugin_versions", + "repo_map", + "research_note", + "schedule_reentry", + "session_list", + "skill_view", + "skills_list", + "terminal_list", + "terminal_read", + "test_run", + "text_search", + "time_get", + "web_fetch" + ] + }, + "responses": [ + { + "status": 200, + "content_type": "application/json", + "body": "{\"id\": \"chatcmpl-cassette\", \"object\": \"chat.completion\", \"model\": \"cassette-model\", \"choices\": [{\"index\": 0, \"message\": {\"role\": \"assistant\", \"content\": \"Done \\u2014 nothing further needed.\"}, \"finish_reason\": \"stop\"}], \"usage\": {\"prompt_tokens\": 64, \"completion_tokens\": 16, \"total_tokens\": 80}}" + } + ] +} diff --git a/tests/_fixtures/cassettes/r6_lifecycle/cassette-model-1a6523e406d0dbee.cassette.json b/tests/_fixtures/cassettes/r6_lifecycle/cassette-model-1a6523e406d0dbee.cassette.json new file mode 100644 index 0000000..264dd7e --- /dev/null +++ b/tests/_fixtures/cassettes/r6_lifecycle/cassette-model-1a6523e406d0dbee.cassette.json @@ -0,0 +1,71 @@ +{ + "fingerprint": "1a6523e406d0dbee271c561905e35ade260ee48bd8dae3d618f81aa036373620", + "note": "captured in seed mode", + "request": { + "model": "cassette-model", + "stream": false, + "messages": [ + { + "role": "system", + "content": "You are LeapFlow, an intelligent assistant that can both converse naturally and take real actions on the user's computer.\n\n## Capabilities\nThe tool index below lists **every** registered tool by name and a one-line summary — this is the complete\ncapability contract; nothing else exists. Only a subset is directly callable this turn (via native tool calling,\nnot a JSON block in your reply). If you need a tool from the index that is not yet callable, call\n`capability_expand` with its category name first — the matching tools become callable immediately after.\nWhen the user asks what LeapFlow itself supports, whether it supports plugins, or which runtime capabilities\nare available, use the live capability evidence exposed by `plugin_list` before making capability claims; report\nconfiguration-dependent or unavailable capabilities as limitations instead of inferring from documentation.\n- **text_search**(text, pattern): Search for a regex pattern in text.\n- **text_replace**(text, old, new, count) [capability_expand category: write]: Replace occurrences of a substring in text.\n- **time_get**(): Get current date and time.\n- **env_info**(): Get system environment information (OS, Python version, cwd).\n- **skills_list**(query, category, source): List available learned skills. Use when user asks about capabilities or you need a specific skill.\n- **skill_view**(name): View the full content of a specific skill document.\n- **code_intel**(path, operation): Precise document symbols (outline) for a source file: classes, functions, and methods with line ranges. Python uses an exact AST parse; other languages use a keyword-prefix scan. Prefer over file_read mode=symbols for accurate navigation before editing. Read-only.\n- **repo_map**(path): Compact project orientation for a repository root: languages, detected test/lint commands, top-level structure, entry points, manifest, and VCS branch. Call this first when entering an unfamiliar codebase. Read-only.\n- **scm_sync**(action, cwd, remote, pull_ref, push_ref, timeout) [capability_expand category: scm]: Run a typed git SCM action. Use this instead of shell_run for git pull/push/status. For 'pull origin main then push', set action='pull_then_push', remote='origin', pull_ref='main', and omit push_ref so LeapFlow pushes the current local branch.\n- **git_query**(action, cwd, ref, path, staged, max_count, stat): Read-only structured git inspection: action=diff|log|status|branch|show. Prefer over shell_run for reading repo state — output is clipped, redacted, and log/branch are parsed into structured fields. Use scm_sync for pull/push.\n- **git_write**(action, cwd, message, stage_all, name, ref, create) [capability_expand category: scm]: Mutating git actions: action=commit (message, stage_all), branch (create+switch), checkout (switch; create=true for -b). Approval-gated. Use scm_sync for pull/push and git_query for reads.\n- **test_run**(command, cwd, timeout): Run the project's test suite and return structured results (framework, passed/failed counts, failing tests). Auto-detects the runner (pytest/npm/go/cargo) or uses a configured/explicit command; executes via the governed shell. ok=true means the runner executed — see 'success' for pass/fail.\n- **lint_check**(command, cwd, timeout): Run the project's linter and return a structured clean/issue result. Auto-detects the linter (ruff/eslint/go vet/clippy) or uses a configured/explicit command; executes via the governed shell. ok=true means the linter ran — see 'clean'.\n- **file_list**(path, pattern, depth): List files and directories at a given path. Use depth=1 or depth=2 to get a recursive tree in one call instead of listing each sub-directory separately.\n- **file_read**(path, max_lines, start_line, max_chars, mode): Read text file content with adaptive context governance. For large or unfamiliar files, prefer mode='outline' or mode='symbols' first, then use mode='raw' with start_line/max_lines for the specific range you actually need. For LeapFlow's own settings, use config_list / config_get / config_set — its config files are outside the workspace and not readable here.\n- **file_write**(path, content, mode) [capability_expand category: write]: Write content to a file (overwrite or append).\n- **code_search**(pattern, patterns, path, glob, ignore_case, multiline, max_results, context_lines): Search file CONTENTS by regex pattern across a directory tree (ripgrep-backed). Requires a regex pattern. NOT for listing or browsing directory contents — use file_list for that. Prefer this over shell_run grep: faster, skips VCS/dependency/build dirs, and returns structured path:line:column matches. Batch related lookups into ONE call via `patterns` (OR-combined, single pass) instead of issuing several separate searches. Use file_read for the surrounding context of a hit.\n- **file_find**(glob, path, max_results): Find files by a recursive glob pattern under a base path (e.g. '**/test_*.py' or '*.md'). Prefer this over shell_run find; skips VCS/dependency/build dirs.\n- **edit_file**(path, edits, dry_run, diff) [capability_expand category: write]: Apply targeted, anchored search-replace edits to an EXISTING text file (use file_write to create/overwrite). Each edit is {original_text, new_text, replace_all?}; original_text must match exactly and uniquely (or set replace_all) — a non-unique or missing anchor is rejected so files are never corrupted. Set dry_run to preview. Alternatively pass a unified 'diff' to apply its hunks as anchored edits. Far cheaper and safer than rewriting a whole file.\n- **shell_run**(command, cwd, timeout) [capability_expand category: shell]: Execute a one-shot shell command with timeout protection. Runs in the active workspace; paths resolving outside it are refused. Reach for a structured tool first when one fits — web_fetch for anything over HTTP(S), git_query/scm_sync for git, code_search/file_find/file_read for the repo, config_get/config_set for LeapFlow's own settings — because those report typed results, while a failed shell command can only be diagnosed from its exit code and stderr. Every shell run counts as an external side effect, so a failure stops the rest of the batch and is not retried automatically.\n- **terminal_open**(command, cwd, shell) [capability_expand category: terminal]: Open a PERSISTENT shell session (REPL/dev server/watch), returning a session_id for terminal_send/read/close. Disabled unless tools.terminal_session_enabled is set. For one-shot commands use shell_run instead.\n- **terminal_send**(session_id, input, wait) [capability_expand category: terminal]: Send a line of input to a persistent terminal session and return output captured shortly after.\n- **terminal_read**(session_id, wait): Drain buffered output from a persistent terminal session (optionally waiting briefly first).\n- **terminal_close**(session_id) [capability_expand category: terminal]: Terminate a persistent terminal session and release its process group.\n- **terminal_list**(): List active persistent terminal sessions.\n- **config_list**(category, limit): List LeapFlow's own writable settings (model, provider, daemon, memory, perception, gateway, …) with current values. Use this to discover the exact key before changing anything. Optionally narrow by `category`. This is the only correct way to inspect LeapFlow configuration — never read config files from disk.\n- **config_get**(key): Read one LeapFlow setting by key (e.g. 'llm.model', 'daemon.log_level'), returning its current value, type, scopes, and whether a change needs a daemon restart. Never read LeapFlow config files from disk — use this.\n- **config_set**(key, value, scope) [capability_expand category: config]: Change one LeapFlow setting by key, e.g. switch the model with key='llm.model'. Values are validated and coerced; credentials are stored in the vault automatically. Call config_list or config_get first if unsure of the key. The result states whether a `leap daemon restart` is required. Never edit LeapFlow config files directly.\n- **web_fetch**(url, select, timeout, max_bytes): Read a URL over HTTP(S) and get back extracted, context-sized content: parsed JSON for API endpoints, readable text plus links for web pages. Use this for anything on the internet — prices, docs, releases, articles — instead of running curl through shell_run: it reports real HTTP status codes, retries rate limits on its own, and is a plain read so a retry is always safe. For JSON APIs pass `select` with a dotted path (e.g. 'chart.result.0.meta') to return just that part instead of the whole payload.\n- **memory_search**(query, limit): Search agent memory for relevant past experiences, observations, and facts.\n- **memory_add**(content, kind) [capability_expand category: write]: Store a new observation or insight in memory for future reference.\n- **research_note**(kind, text): Record a compact, structured note about the current task's state so it survives context compression on long / multi-step tasks. Use for durable findings, open questions still to resolve, decisions / excluded paths, and the immediate next step. One concise sentence per note.\n- **capability_expand**(category): Fetch the full callable schema for every tool in a capability category (e.g. 'hub', 'gateway', 'desktop', '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.\n- **delegate_task**(goal, context) [capability_expand category: delegate]: Delegate a complex sub-task to an isolated subagent. The subagent gets a fresh context and restricted tool access. Use when a task is self-contained and can be solved independently.\n- **schedule_reentry**(kind, reason, delay_seconds, event_match, max_reentries, deadline_seconds): Register a re-entry so this task can resume later from its current orientation (findings / open questions / next step). Use when work must pause and continue after a delay (kind=time) or when a matching platform event arrives (kind=event), instead of finishing now. The research-ledger state is carried over automatically.\n- **hub_push**(skill_name, visibility, version) [capability_expand category: hub]: Push a local skill to the ModelScope Hub for sharing or backup.\n- **hub_pull**(repo_id, version) [capability_expand category: hub]: Pull a skill from the ModelScope Hub to install locally.\n- **hub_search**(query) [capability_expand category: hub]: Search for skills on the Hub by keyword or description.\n- **hub_sync**(mode, dry_run) [capability_expand category: hub]: Preview or execute sync between local skills and Hub.\n- **platform_action**(platform, action, payload, backend_kind) [capability_expand category: gateway]: Execute an exact registered business action on an external platform through LeapFlow's App Connector layer. Actions must be copied from the App Connector Capability Index and are addressed as domain.operation, e.g. im.send_message or docs.create_markdown. All business fields (chat_id, text, query, etc.) MUST be placed inside `payload`, never at the top level. Example: {\"platform\":\"feishu\",\"action\":\"im.send_message\",\"payload\":{\"chat_id\":\"oc_xxx\",\"text\":\"hello\"}}. Do not invent action names, do not use management actions such as list/guide/connect/status here.\n- **platform_connect**(action, platform, credentials, options, checkpoint) [capability_expand category: gateway]: List, guide, connect, disconnect, remove, or check status for external platforms using the App Connector management namespace. Supports REST and CLI backends. Use this for management actions such as list/guide/preflight/connect/status; use platform_action only for exact registered business actions.\n- **gateway_send**(platform, chat_id, text, thread_id) [capability_expand category: gateway]: Send a message to a connected external platform (Feishu group, Telegram chat, DingTalk conversation, etc.). Requires the platform to be connected via gateway_connect first. Use gateway_connect with action='list' to see connected platforms and available chat IDs.\n- **gateway_connect**(action, platform, credentials, options) [capability_expand category: gateway]: Connect, configure, or manage external platform integrations (Feishu, DingTalk, Telegram, Slack, Discord, etc.). Conversational flow: 1) call 'guide' to get setup steps + required fields, 2) present the steps to the user and ask for ALL required credentials in a single message, 3) call 'connect' with the credentials. Goal: complete in 1–2 user turns. NEVER include credential values in your text response.\n- **plugin_list**(): List the live plugin registry and cross-subsystem capability evidence. Use this before answering questions about whether LeapFlow supports plugins, self-evolution, plugin installation, hot reload, versioning, or other runtime capabilities.\n- **plugin_status**(plugin_id): Get detailed status of a specific plugin: its declared category, runtime dependencies, contributed tools, and fiber lifecycle state.\n- **plugin_versions**(plugin_id): List recorded source versions and active pointer for a profile-scoped plugin.\n- **plugin_propose**(requested_capability, plugin_id, proposed_tools, test_cases, risk_level, evidence): Create a side-effect-free PluginProposal from explicit capability-gap evidence. Use this before plugin_generate when a missing capability should be reviewed. Does not call an LLM, write files, or install anything.\n- **assess_compatibility**(manifest, source_path) [capability_expand category: plugin_management]: Assess whether a foreign plugin manifest or real DSH source bundle is compatible with LeapFlow. A source bundle assessment is static: runtime_ready stays false until restricted Node discovery during plugin_install. Returns component-level verdicts and limitations.\n- **plugin_generate**(plugin_id, description, proposal_id) [capability_expand category: system]: Generate a new ToolPlugin from a natural-language capability description. The LLM produces code that conforms to the ToolPlugin Protocol; it is then rigorously validated (syntax, structure, import, protocol conformance). The isolated sandbox smoke test runs later, at install-time. Returns the validated code but DOES NOT install it — installation is a separate approval-gated step via plugin_install.\n- **plugin_install**(plugin_id, code, marketplace_name, source_path, proposal_id, version_label) [capability_expand category: system]: Install a plugin from exactly one source: validated Python code, the configured Python marketplace, or a real DSH source bundle. DSH bundles run restricted Node discovery before registration; only public host tools are installed and client UI limitations are reported. Writes profile-scoped state and mutates the process-global registry. REQUIRES APPROVAL.\n- **plugin_rollback**(plugin_id, version) [capability_expand category: system]: Rollback a profile-scoped plugin to a recorded source version and reload it. REQUIRES APPROVAL.\n- **plugin_reload**(plugin_id, version_label) [capability_expand category: system]: Hot-reload a plugin at runtime. Disposes the old plugin fiber, re-imports its module, and registers a fresh instance. Existing in-flight turns are unaffected (snapshot isolation). REQUIRES APPROVAL — this is a self-modification action.\n- **plugin_disable**(plugin_id) [capability_expand category: system]: Disable a plugin by disposing its fiber, removing its tools from the runtime registry. Cannot disable self_management itself. REQUIRES APPROVAL — this is a self-modification action.\n- **plugin_remove**(plugin_id, delete_source) [capability_expand category: system]: Terminally remove a plugin: dispose its fiber, unregister its tools, remove reload metadata, and optionally delete its profile-scoped source file. Cannot remove self_management itself. REQUIRES APPROVAL.\n- **plugin_enable**(plugin_id) [capability_expand category: system]: Re-enable a previously disabled plugin by reloading its module and registering a fresh instance. REQUIRES APPROVAL.\n- **fixture_echo**(text) [capability_expand category: bridge]: Return the supplied text from a pre-built DSH package.\n- **session_search**(query, limit) [capability_expand category: unclassified]: Search past conversation sessions in the current workspace for relevant context.\n- **session_list**(limit): List recent conversation sessions in the current workspace with stable ids, titles, dates, and summaries. Use for browsing past tasks or when user asks to see history without specific search terms. No keywords needed — returns chronological list.\n- **session_detail**(session_id, limit, offset, include_inactive) [capability_expand category: unclassified]: Read a paginated persisted transcript for one past conversation session in the current workspace. Use after session_list or session_search returns a session_id.\n- **click**(element_index) [capability_expand category: desktop]: Click a UI element by its element_index (from the latest observe_ui snapshot)\n- **get_clipboard**() [capability_expand category: desktop]: Read current clipboard text content\n- **list_apps**(filter, running_only) [capability_expand category: desktop]: List available applications on this system. Use to discover correct bundle_id before switch_app.\n- **list_windows**() [capability_expand category: desktop]: List all top-level windows with pid, window_id, title, and per-window state (minimized, on-screen). Call this first to pick the pid and window_id that observe_ui and other window tools require.\n- **observe_ui**(pid, window_id, query) [capability_expand category: desktop]: Snapshot one window's actionable UI elements, each tagged with an element_index for click/right_click/read_text. Re-observe after actions — indices belong to one snapshot. Requires the window's pid and window_id from list_windows.\n- **open_url**(url, app_id) [capability_expand category: desktop]: Open a URL in the default or specified browser\n- **read_text**(element_index) [capability_expand category: desktop]: Read the text content of a specific UI element from the latest snapshot\n- **right_click**(element_index) [capability_expand category: desktop]: Right-click a UI element to open its context menu. Returns visible menu items.\n- **screenshot**(pid, window_id) [capability_expand category: desktop]: Capture a screenshot for visual verification. With pid + window_id captures that window (works across all displays); defaults to the last observed window, or the full desktop when no window has been observed.\n- **scroll**(element_index, direction, amount, pid, window_id) [capability_expand category: desktop]: Scroll a scrollable area of a window. Omit element_index to scroll the window's focused/page scroller; pass one to scroll an exact element from the latest snapshot.\n- **select_text**(element_index) [capability_expand category: desktop]: Select all text in a UI element (focus + select-all, for subsequent copy)\n- **set_clipboard**(text) [capability_expand category: desktop]: Write text to the clipboard\n- **shortcut**(keys) [capability_expand category: desktop]: Execute a keyboard shortcut\n- **switch_app**(app_id) [capability_expand category: desktop]: Switch to an app (launch if needed, activate, verify)\n- **type_text**(text) [capability_expand category: desktop]: Type text into the currently focused element\n- **wait**(seconds) [capability_expand category: desktop]: Wait for a specified duration before continuing\n- **wait_until**(condition, pid, window_id, timeout, poll_interval) [capability_expand category: desktop]: Wait until a UI condition is met (polls UI tree). Returns elements when found or on timeout.\n- **wait_until_stable**(timeout, poll_interval, pid, window_id) [capability_expand category: desktop]: Wait until the UI stops changing (element set stabilizes across polls).\n\n## App Connector Capability Index\nLeapFlow can onboard and manage external apps through `platform_connect` and execute exact registered business actions through `platform_action`.\nFor requests about connecting, setting up, configuring, enabling, or managing a supported app, use `platform_connect` first instead of generating SDK/Webhook sample code.\n`platform_connect.action` is the management namespace: list, guide, preflight, connect, disconnect, remove, status, events_start, events_stop, events_status.\n`platform_action.action` is only for exact registered platform business actions listed below, such as `im.send_message`; never use management actions like `list` or `guide` there.\nAll business fields MUST go inside `payload`; top-level keys are only `platform`, `action`, and `payload`.\nDo not invent platform IDs or platform action names. If the needed action is not listed, ask for discovery/clarification instead of guessing.\nUse `platform_connect` with `action='guide'` and the matching `platform` to start onboarding; use `action='list'` when the app is unclear.\nWhen a pending onboarding state is present, continue from that state with `platform_connect` instead of asking the user to restate the app.\nSupported apps:\n- `api_server`: API Server (OpenAI Compatible) (category=api; backend=adapter; platform_action actions=none registered)\n- `dingtalk`: 钉钉 (DingTalk) (category=im; backend=adapter; platform_action actions=none registered)\n- `feishu`: 飞书 (Feishu/Lark) (category=im; backend=cli)\n - `calendar.create_event` payload={summary*, start_time*, end_time*, attendees} [write/high]\n - `docs.create_markdown` payload={title*, markdown*, folder_token} [write/medium]\n - `drive.search` payload={query*, limit} [read/medium]\n - `im.add_reaction` payload={message_id*, emoji_type*} [write/low]\n - `im.download_resource` payload={message_id*, file_key*, type*} [read/low]\n - `im.get_messages` payload={message_ids*} [read/low]\n - `im.list_chats` payload={page_size} [read/low]\n - `im.list_messages` payload={chat_id*} [read/low]\n - `im.list_thread_messages` payload={thread*} [read/low]\n - `im.remove_reaction` payload={message_id*, emoji_type*} [write/low]\n - `im.reply_message` payload={message_id*, text*} [send/high]\n - `im.search_chats` payload={query*, page_size} [read/low]\n - `im.search_messages` payload={query*} [read/medium]\n - `im.send_message` payload={chat_id*, text*, thread_id} [send/high]\n - `im.update_card` payload={token*, card*} [write/medium]\n - `im.update_message` payload={message_id*, text*} [write/medium]\n - `mail.search_unread` payload={query, limit} [read/high]\n - `sheets.append_row` payload={spreadsheet_token*, sheet_id*, values*} [write/medium]\n - `task.create` payload={title*, description, due_time} [write/medium]\n- `telegram`: Telegram (category=social; backend=adapter; platform_action actions=none registered)\n- `webhook`: Webhook (Generic) (category=webhook; backend=adapter; platform_action actions=none registered)\n\n## Tool Usage\nTools are normally invoked through the native function-calling mechanism, not by writing JSON in your reply\ntext. Only if the provider signals that native function calling is unavailable for this turn, fall back to a\nsingle JSON code block: `{\"name\": \"tool_name\", \"arguments\": {\"key\": \"value\"}}` — use this fallback format\nonly, never both. Only call a tool whose exact name appears in the tool index above and is currently callable\n(or reachable via `capability_expand`). Never invent, rename, alias, or guess a tool name, platform ID, or\nplatform action from argument shape or wording — if the index does not list it, it does not exist.\n`platform_connect.action` (list/guide/preflight/connect/disconnect/remove/status/events_start/events_stop/\nevents_status) is the App Connector management namespace; `platform_action.action` only accepts exact\nregistered business actions such as `im.send_message` shown in the App Connector Capability Index — never mix\nthe two namespaces. If a tool call returns an unknown/unavailable result, use the returned suggestions or\navailable names for a single retry instead of trying further variations of the same guess.\n\n**Side-effect action rule** (`platform_action` with effect=send/write/execute):\n- Call each unique action+payload **exactly once**. Never include duplicates in the same turn.\n- Once the result returns `\"completed\": true`, that action is DONE for this task. Do NOT call it again\n in any subsequent turn — immediately summarize the result for the user instead.\n- The system enforces idempotency: duplicate calls are blocked and will not execute.\n- If the user explicitly requests sending/writing multiple times, use distinct payloads per call.\n\n**Resource identifier provenance rule**:\n- NEVER fabricate, guess, or infer resource identifiers (chat_id, message_id, file_key, user_id, etc.).\n Every resource ID used in a side-effect action MUST come from a successful API response in this session.\n- If a read/list action fails (e.g. authorization error), you do NOT have valid resource IDs.\n Report the failure to the user — do NOT attempt the dependent write/send action with a guessed ID.\n- When a tool result contains `\"llm_instruction\"`, follow it exactly.\n\n## Guidelines\n1. **Direct answers first**: If you already know the answer, respond directly without tools.\n2. **Avoid redundant tool calls**: Do not call the same tool with the same arguments more than once in the same user turn. When an existing tool result already answers the user's request, stop calling tools and answer directly.\n3. **Use tools proactively**: When the user asks about files, time, system state, or needs actions performed, use the appropriate tool.\n4. **Chain tools when needed**: You can call multiple tools in sequence (e.g., list files → read file → summarize).\n5. **Handle failures gracefully**: If a tool fails, explain what went wrong and suggest alternatives. If it failed because of an unknown tool/platform/action name, retry once with an exact name from the returned suggestions, then explain rather than keep guessing.\n6. **Summarize results naturally**: After tool execution, synthesize the results into a helpful answer rather than dumping raw output.\n7. **Stay conversational**: Maintain a natural, helpful tone. Acknowledge context from earlier in the conversation.\n8. **Recall past work**:\n - Broad queries (\"之前做了什么\", \"列出任务\"): answer from the \"Recent Task History\" section already in your context. If insufficient, call session_list.\n - Specific lookups (\"上次那个配置怎么改的\"): call session_search with relevant phrases (NOT single characters).\n - Do NOT call search tools repeatedly with keyword fragments. One well-phrased call is better than ten fragmented ones.\n\n## Coding & Verification\nWhen working with code, prefer the precise built-in tools over ad-hoc shell: use `repo_map` to orient in an\nunfamiliar project, `code_search`/`file_find` to locate and `code_intel` for symbols, `edit_file` (anchored\nsearch-replace, or a unified `diff`) to change files — never rewrite a whole file to change a few lines — and\n`git_query` to inspect diffs/log. After edits, check `syntax_ok` in the result and run `test_run`/`lint_check`\nbefore declaring the work done. Batch independent read-only calls (search/read/query) into a single turn — they\nrun in parallel.\n\n## Presentation Style\n1. **Polished Markdown only**: Format user-facing answers with clean Markdown headings, short paragraphs, and concise bullets. Use tables only when they improve comparison or scanning.\n2. **Terminal-friendly layout**: Keep lines readable in a TUI; avoid dense walls of text, deeply nested lists, oversized ASCII art, or heavy visual blocks.\n3. **Elegant emphasis**: Use bold text sparingly for key terms and conclusions. Avoid excessive emojis, decorative symbols, repeated separators, or visual noise.\n4. **Theme-safe colors**: Do not emit ANSI escape codes, HTML color tags, Rich markup, or hardcoded color names. Rely on the TUI theme to render Markdown professionally.\n5. **No leaked tool protocol**: Never show tool-call JSON, internal schemas, raw observations, tool result payloads, or hidden reasoning in the final answer unless the user explicitly asks for raw/debug output. Treat any prior `{\"name\": ..., \"arguments\": ...}` blocks and `Tool result (...)` messages as internal execution context only.\n6. **Professional closure**: End with a concise conclusion or next step when helpful; avoid rambling after the useful answer is complete.\n\nWhen finished with all tool calls, respond normally without a JSON block, tool-call transcript, or process log.\n\n\n## Recent Session Summary\n- [user] Invoke fixture_echo with the text after restart.\n\n## Task Contract\n- Task ID: turn-1\n- Original user request: Invoke fixture_echo with the text after restart.\n- Workspace root: \n- Allowed roots: \n- Treat relative project paths as relative to the workspace root; never infer `.` as the project root when a workspace root is provided.\n- Workspace boundary is enforced by tools: do not read, search, edit, or run commands against paths outside the allowed roots unless the user explicitly requests an external path and the tool/approval policy permits it.\n- LeapFlow workspace config is optional at `/.leapflow/config.yaml`; runtime config is loaded from `~/.leapflow/config/user.yaml` and `~/.leapflow/profiles//config/*.yaml`.\n- Preserve this task contract across summarization, compression, tool loops, and memory retrieval.\n" + }, + { + "role": "user", + "content": "Invoke fixture_echo with the text after restart.\nInvoke fixture_echo with the text after restart." + }, + { + "role": "assistant", + "content": "", + "tool_calls": [ + "fixture_echo" + ] + }, + { + "role": "tool", + "content": "{\"ok\": true, \"echo\": \"after restart\", \"execution_id\": \"\", \"idempotency_key\": \"\", \"execution_policy\": \"read_only\", \"tool_call_id\": \"call_1\", \"execution_status\": \"completed\"}", + "tool_result": true + }, + { + "role": "assistant", + "content": "Operation interrupted. Continuing..." + } + ], + "tools": [ + "capability_expand", + "code_intel", + "code_search", + "config_get", + "config_list", + "env_info", + "file_find", + "file_list", + "file_read", + "git_query", + "lint_check", + "memory_search", + "plugin_list", + "plugin_propose", + "plugin_status", + "plugin_versions", + "repo_map", + "research_note", + "schedule_reentry", + "session_list", + "skill_view", + "skills_list", + "terminal_list", + "terminal_read", + "test_run", + "text_search", + "time_get", + "web_fetch" + ] + }, + "responses": [ + { + "status": 200, + "content_type": "application/json", + "body": "{\"id\": \"chatcmpl-cassette\", \"object\": \"chat.completion\", \"model\": \"cassette-model\", \"choices\": [{\"index\": 0, \"message\": {\"role\": \"assistant\", \"content\": \"The DSH tool ran after restart.\"}, \"finish_reason\": \"stop\"}], \"usage\": {\"prompt_tokens\": 64, \"completion_tokens\": 16, \"total_tokens\": 80}}" + } + ] +} diff --git a/tests/_fixtures/cassettes/r6_lifecycle/cassette-model-1cfa198b039a6f33.cassette.json b/tests/_fixtures/cassettes/r6_lifecycle/cassette-model-1cfa198b039a6f33.cassette.json new file mode 100644 index 0000000..9ea983f --- /dev/null +++ b/tests/_fixtures/cassettes/r6_lifecycle/cassette-model-1cfa198b039a6f33.cassette.json @@ -0,0 +1,80 @@ +{ + "fingerprint": "1cfa198b039a6f33428e3fa2a1ebbfec8579074c19381de63999f1e78e7c3e71", + "note": "captured in seed mode", + "request": { + "model": "cassette-model", + "stream": false, + "messages": [ + { + "role": "system", + "content": "You are LeapFlow, an intelligent assistant that can both converse naturally and take real actions on the user's computer.\n\n## Capabilities\nThe tool index below lists **every** registered tool by name and a one-line summary — this is the complete\ncapability contract; nothing else exists. Only a subset is directly callable this turn (via native tool calling,\nnot a JSON block in your reply). If you need a tool from the index that is not yet callable, call\n`capability_expand` with its category name first — the matching tools become callable immediately after.\nWhen the user asks what LeapFlow itself supports, whether it supports plugins, or which runtime capabilities\nare available, use the live capability evidence exposed by `plugin_list` before making capability claims; report\nconfiguration-dependent or unavailable capabilities as limitations instead of inferring from documentation.\n- **text_search**(text, pattern): Search for a regex pattern in text.\n- **text_replace**(text, old, new, count) [capability_expand category: write]: Replace occurrences of a substring in text.\n- **time_get**(): Get current date and time.\n- **env_info**(): Get system environment information (OS, Python version, cwd).\n- **skills_list**(query, category, source): List available learned skills. Use when user asks about capabilities or you need a specific skill.\n- **skill_view**(name): View the full content of a specific skill document.\n- **code_intel**(path, operation): Precise document symbols (outline) for a source file: classes, functions, and methods with line ranges. Python uses an exact AST parse; other languages use a keyword-prefix scan. Prefer over file_read mode=symbols for accurate navigation before editing. Read-only.\n- **repo_map**(path): Compact project orientation for a repository root: languages, detected test/lint commands, top-level structure, entry points, manifest, and VCS branch. Call this first when entering an unfamiliar codebase. Read-only.\n- **scm_sync**(action, cwd, remote, pull_ref, push_ref, timeout) [capability_expand category: scm]: Run a typed git SCM action. Use this instead of shell_run for git pull/push/status. For 'pull origin main then push', set action='pull_then_push', remote='origin', pull_ref='main', and omit push_ref so LeapFlow pushes the current local branch.\n- **git_query**(action, cwd, ref, path, staged, max_count, stat): Read-only structured git inspection: action=diff|log|status|branch|show. Prefer over shell_run for reading repo state — output is clipped, redacted, and log/branch are parsed into structured fields. Use scm_sync for pull/push.\n- **git_write**(action, cwd, message, stage_all, name, ref, create) [capability_expand category: scm]: Mutating git actions: action=commit (message, stage_all), branch (create+switch), checkout (switch; create=true for -b). Approval-gated. Use scm_sync for pull/push and git_query for reads.\n- **test_run**(command, cwd, timeout): Run the project's test suite and return structured results (framework, passed/failed counts, failing tests). Auto-detects the runner (pytest/npm/go/cargo) or uses a configured/explicit command; executes via the governed shell. ok=true means the runner executed — see 'success' for pass/fail.\n- **lint_check**(command, cwd, timeout): Run the project's linter and return a structured clean/issue result. Auto-detects the linter (ruff/eslint/go vet/clippy) or uses a configured/explicit command; executes via the governed shell. ok=true means the linter ran — see 'clean'.\n- **file_list**(path, pattern, depth): List files and directories at a given path. Use depth=1 or depth=2 to get a recursive tree in one call instead of listing each sub-directory separately.\n- **file_read**(path, max_lines, start_line, max_chars, mode): Read text file content with adaptive context governance. For large or unfamiliar files, prefer mode='outline' or mode='symbols' first, then use mode='raw' with start_line/max_lines for the specific range you actually need. For LeapFlow's own settings, use config_list / config_get / config_set — its config files are outside the workspace and not readable here.\n- **file_write**(path, content, mode) [capability_expand category: write]: Write content to a file (overwrite or append).\n- **code_search**(pattern, patterns, path, glob, ignore_case, multiline, max_results, context_lines): Search file CONTENTS by regex pattern across a directory tree (ripgrep-backed). Requires a regex pattern. NOT for listing or browsing directory contents — use file_list for that. Prefer this over shell_run grep: faster, skips VCS/dependency/build dirs, and returns structured path:line:column matches. Batch related lookups into ONE call via `patterns` (OR-combined, single pass) instead of issuing several separate searches. Use file_read for the surrounding context of a hit.\n- **file_find**(glob, path, max_results): Find files by a recursive glob pattern under a base path (e.g. '**/test_*.py' or '*.md'). Prefer this over shell_run find; skips VCS/dependency/build dirs.\n- **edit_file**(path, edits, dry_run, diff) [capability_expand category: write]: Apply targeted, anchored search-replace edits to an EXISTING text file (use file_write to create/overwrite). Each edit is {original_text, new_text, replace_all?}; original_text must match exactly and uniquely (or set replace_all) — a non-unique or missing anchor is rejected so files are never corrupted. Set dry_run to preview. Alternatively pass a unified 'diff' to apply its hunks as anchored edits. Far cheaper and safer than rewriting a whole file.\n- **shell_run**(command, cwd, timeout) [capability_expand category: shell]: Execute a one-shot shell command with timeout protection. Runs in the active workspace; paths resolving outside it are refused. Reach for a structured tool first when one fits — web_fetch for anything over HTTP(S), git_query/scm_sync for git, code_search/file_find/file_read for the repo, config_get/config_set for LeapFlow's own settings — because those report typed results, while a failed shell command can only be diagnosed from its exit code and stderr. Every shell run counts as an external side effect, so a failure stops the rest of the batch and is not retried automatically.\n- **terminal_open**(command, cwd, shell) [capability_expand category: terminal]: Open a PERSISTENT shell session (REPL/dev server/watch), returning a session_id for terminal_send/read/close. Disabled unless tools.terminal_session_enabled is set. For one-shot commands use shell_run instead.\n- **terminal_send**(session_id, input, wait) [capability_expand category: terminal]: Send a line of input to a persistent terminal session and return output captured shortly after.\n- **terminal_read**(session_id, wait): Drain buffered output from a persistent terminal session (optionally waiting briefly first).\n- **terminal_close**(session_id) [capability_expand category: terminal]: Terminate a persistent terminal session and release its process group.\n- **terminal_list**(): List active persistent terminal sessions.\n- **config_list**(category, limit): List LeapFlow's own writable settings (model, provider, daemon, memory, perception, gateway, …) with current values. Use this to discover the exact key before changing anything. Optionally narrow by `category`. This is the only correct way to inspect LeapFlow configuration — never read config files from disk.\n- **config_get**(key): Read one LeapFlow setting by key (e.g. 'llm.model', 'daemon.log_level'), returning its current value, type, scopes, and whether a change needs a daemon restart. Never read LeapFlow config files from disk — use this.\n- **config_set**(key, value, scope) [capability_expand category: config]: Change one LeapFlow setting by key, e.g. switch the model with key='llm.model'. Values are validated and coerced; credentials are stored in the vault automatically. Call config_list or config_get first if unsure of the key. The result states whether a `leap daemon restart` is required. Never edit LeapFlow config files directly.\n- **web_fetch**(url, select, timeout, max_bytes): Read a URL over HTTP(S) and get back extracted, context-sized content: parsed JSON for API endpoints, readable text plus links for web pages. Use this for anything on the internet — prices, docs, releases, articles — instead of running curl through shell_run: it reports real HTTP status codes, retries rate limits on its own, and is a plain read so a retry is always safe. For JSON APIs pass `select` with a dotted path (e.g. 'chart.result.0.meta') to return just that part instead of the whole payload.\n- **memory_search**(query, limit): Search agent memory for relevant past experiences, observations, and facts.\n- **memory_add**(content, kind) [capability_expand category: write]: Store a new observation or insight in memory for future reference.\n- **research_note**(kind, text): Record a compact, structured note about the current task's state so it survives context compression on long / multi-step tasks. Use for durable findings, open questions still to resolve, decisions / excluded paths, and the immediate next step. One concise sentence per note.\n- **capability_expand**(category): Fetch the full callable schema for every tool in a capability category (e.g. 'hub', 'gateway', 'desktop', '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.\n- **delegate_task**(goal, context) [capability_expand category: delegate]: Delegate a complex sub-task to an isolated subagent. The subagent gets a fresh context and restricted tool access. Use when a task is self-contained and can be solved independently.\n- **schedule_reentry**(kind, reason, delay_seconds, event_match, max_reentries, deadline_seconds): Register a re-entry so this task can resume later from its current orientation (findings / open questions / next step). Use when work must pause and continue after a delay (kind=time) or when a matching platform event arrives (kind=event), instead of finishing now. The research-ledger state is carried over automatically.\n- **hub_push**(skill_name, visibility, version) [capability_expand category: hub]: Push a local skill to the ModelScope Hub for sharing or backup.\n- **hub_pull**(repo_id, version) [capability_expand category: hub]: Pull a skill from the ModelScope Hub to install locally.\n- **hub_search**(query) [capability_expand category: hub]: Search for skills on the Hub by keyword or description.\n- **hub_sync**(mode, dry_run) [capability_expand category: hub]: Preview or execute sync between local skills and Hub.\n- **platform_action**(platform, action, payload, backend_kind) [capability_expand category: gateway]: Execute an exact registered business action on an external platform through LeapFlow's App Connector layer. Actions must be copied from the App Connector Capability Index and are addressed as domain.operation, e.g. im.send_message or docs.create_markdown. All business fields (chat_id, text, query, etc.) MUST be placed inside `payload`, never at the top level. Example: {\"platform\":\"feishu\",\"action\":\"im.send_message\",\"payload\":{\"chat_id\":\"oc_xxx\",\"text\":\"hello\"}}. Do not invent action names, do not use management actions such as list/guide/connect/status here.\n- **platform_connect**(action, platform, credentials, options, checkpoint) [capability_expand category: gateway]: List, guide, connect, disconnect, remove, or check status for external platforms using the App Connector management namespace. Supports REST and CLI backends. Use this for management actions such as list/guide/preflight/connect/status; use platform_action only for exact registered business actions.\n- **gateway_send**(platform, chat_id, text, thread_id) [capability_expand category: gateway]: Send a message to a connected external platform (Feishu group, Telegram chat, DingTalk conversation, etc.). Requires the platform to be connected via gateway_connect first. Use gateway_connect with action='list' to see connected platforms and available chat IDs.\n- **gateway_connect**(action, platform, credentials, options) [capability_expand category: gateway]: Connect, configure, or manage external platform integrations (Feishu, DingTalk, Telegram, Slack, Discord, etc.). Conversational flow: 1) call 'guide' to get setup steps + required fields, 2) present the steps to the user and ask for ALL required credentials in a single message, 3) call 'connect' with the credentials. Goal: complete in 1–2 user turns. NEVER include credential values in your text response.\n- **plugin_list**(): List the live plugin registry and cross-subsystem capability evidence. Use this before answering questions about whether LeapFlow supports plugins, self-evolution, plugin installation, hot reload, versioning, or other runtime capabilities.\n- **plugin_status**(plugin_id): Get detailed status of a specific plugin: its declared category, runtime dependencies, contributed tools, and fiber lifecycle state.\n- **plugin_versions**(plugin_id): List recorded source versions and active pointer for a profile-scoped plugin.\n- **plugin_propose**(requested_capability, plugin_id, proposed_tools, test_cases, risk_level, evidence): Create a side-effect-free PluginProposal from explicit capability-gap evidence. Use this before plugin_generate when a missing capability should be reviewed. Does not call an LLM, write files, or install anything.\n- **assess_compatibility**(manifest, source_path) [capability_expand category: plugin_management]: Assess whether a foreign plugin manifest or real DSH source bundle is compatible with LeapFlow. A source bundle assessment is static: runtime_ready stays false until restricted Node discovery during plugin_install. Returns component-level verdicts and limitations.\n- **plugin_generate**(plugin_id, description, proposal_id) [capability_expand category: system]: Generate a new ToolPlugin from a natural-language capability description. The LLM produces code that conforms to the ToolPlugin Protocol; it is then rigorously validated (syntax, structure, import, protocol conformance). The isolated sandbox smoke test runs later, at install-time. Returns the validated code but DOES NOT install it — installation is a separate approval-gated step via plugin_install.\n- **plugin_install**(plugin_id, code, marketplace_name, source_path, proposal_id, version_label) [capability_expand category: system]: Install a plugin from exactly one source: validated Python code, the configured Python marketplace, or a real DSH source bundle. DSH bundles run restricted Node discovery before registration; only public host tools are installed and client UI limitations are reported. Writes profile-scoped state and mutates the process-global registry. REQUIRES APPROVAL.\n- **plugin_rollback**(plugin_id, version) [capability_expand category: system]: Rollback a profile-scoped plugin to a recorded source version and reload it. REQUIRES APPROVAL.\n- **plugin_reload**(plugin_id, version_label) [capability_expand category: system]: Hot-reload a plugin at runtime. Disposes the old plugin fiber, re-imports its module, and registers a fresh instance. Existing in-flight turns are unaffected (snapshot isolation). REQUIRES APPROVAL — this is a self-modification action.\n- **plugin_disable**(plugin_id) [capability_expand category: system]: Disable a plugin by disposing its fiber, removing its tools from the runtime registry. Cannot disable self_management itself. REQUIRES APPROVAL — this is a self-modification action.\n- **plugin_remove**(plugin_id, delete_source) [capability_expand category: system]: Terminally remove a plugin: dispose its fiber, unregister its tools, remove reload metadata, and optionally delete its profile-scoped source file. Cannot remove self_management itself. REQUIRES APPROVAL.\n- **plugin_enable**(plugin_id) [capability_expand category: system]: Re-enable a previously disabled plugin by reloading its module and registering a fresh instance. REQUIRES APPROVAL.\n- **fixture_echo**(text) [capability_expand category: bridge]: Return the supplied text from a pre-built DSH package.\n- **session_search**(query, limit) [capability_expand category: unclassified]: Search past conversation sessions in the current workspace for relevant context.\n- **session_list**(limit): List recent conversation sessions in the current workspace with stable ids, titles, dates, and summaries. Use for browsing past tasks or when user asks to see history without specific search terms. No keywords needed — returns chronological list.\n- **session_detail**(session_id, limit, offset, include_inactive) [capability_expand category: unclassified]: Read a paginated persisted transcript for one past conversation session in the current workspace. Use after session_list or session_search returns a session_id.\n- **click**(element_index) [capability_expand category: desktop]: Click a UI element by its element_index (from the latest observe_ui snapshot)\n- **get_clipboard**() [capability_expand category: desktop]: Read current clipboard text content\n- **list_apps**(filter, running_only) [capability_expand category: desktop]: List available applications on this system. Use to discover correct bundle_id before switch_app.\n- **list_windows**() [capability_expand category: desktop]: List all top-level windows with pid, window_id, title, and per-window state (minimized, on-screen). Call this first to pick the pid and window_id that observe_ui and other window tools require.\n- **observe_ui**(pid, window_id, query) [capability_expand category: desktop]: Snapshot one window's actionable UI elements, each tagged with an element_index for click/right_click/read_text. Re-observe after actions — indices belong to one snapshot. Requires the window's pid and window_id from list_windows.\n- **open_url**(url, app_id) [capability_expand category: desktop]: Open a URL in the default or specified browser\n- **read_text**(element_index) [capability_expand category: desktop]: Read the text content of a specific UI element from the latest snapshot\n- **right_click**(element_index) [capability_expand category: desktop]: Right-click a UI element to open its context menu. Returns visible menu items.\n- **screenshot**(pid, window_id) [capability_expand category: desktop]: Capture a screenshot for visual verification. With pid + window_id captures that window (works across all displays); defaults to the last observed window, or the full desktop when no window has been observed.\n- **scroll**(element_index, direction, amount, pid, window_id) [capability_expand category: desktop]: Scroll a scrollable area of a window. Omit element_index to scroll the window's focused/page scroller; pass one to scroll an exact element from the latest snapshot.\n- **select_text**(element_index) [capability_expand category: desktop]: Select all text in a UI element (focus + select-all, for subsequent copy)\n- **set_clipboard**(text) [capability_expand category: desktop]: Write text to the clipboard\n- **shortcut**(keys) [capability_expand category: desktop]: Execute a keyboard shortcut\n- **switch_app**(app_id) [capability_expand category: desktop]: Switch to an app (launch if needed, activate, verify)\n- **type_text**(text) [capability_expand category: desktop]: Type text into the currently focused element\n- **wait**(seconds) [capability_expand category: desktop]: Wait for a specified duration before continuing\n- **wait_until**(condition, pid, window_id, timeout, poll_interval) [capability_expand category: desktop]: Wait until a UI condition is met (polls UI tree). Returns elements when found or on timeout.\n- **wait_until_stable**(timeout, poll_interval, pid, window_id) [capability_expand category: desktop]: Wait until the UI stops changing (element set stabilizes across polls).\n\n## App Connector Capability Index\nLeapFlow can onboard and manage external apps through `platform_connect` and execute exact registered business actions through `platform_action`.\nFor requests about connecting, setting up, configuring, enabling, or managing a supported app, use `platform_connect` first instead of generating SDK/Webhook sample code.\n`platform_connect.action` is the management namespace: list, guide, preflight, connect, disconnect, remove, status, events_start, events_stop, events_status.\n`platform_action.action` is only for exact registered platform business actions listed below, such as `im.send_message`; never use management actions like `list` or `guide` there.\nAll business fields MUST go inside `payload`; top-level keys are only `platform`, `action`, and `payload`.\nDo not invent platform IDs or platform action names. If the needed action is not listed, ask for discovery/clarification instead of guessing.\nUse `platform_connect` with `action='guide'` and the matching `platform` to start onboarding; use `action='list'` when the app is unclear.\nWhen a pending onboarding state is present, continue from that state with `platform_connect` instead of asking the user to restate the app.\nSupported apps:\n- `api_server`: API Server (OpenAI Compatible) (category=api; backend=adapter; platform_action actions=none registered)\n- `dingtalk`: 钉钉 (DingTalk) (category=im; backend=adapter; platform_action actions=none registered)\n- `feishu`: 飞书 (Feishu/Lark) (category=im; backend=cli)\n - `calendar.create_event` payload={summary*, start_time*, end_time*, attendees} [write/high]\n - `docs.create_markdown` payload={title*, markdown*, folder_token} [write/medium]\n - `drive.search` payload={query*, limit} [read/medium]\n - `im.add_reaction` payload={message_id*, emoji_type*} [write/low]\n - `im.download_resource` payload={message_id*, file_key*, type*} [read/low]\n - `im.get_messages` payload={message_ids*} [read/low]\n - `im.list_chats` payload={page_size} [read/low]\n - `im.list_messages` payload={chat_id*} [read/low]\n - `im.list_thread_messages` payload={thread*} [read/low]\n - `im.remove_reaction` payload={message_id*, emoji_type*} [write/low]\n - `im.reply_message` payload={message_id*, text*} [send/high]\n - `im.search_chats` payload={query*, page_size} [read/low]\n - `im.search_messages` payload={query*} [read/medium]\n - `im.send_message` payload={chat_id*, text*, thread_id} [send/high]\n - `im.update_card` payload={token*, card*} [write/medium]\n - `im.update_message` payload={message_id*, text*} [write/medium]\n - `mail.search_unread` payload={query, limit} [read/high]\n - `sheets.append_row` payload={spreadsheet_token*, sheet_id*, values*} [write/medium]\n - `task.create` payload={title*, description, due_time} [write/medium]\n- `telegram`: Telegram (category=social; backend=adapter; platform_action actions=none registered)\n- `webhook`: Webhook (Generic) (category=webhook; backend=adapter; platform_action actions=none registered)\n\n## Tool Usage\nTools are normally invoked through the native function-calling mechanism, not by writing JSON in your reply\ntext. Only if the provider signals that native function calling is unavailable for this turn, fall back to a\nsingle JSON code block: `{\"name\": \"tool_name\", \"arguments\": {\"key\": \"value\"}}` — use this fallback format\nonly, never both. Only call a tool whose exact name appears in the tool index above and is currently callable\n(or reachable via `capability_expand`). Never invent, rename, alias, or guess a tool name, platform ID, or\nplatform action from argument shape or wording — if the index does not list it, it does not exist.\n`platform_connect.action` (list/guide/preflight/connect/disconnect/remove/status/events_start/events_stop/\nevents_status) is the App Connector management namespace; `platform_action.action` only accepts exact\nregistered business actions such as `im.send_message` shown in the App Connector Capability Index — never mix\nthe two namespaces. If a tool call returns an unknown/unavailable result, use the returned suggestions or\navailable names for a single retry instead of trying further variations of the same guess.\n\n**Side-effect action rule** (`platform_action` with effect=send/write/execute):\n- Call each unique action+payload **exactly once**. Never include duplicates in the same turn.\n- Once the result returns `\"completed\": true`, that action is DONE for this task. Do NOT call it again\n in any subsequent turn — immediately summarize the result for the user instead.\n- The system enforces idempotency: duplicate calls are blocked and will not execute.\n- If the user explicitly requests sending/writing multiple times, use distinct payloads per call.\n\n**Resource identifier provenance rule**:\n- NEVER fabricate, guess, or infer resource identifiers (chat_id, message_id, file_key, user_id, etc.).\n Every resource ID used in a side-effect action MUST come from a successful API response in this session.\n- If a read/list action fails (e.g. authorization error), you do NOT have valid resource IDs.\n Report the failure to the user — do NOT attempt the dependent write/send action with a guessed ID.\n- When a tool result contains `\"llm_instruction\"`, follow it exactly.\n\n## Guidelines\n1. **Direct answers first**: If you already know the answer, respond directly without tools.\n2. **Avoid redundant tool calls**: Do not call the same tool with the same arguments more than once in the same user turn. When an existing tool result already answers the user's request, stop calling tools and answer directly.\n3. **Use tools proactively**: When the user asks about files, time, system state, or needs actions performed, use the appropriate tool.\n4. **Chain tools when needed**: You can call multiple tools in sequence (e.g., list files → read file → summarize).\n5. **Handle failures gracefully**: If a tool fails, explain what went wrong and suggest alternatives. If it failed because of an unknown tool/platform/action name, retry once with an exact name from the returned suggestions, then explain rather than keep guessing.\n6. **Summarize results naturally**: After tool execution, synthesize the results into a helpful answer rather than dumping raw output.\n7. **Stay conversational**: Maintain a natural, helpful tone. Acknowledge context from earlier in the conversation.\n8. **Recall past work**:\n - Broad queries (\"之前做了什么\", \"列出任务\"): answer from the \"Recent Task History\" section already in your context. If insufficient, call session_list.\n - Specific lookups (\"上次那个配置怎么改的\"): call session_search with relevant phrases (NOT single characters).\n - Do NOT call search tools repeatedly with keyword fragments. One well-phrased call is better than ten fragmented ones.\n\n## Coding & Verification\nWhen working with code, prefer the precise built-in tools over ad-hoc shell: use `repo_map` to orient in an\nunfamiliar project, `code_search`/`file_find` to locate and `code_intel` for symbols, `edit_file` (anchored\nsearch-replace, or a unified `diff`) to change files — never rewrite a whole file to change a few lines — and\n`git_query` to inspect diffs/log. After edits, check `syntax_ok` in the result and run `test_run`/`lint_check`\nbefore declaring the work done. Batch independent read-only calls (search/read/query) into a single turn — they\nrun in parallel.\n\n## Presentation Style\n1. **Polished Markdown only**: Format user-facing answers with clean Markdown headings, short paragraphs, and concise bullets. Use tables only when they improve comparison or scanning.\n2. **Terminal-friendly layout**: Keep lines readable in a TUI; avoid dense walls of text, deeply nested lists, oversized ASCII art, or heavy visual blocks.\n3. **Elegant emphasis**: Use bold text sparingly for key terms and conclusions. Avoid excessive emojis, decorative symbols, repeated separators, or visual noise.\n4. **Theme-safe colors**: Do not emit ANSI escape codes, HTML color tags, Rich markup, or hardcoded color names. Rely on the TUI theme to render Markdown professionally.\n5. **No leaked tool protocol**: Never show tool-call JSON, internal schemas, raw observations, tool result payloads, or hidden reasoning in the final answer unless the user explicitly asks for raw/debug output. Treat any prior `{\"name\": ..., \"arguments\": ...}` blocks and `Tool result (...)` messages as internal execution context only.\n6. **Professional closure**: End with a concise conclusion or next step when helpful; avoid rambling after the useful answer is complete.\n\nWhen finished with all tool calls, respond normally without a JSON block, tool-call transcript, or process log.\n\n## Task Contract\n- Task ID: turn-2\n- Original user request: Remove the hermetic DSH echo plugin completely.\n- Workspace root: \n- Allowed roots: \n- Treat relative project paths as relative to the workspace root; never infer `.` as the project root when a workspace root is provided.\n- Workspace boundary is enforced by tools: do not read, search, edit, or run commands against paths outside the allowed roots unless the user explicitly requests an external path and the tool/approval policy permits it.\n- LeapFlow workspace config is optional at `/.leapflow/config.yaml`; runtime config is loaded from `~/.leapflow/config/user.yaml` and `~/.leapflow/profiles//config/*.yaml`.\n- Preserve this task contract across summarization, compression, tool loops, and memory retrieval.\n" + }, + { + "role": "user", + "content": "Invoke fixture_echo with the text after restart." + }, + { + "role": "assistant", + "content": "[Called: fixture_echo]\nThe DSH tool ran after restart." + }, + { + "role": "user", + "content": "Remove the hermetic DSH echo plugin completely.\nRemove the hermetic DSH echo plugin completely." + }, + { + "role": "assistant", + "content": "", + "tool_calls": [ + "plugin_remove" + ] + }, + { + "role": "tool", + "content": "{\"ok\": true, \"action\": \"remove\", \"plugin_id\": \"r6_dsh_echo\", \"state\": \"disposed\", \"source_path\": \"\", \"source_deleted\": true, \"execution_id\": \"\", \"idempotency_key\": \"\", \"execution_policy\": \"mutating_once\", \"tool_call_id\": \"call_1\", \"execution_status\": \"completed\"}", + "tool_result": true + }, + { + "role": "assistant", + "content": "Operation interrupted. Continuing..." + } + ], + "tools": [ + "capability_expand", + "code_intel", + "code_search", + "config_get", + "config_list", + "env_info", + "file_find", + "file_list", + "file_read", + "fixture_echo", + "git_query", + "lint_check", + "memory_search", + "plugin_list", + "plugin_propose", + "plugin_status", + "plugin_versions", + "repo_map", + "research_note", + "schedule_reentry", + "session_list", + "skill_view", + "skills_list", + "terminal_list", + "terminal_read", + "test_run", + "text_search", + "time_get", + "web_fetch" + ] + }, + "responses": [ + { + "status": 200, + "content_type": "application/json", + "body": "{\"id\": \"chatcmpl-cassette\", \"object\": \"chat.completion\", \"model\": \"cassette-model\", \"choices\": [{\"index\": 0, \"message\": {\"role\": \"assistant\", \"content\": \"Removed the hermetic DSH plugin.\"}, \"finish_reason\": \"stop\"}], \"usage\": {\"prompt_tokens\": 64, \"completion_tokens\": 16, \"total_tokens\": 80}}" + } + ] +} diff --git a/tests/_fixtures/cassettes/r6_lifecycle/cassette-model-376e020dd8ad4c8a.cassette.json b/tests/_fixtures/cassettes/r6_lifecycle/cassette-model-376e020dd8ad4c8a.cassette.json new file mode 100644 index 0000000..44be836 --- /dev/null +++ b/tests/_fixtures/cassettes/r6_lifecycle/cassette-model-376e020dd8ad4c8a.cassette.json @@ -0,0 +1,71 @@ +{ + "fingerprint": "376e020dd8ad4c8aa6ad471694e8ba99b6532e1aed7cec429ea04bb22ab3ad5c", + "note": "captured in seed mode", + "request": { + "model": "cassette-model", + "stream": false, + "messages": [ + { + "role": "system", + "content": "You are LeapFlow, an intelligent assistant that can both converse naturally and take real actions on the user's computer.\n\n## Capabilities\nThe tool index below lists **every** registered tool by name and a one-line summary — this is the complete\ncapability contract; nothing else exists. Only a subset is directly callable this turn (via native tool calling,\nnot a JSON block in your reply). If you need a tool from the index that is not yet callable, call\n`capability_expand` with its category name first — the matching tools become callable immediately after.\nWhen the user asks what LeapFlow itself supports, whether it supports plugins, or which runtime capabilities\nare available, use the live capability evidence exposed by `plugin_list` before making capability claims; report\nconfiguration-dependent or unavailable capabilities as limitations instead of inferring from documentation.\n- **text_search**(text, pattern): Search for a regex pattern in text.\n- **text_replace**(text, old, new, count) [capability_expand category: write]: Replace occurrences of a substring in text.\n- **time_get**(): Get current date and time.\n- **env_info**(): Get system environment information (OS, Python version, cwd).\n- **skills_list**(query, category, source): List available learned skills. Use when user asks about capabilities or you need a specific skill.\n- **skill_view**(name): View the full content of a specific skill document.\n- **code_intel**(path, operation): Precise document symbols (outline) for a source file: classes, functions, and methods with line ranges. Python uses an exact AST parse; other languages use a keyword-prefix scan. Prefer over file_read mode=symbols for accurate navigation before editing. Read-only.\n- **repo_map**(path): Compact project orientation for a repository root: languages, detected test/lint commands, top-level structure, entry points, manifest, and VCS branch. Call this first when entering an unfamiliar codebase. Read-only.\n- **scm_sync**(action, cwd, remote, pull_ref, push_ref, timeout) [capability_expand category: scm]: Run a typed git SCM action. Use this instead of shell_run for git pull/push/status. For 'pull origin main then push', set action='pull_then_push', remote='origin', pull_ref='main', and omit push_ref so LeapFlow pushes the current local branch.\n- **git_query**(action, cwd, ref, path, staged, max_count, stat): Read-only structured git inspection: action=diff|log|status|branch|show. Prefer over shell_run for reading repo state — output is clipped, redacted, and log/branch are parsed into structured fields. Use scm_sync for pull/push.\n- **git_write**(action, cwd, message, stage_all, name, ref, create) [capability_expand category: scm]: Mutating git actions: action=commit (message, stage_all), branch (create+switch), checkout (switch; create=true for -b). Approval-gated. Use scm_sync for pull/push and git_query for reads.\n- **test_run**(command, cwd, timeout): Run the project's test suite and return structured results (framework, passed/failed counts, failing tests). Auto-detects the runner (pytest/npm/go/cargo) or uses a configured/explicit command; executes via the governed shell. ok=true means the runner executed — see 'success' for pass/fail.\n- **lint_check**(command, cwd, timeout): Run the project's linter and return a structured clean/issue result. Auto-detects the linter (ruff/eslint/go vet/clippy) or uses a configured/explicit command; executes via the governed shell. ok=true means the linter ran — see 'clean'.\n- **file_list**(path, pattern, depth): List files and directories at a given path. Use depth=1 or depth=2 to get a recursive tree in one call instead of listing each sub-directory separately.\n- **file_read**(path, max_lines, start_line, max_chars, mode): Read text file content with adaptive context governance. For large or unfamiliar files, prefer mode='outline' or mode='symbols' first, then use mode='raw' with start_line/max_lines for the specific range you actually need. For LeapFlow's own settings, use config_list / config_get / config_set — its config files are outside the workspace and not readable here.\n- **file_write**(path, content, mode) [capability_expand category: write]: Write content to a file (overwrite or append).\n- **code_search**(pattern, patterns, path, glob, ignore_case, multiline, max_results, context_lines): Search file CONTENTS by regex pattern across a directory tree (ripgrep-backed). Requires a regex pattern. NOT for listing or browsing directory contents — use file_list for that. Prefer this over shell_run grep: faster, skips VCS/dependency/build dirs, and returns structured path:line:column matches. Batch related lookups into ONE call via `patterns` (OR-combined, single pass) instead of issuing several separate searches. Use file_read for the surrounding context of a hit.\n- **file_find**(glob, path, max_results): Find files by a recursive glob pattern under a base path (e.g. '**/test_*.py' or '*.md'). Prefer this over shell_run find; skips VCS/dependency/build dirs.\n- **edit_file**(path, edits, dry_run, diff) [capability_expand category: write]: Apply targeted, anchored search-replace edits to an EXISTING text file (use file_write to create/overwrite). Each edit is {original_text, new_text, replace_all?}; original_text must match exactly and uniquely (or set replace_all) — a non-unique or missing anchor is rejected so files are never corrupted. Set dry_run to preview. Alternatively pass a unified 'diff' to apply its hunks as anchored edits. Far cheaper and safer than rewriting a whole file.\n- **shell_run**(command, cwd, timeout) [capability_expand category: shell]: Execute a one-shot shell command with timeout protection. Runs in the active workspace; paths resolving outside it are refused. Reach for a structured tool first when one fits — web_fetch for anything over HTTP(S), git_query/scm_sync for git, code_search/file_find/file_read for the repo, config_get/config_set for LeapFlow's own settings — because those report typed results, while a failed shell command can only be diagnosed from its exit code and stderr. Every shell run counts as an external side effect, so a failure stops the rest of the batch and is not retried automatically.\n- **terminal_open**(command, cwd, shell) [capability_expand category: terminal]: Open a PERSISTENT shell session (REPL/dev server/watch), returning a session_id for terminal_send/read/close. Disabled unless tools.terminal_session_enabled is set. For one-shot commands use shell_run instead.\n- **terminal_send**(session_id, input, wait) [capability_expand category: terminal]: Send a line of input to a persistent terminal session and return output captured shortly after.\n- **terminal_read**(session_id, wait): Drain buffered output from a persistent terminal session (optionally waiting briefly first).\n- **terminal_close**(session_id) [capability_expand category: terminal]: Terminate a persistent terminal session and release its process group.\n- **terminal_list**(): List active persistent terminal sessions.\n- **config_list**(category, limit): List LeapFlow's own writable settings (model, provider, daemon, memory, perception, gateway, …) with current values. Use this to discover the exact key before changing anything. Optionally narrow by `category`. This is the only correct way to inspect LeapFlow configuration — never read config files from disk.\n- **config_get**(key): Read one LeapFlow setting by key (e.g. 'llm.model', 'daemon.log_level'), returning its current value, type, scopes, and whether a change needs a daemon restart. Never read LeapFlow config files from disk — use this.\n- **config_set**(key, value, scope) [capability_expand category: config]: Change one LeapFlow setting by key, e.g. switch the model with key='llm.model'. Values are validated and coerced; credentials are stored in the vault automatically. Call config_list or config_get first if unsure of the key. The result states whether a `leap daemon restart` is required. Never edit LeapFlow config files directly.\n- **web_fetch**(url, select, timeout, max_bytes): Read a URL over HTTP(S) and get back extracted, context-sized content: parsed JSON for API endpoints, readable text plus links for web pages. Use this for anything on the internet — prices, docs, releases, articles — instead of running curl through shell_run: it reports real HTTP status codes, retries rate limits on its own, and is a plain read so a retry is always safe. For JSON APIs pass `select` with a dotted path (e.g. 'chart.result.0.meta') to return just that part instead of the whole payload.\n- **memory_search**(query, limit): Search agent memory for relevant past experiences, observations, and facts.\n- **memory_add**(content, kind) [capability_expand category: write]: Store a new observation or insight in memory for future reference.\n- **research_note**(kind, text): Record a compact, structured note about the current task's state so it survives context compression on long / multi-step tasks. Use for durable findings, open questions still to resolve, decisions / excluded paths, and the immediate next step. One concise sentence per note.\n- **capability_expand**(category): Fetch the full callable schema for every tool in a capability category (e.g. 'hub', 'gateway', 'desktop', '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.\n- **delegate_task**(goal, context) [capability_expand category: delegate]: Delegate a complex sub-task to an isolated subagent. The subagent gets a fresh context and restricted tool access. Use when a task is self-contained and can be solved independently.\n- **schedule_reentry**(kind, reason, delay_seconds, event_match, max_reentries, deadline_seconds): Register a re-entry so this task can resume later from its current orientation (findings / open questions / next step). Use when work must pause and continue after a delay (kind=time) or when a matching platform event arrives (kind=event), instead of finishing now. The research-ledger state is carried over automatically.\n- **hub_push**(skill_name, visibility, version) [capability_expand category: hub]: Push a local skill to the ModelScope Hub for sharing or backup.\n- **hub_pull**(repo_id, version) [capability_expand category: hub]: Pull a skill from the ModelScope Hub to install locally.\n- **hub_search**(query) [capability_expand category: hub]: Search for skills on the Hub by keyword or description.\n- **hub_sync**(mode, dry_run) [capability_expand category: hub]: Preview or execute sync between local skills and Hub.\n- **platform_action**(platform, action, payload, backend_kind) [capability_expand category: gateway]: Execute an exact registered business action on an external platform through LeapFlow's App Connector layer. Actions must be copied from the App Connector Capability Index and are addressed as domain.operation, e.g. im.send_message or docs.create_markdown. All business fields (chat_id, text, query, etc.) MUST be placed inside `payload`, never at the top level. Example: {\"platform\":\"feishu\",\"action\":\"im.send_message\",\"payload\":{\"chat_id\":\"oc_xxx\",\"text\":\"hello\"}}. Do not invent action names, do not use management actions such as list/guide/connect/status here.\n- **platform_connect**(action, platform, credentials, options, checkpoint) [capability_expand category: gateway]: List, guide, connect, disconnect, remove, or check status for external platforms using the App Connector management namespace. Supports REST and CLI backends. Use this for management actions such as list/guide/preflight/connect/status; use platform_action only for exact registered business actions.\n- **gateway_send**(platform, chat_id, text, thread_id) [capability_expand category: gateway]: Send a message to a connected external platform (Feishu group, Telegram chat, DingTalk conversation, etc.). Requires the platform to be connected via gateway_connect first. Use gateway_connect with action='list' to see connected platforms and available chat IDs.\n- **gateway_connect**(action, platform, credentials, options) [capability_expand category: gateway]: Connect, configure, or manage external platform integrations (Feishu, DingTalk, Telegram, Slack, Discord, etc.). Conversational flow: 1) call 'guide' to get setup steps + required fields, 2) present the steps to the user and ask for ALL required credentials in a single message, 3) call 'connect' with the credentials. Goal: complete in 1–2 user turns. NEVER include credential values in your text response.\n- **plugin_list**(): List the live plugin registry and cross-subsystem capability evidence. Use this before answering questions about whether LeapFlow supports plugins, self-evolution, plugin installation, hot reload, versioning, or other runtime capabilities.\n- **plugin_status**(plugin_id): Get detailed status of a specific plugin: its declared category, runtime dependencies, contributed tools, and fiber lifecycle state.\n- **plugin_versions**(plugin_id): List recorded source versions and active pointer for a profile-scoped plugin.\n- **plugin_propose**(requested_capability, plugin_id, proposed_tools, test_cases, risk_level, evidence): Create a side-effect-free PluginProposal from explicit capability-gap evidence. Use this before plugin_generate when a missing capability should be reviewed. Does not call an LLM, write files, or install anything.\n- **assess_compatibility**(manifest, source_path) [capability_expand category: plugin_management]: Assess whether a foreign plugin manifest or real DSH source bundle is compatible with LeapFlow. A source bundle assessment is static: runtime_ready stays false until restricted Node discovery during plugin_install. Returns component-level verdicts and limitations.\n- **plugin_generate**(plugin_id, description, proposal_id) [capability_expand category: system]: Generate a new ToolPlugin from a natural-language capability description. The LLM produces code that conforms to the ToolPlugin Protocol; it is then rigorously validated (syntax, structure, import, protocol conformance). The isolated sandbox smoke test runs later, at install-time. Returns the validated code but DOES NOT install it — installation is a separate approval-gated step via plugin_install.\n- **plugin_install**(plugin_id, code, marketplace_name, source_path, proposal_id, version_label) [capability_expand category: system]: Install a plugin from exactly one source: validated Python code, the configured Python marketplace, or a real DSH source bundle. DSH bundles run restricted Node discovery before registration; only public host tools are installed and client UI limitations are reported. Writes profile-scoped state and mutates the process-global registry. REQUIRES APPROVAL.\n- **plugin_rollback**(plugin_id, version) [capability_expand category: system]: Rollback a profile-scoped plugin to a recorded source version and reload it. REQUIRES APPROVAL.\n- **plugin_reload**(plugin_id, version_label) [capability_expand category: system]: Hot-reload a plugin at runtime. Disposes the old plugin fiber, re-imports its module, and registers a fresh instance. Existing in-flight turns are unaffected (snapshot isolation). REQUIRES APPROVAL — this is a self-modification action.\n- **plugin_disable**(plugin_id) [capability_expand category: system]: Disable a plugin by disposing its fiber, removing its tools from the runtime registry. Cannot disable self_management itself. REQUIRES APPROVAL — this is a self-modification action.\n- **plugin_remove**(plugin_id, delete_source) [capability_expand category: system]: Terminally remove a plugin: dispose its fiber, unregister its tools, remove reload metadata, and optionally delete its profile-scoped source file. Cannot remove self_management itself. REQUIRES APPROVAL.\n- **plugin_enable**(plugin_id) [capability_expand category: system]: Re-enable a previously disabled plugin by reloading its module and registering a fresh instance. REQUIRES APPROVAL.\n- **session_search**(query, limit) [capability_expand category: unclassified]: Search past conversation sessions in the current workspace for relevant context.\n- **session_list**(limit): List recent conversation sessions in the current workspace with stable ids, titles, dates, and summaries. Use for browsing past tasks or when user asks to see history without specific search terms. No keywords needed — returns chronological list.\n- **session_detail**(session_id, limit, offset, include_inactive) [capability_expand category: unclassified]: Read a paginated persisted transcript for one past conversation session in the current workspace. Use after session_list or session_search returns a session_id.\n- **click**(element_index) [capability_expand category: desktop]: Click a UI element by its element_index (from the latest observe_ui snapshot)\n- **get_clipboard**() [capability_expand category: desktop]: Read current clipboard text content\n- **list_apps**(filter, running_only) [capability_expand category: desktop]: List available applications on this system. Use to discover correct bundle_id before switch_app.\n- **list_windows**() [capability_expand category: desktop]: List all top-level windows with pid, window_id, title, and per-window state (minimized, on-screen). Call this first to pick the pid and window_id that observe_ui and other window tools require.\n- **observe_ui**(pid, window_id, query) [capability_expand category: desktop]: Snapshot one window's actionable UI elements, each tagged with an element_index for click/right_click/read_text. Re-observe after actions — indices belong to one snapshot. Requires the window's pid and window_id from list_windows.\n- **open_url**(url, app_id) [capability_expand category: desktop]: Open a URL in the default or specified browser\n- **read_text**(element_index) [capability_expand category: desktop]: Read the text content of a specific UI element from the latest snapshot\n- **right_click**(element_index) [capability_expand category: desktop]: Right-click a UI element to open its context menu. Returns visible menu items.\n- **screenshot**(pid, window_id) [capability_expand category: desktop]: Capture a screenshot for visual verification. With pid + window_id captures that window (works across all displays); defaults to the last observed window, or the full desktop when no window has been observed.\n- **scroll**(element_index, direction, amount, pid, window_id) [capability_expand category: desktop]: Scroll a scrollable area of a window. Omit element_index to scroll the window's focused/page scroller; pass one to scroll an exact element from the latest snapshot.\n- **select_text**(element_index) [capability_expand category: desktop]: Select all text in a UI element (focus + select-all, for subsequent copy)\n- **set_clipboard**(text) [capability_expand category: desktop]: Write text to the clipboard\n- **shortcut**(keys) [capability_expand category: desktop]: Execute a keyboard shortcut\n- **switch_app**(app_id) [capability_expand category: desktop]: Switch to an app (launch if needed, activate, verify)\n- **type_text**(text) [capability_expand category: desktop]: Type text into the currently focused element\n- **wait**(seconds) [capability_expand category: desktop]: Wait for a specified duration before continuing\n- **wait_until**(condition, pid, window_id, timeout, poll_interval) [capability_expand category: desktop]: Wait until a UI condition is met (polls UI tree). Returns elements when found or on timeout.\n- **wait_until_stable**(timeout, poll_interval, pid, window_id) [capability_expand category: desktop]: Wait until the UI stops changing (element set stabilizes across polls).\n\n## App Connector Capability Index\nLeapFlow can onboard and manage external apps through `platform_connect` and execute exact registered business actions through `platform_action`.\nFor requests about connecting, setting up, configuring, enabling, or managing a supported app, use `platform_connect` first instead of generating SDK/Webhook sample code.\n`platform_connect.action` is the management namespace: list, guide, preflight, connect, disconnect, remove, status, events_start, events_stop, events_status.\n`platform_action.action` is only for exact registered platform business actions listed below, such as `im.send_message`; never use management actions like `list` or `guide` there.\nAll business fields MUST go inside `payload`; top-level keys are only `platform`, `action`, and `payload`.\nDo not invent platform IDs or platform action names. If the needed action is not listed, ask for discovery/clarification instead of guessing.\nUse `platform_connect` with `action='guide'` and the matching `platform` to start onboarding; use `action='list'` when the app is unclear.\nWhen a pending onboarding state is present, continue from that state with `platform_connect` instead of asking the user to restate the app.\nSupported apps:\n- `api_server`: API Server (OpenAI Compatible) (category=api; backend=adapter; platform_action actions=none registered)\n- `dingtalk`: 钉钉 (DingTalk) (category=im; backend=adapter; platform_action actions=none registered)\n- `feishu`: 飞书 (Feishu/Lark) (category=im; backend=cli)\n - `calendar.create_event` payload={summary*, start_time*, end_time*, attendees} [write/high]\n - `docs.create_markdown` payload={title*, markdown*, folder_token} [write/medium]\n - `drive.search` payload={query*, limit} [read/medium]\n - `im.add_reaction` payload={message_id*, emoji_type*} [write/low]\n - `im.download_resource` payload={message_id*, file_key*, type*} [read/low]\n - `im.get_messages` payload={message_ids*} [read/low]\n - `im.list_chats` payload={page_size} [read/low]\n - `im.list_messages` payload={chat_id*} [read/low]\n - `im.list_thread_messages` payload={thread*} [read/low]\n - `im.remove_reaction` payload={message_id*, emoji_type*} [write/low]\n - `im.reply_message` payload={message_id*, text*} [send/high]\n - `im.search_chats` payload={query*, page_size} [read/low]\n - `im.search_messages` payload={query*} [read/medium]\n - `im.send_message` payload={chat_id*, text*, thread_id} [send/high]\n - `im.update_card` payload={token*, card*} [write/medium]\n - `im.update_message` payload={message_id*, text*} [write/medium]\n - `mail.search_unread` payload={query, limit} [read/high]\n - `sheets.append_row` payload={spreadsheet_token*, sheet_id*, values*} [write/medium]\n - `task.create` payload={title*, description, due_time} [write/medium]\n- `telegram`: Telegram (category=social; backend=adapter; platform_action actions=none registered)\n- `webhook`: Webhook (Generic) (category=webhook; backend=adapter; platform_action actions=none registered)\n\n## Tool Usage\nTools are normally invoked through the native function-calling mechanism, not by writing JSON in your reply\ntext. Only if the provider signals that native function calling is unavailable for this turn, fall back to a\nsingle JSON code block: `{\"name\": \"tool_name\", \"arguments\": {\"key\": \"value\"}}` — use this fallback format\nonly, never both. Only call a tool whose exact name appears in the tool index above and is currently callable\n(or reachable via `capability_expand`). Never invent, rename, alias, or guess a tool name, platform ID, or\nplatform action from argument shape or wording — if the index does not list it, it does not exist.\n`platform_connect.action` (list/guide/preflight/connect/disconnect/remove/status/events_start/events_stop/\nevents_status) is the App Connector management namespace; `platform_action.action` only accepts exact\nregistered business actions such as `im.send_message` shown in the App Connector Capability Index — never mix\nthe two namespaces. If a tool call returns an unknown/unavailable result, use the returned suggestions or\navailable names for a single retry instead of trying further variations of the same guess.\n\n**Side-effect action rule** (`platform_action` with effect=send/write/execute):\n- Call each unique action+payload **exactly once**. Never include duplicates in the same turn.\n- Once the result returns `\"completed\": true`, that action is DONE for this task. Do NOT call it again\n in any subsequent turn — immediately summarize the result for the user instead.\n- The system enforces idempotency: duplicate calls are blocked and will not execute.\n- If the user explicitly requests sending/writing multiple times, use distinct payloads per call.\n\n**Resource identifier provenance rule**:\n- NEVER fabricate, guess, or infer resource identifiers (chat_id, message_id, file_key, user_id, etc.).\n Every resource ID used in a side-effect action MUST come from a successful API response in this session.\n- If a read/list action fails (e.g. authorization error), you do NOT have valid resource IDs.\n Report the failure to the user — do NOT attempt the dependent write/send action with a guessed ID.\n- When a tool result contains `\"llm_instruction\"`, follow it exactly.\n\n## Guidelines\n1. **Direct answers first**: If you already know the answer, respond directly without tools.\n2. **Avoid redundant tool calls**: Do not call the same tool with the same arguments more than once in the same user turn. When an existing tool result already answers the user's request, stop calling tools and answer directly.\n3. **Use tools proactively**: When the user asks about files, time, system state, or needs actions performed, use the appropriate tool.\n4. **Chain tools when needed**: You can call multiple tools in sequence (e.g., list files → read file → summarize).\n5. **Handle failures gracefully**: If a tool fails, explain what went wrong and suggest alternatives. If it failed because of an unknown tool/platform/action name, retry once with an exact name from the returned suggestions, then explain rather than keep guessing.\n6. **Summarize results naturally**: After tool execution, synthesize the results into a helpful answer rather than dumping raw output.\n7. **Stay conversational**: Maintain a natural, helpful tone. Acknowledge context from earlier in the conversation.\n8. **Recall past work**:\n - Broad queries (\"之前做了什么\", \"列出任务\"): answer from the \"Recent Task History\" section already in your context. If insufficient, call session_list.\n - Specific lookups (\"上次那个配置怎么改的\"): call session_search with relevant phrases (NOT single characters).\n - Do NOT call search tools repeatedly with keyword fragments. One well-phrased call is better than ten fragmented ones.\n\n## Coding & Verification\nWhen working with code, prefer the precise built-in tools over ad-hoc shell: use `repo_map` to orient in an\nunfamiliar project, `code_search`/`file_find` to locate and `code_intel` for symbols, `edit_file` (anchored\nsearch-replace, or a unified `diff`) to change files — never rewrite a whole file to change a few lines — and\n`git_query` to inspect diffs/log. After edits, check `syntax_ok` in the result and run `test_run`/`lint_check`\nbefore declaring the work done. Batch independent read-only calls (search/read/query) into a single turn — they\nrun in parallel.\n\n## Presentation Style\n1. **Polished Markdown only**: Format user-facing answers with clean Markdown headings, short paragraphs, and concise bullets. Use tables only when they improve comparison or scanning.\n2. **Terminal-friendly layout**: Keep lines readable in a TUI; avoid dense walls of text, deeply nested lists, oversized ASCII art, or heavy visual blocks.\n3. **Elegant emphasis**: Use bold text sparingly for key terms and conclusions. Avoid excessive emojis, decorative symbols, repeated separators, or visual noise.\n4. **Theme-safe colors**: Do not emit ANSI escape codes, HTML color tags, Rich markup, or hardcoded color names. Rely on the TUI theme to render Markdown professionally.\n5. **No leaked tool protocol**: Never show tool-call JSON, internal schemas, raw observations, tool result payloads, or hidden reasoning in the final answer unless the user explicitly asks for raw/debug output. Treat any prior `{\"name\": ..., \"arguments\": ...}` blocks and `Tool result (...)` messages as internal execution context only.\n6. **Professional closure**: End with a concise conclusion or next step when helpful; avoid rambling after the useful answer is complete.\n\nWhen finished with all tool calls, respond normally without a JSON block, tool-call transcript, or process log.\n\n\n## Recent Session Summary\n- [user] Install the hermetic DSH echo plugin from this workspace.\n\n## Task Contract\n- Task ID: turn-1\n- Original user request: Install the hermetic DSH echo plugin from this workspace.\n- Workspace root: \n- Allowed roots: \n- Treat relative project paths as relative to the workspace root; never infer `.` as the project root when a workspace root is provided.\n- Workspace boundary is enforced by tools: do not read, search, edit, or run commands against paths outside the allowed roots unless the user explicitly requests an external path and the tool/approval policy permits it.\n- LeapFlow workspace config is optional at `/.leapflow/config.yaml`; runtime config is loaded from `~/.leapflow/config/user.yaml` and `~/.leapflow/profiles//config/*.yaml`.\n- Preserve this task contract across summarization, compression, tool loops, and memory retrieval.\n" + }, + { + "role": "user", + "content": "Install the hermetic DSH echo plugin from this workspace.\nInstall the hermetic DSH echo plugin from this workspace." + }, + { + "role": "assistant", + "content": "", + "tool_calls": [ + "plugin_install" + ] + }, + { + "role": "tool", + "content": "{\"ok\": true, \"action\": \"install\", \"plugin_id\": \"r6_dsh_echo\", \"installed_tools\": [\"fixture_echo\"], \"state\": \"active\", \"version\": \"r6\", \"source_kind\": \"dsh_package\", \"bundle_sha256\": \"\", \"descriptor_path\": \"\", \"verdict\": \"adaptable\", \"limitations\": [], \"client_components\": [], \"execution_id\": \"\", \"idempotency_key\": \"\", \"execution_policy\": \"mutating_once\", \"tool_call_id\": \"call_1\", \"execution_status\": \"completed\"}", + "tool_result": true + }, + { + "role": "assistant", + "content": "Operation interrupted. Continuing..." + } + ], + "tools": [ + "capability_expand", + "code_intel", + "code_search", + "config_get", + "config_list", + "env_info", + "file_find", + "file_list", + "file_read", + "git_query", + "lint_check", + "memory_search", + "plugin_list", + "plugin_propose", + "plugin_status", + "plugin_versions", + "repo_map", + "research_note", + "schedule_reentry", + "session_list", + "skill_view", + "skills_list", + "terminal_list", + "terminal_read", + "test_run", + "text_search", + "time_get", + "web_fetch" + ] + }, + "responses": [ + { + "status": 200, + "content_type": "application/json", + "body": "{\"id\": \"chatcmpl-cassette\", \"object\": \"chat.completion\", \"model\": \"cassette-model\", \"choices\": [{\"index\": 0, \"message\": {\"role\": \"assistant\", \"content\": \"Installed the hermetic DSH plugin.\"}, \"finish_reason\": \"stop\"}], \"usage\": {\"prompt_tokens\": 64, \"completion_tokens\": 16, \"total_tokens\": 80}}" + } + ] +} diff --git a/tests/_fixtures/cassettes/r6_lifecycle/cassette-model-40590d8cbb431bcb.cassette.json b/tests/_fixtures/cassettes/r6_lifecycle/cassette-model-40590d8cbb431bcb.cassette.json new file mode 100644 index 0000000..281343d --- /dev/null +++ b/tests/_fixtures/cassettes/r6_lifecycle/cassette-model-40590d8cbb431bcb.cassette.json @@ -0,0 +1,55 @@ +{ + "fingerprint": "40590d8cbb431bcb08e69885b8b40f4afacd30c04240b3d71f56619d1e2c58b0", + "note": "captured in seed mode", + "request": { + "model": "cassette-model", + "stream": false, + "messages": [ + { + "role": "system", + "content": "You are LeapFlow, an intelligent assistant that can both converse naturally and take real actions on the user's computer.\n\n## Capabilities\nThe tool index below lists **every** registered tool by name and a one-line summary — this is the complete\ncapability contract; nothing else exists. Only a subset is directly callable this turn (via native tool calling,\nnot a JSON block in your reply). If you need a tool from the index that is not yet callable, call\n`capability_expand` with its category name first — the matching tools become callable immediately after.\nWhen the user asks what LeapFlow itself supports, whether it supports plugins, or which runtime capabilities\nare available, use the live capability evidence exposed by `plugin_list` before making capability claims; report\nconfiguration-dependent or unavailable capabilities as limitations instead of inferring from documentation.\n- **text_search**(text, pattern): Search for a regex pattern in text.\n- **text_replace**(text, old, new, count) [capability_expand category: write]: Replace occurrences of a substring in text.\n- **time_get**(): Get current date and time.\n- **env_info**(): Get system environment information (OS, Python version, cwd).\n- **skills_list**(query, category, source): List available learned skills. Use when user asks about capabilities or you need a specific skill.\n- **skill_view**(name): View the full content of a specific skill document.\n- **code_intel**(path, operation): Precise document symbols (outline) for a source file: classes, functions, and methods with line ranges. Python uses an exact AST parse; other languages use a keyword-prefix scan. Prefer over file_read mode=symbols for accurate navigation before editing. Read-only.\n- **repo_map**(path): Compact project orientation for a repository root: languages, detected test/lint commands, top-level structure, entry points, manifest, and VCS branch. Call this first when entering an unfamiliar codebase. Read-only.\n- **scm_sync**(action, cwd, remote, pull_ref, push_ref, timeout) [capability_expand category: scm]: Run a typed git SCM action. Use this instead of shell_run for git pull/push/status. For 'pull origin main then push', set action='pull_then_push', remote='origin', pull_ref='main', and omit push_ref so LeapFlow pushes the current local branch.\n- **git_query**(action, cwd, ref, path, staged, max_count, stat): Read-only structured git inspection: action=diff|log|status|branch|show. Prefer over shell_run for reading repo state — output is clipped, redacted, and log/branch are parsed into structured fields. Use scm_sync for pull/push.\n- **git_write**(action, cwd, message, stage_all, name, ref, create) [capability_expand category: scm]: Mutating git actions: action=commit (message, stage_all), branch (create+switch), checkout (switch; create=true for -b). Approval-gated. Use scm_sync for pull/push and git_query for reads.\n- **test_run**(command, cwd, timeout): Run the project's test suite and return structured results (framework, passed/failed counts, failing tests). Auto-detects the runner (pytest/npm/go/cargo) or uses a configured/explicit command; executes via the governed shell. ok=true means the runner executed — see 'success' for pass/fail.\n- **lint_check**(command, cwd, timeout): Run the project's linter and return a structured clean/issue result. Auto-detects the linter (ruff/eslint/go vet/clippy) or uses a configured/explicit command; executes via the governed shell. ok=true means the linter ran — see 'clean'.\n- **file_list**(path, pattern, depth): List files and directories at a given path. Use depth=1 or depth=2 to get a recursive tree in one call instead of listing each sub-directory separately.\n- **file_read**(path, max_lines, start_line, max_chars, mode): Read text file content with adaptive context governance. For large or unfamiliar files, prefer mode='outline' or mode='symbols' first, then use mode='raw' with start_line/max_lines for the specific range you actually need. For LeapFlow's own settings, use config_list / config_get / config_set — its config files are outside the workspace and not readable here.\n- **file_write**(path, content, mode) [capability_expand category: write]: Write content to a file (overwrite or append).\n- **code_search**(pattern, patterns, path, glob, ignore_case, multiline, max_results, context_lines): Search file CONTENTS by regex pattern across a directory tree (ripgrep-backed). Requires a regex pattern. NOT for listing or browsing directory contents — use file_list for that. Prefer this over shell_run grep: faster, skips VCS/dependency/build dirs, and returns structured path:line:column matches. Batch related lookups into ONE call via `patterns` (OR-combined, single pass) instead of issuing several separate searches. Use file_read for the surrounding context of a hit.\n- **file_find**(glob, path, max_results): Find files by a recursive glob pattern under a base path (e.g. '**/test_*.py' or '*.md'). Prefer this over shell_run find; skips VCS/dependency/build dirs.\n- **edit_file**(path, edits, dry_run, diff) [capability_expand category: write]: Apply targeted, anchored search-replace edits to an EXISTING text file (use file_write to create/overwrite). Each edit is {original_text, new_text, replace_all?}; original_text must match exactly and uniquely (or set replace_all) — a non-unique or missing anchor is rejected so files are never corrupted. Set dry_run to preview. Alternatively pass a unified 'diff' to apply its hunks as anchored edits. Far cheaper and safer than rewriting a whole file.\n- **shell_run**(command, cwd, timeout) [capability_expand category: shell]: Execute a one-shot shell command with timeout protection. Runs in the active workspace; paths resolving outside it are refused. Reach for a structured tool first when one fits — web_fetch for anything over HTTP(S), git_query/scm_sync for git, code_search/file_find/file_read for the repo, config_get/config_set for LeapFlow's own settings — because those report typed results, while a failed shell command can only be diagnosed from its exit code and stderr. Every shell run counts as an external side effect, so a failure stops the rest of the batch and is not retried automatically.\n- **terminal_open**(command, cwd, shell) [capability_expand category: terminal]: Open a PERSISTENT shell session (REPL/dev server/watch), returning a session_id for terminal_send/read/close. Disabled unless tools.terminal_session_enabled is set. For one-shot commands use shell_run instead.\n- **terminal_send**(session_id, input, wait) [capability_expand category: terminal]: Send a line of input to a persistent terminal session and return output captured shortly after.\n- **terminal_read**(session_id, wait): Drain buffered output from a persistent terminal session (optionally waiting briefly first).\n- **terminal_close**(session_id) [capability_expand category: terminal]: Terminate a persistent terminal session and release its process group.\n- **terminal_list**(): List active persistent terminal sessions.\n- **config_list**(category, limit): List LeapFlow's own writable settings (model, provider, daemon, memory, perception, gateway, …) with current values. Use this to discover the exact key before changing anything. Optionally narrow by `category`. This is the only correct way to inspect LeapFlow configuration — never read config files from disk.\n- **config_get**(key): Read one LeapFlow setting by key (e.g. 'llm.model', 'daemon.log_level'), returning its current value, type, scopes, and whether a change needs a daemon restart. Never read LeapFlow config files from disk — use this.\n- **config_set**(key, value, scope) [capability_expand category: config]: Change one LeapFlow setting by key, e.g. switch the model with key='llm.model'. Values are validated and coerced; credentials are stored in the vault automatically. Call config_list or config_get first if unsure of the key. The result states whether a `leap daemon restart` is required. Never edit LeapFlow config files directly.\n- **web_fetch**(url, select, timeout, max_bytes): Read a URL over HTTP(S) and get back extracted, context-sized content: parsed JSON for API endpoints, readable text plus links for web pages. Use this for anything on the internet — prices, docs, releases, articles — instead of running curl through shell_run: it reports real HTTP status codes, retries rate limits on its own, and is a plain read so a retry is always safe. For JSON APIs pass `select` with a dotted path (e.g. 'chart.result.0.meta') to return just that part instead of the whole payload.\n- **memory_search**(query, limit): Search agent memory for relevant past experiences, observations, and facts.\n- **memory_add**(content, kind) [capability_expand category: write]: Store a new observation or insight in memory for future reference.\n- **research_note**(kind, text): Record a compact, structured note about the current task's state so it survives context compression on long / multi-step tasks. Use for durable findings, open questions still to resolve, decisions / excluded paths, and the immediate next step. One concise sentence per note.\n- **capability_expand**(category): Fetch the full callable schema for every tool in a capability category (e.g. 'hub', 'gateway', 'desktop', '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.\n- **delegate_task**(goal, context) [capability_expand category: delegate]: Delegate a complex sub-task to an isolated subagent. The subagent gets a fresh context and restricted tool access. Use when a task is self-contained and can be solved independently.\n- **schedule_reentry**(kind, reason, delay_seconds, event_match, max_reentries, deadline_seconds): Register a re-entry so this task can resume later from its current orientation (findings / open questions / next step). Use when work must pause and continue after a delay (kind=time) or when a matching platform event arrives (kind=event), instead of finishing now. The research-ledger state is carried over automatically.\n- **hub_push**(skill_name, visibility, version) [capability_expand category: hub]: Push a local skill to the ModelScope Hub for sharing or backup.\n- **hub_pull**(repo_id, version) [capability_expand category: hub]: Pull a skill from the ModelScope Hub to install locally.\n- **hub_search**(query) [capability_expand category: hub]: Search for skills on the Hub by keyword or description.\n- **hub_sync**(mode, dry_run) [capability_expand category: hub]: Preview or execute sync between local skills and Hub.\n- **platform_action**(platform, action, payload, backend_kind) [capability_expand category: gateway]: Execute an exact registered business action on an external platform through LeapFlow's App Connector layer. Actions must be copied from the App Connector Capability Index and are addressed as domain.operation, e.g. im.send_message or docs.create_markdown. All business fields (chat_id, text, query, etc.) MUST be placed inside `payload`, never at the top level. Example: {\"platform\":\"feishu\",\"action\":\"im.send_message\",\"payload\":{\"chat_id\":\"oc_xxx\",\"text\":\"hello\"}}. Do not invent action names, do not use management actions such as list/guide/connect/status here.\n- **platform_connect**(action, platform, credentials, options, checkpoint) [capability_expand category: gateway]: List, guide, connect, disconnect, remove, or check status for external platforms using the App Connector management namespace. Supports REST and CLI backends. Use this for management actions such as list/guide/preflight/connect/status; use platform_action only for exact registered business actions.\n- **gateway_send**(platform, chat_id, text, thread_id) [capability_expand category: gateway]: Send a message to a connected external platform (Feishu group, Telegram chat, DingTalk conversation, etc.). Requires the platform to be connected via gateway_connect first. Use gateway_connect with action='list' to see connected platforms and available chat IDs.\n- **gateway_connect**(action, platform, credentials, options) [capability_expand category: gateway]: Connect, configure, or manage external platform integrations (Feishu, DingTalk, Telegram, Slack, Discord, etc.). Conversational flow: 1) call 'guide' to get setup steps + required fields, 2) present the steps to the user and ask for ALL required credentials in a single message, 3) call 'connect' with the credentials. Goal: complete in 1–2 user turns. NEVER include credential values in your text response.\n- **plugin_list**(): List the live plugin registry and cross-subsystem capability evidence. Use this before answering questions about whether LeapFlow supports plugins, self-evolution, plugin installation, hot reload, versioning, or other runtime capabilities.\n- **plugin_status**(plugin_id): Get detailed status of a specific plugin: its declared category, runtime dependencies, contributed tools, and fiber lifecycle state.\n- **plugin_versions**(plugin_id): List recorded source versions and active pointer for a profile-scoped plugin.\n- **plugin_propose**(requested_capability, plugin_id, proposed_tools, test_cases, risk_level, evidence): Create a side-effect-free PluginProposal from explicit capability-gap evidence. Use this before plugin_generate when a missing capability should be reviewed. Does not call an LLM, write files, or install anything.\n- **assess_compatibility**(manifest, source_path) [capability_expand category: plugin_management]: Assess whether a foreign plugin manifest or real DSH source bundle is compatible with LeapFlow. A source bundle assessment is static: runtime_ready stays false until restricted Node discovery during plugin_install. Returns component-level verdicts and limitations.\n- **plugin_generate**(plugin_id, description, proposal_id) [capability_expand category: system]: Generate a new ToolPlugin from a natural-language capability description. The LLM produces code that conforms to the ToolPlugin Protocol; it is then rigorously validated (syntax, structure, import, protocol conformance). The isolated sandbox smoke test runs later, at install-time. Returns the validated code but DOES NOT install it — installation is a separate approval-gated step via plugin_install.\n- **plugin_install**(plugin_id, code, marketplace_name, source_path, proposal_id, version_label) [capability_expand category: system]: Install a plugin from exactly one source: validated Python code, the configured Python marketplace, or a real DSH source bundle. DSH bundles run restricted Node discovery before registration; only public host tools are installed and client UI limitations are reported. Writes profile-scoped state and mutates the process-global registry. REQUIRES APPROVAL.\n- **plugin_rollback**(plugin_id, version) [capability_expand category: system]: Rollback a profile-scoped plugin to a recorded source version and reload it. REQUIRES APPROVAL.\n- **plugin_reload**(plugin_id, version_label) [capability_expand category: system]: Hot-reload a plugin at runtime. Disposes the old plugin fiber, re-imports its module, and registers a fresh instance. Existing in-flight turns are unaffected (snapshot isolation). REQUIRES APPROVAL — this is a self-modification action.\n- **plugin_disable**(plugin_id) [capability_expand category: system]: Disable a plugin by disposing its fiber, removing its tools from the runtime registry. Cannot disable self_management itself. REQUIRES APPROVAL — this is a self-modification action.\n- **plugin_remove**(plugin_id, delete_source) [capability_expand category: system]: Terminally remove a plugin: dispose its fiber, unregister its tools, remove reload metadata, and optionally delete its profile-scoped source file. Cannot remove self_management itself. REQUIRES APPROVAL.\n- **plugin_enable**(plugin_id) [capability_expand category: system]: Re-enable a previously disabled plugin by reloading its module and registering a fresh instance. REQUIRES APPROVAL.\n- **session_search**(query, limit) [capability_expand category: unclassified]: Search past conversation sessions in the current workspace for relevant context.\n- **session_list**(limit): List recent conversation sessions in the current workspace with stable ids, titles, dates, and summaries. Use for browsing past tasks or when user asks to see history without specific search terms. No keywords needed — returns chronological list.\n- **session_detail**(session_id, limit, offset, include_inactive) [capability_expand category: unclassified]: Read a paginated persisted transcript for one past conversation session in the current workspace. Use after session_list or session_search returns a session_id.\n- **click**(element_index) [capability_expand category: desktop]: Click a UI element by its element_index (from the latest observe_ui snapshot)\n- **get_clipboard**() [capability_expand category: desktop]: Read current clipboard text content\n- **list_apps**(filter, running_only) [capability_expand category: desktop]: List available applications on this system. Use to discover correct bundle_id before switch_app.\n- **list_windows**() [capability_expand category: desktop]: List all top-level windows with pid, window_id, title, and per-window state (minimized, on-screen). Call this first to pick the pid and window_id that observe_ui and other window tools require.\n- **observe_ui**(pid, window_id, query) [capability_expand category: desktop]: Snapshot one window's actionable UI elements, each tagged with an element_index for click/right_click/read_text. Re-observe after actions — indices belong to one snapshot. Requires the window's pid and window_id from list_windows.\n- **open_url**(url, app_id) [capability_expand category: desktop]: Open a URL in the default or specified browser\n- **read_text**(element_index) [capability_expand category: desktop]: Read the text content of a specific UI element from the latest snapshot\n- **right_click**(element_index) [capability_expand category: desktop]: Right-click a UI element to open its context menu. Returns visible menu items.\n- **screenshot**(pid, window_id) [capability_expand category: desktop]: Capture a screenshot for visual verification. With pid + window_id captures that window (works across all displays); defaults to the last observed window, or the full desktop when no window has been observed.\n- **scroll**(element_index, direction, amount, pid, window_id) [capability_expand category: desktop]: Scroll a scrollable area of a window. Omit element_index to scroll the window's focused/page scroller; pass one to scroll an exact element from the latest snapshot.\n- **select_text**(element_index) [capability_expand category: desktop]: Select all text in a UI element (focus + select-all, for subsequent copy)\n- **set_clipboard**(text) [capability_expand category: desktop]: Write text to the clipboard\n- **shortcut**(keys) [capability_expand category: desktop]: Execute a keyboard shortcut\n- **switch_app**(app_id) [capability_expand category: desktop]: Switch to an app (launch if needed, activate, verify)\n- **type_text**(text) [capability_expand category: desktop]: Type text into the currently focused element\n- **wait**(seconds) [capability_expand category: desktop]: Wait for a specified duration before continuing\n- **wait_until**(condition, pid, window_id, timeout, poll_interval) [capability_expand category: desktop]: Wait until a UI condition is met (polls UI tree). Returns elements when found or on timeout.\n- **wait_until_stable**(timeout, poll_interval, pid, window_id) [capability_expand category: desktop]: Wait until the UI stops changing (element set stabilizes across polls).\n\n## App Connector Capability Index\nLeapFlow can onboard and manage external apps through `platform_connect` and execute exact registered business actions through `platform_action`.\nFor requests about connecting, setting up, configuring, enabling, or managing a supported app, use `platform_connect` first instead of generating SDK/Webhook sample code.\n`platform_connect.action` is the management namespace: list, guide, preflight, connect, disconnect, remove, status, events_start, events_stop, events_status.\n`platform_action.action` is only for exact registered platform business actions listed below, such as `im.send_message`; never use management actions like `list` or `guide` there.\nAll business fields MUST go inside `payload`; top-level keys are only `platform`, `action`, and `payload`.\nDo not invent platform IDs or platform action names. If the needed action is not listed, ask for discovery/clarification instead of guessing.\nUse `platform_connect` with `action='guide'` and the matching `platform` to start onboarding; use `action='list'` when the app is unclear.\nWhen a pending onboarding state is present, continue from that state with `platform_connect` instead of asking the user to restate the app.\nSupported apps:\n- `api_server`: API Server (OpenAI Compatible) (category=api; backend=adapter; platform_action actions=none registered)\n- `dingtalk`: 钉钉 (DingTalk) (category=im; backend=adapter; platform_action actions=none registered)\n- `feishu`: 飞书 (Feishu/Lark) (category=im; backend=cli)\n - `calendar.create_event` payload={summary*, start_time*, end_time*, attendees} [write/high]\n - `docs.create_markdown` payload={title*, markdown*, folder_token} [write/medium]\n - `drive.search` payload={query*, limit} [read/medium]\n - `im.add_reaction` payload={message_id*, emoji_type*} [write/low]\n - `im.download_resource` payload={message_id*, file_key*, type*} [read/low]\n - `im.get_messages` payload={message_ids*} [read/low]\n - `im.list_chats` payload={page_size} [read/low]\n - `im.list_messages` payload={chat_id*} [read/low]\n - `im.list_thread_messages` payload={thread*} [read/low]\n - `im.remove_reaction` payload={message_id*, emoji_type*} [write/low]\n - `im.reply_message` payload={message_id*, text*} [send/high]\n - `im.search_chats` payload={query*, page_size} [read/low]\n - `im.search_messages` payload={query*} [read/medium]\n - `im.send_message` payload={chat_id*, text*, thread_id} [send/high]\n - `im.update_card` payload={token*, card*} [write/medium]\n - `im.update_message` payload={message_id*, text*} [write/medium]\n - `mail.search_unread` payload={query, limit} [read/high]\n - `sheets.append_row` payload={spreadsheet_token*, sheet_id*, values*} [write/medium]\n - `task.create` payload={title*, description, due_time} [write/medium]\n- `telegram`: Telegram (category=social; backend=adapter; platform_action actions=none registered)\n- `webhook`: Webhook (Generic) (category=webhook; backend=adapter; platform_action actions=none registered)\n\n## Tool Usage\nTools are normally invoked through the native function-calling mechanism, not by writing JSON in your reply\ntext. Only if the provider signals that native function calling is unavailable for this turn, fall back to a\nsingle JSON code block: `{\"name\": \"tool_name\", \"arguments\": {\"key\": \"value\"}}` — use this fallback format\nonly, never both. Only call a tool whose exact name appears in the tool index above and is currently callable\n(or reachable via `capability_expand`). Never invent, rename, alias, or guess a tool name, platform ID, or\nplatform action from argument shape or wording — if the index does not list it, it does not exist.\n`platform_connect.action` (list/guide/preflight/connect/disconnect/remove/status/events_start/events_stop/\nevents_status) is the App Connector management namespace; `platform_action.action` only accepts exact\nregistered business actions such as `im.send_message` shown in the App Connector Capability Index — never mix\nthe two namespaces. If a tool call returns an unknown/unavailable result, use the returned suggestions or\navailable names for a single retry instead of trying further variations of the same guess.\n\n**Side-effect action rule** (`platform_action` with effect=send/write/execute):\n- Call each unique action+payload **exactly once**. Never include duplicates in the same turn.\n- Once the result returns `\"completed\": true`, that action is DONE for this task. Do NOT call it again\n in any subsequent turn — immediately summarize the result for the user instead.\n- The system enforces idempotency: duplicate calls are blocked and will not execute.\n- If the user explicitly requests sending/writing multiple times, use distinct payloads per call.\n\n**Resource identifier provenance rule**:\n- NEVER fabricate, guess, or infer resource identifiers (chat_id, message_id, file_key, user_id, etc.).\n Every resource ID used in a side-effect action MUST come from a successful API response in this session.\n- If a read/list action fails (e.g. authorization error), you do NOT have valid resource IDs.\n Report the failure to the user — do NOT attempt the dependent write/send action with a guessed ID.\n- When a tool result contains `\"llm_instruction\"`, follow it exactly.\n\n## Guidelines\n1. **Direct answers first**: If you already know the answer, respond directly without tools.\n2. **Avoid redundant tool calls**: Do not call the same tool with the same arguments more than once in the same user turn. When an existing tool result already answers the user's request, stop calling tools and answer directly.\n3. **Use tools proactively**: When the user asks about files, time, system state, or needs actions performed, use the appropriate tool.\n4. **Chain tools when needed**: You can call multiple tools in sequence (e.g., list files → read file → summarize).\n5. **Handle failures gracefully**: If a tool fails, explain what went wrong and suggest alternatives. If it failed because of an unknown tool/platform/action name, retry once with an exact name from the returned suggestions, then explain rather than keep guessing.\n6. **Summarize results naturally**: After tool execution, synthesize the results into a helpful answer rather than dumping raw output.\n7. **Stay conversational**: Maintain a natural, helpful tone. Acknowledge context from earlier in the conversation.\n8. **Recall past work**:\n - Broad queries (\"之前做了什么\", \"列出任务\"): answer from the \"Recent Task History\" section already in your context. If insufficient, call session_list.\n - Specific lookups (\"上次那个配置怎么改的\"): call session_search with relevant phrases (NOT single characters).\n - Do NOT call search tools repeatedly with keyword fragments. One well-phrased call is better than ten fragmented ones.\n\n## Coding & Verification\nWhen working with code, prefer the precise built-in tools over ad-hoc shell: use `repo_map` to orient in an\nunfamiliar project, `code_search`/`file_find` to locate and `code_intel` for symbols, `edit_file` (anchored\nsearch-replace, or a unified `diff`) to change files — never rewrite a whole file to change a few lines — and\n`git_query` to inspect diffs/log. After edits, check `syntax_ok` in the result and run `test_run`/`lint_check`\nbefore declaring the work done. Batch independent read-only calls (search/read/query) into a single turn — they\nrun in parallel.\n\n## Presentation Style\n1. **Polished Markdown only**: Format user-facing answers with clean Markdown headings, short paragraphs, and concise bullets. Use tables only when they improve comparison or scanning.\n2. **Terminal-friendly layout**: Keep lines readable in a TUI; avoid dense walls of text, deeply nested lists, oversized ASCII art, or heavy visual blocks.\n3. **Elegant emphasis**: Use bold text sparingly for key terms and conclusions. Avoid excessive emojis, decorative symbols, repeated separators, or visual noise.\n4. **Theme-safe colors**: Do not emit ANSI escape codes, HTML color tags, Rich markup, or hardcoded color names. Rely on the TUI theme to render Markdown professionally.\n5. **No leaked tool protocol**: Never show tool-call JSON, internal schemas, raw observations, tool result payloads, or hidden reasoning in the final answer unless the user explicitly asks for raw/debug output. Treat any prior `{\"name\": ..., \"arguments\": ...}` blocks and `Tool result (...)` messages as internal execution context only.\n6. **Professional closure**: End with a concise conclusion or next step when helpful; avoid rambling after the useful answer is complete.\n\nWhen finished with all tool calls, respond normally without a JSON block, tool-call transcript, or process log.\n\n\n## Recent Session Summary\n- [user] Install the hermetic DSH echo plugin from this workspace.\n\n## Task Contract\n- Task ID: turn-1\n- Original user request: Install the hermetic DSH echo plugin from this workspace.\n- Workspace root: \n- Allowed roots: \n- Treat relative project paths as relative to the workspace root; never infer `.` as the project root when a workspace root is provided.\n- Workspace boundary is enforced by tools: do not read, search, edit, or run commands against paths outside the allowed roots unless the user explicitly requests an external path and the tool/approval policy permits it.\n- LeapFlow workspace config is optional at `/.leapflow/config.yaml`; runtime config is loaded from `~/.leapflow/config/user.yaml` and `~/.leapflow/profiles//config/*.yaml`.\n- Preserve this task contract across summarization, compression, tool loops, and memory retrieval.\n" + }, + { + "role": "user", + "content": "Install the hermetic DSH echo plugin from this workspace.\nInstall the hermetic DSH echo plugin from this workspace." + } + ], + "tools": [ + "capability_expand", + "code_intel", + "code_search", + "config_get", + "config_list", + "env_info", + "file_find", + "file_list", + "file_read", + "git_query", + "lint_check", + "memory_search", + "plugin_list", + "plugin_propose", + "plugin_status", + "plugin_versions", + "repo_map", + "research_note", + "schedule_reentry", + "session_list", + "skill_view", + "skills_list", + "terminal_list", + "terminal_read", + "test_run", + "text_search", + "time_get", + "web_fetch" + ] + }, + "responses": [ + { + "status": 200, + "content_type": "application/json", + "body": "{\"id\": \"chatcmpl-cassette\", \"object\": \"chat.completion\", \"model\": \"cassette-model\", \"choices\": [{\"index\": 0, \"message\": {\"role\": \"assistant\", \"content\": \"\", \"tool_calls\": [{\"id\": \"call_1\", \"type\": \"function\", \"function\": {\"name\": \"plugin_install\", \"arguments\": \"{\\\"plugin_id\\\": \\\"r6_dsh_echo\\\", \\\"source_path\\\": \\\"/tmp/lfj-r6_lifecycle/workspaces/life/dsh-echo\\\", \\\"version_label\\\": \\\"r6\\\"}\"}}]}, \"finish_reason\": \"tool_calls\"}], \"usage\": {\"prompt_tokens\": 64, \"completion_tokens\": 16, \"total_tokens\": 80}}" + } + ] +} diff --git a/tests/_fixtures/cassettes/r6_lifecycle/cassette-model-6b0fa7950b180fdd.cassette.json b/tests/_fixtures/cassettes/r6_lifecycle/cassette-model-6b0fa7950b180fdd.cassette.json new file mode 100644 index 0000000..995a4db --- /dev/null +++ b/tests/_fixtures/cassettes/r6_lifecycle/cassette-model-6b0fa7950b180fdd.cassette.json @@ -0,0 +1,55 @@ +{ + "fingerprint": "6b0fa7950b180fdd6712fa9f6967768019769ce533ed49349989866f401c0d3b", + "note": "captured in seed mode", + "request": { + "model": "cassette-model", + "stream": false, + "messages": [ + { + "role": "system", + "content": "You are LeapFlow, an intelligent assistant that can both converse naturally and take real actions on the user's computer.\n\n## Capabilities\nThe tool index below lists **every** registered tool by name and a one-line summary — this is the complete\ncapability contract; nothing else exists. Only a subset is directly callable this turn (via native tool calling,\nnot a JSON block in your reply). If you need a tool from the index that is not yet callable, call\n`capability_expand` with its category name first — the matching tools become callable immediately after.\nWhen the user asks what LeapFlow itself supports, whether it supports plugins, or which runtime capabilities\nare available, use the live capability evidence exposed by `plugin_list` before making capability claims; report\nconfiguration-dependent or unavailable capabilities as limitations instead of inferring from documentation.\n- **text_search**(text, pattern): Search for a regex pattern in text.\n- **text_replace**(text, old, new, count) [capability_expand category: write]: Replace occurrences of a substring in text.\n- **time_get**(): Get current date and time.\n- **env_info**(): Get system environment information (OS, Python version, cwd).\n- **skills_list**(query, category, source): List available learned skills. Use when user asks about capabilities or you need a specific skill.\n- **skill_view**(name): View the full content of a specific skill document.\n- **code_intel**(path, operation): Precise document symbols (outline) for a source file: classes, functions, and methods with line ranges. Python uses an exact AST parse; other languages use a keyword-prefix scan. Prefer over file_read mode=symbols for accurate navigation before editing. Read-only.\n- **repo_map**(path): Compact project orientation for a repository root: languages, detected test/lint commands, top-level structure, entry points, manifest, and VCS branch. Call this first when entering an unfamiliar codebase. Read-only.\n- **scm_sync**(action, cwd, remote, pull_ref, push_ref, timeout) [capability_expand category: scm]: Run a typed git SCM action. Use this instead of shell_run for git pull/push/status. For 'pull origin main then push', set action='pull_then_push', remote='origin', pull_ref='main', and omit push_ref so LeapFlow pushes the current local branch.\n- **git_query**(action, cwd, ref, path, staged, max_count, stat): Read-only structured git inspection: action=diff|log|status|branch|show. Prefer over shell_run for reading repo state — output is clipped, redacted, and log/branch are parsed into structured fields. Use scm_sync for pull/push.\n- **git_write**(action, cwd, message, stage_all, name, ref, create) [capability_expand category: scm]: Mutating git actions: action=commit (message, stage_all), branch (create+switch), checkout (switch; create=true for -b). Approval-gated. Use scm_sync for pull/push and git_query for reads.\n- **test_run**(command, cwd, timeout): Run the project's test suite and return structured results (framework, passed/failed counts, failing tests). Auto-detects the runner (pytest/npm/go/cargo) or uses a configured/explicit command; executes via the governed shell. ok=true means the runner executed — see 'success' for pass/fail.\n- **lint_check**(command, cwd, timeout): Run the project's linter and return a structured clean/issue result. Auto-detects the linter (ruff/eslint/go vet/clippy) or uses a configured/explicit command; executes via the governed shell. ok=true means the linter ran — see 'clean'.\n- **file_list**(path, pattern, depth): List files and directories at a given path. Use depth=1 or depth=2 to get a recursive tree in one call instead of listing each sub-directory separately.\n- **file_read**(path, max_lines, start_line, max_chars, mode): Read text file content with adaptive context governance. For large or unfamiliar files, prefer mode='outline' or mode='symbols' first, then use mode='raw' with start_line/max_lines for the specific range you actually need. For LeapFlow's own settings, use config_list / config_get / config_set — its config files are outside the workspace and not readable here.\n- **file_write**(path, content, mode) [capability_expand category: write]: Write content to a file (overwrite or append).\n- **code_search**(pattern, patterns, path, glob, ignore_case, multiline, max_results, context_lines): Search file CONTENTS by regex pattern across a directory tree (ripgrep-backed). Requires a regex pattern. NOT for listing or browsing directory contents — use file_list for that. Prefer this over shell_run grep: faster, skips VCS/dependency/build dirs, and returns structured path:line:column matches. Batch related lookups into ONE call via `patterns` (OR-combined, single pass) instead of issuing several separate searches. Use file_read for the surrounding context of a hit.\n- **file_find**(glob, path, max_results): Find files by a recursive glob pattern under a base path (e.g. '**/test_*.py' or '*.md'). Prefer this over shell_run find; skips VCS/dependency/build dirs.\n- **edit_file**(path, edits, dry_run, diff) [capability_expand category: write]: Apply targeted, anchored search-replace edits to an EXISTING text file (use file_write to create/overwrite). Each edit is {original_text, new_text, replace_all?}; original_text must match exactly and uniquely (or set replace_all) — a non-unique or missing anchor is rejected so files are never corrupted. Set dry_run to preview. Alternatively pass a unified 'diff' to apply its hunks as anchored edits. Far cheaper and safer than rewriting a whole file.\n- **shell_run**(command, cwd, timeout) [capability_expand category: shell]: Execute a one-shot shell command with timeout protection. Runs in the active workspace; paths resolving outside it are refused. Reach for a structured tool first when one fits — web_fetch for anything over HTTP(S), git_query/scm_sync for git, code_search/file_find/file_read for the repo, config_get/config_set for LeapFlow's own settings — because those report typed results, while a failed shell command can only be diagnosed from its exit code and stderr. Every shell run counts as an external side effect, so a failure stops the rest of the batch and is not retried automatically.\n- **terminal_open**(command, cwd, shell) [capability_expand category: terminal]: Open a PERSISTENT shell session (REPL/dev server/watch), returning a session_id for terminal_send/read/close. Disabled unless tools.terminal_session_enabled is set. For one-shot commands use shell_run instead.\n- **terminal_send**(session_id, input, wait) [capability_expand category: terminal]: Send a line of input to a persistent terminal session and return output captured shortly after.\n- **terminal_read**(session_id, wait): Drain buffered output from a persistent terminal session (optionally waiting briefly first).\n- **terminal_close**(session_id) [capability_expand category: terminal]: Terminate a persistent terminal session and release its process group.\n- **terminal_list**(): List active persistent terminal sessions.\n- **config_list**(category, limit): List LeapFlow's own writable settings (model, provider, daemon, memory, perception, gateway, …) with current values. Use this to discover the exact key before changing anything. Optionally narrow by `category`. This is the only correct way to inspect LeapFlow configuration — never read config files from disk.\n- **config_get**(key): Read one LeapFlow setting by key (e.g. 'llm.model', 'daemon.log_level'), returning its current value, type, scopes, and whether a change needs a daemon restart. Never read LeapFlow config files from disk — use this.\n- **config_set**(key, value, scope) [capability_expand category: config]: Change one LeapFlow setting by key, e.g. switch the model with key='llm.model'. Values are validated and coerced; credentials are stored in the vault automatically. Call config_list or config_get first if unsure of the key. The result states whether a `leap daemon restart` is required. Never edit LeapFlow config files directly.\n- **web_fetch**(url, select, timeout, max_bytes): Read a URL over HTTP(S) and get back extracted, context-sized content: parsed JSON for API endpoints, readable text plus links for web pages. Use this for anything on the internet — prices, docs, releases, articles — instead of running curl through shell_run: it reports real HTTP status codes, retries rate limits on its own, and is a plain read so a retry is always safe. For JSON APIs pass `select` with a dotted path (e.g. 'chart.result.0.meta') to return just that part instead of the whole payload.\n- **memory_search**(query, limit): Search agent memory for relevant past experiences, observations, and facts.\n- **memory_add**(content, kind) [capability_expand category: write]: Store a new observation or insight in memory for future reference.\n- **research_note**(kind, text): Record a compact, structured note about the current task's state so it survives context compression on long / multi-step tasks. Use for durable findings, open questions still to resolve, decisions / excluded paths, and the immediate next step. One concise sentence per note.\n- **capability_expand**(category): Fetch the full callable schema for every tool in a capability category (e.g. 'hub', 'gateway', 'desktop', '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.\n- **delegate_task**(goal, context) [capability_expand category: delegate]: Delegate a complex sub-task to an isolated subagent. The subagent gets a fresh context and restricted tool access. Use when a task is self-contained and can be solved independently.\n- **schedule_reentry**(kind, reason, delay_seconds, event_match, max_reentries, deadline_seconds): Register a re-entry so this task can resume later from its current orientation (findings / open questions / next step). Use when work must pause and continue after a delay (kind=time) or when a matching platform event arrives (kind=event), instead of finishing now. The research-ledger state is carried over automatically.\n- **hub_push**(skill_name, visibility, version) [capability_expand category: hub]: Push a local skill to the ModelScope Hub for sharing or backup.\n- **hub_pull**(repo_id, version) [capability_expand category: hub]: Pull a skill from the ModelScope Hub to install locally.\n- **hub_search**(query) [capability_expand category: hub]: Search for skills on the Hub by keyword or description.\n- **hub_sync**(mode, dry_run) [capability_expand category: hub]: Preview or execute sync between local skills and Hub.\n- **platform_action**(platform, action, payload, backend_kind) [capability_expand category: gateway]: Execute an exact registered business action on an external platform through LeapFlow's App Connector layer. Actions must be copied from the App Connector Capability Index and are addressed as domain.operation, e.g. im.send_message or docs.create_markdown. All business fields (chat_id, text, query, etc.) MUST be placed inside `payload`, never at the top level. Example: {\"platform\":\"feishu\",\"action\":\"im.send_message\",\"payload\":{\"chat_id\":\"oc_xxx\",\"text\":\"hello\"}}. Do not invent action names, do not use management actions such as list/guide/connect/status here.\n- **platform_connect**(action, platform, credentials, options, checkpoint) [capability_expand category: gateway]: List, guide, connect, disconnect, remove, or check status for external platforms using the App Connector management namespace. Supports REST and CLI backends. Use this for management actions such as list/guide/preflight/connect/status; use platform_action only for exact registered business actions.\n- **gateway_send**(platform, chat_id, text, thread_id) [capability_expand category: gateway]: Send a message to a connected external platform (Feishu group, Telegram chat, DingTalk conversation, etc.). Requires the platform to be connected via gateway_connect first. Use gateway_connect with action='list' to see connected platforms and available chat IDs.\n- **gateway_connect**(action, platform, credentials, options) [capability_expand category: gateway]: Connect, configure, or manage external platform integrations (Feishu, DingTalk, Telegram, Slack, Discord, etc.). Conversational flow: 1) call 'guide' to get setup steps + required fields, 2) present the steps to the user and ask for ALL required credentials in a single message, 3) call 'connect' with the credentials. Goal: complete in 1–2 user turns. NEVER include credential values in your text response.\n- **plugin_list**(): List the live plugin registry and cross-subsystem capability evidence. Use this before answering questions about whether LeapFlow supports plugins, self-evolution, plugin installation, hot reload, versioning, or other runtime capabilities.\n- **plugin_status**(plugin_id): Get detailed status of a specific plugin: its declared category, runtime dependencies, contributed tools, and fiber lifecycle state.\n- **plugin_versions**(plugin_id): List recorded source versions and active pointer for a profile-scoped plugin.\n- **plugin_propose**(requested_capability, plugin_id, proposed_tools, test_cases, risk_level, evidence): Create a side-effect-free PluginProposal from explicit capability-gap evidence. Use this before plugin_generate when a missing capability should be reviewed. Does not call an LLM, write files, or install anything.\n- **assess_compatibility**(manifest, source_path) [capability_expand category: plugin_management]: Assess whether a foreign plugin manifest or real DSH source bundle is compatible with LeapFlow. A source bundle assessment is static: runtime_ready stays false until restricted Node discovery during plugin_install. Returns component-level verdicts and limitations.\n- **plugin_generate**(plugin_id, description, proposal_id) [capability_expand category: system]: Generate a new ToolPlugin from a natural-language capability description. The LLM produces code that conforms to the ToolPlugin Protocol; it is then rigorously validated (syntax, structure, import, protocol conformance). The isolated sandbox smoke test runs later, at install-time. Returns the validated code but DOES NOT install it — installation is a separate approval-gated step via plugin_install.\n- **plugin_install**(plugin_id, code, marketplace_name, source_path, proposal_id, version_label) [capability_expand category: system]: Install a plugin from exactly one source: validated Python code, the configured Python marketplace, or a real DSH source bundle. DSH bundles run restricted Node discovery before registration; only public host tools are installed and client UI limitations are reported. Writes profile-scoped state and mutates the process-global registry. REQUIRES APPROVAL.\n- **plugin_rollback**(plugin_id, version) [capability_expand category: system]: Rollback a profile-scoped plugin to a recorded source version and reload it. REQUIRES APPROVAL.\n- **plugin_reload**(plugin_id, version_label) [capability_expand category: system]: Hot-reload a plugin at runtime. Disposes the old plugin fiber, re-imports its module, and registers a fresh instance. Existing in-flight turns are unaffected (snapshot isolation). REQUIRES APPROVAL — this is a self-modification action.\n- **plugin_disable**(plugin_id) [capability_expand category: system]: Disable a plugin by disposing its fiber, removing its tools from the runtime registry. Cannot disable self_management itself. REQUIRES APPROVAL — this is a self-modification action.\n- **plugin_remove**(plugin_id, delete_source) [capability_expand category: system]: Terminally remove a plugin: dispose its fiber, unregister its tools, remove reload metadata, and optionally delete its profile-scoped source file. Cannot remove self_management itself. REQUIRES APPROVAL.\n- **plugin_enable**(plugin_id) [capability_expand category: system]: Re-enable a previously disabled plugin by reloading its module and registering a fresh instance. REQUIRES APPROVAL.\n- **fixture_echo**(text) [capability_expand category: bridge]: Return the supplied text from a pre-built DSH package.\n- **session_search**(query, limit) [capability_expand category: unclassified]: Search past conversation sessions in the current workspace for relevant context.\n- **session_list**(limit): List recent conversation sessions in the current workspace with stable ids, titles, dates, and summaries. Use for browsing past tasks or when user asks to see history without specific search terms. No keywords needed — returns chronological list.\n- **session_detail**(session_id, limit, offset, include_inactive) [capability_expand category: unclassified]: Read a paginated persisted transcript for one past conversation session in the current workspace. Use after session_list or session_search returns a session_id.\n- **click**(element_index) [capability_expand category: desktop]: Click a UI element by its element_index (from the latest observe_ui snapshot)\n- **get_clipboard**() [capability_expand category: desktop]: Read current clipboard text content\n- **list_apps**(filter, running_only) [capability_expand category: desktop]: List available applications on this system. Use to discover correct bundle_id before switch_app.\n- **list_windows**() [capability_expand category: desktop]: List all top-level windows with pid, window_id, title, and per-window state (minimized, on-screen). Call this first to pick the pid and window_id that observe_ui and other window tools require.\n- **observe_ui**(pid, window_id, query) [capability_expand category: desktop]: Snapshot one window's actionable UI elements, each tagged with an element_index for click/right_click/read_text. Re-observe after actions — indices belong to one snapshot. Requires the window's pid and window_id from list_windows.\n- **open_url**(url, app_id) [capability_expand category: desktop]: Open a URL in the default or specified browser\n- **read_text**(element_index) [capability_expand category: desktop]: Read the text content of a specific UI element from the latest snapshot\n- **right_click**(element_index) [capability_expand category: desktop]: Right-click a UI element to open its context menu. Returns visible menu items.\n- **screenshot**(pid, window_id) [capability_expand category: desktop]: Capture a screenshot for visual verification. With pid + window_id captures that window (works across all displays); defaults to the last observed window, or the full desktop when no window has been observed.\n- **scroll**(element_index, direction, amount, pid, window_id) [capability_expand category: desktop]: Scroll a scrollable area of a window. Omit element_index to scroll the window's focused/page scroller; pass one to scroll an exact element from the latest snapshot.\n- **select_text**(element_index) [capability_expand category: desktop]: Select all text in a UI element (focus + select-all, for subsequent copy)\n- **set_clipboard**(text) [capability_expand category: desktop]: Write text to the clipboard\n- **shortcut**(keys) [capability_expand category: desktop]: Execute a keyboard shortcut\n- **switch_app**(app_id) [capability_expand category: desktop]: Switch to an app (launch if needed, activate, verify)\n- **type_text**(text) [capability_expand category: desktop]: Type text into the currently focused element\n- **wait**(seconds) [capability_expand category: desktop]: Wait for a specified duration before continuing\n- **wait_until**(condition, pid, window_id, timeout, poll_interval) [capability_expand category: desktop]: Wait until a UI condition is met (polls UI tree). Returns elements when found or on timeout.\n- **wait_until_stable**(timeout, poll_interval, pid, window_id) [capability_expand category: desktop]: Wait until the UI stops changing (element set stabilizes across polls).\n\n## App Connector Capability Index\nLeapFlow can onboard and manage external apps through `platform_connect` and execute exact registered business actions through `platform_action`.\nFor requests about connecting, setting up, configuring, enabling, or managing a supported app, use `platform_connect` first instead of generating SDK/Webhook sample code.\n`platform_connect.action` is the management namespace: list, guide, preflight, connect, disconnect, remove, status, events_start, events_stop, events_status.\n`platform_action.action` is only for exact registered platform business actions listed below, such as `im.send_message`; never use management actions like `list` or `guide` there.\nAll business fields MUST go inside `payload`; top-level keys are only `platform`, `action`, and `payload`.\nDo not invent platform IDs or platform action names. If the needed action is not listed, ask for discovery/clarification instead of guessing.\nUse `platform_connect` with `action='guide'` and the matching `platform` to start onboarding; use `action='list'` when the app is unclear.\nWhen a pending onboarding state is present, continue from that state with `platform_connect` instead of asking the user to restate the app.\nSupported apps:\n- `api_server`: API Server (OpenAI Compatible) (category=api; backend=adapter; platform_action actions=none registered)\n- `dingtalk`: 钉钉 (DingTalk) (category=im; backend=adapter; platform_action actions=none registered)\n- `feishu`: 飞书 (Feishu/Lark) (category=im; backend=cli)\n - `calendar.create_event` payload={summary*, start_time*, end_time*, attendees} [write/high]\n - `docs.create_markdown` payload={title*, markdown*, folder_token} [write/medium]\n - `drive.search` payload={query*, limit} [read/medium]\n - `im.add_reaction` payload={message_id*, emoji_type*} [write/low]\n - `im.download_resource` payload={message_id*, file_key*, type*} [read/low]\n - `im.get_messages` payload={message_ids*} [read/low]\n - `im.list_chats` payload={page_size} [read/low]\n - `im.list_messages` payload={chat_id*} [read/low]\n - `im.list_thread_messages` payload={thread*} [read/low]\n - `im.remove_reaction` payload={message_id*, emoji_type*} [write/low]\n - `im.reply_message` payload={message_id*, text*} [send/high]\n - `im.search_chats` payload={query*, page_size} [read/low]\n - `im.search_messages` payload={query*} [read/medium]\n - `im.send_message` payload={chat_id*, text*, thread_id} [send/high]\n - `im.update_card` payload={token*, card*} [write/medium]\n - `im.update_message` payload={message_id*, text*} [write/medium]\n - `mail.search_unread` payload={query, limit} [read/high]\n - `sheets.append_row` payload={spreadsheet_token*, sheet_id*, values*} [write/medium]\n - `task.create` payload={title*, description, due_time} [write/medium]\n- `telegram`: Telegram (category=social; backend=adapter; platform_action actions=none registered)\n- `webhook`: Webhook (Generic) (category=webhook; backend=adapter; platform_action actions=none registered)\n\n## Tool Usage\nTools are normally invoked through the native function-calling mechanism, not by writing JSON in your reply\ntext. Only if the provider signals that native function calling is unavailable for this turn, fall back to a\nsingle JSON code block: `{\"name\": \"tool_name\", \"arguments\": {\"key\": \"value\"}}` — use this fallback format\nonly, never both. Only call a tool whose exact name appears in the tool index above and is currently callable\n(or reachable via `capability_expand`). Never invent, rename, alias, or guess a tool name, platform ID, or\nplatform action from argument shape or wording — if the index does not list it, it does not exist.\n`platform_connect.action` (list/guide/preflight/connect/disconnect/remove/status/events_start/events_stop/\nevents_status) is the App Connector management namespace; `platform_action.action` only accepts exact\nregistered business actions such as `im.send_message` shown in the App Connector Capability Index — never mix\nthe two namespaces. If a tool call returns an unknown/unavailable result, use the returned suggestions or\navailable names for a single retry instead of trying further variations of the same guess.\n\n**Side-effect action rule** (`platform_action` with effect=send/write/execute):\n- Call each unique action+payload **exactly once**. Never include duplicates in the same turn.\n- Once the result returns `\"completed\": true`, that action is DONE for this task. Do NOT call it again\n in any subsequent turn — immediately summarize the result for the user instead.\n- The system enforces idempotency: duplicate calls are blocked and will not execute.\n- If the user explicitly requests sending/writing multiple times, use distinct payloads per call.\n\n**Resource identifier provenance rule**:\n- NEVER fabricate, guess, or infer resource identifiers (chat_id, message_id, file_key, user_id, etc.).\n Every resource ID used in a side-effect action MUST come from a successful API response in this session.\n- If a read/list action fails (e.g. authorization error), you do NOT have valid resource IDs.\n Report the failure to the user — do NOT attempt the dependent write/send action with a guessed ID.\n- When a tool result contains `\"llm_instruction\"`, follow it exactly.\n\n## Guidelines\n1. **Direct answers first**: If you already know the answer, respond directly without tools.\n2. **Avoid redundant tool calls**: Do not call the same tool with the same arguments more than once in the same user turn. When an existing tool result already answers the user's request, stop calling tools and answer directly.\n3. **Use tools proactively**: When the user asks about files, time, system state, or needs actions performed, use the appropriate tool.\n4. **Chain tools when needed**: You can call multiple tools in sequence (e.g., list files → read file → summarize).\n5. **Handle failures gracefully**: If a tool fails, explain what went wrong and suggest alternatives. If it failed because of an unknown tool/platform/action name, retry once with an exact name from the returned suggestions, then explain rather than keep guessing.\n6. **Summarize results naturally**: After tool execution, synthesize the results into a helpful answer rather than dumping raw output.\n7. **Stay conversational**: Maintain a natural, helpful tone. Acknowledge context from earlier in the conversation.\n8. **Recall past work**:\n - Broad queries (\"之前做了什么\", \"列出任务\"): answer from the \"Recent Task History\" section already in your context. If insufficient, call session_list.\n - Specific lookups (\"上次那个配置怎么改的\"): call session_search with relevant phrases (NOT single characters).\n - Do NOT call search tools repeatedly with keyword fragments. One well-phrased call is better than ten fragmented ones.\n\n## Coding & Verification\nWhen working with code, prefer the precise built-in tools over ad-hoc shell: use `repo_map` to orient in an\nunfamiliar project, `code_search`/`file_find` to locate and `code_intel` for symbols, `edit_file` (anchored\nsearch-replace, or a unified `diff`) to change files — never rewrite a whole file to change a few lines — and\n`git_query` to inspect diffs/log. After edits, check `syntax_ok` in the result and run `test_run`/`lint_check`\nbefore declaring the work done. Batch independent read-only calls (search/read/query) into a single turn — they\nrun in parallel.\n\n## Presentation Style\n1. **Polished Markdown only**: Format user-facing answers with clean Markdown headings, short paragraphs, and concise bullets. Use tables only when they improve comparison or scanning.\n2. **Terminal-friendly layout**: Keep lines readable in a TUI; avoid dense walls of text, deeply nested lists, oversized ASCII art, or heavy visual blocks.\n3. **Elegant emphasis**: Use bold text sparingly for key terms and conclusions. Avoid excessive emojis, decorative symbols, repeated separators, or visual noise.\n4. **Theme-safe colors**: Do not emit ANSI escape codes, HTML color tags, Rich markup, or hardcoded color names. Rely on the TUI theme to render Markdown professionally.\n5. **No leaked tool protocol**: Never show tool-call JSON, internal schemas, raw observations, tool result payloads, or hidden reasoning in the final answer unless the user explicitly asks for raw/debug output. Treat any prior `{\"name\": ..., \"arguments\": ...}` blocks and `Tool result (...)` messages as internal execution context only.\n6. **Professional closure**: End with a concise conclusion or next step when helpful; avoid rambling after the useful answer is complete.\n\nWhen finished with all tool calls, respond normally without a JSON block, tool-call transcript, or process log.\n\n\n## Recent Session Summary\n- [user] Invoke fixture_echo with the text after restart.\n\n## Task Contract\n- Task ID: turn-1\n- Original user request: Invoke fixture_echo with the text after restart.\n- Workspace root: \n- Allowed roots: \n- Treat relative project paths as relative to the workspace root; never infer `.` as the project root when a workspace root is provided.\n- Workspace boundary is enforced by tools: do not read, search, edit, or run commands against paths outside the allowed roots unless the user explicitly requests an external path and the tool/approval policy permits it.\n- LeapFlow workspace config is optional at `/.leapflow/config.yaml`; runtime config is loaded from `~/.leapflow/config/user.yaml` and `~/.leapflow/profiles//config/*.yaml`.\n- Preserve this task contract across summarization, compression, tool loops, and memory retrieval.\n" + }, + { + "role": "user", + "content": "Invoke fixture_echo with the text after restart.\nInvoke fixture_echo with the text after restart." + } + ], + "tools": [ + "capability_expand", + "code_intel", + "code_search", + "config_get", + "config_list", + "env_info", + "file_find", + "file_list", + "file_read", + "git_query", + "lint_check", + "memory_search", + "plugin_list", + "plugin_propose", + "plugin_status", + "plugin_versions", + "repo_map", + "research_note", + "schedule_reentry", + "session_list", + "skill_view", + "skills_list", + "terminal_list", + "terminal_read", + "test_run", + "text_search", + "time_get", + "web_fetch" + ] + }, + "responses": [ + { + "status": 200, + "content_type": "application/json", + "body": "{\"id\": \"chatcmpl-cassette\", \"object\": \"chat.completion\", \"model\": \"cassette-model\", \"choices\": [{\"index\": 0, \"message\": {\"role\": \"assistant\", \"content\": \"\", \"tool_calls\": [{\"id\": \"call_1\", \"type\": \"function\", \"function\": {\"name\": \"fixture_echo\", \"arguments\": \"{\\\"text\\\": \\\"after restart\\\"}\"}}]}, \"finish_reason\": \"tool_calls\"}], \"usage\": {\"prompt_tokens\": 64, \"completion_tokens\": 16, \"total_tokens\": 80}}" + } + ] +} diff --git a/tests/_fixtures/cassettes/r6_lifecycle/cassette-model-7864f96633b6d145.cassette.json b/tests/_fixtures/cassettes/r6_lifecycle/cassette-model-7864f96633b6d145.cassette.json new file mode 100644 index 0000000..da93d8a --- /dev/null +++ b/tests/_fixtures/cassettes/r6_lifecycle/cassette-model-7864f96633b6d145.cassette.json @@ -0,0 +1,59 @@ +{ + "fingerprint": "7864f96633b6d145e22e563b422921a77689b1b9f73ce9902761de0939d136c2", + "note": "captured in seed mode", + "request": { + "model": "cassette-model", + "stream": false, + "messages": [ + { + "role": "system", + "content": "You are LeapFlow, an intelligent assistant that can both converse naturally and take real actions on the user's computer.\n\n## Capabilities\nThe tool index below lists **every** registered tool by name and a one-line summary — this is the complete\ncapability contract; nothing else exists. Only a subset is directly callable this turn (via native tool calling,\nnot a JSON block in your reply). If you need a tool from the index that is not yet callable, call\n`capability_expand` with its category name first — the matching tools become callable immediately after.\nWhen the user asks what LeapFlow itself supports, whether it supports plugins, or which runtime capabilities\nare available, use the live capability evidence exposed by `plugin_list` before making capability claims; report\nconfiguration-dependent or unavailable capabilities as limitations instead of inferring from documentation.\n- **text_search**(text, pattern): Search for a regex pattern in text.\n- **text_replace**(text, old, new, count) [capability_expand category: write]: Replace occurrences of a substring in text.\n- **time_get**(): Get current date and time.\n- **env_info**(): Get system environment information (OS, Python version, cwd).\n- **skills_list**(query, category, source): List available learned skills. Use when user asks about capabilities or you need a specific skill.\n- **skill_view**(name): View the full content of a specific skill document.\n- **code_intel**(path, operation): Precise document symbols (outline) for a source file: classes, functions, and methods with line ranges. Python uses an exact AST parse; other languages use a keyword-prefix scan. Prefer over file_read mode=symbols for accurate navigation before editing. Read-only.\n- **repo_map**(path): Compact project orientation for a repository root: languages, detected test/lint commands, top-level structure, entry points, manifest, and VCS branch. Call this first when entering an unfamiliar codebase. Read-only.\n- **scm_sync**(action, cwd, remote, pull_ref, push_ref, timeout) [capability_expand category: scm]: Run a typed git SCM action. Use this instead of shell_run for git pull/push/status. For 'pull origin main then push', set action='pull_then_push', remote='origin', pull_ref='main', and omit push_ref so LeapFlow pushes the current local branch.\n- **git_query**(action, cwd, ref, path, staged, max_count, stat): Read-only structured git inspection: action=diff|log|status|branch|show. Prefer over shell_run for reading repo state — output is clipped, redacted, and log/branch are parsed into structured fields. Use scm_sync for pull/push.\n- **git_write**(action, cwd, message, stage_all, name, ref, create) [capability_expand category: scm]: Mutating git actions: action=commit (message, stage_all), branch (create+switch), checkout (switch; create=true for -b). Approval-gated. Use scm_sync for pull/push and git_query for reads.\n- **test_run**(command, cwd, timeout): Run the project's test suite and return structured results (framework, passed/failed counts, failing tests). Auto-detects the runner (pytest/npm/go/cargo) or uses a configured/explicit command; executes via the governed shell. ok=true means the runner executed — see 'success' for pass/fail.\n- **lint_check**(command, cwd, timeout): Run the project's linter and return a structured clean/issue result. Auto-detects the linter (ruff/eslint/go vet/clippy) or uses a configured/explicit command; executes via the governed shell. ok=true means the linter ran — see 'clean'.\n- **file_list**(path, pattern, depth): List files and directories at a given path. Use depth=1 or depth=2 to get a recursive tree in one call instead of listing each sub-directory separately.\n- **file_read**(path, max_lines, start_line, max_chars, mode): Read text file content with adaptive context governance. For large or unfamiliar files, prefer mode='outline' or mode='symbols' first, then use mode='raw' with start_line/max_lines for the specific range you actually need. For LeapFlow's own settings, use config_list / config_get / config_set — its config files are outside the workspace and not readable here.\n- **file_write**(path, content, mode) [capability_expand category: write]: Write content to a file (overwrite or append).\n- **code_search**(pattern, patterns, path, glob, ignore_case, multiline, max_results, context_lines): Search file CONTENTS by regex pattern across a directory tree (ripgrep-backed). Requires a regex pattern. NOT for listing or browsing directory contents — use file_list for that. Prefer this over shell_run grep: faster, skips VCS/dependency/build dirs, and returns structured path:line:column matches. Batch related lookups into ONE call via `patterns` (OR-combined, single pass) instead of issuing several separate searches. Use file_read for the surrounding context of a hit.\n- **file_find**(glob, path, max_results): Find files by a recursive glob pattern under a base path (e.g. '**/test_*.py' or '*.md'). Prefer this over shell_run find; skips VCS/dependency/build dirs.\n- **edit_file**(path, edits, dry_run, diff) [capability_expand category: write]: Apply targeted, anchored search-replace edits to an EXISTING text file (use file_write to create/overwrite). Each edit is {original_text, new_text, replace_all?}; original_text must match exactly and uniquely (or set replace_all) — a non-unique or missing anchor is rejected so files are never corrupted. Set dry_run to preview. Alternatively pass a unified 'diff' to apply its hunks as anchored edits. Far cheaper and safer than rewriting a whole file.\n- **shell_run**(command, cwd, timeout) [capability_expand category: shell]: Execute a one-shot shell command with timeout protection. Runs in the active workspace; paths resolving outside it are refused. Reach for a structured tool first when one fits — web_fetch for anything over HTTP(S), git_query/scm_sync for git, code_search/file_find/file_read for the repo, config_get/config_set for LeapFlow's own settings — because those report typed results, while a failed shell command can only be diagnosed from its exit code and stderr. Every shell run counts as an external side effect, so a failure stops the rest of the batch and is not retried automatically.\n- **terminal_open**(command, cwd, shell) [capability_expand category: terminal]: Open a PERSISTENT shell session (REPL/dev server/watch), returning a session_id for terminal_send/read/close. Disabled unless tools.terminal_session_enabled is set. For one-shot commands use shell_run instead.\n- **terminal_send**(session_id, input, wait) [capability_expand category: terminal]: Send a line of input to a persistent terminal session and return output captured shortly after.\n- **terminal_read**(session_id, wait): Drain buffered output from a persistent terminal session (optionally waiting briefly first).\n- **terminal_close**(session_id) [capability_expand category: terminal]: Terminate a persistent terminal session and release its process group.\n- **terminal_list**(): List active persistent terminal sessions.\n- **config_list**(category, limit): List LeapFlow's own writable settings (model, provider, daemon, memory, perception, gateway, …) with current values. Use this to discover the exact key before changing anything. Optionally narrow by `category`. This is the only correct way to inspect LeapFlow configuration — never read config files from disk.\n- **config_get**(key): Read one LeapFlow setting by key (e.g. 'llm.model', 'daemon.log_level'), returning its current value, type, scopes, and whether a change needs a daemon restart. Never read LeapFlow config files from disk — use this.\n- **config_set**(key, value, scope) [capability_expand category: config]: Change one LeapFlow setting by key, e.g. switch the model with key='llm.model'. Values are validated and coerced; credentials are stored in the vault automatically. Call config_list or config_get first if unsure of the key. The result states whether a `leap daemon restart` is required. Never edit LeapFlow config files directly.\n- **web_fetch**(url, select, timeout, max_bytes): Read a URL over HTTP(S) and get back extracted, context-sized content: parsed JSON for API endpoints, readable text plus links for web pages. Use this for anything on the internet — prices, docs, releases, articles — instead of running curl through shell_run: it reports real HTTP status codes, retries rate limits on its own, and is a plain read so a retry is always safe. For JSON APIs pass `select` with a dotted path (e.g. 'chart.result.0.meta') to return just that part instead of the whole payload.\n- **memory_search**(query, limit): Search agent memory for relevant past experiences, observations, and facts.\n- **memory_add**(content, kind) [capability_expand category: write]: Store a new observation or insight in memory for future reference.\n- **research_note**(kind, text): Record a compact, structured note about the current task's state so it survives context compression on long / multi-step tasks. Use for durable findings, open questions still to resolve, decisions / excluded paths, and the immediate next step. One concise sentence per note.\n- **capability_expand**(category): Fetch the full callable schema for every tool in a capability category (e.g. 'hub', 'gateway', 'desktop', '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.\n- **delegate_task**(goal, context) [capability_expand category: delegate]: Delegate a complex sub-task to an isolated subagent. The subagent gets a fresh context and restricted tool access. Use when a task is self-contained and can be solved independently.\n- **schedule_reentry**(kind, reason, delay_seconds, event_match, max_reentries, deadline_seconds): Register a re-entry so this task can resume later from its current orientation (findings / open questions / next step). Use when work must pause and continue after a delay (kind=time) or when a matching platform event arrives (kind=event), instead of finishing now. The research-ledger state is carried over automatically.\n- **hub_push**(skill_name, visibility, version) [capability_expand category: hub]: Push a local skill to the ModelScope Hub for sharing or backup.\n- **hub_pull**(repo_id, version) [capability_expand category: hub]: Pull a skill from the ModelScope Hub to install locally.\n- **hub_search**(query) [capability_expand category: hub]: Search for skills on the Hub by keyword or description.\n- **hub_sync**(mode, dry_run) [capability_expand category: hub]: Preview or execute sync between local skills and Hub.\n- **platform_action**(platform, action, payload, backend_kind) [capability_expand category: gateway]: Execute an exact registered business action on an external platform through LeapFlow's App Connector layer. Actions must be copied from the App Connector Capability Index and are addressed as domain.operation, e.g. im.send_message or docs.create_markdown. All business fields (chat_id, text, query, etc.) MUST be placed inside `payload`, never at the top level. Example: {\"platform\":\"feishu\",\"action\":\"im.send_message\",\"payload\":{\"chat_id\":\"oc_xxx\",\"text\":\"hello\"}}. Do not invent action names, do not use management actions such as list/guide/connect/status here.\n- **platform_connect**(action, platform, credentials, options, checkpoint) [capability_expand category: gateway]: List, guide, connect, disconnect, remove, or check status for external platforms using the App Connector management namespace. Supports REST and CLI backends. Use this for management actions such as list/guide/preflight/connect/status; use platform_action only for exact registered business actions.\n- **gateway_send**(platform, chat_id, text, thread_id) [capability_expand category: gateway]: Send a message to a connected external platform (Feishu group, Telegram chat, DingTalk conversation, etc.). Requires the platform to be connected via gateway_connect first. Use gateway_connect with action='list' to see connected platforms and available chat IDs.\n- **gateway_connect**(action, platform, credentials, options) [capability_expand category: gateway]: Connect, configure, or manage external platform integrations (Feishu, DingTalk, Telegram, Slack, Discord, etc.). Conversational flow: 1) call 'guide' to get setup steps + required fields, 2) present the steps to the user and ask for ALL required credentials in a single message, 3) call 'connect' with the credentials. Goal: complete in 1–2 user turns. NEVER include credential values in your text response.\n- **plugin_list**(): List the live plugin registry and cross-subsystem capability evidence. Use this before answering questions about whether LeapFlow supports plugins, self-evolution, plugin installation, hot reload, versioning, or other runtime capabilities.\n- **plugin_status**(plugin_id): Get detailed status of a specific plugin: its declared category, runtime dependencies, contributed tools, and fiber lifecycle state.\n- **plugin_versions**(plugin_id): List recorded source versions and active pointer for a profile-scoped plugin.\n- **plugin_propose**(requested_capability, plugin_id, proposed_tools, test_cases, risk_level, evidence): Create a side-effect-free PluginProposal from explicit capability-gap evidence. Use this before plugin_generate when a missing capability should be reviewed. Does not call an LLM, write files, or install anything.\n- **assess_compatibility**(manifest, source_path) [capability_expand category: plugin_management]: Assess whether a foreign plugin manifest or real DSH source bundle is compatible with LeapFlow. A source bundle assessment is static: runtime_ready stays false until restricted Node discovery during plugin_install. Returns component-level verdicts and limitations.\n- **plugin_generate**(plugin_id, description, proposal_id) [capability_expand category: system]: Generate a new ToolPlugin from a natural-language capability description. The LLM produces code that conforms to the ToolPlugin Protocol; it is then rigorously validated (syntax, structure, import, protocol conformance). The isolated sandbox smoke test runs later, at install-time. Returns the validated code but DOES NOT install it — installation is a separate approval-gated step via plugin_install.\n- **plugin_install**(plugin_id, code, marketplace_name, source_path, proposal_id, version_label) [capability_expand category: system]: Install a plugin from exactly one source: validated Python code, the configured Python marketplace, or a real DSH source bundle. DSH bundles run restricted Node discovery before registration; only public host tools are installed and client UI limitations are reported. Writes profile-scoped state and mutates the process-global registry. REQUIRES APPROVAL.\n- **plugin_rollback**(plugin_id, version) [capability_expand category: system]: Rollback a profile-scoped plugin to a recorded source version and reload it. REQUIRES APPROVAL.\n- **plugin_reload**(plugin_id, version_label) [capability_expand category: system]: Hot-reload a plugin at runtime. Disposes the old plugin fiber, re-imports its module, and registers a fresh instance. Existing in-flight turns are unaffected (snapshot isolation). REQUIRES APPROVAL — this is a self-modification action.\n- **plugin_disable**(plugin_id) [capability_expand category: system]: Disable a plugin by disposing its fiber, removing its tools from the runtime registry. Cannot disable self_management itself. REQUIRES APPROVAL — this is a self-modification action.\n- **plugin_remove**(plugin_id, delete_source) [capability_expand category: system]: Terminally remove a plugin: dispose its fiber, unregister its tools, remove reload metadata, and optionally delete its profile-scoped source file. Cannot remove self_management itself. REQUIRES APPROVAL.\n- **plugin_enable**(plugin_id) [capability_expand category: system]: Re-enable a previously disabled plugin by reloading its module and registering a fresh instance. REQUIRES APPROVAL.\n- **session_search**(query, limit) [capability_expand category: unclassified]: Search past conversation sessions in the current workspace for relevant context.\n- **session_list**(limit): List recent conversation sessions in the current workspace with stable ids, titles, dates, and summaries. Use for browsing past tasks or when user asks to see history without specific search terms. No keywords needed — returns chronological list.\n- **session_detail**(session_id, limit, offset, include_inactive) [capability_expand category: unclassified]: Read a paginated persisted transcript for one past conversation session in the current workspace. Use after session_list or session_search returns a session_id.\n- **fixture_echo**(text) [capability_expand category: bridge]: Return the supplied text from a pre-built DSH package.\n- **click**(element_index) [capability_expand category: desktop]: Click a UI element by its element_index (from the latest observe_ui snapshot)\n- **get_clipboard**() [capability_expand category: desktop]: Read current clipboard text content\n- **list_apps**(filter, running_only) [capability_expand category: desktop]: List available applications on this system. Use to discover correct bundle_id before switch_app.\n- **list_windows**() [capability_expand category: desktop]: List all top-level windows with pid, window_id, title, and per-window state (minimized, on-screen). Call this first to pick the pid and window_id that observe_ui and other window tools require.\n- **observe_ui**(pid, window_id, query) [capability_expand category: desktop]: Snapshot one window's actionable UI elements, each tagged with an element_index for click/right_click/read_text. Re-observe after actions — indices belong to one snapshot. Requires the window's pid and window_id from list_windows.\n- **open_url**(url, app_id) [capability_expand category: desktop]: Open a URL in the default or specified browser\n- **read_text**(element_index) [capability_expand category: desktop]: Read the text content of a specific UI element from the latest snapshot\n- **right_click**(element_index) [capability_expand category: desktop]: Right-click a UI element to open its context menu. Returns visible menu items.\n- **screenshot**(pid, window_id) [capability_expand category: desktop]: Capture a screenshot for visual verification. With pid + window_id captures that window (works across all displays); defaults to the last observed window, or the full desktop when no window has been observed.\n- **scroll**(element_index, direction, amount, pid, window_id) [capability_expand category: desktop]: Scroll a scrollable area of a window. Omit element_index to scroll the window's focused/page scroller; pass one to scroll an exact element from the latest snapshot.\n- **select_text**(element_index) [capability_expand category: desktop]: Select all text in a UI element (focus + select-all, for subsequent copy)\n- **set_clipboard**(text) [capability_expand category: desktop]: Write text to the clipboard\n- **shortcut**(keys) [capability_expand category: desktop]: Execute a keyboard shortcut\n- **switch_app**(app_id) [capability_expand category: desktop]: Switch to an app (launch if needed, activate, verify)\n- **type_text**(text) [capability_expand category: desktop]: Type text into the currently focused element\n- **wait**(seconds) [capability_expand category: desktop]: Wait for a specified duration before continuing\n- **wait_until**(condition, pid, window_id, timeout, poll_interval) [capability_expand category: desktop]: Wait until a UI condition is met (polls UI tree). Returns elements when found or on timeout.\n- **wait_until_stable**(timeout, poll_interval, pid, window_id) [capability_expand category: desktop]: Wait until the UI stops changing (element set stabilizes across polls).\n\n## App Connector Capability Index\nLeapFlow can onboard and manage external apps through `platform_connect` and execute exact registered business actions through `platform_action`.\nFor requests about connecting, setting up, configuring, enabling, or managing a supported app, use `platform_connect` first instead of generating SDK/Webhook sample code.\n`platform_connect.action` is the management namespace: list, guide, preflight, connect, disconnect, remove, status, events_start, events_stop, events_status.\n`platform_action.action` is only for exact registered platform business actions listed below, such as `im.send_message`; never use management actions like `list` or `guide` there.\nAll business fields MUST go inside `payload`; top-level keys are only `platform`, `action`, and `payload`.\nDo not invent platform IDs or platform action names. If the needed action is not listed, ask for discovery/clarification instead of guessing.\nUse `platform_connect` with `action='guide'` and the matching `platform` to start onboarding; use `action='list'` when the app is unclear.\nWhen a pending onboarding state is present, continue from that state with `platform_connect` instead of asking the user to restate the app.\nSupported apps:\n- `api_server`: API Server (OpenAI Compatible) (category=api; backend=adapter; platform_action actions=none registered)\n- `dingtalk`: 钉钉 (DingTalk) (category=im; backend=adapter; platform_action actions=none registered)\n- `feishu`: 飞书 (Feishu/Lark) (category=im; backend=cli)\n - `calendar.create_event` payload={summary*, start_time*, end_time*, attendees} [write/high]\n - `docs.create_markdown` payload={title*, markdown*, folder_token} [write/medium]\n - `drive.search` payload={query*, limit} [read/medium]\n - `im.add_reaction` payload={message_id*, emoji_type*} [write/low]\n - `im.download_resource` payload={message_id*, file_key*, type*} [read/low]\n - `im.get_messages` payload={message_ids*} [read/low]\n - `im.list_chats` payload={page_size} [read/low]\n - `im.list_messages` payload={chat_id*} [read/low]\n - `im.list_thread_messages` payload={thread*} [read/low]\n - `im.remove_reaction` payload={message_id*, emoji_type*} [write/low]\n - `im.reply_message` payload={message_id*, text*} [send/high]\n - `im.search_chats` payload={query*, page_size} [read/low]\n - `im.search_messages` payload={query*} [read/medium]\n - `im.send_message` payload={chat_id*, text*, thread_id} [send/high]\n - `im.update_card` payload={token*, card*} [write/medium]\n - `im.update_message` payload={message_id*, text*} [write/medium]\n - `mail.search_unread` payload={query, limit} [read/high]\n - `sheets.append_row` payload={spreadsheet_token*, sheet_id*, values*} [write/medium]\n - `task.create` payload={title*, description, due_time} [write/medium]\n- `telegram`: Telegram (category=social; backend=adapter; platform_action actions=none registered)\n- `webhook`: Webhook (Generic) (category=webhook; backend=adapter; platform_action actions=none registered)\n\n## Tool Usage\nTools are normally invoked through the native function-calling mechanism, not by writing JSON in your reply\ntext. Only if the provider signals that native function calling is unavailable for this turn, fall back to a\nsingle JSON code block: `{\"name\": \"tool_name\", \"arguments\": {\"key\": \"value\"}}` — use this fallback format\nonly, never both. Only call a tool whose exact name appears in the tool index above and is currently callable\n(or reachable via `capability_expand`). Never invent, rename, alias, or guess a tool name, platform ID, or\nplatform action from argument shape or wording — if the index does not list it, it does not exist.\n`platform_connect.action` (list/guide/preflight/connect/disconnect/remove/status/events_start/events_stop/\nevents_status) is the App Connector management namespace; `platform_action.action` only accepts exact\nregistered business actions such as `im.send_message` shown in the App Connector Capability Index — never mix\nthe two namespaces. If a tool call returns an unknown/unavailable result, use the returned suggestions or\navailable names for a single retry instead of trying further variations of the same guess.\n\n**Side-effect action rule** (`platform_action` with effect=send/write/execute):\n- Call each unique action+payload **exactly once**. Never include duplicates in the same turn.\n- Once the result returns `\"completed\": true`, that action is DONE for this task. Do NOT call it again\n in any subsequent turn — immediately summarize the result for the user instead.\n- The system enforces idempotency: duplicate calls are blocked and will not execute.\n- If the user explicitly requests sending/writing multiple times, use distinct payloads per call.\n\n**Resource identifier provenance rule**:\n- NEVER fabricate, guess, or infer resource identifiers (chat_id, message_id, file_key, user_id, etc.).\n Every resource ID used in a side-effect action MUST come from a successful API response in this session.\n- If a read/list action fails (e.g. authorization error), you do NOT have valid resource IDs.\n Report the failure to the user — do NOT attempt the dependent write/send action with a guessed ID.\n- When a tool result contains `\"llm_instruction\"`, follow it exactly.\n\n## Guidelines\n1. **Direct answers first**: If you already know the answer, respond directly without tools.\n2. **Avoid redundant tool calls**: Do not call the same tool with the same arguments more than once in the same user turn. When an existing tool result already answers the user's request, stop calling tools and answer directly.\n3. **Use tools proactively**: When the user asks about files, time, system state, or needs actions performed, use the appropriate tool.\n4. **Chain tools when needed**: You can call multiple tools in sequence (e.g., list files → read file → summarize).\n5. **Handle failures gracefully**: If a tool fails, explain what went wrong and suggest alternatives. If it failed because of an unknown tool/platform/action name, retry once with an exact name from the returned suggestions, then explain rather than keep guessing.\n6. **Summarize results naturally**: After tool execution, synthesize the results into a helpful answer rather than dumping raw output.\n7. **Stay conversational**: Maintain a natural, helpful tone. Acknowledge context from earlier in the conversation.\n8. **Recall past work**:\n - Broad queries (\"之前做了什么\", \"列出任务\"): answer from the \"Recent Task History\" section already in your context. If insufficient, call session_list.\n - Specific lookups (\"上次那个配置怎么改的\"): call session_search with relevant phrases (NOT single characters).\n - Do NOT call search tools repeatedly with keyword fragments. One well-phrased call is better than ten fragmented ones.\n\n## Coding & Verification\nWhen working with code, prefer the precise built-in tools over ad-hoc shell: use `repo_map` to orient in an\nunfamiliar project, `code_search`/`file_find` to locate and `code_intel` for symbols, `edit_file` (anchored\nsearch-replace, or a unified `diff`) to change files — never rewrite a whole file to change a few lines — and\n`git_query` to inspect diffs/log. After edits, check `syntax_ok` in the result and run `test_run`/`lint_check`\nbefore declaring the work done. Batch independent read-only calls (search/read/query) into a single turn — they\nrun in parallel.\n\n## Presentation Style\n1. **Polished Markdown only**: Format user-facing answers with clean Markdown headings, short paragraphs, and concise bullets. Use tables only when they improve comparison or scanning.\n2. **Terminal-friendly layout**: Keep lines readable in a TUI; avoid dense walls of text, deeply nested lists, oversized ASCII art, or heavy visual blocks.\n3. **Elegant emphasis**: Use bold text sparingly for key terms and conclusions. Avoid excessive emojis, decorative symbols, repeated separators, or visual noise.\n4. **Theme-safe colors**: Do not emit ANSI escape codes, HTML color tags, Rich markup, or hardcoded color names. Rely on the TUI theme to render Markdown professionally.\n5. **No leaked tool protocol**: Never show tool-call JSON, internal schemas, raw observations, tool result payloads, or hidden reasoning in the final answer unless the user explicitly asks for raw/debug output. Treat any prior `{\"name\": ..., \"arguments\": ...}` blocks and `Tool result (...)` messages as internal execution context only.\n6. **Professional closure**: End with a concise conclusion or next step when helpful; avoid rambling after the useful answer is complete.\n\nWhen finished with all tool calls, respond normally without a JSON block, tool-call transcript, or process log.\n\n\n## Recent Session Summary\n- [assistant] Installed the hermetic DSH plugin.\n- [user] Invoke fixture_echo with the text before restart.\n\n## Task Contract\n- Task ID: turn-2\n- Original user request: Invoke fixture_echo with the text before restart.\n- Workspace root: \n- Allowed roots: \n- Treat relative project paths as relative to the workspace root; never infer `.` as the project root when a workspace root is provided.\n- Workspace boundary is enforced by tools: do not read, search, edit, or run commands against paths outside the allowed roots unless the user explicitly requests an external path and the tool/approval policy permits it.\n- LeapFlow workspace config is optional at `/.leapflow/config.yaml`; runtime config is loaded from `~/.leapflow/config/user.yaml` and `~/.leapflow/profiles//config/*.yaml`.\n- Preserve this task contract across summarization, compression, tool loops, and memory retrieval.\n" + }, + { + "role": "assistant", + "content": "Installed the hermetic DSH plugin." + }, + { + "role": "user", + "content": "Invoke fixture_echo with the text before restart.\nInvoke fixture_echo with the text before restart." + } + ], + "tools": [ + "capability_expand", + "code_intel", + "code_search", + "config_get", + "config_list", + "env_info", + "file_find", + "file_list", + "file_read", + "git_query", + "lint_check", + "memory_search", + "plugin_list", + "plugin_propose", + "plugin_status", + "plugin_versions", + "repo_map", + "research_note", + "schedule_reentry", + "session_list", + "skill_view", + "skills_list", + "terminal_list", + "terminal_read", + "test_run", + "text_search", + "time_get", + "web_fetch" + ] + }, + "responses": [ + { + "status": 200, + "content_type": "application/json", + "body": "{\"id\": \"chatcmpl-cassette\", \"object\": \"chat.completion\", \"model\": \"cassette-model\", \"choices\": [{\"index\": 0, \"message\": {\"role\": \"assistant\", \"content\": \"\", \"tool_calls\": [{\"id\": \"call_1\", \"type\": \"function\", \"function\": {\"name\": \"fixture_echo\", \"arguments\": \"{\\\"text\\\": \\\"before restart\\\"}\"}}]}, \"finish_reason\": \"tool_calls\"}], \"usage\": {\"prompt_tokens\": 64, \"completion_tokens\": 16, \"total_tokens\": 80}}" + } + ] +} diff --git a/tests/_fixtures/cassettes/r6_lifecycle/cassette-model-80a159a4e44668d2.cassette.json b/tests/_fixtures/cassettes/r6_lifecycle/cassette-model-80a159a4e44668d2.cassette.json new file mode 100644 index 0000000..96816a3 --- /dev/null +++ b/tests/_fixtures/cassettes/r6_lifecycle/cassette-model-80a159a4e44668d2.cassette.json @@ -0,0 +1,64 @@ +{ + "fingerprint": "80a159a4e44668d2d563cb6af54f986c2156338fbb4b2548d8f6ec9d9c5c071f", + "note": "captured in seed mode", + "request": { + "model": "cassette-model", + "stream": false, + "messages": [ + { + "role": "system", + "content": "You are LeapFlow, an intelligent assistant that can both converse naturally and take real actions on the user's computer.\n\n## Capabilities\nThe tool index below lists **every** registered tool by name and a one-line summary — this is the complete\ncapability contract; nothing else exists. Only a subset is directly callable this turn (via native tool calling,\nnot a JSON block in your reply). If you need a tool from the index that is not yet callable, call\n`capability_expand` with its category name first — the matching tools become callable immediately after.\nWhen the user asks what LeapFlow itself supports, whether it supports plugins, or which runtime capabilities\nare available, use the live capability evidence exposed by `plugin_list` before making capability claims; report\nconfiguration-dependent or unavailable capabilities as limitations instead of inferring from documentation.\n- **text_search**(text, pattern): Search for a regex pattern in text.\n- **text_replace**(text, old, new, count) [capability_expand category: write]: Replace occurrences of a substring in text.\n- **time_get**(): Get current date and time.\n- **env_info**(): Get system environment information (OS, Python version, cwd).\n- **skills_list**(query, category, source): List available learned skills. Use when user asks about capabilities or you need a specific skill.\n- **skill_view**(name): View the full content of a specific skill document.\n- **code_intel**(path, operation): Precise document symbols (outline) for a source file: classes, functions, and methods with line ranges. Python uses an exact AST parse; other languages use a keyword-prefix scan. Prefer over file_read mode=symbols for accurate navigation before editing. Read-only.\n- **repo_map**(path): Compact project orientation for a repository root: languages, detected test/lint commands, top-level structure, entry points, manifest, and VCS branch. Call this first when entering an unfamiliar codebase. Read-only.\n- **scm_sync**(action, cwd, remote, pull_ref, push_ref, timeout) [capability_expand category: scm]: Run a typed git SCM action. Use this instead of shell_run for git pull/push/status. For 'pull origin main then push', set action='pull_then_push', remote='origin', pull_ref='main', and omit push_ref so LeapFlow pushes the current local branch.\n- **git_query**(action, cwd, ref, path, staged, max_count, stat): Read-only structured git inspection: action=diff|log|status|branch|show. Prefer over shell_run for reading repo state — output is clipped, redacted, and log/branch are parsed into structured fields. Use scm_sync for pull/push.\n- **git_write**(action, cwd, message, stage_all, name, ref, create) [capability_expand category: scm]: Mutating git actions: action=commit (message, stage_all), branch (create+switch), checkout (switch; create=true for -b). Approval-gated. Use scm_sync for pull/push and git_query for reads.\n- **test_run**(command, cwd, timeout): Run the project's test suite and return structured results (framework, passed/failed counts, failing tests). Auto-detects the runner (pytest/npm/go/cargo) or uses a configured/explicit command; executes via the governed shell. ok=true means the runner executed — see 'success' for pass/fail.\n- **lint_check**(command, cwd, timeout): Run the project's linter and return a structured clean/issue result. Auto-detects the linter (ruff/eslint/go vet/clippy) or uses a configured/explicit command; executes via the governed shell. ok=true means the linter ran — see 'clean'.\n- **file_list**(path, pattern, depth): List files and directories at a given path. Use depth=1 or depth=2 to get a recursive tree in one call instead of listing each sub-directory separately.\n- **file_read**(path, max_lines, start_line, max_chars, mode): Read text file content with adaptive context governance. For large or unfamiliar files, prefer mode='outline' or mode='symbols' first, then use mode='raw' with start_line/max_lines for the specific range you actually need. For LeapFlow's own settings, use config_list / config_get / config_set — its config files are outside the workspace and not readable here.\n- **file_write**(path, content, mode) [capability_expand category: write]: Write content to a file (overwrite or append).\n- **code_search**(pattern, patterns, path, glob, ignore_case, multiline, max_results, context_lines): Search file CONTENTS by regex pattern across a directory tree (ripgrep-backed). Requires a regex pattern. NOT for listing or browsing directory contents — use file_list for that. Prefer this over shell_run grep: faster, skips VCS/dependency/build dirs, and returns structured path:line:column matches. Batch related lookups into ONE call via `patterns` (OR-combined, single pass) instead of issuing several separate searches. Use file_read for the surrounding context of a hit.\n- **file_find**(glob, path, max_results): Find files by a recursive glob pattern under a base path (e.g. '**/test_*.py' or '*.md'). Prefer this over shell_run find; skips VCS/dependency/build dirs.\n- **edit_file**(path, edits, dry_run, diff) [capability_expand category: write]: Apply targeted, anchored search-replace edits to an EXISTING text file (use file_write to create/overwrite). Each edit is {original_text, new_text, replace_all?}; original_text must match exactly and uniquely (or set replace_all) — a non-unique or missing anchor is rejected so files are never corrupted. Set dry_run to preview. Alternatively pass a unified 'diff' to apply its hunks as anchored edits. Far cheaper and safer than rewriting a whole file.\n- **shell_run**(command, cwd, timeout) [capability_expand category: shell]: Execute a one-shot shell command with timeout protection. Runs in the active workspace; paths resolving outside it are refused. Reach for a structured tool first when one fits — web_fetch for anything over HTTP(S), git_query/scm_sync for git, code_search/file_find/file_read for the repo, config_get/config_set for LeapFlow's own settings — because those report typed results, while a failed shell command can only be diagnosed from its exit code and stderr. Every shell run counts as an external side effect, so a failure stops the rest of the batch and is not retried automatically.\n- **terminal_open**(command, cwd, shell) [capability_expand category: terminal]: Open a PERSISTENT shell session (REPL/dev server/watch), returning a session_id for terminal_send/read/close. Disabled unless tools.terminal_session_enabled is set. For one-shot commands use shell_run instead.\n- **terminal_send**(session_id, input, wait) [capability_expand category: terminal]: Send a line of input to a persistent terminal session and return output captured shortly after.\n- **terminal_read**(session_id, wait): Drain buffered output from a persistent terminal session (optionally waiting briefly first).\n- **terminal_close**(session_id) [capability_expand category: terminal]: Terminate a persistent terminal session and release its process group.\n- **terminal_list**(): List active persistent terminal sessions.\n- **config_list**(category, limit): List LeapFlow's own writable settings (model, provider, daemon, memory, perception, gateway, …) with current values. Use this to discover the exact key before changing anything. Optionally narrow by `category`. This is the only correct way to inspect LeapFlow configuration — never read config files from disk.\n- **config_get**(key): Read one LeapFlow setting by key (e.g. 'llm.model', 'daemon.log_level'), returning its current value, type, scopes, and whether a change needs a daemon restart. Never read LeapFlow config files from disk — use this.\n- **config_set**(key, value, scope) [capability_expand category: config]: Change one LeapFlow setting by key, e.g. switch the model with key='llm.model'. Values are validated and coerced; credentials are stored in the vault automatically. Call config_list or config_get first if unsure of the key. The result states whether a `leap daemon restart` is required. Never edit LeapFlow config files directly.\n- **web_fetch**(url, select, timeout, max_bytes): Read a URL over HTTP(S) and get back extracted, context-sized content: parsed JSON for API endpoints, readable text plus links for web pages. Use this for anything on the internet — prices, docs, releases, articles — instead of running curl through shell_run: it reports real HTTP status codes, retries rate limits on its own, and is a plain read so a retry is always safe. For JSON APIs pass `select` with a dotted path (e.g. 'chart.result.0.meta') to return just that part instead of the whole payload.\n- **memory_search**(query, limit): Search agent memory for relevant past experiences, observations, and facts.\n- **memory_add**(content, kind) [capability_expand category: write]: Store a new observation or insight in memory for future reference.\n- **research_note**(kind, text): Record a compact, structured note about the current task's state so it survives context compression on long / multi-step tasks. Use for durable findings, open questions still to resolve, decisions / excluded paths, and the immediate next step. One concise sentence per note.\n- **capability_expand**(category): Fetch the full callable schema for every tool in a capability category (e.g. 'hub', 'gateway', 'desktop', '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.\n- **delegate_task**(goal, context) [capability_expand category: delegate]: Delegate a complex sub-task to an isolated subagent. The subagent gets a fresh context and restricted tool access. Use when a task is self-contained and can be solved independently.\n- **schedule_reentry**(kind, reason, delay_seconds, event_match, max_reentries, deadline_seconds): Register a re-entry so this task can resume later from its current orientation (findings / open questions / next step). Use when work must pause and continue after a delay (kind=time) or when a matching platform event arrives (kind=event), instead of finishing now. The research-ledger state is carried over automatically.\n- **hub_push**(skill_name, visibility, version) [capability_expand category: hub]: Push a local skill to the ModelScope Hub for sharing or backup.\n- **hub_pull**(repo_id, version) [capability_expand category: hub]: Pull a skill from the ModelScope Hub to install locally.\n- **hub_search**(query) [capability_expand category: hub]: Search for skills on the Hub by keyword or description.\n- **hub_sync**(mode, dry_run) [capability_expand category: hub]: Preview or execute sync between local skills and Hub.\n- **platform_action**(platform, action, payload, backend_kind) [capability_expand category: gateway]: Execute an exact registered business action on an external platform through LeapFlow's App Connector layer. Actions must be copied from the App Connector Capability Index and are addressed as domain.operation, e.g. im.send_message or docs.create_markdown. All business fields (chat_id, text, query, etc.) MUST be placed inside `payload`, never at the top level. Example: {\"platform\":\"feishu\",\"action\":\"im.send_message\",\"payload\":{\"chat_id\":\"oc_xxx\",\"text\":\"hello\"}}. Do not invent action names, do not use management actions such as list/guide/connect/status here.\n- **platform_connect**(action, platform, credentials, options, checkpoint) [capability_expand category: gateway]: List, guide, connect, disconnect, remove, or check status for external platforms using the App Connector management namespace. Supports REST and CLI backends. Use this for management actions such as list/guide/preflight/connect/status; use platform_action only for exact registered business actions.\n- **gateway_send**(platform, chat_id, text, thread_id) [capability_expand category: gateway]: Send a message to a connected external platform (Feishu group, Telegram chat, DingTalk conversation, etc.). Requires the platform to be connected via gateway_connect first. Use gateway_connect with action='list' to see connected platforms and available chat IDs.\n- **gateway_connect**(action, platform, credentials, options) [capability_expand category: gateway]: Connect, configure, or manage external platform integrations (Feishu, DingTalk, Telegram, Slack, Discord, etc.). Conversational flow: 1) call 'guide' to get setup steps + required fields, 2) present the steps to the user and ask for ALL required credentials in a single message, 3) call 'connect' with the credentials. Goal: complete in 1–2 user turns. NEVER include credential values in your text response.\n- **plugin_list**(): List the live plugin registry and cross-subsystem capability evidence. Use this before answering questions about whether LeapFlow supports plugins, self-evolution, plugin installation, hot reload, versioning, or other runtime capabilities.\n- **plugin_status**(plugin_id): Get detailed status of a specific plugin: its declared category, runtime dependencies, contributed tools, and fiber lifecycle state.\n- **plugin_versions**(plugin_id): List recorded source versions and active pointer for a profile-scoped plugin.\n- **plugin_propose**(requested_capability, plugin_id, proposed_tools, test_cases, risk_level, evidence): Create a side-effect-free PluginProposal from explicit capability-gap evidence. Use this before plugin_generate when a missing capability should be reviewed. Does not call an LLM, write files, or install anything.\n- **assess_compatibility**(manifest, source_path) [capability_expand category: plugin_management]: Assess whether a foreign plugin manifest or real DSH source bundle is compatible with LeapFlow. A source bundle assessment is static: runtime_ready stays false until restricted Node discovery during plugin_install. Returns component-level verdicts and limitations.\n- **plugin_generate**(plugin_id, description, proposal_id) [capability_expand category: system]: Generate a new ToolPlugin from a natural-language capability description. The LLM produces code that conforms to the ToolPlugin Protocol; it is then rigorously validated (syntax, structure, import, protocol conformance). The isolated sandbox smoke test runs later, at install-time. Returns the validated code but DOES NOT install it — installation is a separate approval-gated step via plugin_install.\n- **plugin_install**(plugin_id, code, marketplace_name, source_path, proposal_id, version_label) [capability_expand category: system]: Install a plugin from exactly one source: validated Python code, the configured Python marketplace, or a real DSH source bundle. DSH bundles run restricted Node discovery before registration; only public host tools are installed and client UI limitations are reported. Writes profile-scoped state and mutates the process-global registry. REQUIRES APPROVAL.\n- **plugin_rollback**(plugin_id, version) [capability_expand category: system]: Rollback a profile-scoped plugin to a recorded source version and reload it. REQUIRES APPROVAL.\n- **plugin_reload**(plugin_id, version_label) [capability_expand category: system]: Hot-reload a plugin at runtime. Disposes the old plugin fiber, re-imports its module, and registers a fresh instance. Existing in-flight turns are unaffected (snapshot isolation). REQUIRES APPROVAL — this is a self-modification action.\n- **plugin_disable**(plugin_id) [capability_expand category: system]: Disable a plugin by disposing its fiber, removing its tools from the runtime registry. Cannot disable self_management itself. REQUIRES APPROVAL — this is a self-modification action.\n- **plugin_remove**(plugin_id, delete_source) [capability_expand category: system]: Terminally remove a plugin: dispose its fiber, unregister its tools, remove reload metadata, and optionally delete its profile-scoped source file. Cannot remove self_management itself. REQUIRES APPROVAL.\n- **plugin_enable**(plugin_id) [capability_expand category: system]: Re-enable a previously disabled plugin by reloading its module and registering a fresh instance. REQUIRES APPROVAL.\n- **fixture_echo**(text) [capability_expand category: bridge]: Return the supplied text from a pre-built DSH package.\n- **session_search**(query, limit) [capability_expand category: unclassified]: Search past conversation sessions in the current workspace for relevant context.\n- **session_list**(limit): List recent conversation sessions in the current workspace with stable ids, titles, dates, and summaries. Use for browsing past tasks or when user asks to see history without specific search terms. No keywords needed — returns chronological list.\n- **session_detail**(session_id, limit, offset, include_inactive) [capability_expand category: unclassified]: Read a paginated persisted transcript for one past conversation session in the current workspace. Use after session_list or session_search returns a session_id.\n- **click**(element_index) [capability_expand category: desktop]: Click a UI element by its element_index (from the latest observe_ui snapshot)\n- **get_clipboard**() [capability_expand category: desktop]: Read current clipboard text content\n- **list_apps**(filter, running_only) [capability_expand category: desktop]: List available applications on this system. Use to discover correct bundle_id before switch_app.\n- **list_windows**() [capability_expand category: desktop]: List all top-level windows with pid, window_id, title, and per-window state (minimized, on-screen). Call this first to pick the pid and window_id that observe_ui and other window tools require.\n- **observe_ui**(pid, window_id, query) [capability_expand category: desktop]: Snapshot one window's actionable UI elements, each tagged with an element_index for click/right_click/read_text. Re-observe after actions — indices belong to one snapshot. Requires the window's pid and window_id from list_windows.\n- **open_url**(url, app_id) [capability_expand category: desktop]: Open a URL in the default or specified browser\n- **read_text**(element_index) [capability_expand category: desktop]: Read the text content of a specific UI element from the latest snapshot\n- **right_click**(element_index) [capability_expand category: desktop]: Right-click a UI element to open its context menu. Returns visible menu items.\n- **screenshot**(pid, window_id) [capability_expand category: desktop]: Capture a screenshot for visual verification. With pid + window_id captures that window (works across all displays); defaults to the last observed window, or the full desktop when no window has been observed.\n- **scroll**(element_index, direction, amount, pid, window_id) [capability_expand category: desktop]: Scroll a scrollable area of a window. Omit element_index to scroll the window's focused/page scroller; pass one to scroll an exact element from the latest snapshot.\n- **select_text**(element_index) [capability_expand category: desktop]: Select all text in a UI element (focus + select-all, for subsequent copy)\n- **set_clipboard**(text) [capability_expand category: desktop]: Write text to the clipboard\n- **shortcut**(keys) [capability_expand category: desktop]: Execute a keyboard shortcut\n- **switch_app**(app_id) [capability_expand category: desktop]: Switch to an app (launch if needed, activate, verify)\n- **type_text**(text) [capability_expand category: desktop]: Type text into the currently focused element\n- **wait**(seconds) [capability_expand category: desktop]: Wait for a specified duration before continuing\n- **wait_until**(condition, pid, window_id, timeout, poll_interval) [capability_expand category: desktop]: Wait until a UI condition is met (polls UI tree). Returns elements when found or on timeout.\n- **wait_until_stable**(timeout, poll_interval, pid, window_id) [capability_expand category: desktop]: Wait until the UI stops changing (element set stabilizes across polls).\n\n## App Connector Capability Index\nLeapFlow can onboard and manage external apps through `platform_connect` and execute exact registered business actions through `platform_action`.\nFor requests about connecting, setting up, configuring, enabling, or managing a supported app, use `platform_connect` first instead of generating SDK/Webhook sample code.\n`platform_connect.action` is the management namespace: list, guide, preflight, connect, disconnect, remove, status, events_start, events_stop, events_status.\n`platform_action.action` is only for exact registered platform business actions listed below, such as `im.send_message`; never use management actions like `list` or `guide` there.\nAll business fields MUST go inside `payload`; top-level keys are only `platform`, `action`, and `payload`.\nDo not invent platform IDs or platform action names. If the needed action is not listed, ask for discovery/clarification instead of guessing.\nUse `platform_connect` with `action='guide'` and the matching `platform` to start onboarding; use `action='list'` when the app is unclear.\nWhen a pending onboarding state is present, continue from that state with `platform_connect` instead of asking the user to restate the app.\nSupported apps:\n- `api_server`: API Server (OpenAI Compatible) (category=api; backend=adapter; platform_action actions=none registered)\n- `dingtalk`: 钉钉 (DingTalk) (category=im; backend=adapter; platform_action actions=none registered)\n- `feishu`: 飞书 (Feishu/Lark) (category=im; backend=cli)\n - `calendar.create_event` payload={summary*, start_time*, end_time*, attendees} [write/high]\n - `docs.create_markdown` payload={title*, markdown*, folder_token} [write/medium]\n - `drive.search` payload={query*, limit} [read/medium]\n - `im.add_reaction` payload={message_id*, emoji_type*} [write/low]\n - `im.download_resource` payload={message_id*, file_key*, type*} [read/low]\n - `im.get_messages` payload={message_ids*} [read/low]\n - `im.list_chats` payload={page_size} [read/low]\n - `im.list_messages` payload={chat_id*} [read/low]\n - `im.list_thread_messages` payload={thread*} [read/low]\n - `im.remove_reaction` payload={message_id*, emoji_type*} [write/low]\n - `im.reply_message` payload={message_id*, text*} [send/high]\n - `im.search_chats` payload={query*, page_size} [read/low]\n - `im.search_messages` payload={query*} [read/medium]\n - `im.send_message` payload={chat_id*, text*, thread_id} [send/high]\n - `im.update_card` payload={token*, card*} [write/medium]\n - `im.update_message` payload={message_id*, text*} [write/medium]\n - `mail.search_unread` payload={query, limit} [read/high]\n - `sheets.append_row` payload={spreadsheet_token*, sheet_id*, values*} [write/medium]\n - `task.create` payload={title*, description, due_time} [write/medium]\n- `telegram`: Telegram (category=social; backend=adapter; platform_action actions=none registered)\n- `webhook`: Webhook (Generic) (category=webhook; backend=adapter; platform_action actions=none registered)\n\n## Tool Usage\nTools are normally invoked through the native function-calling mechanism, not by writing JSON in your reply\ntext. Only if the provider signals that native function calling is unavailable for this turn, fall back to a\nsingle JSON code block: `{\"name\": \"tool_name\", \"arguments\": {\"key\": \"value\"}}` — use this fallback format\nonly, never both. Only call a tool whose exact name appears in the tool index above and is currently callable\n(or reachable via `capability_expand`). Never invent, rename, alias, or guess a tool name, platform ID, or\nplatform action from argument shape or wording — if the index does not list it, it does not exist.\n`platform_connect.action` (list/guide/preflight/connect/disconnect/remove/status/events_start/events_stop/\nevents_status) is the App Connector management namespace; `platform_action.action` only accepts exact\nregistered business actions such as `im.send_message` shown in the App Connector Capability Index — never mix\nthe two namespaces. If a tool call returns an unknown/unavailable result, use the returned suggestions or\navailable names for a single retry instead of trying further variations of the same guess.\n\n**Side-effect action rule** (`platform_action` with effect=send/write/execute):\n- Call each unique action+payload **exactly once**. Never include duplicates in the same turn.\n- Once the result returns `\"completed\": true`, that action is DONE for this task. Do NOT call it again\n in any subsequent turn — immediately summarize the result for the user instead.\n- The system enforces idempotency: duplicate calls are blocked and will not execute.\n- If the user explicitly requests sending/writing multiple times, use distinct payloads per call.\n\n**Resource identifier provenance rule**:\n- NEVER fabricate, guess, or infer resource identifiers (chat_id, message_id, file_key, user_id, etc.).\n Every resource ID used in a side-effect action MUST come from a successful API response in this session.\n- If a read/list action fails (e.g. authorization error), you do NOT have valid resource IDs.\n Report the failure to the user — do NOT attempt the dependent write/send action with a guessed ID.\n- When a tool result contains `\"llm_instruction\"`, follow it exactly.\n\n## Guidelines\n1. **Direct answers first**: If you already know the answer, respond directly without tools.\n2. **Avoid redundant tool calls**: Do not call the same tool with the same arguments more than once in the same user turn. When an existing tool result already answers the user's request, stop calling tools and answer directly.\n3. **Use tools proactively**: When the user asks about files, time, system state, or needs actions performed, use the appropriate tool.\n4. **Chain tools when needed**: You can call multiple tools in sequence (e.g., list files → read file → summarize).\n5. **Handle failures gracefully**: If a tool fails, explain what went wrong and suggest alternatives. If it failed because of an unknown tool/platform/action name, retry once with an exact name from the returned suggestions, then explain rather than keep guessing.\n6. **Summarize results naturally**: After tool execution, synthesize the results into a helpful answer rather than dumping raw output.\n7. **Stay conversational**: Maintain a natural, helpful tone. Acknowledge context from earlier in the conversation.\n8. **Recall past work**:\n - Broad queries (\"之前做了什么\", \"列出任务\"): answer from the \"Recent Task History\" section already in your context. If insufficient, call session_list.\n - Specific lookups (\"上次那个配置怎么改的\"): call session_search with relevant phrases (NOT single characters).\n - Do NOT call search tools repeatedly with keyword fragments. One well-phrased call is better than ten fragmented ones.\n\n## Coding & Verification\nWhen working with code, prefer the precise built-in tools over ad-hoc shell: use `repo_map` to orient in an\nunfamiliar project, `code_search`/`file_find` to locate and `code_intel` for symbols, `edit_file` (anchored\nsearch-replace, or a unified `diff`) to change files — never rewrite a whole file to change a few lines — and\n`git_query` to inspect diffs/log. After edits, check `syntax_ok` in the result and run `test_run`/`lint_check`\nbefore declaring the work done. Batch independent read-only calls (search/read/query) into a single turn — they\nrun in parallel.\n\n## Presentation Style\n1. **Polished Markdown only**: Format user-facing answers with clean Markdown headings, short paragraphs, and concise bullets. Use tables only when they improve comparison or scanning.\n2. **Terminal-friendly layout**: Keep lines readable in a TUI; avoid dense walls of text, deeply nested lists, oversized ASCII art, or heavy visual blocks.\n3. **Elegant emphasis**: Use bold text sparingly for key terms and conclusions. Avoid excessive emojis, decorative symbols, repeated separators, or visual noise.\n4. **Theme-safe colors**: Do not emit ANSI escape codes, HTML color tags, Rich markup, or hardcoded color names. Rely on the TUI theme to render Markdown professionally.\n5. **No leaked tool protocol**: Never show tool-call JSON, internal schemas, raw observations, tool result payloads, or hidden reasoning in the final answer unless the user explicitly asks for raw/debug output. Treat any prior `{\"name\": ..., \"arguments\": ...}` blocks and `Tool result (...)` messages as internal execution context only.\n6. **Professional closure**: End with a concise conclusion or next step when helpful; avoid rambling after the useful answer is complete.\n\nWhen finished with all tool calls, respond normally without a JSON block, tool-call transcript, or process log.\n\n## Task Contract\n- Task ID: turn-2\n- Original user request: Remove the hermetic DSH echo plugin completely.\n- Workspace root: \n- Allowed roots: \n- Treat relative project paths as relative to the workspace root; never infer `.` as the project root when a workspace root is provided.\n- Workspace boundary is enforced by tools: do not read, search, edit, or run commands against paths outside the allowed roots unless the user explicitly requests an external path and the tool/approval policy permits it.\n- LeapFlow workspace config is optional at `/.leapflow/config.yaml`; runtime config is loaded from `~/.leapflow/config/user.yaml` and `~/.leapflow/profiles//config/*.yaml`.\n- Preserve this task contract across summarization, compression, tool loops, and memory retrieval.\n" + }, + { + "role": "user", + "content": "Invoke fixture_echo with the text after restart." + }, + { + "role": "assistant", + "content": "[Called: fixture_echo]\nThe DSH tool ran after restart." + }, + { + "role": "user", + "content": "Remove the hermetic DSH echo plugin completely.\nRemove the hermetic DSH echo plugin completely." + } + ], + "tools": [ + "capability_expand", + "code_intel", + "code_search", + "config_get", + "config_list", + "env_info", + "file_find", + "file_list", + "file_read", + "fixture_echo", + "git_query", + "lint_check", + "memory_search", + "plugin_list", + "plugin_propose", + "plugin_status", + "plugin_versions", + "repo_map", + "research_note", + "schedule_reentry", + "session_list", + "skill_view", + "skills_list", + "terminal_list", + "terminal_read", + "test_run", + "text_search", + "time_get", + "web_fetch" + ] + }, + "responses": [ + { + "status": 200, + "content_type": "application/json", + "body": "{\"id\": \"chatcmpl-cassette\", \"object\": \"chat.completion\", \"model\": \"cassette-model\", \"choices\": [{\"index\": 0, \"message\": {\"role\": \"assistant\", \"content\": \"\", \"tool_calls\": [{\"id\": \"call_1\", \"type\": \"function\", \"function\": {\"name\": \"plugin_remove\", \"arguments\": \"{\\\"plugin_id\\\": \\\"r6_dsh_echo\\\", \\\"delete_source\\\": true}\"}}]}, \"finish_reason\": \"tool_calls\"}], \"usage\": {\"prompt_tokens\": 64, \"completion_tokens\": 16, \"total_tokens\": 80}}" + } + ] +} diff --git a/tests/_fixtures/cassettes/r6_lifecycle/cassette-model-bd5f16c923bdcbbb.cassette.json b/tests/_fixtures/cassettes/r6_lifecycle/cassette-model-bd5f16c923bdcbbb.cassette.json new file mode 100644 index 0000000..80d5ca5 --- /dev/null +++ b/tests/_fixtures/cassettes/r6_lifecycle/cassette-model-bd5f16c923bdcbbb.cassette.json @@ -0,0 +1,75 @@ +{ + "fingerprint": "bd5f16c923bdcbbbd190d5f59dd4e01bc0f2f681888c08e85fae90f5d47d6bd6", + "note": "captured in seed mode", + "request": { + "model": "cassette-model", + "stream": false, + "messages": [ + { + "role": "system", + "content": "You are LeapFlow, an intelligent assistant that can both converse naturally and take real actions on the user's computer.\n\n## Capabilities\nThe tool index below lists **every** registered tool by name and a one-line summary — this is the complete\ncapability contract; nothing else exists. Only a subset is directly callable this turn (via native tool calling,\nnot a JSON block in your reply). If you need a tool from the index that is not yet callable, call\n`capability_expand` with its category name first — the matching tools become callable immediately after.\nWhen the user asks what LeapFlow itself supports, whether it supports plugins, or which runtime capabilities\nare available, use the live capability evidence exposed by `plugin_list` before making capability claims; report\nconfiguration-dependent or unavailable capabilities as limitations instead of inferring from documentation.\n- **text_search**(text, pattern): Search for a regex pattern in text.\n- **text_replace**(text, old, new, count) [capability_expand category: write]: Replace occurrences of a substring in text.\n- **time_get**(): Get current date and time.\n- **env_info**(): Get system environment information (OS, Python version, cwd).\n- **skills_list**(query, category, source): List available learned skills. Use when user asks about capabilities or you need a specific skill.\n- **skill_view**(name): View the full content of a specific skill document.\n- **code_intel**(path, operation): Precise document symbols (outline) for a source file: classes, functions, and methods with line ranges. Python uses an exact AST parse; other languages use a keyword-prefix scan. Prefer over file_read mode=symbols for accurate navigation before editing. Read-only.\n- **repo_map**(path): Compact project orientation for a repository root: languages, detected test/lint commands, top-level structure, entry points, manifest, and VCS branch. Call this first when entering an unfamiliar codebase. Read-only.\n- **scm_sync**(action, cwd, remote, pull_ref, push_ref, timeout) [capability_expand category: scm]: Run a typed git SCM action. Use this instead of shell_run for git pull/push/status. For 'pull origin main then push', set action='pull_then_push', remote='origin', pull_ref='main', and omit push_ref so LeapFlow pushes the current local branch.\n- **git_query**(action, cwd, ref, path, staged, max_count, stat): Read-only structured git inspection: action=diff|log|status|branch|show. Prefer over shell_run for reading repo state — output is clipped, redacted, and log/branch are parsed into structured fields. Use scm_sync for pull/push.\n- **git_write**(action, cwd, message, stage_all, name, ref, create) [capability_expand category: scm]: Mutating git actions: action=commit (message, stage_all), branch (create+switch), checkout (switch; create=true for -b). Approval-gated. Use scm_sync for pull/push and git_query for reads.\n- **test_run**(command, cwd, timeout): Run the project's test suite and return structured results (framework, passed/failed counts, failing tests). Auto-detects the runner (pytest/npm/go/cargo) or uses a configured/explicit command; executes via the governed shell. ok=true means the runner executed — see 'success' for pass/fail.\n- **lint_check**(command, cwd, timeout): Run the project's linter and return a structured clean/issue result. Auto-detects the linter (ruff/eslint/go vet/clippy) or uses a configured/explicit command; executes via the governed shell. ok=true means the linter ran — see 'clean'.\n- **file_list**(path, pattern, depth): List files and directories at a given path. Use depth=1 or depth=2 to get a recursive tree in one call instead of listing each sub-directory separately.\n- **file_read**(path, max_lines, start_line, max_chars, mode): Read text file content with adaptive context governance. For large or unfamiliar files, prefer mode='outline' or mode='symbols' first, then use mode='raw' with start_line/max_lines for the specific range you actually need. For LeapFlow's own settings, use config_list / config_get / config_set — its config files are outside the workspace and not readable here.\n- **file_write**(path, content, mode) [capability_expand category: write]: Write content to a file (overwrite or append).\n- **code_search**(pattern, patterns, path, glob, ignore_case, multiline, max_results, context_lines): Search file CONTENTS by regex pattern across a directory tree (ripgrep-backed). Requires a regex pattern. NOT for listing or browsing directory contents — use file_list for that. Prefer this over shell_run grep: faster, skips VCS/dependency/build dirs, and returns structured path:line:column matches. Batch related lookups into ONE call via `patterns` (OR-combined, single pass) instead of issuing several separate searches. Use file_read for the surrounding context of a hit.\n- **file_find**(glob, path, max_results): Find files by a recursive glob pattern under a base path (e.g. '**/test_*.py' or '*.md'). Prefer this over shell_run find; skips VCS/dependency/build dirs.\n- **edit_file**(path, edits, dry_run, diff) [capability_expand category: write]: Apply targeted, anchored search-replace edits to an EXISTING text file (use file_write to create/overwrite). Each edit is {original_text, new_text, replace_all?}; original_text must match exactly and uniquely (or set replace_all) — a non-unique or missing anchor is rejected so files are never corrupted. Set dry_run to preview. Alternatively pass a unified 'diff' to apply its hunks as anchored edits. Far cheaper and safer than rewriting a whole file.\n- **shell_run**(command, cwd, timeout) [capability_expand category: shell]: Execute a one-shot shell command with timeout protection. Runs in the active workspace; paths resolving outside it are refused. Reach for a structured tool first when one fits — web_fetch for anything over HTTP(S), git_query/scm_sync for git, code_search/file_find/file_read for the repo, config_get/config_set for LeapFlow's own settings — because those report typed results, while a failed shell command can only be diagnosed from its exit code and stderr. Every shell run counts as an external side effect, so a failure stops the rest of the batch and is not retried automatically.\n- **terminal_open**(command, cwd, shell) [capability_expand category: terminal]: Open a PERSISTENT shell session (REPL/dev server/watch), returning a session_id for terminal_send/read/close. Disabled unless tools.terminal_session_enabled is set. For one-shot commands use shell_run instead.\n- **terminal_send**(session_id, input, wait) [capability_expand category: terminal]: Send a line of input to a persistent terminal session and return output captured shortly after.\n- **terminal_read**(session_id, wait): Drain buffered output from a persistent terminal session (optionally waiting briefly first).\n- **terminal_close**(session_id) [capability_expand category: terminal]: Terminate a persistent terminal session and release its process group.\n- **terminal_list**(): List active persistent terminal sessions.\n- **config_list**(category, limit): List LeapFlow's own writable settings (model, provider, daemon, memory, perception, gateway, …) with current values. Use this to discover the exact key before changing anything. Optionally narrow by `category`. This is the only correct way to inspect LeapFlow configuration — never read config files from disk.\n- **config_get**(key): Read one LeapFlow setting by key (e.g. 'llm.model', 'daemon.log_level'), returning its current value, type, scopes, and whether a change needs a daemon restart. Never read LeapFlow config files from disk — use this.\n- **config_set**(key, value, scope) [capability_expand category: config]: Change one LeapFlow setting by key, e.g. switch the model with key='llm.model'. Values are validated and coerced; credentials are stored in the vault automatically. Call config_list or config_get first if unsure of the key. The result states whether a `leap daemon restart` is required. Never edit LeapFlow config files directly.\n- **web_fetch**(url, select, timeout, max_bytes): Read a URL over HTTP(S) and get back extracted, context-sized content: parsed JSON for API endpoints, readable text plus links for web pages. Use this for anything on the internet — prices, docs, releases, articles — instead of running curl through shell_run: it reports real HTTP status codes, retries rate limits on its own, and is a plain read so a retry is always safe. For JSON APIs pass `select` with a dotted path (e.g. 'chart.result.0.meta') to return just that part instead of the whole payload.\n- **memory_search**(query, limit): Search agent memory for relevant past experiences, observations, and facts.\n- **memory_add**(content, kind) [capability_expand category: write]: Store a new observation or insight in memory for future reference.\n- **research_note**(kind, text): Record a compact, structured note about the current task's state so it survives context compression on long / multi-step tasks. Use for durable findings, open questions still to resolve, decisions / excluded paths, and the immediate next step. One concise sentence per note.\n- **capability_expand**(category): Fetch the full callable schema for every tool in a capability category (e.g. 'hub', 'gateway', 'desktop', '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.\n- **delegate_task**(goal, context) [capability_expand category: delegate]: Delegate a complex sub-task to an isolated subagent. The subagent gets a fresh context and restricted tool access. Use when a task is self-contained and can be solved independently.\n- **schedule_reentry**(kind, reason, delay_seconds, event_match, max_reentries, deadline_seconds): Register a re-entry so this task can resume later from its current orientation (findings / open questions / next step). Use when work must pause and continue after a delay (kind=time) or when a matching platform event arrives (kind=event), instead of finishing now. The research-ledger state is carried over automatically.\n- **hub_push**(skill_name, visibility, version) [capability_expand category: hub]: Push a local skill to the ModelScope Hub for sharing or backup.\n- **hub_pull**(repo_id, version) [capability_expand category: hub]: Pull a skill from the ModelScope Hub to install locally.\n- **hub_search**(query) [capability_expand category: hub]: Search for skills on the Hub by keyword or description.\n- **hub_sync**(mode, dry_run) [capability_expand category: hub]: Preview or execute sync between local skills and Hub.\n- **platform_action**(platform, action, payload, backend_kind) [capability_expand category: gateway]: Execute an exact registered business action on an external platform through LeapFlow's App Connector layer. Actions must be copied from the App Connector Capability Index and are addressed as domain.operation, e.g. im.send_message or docs.create_markdown. All business fields (chat_id, text, query, etc.) MUST be placed inside `payload`, never at the top level. Example: {\"platform\":\"feishu\",\"action\":\"im.send_message\",\"payload\":{\"chat_id\":\"oc_xxx\",\"text\":\"hello\"}}. Do not invent action names, do not use management actions such as list/guide/connect/status here.\n- **platform_connect**(action, platform, credentials, options, checkpoint) [capability_expand category: gateway]: List, guide, connect, disconnect, remove, or check status for external platforms using the App Connector management namespace. Supports REST and CLI backends. Use this for management actions such as list/guide/preflight/connect/status; use platform_action only for exact registered business actions.\n- **gateway_send**(platform, chat_id, text, thread_id) [capability_expand category: gateway]: Send a message to a connected external platform (Feishu group, Telegram chat, DingTalk conversation, etc.). Requires the platform to be connected via gateway_connect first. Use gateway_connect with action='list' to see connected platforms and available chat IDs.\n- **gateway_connect**(action, platform, credentials, options) [capability_expand category: gateway]: Connect, configure, or manage external platform integrations (Feishu, DingTalk, Telegram, Slack, Discord, etc.). Conversational flow: 1) call 'guide' to get setup steps + required fields, 2) present the steps to the user and ask for ALL required credentials in a single message, 3) call 'connect' with the credentials. Goal: complete in 1–2 user turns. NEVER include credential values in your text response.\n- **plugin_list**(): List the live plugin registry and cross-subsystem capability evidence. Use this before answering questions about whether LeapFlow supports plugins, self-evolution, plugin installation, hot reload, versioning, or other runtime capabilities.\n- **plugin_status**(plugin_id): Get detailed status of a specific plugin: its declared category, runtime dependencies, contributed tools, and fiber lifecycle state.\n- **plugin_versions**(plugin_id): List recorded source versions and active pointer for a profile-scoped plugin.\n- **plugin_propose**(requested_capability, plugin_id, proposed_tools, test_cases, risk_level, evidence): Create a side-effect-free PluginProposal from explicit capability-gap evidence. Use this before plugin_generate when a missing capability should be reviewed. Does not call an LLM, write files, or install anything.\n- **assess_compatibility**(manifest, source_path) [capability_expand category: plugin_management]: Assess whether a foreign plugin manifest or real DSH source bundle is compatible with LeapFlow. A source bundle assessment is static: runtime_ready stays false until restricted Node discovery during plugin_install. Returns component-level verdicts and limitations.\n- **plugin_generate**(plugin_id, description, proposal_id) [capability_expand category: system]: Generate a new ToolPlugin from a natural-language capability description. The LLM produces code that conforms to the ToolPlugin Protocol; it is then rigorously validated (syntax, structure, import, protocol conformance). The isolated sandbox smoke test runs later, at install-time. Returns the validated code but DOES NOT install it — installation is a separate approval-gated step via plugin_install.\n- **plugin_install**(plugin_id, code, marketplace_name, source_path, proposal_id, version_label) [capability_expand category: system]: Install a plugin from exactly one source: validated Python code, the configured Python marketplace, or a real DSH source bundle. DSH bundles run restricted Node discovery before registration; only public host tools are installed and client UI limitations are reported. Writes profile-scoped state and mutates the process-global registry. REQUIRES APPROVAL.\n- **plugin_rollback**(plugin_id, version) [capability_expand category: system]: Rollback a profile-scoped plugin to a recorded source version and reload it. REQUIRES APPROVAL.\n- **plugin_reload**(plugin_id, version_label) [capability_expand category: system]: Hot-reload a plugin at runtime. Disposes the old plugin fiber, re-imports its module, and registers a fresh instance. Existing in-flight turns are unaffected (snapshot isolation). REQUIRES APPROVAL — this is a self-modification action.\n- **plugin_disable**(plugin_id) [capability_expand category: system]: Disable a plugin by disposing its fiber, removing its tools from the runtime registry. Cannot disable self_management itself. REQUIRES APPROVAL — this is a self-modification action.\n- **plugin_remove**(plugin_id, delete_source) [capability_expand category: system]: Terminally remove a plugin: dispose its fiber, unregister its tools, remove reload metadata, and optionally delete its profile-scoped source file. Cannot remove self_management itself. REQUIRES APPROVAL.\n- **plugin_enable**(plugin_id) [capability_expand category: system]: Re-enable a previously disabled plugin by reloading its module and registering a fresh instance. REQUIRES APPROVAL.\n- **session_search**(query, limit) [capability_expand category: unclassified]: Search past conversation sessions in the current workspace for relevant context.\n- **session_list**(limit): List recent conversation sessions in the current workspace with stable ids, titles, dates, and summaries. Use for browsing past tasks or when user asks to see history without specific search terms. No keywords needed — returns chronological list.\n- **session_detail**(session_id, limit, offset, include_inactive) [capability_expand category: unclassified]: Read a paginated persisted transcript for one past conversation session in the current workspace. Use after session_list or session_search returns a session_id.\n- **fixture_echo**(text) [capability_expand category: bridge]: Return the supplied text from a pre-built DSH package.\n- **click**(element_index) [capability_expand category: desktop]: Click a UI element by its element_index (from the latest observe_ui snapshot)\n- **get_clipboard**() [capability_expand category: desktop]: Read current clipboard text content\n- **list_apps**(filter, running_only) [capability_expand category: desktop]: List available applications on this system. Use to discover correct bundle_id before switch_app.\n- **list_windows**() [capability_expand category: desktop]: List all top-level windows with pid, window_id, title, and per-window state (minimized, on-screen). Call this first to pick the pid and window_id that observe_ui and other window tools require.\n- **observe_ui**(pid, window_id, query) [capability_expand category: desktop]: Snapshot one window's actionable UI elements, each tagged with an element_index for click/right_click/read_text. Re-observe after actions — indices belong to one snapshot. Requires the window's pid and window_id from list_windows.\n- **open_url**(url, app_id) [capability_expand category: desktop]: Open a URL in the default or specified browser\n- **read_text**(element_index) [capability_expand category: desktop]: Read the text content of a specific UI element from the latest snapshot\n- **right_click**(element_index) [capability_expand category: desktop]: Right-click a UI element to open its context menu. Returns visible menu items.\n- **screenshot**(pid, window_id) [capability_expand category: desktop]: Capture a screenshot for visual verification. With pid + window_id captures that window (works across all displays); defaults to the last observed window, or the full desktop when no window has been observed.\n- **scroll**(element_index, direction, amount, pid, window_id) [capability_expand category: desktop]: Scroll a scrollable area of a window. Omit element_index to scroll the window's focused/page scroller; pass one to scroll an exact element from the latest snapshot.\n- **select_text**(element_index) [capability_expand category: desktop]: Select all text in a UI element (focus + select-all, for subsequent copy)\n- **set_clipboard**(text) [capability_expand category: desktop]: Write text to the clipboard\n- **shortcut**(keys) [capability_expand category: desktop]: Execute a keyboard shortcut\n- **switch_app**(app_id) [capability_expand category: desktop]: Switch to an app (launch if needed, activate, verify)\n- **type_text**(text) [capability_expand category: desktop]: Type text into the currently focused element\n- **wait**(seconds) [capability_expand category: desktop]: Wait for a specified duration before continuing\n- **wait_until**(condition, pid, window_id, timeout, poll_interval) [capability_expand category: desktop]: Wait until a UI condition is met (polls UI tree). Returns elements when found or on timeout.\n- **wait_until_stable**(timeout, poll_interval, pid, window_id) [capability_expand category: desktop]: Wait until the UI stops changing (element set stabilizes across polls).\n\n## App Connector Capability Index\nLeapFlow can onboard and manage external apps through `platform_connect` and execute exact registered business actions through `platform_action`.\nFor requests about connecting, setting up, configuring, enabling, or managing a supported app, use `platform_connect` first instead of generating SDK/Webhook sample code.\n`platform_connect.action` is the management namespace: list, guide, preflight, connect, disconnect, remove, status, events_start, events_stop, events_status.\n`platform_action.action` is only for exact registered platform business actions listed below, such as `im.send_message`; never use management actions like `list` or `guide` there.\nAll business fields MUST go inside `payload`; top-level keys are only `platform`, `action`, and `payload`.\nDo not invent platform IDs or platform action names. If the needed action is not listed, ask for discovery/clarification instead of guessing.\nUse `platform_connect` with `action='guide'` and the matching `platform` to start onboarding; use `action='list'` when the app is unclear.\nWhen a pending onboarding state is present, continue from that state with `platform_connect` instead of asking the user to restate the app.\nSupported apps:\n- `api_server`: API Server (OpenAI Compatible) (category=api; backend=adapter; platform_action actions=none registered)\n- `dingtalk`: 钉钉 (DingTalk) (category=im; backend=adapter; platform_action actions=none registered)\n- `feishu`: 飞书 (Feishu/Lark) (category=im; backend=cli)\n - `calendar.create_event` payload={summary*, start_time*, end_time*, attendees} [write/high]\n - `docs.create_markdown` payload={title*, markdown*, folder_token} [write/medium]\n - `drive.search` payload={query*, limit} [read/medium]\n - `im.add_reaction` payload={message_id*, emoji_type*} [write/low]\n - `im.download_resource` payload={message_id*, file_key*, type*} [read/low]\n - `im.get_messages` payload={message_ids*} [read/low]\n - `im.list_chats` payload={page_size} [read/low]\n - `im.list_messages` payload={chat_id*} [read/low]\n - `im.list_thread_messages` payload={thread*} [read/low]\n - `im.remove_reaction` payload={message_id*, emoji_type*} [write/low]\n - `im.reply_message` payload={message_id*, text*} [send/high]\n - `im.search_chats` payload={query*, page_size} [read/low]\n - `im.search_messages` payload={query*} [read/medium]\n - `im.send_message` payload={chat_id*, text*, thread_id} [send/high]\n - `im.update_card` payload={token*, card*} [write/medium]\n - `im.update_message` payload={message_id*, text*} [write/medium]\n - `mail.search_unread` payload={query, limit} [read/high]\n - `sheets.append_row` payload={spreadsheet_token*, sheet_id*, values*} [write/medium]\n - `task.create` payload={title*, description, due_time} [write/medium]\n- `telegram`: Telegram (category=social; backend=adapter; platform_action actions=none registered)\n- `webhook`: Webhook (Generic) (category=webhook; backend=adapter; platform_action actions=none registered)\n\n## Tool Usage\nTools are normally invoked through the native function-calling mechanism, not by writing JSON in your reply\ntext. Only if the provider signals that native function calling is unavailable for this turn, fall back to a\nsingle JSON code block: `{\"name\": \"tool_name\", \"arguments\": {\"key\": \"value\"}}` — use this fallback format\nonly, never both. Only call a tool whose exact name appears in the tool index above and is currently callable\n(or reachable via `capability_expand`). Never invent, rename, alias, or guess a tool name, platform ID, or\nplatform action from argument shape or wording — if the index does not list it, it does not exist.\n`platform_connect.action` (list/guide/preflight/connect/disconnect/remove/status/events_start/events_stop/\nevents_status) is the App Connector management namespace; `platform_action.action` only accepts exact\nregistered business actions such as `im.send_message` shown in the App Connector Capability Index — never mix\nthe two namespaces. If a tool call returns an unknown/unavailable result, use the returned suggestions or\navailable names for a single retry instead of trying further variations of the same guess.\n\n**Side-effect action rule** (`platform_action` with effect=send/write/execute):\n- Call each unique action+payload **exactly once**. Never include duplicates in the same turn.\n- Once the result returns `\"completed\": true`, that action is DONE for this task. Do NOT call it again\n in any subsequent turn — immediately summarize the result for the user instead.\n- The system enforces idempotency: duplicate calls are blocked and will not execute.\n- If the user explicitly requests sending/writing multiple times, use distinct payloads per call.\n\n**Resource identifier provenance rule**:\n- NEVER fabricate, guess, or infer resource identifiers (chat_id, message_id, file_key, user_id, etc.).\n Every resource ID used in a side-effect action MUST come from a successful API response in this session.\n- If a read/list action fails (e.g. authorization error), you do NOT have valid resource IDs.\n Report the failure to the user — do NOT attempt the dependent write/send action with a guessed ID.\n- When a tool result contains `\"llm_instruction\"`, follow it exactly.\n\n## Guidelines\n1. **Direct answers first**: If you already know the answer, respond directly without tools.\n2. **Avoid redundant tool calls**: Do not call the same tool with the same arguments more than once in the same user turn. When an existing tool result already answers the user's request, stop calling tools and answer directly.\n3. **Use tools proactively**: When the user asks about files, time, system state, or needs actions performed, use the appropriate tool.\n4. **Chain tools when needed**: You can call multiple tools in sequence (e.g., list files → read file → summarize).\n5. **Handle failures gracefully**: If a tool fails, explain what went wrong and suggest alternatives. If it failed because of an unknown tool/platform/action name, retry once with an exact name from the returned suggestions, then explain rather than keep guessing.\n6. **Summarize results naturally**: After tool execution, synthesize the results into a helpful answer rather than dumping raw output.\n7. **Stay conversational**: Maintain a natural, helpful tone. Acknowledge context from earlier in the conversation.\n8. **Recall past work**:\n - Broad queries (\"之前做了什么\", \"列出任务\"): answer from the \"Recent Task History\" section already in your context. If insufficient, call session_list.\n - Specific lookups (\"上次那个配置怎么改的\"): call session_search with relevant phrases (NOT single characters).\n - Do NOT call search tools repeatedly with keyword fragments. One well-phrased call is better than ten fragmented ones.\n\n## Coding & Verification\nWhen working with code, prefer the precise built-in tools over ad-hoc shell: use `repo_map` to orient in an\nunfamiliar project, `code_search`/`file_find` to locate and `code_intel` for symbols, `edit_file` (anchored\nsearch-replace, or a unified `diff`) to change files — never rewrite a whole file to change a few lines — and\n`git_query` to inspect diffs/log. After edits, check `syntax_ok` in the result and run `test_run`/`lint_check`\nbefore declaring the work done. Batch independent read-only calls (search/read/query) into a single turn — they\nrun in parallel.\n\n## Presentation Style\n1. **Polished Markdown only**: Format user-facing answers with clean Markdown headings, short paragraphs, and concise bullets. Use tables only when they improve comparison or scanning.\n2. **Terminal-friendly layout**: Keep lines readable in a TUI; avoid dense walls of text, deeply nested lists, oversized ASCII art, or heavy visual blocks.\n3. **Elegant emphasis**: Use bold text sparingly for key terms and conclusions. Avoid excessive emojis, decorative symbols, repeated separators, or visual noise.\n4. **Theme-safe colors**: Do not emit ANSI escape codes, HTML color tags, Rich markup, or hardcoded color names. Rely on the TUI theme to render Markdown professionally.\n5. **No leaked tool protocol**: Never show tool-call JSON, internal schemas, raw observations, tool result payloads, or hidden reasoning in the final answer unless the user explicitly asks for raw/debug output. Treat any prior `{\"name\": ..., \"arguments\": ...}` blocks and `Tool result (...)` messages as internal execution context only.\n6. **Professional closure**: End with a concise conclusion or next step when helpful; avoid rambling after the useful answer is complete.\n\nWhen finished with all tool calls, respond normally without a JSON block, tool-call transcript, or process log.\n\n\n## Recent Session Summary\n- [assistant] Installed the hermetic DSH plugin.\n- [user] Invoke fixture_echo with the text before restart.\n\n## Task Contract\n- Task ID: turn-2\n- Original user request: Invoke fixture_echo with the text before restart.\n- Workspace root: \n- Allowed roots: \n- Treat relative project paths as relative to the workspace root; never infer `.` as the project root when a workspace root is provided.\n- Workspace boundary is enforced by tools: do not read, search, edit, or run commands against paths outside the allowed roots unless the user explicitly requests an external path and the tool/approval policy permits it.\n- LeapFlow workspace config is optional at `/.leapflow/config.yaml`; runtime config is loaded from `~/.leapflow/config/user.yaml` and `~/.leapflow/profiles//config/*.yaml`.\n- Preserve this task contract across summarization, compression, tool loops, and memory retrieval.\n" + }, + { + "role": "assistant", + "content": "Installed the hermetic DSH plugin." + }, + { + "role": "user", + "content": "Invoke fixture_echo with the text before restart.\nInvoke fixture_echo with the text before restart." + }, + { + "role": "assistant", + "content": "", + "tool_calls": [ + "fixture_echo" + ] + }, + { + "role": "tool", + "content": "{\"ok\": true, \"echo\": \"before restart\", \"execution_id\": \"\", \"idempotency_key\": \"\", \"execution_policy\": \"read_only\", \"tool_call_id\": \"call_1\", \"execution_status\": \"completed\"}", + "tool_result": true + }, + { + "role": "assistant", + "content": "Operation interrupted. Continuing..." + } + ], + "tools": [ + "capability_expand", + "code_intel", + "code_search", + "config_get", + "config_list", + "env_info", + "file_find", + "file_list", + "file_read", + "git_query", + "lint_check", + "memory_search", + "plugin_list", + "plugin_propose", + "plugin_status", + "plugin_versions", + "repo_map", + "research_note", + "schedule_reentry", + "session_list", + "skill_view", + "skills_list", + "terminal_list", + "terminal_read", + "test_run", + "text_search", + "time_get", + "web_fetch" + ] + }, + "responses": [ + { + "status": 200, + "content_type": "application/json", + "body": "{\"id\": \"chatcmpl-cassette\", \"object\": \"chat.completion\", \"model\": \"cassette-model\", \"choices\": [{\"index\": 0, \"message\": {\"role\": \"assistant\", \"content\": \"The DSH tool ran before restart.\"}, \"finish_reason\": \"stop\"}], \"usage\": {\"prompt_tokens\": 64, \"completion_tokens\": 16, \"total_tokens\": 80}}" + } + ] +} diff --git a/tests/_fixtures/cassettes/r7_adaptive_plugin_loop/cassette-model-00d4eaae7debc887.cassette.json b/tests/_fixtures/cassettes/r7_adaptive_plugin_loop/cassette-model-00d4eaae7debc887.cassette.json new file mode 100644 index 0000000..ec93870 --- /dev/null +++ b/tests/_fixtures/cassettes/r7_adaptive_plugin_loop/cassette-model-00d4eaae7debc887.cassette.json @@ -0,0 +1,59 @@ +{ + "fingerprint": "00d4eaae7debc887047d51b2a7846b9ee9a1aa668a3394789ae1f2311c24db6a", + "note": "captured in seed mode", + "request": { + "model": "cassette-model", + "stream": false, + "messages": [ + { + "role": "system", + "content": "You are LeapFlow, an intelligent assistant that can both converse naturally and take real actions on the user's computer.\n\n## Capabilities\nThe tool index below lists **every** registered tool by name and a one-line summary — this is the complete\ncapability contract; nothing else exists. Only a subset is directly callable this turn (via native tool calling,\nnot a JSON block in your reply). If you need a tool from the index that is not yet callable, call\n`capability_expand` with its category name first — the matching tools become callable immediately after.\nWhen the user asks what LeapFlow itself supports, whether it supports plugins, or which runtime capabilities\nare available, use the live capability evidence exposed by `plugin_list` before making capability claims; report\nconfiguration-dependent or unavailable capabilities as limitations instead of inferring from documentation.\n- **text_search**(text, pattern): Search for a regex pattern in text.\n- **text_replace**(text, old, new, count) [capability_expand category: write]: Replace occurrences of a substring in text.\n- **time_get**(): Get current date and time.\n- **env_info**(): Get system environment information (OS, Python version, cwd).\n- **skills_list**(query, category, source): List available learned skills. Use when user asks about capabilities or you need a specific skill.\n- **skill_view**(name): View the full content of a specific skill document.\n- **code_intel**(path, operation): Precise document symbols (outline) for a source file: classes, functions, and methods with line ranges. Python uses an exact AST parse; other languages use a keyword-prefix scan. Prefer over file_read mode=symbols for accurate navigation before editing. Read-only.\n- **repo_map**(path): Compact project orientation for a repository root: languages, detected test/lint commands, top-level structure, entry points, manifest, and VCS branch. Call this first when entering an unfamiliar codebase. Read-only.\n- **scm_sync**(action, cwd, remote, pull_ref, push_ref, timeout) [capability_expand category: scm]: Run a typed git SCM action. Use this instead of shell_run for git pull/push/status. For 'pull origin main then push', set action='pull_then_push', remote='origin', pull_ref='main', and omit push_ref so LeapFlow pushes the current local branch.\n- **git_query**(action, cwd, ref, path, staged, max_count, stat): Read-only structured git inspection: action=diff|log|status|branch|show. Prefer over shell_run for reading repo state — output is clipped, redacted, and log/branch are parsed into structured fields. Use scm_sync for pull/push.\n- **git_write**(action, cwd, message, stage_all, name, ref, create) [capability_expand category: scm]: Mutating git actions: action=commit (message, stage_all), branch (create+switch), checkout (switch; create=true for -b). Approval-gated. Use scm_sync for pull/push and git_query for reads.\n- **test_run**(command, cwd, timeout): Run the project's test suite and return structured results (framework, passed/failed counts, failing tests). Auto-detects the runner (pytest/npm/go/cargo) or uses a configured/explicit command; executes via the governed shell. ok=true means the runner executed — see 'success' for pass/fail.\n- **lint_check**(command, cwd, timeout): Run the project's linter and return a structured clean/issue result. Auto-detects the linter (ruff/eslint/go vet/clippy) or uses a configured/explicit command; executes via the governed shell. ok=true means the linter ran — see 'clean'.\n- **file_list**(path, pattern, depth): List files and directories at a given path. Use depth=1 or depth=2 to get a recursive tree in one call instead of listing each sub-directory separately.\n- **file_read**(path, max_lines, start_line, max_chars, mode): Read text file content with adaptive context governance. For large or unfamiliar files, prefer mode='outline' or mode='symbols' first, then use mode='raw' with start_line/max_lines for the specific range you actually need. For LeapFlow's own settings, use config_list / config_get / config_set — its config files are outside the workspace and not readable here.\n- **file_write**(path, content, mode) [capability_expand category: write]: Write content to a file (overwrite or append).\n- **code_search**(pattern, patterns, path, glob, ignore_case, multiline, max_results, context_lines): Search file CONTENTS by regex pattern across a directory tree (ripgrep-backed). Requires a regex pattern. NOT for listing or browsing directory contents — use file_list for that. Prefer this over shell_run grep: faster, skips VCS/dependency/build dirs, and returns structured path:line:column matches. Batch related lookups into ONE call via `patterns` (OR-combined, single pass) instead of issuing several separate searches. Use file_read for the surrounding context of a hit.\n- **file_find**(glob, path, max_results): Find files by a recursive glob pattern under a base path (e.g. '**/test_*.py' or '*.md'). Prefer this over shell_run find; skips VCS/dependency/build dirs.\n- **edit_file**(path, edits, dry_run, diff) [capability_expand category: write]: Apply targeted, anchored search-replace edits to an EXISTING text file (use file_write to create/overwrite). Each edit is {original_text, new_text, replace_all?}; original_text must match exactly and uniquely (or set replace_all) — a non-unique or missing anchor is rejected so files are never corrupted. Set dry_run to preview. Alternatively pass a unified 'diff' to apply its hunks as anchored edits. Far cheaper and safer than rewriting a whole file.\n- **shell_run**(command, cwd, timeout) [capability_expand category: shell]: Execute a one-shot shell command with timeout protection. Runs in the active workspace; paths resolving outside it are refused. Reach for a structured tool first when one fits — web_fetch for anything over HTTP(S), git_query/scm_sync for git, code_search/file_find/file_read for the repo, config_get/config_set for LeapFlow's own settings — because those report typed results, while a failed shell command can only be diagnosed from its exit code and stderr. Every shell run counts as an external side effect, so a failure stops the rest of the batch and is not retried automatically.\n- **terminal_open**(command, cwd, shell) [capability_expand category: terminal]: Open a PERSISTENT shell session (REPL/dev server/watch), returning a session_id for terminal_send/read/close. Disabled unless tools.terminal_session_enabled is set. For one-shot commands use shell_run instead.\n- **terminal_send**(session_id, input, wait) [capability_expand category: terminal]: Send a line of input to a persistent terminal session and return output captured shortly after.\n- **terminal_read**(session_id, wait): Drain buffered output from a persistent terminal session (optionally waiting briefly first).\n- **terminal_close**(session_id) [capability_expand category: terminal]: Terminate a persistent terminal session and release its process group.\n- **terminal_list**(): List active persistent terminal sessions.\n- **config_list**(category, limit): List LeapFlow's own writable settings (model, provider, daemon, memory, perception, gateway, …) with current values. Use this to discover the exact key before changing anything. Optionally narrow by `category`. This is the only correct way to inspect LeapFlow configuration — never read config files from disk.\n- **config_get**(key): Read one LeapFlow setting by key (e.g. 'llm.model', 'daemon.log_level'), returning its current value, type, scopes, and whether a change needs a daemon restart. Never read LeapFlow config files from disk — use this.\n- **config_set**(key, value, scope) [capability_expand category: config]: Change one LeapFlow setting by key, e.g. switch the model with key='llm.model'. Values are validated and coerced; credentials are stored in the vault automatically. Call config_list or config_get first if unsure of the key. The result states whether a `leap daemon restart` is required. Never edit LeapFlow config files directly.\n- **web_fetch**(url, select, timeout, max_bytes): Read a URL over HTTP(S) and get back extracted, context-sized content: parsed JSON for API endpoints, readable text plus links for web pages. Use this for anything on the internet — prices, docs, releases, articles — instead of running curl through shell_run: it reports real HTTP status codes, retries rate limits on its own, and is a plain read so a retry is always safe. For JSON APIs pass `select` with a dotted path (e.g. 'chart.result.0.meta') to return just that part instead of the whole payload.\n- **memory_search**(query, limit): Search agent memory for relevant past experiences, observations, and facts.\n- **memory_add**(content, kind) [capability_expand category: write]: Store a new observation or insight in memory for future reference.\n- **research_note**(kind, text): Record a compact, structured note about the current task's state so it survives context compression on long / multi-step tasks. Use for durable findings, open questions still to resolve, decisions / excluded paths, and the immediate next step. One concise sentence per note.\n- **capability_expand**(category): Fetch the full callable schema for every tool in a capability category (e.g. 'hub', 'gateway', 'desktop', '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.\n- **delegate_task**(goal, context) [capability_expand category: delegate]: Delegate a complex sub-task to an isolated subagent. The subagent gets a fresh context and restricted tool access. Use when a task is self-contained and can be solved independently.\n- **schedule_reentry**(kind, reason, delay_seconds, event_match, max_reentries, deadline_seconds): Register a re-entry so this task can resume later from its current orientation (findings / open questions / next step). Use when work must pause and continue after a delay (kind=time) or when a matching platform event arrives (kind=event), instead of finishing now. The research-ledger state is carried over automatically.\n- **hub_push**(skill_name, visibility, version) [capability_expand category: hub]: Push a local skill to the ModelScope Hub for sharing or backup.\n- **hub_pull**(repo_id, version) [capability_expand category: hub]: Pull a skill from the ModelScope Hub to install locally.\n- **hub_search**(query) [capability_expand category: hub]: Search for skills on the Hub by keyword or description.\n- **hub_sync**(mode, dry_run) [capability_expand category: hub]: Preview or execute sync between local skills and Hub.\n- **platform_action**(platform, action, payload, backend_kind) [capability_expand category: gateway]: Execute an exact registered business action on an external platform through LeapFlow's App Connector layer. Actions must be copied from the App Connector Capability Index and are addressed as domain.operation, e.g. im.send_message or docs.create_markdown. All business fields (chat_id, text, query, etc.) MUST be placed inside `payload`, never at the top level. Example: {\"platform\":\"feishu\",\"action\":\"im.send_message\",\"payload\":{\"chat_id\":\"oc_xxx\",\"text\":\"hello\"}}. Do not invent action names, do not use management actions such as list/guide/connect/status here.\n- **platform_connect**(action, platform, credentials, options, checkpoint) [capability_expand category: gateway]: List, guide, connect, disconnect, remove, or check status for external platforms using the App Connector management namespace. Supports REST and CLI backends. Use this for management actions such as list/guide/preflight/connect/status; use platform_action only for exact registered business actions.\n- **gateway_send**(platform, chat_id, text, thread_id) [capability_expand category: gateway]: Send a message to a connected external platform (Feishu group, Telegram chat, DingTalk conversation, etc.). Requires the platform to be connected via gateway_connect first. Use gateway_connect with action='list' to see connected platforms and available chat IDs.\n- **gateway_connect**(action, platform, credentials, options) [capability_expand category: gateway]: Connect, configure, or manage external platform integrations (Feishu, DingTalk, Telegram, Slack, Discord, etc.). Conversational flow: 1) call 'guide' to get setup steps + required fields, 2) present the steps to the user and ask for ALL required credentials in a single message, 3) call 'connect' with the credentials. Goal: complete in 1–2 user turns. NEVER include credential values in your text response.\n- **plugin_list**(): List the live plugin registry and cross-subsystem capability evidence. Use this before answering questions about whether LeapFlow supports plugins, self-evolution, plugin installation, hot reload, versioning, or other runtime capabilities.\n- **plugin_status**(plugin_id): Get detailed status of a specific plugin: its declared category, runtime dependencies, contributed tools, and fiber lifecycle state.\n- **plugin_versions**(plugin_id): List recorded source versions and active pointer for a profile-scoped plugin.\n- **plugin_propose**(requested_capability, plugin_id, proposed_tools, test_cases, risk_level, evidence): Create a side-effect-free PluginProposal from explicit capability-gap evidence. Use this before plugin_generate when a missing capability should be reviewed. Does not call an LLM, write files, or install anything.\n- **assess_compatibility**(manifest, source_path) [capability_expand category: plugin_management]: Assess whether a foreign plugin manifest or real DSH source bundle is compatible with LeapFlow. A source bundle assessment is static: runtime_ready stays false until restricted Node discovery during plugin_install. Returns component-level verdicts and limitations.\n- **plugin_generate**(plugin_id, description, proposal_id) [capability_expand category: system]: Generate a new ToolPlugin from a natural-language capability description. The LLM produces code that conforms to the ToolPlugin Protocol; it is then rigorously validated (syntax, structure, import, protocol conformance). The isolated sandbox smoke test runs later, at install-time. Returns the validated code but DOES NOT install it — installation is a separate approval-gated step via plugin_install.\n- **plugin_install**(plugin_id, code, marketplace_name, source_path, proposal_id, version_label) [capability_expand category: system]: Install a plugin from exactly one source: validated Python code, the configured Python marketplace, or a real DSH source bundle. DSH bundles run restricted Node discovery before registration; only public host tools are installed and client UI limitations are reported. Writes profile-scoped state and mutates the process-global registry. REQUIRES APPROVAL.\n- **plugin_rollback**(plugin_id, version) [capability_expand category: system]: Rollback a profile-scoped plugin to a recorded source version and reload it. REQUIRES APPROVAL.\n- **plugin_reload**(plugin_id, version_label) [capability_expand category: system]: Hot-reload a plugin at runtime. Disposes the old plugin fiber, re-imports its module, and registers a fresh instance. Existing in-flight turns are unaffected (snapshot isolation). REQUIRES APPROVAL — this is a self-modification action.\n- **plugin_disable**(plugin_id) [capability_expand category: system]: Disable a plugin by disposing its fiber, removing its tools from the runtime registry. Cannot disable self_management itself. REQUIRES APPROVAL — this is a self-modification action.\n- **plugin_remove**(plugin_id, delete_source) [capability_expand category: system]: Terminally remove a plugin: dispose its fiber, unregister its tools, remove reload metadata, and optionally delete its profile-scoped source file. Cannot remove self_management itself. REQUIRES APPROVAL.\n- **plugin_enable**(plugin_id) [capability_expand category: system]: Re-enable a previously disabled plugin by reloading its module and registering a fresh instance. REQUIRES APPROVAL.\n- **session_search**(query, limit) [capability_expand category: unclassified]: Search past conversation sessions in the current workspace for relevant context.\n- **session_list**(limit): List recent conversation sessions in the current workspace with stable ids, titles, dates, and summaries. Use for browsing past tasks or when user asks to see history without specific search terms. No keywords needed — returns chronological list.\n- **session_detail**(session_id, limit, offset, include_inactive) [capability_expand category: unclassified]: Read a paginated persisted transcript for one past conversation session in the current workspace. Use after session_list or session_search returns a session_id.\n- **click**(element_index) [capability_expand category: desktop]: Click a UI element by its element_index (from the latest observe_ui snapshot)\n- **get_clipboard**() [capability_expand category: desktop]: Read current clipboard text content\n- **list_apps**(filter, running_only) [capability_expand category: desktop]: List available applications on this system. Use to discover correct bundle_id before switch_app.\n- **list_windows**() [capability_expand category: desktop]: List all top-level windows with pid, window_id, title, and per-window state (minimized, on-screen). Call this first to pick the pid and window_id that observe_ui and other window tools require.\n- **observe_ui**(pid, window_id, query) [capability_expand category: desktop]: Snapshot one window's actionable UI elements, each tagged with an element_index for click/right_click/read_text. Re-observe after actions — indices belong to one snapshot. Requires the window's pid and window_id from list_windows.\n- **open_url**(url, app_id) [capability_expand category: desktop]: Open a URL in the default or specified browser\n- **read_text**(element_index) [capability_expand category: desktop]: Read the text content of a specific UI element from the latest snapshot\n- **right_click**(element_index) [capability_expand category: desktop]: Right-click a UI element to open its context menu. Returns visible menu items.\n- **screenshot**(pid, window_id) [capability_expand category: desktop]: Capture a screenshot for visual verification. With pid + window_id captures that window (works across all displays); defaults to the last observed window, or the full desktop when no window has been observed.\n- **scroll**(element_index, direction, amount, pid, window_id) [capability_expand category: desktop]: Scroll a scrollable area of a window. Omit element_index to scroll the window's focused/page scroller; pass one to scroll an exact element from the latest snapshot.\n- **select_text**(element_index) [capability_expand category: desktop]: Select all text in a UI element (focus + select-all, for subsequent copy)\n- **set_clipboard**(text) [capability_expand category: desktop]: Write text to the clipboard\n- **shortcut**(keys) [capability_expand category: desktop]: Execute a keyboard shortcut\n- **switch_app**(app_id) [capability_expand category: desktop]: Switch to an app (launch if needed, activate, verify)\n- **type_text**(text) [capability_expand category: desktop]: Type text into the currently focused element\n- **wait**(seconds) [capability_expand category: desktop]: Wait for a specified duration before continuing\n- **wait_until**(condition, pid, window_id, timeout, poll_interval) [capability_expand category: desktop]: Wait until a UI condition is met (polls UI tree). Returns elements when found or on timeout.\n- **wait_until_stable**(timeout, poll_interval, pid, window_id) [capability_expand category: desktop]: Wait until the UI stops changing (element set stabilizes across polls).\n\n## App Connector Capability Index\nLeapFlow can onboard and manage external apps through `platform_connect` and execute exact registered business actions through `platform_action`.\nFor requests about connecting, setting up, configuring, enabling, or managing a supported app, use `platform_connect` first instead of generating SDK/Webhook sample code.\n`platform_connect.action` is the management namespace: list, guide, preflight, connect, disconnect, remove, status, events_start, events_stop, events_status.\n`platform_action.action` is only for exact registered platform business actions listed below, such as `im.send_message`; never use management actions like `list` or `guide` there.\nAll business fields MUST go inside `payload`; top-level keys are only `platform`, `action`, and `payload`.\nDo not invent platform IDs or platform action names. If the needed action is not listed, ask for discovery/clarification instead of guessing.\nUse `platform_connect` with `action='guide'` and the matching `platform` to start onboarding; use `action='list'` when the app is unclear.\nWhen a pending onboarding state is present, continue from that state with `platform_connect` instead of asking the user to restate the app.\nSupported apps:\n- `api_server`: API Server (OpenAI Compatible) (category=api; backend=adapter; platform_action actions=none registered)\n- `dingtalk`: 钉钉 (DingTalk) (category=im; backend=adapter; platform_action actions=none registered)\n- `feishu`: 飞书 (Feishu/Lark) (category=im; backend=cli)\n - `calendar.create_event` payload={summary*, start_time*, end_time*, attendees} [write/high]\n - `docs.create_markdown` payload={title*, markdown*, folder_token} [write/medium]\n - `drive.search` payload={query*, limit} [read/medium]\n - `im.add_reaction` payload={message_id*, emoji_type*} [write/low]\n - `im.download_resource` payload={message_id*, file_key*, type*} [read/low]\n - `im.get_messages` payload={message_ids*} [read/low]\n - `im.list_chats` payload={page_size} [read/low]\n - `im.list_messages` payload={chat_id*} [read/low]\n - `im.list_thread_messages` payload={thread*} [read/low]\n - `im.remove_reaction` payload={message_id*, emoji_type*} [write/low]\n - `im.reply_message` payload={message_id*, text*} [send/high]\n - `im.search_chats` payload={query*, page_size} [read/low]\n - `im.search_messages` payload={query*} [read/medium]\n - `im.send_message` payload={chat_id*, text*, thread_id} [send/high]\n - `im.update_card` payload={token*, card*} [write/medium]\n - `im.update_message` payload={message_id*, text*} [write/medium]\n - `mail.search_unread` payload={query, limit} [read/high]\n - `sheets.append_row` payload={spreadsheet_token*, sheet_id*, values*} [write/medium]\n - `task.create` payload={title*, description, due_time} [write/medium]\n- `telegram`: Telegram (category=social; backend=adapter; platform_action actions=none registered)\n- `webhook`: Webhook (Generic) (category=webhook; backend=adapter; platform_action actions=none registered)\n\n## Tool Usage\nTools are normally invoked through the native function-calling mechanism, not by writing JSON in your reply\ntext. Only if the provider signals that native function calling is unavailable for this turn, fall back to a\nsingle JSON code block: `{\"name\": \"tool_name\", \"arguments\": {\"key\": \"value\"}}` — use this fallback format\nonly, never both. Only call a tool whose exact name appears in the tool index above and is currently callable\n(or reachable via `capability_expand`). Never invent, rename, alias, or guess a tool name, platform ID, or\nplatform action from argument shape or wording — if the index does not list it, it does not exist.\n`platform_connect.action` (list/guide/preflight/connect/disconnect/remove/status/events_start/events_stop/\nevents_status) is the App Connector management namespace; `platform_action.action` only accepts exact\nregistered business actions such as `im.send_message` shown in the App Connector Capability Index — never mix\nthe two namespaces. If a tool call returns an unknown/unavailable result, use the returned suggestions or\navailable names for a single retry instead of trying further variations of the same guess.\n\n**Side-effect action rule** (`platform_action` with effect=send/write/execute):\n- Call each unique action+payload **exactly once**. Never include duplicates in the same turn.\n- Once the result returns `\"completed\": true`, that action is DONE for this task. Do NOT call it again\n in any subsequent turn — immediately summarize the result for the user instead.\n- The system enforces idempotency: duplicate calls are blocked and will not execute.\n- If the user explicitly requests sending/writing multiple times, use distinct payloads per call.\n\n**Resource identifier provenance rule**:\n- NEVER fabricate, guess, or infer resource identifiers (chat_id, message_id, file_key, user_id, etc.).\n Every resource ID used in a side-effect action MUST come from a successful API response in this session.\n- If a read/list action fails (e.g. authorization error), you do NOT have valid resource IDs.\n Report the failure to the user — do NOT attempt the dependent write/send action with a guessed ID.\n- When a tool result contains `\"llm_instruction\"`, follow it exactly.\n\n## Guidelines\n1. **Direct answers first**: If you already know the answer, respond directly without tools.\n2. **Avoid redundant tool calls**: Do not call the same tool with the same arguments more than once in the same user turn. When an existing tool result already answers the user's request, stop calling tools and answer directly.\n3. **Use tools proactively**: When the user asks about files, time, system state, or needs actions performed, use the appropriate tool.\n4. **Chain tools when needed**: You can call multiple tools in sequence (e.g., list files → read file → summarize).\n5. **Handle failures gracefully**: If a tool fails, explain what went wrong and suggest alternatives. If it failed because of an unknown tool/platform/action name, retry once with an exact name from the returned suggestions, then explain rather than keep guessing.\n6. **Summarize results naturally**: After tool execution, synthesize the results into a helpful answer rather than dumping raw output.\n7. **Stay conversational**: Maintain a natural, helpful tone. Acknowledge context from earlier in the conversation.\n8. **Recall past work**:\n - Broad queries (\"之前做了什么\", \"列出任务\"): answer from the \"Recent Task History\" section already in your context. If insufficient, call session_list.\n - Specific lookups (\"上次那个配置怎么改的\"): call session_search with relevant phrases (NOT single characters).\n - Do NOT call search tools repeatedly with keyword fragments. One well-phrased call is better than ten fragmented ones.\n\n## Coding & Verification\nWhen working with code, prefer the precise built-in tools over ad-hoc shell: use `repo_map` to orient in an\nunfamiliar project, `code_search`/`file_find` to locate and `code_intel` for symbols, `edit_file` (anchored\nsearch-replace, or a unified `diff`) to change files — never rewrite a whole file to change a few lines — and\n`git_query` to inspect diffs/log. After edits, check `syntax_ok` in the result and run `test_run`/`lint_check`\nbefore declaring the work done. Batch independent read-only calls (search/read/query) into a single turn — they\nrun in parallel.\n\n## Presentation Style\n1. **Polished Markdown only**: Format user-facing answers with clean Markdown headings, short paragraphs, and concise bullets. Use tables only when they improve comparison or scanning.\n2. **Terminal-friendly layout**: Keep lines readable in a TUI; avoid dense walls of text, deeply nested lists, oversized ASCII art, or heavy visual blocks.\n3. **Elegant emphasis**: Use bold text sparingly for key terms and conclusions. Avoid excessive emojis, decorative symbols, repeated separators, or visual noise.\n4. **Theme-safe colors**: Do not emit ANSI escape codes, HTML color tags, Rich markup, or hardcoded color names. Rely on the TUI theme to render Markdown professionally.\n5. **No leaked tool protocol**: Never show tool-call JSON, internal schemas, raw observations, tool result payloads, or hidden reasoning in the final answer unless the user explicitly asks for raw/debug output. Treat any prior `{\"name\": ..., \"arguments\": ...}` blocks and `Tool result (...)` messages as internal execution context only.\n6. **Professional closure**: End with a concise conclusion or next step when helpful; avoid rambling after the useful answer is complete.\n\nWhen finished with all tool calls, respond normally without a JSON block, tool-call transcript, or process log.\n\n\n## Recent Session Summary\n- [assistant] Observed the missing JSON pretty tool.\n- [user] Install the prepared adaptive JSON pretty plugin.\n\n## Task Contract\n- Task ID: turn-2\n- Original user request: Install the prepared adaptive JSON pretty plugin.\n- Workspace root: \n- Allowed roots: \n- Treat relative project paths as relative to the workspace root; never infer `.` as the project root when a workspace root is provided.\n- Workspace boundary is enforced by tools: do not read, search, edit, or run commands against paths outside the allowed roots unless the user explicitly requests an external path and the tool/approval policy permits it.\n- LeapFlow workspace config is optional at `/.leapflow/config.yaml`; runtime config is loaded from `~/.leapflow/config/user.yaml` and `~/.leapflow/profiles//config/*.yaml`.\n- Preserve this task contract across summarization, compression, tool loops, and memory retrieval.\n" + }, + { + "role": "assistant", + "content": "Observed the missing JSON pretty tool." + }, + { + "role": "user", + "content": "Install the prepared adaptive JSON pretty plugin.\nInstall the prepared adaptive JSON pretty plugin." + } + ], + "tools": [ + "capability_expand", + "code_intel", + "code_search", + "config_get", + "config_list", + "env_info", + "file_find", + "file_list", + "file_read", + "git_query", + "lint_check", + "memory_search", + "plugin_list", + "plugin_propose", + "plugin_status", + "plugin_versions", + "repo_map", + "research_note", + "schedule_reentry", + "session_list", + "skill_view", + "skills_list", + "terminal_list", + "terminal_read", + "test_run", + "text_search", + "time_get", + "web_fetch" + ] + }, + "responses": [ + { + "status": 200, + "content_type": "application/json", + "body": "{\"id\": \"chatcmpl-cassette\", \"object\": \"chat.completion\", \"model\": \"cassette-model\", \"choices\": [{\"index\": 0, \"message\": {\"role\": \"assistant\", \"content\": \"\", \"tool_calls\": [{\"id\": \"call_1\", \"type\": \"function\", \"function\": {\"name\": \"plugin_install\", \"arguments\": \"{\\\"plugin_id\\\": \\\"json_pretty_loop_e2e\\\", \\\"code\\\": \\\"from __future__ import annotations\\\\n\\\\nimport json\\\\nfrom typing import Any\\\\n\\\\nfrom leapflow.plugins.protocol import ToolMetadata\\\\n\\\\n\\\\nasync def json_pretty_loop_e2e(text: str = \\\\\\\"\\\\\\\", **kwargs: Any) -> dict[str, Any]:\\\\n payload = text or kwargs.get(\\\\\\\"payload\\\\\\\") or \\\\\\\"{}\\\\\\\"\\\\n try:\\\\n parsed = json.loads(str(payload))\\\\n except json.JSONDecodeError as exc:\\\\n return {\\\\\\\"ok\\\\\\\": False, \\\\\\\"error\\\\\\\": str(exc)}\\\\n return {\\\\\\\"ok\\\\\\\": True, \\\\\\\"content\\\\\\\": json.dumps(parsed, ensure_ascii=False, indent=2, sort_keys=True)}\\\\n\\\\n\\\\nclass JsonPrettyLoopE2EPlugin:\\\\n @property\\\\n def plugin_id(self) -> str:\\\\n return \\\\\\\"json_pretty_loop_e2e\\\\\\\"\\\\n\\\\n @property\\\\n def category(self) -> str:\\\\n return \\\\\\\"formatting\\\\\\\"\\\\n\\\\n @property\\\\n def dependencies(self) -> list[str]:\\\\n return []\\\\n\\\\n @property\\\\n def tools(self) -> list[ToolMetadata]:\\\\n return [\\\\n ToolMetadata(\\\\n name=\\\\\\\"json_pretty_loop_e2e\\\\\\\",\\\\n description=\\\\\\\"Pretty-print JSON for the adaptive closed-loop journey.\\\\\\\",\\\\n parameters_schema={\\\\n \\\\\\\"type\\\\\\\": \\\\\\\"object\\\\\\\",\\\\n \\\\\\\"properties\\\\\\\": {\\\\n \\\\\\\"text\\\\\\\": {\\\\\\\"type\\\\\\\": \\\\\\\"string\\\\\\\", \\\\\\\"description\\\\\\\": \\\\\\\"JSON text to format\\\\\\\"}\\\\n },\\\\n },\\\\n handler=json_pretty_loop_e2e,\\\\n x_leapflow={\\\\n \\\\\\\"category\\\\\\\": \\\\\\\"formatting\\\\\\\",\\\\n \\\\\\\"risk_level\\\\\\\": \\\\\\\"read_only\\\\\\\",\\\\n \\\\\\\"schema_cost\\\\\\\": \\\\\\\"low\\\\\\\",\\\\n \\\\\\\"requires_approval\\\\\\\": False,\\\\n },\\\\n provides_capabilities=(\\\\\\\"json.pretty\\\\\\\",),\\\\n requires_platform_capabilities=(\\\\\\\"file.ops\\\\\\\",),\\\\n )\\\\n ]\\\\n\\\\n def bind_runtime(self, **deps: Any) -> None:\\\\n return None\\\\n\\\\n\\\\nplugin = JsonPrettyLoopE2EPlugin()\\\\n\\\", \\\"version_label\\\": \\\"r7\\\"}\"}}]}, \"finish_reason\": \"tool_calls\"}], \"usage\": {\"prompt_tokens\": 64, \"completion_tokens\": 16, \"total_tokens\": 80}}" + } + ] +} diff --git a/tests/_fixtures/cassettes/r7_adaptive_plugin_loop/cassette-model-181a36b9b384ee8c.cassette.json b/tests/_fixtures/cassettes/r7_adaptive_plugin_loop/cassette-model-181a36b9b384ee8c.cassette.json new file mode 100644 index 0000000..56da921 --- /dev/null +++ b/tests/_fixtures/cassettes/r7_adaptive_plugin_loop/cassette-model-181a36b9b384ee8c.cassette.json @@ -0,0 +1,55 @@ +{ + "fingerprint": "181a36b9b384ee8c454d6e911aafe4197bc04ed8a43e29c1975ff5f6d4ca3317", + "note": "captured in seed mode", + "request": { + "model": "cassette-model", + "stream": false, + "messages": [ + { + "role": "system", + "content": "You are LeapFlow, an intelligent assistant that can both converse naturally and take real actions on the user's computer.\n\n## Capabilities\nThe tool index below lists **every** registered tool by name and a one-line summary — this is the complete\ncapability contract; nothing else exists. Only a subset is directly callable this turn (via native tool calling,\nnot a JSON block in your reply). If you need a tool from the index that is not yet callable, call\n`capability_expand` with its category name first — the matching tools become callable immediately after.\nWhen the user asks what LeapFlow itself supports, whether it supports plugins, or which runtime capabilities\nare available, use the live capability evidence exposed by `plugin_list` before making capability claims; report\nconfiguration-dependent or unavailable capabilities as limitations instead of inferring from documentation.\n- **text_search**(text, pattern): Search for a regex pattern in text.\n- **text_replace**(text, old, new, count) [capability_expand category: write]: Replace occurrences of a substring in text.\n- **time_get**(): Get current date and time.\n- **env_info**(): Get system environment information (OS, Python version, cwd).\n- **skills_list**(query, category, source): List available learned skills. Use when user asks about capabilities or you need a specific skill.\n- **skill_view**(name): View the full content of a specific skill document.\n- **code_intel**(path, operation): Precise document symbols (outline) for a source file: classes, functions, and methods with line ranges. Python uses an exact AST parse; other languages use a keyword-prefix scan. Prefer over file_read mode=symbols for accurate navigation before editing. Read-only.\n- **repo_map**(path): Compact project orientation for a repository root: languages, detected test/lint commands, top-level structure, entry points, manifest, and VCS branch. Call this first when entering an unfamiliar codebase. Read-only.\n- **scm_sync**(action, cwd, remote, pull_ref, push_ref, timeout) [capability_expand category: scm]: Run a typed git SCM action. Use this instead of shell_run for git pull/push/status. For 'pull origin main then push', set action='pull_then_push', remote='origin', pull_ref='main', and omit push_ref so LeapFlow pushes the current local branch.\n- **git_query**(action, cwd, ref, path, staged, max_count, stat): Read-only structured git inspection: action=diff|log|status|branch|show. Prefer over shell_run for reading repo state — output is clipped, redacted, and log/branch are parsed into structured fields. Use scm_sync for pull/push.\n- **git_write**(action, cwd, message, stage_all, name, ref, create) [capability_expand category: scm]: Mutating git actions: action=commit (message, stage_all), branch (create+switch), checkout (switch; create=true for -b). Approval-gated. Use scm_sync for pull/push and git_query for reads.\n- **test_run**(command, cwd, timeout): Run the project's test suite and return structured results (framework, passed/failed counts, failing tests). Auto-detects the runner (pytest/npm/go/cargo) or uses a configured/explicit command; executes via the governed shell. ok=true means the runner executed — see 'success' for pass/fail.\n- **lint_check**(command, cwd, timeout): Run the project's linter and return a structured clean/issue result. Auto-detects the linter (ruff/eslint/go vet/clippy) or uses a configured/explicit command; executes via the governed shell. ok=true means the linter ran — see 'clean'.\n- **file_list**(path, pattern, depth): List files and directories at a given path. Use depth=1 or depth=2 to get a recursive tree in one call instead of listing each sub-directory separately.\n- **file_read**(path, max_lines, start_line, max_chars, mode): Read text file content with adaptive context governance. For large or unfamiliar files, prefer mode='outline' or mode='symbols' first, then use mode='raw' with start_line/max_lines for the specific range you actually need. For LeapFlow's own settings, use config_list / config_get / config_set — its config files are outside the workspace and not readable here.\n- **file_write**(path, content, mode) [capability_expand category: write]: Write content to a file (overwrite or append).\n- **code_search**(pattern, patterns, path, glob, ignore_case, multiline, max_results, context_lines): Search file CONTENTS by regex pattern across a directory tree (ripgrep-backed). Requires a regex pattern. NOT for listing or browsing directory contents — use file_list for that. Prefer this over shell_run grep: faster, skips VCS/dependency/build dirs, and returns structured path:line:column matches. Batch related lookups into ONE call via `patterns` (OR-combined, single pass) instead of issuing several separate searches. Use file_read for the surrounding context of a hit.\n- **file_find**(glob, path, max_results): Find files by a recursive glob pattern under a base path (e.g. '**/test_*.py' or '*.md'). Prefer this over shell_run find; skips VCS/dependency/build dirs.\n- **edit_file**(path, edits, dry_run, diff) [capability_expand category: write]: Apply targeted, anchored search-replace edits to an EXISTING text file (use file_write to create/overwrite). Each edit is {original_text, new_text, replace_all?}; original_text must match exactly and uniquely (or set replace_all) — a non-unique or missing anchor is rejected so files are never corrupted. Set dry_run to preview. Alternatively pass a unified 'diff' to apply its hunks as anchored edits. Far cheaper and safer than rewriting a whole file.\n- **shell_run**(command, cwd, timeout) [capability_expand category: shell]: Execute a one-shot shell command with timeout protection. Runs in the active workspace; paths resolving outside it are refused. Reach for a structured tool first when one fits — web_fetch for anything over HTTP(S), git_query/scm_sync for git, code_search/file_find/file_read for the repo, config_get/config_set for LeapFlow's own settings — because those report typed results, while a failed shell command can only be diagnosed from its exit code and stderr. Every shell run counts as an external side effect, so a failure stops the rest of the batch and is not retried automatically.\n- **terminal_open**(command, cwd, shell) [capability_expand category: terminal]: Open a PERSISTENT shell session (REPL/dev server/watch), returning a session_id for terminal_send/read/close. Disabled unless tools.terminal_session_enabled is set. For one-shot commands use shell_run instead.\n- **terminal_send**(session_id, input, wait) [capability_expand category: terminal]: Send a line of input to a persistent terminal session and return output captured shortly after.\n- **terminal_read**(session_id, wait): Drain buffered output from a persistent terminal session (optionally waiting briefly first).\n- **terminal_close**(session_id) [capability_expand category: terminal]: Terminate a persistent terminal session and release its process group.\n- **terminal_list**(): List active persistent terminal sessions.\n- **config_list**(category, limit): List LeapFlow's own writable settings (model, provider, daemon, memory, perception, gateway, …) with current values. Use this to discover the exact key before changing anything. Optionally narrow by `category`. This is the only correct way to inspect LeapFlow configuration — never read config files from disk.\n- **config_get**(key): Read one LeapFlow setting by key (e.g. 'llm.model', 'daemon.log_level'), returning its current value, type, scopes, and whether a change needs a daemon restart. Never read LeapFlow config files from disk — use this.\n- **config_set**(key, value, scope) [capability_expand category: config]: Change one LeapFlow setting by key, e.g. switch the model with key='llm.model'. Values are validated and coerced; credentials are stored in the vault automatically. Call config_list or config_get first if unsure of the key. The result states whether a `leap daemon restart` is required. Never edit LeapFlow config files directly.\n- **web_fetch**(url, select, timeout, max_bytes): Read a URL over HTTP(S) and get back extracted, context-sized content: parsed JSON for API endpoints, readable text plus links for web pages. Use this for anything on the internet — prices, docs, releases, articles — instead of running curl through shell_run: it reports real HTTP status codes, retries rate limits on its own, and is a plain read so a retry is always safe. For JSON APIs pass `select` with a dotted path (e.g. 'chart.result.0.meta') to return just that part instead of the whole payload.\n- **memory_search**(query, limit): Search agent memory for relevant past experiences, observations, and facts.\n- **memory_add**(content, kind) [capability_expand category: write]: Store a new observation or insight in memory for future reference.\n- **research_note**(kind, text): Record a compact, structured note about the current task's state so it survives context compression on long / multi-step tasks. Use for durable findings, open questions still to resolve, decisions / excluded paths, and the immediate next step. One concise sentence per note.\n- **capability_expand**(category): Fetch the full callable schema for every tool in a capability category (e.g. 'hub', 'gateway', 'desktop', '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.\n- **delegate_task**(goal, context) [capability_expand category: delegate]: Delegate a complex sub-task to an isolated subagent. The subagent gets a fresh context and restricted tool access. Use when a task is self-contained and can be solved independently.\n- **schedule_reentry**(kind, reason, delay_seconds, event_match, max_reentries, deadline_seconds): Register a re-entry so this task can resume later from its current orientation (findings / open questions / next step). Use when work must pause and continue after a delay (kind=time) or when a matching platform event arrives (kind=event), instead of finishing now. The research-ledger state is carried over automatically.\n- **hub_push**(skill_name, visibility, version) [capability_expand category: hub]: Push a local skill to the ModelScope Hub for sharing or backup.\n- **hub_pull**(repo_id, version) [capability_expand category: hub]: Pull a skill from the ModelScope Hub to install locally.\n- **hub_search**(query) [capability_expand category: hub]: Search for skills on the Hub by keyword or description.\n- **hub_sync**(mode, dry_run) [capability_expand category: hub]: Preview or execute sync between local skills and Hub.\n- **platform_action**(platform, action, payload, backend_kind) [capability_expand category: gateway]: Execute an exact registered business action on an external platform through LeapFlow's App Connector layer. Actions must be copied from the App Connector Capability Index and are addressed as domain.operation, e.g. im.send_message or docs.create_markdown. All business fields (chat_id, text, query, etc.) MUST be placed inside `payload`, never at the top level. Example: {\"platform\":\"feishu\",\"action\":\"im.send_message\",\"payload\":{\"chat_id\":\"oc_xxx\",\"text\":\"hello\"}}. Do not invent action names, do not use management actions such as list/guide/connect/status here.\n- **platform_connect**(action, platform, credentials, options, checkpoint) [capability_expand category: gateway]: List, guide, connect, disconnect, remove, or check status for external platforms using the App Connector management namespace. Supports REST and CLI backends. Use this for management actions such as list/guide/preflight/connect/status; use platform_action only for exact registered business actions.\n- **gateway_send**(platform, chat_id, text, thread_id) [capability_expand category: gateway]: Send a message to a connected external platform (Feishu group, Telegram chat, DingTalk conversation, etc.). Requires the platform to be connected via gateway_connect first. Use gateway_connect with action='list' to see connected platforms and available chat IDs.\n- **gateway_connect**(action, platform, credentials, options) [capability_expand category: gateway]: Connect, configure, or manage external platform integrations (Feishu, DingTalk, Telegram, Slack, Discord, etc.). Conversational flow: 1) call 'guide' to get setup steps + required fields, 2) present the steps to the user and ask for ALL required credentials in a single message, 3) call 'connect' with the credentials. Goal: complete in 1–2 user turns. NEVER include credential values in your text response.\n- **plugin_list**(): List the live plugin registry and cross-subsystem capability evidence. Use this before answering questions about whether LeapFlow supports plugins, self-evolution, plugin installation, hot reload, versioning, or other runtime capabilities.\n- **plugin_status**(plugin_id): Get detailed status of a specific plugin: its declared category, runtime dependencies, contributed tools, and fiber lifecycle state.\n- **plugin_versions**(plugin_id): List recorded source versions and active pointer for a profile-scoped plugin.\n- **plugin_propose**(requested_capability, plugin_id, proposed_tools, test_cases, risk_level, evidence): Create a side-effect-free PluginProposal from explicit capability-gap evidence. Use this before plugin_generate when a missing capability should be reviewed. Does not call an LLM, write files, or install anything.\n- **assess_compatibility**(manifest, source_path) [capability_expand category: plugin_management]: Assess whether a foreign plugin manifest or real DSH source bundle is compatible with LeapFlow. A source bundle assessment is static: runtime_ready stays false until restricted Node discovery during plugin_install. Returns component-level verdicts and limitations.\n- **plugin_generate**(plugin_id, description, proposal_id) [capability_expand category: system]: Generate a new ToolPlugin from a natural-language capability description. The LLM produces code that conforms to the ToolPlugin Protocol; it is then rigorously validated (syntax, structure, import, protocol conformance). The isolated sandbox smoke test runs later, at install-time. Returns the validated code but DOES NOT install it — installation is a separate approval-gated step via plugin_install.\n- **plugin_install**(plugin_id, code, marketplace_name, source_path, proposal_id, version_label) [capability_expand category: system]: Install a plugin from exactly one source: validated Python code, the configured Python marketplace, or a real DSH source bundle. DSH bundles run restricted Node discovery before registration; only public host tools are installed and client UI limitations are reported. Writes profile-scoped state and mutates the process-global registry. REQUIRES APPROVAL.\n- **plugin_rollback**(plugin_id, version) [capability_expand category: system]: Rollback a profile-scoped plugin to a recorded source version and reload it. REQUIRES APPROVAL.\n- **plugin_reload**(plugin_id, version_label) [capability_expand category: system]: Hot-reload a plugin at runtime. Disposes the old plugin fiber, re-imports its module, and registers a fresh instance. Existing in-flight turns are unaffected (snapshot isolation). REQUIRES APPROVAL — this is a self-modification action.\n- **plugin_disable**(plugin_id) [capability_expand category: system]: Disable a plugin by disposing its fiber, removing its tools from the runtime registry. Cannot disable self_management itself. REQUIRES APPROVAL — this is a self-modification action.\n- **plugin_remove**(plugin_id, delete_source) [capability_expand category: system]: Terminally remove a plugin: dispose its fiber, unregister its tools, remove reload metadata, and optionally delete its profile-scoped source file. Cannot remove self_management itself. REQUIRES APPROVAL.\n- **plugin_enable**(plugin_id) [capability_expand category: system]: Re-enable a previously disabled plugin by reloading its module and registering a fresh instance. REQUIRES APPROVAL.\n- **session_search**(query, limit) [capability_expand category: unclassified]: Search past conversation sessions in the current workspace for relevant context.\n- **session_list**(limit): List recent conversation sessions in the current workspace with stable ids, titles, dates, and summaries. Use for browsing past tasks or when user asks to see history without specific search terms. No keywords needed — returns chronological list.\n- **session_detail**(session_id, limit, offset, include_inactive) [capability_expand category: unclassified]: Read a paginated persisted transcript for one past conversation session in the current workspace. Use after session_list or session_search returns a session_id.\n- **click**(element_index) [capability_expand category: desktop]: Click a UI element by its element_index (from the latest observe_ui snapshot)\n- **get_clipboard**() [capability_expand category: desktop]: Read current clipboard text content\n- **list_apps**(filter, running_only) [capability_expand category: desktop]: List available applications on this system. Use to discover correct bundle_id before switch_app.\n- **list_windows**() [capability_expand category: desktop]: List all top-level windows with pid, window_id, title, and per-window state (minimized, on-screen). Call this first to pick the pid and window_id that observe_ui and other window tools require.\n- **observe_ui**(pid, window_id, query) [capability_expand category: desktop]: Snapshot one window's actionable UI elements, each tagged with an element_index for click/right_click/read_text. Re-observe after actions — indices belong to one snapshot. Requires the window's pid and window_id from list_windows.\n- **open_url**(url, app_id) [capability_expand category: desktop]: Open a URL in the default or specified browser\n- **read_text**(element_index) [capability_expand category: desktop]: Read the text content of a specific UI element from the latest snapshot\n- **right_click**(element_index) [capability_expand category: desktop]: Right-click a UI element to open its context menu. Returns visible menu items.\n- **screenshot**(pid, window_id) [capability_expand category: desktop]: Capture a screenshot for visual verification. With pid + window_id captures that window (works across all displays); defaults to the last observed window, or the full desktop when no window has been observed.\n- **scroll**(element_index, direction, amount, pid, window_id) [capability_expand category: desktop]: Scroll a scrollable area of a window. Omit element_index to scroll the window's focused/page scroller; pass one to scroll an exact element from the latest snapshot.\n- **select_text**(element_index) [capability_expand category: desktop]: Select all text in a UI element (focus + select-all, for subsequent copy)\n- **set_clipboard**(text) [capability_expand category: desktop]: Write text to the clipboard\n- **shortcut**(keys) [capability_expand category: desktop]: Execute a keyboard shortcut\n- **switch_app**(app_id) [capability_expand category: desktop]: Switch to an app (launch if needed, activate, verify)\n- **type_text**(text) [capability_expand category: desktop]: Type text into the currently focused element\n- **wait**(seconds) [capability_expand category: desktop]: Wait for a specified duration before continuing\n- **wait_until**(condition, pid, window_id, timeout, poll_interval) [capability_expand category: desktop]: Wait until a UI condition is met (polls UI tree). Returns elements when found or on timeout.\n- **wait_until_stable**(timeout, poll_interval, pid, window_id) [capability_expand category: desktop]: Wait until the UI stops changing (element set stabilizes across polls).\n\n## App Connector Capability Index\nLeapFlow can onboard and manage external apps through `platform_connect` and execute exact registered business actions through `platform_action`.\nFor requests about connecting, setting up, configuring, enabling, or managing a supported app, use `platform_connect` first instead of generating SDK/Webhook sample code.\n`platform_connect.action` is the management namespace: list, guide, preflight, connect, disconnect, remove, status, events_start, events_stop, events_status.\n`platform_action.action` is only for exact registered platform business actions listed below, such as `im.send_message`; never use management actions like `list` or `guide` there.\nAll business fields MUST go inside `payload`; top-level keys are only `platform`, `action`, and `payload`.\nDo not invent platform IDs or platform action names. If the needed action is not listed, ask for discovery/clarification instead of guessing.\nUse `platform_connect` with `action='guide'` and the matching `platform` to start onboarding; use `action='list'` when the app is unclear.\nWhen a pending onboarding state is present, continue from that state with `platform_connect` instead of asking the user to restate the app.\nSupported apps:\n- `api_server`: API Server (OpenAI Compatible) (category=api; backend=adapter; platform_action actions=none registered)\n- `dingtalk`: 钉钉 (DingTalk) (category=im; backend=adapter; platform_action actions=none registered)\n- `feishu`: 飞书 (Feishu/Lark) (category=im; backend=cli)\n - `calendar.create_event` payload={summary*, start_time*, end_time*, attendees} [write/high]\n - `docs.create_markdown` payload={title*, markdown*, folder_token} [write/medium]\n - `drive.search` payload={query*, limit} [read/medium]\n - `im.add_reaction` payload={message_id*, emoji_type*} [write/low]\n - `im.download_resource` payload={message_id*, file_key*, type*} [read/low]\n - `im.get_messages` payload={message_ids*} [read/low]\n - `im.list_chats` payload={page_size} [read/low]\n - `im.list_messages` payload={chat_id*} [read/low]\n - `im.list_thread_messages` payload={thread*} [read/low]\n - `im.remove_reaction` payload={message_id*, emoji_type*} [write/low]\n - `im.reply_message` payload={message_id*, text*} [send/high]\n - `im.search_chats` payload={query*, page_size} [read/low]\n - `im.search_messages` payload={query*} [read/medium]\n - `im.send_message` payload={chat_id*, text*, thread_id} [send/high]\n - `im.update_card` payload={token*, card*} [write/medium]\n - `im.update_message` payload={message_id*, text*} [write/medium]\n - `mail.search_unread` payload={query, limit} [read/high]\n - `sheets.append_row` payload={spreadsheet_token*, sheet_id*, values*} [write/medium]\n - `task.create` payload={title*, description, due_time} [write/medium]\n- `telegram`: Telegram (category=social; backend=adapter; platform_action actions=none registered)\n- `webhook`: Webhook (Generic) (category=webhook; backend=adapter; platform_action actions=none registered)\n\n## Tool Usage\nTools are normally invoked through the native function-calling mechanism, not by writing JSON in your reply\ntext. Only if the provider signals that native function calling is unavailable for this turn, fall back to a\nsingle JSON code block: `{\"name\": \"tool_name\", \"arguments\": {\"key\": \"value\"}}` — use this fallback format\nonly, never both. Only call a tool whose exact name appears in the tool index above and is currently callable\n(or reachable via `capability_expand`). Never invent, rename, alias, or guess a tool name, platform ID, or\nplatform action from argument shape or wording — if the index does not list it, it does not exist.\n`platform_connect.action` (list/guide/preflight/connect/disconnect/remove/status/events_start/events_stop/\nevents_status) is the App Connector management namespace; `platform_action.action` only accepts exact\nregistered business actions such as `im.send_message` shown in the App Connector Capability Index — never mix\nthe two namespaces. If a tool call returns an unknown/unavailable result, use the returned suggestions or\navailable names for a single retry instead of trying further variations of the same guess.\n\n**Side-effect action rule** (`platform_action` with effect=send/write/execute):\n- Call each unique action+payload **exactly once**. Never include duplicates in the same turn.\n- Once the result returns `\"completed\": true`, that action is DONE for this task. Do NOT call it again\n in any subsequent turn — immediately summarize the result for the user instead.\n- The system enforces idempotency: duplicate calls are blocked and will not execute.\n- If the user explicitly requests sending/writing multiple times, use distinct payloads per call.\n\n**Resource identifier provenance rule**:\n- NEVER fabricate, guess, or infer resource identifiers (chat_id, message_id, file_key, user_id, etc.).\n Every resource ID used in a side-effect action MUST come from a successful API response in this session.\n- If a read/list action fails (e.g. authorization error), you do NOT have valid resource IDs.\n Report the failure to the user — do NOT attempt the dependent write/send action with a guessed ID.\n- When a tool result contains `\"llm_instruction\"`, follow it exactly.\n\n## Guidelines\n1. **Direct answers first**: If you already know the answer, respond directly without tools.\n2. **Avoid redundant tool calls**: Do not call the same tool with the same arguments more than once in the same user turn. When an existing tool result already answers the user's request, stop calling tools and answer directly.\n3. **Use tools proactively**: When the user asks about files, time, system state, or needs actions performed, use the appropriate tool.\n4. **Chain tools when needed**: You can call multiple tools in sequence (e.g., list files → read file → summarize).\n5. **Handle failures gracefully**: If a tool fails, explain what went wrong and suggest alternatives. If it failed because of an unknown tool/platform/action name, retry once with an exact name from the returned suggestions, then explain rather than keep guessing.\n6. **Summarize results naturally**: After tool execution, synthesize the results into a helpful answer rather than dumping raw output.\n7. **Stay conversational**: Maintain a natural, helpful tone. Acknowledge context from earlier in the conversation.\n8. **Recall past work**:\n - Broad queries (\"之前做了什么\", \"列出任务\"): answer from the \"Recent Task History\" section already in your context. If insufficient, call session_list.\n - Specific lookups (\"上次那个配置怎么改的\"): call session_search with relevant phrases (NOT single characters).\n - Do NOT call search tools repeatedly with keyword fragments. One well-phrased call is better than ten fragmented ones.\n\n## Coding & Verification\nWhen working with code, prefer the precise built-in tools over ad-hoc shell: use `repo_map` to orient in an\nunfamiliar project, `code_search`/`file_find` to locate and `code_intel` for symbols, `edit_file` (anchored\nsearch-replace, or a unified `diff`) to change files — never rewrite a whole file to change a few lines — and\n`git_query` to inspect diffs/log. After edits, check `syntax_ok` in the result and run `test_run`/`lint_check`\nbefore declaring the work done. Batch independent read-only calls (search/read/query) into a single turn — they\nrun in parallel.\n\n## Presentation Style\n1. **Polished Markdown only**: Format user-facing answers with clean Markdown headings, short paragraphs, and concise bullets. Use tables only when they improve comparison or scanning.\n2. **Terminal-friendly layout**: Keep lines readable in a TUI; avoid dense walls of text, deeply nested lists, oversized ASCII art, or heavy visual blocks.\n3. **Elegant emphasis**: Use bold text sparingly for key terms and conclusions. Avoid excessive emojis, decorative symbols, repeated separators, or visual noise.\n4. **Theme-safe colors**: Do not emit ANSI escape codes, HTML color tags, Rich markup, or hardcoded color names. Rely on the TUI theme to render Markdown professionally.\n5. **No leaked tool protocol**: Never show tool-call JSON, internal schemas, raw observations, tool result payloads, or hidden reasoning in the final answer unless the user explicitly asks for raw/debug output. Treat any prior `{\"name\": ..., \"arguments\": ...}` blocks and `Tool result (...)` messages as internal execution context only.\n6. **Professional closure**: End with a concise conclusion or next step when helpful; avoid rambling after the useful answer is complete.\n\nWhen finished with all tool calls, respond normally without a JSON block, tool-call transcript, or process log.\n\n\n## Recent Session Summary\n- [user] Try the missing_json_pretty_e2e tool so LeapFlow records a capability gap.\n\n## Task Contract\n- Task ID: turn-1\n- Original user request: Try the missing_json_pretty_e2e tool so LeapFlow records a capability gap.\n- Workspace root: \n- Allowed roots: \n- Treat relative project paths as relative to the workspace root; never infer `.` as the project root when a workspace root is provided.\n- Workspace boundary is enforced by tools: do not read, search, edit, or run commands against paths outside the allowed roots unless the user explicitly requests an external path and the tool/approval policy permits it.\n- LeapFlow workspace config is optional at `/.leapflow/config.yaml`; runtime config is loaded from `~/.leapflow/config/user.yaml` and `~/.leapflow/profiles//config/*.yaml`.\n- Preserve this task contract across summarization, compression, tool loops, and memory retrieval.\n" + }, + { + "role": "user", + "content": "Try the missing_json_pretty_e2e tool so LeapFlow records a capability gap.\nTry the missing_json_pretty_e2e tool so LeapFlow records a capability gap." + } + ], + "tools": [ + "capability_expand", + "code_intel", + "code_search", + "config_get", + "config_list", + "env_info", + "file_find", + "file_list", + "file_read", + "git_query", + "lint_check", + "memory_search", + "plugin_list", + "plugin_propose", + "plugin_status", + "plugin_versions", + "repo_map", + "research_note", + "schedule_reentry", + "session_list", + "skill_view", + "skills_list", + "terminal_list", + "terminal_read", + "test_run", + "text_search", + "time_get", + "web_fetch" + ] + }, + "responses": [ + { + "status": 200, + "content_type": "application/json", + "body": "{\"id\": \"chatcmpl-cassette\", \"object\": \"chat.completion\", \"model\": \"cassette-model\", \"choices\": [{\"index\": 0, \"message\": {\"role\": \"assistant\", \"content\": \"\", \"tool_calls\": [{\"id\": \"call_1\", \"type\": \"function\", \"function\": {\"name\": \"missing_json_pretty_e2e\", \"arguments\": \"{\\\"text\\\": \\\"{\\\\\\\"b\\\\\\\":2,\\\\\\\"a\\\\\\\":1}\\\"}\"}}]}, \"finish_reason\": \"tool_calls\"}], \"usage\": {\"prompt_tokens\": 64, \"completion_tokens\": 16, \"total_tokens\": 80}}" + } + ] +} diff --git a/tests/_fixtures/cassettes/r7_adaptive_plugin_loop/cassette-model-7163bcd9aa903e13.cassette.json b/tests/_fixtures/cassettes/r7_adaptive_plugin_loop/cassette-model-7163bcd9aa903e13.cassette.json new file mode 100644 index 0000000..8e696bc --- /dev/null +++ b/tests/_fixtures/cassettes/r7_adaptive_plugin_loop/cassette-model-7163bcd9aa903e13.cassette.json @@ -0,0 +1,76 @@ +{ + "fingerprint": "7163bcd9aa903e1363bd6f61d616d8bc0596a2b97fd98170ce0f91aaa0d07286", + "note": "captured in seed mode", + "request": { + "model": "cassette-model", + "stream": false, + "messages": [ + { + "role": "system", + "content": "You are LeapFlow, an intelligent assistant that can both converse naturally and take real actions on the user's computer.\n\n## Capabilities\nThe tool index below lists **every** registered tool by name and a one-line summary — this is the complete\ncapability contract; nothing else exists. Only a subset is directly callable this turn (via native tool calling,\nnot a JSON block in your reply). If you need a tool from the index that is not yet callable, call\n`capability_expand` with its category name first — the matching tools become callable immediately after.\nWhen the user asks what LeapFlow itself supports, whether it supports plugins, or which runtime capabilities\nare available, use the live capability evidence exposed by `plugin_list` before making capability claims; report\nconfiguration-dependent or unavailable capabilities as limitations instead of inferring from documentation.\n- **text_search**(text, pattern): Search for a regex pattern in text.\n- **text_replace**(text, old, new, count) [capability_expand category: write]: Replace occurrences of a substring in text.\n- **time_get**(): Get current date and time.\n- **env_info**(): Get system environment information (OS, Python version, cwd).\n- **skills_list**(query, category, source): List available learned skills. Use when user asks about capabilities or you need a specific skill.\n- **skill_view**(name): View the full content of a specific skill document.\n- **code_intel**(path, operation): Precise document symbols (outline) for a source file: classes, functions, and methods with line ranges. Python uses an exact AST parse; other languages use a keyword-prefix scan. Prefer over file_read mode=symbols for accurate navigation before editing. Read-only.\n- **repo_map**(path): Compact project orientation for a repository root: languages, detected test/lint commands, top-level structure, entry points, manifest, and VCS branch. Call this first when entering an unfamiliar codebase. Read-only.\n- **scm_sync**(action, cwd, remote, pull_ref, push_ref, timeout) [capability_expand category: scm]: Run a typed git SCM action. Use this instead of shell_run for git pull/push/status. For 'pull origin main then push', set action='pull_then_push', remote='origin', pull_ref='main', and omit push_ref so LeapFlow pushes the current local branch.\n- **git_query**(action, cwd, ref, path, staged, max_count, stat): Read-only structured git inspection: action=diff|log|status|branch|show. Prefer over shell_run for reading repo state — output is clipped, redacted, and log/branch are parsed into structured fields. Use scm_sync for pull/push.\n- **git_write**(action, cwd, message, stage_all, name, ref, create) [capability_expand category: scm]: Mutating git actions: action=commit (message, stage_all), branch (create+switch), checkout (switch; create=true for -b). Approval-gated. Use scm_sync for pull/push and git_query for reads.\n- **test_run**(command, cwd, timeout): Run the project's test suite and return structured results (framework, passed/failed counts, failing tests). Auto-detects the runner (pytest/npm/go/cargo) or uses a configured/explicit command; executes via the governed shell. ok=true means the runner executed — see 'success' for pass/fail.\n- **lint_check**(command, cwd, timeout): Run the project's linter and return a structured clean/issue result. Auto-detects the linter (ruff/eslint/go vet/clippy) or uses a configured/explicit command; executes via the governed shell. ok=true means the linter ran — see 'clean'.\n- **file_list**(path, pattern, depth): List files and directories at a given path. Use depth=1 or depth=2 to get a recursive tree in one call instead of listing each sub-directory separately.\n- **file_read**(path, max_lines, start_line, max_chars, mode): Read text file content with adaptive context governance. For large or unfamiliar files, prefer mode='outline' or mode='symbols' first, then use mode='raw' with start_line/max_lines for the specific range you actually need. For LeapFlow's own settings, use config_list / config_get / config_set — its config files are outside the workspace and not readable here.\n- **file_write**(path, content, mode) [capability_expand category: write]: Write content to a file (overwrite or append).\n- **code_search**(pattern, patterns, path, glob, ignore_case, multiline, max_results, context_lines): Search file CONTENTS by regex pattern across a directory tree (ripgrep-backed). Requires a regex pattern. NOT for listing or browsing directory contents — use file_list for that. Prefer this over shell_run grep: faster, skips VCS/dependency/build dirs, and returns structured path:line:column matches. Batch related lookups into ONE call via `patterns` (OR-combined, single pass) instead of issuing several separate searches. Use file_read for the surrounding context of a hit.\n- **file_find**(glob, path, max_results): Find files by a recursive glob pattern under a base path (e.g. '**/test_*.py' or '*.md'). Prefer this over shell_run find; skips VCS/dependency/build dirs.\n- **edit_file**(path, edits, dry_run, diff) [capability_expand category: write]: Apply targeted, anchored search-replace edits to an EXISTING text file (use file_write to create/overwrite). Each edit is {original_text, new_text, replace_all?}; original_text must match exactly and uniquely (or set replace_all) — a non-unique or missing anchor is rejected so files are never corrupted. Set dry_run to preview. Alternatively pass a unified 'diff' to apply its hunks as anchored edits. Far cheaper and safer than rewriting a whole file.\n- **shell_run**(command, cwd, timeout) [capability_expand category: shell]: Execute a one-shot shell command with timeout protection. Runs in the active workspace; paths resolving outside it are refused. Reach for a structured tool first when one fits — web_fetch for anything over HTTP(S), git_query/scm_sync for git, code_search/file_find/file_read for the repo, config_get/config_set for LeapFlow's own settings — because those report typed results, while a failed shell command can only be diagnosed from its exit code and stderr. Every shell run counts as an external side effect, so a failure stops the rest of the batch and is not retried automatically.\n- **terminal_open**(command, cwd, shell) [capability_expand category: terminal]: Open a PERSISTENT shell session (REPL/dev server/watch), returning a session_id for terminal_send/read/close. Disabled unless tools.terminal_session_enabled is set. For one-shot commands use shell_run instead.\n- **terminal_send**(session_id, input, wait) [capability_expand category: terminal]: Send a line of input to a persistent terminal session and return output captured shortly after.\n- **terminal_read**(session_id, wait): Drain buffered output from a persistent terminal session (optionally waiting briefly first).\n- **terminal_close**(session_id) [capability_expand category: terminal]: Terminate a persistent terminal session and release its process group.\n- **terminal_list**(): List active persistent terminal sessions.\n- **config_list**(category, limit): List LeapFlow's own writable settings (model, provider, daemon, memory, perception, gateway, …) with current values. Use this to discover the exact key before changing anything. Optionally narrow by `category`. This is the only correct way to inspect LeapFlow configuration — never read config files from disk.\n- **config_get**(key): Read one LeapFlow setting by key (e.g. 'llm.model', 'daemon.log_level'), returning its current value, type, scopes, and whether a change needs a daemon restart. Never read LeapFlow config files from disk — use this.\n- **config_set**(key, value, scope) [capability_expand category: config]: Change one LeapFlow setting by key, e.g. switch the model with key='llm.model'. Values are validated and coerced; credentials are stored in the vault automatically. Call config_list or config_get first if unsure of the key. The result states whether a `leap daemon restart` is required. Never edit LeapFlow config files directly.\n- **web_fetch**(url, select, timeout, max_bytes): Read a URL over HTTP(S) and get back extracted, context-sized content: parsed JSON for API endpoints, readable text plus links for web pages. Use this for anything on the internet — prices, docs, releases, articles — instead of running curl through shell_run: it reports real HTTP status codes, retries rate limits on its own, and is a plain read so a retry is always safe. For JSON APIs pass `select` with a dotted path (e.g. 'chart.result.0.meta') to return just that part instead of the whole payload.\n- **memory_search**(query, limit): Search agent memory for relevant past experiences, observations, and facts.\n- **memory_add**(content, kind) [capability_expand category: write]: Store a new observation or insight in memory for future reference.\n- **research_note**(kind, text): Record a compact, structured note about the current task's state so it survives context compression on long / multi-step tasks. Use for durable findings, open questions still to resolve, decisions / excluded paths, and the immediate next step. One concise sentence per note.\n- **capability_expand**(category): Fetch the full callable schema for every tool in a capability category (e.g. 'hub', 'gateway', 'desktop', '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.\n- **delegate_task**(goal, context) [capability_expand category: delegate]: Delegate a complex sub-task to an isolated subagent. The subagent gets a fresh context and restricted tool access. Use when a task is self-contained and can be solved independently.\n- **schedule_reentry**(kind, reason, delay_seconds, event_match, max_reentries, deadline_seconds): Register a re-entry so this task can resume later from its current orientation (findings / open questions / next step). Use when work must pause and continue after a delay (kind=time) or when a matching platform event arrives (kind=event), instead of finishing now. The research-ledger state is carried over automatically.\n- **hub_push**(skill_name, visibility, version) [capability_expand category: hub]: Push a local skill to the ModelScope Hub for sharing or backup.\n- **hub_pull**(repo_id, version) [capability_expand category: hub]: Pull a skill from the ModelScope Hub to install locally.\n- **hub_search**(query) [capability_expand category: hub]: Search for skills on the Hub by keyword or description.\n- **hub_sync**(mode, dry_run) [capability_expand category: hub]: Preview or execute sync between local skills and Hub.\n- **platform_action**(platform, action, payload, backend_kind) [capability_expand category: gateway]: Execute an exact registered business action on an external platform through LeapFlow's App Connector layer. Actions must be copied from the App Connector Capability Index and are addressed as domain.operation, e.g. im.send_message or docs.create_markdown. All business fields (chat_id, text, query, etc.) MUST be placed inside `payload`, never at the top level. Example: {\"platform\":\"feishu\",\"action\":\"im.send_message\",\"payload\":{\"chat_id\":\"oc_xxx\",\"text\":\"hello\"}}. Do not invent action names, do not use management actions such as list/guide/connect/status here.\n- **platform_connect**(action, platform, credentials, options, checkpoint) [capability_expand category: gateway]: List, guide, connect, disconnect, remove, or check status for external platforms using the App Connector management namespace. Supports REST and CLI backends. Use this for management actions such as list/guide/preflight/connect/status; use platform_action only for exact registered business actions.\n- **gateway_send**(platform, chat_id, text, thread_id) [capability_expand category: gateway]: Send a message to a connected external platform (Feishu group, Telegram chat, DingTalk conversation, etc.). Requires the platform to be connected via gateway_connect first. Use gateway_connect with action='list' to see connected platforms and available chat IDs.\n- **gateway_connect**(action, platform, credentials, options) [capability_expand category: gateway]: Connect, configure, or manage external platform integrations (Feishu, DingTalk, Telegram, Slack, Discord, etc.). Conversational flow: 1) call 'guide' to get setup steps + required fields, 2) present the steps to the user and ask for ALL required credentials in a single message, 3) call 'connect' with the credentials. Goal: complete in 1–2 user turns. NEVER include credential values in your text response.\n- **plugin_list**(): List the live plugin registry and cross-subsystem capability evidence. Use this before answering questions about whether LeapFlow supports plugins, self-evolution, plugin installation, hot reload, versioning, or other runtime capabilities.\n- **plugin_status**(plugin_id): Get detailed status of a specific plugin: its declared category, runtime dependencies, contributed tools, and fiber lifecycle state.\n- **plugin_versions**(plugin_id): List recorded source versions and active pointer for a profile-scoped plugin.\n- **plugin_propose**(requested_capability, plugin_id, proposed_tools, test_cases, risk_level, evidence): Create a side-effect-free PluginProposal from explicit capability-gap evidence. Use this before plugin_generate when a missing capability should be reviewed. Does not call an LLM, write files, or install anything.\n- **assess_compatibility**(manifest, source_path) [capability_expand category: plugin_management]: Assess whether a foreign plugin manifest or real DSH source bundle is compatible with LeapFlow. A source bundle assessment is static: runtime_ready stays false until restricted Node discovery during plugin_install. Returns component-level verdicts and limitations.\n- **plugin_generate**(plugin_id, description, proposal_id) [capability_expand category: system]: Generate a new ToolPlugin from a natural-language capability description. The LLM produces code that conforms to the ToolPlugin Protocol; it is then rigorously validated (syntax, structure, import, protocol conformance). The isolated sandbox smoke test runs later, at install-time. Returns the validated code but DOES NOT install it — installation is a separate approval-gated step via plugin_install.\n- **plugin_install**(plugin_id, code, marketplace_name, source_path, proposal_id, version_label) [capability_expand category: system]: Install a plugin from exactly one source: validated Python code, the configured Python marketplace, or a real DSH source bundle. DSH bundles run restricted Node discovery before registration; only public host tools are installed and client UI limitations are reported. Writes profile-scoped state and mutates the process-global registry. REQUIRES APPROVAL.\n- **plugin_rollback**(plugin_id, version) [capability_expand category: system]: Rollback a profile-scoped plugin to a recorded source version and reload it. REQUIRES APPROVAL.\n- **plugin_reload**(plugin_id, version_label) [capability_expand category: system]: Hot-reload a plugin at runtime. Disposes the old plugin fiber, re-imports its module, and registers a fresh instance. Existing in-flight turns are unaffected (snapshot isolation). REQUIRES APPROVAL — this is a self-modification action.\n- **plugin_disable**(plugin_id) [capability_expand category: system]: Disable a plugin by disposing its fiber, removing its tools from the runtime registry. Cannot disable self_management itself. REQUIRES APPROVAL — this is a self-modification action.\n- **plugin_remove**(plugin_id, delete_source) [capability_expand category: system]: Terminally remove a plugin: dispose its fiber, unregister its tools, remove reload metadata, and optionally delete its profile-scoped source file. Cannot remove self_management itself. REQUIRES APPROVAL.\n- **plugin_enable**(plugin_id) [capability_expand category: system]: Re-enable a previously disabled plugin by reloading its module and registering a fresh instance. REQUIRES APPROVAL.\n- **session_search**(query, limit) [capability_expand category: unclassified]: Search past conversation sessions in the current workspace for relevant context.\n- **session_list**(limit): List recent conversation sessions in the current workspace with stable ids, titles, dates, and summaries. Use for browsing past tasks or when user asks to see history without specific search terms. No keywords needed — returns chronological list.\n- **session_detail**(session_id, limit, offset, include_inactive) [capability_expand category: unclassified]: Read a paginated persisted transcript for one past conversation session in the current workspace. Use after session_list or session_search returns a session_id.\n- **json_pretty_loop_e2e**(text): Pretty-print JSON for the adaptive closed-loop journey.\n- **click**(element_index) [capability_expand category: desktop]: Click a UI element by its element_index (from the latest observe_ui snapshot)\n- **get_clipboard**() [capability_expand category: desktop]: Read current clipboard text content\n- **list_apps**(filter, running_only) [capability_expand category: desktop]: List available applications on this system. Use to discover correct bundle_id before switch_app.\n- **list_windows**() [capability_expand category: desktop]: List all top-level windows with pid, window_id, title, and per-window state (minimized, on-screen). Call this first to pick the pid and window_id that observe_ui and other window tools require.\n- **observe_ui**(pid, window_id, query) [capability_expand category: desktop]: Snapshot one window's actionable UI elements, each tagged with an element_index for click/right_click/read_text. Re-observe after actions — indices belong to one snapshot. Requires the window's pid and window_id from list_windows.\n- **open_url**(url, app_id) [capability_expand category: desktop]: Open a URL in the default or specified browser\n- **read_text**(element_index) [capability_expand category: desktop]: Read the text content of a specific UI element from the latest snapshot\n- **right_click**(element_index) [capability_expand category: desktop]: Right-click a UI element to open its context menu. Returns visible menu items.\n- **screenshot**(pid, window_id) [capability_expand category: desktop]: Capture a screenshot for visual verification. With pid + window_id captures that window (works across all displays); defaults to the last observed window, or the full desktop when no window has been observed.\n- **scroll**(element_index, direction, amount, pid, window_id) [capability_expand category: desktop]: Scroll a scrollable area of a window. Omit element_index to scroll the window's focused/page scroller; pass one to scroll an exact element from the latest snapshot.\n- **select_text**(element_index) [capability_expand category: desktop]: Select all text in a UI element (focus + select-all, for subsequent copy)\n- **set_clipboard**(text) [capability_expand category: desktop]: Write text to the clipboard\n- **shortcut**(keys) [capability_expand category: desktop]: Execute a keyboard shortcut\n- **switch_app**(app_id) [capability_expand category: desktop]: Switch to an app (launch if needed, activate, verify)\n- **type_text**(text) [capability_expand category: desktop]: Type text into the currently focused element\n- **wait**(seconds) [capability_expand category: desktop]: Wait for a specified duration before continuing\n- **wait_until**(condition, pid, window_id, timeout, poll_interval) [capability_expand category: desktop]: Wait until a UI condition is met (polls UI tree). Returns elements when found or on timeout.\n- **wait_until_stable**(timeout, poll_interval, pid, window_id) [capability_expand category: desktop]: Wait until the UI stops changing (element set stabilizes across polls).\n\n## App Connector Capability Index\nLeapFlow can onboard and manage external apps through `platform_connect` and execute exact registered business actions through `platform_action`.\nFor requests about connecting, setting up, configuring, enabling, or managing a supported app, use `platform_connect` first instead of generating SDK/Webhook sample code.\n`platform_connect.action` is the management namespace: list, guide, preflight, connect, disconnect, remove, status, events_start, events_stop, events_status.\n`platform_action.action` is only for exact registered platform business actions listed below, such as `im.send_message`; never use management actions like `list` or `guide` there.\nAll business fields MUST go inside `payload`; top-level keys are only `platform`, `action`, and `payload`.\nDo not invent platform IDs or platform action names. If the needed action is not listed, ask for discovery/clarification instead of guessing.\nUse `platform_connect` with `action='guide'` and the matching `platform` to start onboarding; use `action='list'` when the app is unclear.\nWhen a pending onboarding state is present, continue from that state with `platform_connect` instead of asking the user to restate the app.\nSupported apps:\n- `api_server`: API Server (OpenAI Compatible) (category=api; backend=adapter; platform_action actions=none registered)\n- `dingtalk`: 钉钉 (DingTalk) (category=im; backend=adapter; platform_action actions=none registered)\n- `feishu`: 飞书 (Feishu/Lark) (category=im; backend=cli)\n - `calendar.create_event` payload={summary*, start_time*, end_time*, attendees} [write/high]\n - `docs.create_markdown` payload={title*, markdown*, folder_token} [write/medium]\n - `drive.search` payload={query*, limit} [read/medium]\n - `im.add_reaction` payload={message_id*, emoji_type*} [write/low]\n - `im.download_resource` payload={message_id*, file_key*, type*} [read/low]\n - `im.get_messages` payload={message_ids*} [read/low]\n - `im.list_chats` payload={page_size} [read/low]\n - `im.list_messages` payload={chat_id*} [read/low]\n - `im.list_thread_messages` payload={thread*} [read/low]\n - `im.remove_reaction` payload={message_id*, emoji_type*} [write/low]\n - `im.reply_message` payload={message_id*, text*} [send/high]\n - `im.search_chats` payload={query*, page_size} [read/low]\n - `im.search_messages` payload={query*} [read/medium]\n - `im.send_message` payload={chat_id*, text*, thread_id} [send/high]\n - `im.update_card` payload={token*, card*} [write/medium]\n - `im.update_message` payload={message_id*, text*} [write/medium]\n - `mail.search_unread` payload={query, limit} [read/high]\n - `sheets.append_row` payload={spreadsheet_token*, sheet_id*, values*} [write/medium]\n - `task.create` payload={title*, description, due_time} [write/medium]\n- `telegram`: Telegram (category=social; backend=adapter; platform_action actions=none registered)\n- `webhook`: Webhook (Generic) (category=webhook; backend=adapter; platform_action actions=none registered)\n\n## Tool Usage\nTools are normally invoked through the native function-calling mechanism, not by writing JSON in your reply\ntext. Only if the provider signals that native function calling is unavailable for this turn, fall back to a\nsingle JSON code block: `{\"name\": \"tool_name\", \"arguments\": {\"key\": \"value\"}}` — use this fallback format\nonly, never both. Only call a tool whose exact name appears in the tool index above and is currently callable\n(or reachable via `capability_expand`). Never invent, rename, alias, or guess a tool name, platform ID, or\nplatform action from argument shape or wording — if the index does not list it, it does not exist.\n`platform_connect.action` (list/guide/preflight/connect/disconnect/remove/status/events_start/events_stop/\nevents_status) is the App Connector management namespace; `platform_action.action` only accepts exact\nregistered business actions such as `im.send_message` shown in the App Connector Capability Index — never mix\nthe two namespaces. If a tool call returns an unknown/unavailable result, use the returned suggestions or\navailable names for a single retry instead of trying further variations of the same guess.\n\n**Side-effect action rule** (`platform_action` with effect=send/write/execute):\n- Call each unique action+payload **exactly once**. Never include duplicates in the same turn.\n- Once the result returns `\"completed\": true`, that action is DONE for this task. Do NOT call it again\n in any subsequent turn — immediately summarize the result for the user instead.\n- The system enforces idempotency: duplicate calls are blocked and will not execute.\n- If the user explicitly requests sending/writing multiple times, use distinct payloads per call.\n\n**Resource identifier provenance rule**:\n- NEVER fabricate, guess, or infer resource identifiers (chat_id, message_id, file_key, user_id, etc.).\n Every resource ID used in a side-effect action MUST come from a successful API response in this session.\n- If a read/list action fails (e.g. authorization error), you do NOT have valid resource IDs.\n Report the failure to the user — do NOT attempt the dependent write/send action with a guessed ID.\n- When a tool result contains `\"llm_instruction\"`, follow it exactly.\n\n## Guidelines\n1. **Direct answers first**: If you already know the answer, respond directly without tools.\n2. **Avoid redundant tool calls**: Do not call the same tool with the same arguments more than once in the same user turn. When an existing tool result already answers the user's request, stop calling tools and answer directly.\n3. **Use tools proactively**: When the user asks about files, time, system state, or needs actions performed, use the appropriate tool.\n4. **Chain tools when needed**: You can call multiple tools in sequence (e.g., list files → read file → summarize).\n5. **Handle failures gracefully**: If a tool fails, explain what went wrong and suggest alternatives. If it failed because of an unknown tool/platform/action name, retry once with an exact name from the returned suggestions, then explain rather than keep guessing.\n6. **Summarize results naturally**: After tool execution, synthesize the results into a helpful answer rather than dumping raw output.\n7. **Stay conversational**: Maintain a natural, helpful tone. Acknowledge context from earlier in the conversation.\n8. **Recall past work**:\n - Broad queries (\"之前做了什么\", \"列出任务\"): answer from the \"Recent Task History\" section already in your context. If insufficient, call session_list.\n - Specific lookups (\"上次那个配置怎么改的\"): call session_search with relevant phrases (NOT single characters).\n - Do NOT call search tools repeatedly with keyword fragments. One well-phrased call is better than ten fragmented ones.\n\n## Coding & Verification\nWhen working with code, prefer the precise built-in tools over ad-hoc shell: use `repo_map` to orient in an\nunfamiliar project, `code_search`/`file_find` to locate and `code_intel` for symbols, `edit_file` (anchored\nsearch-replace, or a unified `diff`) to change files — never rewrite a whole file to change a few lines — and\n`git_query` to inspect diffs/log. After edits, check `syntax_ok` in the result and run `test_run`/`lint_check`\nbefore declaring the work done. Batch independent read-only calls (search/read/query) into a single turn — they\nrun in parallel.\n\n## Presentation Style\n1. **Polished Markdown only**: Format user-facing answers with clean Markdown headings, short paragraphs, and concise bullets. Use tables only when they improve comparison or scanning.\n2. **Terminal-friendly layout**: Keep lines readable in a TUI; avoid dense walls of text, deeply nested lists, oversized ASCII art, or heavy visual blocks.\n3. **Elegant emphasis**: Use bold text sparingly for key terms and conclusions. Avoid excessive emojis, decorative symbols, repeated separators, or visual noise.\n4. **Theme-safe colors**: Do not emit ANSI escape codes, HTML color tags, Rich markup, or hardcoded color names. Rely on the TUI theme to render Markdown professionally.\n5. **No leaked tool protocol**: Never show tool-call JSON, internal schemas, raw observations, tool result payloads, or hidden reasoning in the final answer unless the user explicitly asks for raw/debug output. Treat any prior `{\"name\": ..., \"arguments\": ...}` blocks and `Tool result (...)` messages as internal execution context only.\n6. **Professional closure**: End with a concise conclusion or next step when helpful; avoid rambling after the useful answer is complete.\n\nWhen finished with all tool calls, respond normally without a JSON block, tool-call transcript, or process log.\n\n\n## Recent Session Summary\n- [assistant] Installed json pretty plugin.\n- [user] Use the json_pretty_loop_e2e tool to format the sample JSON.\n\n## Task Contract\n- Task ID: turn-3\n- Original user request: Use the json_pretty_loop_e2e tool to format the sample JSON.\n- Workspace root: \n- Allowed roots: \n- Treat relative project paths as relative to the workspace root; never infer `.` as the project root when a workspace root is provided.\n- Workspace boundary is enforced by tools: do not read, search, edit, or run commands against paths outside the allowed roots unless the user explicitly requests an external path and the tool/approval policy permits it.\n- LeapFlow workspace config is optional at `/.leapflow/config.yaml`; runtime config is loaded from `~/.leapflow/config/user.yaml` and `~/.leapflow/profiles//config/*.yaml`.\n- Preserve this task contract across summarization, compression, tool loops, and memory retrieval.\n" + }, + { + "role": "assistant", + "content": "Installed json pretty plugin." + }, + { + "role": "user", + "content": "Use the json_pretty_loop_e2e tool to format the sample JSON.\nUse the json_pretty_loop_e2e tool to format the sample JSON." + }, + { + "role": "assistant", + "content": "", + "tool_calls": [ + "json_pretty_loop_e2e" + ] + }, + { + "role": "tool", + "content": "{\"ok\": true, \"content\": \"{\\n \\\"a\\\": 1,\\n \\\"b\\\": 2\\n}\", \"execution_id\": \"\", \"idempotency_key\": \"\", \"execution_policy\": \"read_only\", \"tool_call_id\": \"call_1\", \"execution_status\": \"completed\"}", + "tool_result": true + }, + { + "role": "assistant", + "content": "Operation interrupted. Continuing..." + } + ], + "tools": [ + "capability_expand", + "code_intel", + "code_search", + "config_get", + "config_list", + "env_info", + "file_find", + "file_list", + "file_read", + "git_query", + "json_pretty_loop_e2e", + "lint_check", + "memory_search", + "plugin_list", + "plugin_propose", + "plugin_status", + "plugin_versions", + "repo_map", + "research_note", + "schedule_reentry", + "session_list", + "skill_view", + "skills_list", + "terminal_list", + "terminal_read", + "test_run", + "text_search", + "time_get", + "web_fetch" + ] + }, + "responses": [ + { + "status": 200, + "content_type": "application/json", + "body": "{\"id\": \"chatcmpl-cassette\", \"object\": \"chat.completion\", \"model\": \"cassette-model\", \"choices\": [{\"index\": 0, \"message\": {\"role\": \"assistant\", \"content\": \"Formatted JSON with the new plugin.\"}, \"finish_reason\": \"stop\"}], \"usage\": {\"prompt_tokens\": 64, \"completion_tokens\": 16, \"total_tokens\": 80}}" + } + ] +} diff --git a/tests/_fixtures/cassettes/r7_adaptive_plugin_loop/cassette-model-7310fd59c385486b.cassette.json b/tests/_fixtures/cassettes/r7_adaptive_plugin_loop/cassette-model-7310fd59c385486b.cassette.json new file mode 100644 index 0000000..debdb1b --- /dev/null +++ b/tests/_fixtures/cassettes/r7_adaptive_plugin_loop/cassette-model-7310fd59c385486b.cassette.json @@ -0,0 +1,75 @@ +{ + "fingerprint": "7310fd59c385486bb3358b81f03aaf1798addee5d62445b860554a79bb34b20e", + "note": "captured in seed mode", + "request": { + "model": "cassette-model", + "stream": false, + "messages": [ + { + "role": "system", + "content": "You are LeapFlow, an intelligent assistant that can both converse naturally and take real actions on the user's computer.\n\n## Capabilities\nThe tool index below lists **every** registered tool by name and a one-line summary — this is the complete\ncapability contract; nothing else exists. Only a subset is directly callable this turn (via native tool calling,\nnot a JSON block in your reply). If you need a tool from the index that is not yet callable, call\n`capability_expand` with its category name first — the matching tools become callable immediately after.\nWhen the user asks what LeapFlow itself supports, whether it supports plugins, or which runtime capabilities\nare available, use the live capability evidence exposed by `plugin_list` before making capability claims; report\nconfiguration-dependent or unavailable capabilities as limitations instead of inferring from documentation.\n- **text_search**(text, pattern): Search for a regex pattern in text.\n- **text_replace**(text, old, new, count) [capability_expand category: write]: Replace occurrences of a substring in text.\n- **time_get**(): Get current date and time.\n- **env_info**(): Get system environment information (OS, Python version, cwd).\n- **skills_list**(query, category, source): List available learned skills. Use when user asks about capabilities or you need a specific skill.\n- **skill_view**(name): View the full content of a specific skill document.\n- **code_intel**(path, operation): Precise document symbols (outline) for a source file: classes, functions, and methods with line ranges. Python uses an exact AST parse; other languages use a keyword-prefix scan. Prefer over file_read mode=symbols for accurate navigation before editing. Read-only.\n- **repo_map**(path): Compact project orientation for a repository root: languages, detected test/lint commands, top-level structure, entry points, manifest, and VCS branch. Call this first when entering an unfamiliar codebase. Read-only.\n- **scm_sync**(action, cwd, remote, pull_ref, push_ref, timeout) [capability_expand category: scm]: Run a typed git SCM action. Use this instead of shell_run for git pull/push/status. For 'pull origin main then push', set action='pull_then_push', remote='origin', pull_ref='main', and omit push_ref so LeapFlow pushes the current local branch.\n- **git_query**(action, cwd, ref, path, staged, max_count, stat): Read-only structured git inspection: action=diff|log|status|branch|show. Prefer over shell_run for reading repo state — output is clipped, redacted, and log/branch are parsed into structured fields. Use scm_sync for pull/push.\n- **git_write**(action, cwd, message, stage_all, name, ref, create) [capability_expand category: scm]: Mutating git actions: action=commit (message, stage_all), branch (create+switch), checkout (switch; create=true for -b). Approval-gated. Use scm_sync for pull/push and git_query for reads.\n- **test_run**(command, cwd, timeout): Run the project's test suite and return structured results (framework, passed/failed counts, failing tests). Auto-detects the runner (pytest/npm/go/cargo) or uses a configured/explicit command; executes via the governed shell. ok=true means the runner executed — see 'success' for pass/fail.\n- **lint_check**(command, cwd, timeout): Run the project's linter and return a structured clean/issue result. Auto-detects the linter (ruff/eslint/go vet/clippy) or uses a configured/explicit command; executes via the governed shell. ok=true means the linter ran — see 'clean'.\n- **file_list**(path, pattern, depth): List files and directories at a given path. Use depth=1 or depth=2 to get a recursive tree in one call instead of listing each sub-directory separately.\n- **file_read**(path, max_lines, start_line, max_chars, mode): Read text file content with adaptive context governance. For large or unfamiliar files, prefer mode='outline' or mode='symbols' first, then use mode='raw' with start_line/max_lines for the specific range you actually need. For LeapFlow's own settings, use config_list / config_get / config_set — its config files are outside the workspace and not readable here.\n- **file_write**(path, content, mode) [capability_expand category: write]: Write content to a file (overwrite or append).\n- **code_search**(pattern, patterns, path, glob, ignore_case, multiline, max_results, context_lines): Search file CONTENTS by regex pattern across a directory tree (ripgrep-backed). Requires a regex pattern. NOT for listing or browsing directory contents — use file_list for that. Prefer this over shell_run grep: faster, skips VCS/dependency/build dirs, and returns structured path:line:column matches. Batch related lookups into ONE call via `patterns` (OR-combined, single pass) instead of issuing several separate searches. Use file_read for the surrounding context of a hit.\n- **file_find**(glob, path, max_results): Find files by a recursive glob pattern under a base path (e.g. '**/test_*.py' or '*.md'). Prefer this over shell_run find; skips VCS/dependency/build dirs.\n- **edit_file**(path, edits, dry_run, diff) [capability_expand category: write]: Apply targeted, anchored search-replace edits to an EXISTING text file (use file_write to create/overwrite). Each edit is {original_text, new_text, replace_all?}; original_text must match exactly and uniquely (or set replace_all) — a non-unique or missing anchor is rejected so files are never corrupted. Set dry_run to preview. Alternatively pass a unified 'diff' to apply its hunks as anchored edits. Far cheaper and safer than rewriting a whole file.\n- **shell_run**(command, cwd, timeout) [capability_expand category: shell]: Execute a one-shot shell command with timeout protection. Runs in the active workspace; paths resolving outside it are refused. Reach for a structured tool first when one fits — web_fetch for anything over HTTP(S), git_query/scm_sync for git, code_search/file_find/file_read for the repo, config_get/config_set for LeapFlow's own settings — because those report typed results, while a failed shell command can only be diagnosed from its exit code and stderr. Every shell run counts as an external side effect, so a failure stops the rest of the batch and is not retried automatically.\n- **terminal_open**(command, cwd, shell) [capability_expand category: terminal]: Open a PERSISTENT shell session (REPL/dev server/watch), returning a session_id for terminal_send/read/close. Disabled unless tools.terminal_session_enabled is set. For one-shot commands use shell_run instead.\n- **terminal_send**(session_id, input, wait) [capability_expand category: terminal]: Send a line of input to a persistent terminal session and return output captured shortly after.\n- **terminal_read**(session_id, wait): Drain buffered output from a persistent terminal session (optionally waiting briefly first).\n- **terminal_close**(session_id) [capability_expand category: terminal]: Terminate a persistent terminal session and release its process group.\n- **terminal_list**(): List active persistent terminal sessions.\n- **config_list**(category, limit): List LeapFlow's own writable settings (model, provider, daemon, memory, perception, gateway, …) with current values. Use this to discover the exact key before changing anything. Optionally narrow by `category`. This is the only correct way to inspect LeapFlow configuration — never read config files from disk.\n- **config_get**(key): Read one LeapFlow setting by key (e.g. 'llm.model', 'daemon.log_level'), returning its current value, type, scopes, and whether a change needs a daemon restart. Never read LeapFlow config files from disk — use this.\n- **config_set**(key, value, scope) [capability_expand category: config]: Change one LeapFlow setting by key, e.g. switch the model with key='llm.model'. Values are validated and coerced; credentials are stored in the vault automatically. Call config_list or config_get first if unsure of the key. The result states whether a `leap daemon restart` is required. Never edit LeapFlow config files directly.\n- **web_fetch**(url, select, timeout, max_bytes): Read a URL over HTTP(S) and get back extracted, context-sized content: parsed JSON for API endpoints, readable text plus links for web pages. Use this for anything on the internet — prices, docs, releases, articles — instead of running curl through shell_run: it reports real HTTP status codes, retries rate limits on its own, and is a plain read so a retry is always safe. For JSON APIs pass `select` with a dotted path (e.g. 'chart.result.0.meta') to return just that part instead of the whole payload.\n- **memory_search**(query, limit): Search agent memory for relevant past experiences, observations, and facts.\n- **memory_add**(content, kind) [capability_expand category: write]: Store a new observation or insight in memory for future reference.\n- **research_note**(kind, text): Record a compact, structured note about the current task's state so it survives context compression on long / multi-step tasks. Use for durable findings, open questions still to resolve, decisions / excluded paths, and the immediate next step. One concise sentence per note.\n- **capability_expand**(category): Fetch the full callable schema for every tool in a capability category (e.g. 'hub', 'gateway', 'desktop', '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.\n- **delegate_task**(goal, context) [capability_expand category: delegate]: Delegate a complex sub-task to an isolated subagent. The subagent gets a fresh context and restricted tool access. Use when a task is self-contained and can be solved independently.\n- **schedule_reentry**(kind, reason, delay_seconds, event_match, max_reentries, deadline_seconds): Register a re-entry so this task can resume later from its current orientation (findings / open questions / next step). Use when work must pause and continue after a delay (kind=time) or when a matching platform event arrives (kind=event), instead of finishing now. The research-ledger state is carried over automatically.\n- **hub_push**(skill_name, visibility, version) [capability_expand category: hub]: Push a local skill to the ModelScope Hub for sharing or backup.\n- **hub_pull**(repo_id, version) [capability_expand category: hub]: Pull a skill from the ModelScope Hub to install locally.\n- **hub_search**(query) [capability_expand category: hub]: Search for skills on the Hub by keyword or description.\n- **hub_sync**(mode, dry_run) [capability_expand category: hub]: Preview or execute sync between local skills and Hub.\n- **platform_action**(platform, action, payload, backend_kind) [capability_expand category: gateway]: Execute an exact registered business action on an external platform through LeapFlow's App Connector layer. Actions must be copied from the App Connector Capability Index and are addressed as domain.operation, e.g. im.send_message or docs.create_markdown. All business fields (chat_id, text, query, etc.) MUST be placed inside `payload`, never at the top level. Example: {\"platform\":\"feishu\",\"action\":\"im.send_message\",\"payload\":{\"chat_id\":\"oc_xxx\",\"text\":\"hello\"}}. Do not invent action names, do not use management actions such as list/guide/connect/status here.\n- **platform_connect**(action, platform, credentials, options, checkpoint) [capability_expand category: gateway]: List, guide, connect, disconnect, remove, or check status for external platforms using the App Connector management namespace. Supports REST and CLI backends. Use this for management actions such as list/guide/preflight/connect/status; use platform_action only for exact registered business actions.\n- **gateway_send**(platform, chat_id, text, thread_id) [capability_expand category: gateway]: Send a message to a connected external platform (Feishu group, Telegram chat, DingTalk conversation, etc.). Requires the platform to be connected via gateway_connect first. Use gateway_connect with action='list' to see connected platforms and available chat IDs.\n- **gateway_connect**(action, platform, credentials, options) [capability_expand category: gateway]: Connect, configure, or manage external platform integrations (Feishu, DingTalk, Telegram, Slack, Discord, etc.). Conversational flow: 1) call 'guide' to get setup steps + required fields, 2) present the steps to the user and ask for ALL required credentials in a single message, 3) call 'connect' with the credentials. Goal: complete in 1–2 user turns. NEVER include credential values in your text response.\n- **plugin_list**(): List the live plugin registry and cross-subsystem capability evidence. Use this before answering questions about whether LeapFlow supports plugins, self-evolution, plugin installation, hot reload, versioning, or other runtime capabilities.\n- **plugin_status**(plugin_id): Get detailed status of a specific plugin: its declared category, runtime dependencies, contributed tools, and fiber lifecycle state.\n- **plugin_versions**(plugin_id): List recorded source versions and active pointer for a profile-scoped plugin.\n- **plugin_propose**(requested_capability, plugin_id, proposed_tools, test_cases, risk_level, evidence): Create a side-effect-free PluginProposal from explicit capability-gap evidence. Use this before plugin_generate when a missing capability should be reviewed. Does not call an LLM, write files, or install anything.\n- **assess_compatibility**(manifest, source_path) [capability_expand category: plugin_management]: Assess whether a foreign plugin manifest or real DSH source bundle is compatible with LeapFlow. A source bundle assessment is static: runtime_ready stays false until restricted Node discovery during plugin_install. Returns component-level verdicts and limitations.\n- **plugin_generate**(plugin_id, description, proposal_id) [capability_expand category: system]: Generate a new ToolPlugin from a natural-language capability description. The LLM produces code that conforms to the ToolPlugin Protocol; it is then rigorously validated (syntax, structure, import, protocol conformance). The isolated sandbox smoke test runs later, at install-time. Returns the validated code but DOES NOT install it — installation is a separate approval-gated step via plugin_install.\n- **plugin_install**(plugin_id, code, marketplace_name, source_path, proposal_id, version_label) [capability_expand category: system]: Install a plugin from exactly one source: validated Python code, the configured Python marketplace, or a real DSH source bundle. DSH bundles run restricted Node discovery before registration; only public host tools are installed and client UI limitations are reported. Writes profile-scoped state and mutates the process-global registry. REQUIRES APPROVAL.\n- **plugin_rollback**(plugin_id, version) [capability_expand category: system]: Rollback a profile-scoped plugin to a recorded source version and reload it. REQUIRES APPROVAL.\n- **plugin_reload**(plugin_id, version_label) [capability_expand category: system]: Hot-reload a plugin at runtime. Disposes the old plugin fiber, re-imports its module, and registers a fresh instance. Existing in-flight turns are unaffected (snapshot isolation). REQUIRES APPROVAL — this is a self-modification action.\n- **plugin_disable**(plugin_id) [capability_expand category: system]: Disable a plugin by disposing its fiber, removing its tools from the runtime registry. Cannot disable self_management itself. REQUIRES APPROVAL — this is a self-modification action.\n- **plugin_remove**(plugin_id, delete_source) [capability_expand category: system]: Terminally remove a plugin: dispose its fiber, unregister its tools, remove reload metadata, and optionally delete its profile-scoped source file. Cannot remove self_management itself. REQUIRES APPROVAL.\n- **plugin_enable**(plugin_id) [capability_expand category: system]: Re-enable a previously disabled plugin by reloading its module and registering a fresh instance. REQUIRES APPROVAL.\n- **session_search**(query, limit) [capability_expand category: unclassified]: Search past conversation sessions in the current workspace for relevant context.\n- **session_list**(limit): List recent conversation sessions in the current workspace with stable ids, titles, dates, and summaries. Use for browsing past tasks or when user asks to see history without specific search terms. No keywords needed — returns chronological list.\n- **session_detail**(session_id, limit, offset, include_inactive) [capability_expand category: unclassified]: Read a paginated persisted transcript for one past conversation session in the current workspace. Use after session_list or session_search returns a session_id.\n- **click**(element_index) [capability_expand category: desktop]: Click a UI element by its element_index (from the latest observe_ui snapshot)\n- **get_clipboard**() [capability_expand category: desktop]: Read current clipboard text content\n- **list_apps**(filter, running_only) [capability_expand category: desktop]: List available applications on this system. Use to discover correct bundle_id before switch_app.\n- **list_windows**() [capability_expand category: desktop]: List all top-level windows with pid, window_id, title, and per-window state (minimized, on-screen). Call this first to pick the pid and window_id that observe_ui and other window tools require.\n- **observe_ui**(pid, window_id, query) [capability_expand category: desktop]: Snapshot one window's actionable UI elements, each tagged with an element_index for click/right_click/read_text. Re-observe after actions — indices belong to one snapshot. Requires the window's pid and window_id from list_windows.\n- **open_url**(url, app_id) [capability_expand category: desktop]: Open a URL in the default or specified browser\n- **read_text**(element_index) [capability_expand category: desktop]: Read the text content of a specific UI element from the latest snapshot\n- **right_click**(element_index) [capability_expand category: desktop]: Right-click a UI element to open its context menu. Returns visible menu items.\n- **screenshot**(pid, window_id) [capability_expand category: desktop]: Capture a screenshot for visual verification. With pid + window_id captures that window (works across all displays); defaults to the last observed window, or the full desktop when no window has been observed.\n- **scroll**(element_index, direction, amount, pid, window_id) [capability_expand category: desktop]: Scroll a scrollable area of a window. Omit element_index to scroll the window's focused/page scroller; pass one to scroll an exact element from the latest snapshot.\n- **select_text**(element_index) [capability_expand category: desktop]: Select all text in a UI element (focus + select-all, for subsequent copy)\n- **set_clipboard**(text) [capability_expand category: desktop]: Write text to the clipboard\n- **shortcut**(keys) [capability_expand category: desktop]: Execute a keyboard shortcut\n- **switch_app**(app_id) [capability_expand category: desktop]: Switch to an app (launch if needed, activate, verify)\n- **type_text**(text) [capability_expand category: desktop]: Type text into the currently focused element\n- **wait**(seconds) [capability_expand category: desktop]: Wait for a specified duration before continuing\n- **wait_until**(condition, pid, window_id, timeout, poll_interval) [capability_expand category: desktop]: Wait until a UI condition is met (polls UI tree). Returns elements when found or on timeout.\n- **wait_until_stable**(timeout, poll_interval, pid, window_id) [capability_expand category: desktop]: Wait until the UI stops changing (element set stabilizes across polls).\n\n## App Connector Capability Index\nLeapFlow can onboard and manage external apps through `platform_connect` and execute exact registered business actions through `platform_action`.\nFor requests about connecting, setting up, configuring, enabling, or managing a supported app, use `platform_connect` first instead of generating SDK/Webhook sample code.\n`platform_connect.action` is the management namespace: list, guide, preflight, connect, disconnect, remove, status, events_start, events_stop, events_status.\n`platform_action.action` is only for exact registered platform business actions listed below, such as `im.send_message`; never use management actions like `list` or `guide` there.\nAll business fields MUST go inside `payload`; top-level keys are only `platform`, `action`, and `payload`.\nDo not invent platform IDs or platform action names. If the needed action is not listed, ask for discovery/clarification instead of guessing.\nUse `platform_connect` with `action='guide'` and the matching `platform` to start onboarding; use `action='list'` when the app is unclear.\nWhen a pending onboarding state is present, continue from that state with `platform_connect` instead of asking the user to restate the app.\nSupported apps:\n- `api_server`: API Server (OpenAI Compatible) (category=api; backend=adapter; platform_action actions=none registered)\n- `dingtalk`: 钉钉 (DingTalk) (category=im; backend=adapter; platform_action actions=none registered)\n- `feishu`: 飞书 (Feishu/Lark) (category=im; backend=cli)\n - `calendar.create_event` payload={summary*, start_time*, end_time*, attendees} [write/high]\n - `docs.create_markdown` payload={title*, markdown*, folder_token} [write/medium]\n - `drive.search` payload={query*, limit} [read/medium]\n - `im.add_reaction` payload={message_id*, emoji_type*} [write/low]\n - `im.download_resource` payload={message_id*, file_key*, type*} [read/low]\n - `im.get_messages` payload={message_ids*} [read/low]\n - `im.list_chats` payload={page_size} [read/low]\n - `im.list_messages` payload={chat_id*} [read/low]\n - `im.list_thread_messages` payload={thread*} [read/low]\n - `im.remove_reaction` payload={message_id*, emoji_type*} [write/low]\n - `im.reply_message` payload={message_id*, text*} [send/high]\n - `im.search_chats` payload={query*, page_size} [read/low]\n - `im.search_messages` payload={query*} [read/medium]\n - `im.send_message` payload={chat_id*, text*, thread_id} [send/high]\n - `im.update_card` payload={token*, card*} [write/medium]\n - `im.update_message` payload={message_id*, text*} [write/medium]\n - `mail.search_unread` payload={query, limit} [read/high]\n - `sheets.append_row` payload={spreadsheet_token*, sheet_id*, values*} [write/medium]\n - `task.create` payload={title*, description, due_time} [write/medium]\n- `telegram`: Telegram (category=social; backend=adapter; platform_action actions=none registered)\n- `webhook`: Webhook (Generic) (category=webhook; backend=adapter; platform_action actions=none registered)\n\n## Tool Usage\nTools are normally invoked through the native function-calling mechanism, not by writing JSON in your reply\ntext. Only if the provider signals that native function calling is unavailable for this turn, fall back to a\nsingle JSON code block: `{\"name\": \"tool_name\", \"arguments\": {\"key\": \"value\"}}` — use this fallback format\nonly, never both. Only call a tool whose exact name appears in the tool index above and is currently callable\n(or reachable via `capability_expand`). Never invent, rename, alias, or guess a tool name, platform ID, or\nplatform action from argument shape or wording — if the index does not list it, it does not exist.\n`platform_connect.action` (list/guide/preflight/connect/disconnect/remove/status/events_start/events_stop/\nevents_status) is the App Connector management namespace; `platform_action.action` only accepts exact\nregistered business actions such as `im.send_message` shown in the App Connector Capability Index — never mix\nthe two namespaces. If a tool call returns an unknown/unavailable result, use the returned suggestions or\navailable names for a single retry instead of trying further variations of the same guess.\n\n**Side-effect action rule** (`platform_action` with effect=send/write/execute):\n- Call each unique action+payload **exactly once**. Never include duplicates in the same turn.\n- Once the result returns `\"completed\": true`, that action is DONE for this task. Do NOT call it again\n in any subsequent turn — immediately summarize the result for the user instead.\n- The system enforces idempotency: duplicate calls are blocked and will not execute.\n- If the user explicitly requests sending/writing multiple times, use distinct payloads per call.\n\n**Resource identifier provenance rule**:\n- NEVER fabricate, guess, or infer resource identifiers (chat_id, message_id, file_key, user_id, etc.).\n Every resource ID used in a side-effect action MUST come from a successful API response in this session.\n- If a read/list action fails (e.g. authorization error), you do NOT have valid resource IDs.\n Report the failure to the user — do NOT attempt the dependent write/send action with a guessed ID.\n- When a tool result contains `\"llm_instruction\"`, follow it exactly.\n\n## Guidelines\n1. **Direct answers first**: If you already know the answer, respond directly without tools.\n2. **Avoid redundant tool calls**: Do not call the same tool with the same arguments more than once in the same user turn. When an existing tool result already answers the user's request, stop calling tools and answer directly.\n3. **Use tools proactively**: When the user asks about files, time, system state, or needs actions performed, use the appropriate tool.\n4. **Chain tools when needed**: You can call multiple tools in sequence (e.g., list files → read file → summarize).\n5. **Handle failures gracefully**: If a tool fails, explain what went wrong and suggest alternatives. If it failed because of an unknown tool/platform/action name, retry once with an exact name from the returned suggestions, then explain rather than keep guessing.\n6. **Summarize results naturally**: After tool execution, synthesize the results into a helpful answer rather than dumping raw output.\n7. **Stay conversational**: Maintain a natural, helpful tone. Acknowledge context from earlier in the conversation.\n8. **Recall past work**:\n - Broad queries (\"之前做了什么\", \"列出任务\"): answer from the \"Recent Task History\" section already in your context. If insufficient, call session_list.\n - Specific lookups (\"上次那个配置怎么改的\"): call session_search with relevant phrases (NOT single characters).\n - Do NOT call search tools repeatedly with keyword fragments. One well-phrased call is better than ten fragmented ones.\n\n## Coding & Verification\nWhen working with code, prefer the precise built-in tools over ad-hoc shell: use `repo_map` to orient in an\nunfamiliar project, `code_search`/`file_find` to locate and `code_intel` for symbols, `edit_file` (anchored\nsearch-replace, or a unified `diff`) to change files — never rewrite a whole file to change a few lines — and\n`git_query` to inspect diffs/log. After edits, check `syntax_ok` in the result and run `test_run`/`lint_check`\nbefore declaring the work done. Batch independent read-only calls (search/read/query) into a single turn — they\nrun in parallel.\n\n## Presentation Style\n1. **Polished Markdown only**: Format user-facing answers with clean Markdown headings, short paragraphs, and concise bullets. Use tables only when they improve comparison or scanning.\n2. **Terminal-friendly layout**: Keep lines readable in a TUI; avoid dense walls of text, deeply nested lists, oversized ASCII art, or heavy visual blocks.\n3. **Elegant emphasis**: Use bold text sparingly for key terms and conclusions. Avoid excessive emojis, decorative symbols, repeated separators, or visual noise.\n4. **Theme-safe colors**: Do not emit ANSI escape codes, HTML color tags, Rich markup, or hardcoded color names. Rely on the TUI theme to render Markdown professionally.\n5. **No leaked tool protocol**: Never show tool-call JSON, internal schemas, raw observations, tool result payloads, or hidden reasoning in the final answer unless the user explicitly asks for raw/debug output. Treat any prior `{\"name\": ..., \"arguments\": ...}` blocks and `Tool result (...)` messages as internal execution context only.\n6. **Professional closure**: End with a concise conclusion or next step when helpful; avoid rambling after the useful answer is complete.\n\nWhen finished with all tool calls, respond normally without a JSON block, tool-call transcript, or process log.\n\n\n## Recent Session Summary\n- [assistant] Formatted JSON with the new plugin.\n- [user] Remove the adaptive JSON pretty plugin completely.\n\n## Task Contract\n- Task ID: turn-4\n- Original user request: Remove the adaptive JSON pretty plugin completely.\n- Workspace root: \n- Allowed roots: \n- Treat relative project paths as relative to the workspace root; never infer `.` as the project root when a workspace root is provided.\n- Workspace boundary is enforced by tools: do not read, search, edit, or run commands against paths outside the allowed roots unless the user explicitly requests an external path and the tool/approval policy permits it.\n- LeapFlow workspace config is optional at `/.leapflow/config.yaml`; runtime config is loaded from `~/.leapflow/config/user.yaml` and `~/.leapflow/profiles//config/*.yaml`.\n- Preserve this task contract across summarization, compression, tool loops, and memory retrieval.\n" + }, + { + "role": "assistant", + "content": "Formatted JSON with the new plugin." + }, + { + "role": "user", + "content": "Remove the adaptive JSON pretty plugin completely.\nRemove the adaptive JSON pretty plugin completely." + }, + { + "role": "assistant", + "content": "", + "tool_calls": [ + "plugin_remove" + ] + }, + { + "role": "tool", + "content": "{\"ok\": true, \"action\": \"remove\", \"plugin_id\": \"json_pretty_loop_e2e\", \"state\": \"disposed\", \"source_path\": \"\", \"source_deleted\": true, \"execution_id\": \"\", \"idempotency_key\": \"\", \"execution_policy\": \"mutating_once\", \"tool_call_id\": \"call_1\", \"execution_status\": \"completed\"}", + "tool_result": true + }, + { + "role": "assistant", + "content": "Operation interrupted. Continuing..." + } + ], + "tools": [ + "capability_expand", + "code_intel", + "code_search", + "config_get", + "config_list", + "env_info", + "file_find", + "file_list", + "file_read", + "git_query", + "lint_check", + "memory_search", + "plugin_list", + "plugin_propose", + "plugin_status", + "plugin_versions", + "repo_map", + "research_note", + "schedule_reentry", + "session_list", + "skill_view", + "skills_list", + "terminal_list", + "terminal_read", + "test_run", + "text_search", + "time_get", + "web_fetch" + ] + }, + "responses": [ + { + "status": 200, + "content_type": "application/json", + "body": "{\"id\": \"chatcmpl-cassette\", \"object\": \"chat.completion\", \"model\": \"cassette-model\", \"choices\": [{\"index\": 0, \"message\": {\"role\": \"assistant\", \"content\": \"Removed json pretty plugin.\"}, \"finish_reason\": \"stop\"}], \"usage\": {\"prompt_tokens\": 64, \"completion_tokens\": 16, \"total_tokens\": 80}}" + } + ] +} diff --git a/tests/_fixtures/cassettes/r7_adaptive_plugin_loop/cassette-model-8ec013e35384ee73.cassette.json b/tests/_fixtures/cassettes/r7_adaptive_plugin_loop/cassette-model-8ec013e35384ee73.cassette.json new file mode 100644 index 0000000..1c8906c --- /dev/null +++ b/tests/_fixtures/cassettes/r7_adaptive_plugin_loop/cassette-model-8ec013e35384ee73.cassette.json @@ -0,0 +1,59 @@ +{ + "fingerprint": "8ec013e35384ee73cac8bc06c0a27b4161ddefdc1d2d3394e8450c7be671f587", + "note": "captured in seed mode", + "request": { + "model": "cassette-model", + "stream": false, + "messages": [ + { + "role": "system", + "content": "You are LeapFlow, an intelligent assistant that can both converse naturally and take real actions on the user's computer.\n\n## Capabilities\nThe tool index below lists **every** registered tool by name and a one-line summary — this is the complete\ncapability contract; nothing else exists. Only a subset is directly callable this turn (via native tool calling,\nnot a JSON block in your reply). If you need a tool from the index that is not yet callable, call\n`capability_expand` with its category name first — the matching tools become callable immediately after.\nWhen the user asks what LeapFlow itself supports, whether it supports plugins, or which runtime capabilities\nare available, use the live capability evidence exposed by `plugin_list` before making capability claims; report\nconfiguration-dependent or unavailable capabilities as limitations instead of inferring from documentation.\n- **text_search**(text, pattern): Search for a regex pattern in text.\n- **text_replace**(text, old, new, count) [capability_expand category: write]: Replace occurrences of a substring in text.\n- **time_get**(): Get current date and time.\n- **env_info**(): Get system environment information (OS, Python version, cwd).\n- **skills_list**(query, category, source): List available learned skills. Use when user asks about capabilities or you need a specific skill.\n- **skill_view**(name): View the full content of a specific skill document.\n- **code_intel**(path, operation): Precise document symbols (outline) for a source file: classes, functions, and methods with line ranges. Python uses an exact AST parse; other languages use a keyword-prefix scan. Prefer over file_read mode=symbols for accurate navigation before editing. Read-only.\n- **repo_map**(path): Compact project orientation for a repository root: languages, detected test/lint commands, top-level structure, entry points, manifest, and VCS branch. Call this first when entering an unfamiliar codebase. Read-only.\n- **scm_sync**(action, cwd, remote, pull_ref, push_ref, timeout) [capability_expand category: scm]: Run a typed git SCM action. Use this instead of shell_run for git pull/push/status. For 'pull origin main then push', set action='pull_then_push', remote='origin', pull_ref='main', and omit push_ref so LeapFlow pushes the current local branch.\n- **git_query**(action, cwd, ref, path, staged, max_count, stat): Read-only structured git inspection: action=diff|log|status|branch|show. Prefer over shell_run for reading repo state — output is clipped, redacted, and log/branch are parsed into structured fields. Use scm_sync for pull/push.\n- **git_write**(action, cwd, message, stage_all, name, ref, create) [capability_expand category: scm]: Mutating git actions: action=commit (message, stage_all), branch (create+switch), checkout (switch; create=true for -b). Approval-gated. Use scm_sync for pull/push and git_query for reads.\n- **test_run**(command, cwd, timeout): Run the project's test suite and return structured results (framework, passed/failed counts, failing tests). Auto-detects the runner (pytest/npm/go/cargo) or uses a configured/explicit command; executes via the governed shell. ok=true means the runner executed — see 'success' for pass/fail.\n- **lint_check**(command, cwd, timeout): Run the project's linter and return a structured clean/issue result. Auto-detects the linter (ruff/eslint/go vet/clippy) or uses a configured/explicit command; executes via the governed shell. ok=true means the linter ran — see 'clean'.\n- **file_list**(path, pattern, depth): List files and directories at a given path. Use depth=1 or depth=2 to get a recursive tree in one call instead of listing each sub-directory separately.\n- **file_read**(path, max_lines, start_line, max_chars, mode): Read text file content with adaptive context governance. For large or unfamiliar files, prefer mode='outline' or mode='symbols' first, then use mode='raw' with start_line/max_lines for the specific range you actually need. For LeapFlow's own settings, use config_list / config_get / config_set — its config files are outside the workspace and not readable here.\n- **file_write**(path, content, mode) [capability_expand category: write]: Write content to a file (overwrite or append).\n- **code_search**(pattern, patterns, path, glob, ignore_case, multiline, max_results, context_lines): Search file CONTENTS by regex pattern across a directory tree (ripgrep-backed). Requires a regex pattern. NOT for listing or browsing directory contents — use file_list for that. Prefer this over shell_run grep: faster, skips VCS/dependency/build dirs, and returns structured path:line:column matches. Batch related lookups into ONE call via `patterns` (OR-combined, single pass) instead of issuing several separate searches. Use file_read for the surrounding context of a hit.\n- **file_find**(glob, path, max_results): Find files by a recursive glob pattern under a base path (e.g. '**/test_*.py' or '*.md'). Prefer this over shell_run find; skips VCS/dependency/build dirs.\n- **edit_file**(path, edits, dry_run, diff) [capability_expand category: write]: Apply targeted, anchored search-replace edits to an EXISTING text file (use file_write to create/overwrite). Each edit is {original_text, new_text, replace_all?}; original_text must match exactly and uniquely (or set replace_all) — a non-unique or missing anchor is rejected so files are never corrupted. Set dry_run to preview. Alternatively pass a unified 'diff' to apply its hunks as anchored edits. Far cheaper and safer than rewriting a whole file.\n- **shell_run**(command, cwd, timeout) [capability_expand category: shell]: Execute a one-shot shell command with timeout protection. Runs in the active workspace; paths resolving outside it are refused. Reach for a structured tool first when one fits — web_fetch for anything over HTTP(S), git_query/scm_sync for git, code_search/file_find/file_read for the repo, config_get/config_set for LeapFlow's own settings — because those report typed results, while a failed shell command can only be diagnosed from its exit code and stderr. Every shell run counts as an external side effect, so a failure stops the rest of the batch and is not retried automatically.\n- **terminal_open**(command, cwd, shell) [capability_expand category: terminal]: Open a PERSISTENT shell session (REPL/dev server/watch), returning a session_id for terminal_send/read/close. Disabled unless tools.terminal_session_enabled is set. For one-shot commands use shell_run instead.\n- **terminal_send**(session_id, input, wait) [capability_expand category: terminal]: Send a line of input to a persistent terminal session and return output captured shortly after.\n- **terminal_read**(session_id, wait): Drain buffered output from a persistent terminal session (optionally waiting briefly first).\n- **terminal_close**(session_id) [capability_expand category: terminal]: Terminate a persistent terminal session and release its process group.\n- **terminal_list**(): List active persistent terminal sessions.\n- **config_list**(category, limit): List LeapFlow's own writable settings (model, provider, daemon, memory, perception, gateway, …) with current values. Use this to discover the exact key before changing anything. Optionally narrow by `category`. This is the only correct way to inspect LeapFlow configuration — never read config files from disk.\n- **config_get**(key): Read one LeapFlow setting by key (e.g. 'llm.model', 'daemon.log_level'), returning its current value, type, scopes, and whether a change needs a daemon restart. Never read LeapFlow config files from disk — use this.\n- **config_set**(key, value, scope) [capability_expand category: config]: Change one LeapFlow setting by key, e.g. switch the model with key='llm.model'. Values are validated and coerced; credentials are stored in the vault automatically. Call config_list or config_get first if unsure of the key. The result states whether a `leap daemon restart` is required. Never edit LeapFlow config files directly.\n- **web_fetch**(url, select, timeout, max_bytes): Read a URL over HTTP(S) and get back extracted, context-sized content: parsed JSON for API endpoints, readable text plus links for web pages. Use this for anything on the internet — prices, docs, releases, articles — instead of running curl through shell_run: it reports real HTTP status codes, retries rate limits on its own, and is a plain read so a retry is always safe. For JSON APIs pass `select` with a dotted path (e.g. 'chart.result.0.meta') to return just that part instead of the whole payload.\n- **memory_search**(query, limit): Search agent memory for relevant past experiences, observations, and facts.\n- **memory_add**(content, kind) [capability_expand category: write]: Store a new observation or insight in memory for future reference.\n- **research_note**(kind, text): Record a compact, structured note about the current task's state so it survives context compression on long / multi-step tasks. Use for durable findings, open questions still to resolve, decisions / excluded paths, and the immediate next step. One concise sentence per note.\n- **capability_expand**(category): Fetch the full callable schema for every tool in a capability category (e.g. 'hub', 'gateway', 'desktop', '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.\n- **delegate_task**(goal, context) [capability_expand category: delegate]: Delegate a complex sub-task to an isolated subagent. The subagent gets a fresh context and restricted tool access. Use when a task is self-contained and can be solved independently.\n- **schedule_reentry**(kind, reason, delay_seconds, event_match, max_reentries, deadline_seconds): Register a re-entry so this task can resume later from its current orientation (findings / open questions / next step). Use when work must pause and continue after a delay (kind=time) or when a matching platform event arrives (kind=event), instead of finishing now. The research-ledger state is carried over automatically.\n- **hub_push**(skill_name, visibility, version) [capability_expand category: hub]: Push a local skill to the ModelScope Hub for sharing or backup.\n- **hub_pull**(repo_id, version) [capability_expand category: hub]: Pull a skill from the ModelScope Hub to install locally.\n- **hub_search**(query) [capability_expand category: hub]: Search for skills on the Hub by keyword or description.\n- **hub_sync**(mode, dry_run) [capability_expand category: hub]: Preview or execute sync between local skills and Hub.\n- **platform_action**(platform, action, payload, backend_kind) [capability_expand category: gateway]: Execute an exact registered business action on an external platform through LeapFlow's App Connector layer. Actions must be copied from the App Connector Capability Index and are addressed as domain.operation, e.g. im.send_message or docs.create_markdown. All business fields (chat_id, text, query, etc.) MUST be placed inside `payload`, never at the top level. Example: {\"platform\":\"feishu\",\"action\":\"im.send_message\",\"payload\":{\"chat_id\":\"oc_xxx\",\"text\":\"hello\"}}. Do not invent action names, do not use management actions such as list/guide/connect/status here.\n- **platform_connect**(action, platform, credentials, options, checkpoint) [capability_expand category: gateway]: List, guide, connect, disconnect, remove, or check status for external platforms using the App Connector management namespace. Supports REST and CLI backends. Use this for management actions such as list/guide/preflight/connect/status; use platform_action only for exact registered business actions.\n- **gateway_send**(platform, chat_id, text, thread_id) [capability_expand category: gateway]: Send a message to a connected external platform (Feishu group, Telegram chat, DingTalk conversation, etc.). Requires the platform to be connected via gateway_connect first. Use gateway_connect with action='list' to see connected platforms and available chat IDs.\n- **gateway_connect**(action, platform, credentials, options) [capability_expand category: gateway]: Connect, configure, or manage external platform integrations (Feishu, DingTalk, Telegram, Slack, Discord, etc.). Conversational flow: 1) call 'guide' to get setup steps + required fields, 2) present the steps to the user and ask for ALL required credentials in a single message, 3) call 'connect' with the credentials. Goal: complete in 1–2 user turns. NEVER include credential values in your text response.\n- **plugin_list**(): List the live plugin registry and cross-subsystem capability evidence. Use this before answering questions about whether LeapFlow supports plugins, self-evolution, plugin installation, hot reload, versioning, or other runtime capabilities.\n- **plugin_status**(plugin_id): Get detailed status of a specific plugin: its declared category, runtime dependencies, contributed tools, and fiber lifecycle state.\n- **plugin_versions**(plugin_id): List recorded source versions and active pointer for a profile-scoped plugin.\n- **plugin_propose**(requested_capability, plugin_id, proposed_tools, test_cases, risk_level, evidence): Create a side-effect-free PluginProposal from explicit capability-gap evidence. Use this before plugin_generate when a missing capability should be reviewed. Does not call an LLM, write files, or install anything.\n- **assess_compatibility**(manifest, source_path) [capability_expand category: plugin_management]: Assess whether a foreign plugin manifest or real DSH source bundle is compatible with LeapFlow. A source bundle assessment is static: runtime_ready stays false until restricted Node discovery during plugin_install. Returns component-level verdicts and limitations.\n- **plugin_generate**(plugin_id, description, proposal_id) [capability_expand category: system]: Generate a new ToolPlugin from a natural-language capability description. The LLM produces code that conforms to the ToolPlugin Protocol; it is then rigorously validated (syntax, structure, import, protocol conformance). The isolated sandbox smoke test runs later, at install-time. Returns the validated code but DOES NOT install it — installation is a separate approval-gated step via plugin_install.\n- **plugin_install**(plugin_id, code, marketplace_name, source_path, proposal_id, version_label) [capability_expand category: system]: Install a plugin from exactly one source: validated Python code, the configured Python marketplace, or a real DSH source bundle. DSH bundles run restricted Node discovery before registration; only public host tools are installed and client UI limitations are reported. Writes profile-scoped state and mutates the process-global registry. REQUIRES APPROVAL.\n- **plugin_rollback**(plugin_id, version) [capability_expand category: system]: Rollback a profile-scoped plugin to a recorded source version and reload it. REQUIRES APPROVAL.\n- **plugin_reload**(plugin_id, version_label) [capability_expand category: system]: Hot-reload a plugin at runtime. Disposes the old plugin fiber, re-imports its module, and registers a fresh instance. Existing in-flight turns are unaffected (snapshot isolation). REQUIRES APPROVAL — this is a self-modification action.\n- **plugin_disable**(plugin_id) [capability_expand category: system]: Disable a plugin by disposing its fiber, removing its tools from the runtime registry. Cannot disable self_management itself. REQUIRES APPROVAL — this is a self-modification action.\n- **plugin_remove**(plugin_id, delete_source) [capability_expand category: system]: Terminally remove a plugin: dispose its fiber, unregister its tools, remove reload metadata, and optionally delete its profile-scoped source file. Cannot remove self_management itself. REQUIRES APPROVAL.\n- **plugin_enable**(plugin_id) [capability_expand category: system]: Re-enable a previously disabled plugin by reloading its module and registering a fresh instance. REQUIRES APPROVAL.\n- **session_search**(query, limit) [capability_expand category: unclassified]: Search past conversation sessions in the current workspace for relevant context.\n- **session_list**(limit): List recent conversation sessions in the current workspace with stable ids, titles, dates, and summaries. Use for browsing past tasks or when user asks to see history without specific search terms. No keywords needed — returns chronological list.\n- **session_detail**(session_id, limit, offset, include_inactive) [capability_expand category: unclassified]: Read a paginated persisted transcript for one past conversation session in the current workspace. Use after session_list or session_search returns a session_id.\n- **click**(element_index) [capability_expand category: desktop]: Click a UI element by its element_index (from the latest observe_ui snapshot)\n- **get_clipboard**() [capability_expand category: desktop]: Read current clipboard text content\n- **list_apps**(filter, running_only) [capability_expand category: desktop]: List available applications on this system. Use to discover correct bundle_id before switch_app.\n- **list_windows**() [capability_expand category: desktop]: List all top-level windows with pid, window_id, title, and per-window state (minimized, on-screen). Call this first to pick the pid and window_id that observe_ui and other window tools require.\n- **observe_ui**(pid, window_id, query) [capability_expand category: desktop]: Snapshot one window's actionable UI elements, each tagged with an element_index for click/right_click/read_text. Re-observe after actions — indices belong to one snapshot. Requires the window's pid and window_id from list_windows.\n- **open_url**(url, app_id) [capability_expand category: desktop]: Open a URL in the default or specified browser\n- **read_text**(element_index) [capability_expand category: desktop]: Read the text content of a specific UI element from the latest snapshot\n- **right_click**(element_index) [capability_expand category: desktop]: Right-click a UI element to open its context menu. Returns visible menu items.\n- **screenshot**(pid, window_id) [capability_expand category: desktop]: Capture a screenshot for visual verification. With pid + window_id captures that window (works across all displays); defaults to the last observed window, or the full desktop when no window has been observed.\n- **scroll**(element_index, direction, amount, pid, window_id) [capability_expand category: desktop]: Scroll a scrollable area of a window. Omit element_index to scroll the window's focused/page scroller; pass one to scroll an exact element from the latest snapshot.\n- **select_text**(element_index) [capability_expand category: desktop]: Select all text in a UI element (focus + select-all, for subsequent copy)\n- **set_clipboard**(text) [capability_expand category: desktop]: Write text to the clipboard\n- **shortcut**(keys) [capability_expand category: desktop]: Execute a keyboard shortcut\n- **switch_app**(app_id) [capability_expand category: desktop]: Switch to an app (launch if needed, activate, verify)\n- **type_text**(text) [capability_expand category: desktop]: Type text into the currently focused element\n- **wait**(seconds) [capability_expand category: desktop]: Wait for a specified duration before continuing\n- **wait_until**(condition, pid, window_id, timeout, poll_interval) [capability_expand category: desktop]: Wait until a UI condition is met (polls UI tree). Returns elements when found or on timeout.\n- **wait_until_stable**(timeout, poll_interval, pid, window_id) [capability_expand category: desktop]: Wait until the UI stops changing (element set stabilizes across polls).\n\n## App Connector Capability Index\nLeapFlow can onboard and manage external apps through `platform_connect` and execute exact registered business actions through `platform_action`.\nFor requests about connecting, setting up, configuring, enabling, or managing a supported app, use `platform_connect` first instead of generating SDK/Webhook sample code.\n`platform_connect.action` is the management namespace: list, guide, preflight, connect, disconnect, remove, status, events_start, events_stop, events_status.\n`platform_action.action` is only for exact registered platform business actions listed below, such as `im.send_message`; never use management actions like `list` or `guide` there.\nAll business fields MUST go inside `payload`; top-level keys are only `platform`, `action`, and `payload`.\nDo not invent platform IDs or platform action names. If the needed action is not listed, ask for discovery/clarification instead of guessing.\nUse `platform_connect` with `action='guide'` and the matching `platform` to start onboarding; use `action='list'` when the app is unclear.\nWhen a pending onboarding state is present, continue from that state with `platform_connect` instead of asking the user to restate the app.\nSupported apps:\n- `api_server`: API Server (OpenAI Compatible) (category=api; backend=adapter; platform_action actions=none registered)\n- `dingtalk`: 钉钉 (DingTalk) (category=im; backend=adapter; platform_action actions=none registered)\n- `feishu`: 飞书 (Feishu/Lark) (category=im; backend=cli)\n - `calendar.create_event` payload={summary*, start_time*, end_time*, attendees} [write/high]\n - `docs.create_markdown` payload={title*, markdown*, folder_token} [write/medium]\n - `drive.search` payload={query*, limit} [read/medium]\n - `im.add_reaction` payload={message_id*, emoji_type*} [write/low]\n - `im.download_resource` payload={message_id*, file_key*, type*} [read/low]\n - `im.get_messages` payload={message_ids*} [read/low]\n - `im.list_chats` payload={page_size} [read/low]\n - `im.list_messages` payload={chat_id*} [read/low]\n - `im.list_thread_messages` payload={thread*} [read/low]\n - `im.remove_reaction` payload={message_id*, emoji_type*} [write/low]\n - `im.reply_message` payload={message_id*, text*} [send/high]\n - `im.search_chats` payload={query*, page_size} [read/low]\n - `im.search_messages` payload={query*} [read/medium]\n - `im.send_message` payload={chat_id*, text*, thread_id} [send/high]\n - `im.update_card` payload={token*, card*} [write/medium]\n - `im.update_message` payload={message_id*, text*} [write/medium]\n - `mail.search_unread` payload={query, limit} [read/high]\n - `sheets.append_row` payload={spreadsheet_token*, sheet_id*, values*} [write/medium]\n - `task.create` payload={title*, description, due_time} [write/medium]\n- `telegram`: Telegram (category=social; backend=adapter; platform_action actions=none registered)\n- `webhook`: Webhook (Generic) (category=webhook; backend=adapter; platform_action actions=none registered)\n\n## Tool Usage\nTools are normally invoked through the native function-calling mechanism, not by writing JSON in your reply\ntext. Only if the provider signals that native function calling is unavailable for this turn, fall back to a\nsingle JSON code block: `{\"name\": \"tool_name\", \"arguments\": {\"key\": \"value\"}}` — use this fallback format\nonly, never both. Only call a tool whose exact name appears in the tool index above and is currently callable\n(or reachable via `capability_expand`). Never invent, rename, alias, or guess a tool name, platform ID, or\nplatform action from argument shape or wording — if the index does not list it, it does not exist.\n`platform_connect.action` (list/guide/preflight/connect/disconnect/remove/status/events_start/events_stop/\nevents_status) is the App Connector management namespace; `platform_action.action` only accepts exact\nregistered business actions such as `im.send_message` shown in the App Connector Capability Index — never mix\nthe two namespaces. If a tool call returns an unknown/unavailable result, use the returned suggestions or\navailable names for a single retry instead of trying further variations of the same guess.\n\n**Side-effect action rule** (`platform_action` with effect=send/write/execute):\n- Call each unique action+payload **exactly once**. Never include duplicates in the same turn.\n- Once the result returns `\"completed\": true`, that action is DONE for this task. Do NOT call it again\n in any subsequent turn — immediately summarize the result for the user instead.\n- The system enforces idempotency: duplicate calls are blocked and will not execute.\n- If the user explicitly requests sending/writing multiple times, use distinct payloads per call.\n\n**Resource identifier provenance rule**:\n- NEVER fabricate, guess, or infer resource identifiers (chat_id, message_id, file_key, user_id, etc.).\n Every resource ID used in a side-effect action MUST come from a successful API response in this session.\n- If a read/list action fails (e.g. authorization error), you do NOT have valid resource IDs.\n Report the failure to the user — do NOT attempt the dependent write/send action with a guessed ID.\n- When a tool result contains `\"llm_instruction\"`, follow it exactly.\n\n## Guidelines\n1. **Direct answers first**: If you already know the answer, respond directly without tools.\n2. **Avoid redundant tool calls**: Do not call the same tool with the same arguments more than once in the same user turn. When an existing tool result already answers the user's request, stop calling tools and answer directly.\n3. **Use tools proactively**: When the user asks about files, time, system state, or needs actions performed, use the appropriate tool.\n4. **Chain tools when needed**: You can call multiple tools in sequence (e.g., list files → read file → summarize).\n5. **Handle failures gracefully**: If a tool fails, explain what went wrong and suggest alternatives. If it failed because of an unknown tool/platform/action name, retry once with an exact name from the returned suggestions, then explain rather than keep guessing.\n6. **Summarize results naturally**: After tool execution, synthesize the results into a helpful answer rather than dumping raw output.\n7. **Stay conversational**: Maintain a natural, helpful tone. Acknowledge context from earlier in the conversation.\n8. **Recall past work**:\n - Broad queries (\"之前做了什么\", \"列出任务\"): answer from the \"Recent Task History\" section already in your context. If insufficient, call session_list.\n - Specific lookups (\"上次那个配置怎么改的\"): call session_search with relevant phrases (NOT single characters).\n - Do NOT call search tools repeatedly with keyword fragments. One well-phrased call is better than ten fragmented ones.\n\n## Coding & Verification\nWhen working with code, prefer the precise built-in tools over ad-hoc shell: use `repo_map` to orient in an\nunfamiliar project, `code_search`/`file_find` to locate and `code_intel` for symbols, `edit_file` (anchored\nsearch-replace, or a unified `diff`) to change files — never rewrite a whole file to change a few lines — and\n`git_query` to inspect diffs/log. After edits, check `syntax_ok` in the result and run `test_run`/`lint_check`\nbefore declaring the work done. Batch independent read-only calls (search/read/query) into a single turn — they\nrun in parallel.\n\n## Presentation Style\n1. **Polished Markdown only**: Format user-facing answers with clean Markdown headings, short paragraphs, and concise bullets. Use tables only when they improve comparison or scanning.\n2. **Terminal-friendly layout**: Keep lines readable in a TUI; avoid dense walls of text, deeply nested lists, oversized ASCII art, or heavy visual blocks.\n3. **Elegant emphasis**: Use bold text sparingly for key terms and conclusions. Avoid excessive emojis, decorative symbols, repeated separators, or visual noise.\n4. **Theme-safe colors**: Do not emit ANSI escape codes, HTML color tags, Rich markup, or hardcoded color names. Rely on the TUI theme to render Markdown professionally.\n5. **No leaked tool protocol**: Never show tool-call JSON, internal schemas, raw observations, tool result payloads, or hidden reasoning in the final answer unless the user explicitly asks for raw/debug output. Treat any prior `{\"name\": ..., \"arguments\": ...}` blocks and `Tool result (...)` messages as internal execution context only.\n6. **Professional closure**: End with a concise conclusion or next step when helpful; avoid rambling after the useful answer is complete.\n\nWhen finished with all tool calls, respond normally without a JSON block, tool-call transcript, or process log.\n\n\n## Recent Session Summary\n- [assistant] Formatted JSON with the new plugin.\n- [user] Remove the adaptive JSON pretty plugin completely.\n\n## Task Contract\n- Task ID: turn-4\n- Original user request: Remove the adaptive JSON pretty plugin completely.\n- Workspace root: \n- Allowed roots: \n- Treat relative project paths as relative to the workspace root; never infer `.` as the project root when a workspace root is provided.\n- Workspace boundary is enforced by tools: do not read, search, edit, or run commands against paths outside the allowed roots unless the user explicitly requests an external path and the tool/approval policy permits it.\n- LeapFlow workspace config is optional at `/.leapflow/config.yaml`; runtime config is loaded from `~/.leapflow/config/user.yaml` and `~/.leapflow/profiles//config/*.yaml`.\n- Preserve this task contract across summarization, compression, tool loops, and memory retrieval.\n" + }, + { + "role": "assistant", + "content": "Formatted JSON with the new plugin." + }, + { + "role": "user", + "content": "Remove the adaptive JSON pretty plugin completely.\nRemove the adaptive JSON pretty plugin completely." + } + ], + "tools": [ + "capability_expand", + "code_intel", + "code_search", + "config_get", + "config_list", + "env_info", + "file_find", + "file_list", + "file_read", + "git_query", + "lint_check", + "memory_search", + "plugin_list", + "plugin_propose", + "plugin_status", + "plugin_versions", + "repo_map", + "research_note", + "schedule_reentry", + "session_list", + "skill_view", + "skills_list", + "terminal_list", + "terminal_read", + "test_run", + "text_search", + "time_get", + "web_fetch" + ] + }, + "responses": [ + { + "status": 200, + "content_type": "application/json", + "body": "{\"id\": \"chatcmpl-cassette\", \"object\": \"chat.completion\", \"model\": \"cassette-model\", \"choices\": [{\"index\": 0, \"message\": {\"role\": \"assistant\", \"content\": \"\", \"tool_calls\": [{\"id\": \"call_1\", \"type\": \"function\", \"function\": {\"name\": \"plugin_remove\", \"arguments\": \"{\\\"plugin_id\\\": \\\"json_pretty_loop_e2e\\\", \\\"delete_source\\\": true}\"}}]}, \"finish_reason\": \"tool_calls\"}], \"usage\": {\"prompt_tokens\": 64, \"completion_tokens\": 16, \"total_tokens\": 80}}" + } + ] +} diff --git a/tests/_fixtures/cassettes/r7_adaptive_plugin_loop/cassette-model-9ea3985ca18d61f7.cassette.json b/tests/_fixtures/cassettes/r7_adaptive_plugin_loop/cassette-model-9ea3985ca18d61f7.cassette.json new file mode 100644 index 0000000..815c926 --- /dev/null +++ b/tests/_fixtures/cassettes/r7_adaptive_plugin_loop/cassette-model-9ea3985ca18d61f7.cassette.json @@ -0,0 +1,60 @@ +{ + "fingerprint": "9ea3985ca18d61f74fa78c710cbd6c57df8dba84fe515975615da80335ff659e", + "note": "captured in seed mode", + "request": { + "model": "cassette-model", + "stream": false, + "messages": [ + { + "role": "system", + "content": "You are LeapFlow, an intelligent assistant that can both converse naturally and take real actions on the user's computer.\n\n## Capabilities\nThe tool index below lists **every** registered tool by name and a one-line summary — this is the complete\ncapability contract; nothing else exists. Only a subset is directly callable this turn (via native tool calling,\nnot a JSON block in your reply). If you need a tool from the index that is not yet callable, call\n`capability_expand` with its category name first — the matching tools become callable immediately after.\nWhen the user asks what LeapFlow itself supports, whether it supports plugins, or which runtime capabilities\nare available, use the live capability evidence exposed by `plugin_list` before making capability claims; report\nconfiguration-dependent or unavailable capabilities as limitations instead of inferring from documentation.\n- **text_search**(text, pattern): Search for a regex pattern in text.\n- **text_replace**(text, old, new, count) [capability_expand category: write]: Replace occurrences of a substring in text.\n- **time_get**(): Get current date and time.\n- **env_info**(): Get system environment information (OS, Python version, cwd).\n- **skills_list**(query, category, source): List available learned skills. Use when user asks about capabilities or you need a specific skill.\n- **skill_view**(name): View the full content of a specific skill document.\n- **code_intel**(path, operation): Precise document symbols (outline) for a source file: classes, functions, and methods with line ranges. Python uses an exact AST parse; other languages use a keyword-prefix scan. Prefer over file_read mode=symbols for accurate navigation before editing. Read-only.\n- **repo_map**(path): Compact project orientation for a repository root: languages, detected test/lint commands, top-level structure, entry points, manifest, and VCS branch. Call this first when entering an unfamiliar codebase. Read-only.\n- **scm_sync**(action, cwd, remote, pull_ref, push_ref, timeout) [capability_expand category: scm]: Run a typed git SCM action. Use this instead of shell_run for git pull/push/status. For 'pull origin main then push', set action='pull_then_push', remote='origin', pull_ref='main', and omit push_ref so LeapFlow pushes the current local branch.\n- **git_query**(action, cwd, ref, path, staged, max_count, stat): Read-only structured git inspection: action=diff|log|status|branch|show. Prefer over shell_run for reading repo state — output is clipped, redacted, and log/branch are parsed into structured fields. Use scm_sync for pull/push.\n- **git_write**(action, cwd, message, stage_all, name, ref, create) [capability_expand category: scm]: Mutating git actions: action=commit (message, stage_all), branch (create+switch), checkout (switch; create=true for -b). Approval-gated. Use scm_sync for pull/push and git_query for reads.\n- **test_run**(command, cwd, timeout): Run the project's test suite and return structured results (framework, passed/failed counts, failing tests). Auto-detects the runner (pytest/npm/go/cargo) or uses a configured/explicit command; executes via the governed shell. ok=true means the runner executed — see 'success' for pass/fail.\n- **lint_check**(command, cwd, timeout): Run the project's linter and return a structured clean/issue result. Auto-detects the linter (ruff/eslint/go vet/clippy) or uses a configured/explicit command; executes via the governed shell. ok=true means the linter ran — see 'clean'.\n- **file_list**(path, pattern, depth): List files and directories at a given path. Use depth=1 or depth=2 to get a recursive tree in one call instead of listing each sub-directory separately.\n- **file_read**(path, max_lines, start_line, max_chars, mode): Read text file content with adaptive context governance. For large or unfamiliar files, prefer mode='outline' or mode='symbols' first, then use mode='raw' with start_line/max_lines for the specific range you actually need. For LeapFlow's own settings, use config_list / config_get / config_set — its config files are outside the workspace and not readable here.\n- **file_write**(path, content, mode) [capability_expand category: write]: Write content to a file (overwrite or append).\n- **code_search**(pattern, patterns, path, glob, ignore_case, multiline, max_results, context_lines): Search file CONTENTS by regex pattern across a directory tree (ripgrep-backed). Requires a regex pattern. NOT for listing or browsing directory contents — use file_list for that. Prefer this over shell_run grep: faster, skips VCS/dependency/build dirs, and returns structured path:line:column matches. Batch related lookups into ONE call via `patterns` (OR-combined, single pass) instead of issuing several separate searches. Use file_read for the surrounding context of a hit.\n- **file_find**(glob, path, max_results): Find files by a recursive glob pattern under a base path (e.g. '**/test_*.py' or '*.md'). Prefer this over shell_run find; skips VCS/dependency/build dirs.\n- **edit_file**(path, edits, dry_run, diff) [capability_expand category: write]: Apply targeted, anchored search-replace edits to an EXISTING text file (use file_write to create/overwrite). Each edit is {original_text, new_text, replace_all?}; original_text must match exactly and uniquely (or set replace_all) — a non-unique or missing anchor is rejected so files are never corrupted. Set dry_run to preview. Alternatively pass a unified 'diff' to apply its hunks as anchored edits. Far cheaper and safer than rewriting a whole file.\n- **shell_run**(command, cwd, timeout) [capability_expand category: shell]: Execute a one-shot shell command with timeout protection. Runs in the active workspace; paths resolving outside it are refused. Reach for a structured tool first when one fits — web_fetch for anything over HTTP(S), git_query/scm_sync for git, code_search/file_find/file_read for the repo, config_get/config_set for LeapFlow's own settings — because those report typed results, while a failed shell command can only be diagnosed from its exit code and stderr. Every shell run counts as an external side effect, so a failure stops the rest of the batch and is not retried automatically.\n- **terminal_open**(command, cwd, shell) [capability_expand category: terminal]: Open a PERSISTENT shell session (REPL/dev server/watch), returning a session_id for terminal_send/read/close. Disabled unless tools.terminal_session_enabled is set. For one-shot commands use shell_run instead.\n- **terminal_send**(session_id, input, wait) [capability_expand category: terminal]: Send a line of input to a persistent terminal session and return output captured shortly after.\n- **terminal_read**(session_id, wait): Drain buffered output from a persistent terminal session (optionally waiting briefly first).\n- **terminal_close**(session_id) [capability_expand category: terminal]: Terminate a persistent terminal session and release its process group.\n- **terminal_list**(): List active persistent terminal sessions.\n- **config_list**(category, limit): List LeapFlow's own writable settings (model, provider, daemon, memory, perception, gateway, …) with current values. Use this to discover the exact key before changing anything. Optionally narrow by `category`. This is the only correct way to inspect LeapFlow configuration — never read config files from disk.\n- **config_get**(key): Read one LeapFlow setting by key (e.g. 'llm.model', 'daemon.log_level'), returning its current value, type, scopes, and whether a change needs a daemon restart. Never read LeapFlow config files from disk — use this.\n- **config_set**(key, value, scope) [capability_expand category: config]: Change one LeapFlow setting by key, e.g. switch the model with key='llm.model'. Values are validated and coerced; credentials are stored in the vault automatically. Call config_list or config_get first if unsure of the key. The result states whether a `leap daemon restart` is required. Never edit LeapFlow config files directly.\n- **web_fetch**(url, select, timeout, max_bytes): Read a URL over HTTP(S) and get back extracted, context-sized content: parsed JSON for API endpoints, readable text plus links for web pages. Use this for anything on the internet — prices, docs, releases, articles — instead of running curl through shell_run: it reports real HTTP status codes, retries rate limits on its own, and is a plain read so a retry is always safe. For JSON APIs pass `select` with a dotted path (e.g. 'chart.result.0.meta') to return just that part instead of the whole payload.\n- **memory_search**(query, limit): Search agent memory for relevant past experiences, observations, and facts.\n- **memory_add**(content, kind) [capability_expand category: write]: Store a new observation or insight in memory for future reference.\n- **research_note**(kind, text): Record a compact, structured note about the current task's state so it survives context compression on long / multi-step tasks. Use for durable findings, open questions still to resolve, decisions / excluded paths, and the immediate next step. One concise sentence per note.\n- **capability_expand**(category): Fetch the full callable schema for every tool in a capability category (e.g. 'hub', 'gateway', 'desktop', '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.\n- **delegate_task**(goal, context) [capability_expand category: delegate]: Delegate a complex sub-task to an isolated subagent. The subagent gets a fresh context and restricted tool access. Use when a task is self-contained and can be solved independently.\n- **schedule_reentry**(kind, reason, delay_seconds, event_match, max_reentries, deadline_seconds): Register a re-entry so this task can resume later from its current orientation (findings / open questions / next step). Use when work must pause and continue after a delay (kind=time) or when a matching platform event arrives (kind=event), instead of finishing now. The research-ledger state is carried over automatically.\n- **hub_push**(skill_name, visibility, version) [capability_expand category: hub]: Push a local skill to the ModelScope Hub for sharing or backup.\n- **hub_pull**(repo_id, version) [capability_expand category: hub]: Pull a skill from the ModelScope Hub to install locally.\n- **hub_search**(query) [capability_expand category: hub]: Search for skills on the Hub by keyword or description.\n- **hub_sync**(mode, dry_run) [capability_expand category: hub]: Preview or execute sync between local skills and Hub.\n- **platform_action**(platform, action, payload, backend_kind) [capability_expand category: gateway]: Execute an exact registered business action on an external platform through LeapFlow's App Connector layer. Actions must be copied from the App Connector Capability Index and are addressed as domain.operation, e.g. im.send_message or docs.create_markdown. All business fields (chat_id, text, query, etc.) MUST be placed inside `payload`, never at the top level. Example: {\"platform\":\"feishu\",\"action\":\"im.send_message\",\"payload\":{\"chat_id\":\"oc_xxx\",\"text\":\"hello\"}}. Do not invent action names, do not use management actions such as list/guide/connect/status here.\n- **platform_connect**(action, platform, credentials, options, checkpoint) [capability_expand category: gateway]: List, guide, connect, disconnect, remove, or check status for external platforms using the App Connector management namespace. Supports REST and CLI backends. Use this for management actions such as list/guide/preflight/connect/status; use platform_action only for exact registered business actions.\n- **gateway_send**(platform, chat_id, text, thread_id) [capability_expand category: gateway]: Send a message to a connected external platform (Feishu group, Telegram chat, DingTalk conversation, etc.). Requires the platform to be connected via gateway_connect first. Use gateway_connect with action='list' to see connected platforms and available chat IDs.\n- **gateway_connect**(action, platform, credentials, options) [capability_expand category: gateway]: Connect, configure, or manage external platform integrations (Feishu, DingTalk, Telegram, Slack, Discord, etc.). Conversational flow: 1) call 'guide' to get setup steps + required fields, 2) present the steps to the user and ask for ALL required credentials in a single message, 3) call 'connect' with the credentials. Goal: complete in 1–2 user turns. NEVER include credential values in your text response.\n- **plugin_list**(): List the live plugin registry and cross-subsystem capability evidence. Use this before answering questions about whether LeapFlow supports plugins, self-evolution, plugin installation, hot reload, versioning, or other runtime capabilities.\n- **plugin_status**(plugin_id): Get detailed status of a specific plugin: its declared category, runtime dependencies, contributed tools, and fiber lifecycle state.\n- **plugin_versions**(plugin_id): List recorded source versions and active pointer for a profile-scoped plugin.\n- **plugin_propose**(requested_capability, plugin_id, proposed_tools, test_cases, risk_level, evidence): Create a side-effect-free PluginProposal from explicit capability-gap evidence. Use this before plugin_generate when a missing capability should be reviewed. Does not call an LLM, write files, or install anything.\n- **assess_compatibility**(manifest, source_path) [capability_expand category: plugin_management]: Assess whether a foreign plugin manifest or real DSH source bundle is compatible with LeapFlow. A source bundle assessment is static: runtime_ready stays false until restricted Node discovery during plugin_install. Returns component-level verdicts and limitations.\n- **plugin_generate**(plugin_id, description, proposal_id) [capability_expand category: system]: Generate a new ToolPlugin from a natural-language capability description. The LLM produces code that conforms to the ToolPlugin Protocol; it is then rigorously validated (syntax, structure, import, protocol conformance). The isolated sandbox smoke test runs later, at install-time. Returns the validated code but DOES NOT install it — installation is a separate approval-gated step via plugin_install.\n- **plugin_install**(plugin_id, code, marketplace_name, source_path, proposal_id, version_label) [capability_expand category: system]: Install a plugin from exactly one source: validated Python code, the configured Python marketplace, or a real DSH source bundle. DSH bundles run restricted Node discovery before registration; only public host tools are installed and client UI limitations are reported. Writes profile-scoped state and mutates the process-global registry. REQUIRES APPROVAL.\n- **plugin_rollback**(plugin_id, version) [capability_expand category: system]: Rollback a profile-scoped plugin to a recorded source version and reload it. REQUIRES APPROVAL.\n- **plugin_reload**(plugin_id, version_label) [capability_expand category: system]: Hot-reload a plugin at runtime. Disposes the old plugin fiber, re-imports its module, and registers a fresh instance. Existing in-flight turns are unaffected (snapshot isolation). REQUIRES APPROVAL — this is a self-modification action.\n- **plugin_disable**(plugin_id) [capability_expand category: system]: Disable a plugin by disposing its fiber, removing its tools from the runtime registry. Cannot disable self_management itself. REQUIRES APPROVAL — this is a self-modification action.\n- **plugin_remove**(plugin_id, delete_source) [capability_expand category: system]: Terminally remove a plugin: dispose its fiber, unregister its tools, remove reload metadata, and optionally delete its profile-scoped source file. Cannot remove self_management itself. REQUIRES APPROVAL.\n- **plugin_enable**(plugin_id) [capability_expand category: system]: Re-enable a previously disabled plugin by reloading its module and registering a fresh instance. REQUIRES APPROVAL.\n- **session_search**(query, limit) [capability_expand category: unclassified]: Search past conversation sessions in the current workspace for relevant context.\n- **session_list**(limit): List recent conversation sessions in the current workspace with stable ids, titles, dates, and summaries. Use for browsing past tasks or when user asks to see history without specific search terms. No keywords needed — returns chronological list.\n- **session_detail**(session_id, limit, offset, include_inactive) [capability_expand category: unclassified]: Read a paginated persisted transcript for one past conversation session in the current workspace. Use after session_list or session_search returns a session_id.\n- **json_pretty_loop_e2e**(text): Pretty-print JSON for the adaptive closed-loop journey.\n- **click**(element_index) [capability_expand category: desktop]: Click a UI element by its element_index (from the latest observe_ui snapshot)\n- **get_clipboard**() [capability_expand category: desktop]: Read current clipboard text content\n- **list_apps**(filter, running_only) [capability_expand category: desktop]: List available applications on this system. Use to discover correct bundle_id before switch_app.\n- **list_windows**() [capability_expand category: desktop]: List all top-level windows with pid, window_id, title, and per-window state (minimized, on-screen). Call this first to pick the pid and window_id that observe_ui and other window tools require.\n- **observe_ui**(pid, window_id, query) [capability_expand category: desktop]: Snapshot one window's actionable UI elements, each tagged with an element_index for click/right_click/read_text. Re-observe after actions — indices belong to one snapshot. Requires the window's pid and window_id from list_windows.\n- **open_url**(url, app_id) [capability_expand category: desktop]: Open a URL in the default or specified browser\n- **read_text**(element_index) [capability_expand category: desktop]: Read the text content of a specific UI element from the latest snapshot\n- **right_click**(element_index) [capability_expand category: desktop]: Right-click a UI element to open its context menu. Returns visible menu items.\n- **screenshot**(pid, window_id) [capability_expand category: desktop]: Capture a screenshot for visual verification. With pid + window_id captures that window (works across all displays); defaults to the last observed window, or the full desktop when no window has been observed.\n- **scroll**(element_index, direction, amount, pid, window_id) [capability_expand category: desktop]: Scroll a scrollable area of a window. Omit element_index to scroll the window's focused/page scroller; pass one to scroll an exact element from the latest snapshot.\n- **select_text**(element_index) [capability_expand category: desktop]: Select all text in a UI element (focus + select-all, for subsequent copy)\n- **set_clipboard**(text) [capability_expand category: desktop]: Write text to the clipboard\n- **shortcut**(keys) [capability_expand category: desktop]: Execute a keyboard shortcut\n- **switch_app**(app_id) [capability_expand category: desktop]: Switch to an app (launch if needed, activate, verify)\n- **type_text**(text) [capability_expand category: desktop]: Type text into the currently focused element\n- **wait**(seconds) [capability_expand category: desktop]: Wait for a specified duration before continuing\n- **wait_until**(condition, pid, window_id, timeout, poll_interval) [capability_expand category: desktop]: Wait until a UI condition is met (polls UI tree). Returns elements when found or on timeout.\n- **wait_until_stable**(timeout, poll_interval, pid, window_id) [capability_expand category: desktop]: Wait until the UI stops changing (element set stabilizes across polls).\n\n## App Connector Capability Index\nLeapFlow can onboard and manage external apps through `platform_connect` and execute exact registered business actions through `platform_action`.\nFor requests about connecting, setting up, configuring, enabling, or managing a supported app, use `platform_connect` first instead of generating SDK/Webhook sample code.\n`platform_connect.action` is the management namespace: list, guide, preflight, connect, disconnect, remove, status, events_start, events_stop, events_status.\n`platform_action.action` is only for exact registered platform business actions listed below, such as `im.send_message`; never use management actions like `list` or `guide` there.\nAll business fields MUST go inside `payload`; top-level keys are only `platform`, `action`, and `payload`.\nDo not invent platform IDs or platform action names. If the needed action is not listed, ask for discovery/clarification instead of guessing.\nUse `platform_connect` with `action='guide'` and the matching `platform` to start onboarding; use `action='list'` when the app is unclear.\nWhen a pending onboarding state is present, continue from that state with `platform_connect` instead of asking the user to restate the app.\nSupported apps:\n- `api_server`: API Server (OpenAI Compatible) (category=api; backend=adapter; platform_action actions=none registered)\n- `dingtalk`: 钉钉 (DingTalk) (category=im; backend=adapter; platform_action actions=none registered)\n- `feishu`: 飞书 (Feishu/Lark) (category=im; backend=cli)\n - `calendar.create_event` payload={summary*, start_time*, end_time*, attendees} [write/high]\n - `docs.create_markdown` payload={title*, markdown*, folder_token} [write/medium]\n - `drive.search` payload={query*, limit} [read/medium]\n - `im.add_reaction` payload={message_id*, emoji_type*} [write/low]\n - `im.download_resource` payload={message_id*, file_key*, type*} [read/low]\n - `im.get_messages` payload={message_ids*} [read/low]\n - `im.list_chats` payload={page_size} [read/low]\n - `im.list_messages` payload={chat_id*} [read/low]\n - `im.list_thread_messages` payload={thread*} [read/low]\n - `im.remove_reaction` payload={message_id*, emoji_type*} [write/low]\n - `im.reply_message` payload={message_id*, text*} [send/high]\n - `im.search_chats` payload={query*, page_size} [read/low]\n - `im.search_messages` payload={query*} [read/medium]\n - `im.send_message` payload={chat_id*, text*, thread_id} [send/high]\n - `im.update_card` payload={token*, card*} [write/medium]\n - `im.update_message` payload={message_id*, text*} [write/medium]\n - `mail.search_unread` payload={query, limit} [read/high]\n - `sheets.append_row` payload={spreadsheet_token*, sheet_id*, values*} [write/medium]\n - `task.create` payload={title*, description, due_time} [write/medium]\n- `telegram`: Telegram (category=social; backend=adapter; platform_action actions=none registered)\n- `webhook`: Webhook (Generic) (category=webhook; backend=adapter; platform_action actions=none registered)\n\n## Tool Usage\nTools are normally invoked through the native function-calling mechanism, not by writing JSON in your reply\ntext. Only if the provider signals that native function calling is unavailable for this turn, fall back to a\nsingle JSON code block: `{\"name\": \"tool_name\", \"arguments\": {\"key\": \"value\"}}` — use this fallback format\nonly, never both. Only call a tool whose exact name appears in the tool index above and is currently callable\n(or reachable via `capability_expand`). Never invent, rename, alias, or guess a tool name, platform ID, or\nplatform action from argument shape or wording — if the index does not list it, it does not exist.\n`platform_connect.action` (list/guide/preflight/connect/disconnect/remove/status/events_start/events_stop/\nevents_status) is the App Connector management namespace; `platform_action.action` only accepts exact\nregistered business actions such as `im.send_message` shown in the App Connector Capability Index — never mix\nthe two namespaces. If a tool call returns an unknown/unavailable result, use the returned suggestions or\navailable names for a single retry instead of trying further variations of the same guess.\n\n**Side-effect action rule** (`platform_action` with effect=send/write/execute):\n- Call each unique action+payload **exactly once**. Never include duplicates in the same turn.\n- Once the result returns `\"completed\": true`, that action is DONE for this task. Do NOT call it again\n in any subsequent turn — immediately summarize the result for the user instead.\n- The system enforces idempotency: duplicate calls are blocked and will not execute.\n- If the user explicitly requests sending/writing multiple times, use distinct payloads per call.\n\n**Resource identifier provenance rule**:\n- NEVER fabricate, guess, or infer resource identifiers (chat_id, message_id, file_key, user_id, etc.).\n Every resource ID used in a side-effect action MUST come from a successful API response in this session.\n- If a read/list action fails (e.g. authorization error), you do NOT have valid resource IDs.\n Report the failure to the user — do NOT attempt the dependent write/send action with a guessed ID.\n- When a tool result contains `\"llm_instruction\"`, follow it exactly.\n\n## Guidelines\n1. **Direct answers first**: If you already know the answer, respond directly without tools.\n2. **Avoid redundant tool calls**: Do not call the same tool with the same arguments more than once in the same user turn. When an existing tool result already answers the user's request, stop calling tools and answer directly.\n3. **Use tools proactively**: When the user asks about files, time, system state, or needs actions performed, use the appropriate tool.\n4. **Chain tools when needed**: You can call multiple tools in sequence (e.g., list files → read file → summarize).\n5. **Handle failures gracefully**: If a tool fails, explain what went wrong and suggest alternatives. If it failed because of an unknown tool/platform/action name, retry once with an exact name from the returned suggestions, then explain rather than keep guessing.\n6. **Summarize results naturally**: After tool execution, synthesize the results into a helpful answer rather than dumping raw output.\n7. **Stay conversational**: Maintain a natural, helpful tone. Acknowledge context from earlier in the conversation.\n8. **Recall past work**:\n - Broad queries (\"之前做了什么\", \"列出任务\"): answer from the \"Recent Task History\" section already in your context. If insufficient, call session_list.\n - Specific lookups (\"上次那个配置怎么改的\"): call session_search with relevant phrases (NOT single characters).\n - Do NOT call search tools repeatedly with keyword fragments. One well-phrased call is better than ten fragmented ones.\n\n## Coding & Verification\nWhen working with code, prefer the precise built-in tools over ad-hoc shell: use `repo_map` to orient in an\nunfamiliar project, `code_search`/`file_find` to locate and `code_intel` for symbols, `edit_file` (anchored\nsearch-replace, or a unified `diff`) to change files — never rewrite a whole file to change a few lines — and\n`git_query` to inspect diffs/log. After edits, check `syntax_ok` in the result and run `test_run`/`lint_check`\nbefore declaring the work done. Batch independent read-only calls (search/read/query) into a single turn — they\nrun in parallel.\n\n## Presentation Style\n1. **Polished Markdown only**: Format user-facing answers with clean Markdown headings, short paragraphs, and concise bullets. Use tables only when they improve comparison or scanning.\n2. **Terminal-friendly layout**: Keep lines readable in a TUI; avoid dense walls of text, deeply nested lists, oversized ASCII art, or heavy visual blocks.\n3. **Elegant emphasis**: Use bold text sparingly for key terms and conclusions. Avoid excessive emojis, decorative symbols, repeated separators, or visual noise.\n4. **Theme-safe colors**: Do not emit ANSI escape codes, HTML color tags, Rich markup, or hardcoded color names. Rely on the TUI theme to render Markdown professionally.\n5. **No leaked tool protocol**: Never show tool-call JSON, internal schemas, raw observations, tool result payloads, or hidden reasoning in the final answer unless the user explicitly asks for raw/debug output. Treat any prior `{\"name\": ..., \"arguments\": ...}` blocks and `Tool result (...)` messages as internal execution context only.\n6. **Professional closure**: End with a concise conclusion or next step when helpful; avoid rambling after the useful answer is complete.\n\nWhen finished with all tool calls, respond normally without a JSON block, tool-call transcript, or process log.\n\n\n## Recent Session Summary\n- [assistant] Installed json pretty plugin.\n- [user] Use the json_pretty_loop_e2e tool to format the sample JSON.\n\n## Task Contract\n- Task ID: turn-3\n- Original user request: Use the json_pretty_loop_e2e tool to format the sample JSON.\n- Workspace root: \n- Allowed roots: \n- Treat relative project paths as relative to the workspace root; never infer `.` as the project root when a workspace root is provided.\n- Workspace boundary is enforced by tools: do not read, search, edit, or run commands against paths outside the allowed roots unless the user explicitly requests an external path and the tool/approval policy permits it.\n- LeapFlow workspace config is optional at `/.leapflow/config.yaml`; runtime config is loaded from `~/.leapflow/config/user.yaml` and `~/.leapflow/profiles//config/*.yaml`.\n- Preserve this task contract across summarization, compression, tool loops, and memory retrieval.\n" + }, + { + "role": "assistant", + "content": "Installed json pretty plugin." + }, + { + "role": "user", + "content": "Use the json_pretty_loop_e2e tool to format the sample JSON.\nUse the json_pretty_loop_e2e tool to format the sample JSON." + } + ], + "tools": [ + "capability_expand", + "code_intel", + "code_search", + "config_get", + "config_list", + "env_info", + "file_find", + "file_list", + "file_read", + "git_query", + "json_pretty_loop_e2e", + "lint_check", + "memory_search", + "plugin_list", + "plugin_propose", + "plugin_status", + "plugin_versions", + "repo_map", + "research_note", + "schedule_reentry", + "session_list", + "skill_view", + "skills_list", + "terminal_list", + "terminal_read", + "test_run", + "text_search", + "time_get", + "web_fetch" + ] + }, + "responses": [ + { + "status": 200, + "content_type": "application/json", + "body": "{\"id\": \"chatcmpl-cassette\", \"object\": \"chat.completion\", \"model\": \"cassette-model\", \"choices\": [{\"index\": 0, \"message\": {\"role\": \"assistant\", \"content\": \"\", \"tool_calls\": [{\"id\": \"call_1\", \"type\": \"function\", \"function\": {\"name\": \"json_pretty_loop_e2e\", \"arguments\": \"{\\\"text\\\": \\\"{\\\\\\\"b\\\\\\\":2,\\\\\\\"a\\\\\\\":1}\\\"}\"}}]}, \"finish_reason\": \"tool_calls\"}], \"usage\": {\"prompt_tokens\": 64, \"completion_tokens\": 16, \"total_tokens\": 80}}" + } + ] +} diff --git a/tests/_fixtures/cassettes/r7_adaptive_plugin_loop/cassette-model-ac1f5809de3359f8.cassette.json b/tests/_fixtures/cassettes/r7_adaptive_plugin_loop/cassette-model-ac1f5809de3359f8.cassette.json new file mode 100644 index 0000000..23ee1b8 --- /dev/null +++ b/tests/_fixtures/cassettes/r7_adaptive_plugin_loop/cassette-model-ac1f5809de3359f8.cassette.json @@ -0,0 +1,75 @@ +{ + "fingerprint": "ac1f5809de3359f862ebdc6d24e3ba34e1070c7d7958dfc5f2da7ae55e5deed8", + "note": "captured in seed mode", + "request": { + "model": "cassette-model", + "stream": false, + "messages": [ + { + "role": "system", + "content": "You are LeapFlow, an intelligent assistant that can both converse naturally and take real actions on the user's computer.\n\n## Capabilities\nThe tool index below lists **every** registered tool by name and a one-line summary — this is the complete\ncapability contract; nothing else exists. Only a subset is directly callable this turn (via native tool calling,\nnot a JSON block in your reply). If you need a tool from the index that is not yet callable, call\n`capability_expand` with its category name first — the matching tools become callable immediately after.\nWhen the user asks what LeapFlow itself supports, whether it supports plugins, or which runtime capabilities\nare available, use the live capability evidence exposed by `plugin_list` before making capability claims; report\nconfiguration-dependent or unavailable capabilities as limitations instead of inferring from documentation.\n- **text_search**(text, pattern): Search for a regex pattern in text.\n- **text_replace**(text, old, new, count) [capability_expand category: write]: Replace occurrences of a substring in text.\n- **time_get**(): Get current date and time.\n- **env_info**(): Get system environment information (OS, Python version, cwd).\n- **skills_list**(query, category, source): List available learned skills. Use when user asks about capabilities or you need a specific skill.\n- **skill_view**(name): View the full content of a specific skill document.\n- **code_intel**(path, operation): Precise document symbols (outline) for a source file: classes, functions, and methods with line ranges. Python uses an exact AST parse; other languages use a keyword-prefix scan. Prefer over file_read mode=symbols for accurate navigation before editing. Read-only.\n- **repo_map**(path): Compact project orientation for a repository root: languages, detected test/lint commands, top-level structure, entry points, manifest, and VCS branch. Call this first when entering an unfamiliar codebase. Read-only.\n- **scm_sync**(action, cwd, remote, pull_ref, push_ref, timeout) [capability_expand category: scm]: Run a typed git SCM action. Use this instead of shell_run for git pull/push/status. For 'pull origin main then push', set action='pull_then_push', remote='origin', pull_ref='main', and omit push_ref so LeapFlow pushes the current local branch.\n- **git_query**(action, cwd, ref, path, staged, max_count, stat): Read-only structured git inspection: action=diff|log|status|branch|show. Prefer over shell_run for reading repo state — output is clipped, redacted, and log/branch are parsed into structured fields. Use scm_sync for pull/push.\n- **git_write**(action, cwd, message, stage_all, name, ref, create) [capability_expand category: scm]: Mutating git actions: action=commit (message, stage_all), branch (create+switch), checkout (switch; create=true for -b). Approval-gated. Use scm_sync for pull/push and git_query for reads.\n- **test_run**(command, cwd, timeout): Run the project's test suite and return structured results (framework, passed/failed counts, failing tests). Auto-detects the runner (pytest/npm/go/cargo) or uses a configured/explicit command; executes via the governed shell. ok=true means the runner executed — see 'success' for pass/fail.\n- **lint_check**(command, cwd, timeout): Run the project's linter and return a structured clean/issue result. Auto-detects the linter (ruff/eslint/go vet/clippy) or uses a configured/explicit command; executes via the governed shell. ok=true means the linter ran — see 'clean'.\n- **file_list**(path, pattern, depth): List files and directories at a given path. Use depth=1 or depth=2 to get a recursive tree in one call instead of listing each sub-directory separately.\n- **file_read**(path, max_lines, start_line, max_chars, mode): Read text file content with adaptive context governance. For large or unfamiliar files, prefer mode='outline' or mode='symbols' first, then use mode='raw' with start_line/max_lines for the specific range you actually need. For LeapFlow's own settings, use config_list / config_get / config_set — its config files are outside the workspace and not readable here.\n- **file_write**(path, content, mode) [capability_expand category: write]: Write content to a file (overwrite or append).\n- **code_search**(pattern, patterns, path, glob, ignore_case, multiline, max_results, context_lines): Search file CONTENTS by regex pattern across a directory tree (ripgrep-backed). Requires a regex pattern. NOT for listing or browsing directory contents — use file_list for that. Prefer this over shell_run grep: faster, skips VCS/dependency/build dirs, and returns structured path:line:column matches. Batch related lookups into ONE call via `patterns` (OR-combined, single pass) instead of issuing several separate searches. Use file_read for the surrounding context of a hit.\n- **file_find**(glob, path, max_results): Find files by a recursive glob pattern under a base path (e.g. '**/test_*.py' or '*.md'). Prefer this over shell_run find; skips VCS/dependency/build dirs.\n- **edit_file**(path, edits, dry_run, diff) [capability_expand category: write]: Apply targeted, anchored search-replace edits to an EXISTING text file (use file_write to create/overwrite). Each edit is {original_text, new_text, replace_all?}; original_text must match exactly and uniquely (or set replace_all) — a non-unique or missing anchor is rejected so files are never corrupted. Set dry_run to preview. Alternatively pass a unified 'diff' to apply its hunks as anchored edits. Far cheaper and safer than rewriting a whole file.\n- **shell_run**(command, cwd, timeout) [capability_expand category: shell]: Execute a one-shot shell command with timeout protection. Runs in the active workspace; paths resolving outside it are refused. Reach for a structured tool first when one fits — web_fetch for anything over HTTP(S), git_query/scm_sync for git, code_search/file_find/file_read for the repo, config_get/config_set for LeapFlow's own settings — because those report typed results, while a failed shell command can only be diagnosed from its exit code and stderr. Every shell run counts as an external side effect, so a failure stops the rest of the batch and is not retried automatically.\n- **terminal_open**(command, cwd, shell) [capability_expand category: terminal]: Open a PERSISTENT shell session (REPL/dev server/watch), returning a session_id for terminal_send/read/close. Disabled unless tools.terminal_session_enabled is set. For one-shot commands use shell_run instead.\n- **terminal_send**(session_id, input, wait) [capability_expand category: terminal]: Send a line of input to a persistent terminal session and return output captured shortly after.\n- **terminal_read**(session_id, wait): Drain buffered output from a persistent terminal session (optionally waiting briefly first).\n- **terminal_close**(session_id) [capability_expand category: terminal]: Terminate a persistent terminal session and release its process group.\n- **terminal_list**(): List active persistent terminal sessions.\n- **config_list**(category, limit): List LeapFlow's own writable settings (model, provider, daemon, memory, perception, gateway, …) with current values. Use this to discover the exact key before changing anything. Optionally narrow by `category`. This is the only correct way to inspect LeapFlow configuration — never read config files from disk.\n- **config_get**(key): Read one LeapFlow setting by key (e.g. 'llm.model', 'daemon.log_level'), returning its current value, type, scopes, and whether a change needs a daemon restart. Never read LeapFlow config files from disk — use this.\n- **config_set**(key, value, scope) [capability_expand category: config]: Change one LeapFlow setting by key, e.g. switch the model with key='llm.model'. Values are validated and coerced; credentials are stored in the vault automatically. Call config_list or config_get first if unsure of the key. The result states whether a `leap daemon restart` is required. Never edit LeapFlow config files directly.\n- **web_fetch**(url, select, timeout, max_bytes): Read a URL over HTTP(S) and get back extracted, context-sized content: parsed JSON for API endpoints, readable text plus links for web pages. Use this for anything on the internet — prices, docs, releases, articles — instead of running curl through shell_run: it reports real HTTP status codes, retries rate limits on its own, and is a plain read so a retry is always safe. For JSON APIs pass `select` with a dotted path (e.g. 'chart.result.0.meta') to return just that part instead of the whole payload.\n- **memory_search**(query, limit): Search agent memory for relevant past experiences, observations, and facts.\n- **memory_add**(content, kind) [capability_expand category: write]: Store a new observation or insight in memory for future reference.\n- **research_note**(kind, text): Record a compact, structured note about the current task's state so it survives context compression on long / multi-step tasks. Use for durable findings, open questions still to resolve, decisions / excluded paths, and the immediate next step. One concise sentence per note.\n- **capability_expand**(category): Fetch the full callable schema for every tool in a capability category (e.g. 'hub', 'gateway', 'desktop', '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.\n- **delegate_task**(goal, context) [capability_expand category: delegate]: Delegate a complex sub-task to an isolated subagent. The subagent gets a fresh context and restricted tool access. Use when a task is self-contained and can be solved independently.\n- **schedule_reentry**(kind, reason, delay_seconds, event_match, max_reentries, deadline_seconds): Register a re-entry so this task can resume later from its current orientation (findings / open questions / next step). Use when work must pause and continue after a delay (kind=time) or when a matching platform event arrives (kind=event), instead of finishing now. The research-ledger state is carried over automatically.\n- **hub_push**(skill_name, visibility, version) [capability_expand category: hub]: Push a local skill to the ModelScope Hub for sharing or backup.\n- **hub_pull**(repo_id, version) [capability_expand category: hub]: Pull a skill from the ModelScope Hub to install locally.\n- **hub_search**(query) [capability_expand category: hub]: Search for skills on the Hub by keyword or description.\n- **hub_sync**(mode, dry_run) [capability_expand category: hub]: Preview or execute sync between local skills and Hub.\n- **platform_action**(platform, action, payload, backend_kind) [capability_expand category: gateway]: Execute an exact registered business action on an external platform through LeapFlow's App Connector layer. Actions must be copied from the App Connector Capability Index and are addressed as domain.operation, e.g. im.send_message or docs.create_markdown. All business fields (chat_id, text, query, etc.) MUST be placed inside `payload`, never at the top level. Example: {\"platform\":\"feishu\",\"action\":\"im.send_message\",\"payload\":{\"chat_id\":\"oc_xxx\",\"text\":\"hello\"}}. Do not invent action names, do not use management actions such as list/guide/connect/status here.\n- **platform_connect**(action, platform, credentials, options, checkpoint) [capability_expand category: gateway]: List, guide, connect, disconnect, remove, or check status for external platforms using the App Connector management namespace. Supports REST and CLI backends. Use this for management actions such as list/guide/preflight/connect/status; use platform_action only for exact registered business actions.\n- **gateway_send**(platform, chat_id, text, thread_id) [capability_expand category: gateway]: Send a message to a connected external platform (Feishu group, Telegram chat, DingTalk conversation, etc.). Requires the platform to be connected via gateway_connect first. Use gateway_connect with action='list' to see connected platforms and available chat IDs.\n- **gateway_connect**(action, platform, credentials, options) [capability_expand category: gateway]: Connect, configure, or manage external platform integrations (Feishu, DingTalk, Telegram, Slack, Discord, etc.). Conversational flow: 1) call 'guide' to get setup steps + required fields, 2) present the steps to the user and ask for ALL required credentials in a single message, 3) call 'connect' with the credentials. Goal: complete in 1–2 user turns. NEVER include credential values in your text response.\n- **plugin_list**(): List the live plugin registry and cross-subsystem capability evidence. Use this before answering questions about whether LeapFlow supports plugins, self-evolution, plugin installation, hot reload, versioning, or other runtime capabilities.\n- **plugin_status**(plugin_id): Get detailed status of a specific plugin: its declared category, runtime dependencies, contributed tools, and fiber lifecycle state.\n- **plugin_versions**(plugin_id): List recorded source versions and active pointer for a profile-scoped plugin.\n- **plugin_propose**(requested_capability, plugin_id, proposed_tools, test_cases, risk_level, evidence): Create a side-effect-free PluginProposal from explicit capability-gap evidence. Use this before plugin_generate when a missing capability should be reviewed. Does not call an LLM, write files, or install anything.\n- **assess_compatibility**(manifest, source_path) [capability_expand category: plugin_management]: Assess whether a foreign plugin manifest or real DSH source bundle is compatible with LeapFlow. A source bundle assessment is static: runtime_ready stays false until restricted Node discovery during plugin_install. Returns component-level verdicts and limitations.\n- **plugin_generate**(plugin_id, description, proposal_id) [capability_expand category: system]: Generate a new ToolPlugin from a natural-language capability description. The LLM produces code that conforms to the ToolPlugin Protocol; it is then rigorously validated (syntax, structure, import, protocol conformance). The isolated sandbox smoke test runs later, at install-time. Returns the validated code but DOES NOT install it — installation is a separate approval-gated step via plugin_install.\n- **plugin_install**(plugin_id, code, marketplace_name, source_path, proposal_id, version_label) [capability_expand category: system]: Install a plugin from exactly one source: validated Python code, the configured Python marketplace, or a real DSH source bundle. DSH bundles run restricted Node discovery before registration; only public host tools are installed and client UI limitations are reported. Writes profile-scoped state and mutates the process-global registry. REQUIRES APPROVAL.\n- **plugin_rollback**(plugin_id, version) [capability_expand category: system]: Rollback a profile-scoped plugin to a recorded source version and reload it. REQUIRES APPROVAL.\n- **plugin_reload**(plugin_id, version_label) [capability_expand category: system]: Hot-reload a plugin at runtime. Disposes the old plugin fiber, re-imports its module, and registers a fresh instance. Existing in-flight turns are unaffected (snapshot isolation). REQUIRES APPROVAL — this is a self-modification action.\n- **plugin_disable**(plugin_id) [capability_expand category: system]: Disable a plugin by disposing its fiber, removing its tools from the runtime registry. Cannot disable self_management itself. REQUIRES APPROVAL — this is a self-modification action.\n- **plugin_remove**(plugin_id, delete_source) [capability_expand category: system]: Terminally remove a plugin: dispose its fiber, unregister its tools, remove reload metadata, and optionally delete its profile-scoped source file. Cannot remove self_management itself. REQUIRES APPROVAL.\n- **plugin_enable**(plugin_id) [capability_expand category: system]: Re-enable a previously disabled plugin by reloading its module and registering a fresh instance. REQUIRES APPROVAL.\n- **session_search**(query, limit) [capability_expand category: unclassified]: Search past conversation sessions in the current workspace for relevant context.\n- **session_list**(limit): List recent conversation sessions in the current workspace with stable ids, titles, dates, and summaries. Use for browsing past tasks or when user asks to see history without specific search terms. No keywords needed — returns chronological list.\n- **session_detail**(session_id, limit, offset, include_inactive) [capability_expand category: unclassified]: Read a paginated persisted transcript for one past conversation session in the current workspace. Use after session_list or session_search returns a session_id.\n- **click**(element_index) [capability_expand category: desktop]: Click a UI element by its element_index (from the latest observe_ui snapshot)\n- **get_clipboard**() [capability_expand category: desktop]: Read current clipboard text content\n- **list_apps**(filter, running_only) [capability_expand category: desktop]: List available applications on this system. Use to discover correct bundle_id before switch_app.\n- **list_windows**() [capability_expand category: desktop]: List all top-level windows with pid, window_id, title, and per-window state (minimized, on-screen). Call this first to pick the pid and window_id that observe_ui and other window tools require.\n- **observe_ui**(pid, window_id, query) [capability_expand category: desktop]: Snapshot one window's actionable UI elements, each tagged with an element_index for click/right_click/read_text. Re-observe after actions — indices belong to one snapshot. Requires the window's pid and window_id from list_windows.\n- **open_url**(url, app_id) [capability_expand category: desktop]: Open a URL in the default or specified browser\n- **read_text**(element_index) [capability_expand category: desktop]: Read the text content of a specific UI element from the latest snapshot\n- **right_click**(element_index) [capability_expand category: desktop]: Right-click a UI element to open its context menu. Returns visible menu items.\n- **screenshot**(pid, window_id) [capability_expand category: desktop]: Capture a screenshot for visual verification. With pid + window_id captures that window (works across all displays); defaults to the last observed window, or the full desktop when no window has been observed.\n- **scroll**(element_index, direction, amount, pid, window_id) [capability_expand category: desktop]: Scroll a scrollable area of a window. Omit element_index to scroll the window's focused/page scroller; pass one to scroll an exact element from the latest snapshot.\n- **select_text**(element_index) [capability_expand category: desktop]: Select all text in a UI element (focus + select-all, for subsequent copy)\n- **set_clipboard**(text) [capability_expand category: desktop]: Write text to the clipboard\n- **shortcut**(keys) [capability_expand category: desktop]: Execute a keyboard shortcut\n- **switch_app**(app_id) [capability_expand category: desktop]: Switch to an app (launch if needed, activate, verify)\n- **type_text**(text) [capability_expand category: desktop]: Type text into the currently focused element\n- **wait**(seconds) [capability_expand category: desktop]: Wait for a specified duration before continuing\n- **wait_until**(condition, pid, window_id, timeout, poll_interval) [capability_expand category: desktop]: Wait until a UI condition is met (polls UI tree). Returns elements when found or on timeout.\n- **wait_until_stable**(timeout, poll_interval, pid, window_id) [capability_expand category: desktop]: Wait until the UI stops changing (element set stabilizes across polls).\n\n## App Connector Capability Index\nLeapFlow can onboard and manage external apps through `platform_connect` and execute exact registered business actions through `platform_action`.\nFor requests about connecting, setting up, configuring, enabling, or managing a supported app, use `platform_connect` first instead of generating SDK/Webhook sample code.\n`platform_connect.action` is the management namespace: list, guide, preflight, connect, disconnect, remove, status, events_start, events_stop, events_status.\n`platform_action.action` is only for exact registered platform business actions listed below, such as `im.send_message`; never use management actions like `list` or `guide` there.\nAll business fields MUST go inside `payload`; top-level keys are only `platform`, `action`, and `payload`.\nDo not invent platform IDs or platform action names. If the needed action is not listed, ask for discovery/clarification instead of guessing.\nUse `platform_connect` with `action='guide'` and the matching `platform` to start onboarding; use `action='list'` when the app is unclear.\nWhen a pending onboarding state is present, continue from that state with `platform_connect` instead of asking the user to restate the app.\nSupported apps:\n- `api_server`: API Server (OpenAI Compatible) (category=api; backend=adapter; platform_action actions=none registered)\n- `dingtalk`: 钉钉 (DingTalk) (category=im; backend=adapter; platform_action actions=none registered)\n- `feishu`: 飞书 (Feishu/Lark) (category=im; backend=cli)\n - `calendar.create_event` payload={summary*, start_time*, end_time*, attendees} [write/high]\n - `docs.create_markdown` payload={title*, markdown*, folder_token} [write/medium]\n - `drive.search` payload={query*, limit} [read/medium]\n - `im.add_reaction` payload={message_id*, emoji_type*} [write/low]\n - `im.download_resource` payload={message_id*, file_key*, type*} [read/low]\n - `im.get_messages` payload={message_ids*} [read/low]\n - `im.list_chats` payload={page_size} [read/low]\n - `im.list_messages` payload={chat_id*} [read/low]\n - `im.list_thread_messages` payload={thread*} [read/low]\n - `im.remove_reaction` payload={message_id*, emoji_type*} [write/low]\n - `im.reply_message` payload={message_id*, text*} [send/high]\n - `im.search_chats` payload={query*, page_size} [read/low]\n - `im.search_messages` payload={query*} [read/medium]\n - `im.send_message` payload={chat_id*, text*, thread_id} [send/high]\n - `im.update_card` payload={token*, card*} [write/medium]\n - `im.update_message` payload={message_id*, text*} [write/medium]\n - `mail.search_unread` payload={query, limit} [read/high]\n - `sheets.append_row` payload={spreadsheet_token*, sheet_id*, values*} [write/medium]\n - `task.create` payload={title*, description, due_time} [write/medium]\n- `telegram`: Telegram (category=social; backend=adapter; platform_action actions=none registered)\n- `webhook`: Webhook (Generic) (category=webhook; backend=adapter; platform_action actions=none registered)\n\n## Tool Usage\nTools are normally invoked through the native function-calling mechanism, not by writing JSON in your reply\ntext. Only if the provider signals that native function calling is unavailable for this turn, fall back to a\nsingle JSON code block: `{\"name\": \"tool_name\", \"arguments\": {\"key\": \"value\"}}` — use this fallback format\nonly, never both. Only call a tool whose exact name appears in the tool index above and is currently callable\n(or reachable via `capability_expand`). Never invent, rename, alias, or guess a tool name, platform ID, or\nplatform action from argument shape or wording — if the index does not list it, it does not exist.\n`platform_connect.action` (list/guide/preflight/connect/disconnect/remove/status/events_start/events_stop/\nevents_status) is the App Connector management namespace; `platform_action.action` only accepts exact\nregistered business actions such as `im.send_message` shown in the App Connector Capability Index — never mix\nthe two namespaces. If a tool call returns an unknown/unavailable result, use the returned suggestions or\navailable names for a single retry instead of trying further variations of the same guess.\n\n**Side-effect action rule** (`platform_action` with effect=send/write/execute):\n- Call each unique action+payload **exactly once**. Never include duplicates in the same turn.\n- Once the result returns `\"completed\": true`, that action is DONE for this task. Do NOT call it again\n in any subsequent turn — immediately summarize the result for the user instead.\n- The system enforces idempotency: duplicate calls are blocked and will not execute.\n- If the user explicitly requests sending/writing multiple times, use distinct payloads per call.\n\n**Resource identifier provenance rule**:\n- NEVER fabricate, guess, or infer resource identifiers (chat_id, message_id, file_key, user_id, etc.).\n Every resource ID used in a side-effect action MUST come from a successful API response in this session.\n- If a read/list action fails (e.g. authorization error), you do NOT have valid resource IDs.\n Report the failure to the user — do NOT attempt the dependent write/send action with a guessed ID.\n- When a tool result contains `\"llm_instruction\"`, follow it exactly.\n\n## Guidelines\n1. **Direct answers first**: If you already know the answer, respond directly without tools.\n2. **Avoid redundant tool calls**: Do not call the same tool with the same arguments more than once in the same user turn. When an existing tool result already answers the user's request, stop calling tools and answer directly.\n3. **Use tools proactively**: When the user asks about files, time, system state, or needs actions performed, use the appropriate tool.\n4. **Chain tools when needed**: You can call multiple tools in sequence (e.g., list files → read file → summarize).\n5. **Handle failures gracefully**: If a tool fails, explain what went wrong and suggest alternatives. If it failed because of an unknown tool/platform/action name, retry once with an exact name from the returned suggestions, then explain rather than keep guessing.\n6. **Summarize results naturally**: After tool execution, synthesize the results into a helpful answer rather than dumping raw output.\n7. **Stay conversational**: Maintain a natural, helpful tone. Acknowledge context from earlier in the conversation.\n8. **Recall past work**:\n - Broad queries (\"之前做了什么\", \"列出任务\"): answer from the \"Recent Task History\" section already in your context. If insufficient, call session_list.\n - Specific lookups (\"上次那个配置怎么改的\"): call session_search with relevant phrases (NOT single characters).\n - Do NOT call search tools repeatedly with keyword fragments. One well-phrased call is better than ten fragmented ones.\n\n## Coding & Verification\nWhen working with code, prefer the precise built-in tools over ad-hoc shell: use `repo_map` to orient in an\nunfamiliar project, `code_search`/`file_find` to locate and `code_intel` for symbols, `edit_file` (anchored\nsearch-replace, or a unified `diff`) to change files — never rewrite a whole file to change a few lines — and\n`git_query` to inspect diffs/log. After edits, check `syntax_ok` in the result and run `test_run`/`lint_check`\nbefore declaring the work done. Batch independent read-only calls (search/read/query) into a single turn — they\nrun in parallel.\n\n## Presentation Style\n1. **Polished Markdown only**: Format user-facing answers with clean Markdown headings, short paragraphs, and concise bullets. Use tables only when they improve comparison or scanning.\n2. **Terminal-friendly layout**: Keep lines readable in a TUI; avoid dense walls of text, deeply nested lists, oversized ASCII art, or heavy visual blocks.\n3. **Elegant emphasis**: Use bold text sparingly for key terms and conclusions. Avoid excessive emojis, decorative symbols, repeated separators, or visual noise.\n4. **Theme-safe colors**: Do not emit ANSI escape codes, HTML color tags, Rich markup, or hardcoded color names. Rely on the TUI theme to render Markdown professionally.\n5. **No leaked tool protocol**: Never show tool-call JSON, internal schemas, raw observations, tool result payloads, or hidden reasoning in the final answer unless the user explicitly asks for raw/debug output. Treat any prior `{\"name\": ..., \"arguments\": ...}` blocks and `Tool result (...)` messages as internal execution context only.\n6. **Professional closure**: End with a concise conclusion or next step when helpful; avoid rambling after the useful answer is complete.\n\nWhen finished with all tool calls, respond normally without a JSON block, tool-call transcript, or process log.\n\n\n## Recent Session Summary\n- [assistant] Observed the missing JSON pretty tool.\n- [user] Install the prepared adaptive JSON pretty plugin.\n\n## Task Contract\n- Task ID: turn-2\n- Original user request: Install the prepared adaptive JSON pretty plugin.\n- Workspace root: \n- Allowed roots: \n- Treat relative project paths as relative to the workspace root; never infer `.` as the project root when a workspace root is provided.\n- Workspace boundary is enforced by tools: do not read, search, edit, or run commands against paths outside the allowed roots unless the user explicitly requests an external path and the tool/approval policy permits it.\n- LeapFlow workspace config is optional at `/.leapflow/config.yaml`; runtime config is loaded from `~/.leapflow/config/user.yaml` and `~/.leapflow/profiles//config/*.yaml`.\n- Preserve this task contract across summarization, compression, tool loops, and memory retrieval.\n" + }, + { + "role": "assistant", + "content": "Observed the missing JSON pretty tool." + }, + { + "role": "user", + "content": "Install the prepared adaptive JSON pretty plugin.\nInstall the prepared adaptive JSON pretty plugin." + }, + { + "role": "assistant", + "content": "", + "tool_calls": [ + "plugin_install" + ] + }, + { + "role": "tool", + "content": "{\"ok\": true, \"action\": \"install\", \"plugin_id\": \"json_pretty_loop_e2e\", \"installed_tools\": [\"json_pretty_loop_e2e\"], \"state\": \"active\", \"version\": \"r7\", \"execution_id\": \"\", \"idempotency_key\": \"\", \"execution_policy\": \"mutating_once\", \"tool_call_id\": \"call_1\", \"execution_status\": \"completed\"}", + "tool_result": true + }, + { + "role": "assistant", + "content": "Operation interrupted. Continuing..." + } + ], + "tools": [ + "capability_expand", + "code_intel", + "code_search", + "config_get", + "config_list", + "env_info", + "file_find", + "file_list", + "file_read", + "git_query", + "lint_check", + "memory_search", + "plugin_list", + "plugin_propose", + "plugin_status", + "plugin_versions", + "repo_map", + "research_note", + "schedule_reentry", + "session_list", + "skill_view", + "skills_list", + "terminal_list", + "terminal_read", + "test_run", + "text_search", + "time_get", + "web_fetch" + ] + }, + "responses": [ + { + "status": 200, + "content_type": "application/json", + "body": "{\"id\": \"chatcmpl-cassette\", \"object\": \"chat.completion\", \"model\": \"cassette-model\", \"choices\": [{\"index\": 0, \"message\": {\"role\": \"assistant\", \"content\": \"Installed json pretty plugin.\"}, \"finish_reason\": \"stop\"}], \"usage\": {\"prompt_tokens\": 64, \"completion_tokens\": 16, \"total_tokens\": 80}}" + } + ] +} diff --git a/tests/_fixtures/cassettes/r7_adaptive_plugin_loop/cassette-model-c4c4b92a9a7b6aff.cassette.json b/tests/_fixtures/cassettes/r7_adaptive_plugin_loop/cassette-model-c4c4b92a9a7b6aff.cassette.json new file mode 100644 index 0000000..371c121 --- /dev/null +++ b/tests/_fixtures/cassettes/r7_adaptive_plugin_loop/cassette-model-c4c4b92a9a7b6aff.cassette.json @@ -0,0 +1,119 @@ +{ + "fingerprint": "c4c4b92a9a7b6aff9cf308ce85c4f8548a189fef4be7143b95921db437892f85", + "note": "captured in seed mode", + "request": { + "model": "cassette-model", + "stream": false, + "messages": [ + { + "role": "system", + "content": "You are LeapFlow, an intelligent assistant that can both converse naturally and take real actions on the user's computer.\n\n## Capabilities\nThe tool index below lists **every** registered tool by name and a one-line summary — this is the complete\ncapability contract; nothing else exists. Only a subset is directly callable this turn (via native tool calling,\nnot a JSON block in your reply). If you need a tool from the index that is not yet callable, call\n`capability_expand` with its category name first — the matching tools become callable immediately after.\nWhen the user asks what LeapFlow itself supports, whether it supports plugins, or which runtime capabilities\nare available, use the live capability evidence exposed by `plugin_list` before making capability claims; report\nconfiguration-dependent or unavailable capabilities as limitations instead of inferring from documentation.\n- **text_search**(text, pattern): Search for a regex pattern in text.\n- **text_replace**(text, old, new, count) [capability_expand category: write]: Replace occurrences of a substring in text.\n- **time_get**(): Get current date and time.\n- **env_info**(): Get system environment information (OS, Python version, cwd).\n- **skills_list**(query, category, source): List available learned skills. Use when user asks about capabilities or you need a specific skill.\n- **skill_view**(name): View the full content of a specific skill document.\n- **code_intel**(path, operation): Precise document symbols (outline) for a source file: classes, functions, and methods with line ranges. Python uses an exact AST parse; other languages use a keyword-prefix scan. Prefer over file_read mode=symbols for accurate navigation before editing. Read-only.\n- **repo_map**(path): Compact project orientation for a repository root: languages, detected test/lint commands, top-level structure, entry points, manifest, and VCS branch. Call this first when entering an unfamiliar codebase. Read-only.\n- **scm_sync**(action, cwd, remote, pull_ref, push_ref, timeout) [capability_expand category: scm]: Run a typed git SCM action. Use this instead of shell_run for git pull/push/status. For 'pull origin main then push', set action='pull_then_push', remote='origin', pull_ref='main', and omit push_ref so LeapFlow pushes the current local branch.\n- **git_query**(action, cwd, ref, path, staged, max_count, stat): Read-only structured git inspection: action=diff|log|status|branch|show. Prefer over shell_run for reading repo state — output is clipped, redacted, and log/branch are parsed into structured fields. Use scm_sync for pull/push.\n- **git_write**(action, cwd, message, stage_all, name, ref, create) [capability_expand category: scm]: Mutating git actions: action=commit (message, stage_all), branch (create+switch), checkout (switch; create=true for -b). Approval-gated. Use scm_sync for pull/push and git_query for reads.\n- **test_run**(command, cwd, timeout): Run the project's test suite and return structured results (framework, passed/failed counts, failing tests). Auto-detects the runner (pytest/npm/go/cargo) or uses a configured/explicit command; executes via the governed shell. ok=true means the runner executed — see 'success' for pass/fail.\n- **lint_check**(command, cwd, timeout): Run the project's linter and return a structured clean/issue result. Auto-detects the linter (ruff/eslint/go vet/clippy) or uses a configured/explicit command; executes via the governed shell. ok=true means the linter ran — see 'clean'.\n- **file_list**(path, pattern, depth): List files and directories at a given path. Use depth=1 or depth=2 to get a recursive tree in one call instead of listing each sub-directory separately.\n- **file_read**(path, max_lines, start_line, max_chars, mode): Read text file content with adaptive context governance. For large or unfamiliar files, prefer mode='outline' or mode='symbols' first, then use mode='raw' with start_line/max_lines for the specific range you actually need. For LeapFlow's own settings, use config_list / config_get / config_set — its config files are outside the workspace and not readable here.\n- **file_write**(path, content, mode) [capability_expand category: write]: Write content to a file (overwrite or append).\n- **code_search**(pattern, patterns, path, glob, ignore_case, multiline, max_results, context_lines): Search file CONTENTS by regex pattern across a directory tree (ripgrep-backed). Requires a regex pattern. NOT for listing or browsing directory contents — use file_list for that. Prefer this over shell_run grep: faster, skips VCS/dependency/build dirs, and returns structured path:line:column matches. Batch related lookups into ONE call via `patterns` (OR-combined, single pass) instead of issuing several separate searches. Use file_read for the surrounding context of a hit.\n- **file_find**(glob, path, max_results): Find files by a recursive glob pattern under a base path (e.g. '**/test_*.py' or '*.md'). Prefer this over shell_run find; skips VCS/dependency/build dirs.\n- **edit_file**(path, edits, dry_run, diff) [capability_expand category: write]: Apply targeted, anchored search-replace edits to an EXISTING text file (use file_write to create/overwrite). Each edit is {original_text, new_text, replace_all?}; original_text must match exactly and uniquely (or set replace_all) — a non-unique or missing anchor is rejected so files are never corrupted. Set dry_run to preview. Alternatively pass a unified 'diff' to apply its hunks as anchored edits. Far cheaper and safer than rewriting a whole file.\n- **shell_run**(command, cwd, timeout) [capability_expand category: shell]: Execute a one-shot shell command with timeout protection. Runs in the active workspace; paths resolving outside it are refused. Reach for a structured tool first when one fits — web_fetch for anything over HTTP(S), git_query/scm_sync for git, code_search/file_find/file_read for the repo, config_get/config_set for LeapFlow's own settings — because those report typed results, while a failed shell command can only be diagnosed from its exit code and stderr. Every shell run counts as an external side effect, so a failure stops the rest of the batch and is not retried automatically.\n- **terminal_open**(command, cwd, shell) [capability_expand category: terminal]: Open a PERSISTENT shell session (REPL/dev server/watch), returning a session_id for terminal_send/read/close. Disabled unless tools.terminal_session_enabled is set. For one-shot commands use shell_run instead.\n- **terminal_send**(session_id, input, wait) [capability_expand category: terminal]: Send a line of input to a persistent terminal session and return output captured shortly after.\n- **terminal_read**(session_id, wait): Drain buffered output from a persistent terminal session (optionally waiting briefly first).\n- **terminal_close**(session_id) [capability_expand category: terminal]: Terminate a persistent terminal session and release its process group.\n- **terminal_list**(): List active persistent terminal sessions.\n- **config_list**(category, limit): List LeapFlow's own writable settings (model, provider, daemon, memory, perception, gateway, …) with current values. Use this to discover the exact key before changing anything. Optionally narrow by `category`. This is the only correct way to inspect LeapFlow configuration — never read config files from disk.\n- **config_get**(key): Read one LeapFlow setting by key (e.g. 'llm.model', 'daemon.log_level'), returning its current value, type, scopes, and whether a change needs a daemon restart. Never read LeapFlow config files from disk — use this.\n- **config_set**(key, value, scope) [capability_expand category: config]: Change one LeapFlow setting by key, e.g. switch the model with key='llm.model'. Values are validated and coerced; credentials are stored in the vault automatically. Call config_list or config_get first if unsure of the key. The result states whether a `leap daemon restart` is required. Never edit LeapFlow config files directly.\n- **web_fetch**(url, select, timeout, max_bytes): Read a URL over HTTP(S) and get back extracted, context-sized content: parsed JSON for API endpoints, readable text plus links for web pages. Use this for anything on the internet — prices, docs, releases, articles — instead of running curl through shell_run: it reports real HTTP status codes, retries rate limits on its own, and is a plain read so a retry is always safe. For JSON APIs pass `select` with a dotted path (e.g. 'chart.result.0.meta') to return just that part instead of the whole payload.\n- **memory_search**(query, limit): Search agent memory for relevant past experiences, observations, and facts.\n- **memory_add**(content, kind) [capability_expand category: write]: Store a new observation or insight in memory for future reference.\n- **research_note**(kind, text): Record a compact, structured note about the current task's state so it survives context compression on long / multi-step tasks. Use for durable findings, open questions still to resolve, decisions / excluded paths, and the immediate next step. One concise sentence per note.\n- **capability_expand**(category): Fetch the full callable schema for every tool in a capability category (e.g. 'hub', 'gateway', 'desktop', '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.\n- **delegate_task**(goal, context) [capability_expand category: delegate]: Delegate a complex sub-task to an isolated subagent. The subagent gets a fresh context and restricted tool access. Use when a task is self-contained and can be solved independently.\n- **schedule_reentry**(kind, reason, delay_seconds, event_match, max_reentries, deadline_seconds): Register a re-entry so this task can resume later from its current orientation (findings / open questions / next step). Use when work must pause and continue after a delay (kind=time) or when a matching platform event arrives (kind=event), instead of finishing now. The research-ledger state is carried over automatically.\n- **hub_push**(skill_name, visibility, version) [capability_expand category: hub]: Push a local skill to the ModelScope Hub for sharing or backup.\n- **hub_pull**(repo_id, version) [capability_expand category: hub]: Pull a skill from the ModelScope Hub to install locally.\n- **hub_search**(query) [capability_expand category: hub]: Search for skills on the Hub by keyword or description.\n- **hub_sync**(mode, dry_run) [capability_expand category: hub]: Preview or execute sync between local skills and Hub.\n- **platform_action**(platform, action, payload, backend_kind) [capability_expand category: gateway]: Execute an exact registered business action on an external platform through LeapFlow's App Connector layer. Actions must be copied from the App Connector Capability Index and are addressed as domain.operation, e.g. im.send_message or docs.create_markdown. All business fields (chat_id, text, query, etc.) MUST be placed inside `payload`, never at the top level. Example: {\"platform\":\"feishu\",\"action\":\"im.send_message\",\"payload\":{\"chat_id\":\"oc_xxx\",\"text\":\"hello\"}}. Do not invent action names, do not use management actions such as list/guide/connect/status here.\n- **platform_connect**(action, platform, credentials, options, checkpoint) [capability_expand category: gateway]: List, guide, connect, disconnect, remove, or check status for external platforms using the App Connector management namespace. Supports REST and CLI backends. Use this for management actions such as list/guide/preflight/connect/status; use platform_action only for exact registered business actions.\n- **gateway_send**(platform, chat_id, text, thread_id) [capability_expand category: gateway]: Send a message to a connected external platform (Feishu group, Telegram chat, DingTalk conversation, etc.). Requires the platform to be connected via gateway_connect first. Use gateway_connect with action='list' to see connected platforms and available chat IDs.\n- **gateway_connect**(action, platform, credentials, options) [capability_expand category: gateway]: Connect, configure, or manage external platform integrations (Feishu, DingTalk, Telegram, Slack, Discord, etc.). Conversational flow: 1) call 'guide' to get setup steps + required fields, 2) present the steps to the user and ask for ALL required credentials in a single message, 3) call 'connect' with the credentials. Goal: complete in 1–2 user turns. NEVER include credential values in your text response.\n- **plugin_list**(): List the live plugin registry and cross-subsystem capability evidence. Use this before answering questions about whether LeapFlow supports plugins, self-evolution, plugin installation, hot reload, versioning, or other runtime capabilities.\n- **plugin_status**(plugin_id): Get detailed status of a specific plugin: its declared category, runtime dependencies, contributed tools, and fiber lifecycle state.\n- **plugin_versions**(plugin_id): List recorded source versions and active pointer for a profile-scoped plugin.\n- **plugin_propose**(requested_capability, plugin_id, proposed_tools, test_cases, risk_level, evidence): Create a side-effect-free PluginProposal from explicit capability-gap evidence. Use this before plugin_generate when a missing capability should be reviewed. Does not call an LLM, write files, or install anything.\n- **assess_compatibility**(manifest, source_path) [capability_expand category: plugin_management]: Assess whether a foreign plugin manifest or real DSH source bundle is compatible with LeapFlow. A source bundle assessment is static: runtime_ready stays false until restricted Node discovery during plugin_install. Returns component-level verdicts and limitations.\n- **plugin_generate**(plugin_id, description, proposal_id) [capability_expand category: system]: Generate a new ToolPlugin from a natural-language capability description. The LLM produces code that conforms to the ToolPlugin Protocol; it is then rigorously validated (syntax, structure, import, protocol conformance). The isolated sandbox smoke test runs later, at install-time. Returns the validated code but DOES NOT install it — installation is a separate approval-gated step via plugin_install.\n- **plugin_install**(plugin_id, code, marketplace_name, source_path, proposal_id, version_label) [capability_expand category: system]: Install a plugin from exactly one source: validated Python code, the configured Python marketplace, or a real DSH source bundle. DSH bundles run restricted Node discovery before registration; only public host tools are installed and client UI limitations are reported. Writes profile-scoped state and mutates the process-global registry. REQUIRES APPROVAL.\n- **plugin_rollback**(plugin_id, version) [capability_expand category: system]: Rollback a profile-scoped plugin to a recorded source version and reload it. REQUIRES APPROVAL.\n- **plugin_reload**(plugin_id, version_label) [capability_expand category: system]: Hot-reload a plugin at runtime. Disposes the old plugin fiber, re-imports its module, and registers a fresh instance. Existing in-flight turns are unaffected (snapshot isolation). REQUIRES APPROVAL — this is a self-modification action.\n- **plugin_disable**(plugin_id) [capability_expand category: system]: Disable a plugin by disposing its fiber, removing its tools from the runtime registry. Cannot disable self_management itself. REQUIRES APPROVAL — this is a self-modification action.\n- **plugin_remove**(plugin_id, delete_source) [capability_expand category: system]: Terminally remove a plugin: dispose its fiber, unregister its tools, remove reload metadata, and optionally delete its profile-scoped source file. Cannot remove self_management itself. REQUIRES APPROVAL.\n- **plugin_enable**(plugin_id) [capability_expand category: system]: Re-enable a previously disabled plugin by reloading its module and registering a fresh instance. REQUIRES APPROVAL.\n- **session_search**(query, limit) [capability_expand category: unclassified]: Search past conversation sessions in the current workspace for relevant context.\n- **session_list**(limit): List recent conversation sessions in the current workspace with stable ids, titles, dates, and summaries. Use for browsing past tasks or when user asks to see history without specific search terms. No keywords needed — returns chronological list.\n- **session_detail**(session_id, limit, offset, include_inactive) [capability_expand category: unclassified]: Read a paginated persisted transcript for one past conversation session in the current workspace. Use after session_list or session_search returns a session_id.\n- **click**(element_index) [capability_expand category: desktop]: Click a UI element by its element_index (from the latest observe_ui snapshot)\n- **get_clipboard**() [capability_expand category: desktop]: Read current clipboard text content\n- **list_apps**(filter, running_only) [capability_expand category: desktop]: List available applications on this system. Use to discover correct bundle_id before switch_app.\n- **list_windows**() [capability_expand category: desktop]: List all top-level windows with pid, window_id, title, and per-window state (minimized, on-screen). Call this first to pick the pid and window_id that observe_ui and other window tools require.\n- **observe_ui**(pid, window_id, query) [capability_expand category: desktop]: Snapshot one window's actionable UI elements, each tagged with an element_index for click/right_click/read_text. Re-observe after actions — indices belong to one snapshot. Requires the window's pid and window_id from list_windows.\n- **open_url**(url, app_id) [capability_expand category: desktop]: Open a URL in the default or specified browser\n- **read_text**(element_index) [capability_expand category: desktop]: Read the text content of a specific UI element from the latest snapshot\n- **right_click**(element_index) [capability_expand category: desktop]: Right-click a UI element to open its context menu. Returns visible menu items.\n- **screenshot**(pid, window_id) [capability_expand category: desktop]: Capture a screenshot for visual verification. With pid + window_id captures that window (works across all displays); defaults to the last observed window, or the full desktop when no window has been observed.\n- **scroll**(element_index, direction, amount, pid, window_id) [capability_expand category: desktop]: Scroll a scrollable area of a window. Omit element_index to scroll the window's focused/page scroller; pass one to scroll an exact element from the latest snapshot.\n- **select_text**(element_index) [capability_expand category: desktop]: Select all text in a UI element (focus + select-all, for subsequent copy)\n- **set_clipboard**(text) [capability_expand category: desktop]: Write text to the clipboard\n- **shortcut**(keys) [capability_expand category: desktop]: Execute a keyboard shortcut\n- **switch_app**(app_id) [capability_expand category: desktop]: Switch to an app (launch if needed, activate, verify)\n- **type_text**(text) [capability_expand category: desktop]: Type text into the currently focused element\n- **wait**(seconds) [capability_expand category: desktop]: Wait for a specified duration before continuing\n- **wait_until**(condition, pid, window_id, timeout, poll_interval) [capability_expand category: desktop]: Wait until a UI condition is met (polls UI tree). Returns elements when found or on timeout.\n- **wait_until_stable**(timeout, poll_interval, pid, window_id) [capability_expand category: desktop]: Wait until the UI stops changing (element set stabilizes across polls).\n\n## App Connector Capability Index\nLeapFlow can onboard and manage external apps through `platform_connect` and execute exact registered business actions through `platform_action`.\nFor requests about connecting, setting up, configuring, enabling, or managing a supported app, use `platform_connect` first instead of generating SDK/Webhook sample code.\n`platform_connect.action` is the management namespace: list, guide, preflight, connect, disconnect, remove, status, events_start, events_stop, events_status.\n`platform_action.action` is only for exact registered platform business actions listed below, such as `im.send_message`; never use management actions like `list` or `guide` there.\nAll business fields MUST go inside `payload`; top-level keys are only `platform`, `action`, and `payload`.\nDo not invent platform IDs or platform action names. If the needed action is not listed, ask for discovery/clarification instead of guessing.\nUse `platform_connect` with `action='guide'` and the matching `platform` to start onboarding; use `action='list'` when the app is unclear.\nWhen a pending onboarding state is present, continue from that state with `platform_connect` instead of asking the user to restate the app.\nSupported apps:\n- `api_server`: API Server (OpenAI Compatible) (category=api; backend=adapter; platform_action actions=none registered)\n- `dingtalk`: 钉钉 (DingTalk) (category=im; backend=adapter; platform_action actions=none registered)\n- `feishu`: 飞书 (Feishu/Lark) (category=im; backend=cli)\n - `calendar.create_event` payload={summary*, start_time*, end_time*, attendees} [write/high]\n - `docs.create_markdown` payload={title*, markdown*, folder_token} [write/medium]\n - `drive.search` payload={query*, limit} [read/medium]\n - `im.add_reaction` payload={message_id*, emoji_type*} [write/low]\n - `im.download_resource` payload={message_id*, file_key*, type*} [read/low]\n - `im.get_messages` payload={message_ids*} [read/low]\n - `im.list_chats` payload={page_size} [read/low]\n - `im.list_messages` payload={chat_id*} [read/low]\n - `im.list_thread_messages` payload={thread*} [read/low]\n - `im.remove_reaction` payload={message_id*, emoji_type*} [write/low]\n - `im.reply_message` payload={message_id*, text*} [send/high]\n - `im.search_chats` payload={query*, page_size} [read/low]\n - `im.search_messages` payload={query*} [read/medium]\n - `im.send_message` payload={chat_id*, text*, thread_id} [send/high]\n - `im.update_card` payload={token*, card*} [write/medium]\n - `im.update_message` payload={message_id*, text*} [write/medium]\n - `mail.search_unread` payload={query, limit} [read/high]\n - `sheets.append_row` payload={spreadsheet_token*, sheet_id*, values*} [write/medium]\n - `task.create` payload={title*, description, due_time} [write/medium]\n- `telegram`: Telegram (category=social; backend=adapter; platform_action actions=none registered)\n- `webhook`: Webhook (Generic) (category=webhook; backend=adapter; platform_action actions=none registered)\n\n## Tool Usage\nTools are normally invoked through the native function-calling mechanism, not by writing JSON in your reply\ntext. Only if the provider signals that native function calling is unavailable for this turn, fall back to a\nsingle JSON code block: `{\"name\": \"tool_name\", \"arguments\": {\"key\": \"value\"}}` — use this fallback format\nonly, never both. Only call a tool whose exact name appears in the tool index above and is currently callable\n(or reachable via `capability_expand`). Never invent, rename, alias, or guess a tool name, platform ID, or\nplatform action from argument shape or wording — if the index does not list it, it does not exist.\n`platform_connect.action` (list/guide/preflight/connect/disconnect/remove/status/events_start/events_stop/\nevents_status) is the App Connector management namespace; `platform_action.action` only accepts exact\nregistered business actions such as `im.send_message` shown in the App Connector Capability Index — never mix\nthe two namespaces. If a tool call returns an unknown/unavailable result, use the returned suggestions or\navailable names for a single retry instead of trying further variations of the same guess.\n\n**Side-effect action rule** (`platform_action` with effect=send/write/execute):\n- Call each unique action+payload **exactly once**. Never include duplicates in the same turn.\n- Once the result returns `\"completed\": true`, that action is DONE for this task. Do NOT call it again\n in any subsequent turn — immediately summarize the result for the user instead.\n- The system enforces idempotency: duplicate calls are blocked and will not execute.\n- If the user explicitly requests sending/writing multiple times, use distinct payloads per call.\n\n**Resource identifier provenance rule**:\n- NEVER fabricate, guess, or infer resource identifiers (chat_id, message_id, file_key, user_id, etc.).\n Every resource ID used in a side-effect action MUST come from a successful API response in this session.\n- If a read/list action fails (e.g. authorization error), you do NOT have valid resource IDs.\n Report the failure to the user — do NOT attempt the dependent write/send action with a guessed ID.\n- When a tool result contains `\"llm_instruction\"`, follow it exactly.\n\n## Guidelines\n1. **Direct answers first**: If you already know the answer, respond directly without tools.\n2. **Avoid redundant tool calls**: Do not call the same tool with the same arguments more than once in the same user turn. When an existing tool result already answers the user's request, stop calling tools and answer directly.\n3. **Use tools proactively**: When the user asks about files, time, system state, or needs actions performed, use the appropriate tool.\n4. **Chain tools when needed**: You can call multiple tools in sequence (e.g., list files → read file → summarize).\n5. **Handle failures gracefully**: If a tool fails, explain what went wrong and suggest alternatives. If it failed because of an unknown tool/platform/action name, retry once with an exact name from the returned suggestions, then explain rather than keep guessing.\n6. **Summarize results naturally**: After tool execution, synthesize the results into a helpful answer rather than dumping raw output.\n7. **Stay conversational**: Maintain a natural, helpful tone. Acknowledge context from earlier in the conversation.\n8. **Recall past work**:\n - Broad queries (\"之前做了什么\", \"列出任务\"): answer from the \"Recent Task History\" section already in your context. If insufficient, call session_list.\n - Specific lookups (\"上次那个配置怎么改的\"): call session_search with relevant phrases (NOT single characters).\n - Do NOT call search tools repeatedly with keyword fragments. One well-phrased call is better than ten fragmented ones.\n\n## Coding & Verification\nWhen working with code, prefer the precise built-in tools over ad-hoc shell: use `repo_map` to orient in an\nunfamiliar project, `code_search`/`file_find` to locate and `code_intel` for symbols, `edit_file` (anchored\nsearch-replace, or a unified `diff`) to change files — never rewrite a whole file to change a few lines — and\n`git_query` to inspect diffs/log. After edits, check `syntax_ok` in the result and run `test_run`/`lint_check`\nbefore declaring the work done. Batch independent read-only calls (search/read/query) into a single turn — they\nrun in parallel.\n\n## Presentation Style\n1. **Polished Markdown only**: Format user-facing answers with clean Markdown headings, short paragraphs, and concise bullets. Use tables only when they improve comparison or scanning.\n2. **Terminal-friendly layout**: Keep lines readable in a TUI; avoid dense walls of text, deeply nested lists, oversized ASCII art, or heavy visual blocks.\n3. **Elegant emphasis**: Use bold text sparingly for key terms and conclusions. Avoid excessive emojis, decorative symbols, repeated separators, or visual noise.\n4. **Theme-safe colors**: Do not emit ANSI escape codes, HTML color tags, Rich markup, or hardcoded color names. Rely on the TUI theme to render Markdown professionally.\n5. **No leaked tool protocol**: Never show tool-call JSON, internal schemas, raw observations, tool result payloads, or hidden reasoning in the final answer unless the user explicitly asks for raw/debug output. Treat any prior `{\"name\": ..., \"arguments\": ...}` blocks and `Tool result (...)` messages as internal execution context only.\n6. **Professional closure**: End with a concise conclusion or next step when helpful; avoid rambling after the useful answer is complete.\n\nWhen finished with all tool calls, respond normally without a JSON block, tool-call transcript, or process log.\n\n\n## Recent Session Summary\n- [user] Try the missing_json_pretty_e2e tool so LeapFlow records a capability gap.\n\n## Task Contract\n- Task ID: turn-1\n- Original user request: Try the missing_json_pretty_e2e tool so LeapFlow records a capability gap.\n- Workspace root: \n- Allowed roots: \n- Treat relative project paths as relative to the workspace root; never infer `.` as the project root when a workspace root is provided.\n- Workspace boundary is enforced by tools: do not read, search, edit, or run commands against paths outside the allowed roots unless the user explicitly requests an external path and the tool/approval policy permits it.\n- LeapFlow workspace config is optional at `/.leapflow/config.yaml`; runtime config is loaded from `~/.leapflow/config/user.yaml` and `~/.leapflow/profiles//config/*.yaml`.\n- Preserve this task contract across summarization, compression, tool loops, and memory retrieval.\n" + }, + { + "role": "user", + "content": "Try the missing_json_pretty_e2e tool so LeapFlow records a capability gap.\nTry the missing_json_pretty_e2e tool so LeapFlow records a capability gap." + }, + { + "role": "assistant", + "content": "", + "tool_calls": [ + "missing_json_pretty_e2e" + ] + }, + { + "role": "tool", + "content": "{\"ok\": false, \"error\": \"Unknown tool: missing_json_pretty_e2e\", \"error_type\": \"unknown_tool\", \"retryable\": true}", + "tool_result": true + }, + { + "role": "user", + "content": "SYSTEM: The previous tool call used an unavailable tool name. Original tool: missing_json_pretty_e2e. Resolution: unknown (no exact canonical tool name match). Suggested canonical tools: text_search, text_replace, research_note, gateway_send. Available tools include: assess_compatibility, capability_expand, code_intel, code_search, config_get, config_list, config_set, delegate_task, edit_file, env_info, file_find, file_list. Retry once using an exact canonical tool name from the available list and valid arguments. Do not invent tool names, use aliases, or infer a tool from argument shape; answer without a tool if no exact tool fits." + } + ], + "tools": [ + "assess_compatibility", + "capability_expand", + "click", + "code_intel", + "code_search", + "config_get", + "config_list", + "config_set", + "delegate_task", + "edit_file", + "env_info", + "file_find", + "file_list", + "file_read", + "file_write", + "gateway_connect", + "gateway_send", + "get_clipboard", + "git_query", + "git_write", + "hub_pull", + "hub_push", + "hub_search", + "hub_sync", + "lint_check", + "list_apps", + "list_windows", + "memory_add", + "memory_search", + "observe_ui", + "open_url", + "platform_action", + "platform_connect", + "plugin_disable", + "plugin_enable", + "plugin_generate", + "plugin_install", + "plugin_list", + "plugin_propose", + "plugin_reload", + "plugin_remove", + "plugin_rollback", + "plugin_status", + "plugin_versions", + "read_text", + "repo_map", + "research_note", + "right_click", + "schedule_reentry", + "scm_sync", + "screenshot", + "scroll", + "select_text", + "session_detail", + "session_list", + "session_search", + "set_clipboard", + "shell_run", + "shortcut", + "skill_view", + "skills_list", + "switch_app", + "terminal_close", + "terminal_list", + "terminal_open", + "terminal_read", + "terminal_send", + "test_run", + "text_replace", + "text_search", + "time_get", + "type_text", + "wait", + "wait_until", + "wait_until_stable", + "web_fetch" + ] + }, + "responses": [ + { + "status": 200, + "content_type": "application/json", + "body": "{\"id\": \"chatcmpl-cassette\", \"object\": \"chat.completion\", \"model\": \"cassette-model\", \"choices\": [{\"index\": 0, \"message\": {\"role\": \"assistant\", \"content\": \"Observed the missing JSON pretty tool.\"}, \"finish_reason\": \"stop\"}], \"usage\": {\"prompt_tokens\": 64, \"completion_tokens\": 16, \"total_tokens\": 80}}" + } + ] +} diff --git a/tests/_fixtures/dsh_exports/stock-panel/client.js b/tests/_fixtures/dsh_exports/stock-panel/client.js new file mode 100644 index 0000000..54186b2 --- /dev/null +++ b/tests/_fixtures/dsh_exports/stock-panel/client.js @@ -0,0 +1,14 @@ +return { + inject: ['timer'], + apply(ctx) { + const slots = ctx.get('slots') + const layout = ctx.get('layout') + styles.insert('.fixture-stock{display:block}') + slots.inject('details', function () { + return slots.register({ name: 'details' }, function () { + if (layout) layout.openDetails() + return React.createElement('div', { className: 'fixture-stock' }, 'stock') + }) + }) + } +} diff --git a/tests/_fixtures/dsh_exports/stock-panel/host.js b/tests/_fixtures/dsh_exports/stock-panel/host.js new file mode 100644 index 0000000..2f8fbdb --- /dev/null +++ b/tests/_fixtures/dsh_exports/stock-panel/host.js @@ -0,0 +1,22 @@ +return { + apply(ctx) { + async function fetchQuote(symbol) { + const shell = ctx.get('shell') + if (shell === undefined) return { ok: false, error: 'shell unavailable' } + const command = "curl -sS -m 5 'https://example.test/quote?q=" + symbol + "'" + const result = await shell.run(shell.resolve({ command, timeoutMs: 5000, stdoutMaxBytes: 4096 })) + if (result.exitCode !== 0) return { ok: false, error: result.stderr.text } + return { ok: true, symbol, raw: result.stdout.text } + } + + harness.handle('fetch-quote', async (args) => fetchQuote(String(args.symbol || 'AAPL'))) + harness.registerTool(ctx, harness.defineTool({ + name: 'fixture_stock_quote', + description: 'Fetch one fixture stock quote through the typed HTTP capability.', + parameters: { + symbol: { type: 'string', required: true, description: 'Ticker symbol' } + }, + async execute(args) { return fetchQuote(String(args.symbol || 'AAPL')) } + })) + } +} diff --git a/tests/_fixtures/dsh_exports/stock-panel/meta.json b/tests/_fixtures/dsh_exports/stock-panel/meta.json new file mode 100644 index 0000000..f4c667d --- /dev/null +++ b/tests/_fixtures/dsh_exports/stock-panel/meta.json @@ -0,0 +1,8 @@ +{ + "name": "Fixture Stock Panel", + "purpose": "Hermetic Cordis export fixture with one public tool and one UI slot.", + "plugin": { + "kind": "new", + "idPrefix": "stock" + } +} diff --git a/tests/_fixtures/dsh_packages/echo/index.cjs b/tests/_fixtures/dsh_packages/echo/index.cjs new file mode 100644 index 0000000..3f3293e --- /dev/null +++ b/tests/_fixtures/dsh_packages/echo/index.cjs @@ -0,0 +1,14 @@ +module.exports = { + apply(ctx) { + harness.registerTool(ctx, harness.defineTool({ + name: 'fixture_echo', + description: 'Return the supplied text from a pre-built DSH package.', + parameters: { + text: { type: 'string', required: true, description: 'Text to echo' } + }, + async execute(args) { + return { ok: true, echo: String(args.text || '') } + } + })) + } +} diff --git a/tests/_fixtures/dsh_packages/echo/package.json b/tests/_fixtures/dsh_packages/echo/package.json new file mode 100644 index 0000000..0dc40f2 --- /dev/null +++ b/tests/_fixtures/dsh_packages/echo/package.json @@ -0,0 +1,11 @@ +{ + "name": "@fixture/dsh-echo", + "version": "1.0.0", + "main": "index.cjs", + "keywords": ["tools"], + "dsh": { + "category": "tools", + "interfaces": ["execute"], + "permissions": [] + } +} diff --git a/tests/journeys/test_r6_lifecycle.py b/tests/journeys/test_r6_lifecycle.py index a794a5b..4729bc4 100644 --- a/tests/journeys/test_r6_lifecycle.py +++ b/tests/journeys/test_r6_lifecycle.py @@ -16,6 +16,8 @@ from __future__ import annotations +import shutil +from pathlib import Path from typing import Any import pytest @@ -23,7 +25,7 @@ from leapflow.daemon.client import DaemonUnavailableError from leapflow.daemon.lifecycle import DaemonInfo, cleanup_stale from leapflow.daemon._transport import get_transport -from tests._harness.cassette_proxy import answer, scripted +from tests._harness.cassette_proxy import answer, scripted, tool_call from tests._harness.journey import Journey, JourneyFactory from tests._harness.leapd import await_for, start_leapd @@ -32,25 +34,44 @@ "src/leapflow/daemon/", "src/leapflow/layout.py", "src/leapflow/cli/commands/daemon.py", + "src/leapflow/plugins/dsh/", + "src/leapflow/plugins/tool_plugins/self_management.py", + "src/leapflow/learning/compatibility/", ) -# No LLM semantics: process lifecycle, stale runtime files, and session resume. -# A live run would spend tokens for no extra signal. +# Deterministic scripted tool dispatch is sufficient for process lifecycle and +# DSH rediscovery; a live run would spend tokens without adding signal. LIVE_SIGNAL = False SESSION = "r6-lifecycle" +DSH_PLUGIN_ID = "r6_dsh_echo" +_DSH_FIXTURE = Path(__file__).resolve().parents[1] / "_fixtures" / "dsh_packages" / "echo" async def _turn(client: Any, message: str, workspace: str) -> list[Any]: - """Run one turn and return its stream events.""" + """Run one turn, approving any explicit safety prompt, and return its events.""" events: list[Any] = [] async for event in client.engine_chat( message, session_id=SESSION, workspace_root=workspace ): events.append(event) + if event.type == "approval_request": + approval = (event.metadata or {}).get("approval") or {} + pending_id = str(approval.get("pending_id") or "") + assert pending_id, f"approval event lacked pending_id: {event.metadata}" + await client.approval_resolve( + pending_id, "allow_once", reason="r6 DSH lifecycle" + ) return events +def _completed(events: list[Any], tool_name: str) -> bool: + return any( + event.type == "tool_complete" and event.content == tool_name + for event in events + ) + + async def _status_or_none(client: Any) -> dict[str, Any] | None: """Return daemon.status, tolerating the short restart reconnect window.""" try: @@ -78,15 +99,33 @@ async def _history_or_none(client: Any) -> dict[str, Any] | None: @pytest.mark.asyncio async def test_r6_daemon_lifecycle(journeys: JourneyFactory) -> None: """The daemon starts, reports itself, serves work, stops, and recovers cleanly.""" + script = scripted(answer("Unused fallback.")) journey = journeys( "r6_lifecycle", - script=scripted(answer("Still here.")), + script=script, deadline_s=120.0, - # One turn; the rest is lifecycle. - max_llm_calls=6, - max_llm_tokens=80_000, + max_llm_calls=10, + max_llm_tokens=160_000, ) - workspace = str(journey.workspace("life")) + workspace_path = journey.workspace("life") + workspace = str(workspace_path) + dsh_source = workspace_path / "dsh-echo" + shutil.copytree(_DSH_FIXTURE, dsh_source) + script.turns[:] = [ + tool_call( + "plugin_install", + plugin_id=DSH_PLUGIN_ID, + source_path=str(dsh_source), + version_label="r6", + ), + answer("Installed the hermetic DSH plugin."), + tool_call("fixture_echo", text="before restart"), + answer("The DSH tool ran before restart."), + tool_call("fixture_echo", text="after restart"), + answer("The DSH tool ran after restart."), + tool_call("plugin_remove", plugin_id=DSH_PLUGIN_ID, delete_source=True), + answer("Removed the hermetic DSH plugin."), + ] client = journey.client() with journey.phase("running: lifecycle artefacts exist and agree with each other"): @@ -114,9 +153,26 @@ async def test_r6_daemon_lifecycle(journeys: JourneyFactory) -> None: ) assert status["runtime_dir"] == str(journey.daemon.runtime_dir) - with journey.phase("serving: a turn works, proving this is more than a socket"): - events = await _turn(client, "Are you there?", workspace) - assert not [event for event in events if event.type == "error"] + with journey.phase("serving: install and invoke a restricted DSH plugin"): + installed = await _turn( + client, + "Install the hermetic DSH echo plugin from this workspace.", + workspace, + ) + assert _completed(installed, "plugin_install"), [event.type for event in installed] + status = await client.command_execute( + "plugin status", DSH_PLUGIN_ID, session_id=SESSION + ) + assert status.get("ok") is True, status + assert status.get("dsh", {}).get("runtime") == "node", status + + invoked = await _turn( + client, + "Invoke fixture_echo with the text before restart.", + workspace, + ) + assert _completed(invoked, "fixture_echo"), [event.type for event in invoked] + assert not [event for event in invoked if event.type == "error"] with journey.phase("stop: shutdown removes the process and its runtime files"): old_pid = journey.daemon.info().pid @@ -189,9 +245,43 @@ async def test_r6_daemon_lifecycle(journeys: JourneyFactory) -> None: what="session.history to respond after restart", ) blob = str(history.get("messages") or []) - assert "Are you there?" in blob, ( + assert "Install the hermetic DSH echo plugin" in blob, ( "the conversation recorded before the restart did not survive it" ) + + with journey.phase("DSH continuity: wrapper rediscovery survives restart"): + plugin_status = await fresh_client.command_execute( + "plugin status", DSH_PLUGIN_ID, session_id=SESSION + ) + assert plugin_status.get("ok") is True, plugin_status + assert plugin_status.get("dsh", {}).get("source_kind") == "dsh_package" + assert plugin_status.get("dsh", {}).get("verdict") == "adaptable" + + invoked = await _turn( + fresh_client, + "Invoke fixture_echo with the text after restart.", + workspace, + ) + assert _completed(invoked, "fixture_echo"), [ + event.type for event in invoked + ] + assert not [event for event in invoked if event.type == "error"] + + with journey.phase("DSH cleanup: removal deletes wrapper and managed bundle"): + removed = await _turn( + fresh_client, + "Remove the hermetic DSH echo plugin completely.", + workspace, + ) + assert _completed(removed, "plugin_remove"), [ + event.type for event in removed + ] + assert not ( + restarted.profile_layout.plugins_dir / f"{DSH_PLUGIN_ID}.py" + ).exists() + assert not ( + restarted.profile_layout.dsh_plugins_dir / DSH_PLUGIN_ID + ).exists() finally: restarted.stop() diff --git a/tests/test_compatibility_assessment.py b/tests/test_compatibility_assessment.py index 365b6b7..77c28b5 100644 --- a/tests/test_compatibility_assessment.py +++ b/tests/test_compatibility_assessment.py @@ -335,7 +335,7 @@ def test_dsh_tools_plugin_compatible(self) -> None: assert report.target_protocol == "ToolPlugin" assert report.rejection_reason is None assert report.manifest.name == "@deepseek-ai/dsh-web-search" - assert report.is_installable() is True + assert report.is_installable() is False # manifest-only: runtime discovery not proven def test_dsh_agent_loop_incompatible(self) -> None: """DSH agent-loop plugin produces INCOMPATIBLE with rejection reason.""" @@ -372,7 +372,7 @@ def test_dsh_llm_plugin_adaptable(self) -> None: assert report.adapter_spec.target_protocol == "LLMProviderPlugin" assert report.adapter_spec.bridge_type == "json_rpc_bridge" assert len(report.adaptation_notes) > 0 - assert report.is_installable() is True + assert report.is_installable() is False # manifest-only: runtime discovery not proven def test_pipeline_short_circuit_on_incompatible(self) -> None: """INCOMPATIBLE at stage 2 stops pipeline (only 2 stages recorded).""" @@ -581,8 +581,8 @@ def test_missing_interfaces_incompatible(self) -> None: assert result.passed is False assert result.verdict == Verdict.INCOMPATIBLE - def test_empty_interfaces_assumed_compatible(self) -> None: - """Plugin with no declared interfaces assumed compatible (benefit of doubt).""" + def test_empty_native_interfaces_defer_to_import_validation(self) -> None: + """A native plugin with no interfaces is validated by its real import path.""" from leapflow.learning.compatibility.stages.interface_analyzer import InterfaceAnalyzer manifest = PluginManifestInput( @@ -595,7 +595,29 @@ def test_empty_interfaces_assumed_compatible(self) -> None: )] result = InterfaceAnalyzer().assess(manifest, prior) assert result.passed is True - assert result.evidence["match_type"] == "assumed" + assert result.verdict is None + assert result.evidence["match_type"] == "native_import_required" + + def test_empty_foreign_interfaces_require_runtime_discovery(self) -> None: + """An empty DSH interface list is a candidate, never native compatibility proof.""" + from leapflow.learning.compatibility.stages.interface_analyzer import InterfaceAnalyzer + + manifest = PluginManifestInput( + name="test", + version="1.0.0", + category="tools", + declared_interfaces=[], + source_language="javascript", + source_format="dsh", + ) + prior = [StageResult( + stage_name="category_resolver", passed=True, + evidence={"target_protocol": "ToolPlugin"}, + )] + result = InterfaceAnalyzer().assess(manifest, prior) + assert result.passed is True + assert result.verdict == Verdict.ADAPTABLE + assert result.evidence["match_type"] == "runtime_discovery_required" def test_fuzzy_match_tool_interface(self) -> None: """Fuzzy substring matching catches tool-like interfaces.""" @@ -671,17 +693,32 @@ def test_no_deps(self) -> None: result = DependencyChecker().assess(manifest, []) assert result.passed is True - def test_unknown_deps_satisfiable(self) -> None: - """Unknown external packages default to satisfiable.""" + def test_unknown_native_deps_defer_to_runtime_binding(self) -> None: + """Native manifests may name runtime dependencies injected by the host.""" from leapflow.learning.compatibility.stages.dependency_checker import DependencyChecker manifest = PluginManifestInput( name="test", version="1.0.0", category="tools", - declared_dependencies=["lodash", "moment"], + declared_dependencies=["custom_runtime_service"], ) result = DependencyChecker().assess(manifest, []) assert result.passed is True - assert result.verdict is None # All satisfiable + assert result.verdict is None + + def test_unknown_dsh_dependencies_are_blocking(self) -> None: + """An unknown npm dependency cannot be assumed present in the Node sandbox.""" + from leapflow.learning.compatibility.stages.dependency_checker import DependencyChecker + + manifest = PluginManifestInput( + name="test", version="1.0.0", category="tools", + declared_dependencies=["lodash", "moment"], + source_language="typescript", + source_format="dsh", + ) + result = DependencyChecker().assess(manifest, []) + assert result.passed is False + assert result.verdict == Verdict.INCOMPATIBLE + assert result.evidence["blocking"] == ["lodash", "moment"] # ═══════════════════════════════════════════════════════════════════ @@ -963,8 +1000,8 @@ def test_mixed_adaptable_partial_yields_adaptable(self) -> None: class TestFullPipelineP1: """Full pipeline tests exercising all 6 stages.""" - def test_dsh_web_tool_all_6_stages(self) -> None: - """DSH web tool passes all 6 stages → COMPATIBLE.""" + def test_dsh_web_tool_with_unknown_npm_dependency_is_blocked(self) -> None: + """Manifest-only DSH packages cannot assume unknown npm dependencies exist.""" raw = { "name": "@deepseek-ai/dsh-web-search", "version": "0.1.0", @@ -979,14 +1016,13 @@ def test_dsh_web_tool_all_6_stages(self) -> None: "dependencies": {"node-fetch": "^3.0.0"}, } report = assess_plugin(raw) - # Web tool with network perm and typescript: should be ADAPTABLE - # (typescript bridge + medium risk is acceptable) - assert report.final_verdict in (Verdict.COMPATIBLE, Verdict.ADAPTABLE) - assert report.is_installable() is True - assert len(report.stages) == 6 + assert report.final_verdict == Verdict.INCOMPATIBLE + assert report.is_installable() is False + assert report.stages[-1].stage_name == "dependency_checker" + assert "node-fetch" in report.stages[-1].evidence["blocking"] - def test_dsh_llm_plugin_all_stages(self) -> None: - """DSH LLM plugin runs all 6 stages and produces ADAPTABLE.""" + def test_dsh_llm_plugin_with_unknown_npm_dependency_is_blocked(self) -> None: + """Foreign provider packages also require a self-contained pre-built bundle.""" raw = { "name": "@deepseek-ai/dsh-llm-openai", "version": "0.2.0", @@ -1001,10 +1037,10 @@ def test_dsh_llm_plugin_all_stages(self) -> None: "dependencies": {"node-fetch": "^3.0.0"}, } report = assess_plugin(raw) - assert report.final_verdict == Verdict.ADAPTABLE + assert report.final_verdict == Verdict.INCOMPATIBLE assert report.target_protocol == "LLMProviderPlugin" - assert report.adapter_spec is not None - assert len(report.stages) == 6 + assert report.adapter_spec is None + assert report.stages[-1].stage_name == "dependency_checker" def test_blocking_deps_short_circuits_at_stage4(self) -> None: """Plugin with blocking deps short-circuits at stage 4.""" @@ -1084,7 +1120,7 @@ def test_typescript_worker_tool_adaptable(self) -> None: } report = assess_plugin(raw) assert report.final_verdict == Verdict.ADAPTABLE - assert report.is_installable() is True + assert report.is_installable() is False # manifest-only: runtime discovery not proven assert report.adapter_spec is not None assert len(report.stages) == 6 @@ -1192,7 +1228,7 @@ async def test_marketplace_incompatible_blocked(self) -> None: result = await plugin._install_from_marketplace_with_gate("test_plugin", "agent-loop-pkg") assert result["ok"] is False - assert "INCOMPATIBLE" in result["error"] + assert "not installable" in result["error"] assert result.get("verdict") == "incompatible" @pytest.mark.asyncio @@ -1226,8 +1262,8 @@ async def test_marketplace_compatible_proceeds(self) -> None: assert result["ok"] is True @pytest.mark.asyncio - async def test_marketplace_no_manifest_still_proceeds(self) -> None: - """If resolve_manifest fails, gate degrades gracefully and proceeds.""" + async def test_marketplace_no_manifest_fails_closed(self) -> None: + """A missing compatibility manifest blocks installation.""" from unittest.mock import AsyncMock, MagicMock, patch from leapflow.plugins.tool_plugins.self_management import SelfManagementPlugin @@ -1243,8 +1279,10 @@ async def test_marketplace_no_manifest_still_proceeds(self) -> None: mock_install.return_value = {"ok": True, "action": "install"} result = await plugin._install_from_marketplace_with_gate("test_plugin", "some-pkg") - # Should proceed to install despite gate failure - assert result["ok"] is True + # The decision-bearing manifest is unavailable, so installation is not attempted. + assert result["ok"] is False + assert result["failure_code"] == "compatibility_manifest_unavailable" + mock_install.assert_not_awaited() # ═══════════════════════════════════════════════════════════════════ # P2: File-Path Manifest Loading Tests @@ -1297,7 +1335,7 @@ def test_load_from_path_object(self, tmp_path) -> None: assert isinstance(manifest_file, Path) report = assess_plugin(manifest_file) - assert report.is_installable() is True + assert report.is_installable() is False # manifest-only: runtime discovery not proven assert report.manifest.name == "@deepseek-ai/dsh-web-search" def test_nonexistent_file_incompatible(self, tmp_path) -> None: @@ -1511,139 +1549,62 @@ def test_original_is_copied_not_referenced(self) -> None: # ═══════════════════════════════════════════════════════════════════ -# P2: Adapter Generator Tests +# P2: Runtime-discovered Adapter Wrapper Tests # ═══════════════════════════════════════════════════════════════════ -def _sample_adapter_inputs(): - """Build a representative (AdapterSpec, PluginManifestInput) pair.""" +def _runtime_adapter_inputs(tmp_path): + """Build a real runtime descriptor rather than inventing tools from a manifest.""" + from leapflow.learning.compatibility.source_inspector import hash_source_files + from leapflow.plugins.dsh.descriptor import ( + DshPluginDescriptor, + DshToolDescriptor, + _sha256_file, + ) + + bundle = tmp_path / "bundle" + bundle.mkdir() + (bundle / "index.cjs").write_text("module.exports={apply(){}}", encoding="utf-8") + source_files = ("index.cjs",) + descriptor = DshPluginDescriptor( + plugin_id="dsh_web_search_bridge", + name="@deepseek-ai/dsh-web-search", + source_kind="dsh_package", + bundle_root=str(bundle), + entry_point="index.cjs", + bundle_sha256=hash_source_files(bundle, source_files), + runtime_sha256=_sha256_file(bundle / "index.cjs"), + source_files=source_files, + tools=( + DshToolDescriptor( + name="web_search", + description="Search the web through a restricted DSH worker.", + parameters_schema={"type": "object", "properties": {}}, + ), + ), + permissions=("network.outbound",), + ) spec = AdapterSpec( source_interface="web", target_protocol="ToolPlugin", bridge_type="json_rpc_bridge", - shim_methods=["config"], - estimated_complexity="low", ) manifest = PluginManifestInput( name="@deepseek-ai/dsh-web-search", version="0.1.0", category="web", - declared_interfaces=["web_search", "web_fetch"], - source_language="typescript", - raw_manifest={"main": "dist/index.js"}, + source_language="javascript", + raw_manifest={"x_leapflow_runtime_descriptor": descriptor.to_dict()}, source_format="dsh", ) return spec, manifest class TestAdapterGeneratorTemplate: - """Tests for generate_adapter_template() (no LLM).""" - - def test_template_produces_valid_python(self, tmp_path) -> None: - """The generated template is syntactically valid Python (py_compile).""" - import py_compile - - from leapflow.learning.compatibility.adapter_generator import ( - generate_adapter_template, - ) - - spec, manifest = _sample_adapter_inputs() - code = generate_adapter_template(spec, manifest) - - out = tmp_path / "generated_adapter.py" - out.write_text(code, encoding="utf-8") - # Raises PyCompileError if the output is not valid Python. - py_compile.compile(str(out), doraise=True) - - def test_template_compiles_with_compile_builtin(self) -> None: - """The generated template compiles via the compile() builtin.""" - from leapflow.learning.compatibility.adapter_generator import ( - generate_adapter_template, - ) - - spec, manifest = _sample_adapter_inputs() - code = generate_adapter_template(spec, manifest) - # Should not raise - compile(code, "", "exec") - - def test_template_class_name_and_plugin_id(self) -> None: - """Output contains the correct class name and plugin_id.""" - from leapflow.learning.compatibility.adapter_generator import ( - generate_adapter_template, - ) - - spec, manifest = _sample_adapter_inputs() - code = generate_adapter_template(spec, manifest) - - assert "class DshWebSearchBridgePlugin:" in code - assert 'return "dsh_web_search_bridge"' in code - - def test_template_declares_handler_per_interface(self) -> None: - """Each declared interface has a ToolMetadata entry and handler.""" - from leapflow.learning.compatibility.adapter_generator import ( - generate_adapter_template, - ) - - spec, manifest = _sample_adapter_inputs() - code = generate_adapter_template(spec, manifest) - - assert 'name="web_search"' in code - assert 'name="web_fetch"' in code - assert "async def _handle_web_search(" in code - assert "async def _handle_web_fetch(" in code - - def test_template_notes_auto_generated_and_source(self) -> None: - """Docstring notes it is auto-generated and names the wrapped plugin.""" - from leapflow.learning.compatibility.adapter_generator import ( - generate_adapter_template, - ) - - spec, manifest = _sample_adapter_inputs() - code = generate_adapter_template(spec, manifest) - - assert "auto-generated" in code.lower() - assert "@deepseek-ai/dsh-web-search" in code - - def test_template_uses_sandbox_host_bridge(self) -> None: - """Handlers delegate to a SandboxHost subprocess bridge.""" - from leapflow.learning.compatibility.adapter_generator import ( - generate_adapter_template, - ) - - spec, manifest = _sample_adapter_inputs() - code = generate_adapter_template(spec, manifest) - - assert "from leapflow.plugins.sandbox.sandbox_host import SandboxHost" in code - assert "self._invoke_bridge(" in code - - def test_template_no_interfaces_falls_back_to_invoke(self) -> None: - """With no declared interfaces, a single 'invoke' tool is generated.""" - from leapflow.learning.compatibility.adapter_generator import ( - generate_adapter_template, - ) - - spec = AdapterSpec( - source_interface="tools", - target_protocol="ToolPlugin", - bridge_type="json_rpc_bridge", - ) - manifest = PluginManifestInput( - name="dsh-empty", - version="1.0.0", - category="tools", - declared_interfaces=[], - source_language="typescript", - ) - code = generate_adapter_template(spec, manifest) - compile(code, "", "exec") - assert 'name="invoke"' in code - assert "async def _handle_invoke(" in code + """Adapters are generated only from verified runtime discovery output.""" - def test_template_sanitizes_interface_names(self) -> None: - """Interface names with dots/dashes become valid handler identifiers.""" - from leapflow.learning.compatibility.adapter_generator import ( - generate_adapter_template, - ) + def test_static_manifest_cannot_generate_an_executable_adapter(self) -> None: + from leapflow.learning.compatibility.adapter_generator import generate_adapter_template spec = AdapterSpec( source_interface="tools", @@ -1651,207 +1612,43 @@ def test_template_sanitizes_interface_names(self) -> None: bridge_type="json_rpc_bridge", ) manifest = PluginManifestInput( - name="dsh-weird", + name="dsh-static-only", version="1.0.0", category="tools", - declared_interfaces=["fs.read-file"], - source_language="typescript", + source_language="javascript", + raw_manifest={"main": "index.js"}, ) - code = generate_adapter_template(spec, manifest) - compile(code, "", "exec") - assert "async def _handle_fs_read_file(" in code - # The declared tool name is preserved verbatim on the ToolMetadata. - assert 'name="fs.read-file"' in code + with pytest.raises(ValueError, match="restricted runtime discovery"): + generate_adapter_template(spec, manifest) - def test_template_instantiable_and_conforms(self) -> None: - """The generated class can be exec'd, instantiated, and conforms.""" - from leapflow.learning.compatibility.adapter_generator import ( - generate_adapter_template, - ) + @pytest.mark.asyncio + async def test_runtime_descriptor_produces_valid_installable_wrapper(self, tmp_path) -> None: + from leapflow.learning.compatibility.adapter_generator import generate_adapter_template + from leapflow.learning.plugin_generator import PluginValidator from leapflow.plugins.protocol import ToolPlugin - spec, manifest = _sample_adapter_inputs() + spec, manifest = _runtime_adapter_inputs(tmp_path) code = generate_adapter_template(spec, manifest) - namespace: dict = {} exec(compile(code, "", "exec"), namespace) - cls = namespace["DshWebSearchBridgePlugin"] - instance = cls() - assert instance.plugin_id == "dsh_web_search_bridge" - assert instance.category == "bridge" - assert len(instance.tools) == 2 - assert isinstance(instance, ToolPlugin) - - -class TestAdapterGeneratorLLM: - """Tests for generate_adapter_with_llm() (optional enhancement).""" - - def test_no_provider_returns_template(self) -> None: - """When llm_provider is None, output equals the template.""" - from leapflow.learning.compatibility.adapter_generator import ( - generate_adapter_template, - generate_adapter_with_llm, - ) - - spec, manifest = _sample_adapter_inputs() - template = generate_adapter_template(spec, manifest) - result = generate_adapter_with_llm(spec, manifest, None) - assert result == template - - def test_llm_provider_refines_template(self) -> None: - """A fake provider returning valid code is used over the template.""" - from leapflow.learning.compatibility.adapter_generator import ( - generate_adapter_with_llm, - ) - - refined = ( - "# refined by llm\n" - "class RefinedAdapter:\n" - " pass\n" - ) - - class FakeProvider: - def generate(self, prompt: str) -> str: - return "```python\n" + refined + "```" + plugin = namespace["plugin"] - spec, manifest = _sample_adapter_inputs() - result = generate_adapter_with_llm(spec, manifest, FakeProvider()) - assert "RefinedAdapter" in result - assert "```" not in result + assert isinstance(plugin, ToolPlugin) + assert plugin.plugin_id == "dsh_web_search_bridge" + assert [tool.name for tool in plugin.tools] == ["web_search"] + validation = await PluginValidator().validate(plugin.plugin_id, code) + assert validation.ok is True, validation.error - def test_llm_invalid_code_falls_back(self) -> None: - """A provider returning code that does not compile falls back.""" + def test_llm_provider_cannot_rewrite_security_boundary(self, tmp_path) -> None: from leapflow.learning.compatibility.adapter_generator import ( generate_adapter_template, generate_adapter_with_llm, ) - class BrokenProvider: + class Provider: def generate(self, prompt: str) -> str: - return "def broken( : this is not python" + return "raise RuntimeError('untrusted replacement')" - spec, manifest = _sample_adapter_inputs() - template = generate_adapter_template(spec, manifest) - result = generate_adapter_with_llm(spec, manifest, BrokenProvider()) - assert result == template - - def test_llm_empty_output_falls_back(self) -> None: - """A provider returning empty output falls back to template.""" - from leapflow.learning.compatibility.adapter_generator import ( - generate_adapter_template, - generate_adapter_with_llm, - ) - - class EmptyProvider: - def generate(self, prompt: str) -> str: - return " " - - spec, manifest = _sample_adapter_inputs() - template = generate_adapter_template(spec, manifest) - result = generate_adapter_with_llm(spec, manifest, EmptyProvider()) - assert result == template - - def test_llm_provider_raises_falls_back(self) -> None: - """A provider that raises degrades gracefully to the template.""" - from leapflow.learning.compatibility.adapter_generator import ( - generate_adapter_template, - generate_adapter_with_llm, - ) - - class RaisingProvider: - def generate(self, prompt: str) -> str: - raise RuntimeError("provider down") - - spec, manifest = _sample_adapter_inputs() - template = generate_adapter_template(spec, manifest) - result = generate_adapter_with_llm(spec, manifest, RaisingProvider()) - assert result == template - - def test_llm_unsupported_provider_falls_back(self) -> None: - """A provider with no supported generation method falls back.""" - from leapflow.learning.compatibility.adapter_generator import ( - generate_adapter_template, - generate_adapter_with_llm, - ) - - class NoMethodProvider: - pass - - spec, manifest = _sample_adapter_inputs() - template = generate_adapter_template(spec, manifest) - result = generate_adapter_with_llm(spec, manifest, NoMethodProvider()) - assert result == template - - def test_llm_async_achat_provider(self) -> None: - """An async achat-style provider is supported.""" - from leapflow.learning.compatibility.adapter_generator import ( - generate_adapter_with_llm, - ) - - refined = "class AsyncRefined:\n pass\n" - - class AsyncProvider: - async def achat(self, messages, stream=False): - return refined - - spec, manifest = _sample_adapter_inputs() - result = generate_adapter_with_llm(spec, manifest, AsyncProvider()) - assert "AsyncRefined" in result - - -class TestAdapterGeneratorEscaping: - """Special characters in manifest fields must still produce valid Python.""" - - def test_adapter_template_handles_special_chars_in_name(self) -> None: - """Plugin names/interfaces with dots, quotes, slashes produce valid Python.""" - from leapflow.learning.compatibility.adapter_generator import ( - generate_adapter_template, - ) - - spec = AdapterSpec( - source_interface="io.read", - target_protocol="ToolPlugin", - bridge_type="json_rpc_bridge", - shim_methods=["io.read-file", 'say"hello'], - estimated_complexity="medium", - ) - manifest = PluginManifestInput( - name='@org/dsh-io.tools"v2', - version="1.0", - category="tools", - declared_interfaces=["io.read-file", 'say"hello', "weird\nname"], - source_language="typescript", - raw_manifest={"main": 'bridge".js'}, - ) - code = generate_adapter_template(spec, manifest) - # The internal compile() guard already ran; this asserts it end-to-end. - compile(code, "", "exec") # Must not raise - assert "class Dsh" in code - assert "plugin_id" in code - - def test_adapter_template_special_chars_instantiable(self) -> None: - """The escaped adapter can be exec'd and instantiated without error.""" - from leapflow.learning.compatibility.adapter_generator import ( - generate_adapter_template, - ) - - spec = AdapterSpec( - source_interface="io.read", - target_protocol="ToolPlugin", - bridge_type="json_rpc_bridge", - estimated_complexity="low", - ) - manifest = PluginManifestInput( - name='@org/dsh-io.tools"v2', - version="1.0", - category="tools", - declared_interfaces=["io.read-file", 'say"hello'], - source_language="typescript", - ) - code = generate_adapter_template(spec, manifest) - namespace: dict = {} - exec(compile(code, "", "exec"), namespace) - cls = namespace["DshIoToolsV2BridgePlugin"] - instance = cls() - assert instance.plugin_id == "dsh_io_tools_v2_bridge" - assert len(instance.tools) == 2 + spec, manifest = _runtime_adapter_inputs(tmp_path) + deterministic = generate_adapter_template(spec, manifest) + assert generate_adapter_with_llm(spec, manifest, Provider()) == deterministic diff --git a/tests/test_dsh_compatibility.py b/tests/test_dsh_compatibility.py new file mode 100644 index 0000000..112f2a6 --- /dev/null +++ b/tests/test_dsh_compatibility.py @@ -0,0 +1,740 @@ +"""Real-artifact tests for the restricted DSH/Cordis compatibility path.""" +from __future__ import annotations + +import asyncio +import json +import shutil +from pathlib import Path + +import pytest + +from leapflow.learning.compatibility import ( + ComponentStatus, + PluginSourceKind, + Verdict, + assess_plugin, + inspect_plugin_source, +) +from leapflow.learning.plugin_generator import PluginValidator +from leapflow.plugins.dsh.bundle import promote_staging_bundle, stage_runtime_bundle +from leapflow.plugins.dsh.capabilities import ( + DshCapabilityBroker, + DshCapabilityError, + parse_curl_get, +) +from leapflow.plugins.dsh.installer import DshInstallError, prepare_dsh_installation +from leapflow.plugins.dsh.node_host import DshNodeHost + +_FIXTURES = Path(__file__).parent / "_fixtures" +_DYNAMIC = _FIXTURES / "dsh_exports" / "stock-panel" +_PACKAGE = _FIXTURES / "dsh_packages" / "echo" + + +def _write_package(root: Path, body: str, *, name: str = "fixture-package") -> Path: + root.mkdir(parents=True, exist_ok=True) + (root / "package.json").write_text( + json.dumps( + { + "name": name, + "version": "1.0.0", + "main": "index.cjs", + "keywords": ["tools"], + } + ), + encoding="utf-8", + ) + (root / "index.cjs").write_text(body, encoding="utf-8") + return root + + +def test_dynamic_export_static_assessment_is_honestly_partial() -> None: + inspection = inspect_plugin_source(_DYNAMIC) + report = assess_plugin(_DYNAMIC) + + assert inspection.execution_plan.source_kind == PluginSourceKind.CORDIS_DYNAMIC_EXPORT + assert inspection.manifest.version == "0.0.0+export" + assert inspection.execution_plan.bundle_sha256 + assert report.final_verdict == Verdict.PARTIAL + assert report.is_installable() is False # discovery has not run + assert report.execution_plan is not None + assert report.execution_plan.installable_candidate is True + statuses = {item.kind.value: item.status for item in report.execution_plan.components} + assert statuses == { + "host": ComponentStatus.CANDIDATE, + "client": ComponentStatus.UNSUPPORTED, + } + assert "client.js UI" in report.execution_plan.limitations[0] + + +def test_prebuilt_package_static_assessment_requires_runtime_discovery() -> None: + report = assess_plugin(_PACKAGE) + + assert report.final_verdict == Verdict.ADAPTABLE + assert report.execution_plan is not None + assert report.execution_plan.source_kind == PluginSourceKind.DSH_PACKAGE + assert report.execution_plan.entry_point == "index.cjs" + assert report.execution_plan.requires_discovery is True + assert report.is_installable() is False + + +def test_package_with_dependency_is_not_installable_in_p0(tmp_path: Path) -> None: + source = tmp_path / "package" + source.mkdir() + (source / "index.cjs").write_text("module.exports={apply(){}}", encoding="utf-8") + (source / "package.json").write_text( + json.dumps( + { + "name": "dependency-plugin", + "version": "1.0.0", + "main": "index.cjs", + "keywords": ["tools"], + "dependencies": {"left-pad": "1.3.0"}, + } + ), + encoding="utf-8", + ) + + report = assess_plugin(source) + + assert report.final_verdict == Verdict.INCOMPATIBLE + assert report.is_installable() is False + assert "npm install/build" in (report.rejection_reason or "") + + +def test_package_peer_dependencies_and_architecture_category_are_preserved( + tmp_path: Path, +) -> None: + source = tmp_path / "agent-loop" + source.mkdir() + (source / "index.js").write_text("export function apply() {}", encoding="utf-8") + (source / "package.json").write_text( + json.dumps( + { + "name": "@deepseek-ai/dsh-agent-loop", + "version": "0.1.0-rc.7", + "type": "module", + "main": "index.js", + "peerDependencies": {"@deepseek-ai/dsh-session": "workspace:^"}, + } + ), + encoding="utf-8", + ) + + report = assess_plugin(source) + + assert report.final_verdict == Verdict.INCOMPATIBLE + assert report.manifest.category == "agent-loop" + assert "single hardened OODA execution loop" in (report.rejection_reason or "") + assert report.execution_plan is not None + assert report.execution_plan.dependencies == ("@deepseek-ai/dsh-session",) + assert report.execution_plan.blockers + + +def test_dynamic_declared_inject_service_is_rejected_before_discovery( + tmp_path: Path, +) -> None: + source = tmp_path / "service-consumer" + source.mkdir() + (source / "meta.json").write_text( + '{"name":"service-consumer","category":"tools"}', encoding="utf-8" + ) + (source / "host.js").write_text( + "return {inject:['greeter','tools'],apply(ctx){harness.registerTool(ctx," + "harness.defineTool({name:'greet',description:'greet',parameters:{}," + "execute:async()=>ctx.greeter.greet('x')}))}}", + encoding="utf-8", + ) + + report = assess_plugin(source) + + assert report.final_verdict == Verdict.INCOMPATIBLE + assert report.execution_plan is not None + assert "required DSH host service: greeter" in (report.rejection_reason or "") + assert "required DSH host service: tools" not in (report.rejection_reason or "") + + +def test_source_inspector_rejects_symlinks(tmp_path: Path) -> None: + source = tmp_path / "plugin" + source.mkdir() + (source / "meta.json").write_text('{"name":"bad"}', encoding="utf-8") + (source / "host.js").write_text("return {apply(){}}", encoding="utf-8") + (source / "escape").symlink_to(tmp_path / "outside") + + with pytest.raises(ValueError, match="symlink"): + inspect_plugin_source(source) + + +@pytest.mark.asyncio +async def test_prebuilt_package_discover_and_invoke() -> None: + host = DshNodeHost( + _PACKAGE, + source_kind=PluginSourceKind.DSH_PACKAGE.value, + entry_point="index.cjs", + ) + try: + discovered = await host.discover() + assert discovered.ok is True, host.stderr_tail + assert [tool["name"] for tool in discovered.result["tools"]] == ["fixture_echo"] + + invoked = await host.invoke("fixture_echo", {"text": "hello"}) + assert invoked.ok is True + assert invoked.result == {"ok": True, "echo": "hello"} + finally: + await host.stop() + + +@pytest.mark.asyncio +async def test_dynamic_export_discovery_and_typed_http_invocation(tmp_path: Path) -> None: + inspection = inspect_plugin_source(_DYNAMIC) + staging, entry = stage_runtime_bundle(inspection, tmp_path, "stock") + fetches: list[dict] = [] + + async def fake_fetch(params: dict) -> dict: + fetches.append(dict(params)) + return {"ok": True, "text": "fixture quote"} + + host = DshNodeHost( + staging, + source_kind=inspection.execution_plan.source_kind.value, + entry_point=entry, + broker=DshCapabilityBroker(web_fetch=fake_fetch), + ) + try: + discovered = await host.discover() + assert discovered.ok is True, host.stderr_tail + assert [tool["name"] for tool in discovered.result["tools"]] == [ + "fixture_stock_quote" + ] + assert discovered.result["handler_channels"] == ["fetch-quote"] + + invoked = await host.invoke("fixture_stock_quote", {"symbol": "AAPL"}) + assert invoked.ok is True + assert invoked.result["raw"] == "fixture quote" + assert fetches[0]["url"] == "https://example.test/quote?q=AAPL" + finally: + await host.stop() + + +@pytest.mark.asyncio +async def test_shell_injection_is_denied_before_any_fetch(tmp_path: Path) -> None: + inspection = inspect_plugin_source(_DYNAMIC) + staging, entry = stage_runtime_bundle(inspection, tmp_path, "stock") + fetches: list[dict] = [] + + async def fake_fetch(params: dict) -> dict: + fetches.append(dict(params)) + return {"ok": True, "text": "must not run"} + + host = DshNodeHost( + staging, + source_kind=inspection.execution_plan.source_kind.value, + entry_point=entry, + broker=DshCapabilityBroker(web_fetch=fake_fetch), + ) + try: + invoked = await host.invoke( + "fixture_stock_quote", + {"symbol": "AAPL'; echo LEAPFLOW_DSH_INJECTION; #"}, + ) + assert invoked.ok is False + assert "only permits" in invoked.error + assert fetches == [] + finally: + await host.stop() + + +@pytest.mark.parametrize( + "command", + [ + "curl -sS https://example.test", + "curl -sS -m 5 'https://example.test'; id", + "curl -sS -m 5 'https://example.test' > /tmp/out", + "curl -sS -m 5 'https://example.test' | sh", + "wget 'https://example.test'", + "curl -sS -m 5 'http://$(whoami)'", + ], +) +def test_curl_shim_rejects_every_non_contract_shape(command: str) -> None: + with pytest.raises(DshCapabilityError): + parse_curl_get(command) + + +def test_curl_shim_accepts_the_exact_legacy_shapes() -> None: + plain = parse_curl_get("curl -sS -m 15 'https://example.test/a?q=1'") + assert plain.url == "https://example.test/a?q=1" + assert plain.decode_gb18030 is False + + gb = parse_curl_get( + "curl -sS -m 20 'https://example.test/a' | iconv -f GB18030 -t UTF-8" + ) + assert gb.decode_gb18030 is True + + +@pytest.mark.asyncio +async def test_worker_timeout_terminates_the_process(tmp_path: Path) -> None: + (tmp_path / "package.json").write_text( + '{"name":"hang","version":"1.0.0","main":"index.cjs","keywords":["tools"]}', + encoding="utf-8", + ) + (tmp_path / "index.cjs").write_text( + "module.exports={apply(ctx){harness.registerTool(ctx,harness.defineTool({" + "name:'hang_forever',description:'hang',parameters:{}," + "execute:async()=>new Promise(()=>{})}))}}", + encoding="utf-8", + ) + host = DshNodeHost( + tmp_path, + source_kind=PluginSourceKind.DSH_PACKAGE.value, + entry_point="index.cjs", + invoke_timeout_s=0.05, + ) + try: + assert (await host.discover()).ok is True + response = await host.invoke("hang_forever", {}) + assert response.ok is False + assert response.error_type == "timeout" + finally: + await host.stop() + + +@pytest.mark.asyncio +async def test_console_output_does_not_pollute_protocol(tmp_path: Path) -> None: + (tmp_path / "index.cjs").write_text( + "console.log('plugin boot noise');module.exports={apply(ctx){" + "harness.registerTool(ctx,harness.defineTool({name:'noisy_tool'," + "description:'noisy',parameters:{},execute:async()=>{console.log('tool noise');" + "return {ok:true}}}))}}", + encoding="utf-8", + ) + host = DshNodeHost( + tmp_path, + source_kind=PluginSourceKind.DSH_PACKAGE.value, + entry_point="index.cjs", + ) + try: + assert (await host.discover()).ok is True + assert (await host.invoke("noisy_tool", {})).ok is True + await asyncio.sleep(0) + assert "plugin boot noise" in host.stderr_tail + assert "tool noise" in host.stderr_tail + finally: + await host.stop() + + +@pytest.mark.asyncio +async def test_prepared_installation_generates_valid_native_wrapper(tmp_path: Path) -> None: + prepared = await prepare_dsh_installation( + _PACKAGE, + plugin_id="fixture-echo", + plugins_dir=tmp_path / "plugins", + dsh_plugins_dir=tmp_path / "plugins" / "dsh", + ) + try: + promote_staging_bundle(prepared.staging_root, prepared.final_root) + result = await PluginValidator().validate( + prepared.plugin_id, prepared.wrapper_source + ) + assert result.ok is True, result.error + assert prepared.compatibility.is_installable() is True + assert prepared.plugin_id == "fixture_echo" + assert [tool.name for tool in prepared.descriptor.tools] == ["fixture_echo"] + assert "plugin = DshBridgePlugin" in prepared.wrapper_source + finally: + shutil.rmtree(tmp_path / "plugins", ignore_errors=True) + + +@pytest.mark.asyncio +async def test_installed_descriptor_rejects_source_tampering(tmp_path: Path) -> None: + prepared = await prepare_dsh_installation( + _PACKAGE, + plugin_id="fixture-echo", + plugins_dir=tmp_path / "plugins", + dsh_plugins_dir=tmp_path / "plugins" / "dsh", + ) + promote_staging_bundle(prepared.staging_root, prepared.final_root) + try: + (prepared.final_root / "index.cjs").write_text( + "module.exports={apply(){/* tampered */}}", encoding="utf-8" + ) + namespace: dict = {} + with pytest.raises(ValueError, match="hash does not match"): + exec(compile(prepared.wrapper_source, "", "exec"), namespace) + finally: + shutil.rmtree(tmp_path / "plugins", ignore_errors=True) + + +@pytest.mark.asyncio +async def test_self_management_dsh_install_reload_status_and_remove( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + import leapflow.plugins as plugins + from leapflow.plugins.registry import ToolPluginRegistry + from leapflow.plugins.scoped_registry import ScopedToolRegistry + from leapflow.plugins.tool_plugins import _load_plugin_from_file + from leapflow.plugins.tool_plugins.self_management import SelfManagementPlugin + + registry = ToolPluginRegistry() + scoped = ScopedToolRegistry(registry) + monkeypatch.setattr(plugins, "_registry", registry) + monkeypatch.setattr(plugins, "_scoped_registry", scoped) + + class Approval: + actions: list = [] + + async def evaluate(self, action): + self.actions.append(action) + return type("Result", (), {"approved": True, "denial_message": ""})() + + class VersionStore: + def record_source(self, *args, **kwargs): + return {"version": "fixture-v1"} + + def active(self, plugin_id): + return None + + def versions(self, plugin_id): + return [] + + manager = SelfManagementPlugin() + manager._plugin_install_dir = str(tmp_path / "plugins") + manager._plugin_version_store = VersionStore() + manager._plugin_approval_gate = Approval() + + result = await manager._plugin_install_handler( + plugin_id="fixture-stock", + source_path=str(_DYNAMIC), + ) + assert result["ok"] is True, result + assert result["plugin_id"] == "fixture_stock" + assert result["verdict"] == "partial" + assert result["limitations"] + assert result["installed_tools"] == ["fixture_stock_quote"] + assert len(manager._plugin_approval_gate.actions) == 1 + approval_metadata = manager._plugin_approval_gate.actions[0].metadata + assert approval_metadata["bundle_sha256"] == result["bundle_sha256"] + assert approval_metadata["verdict"] == "partial" + assert "compat.shell.curl_get" in approval_metadata["permissions"] + assert {item["kind"]: item["status"] for item in approval_metadata["components"]} == { + "host": "candidate", + "client": "unsupported", + } + + plugin = registry.get_plugin("fixture_stock") + assert plugin is not None + + async def fake_fetch(params: dict) -> dict: + return {"ok": True, "text": f"quote:{params['url']}"} + + plugin.bind_runtime(web_fetch=fake_fetch) + invoked = await plugin.tools[0].handler(symbol="AAPL") + assert invoked["ok"] is True + assert invoked["raw"].startswith("quote:https://example.test/quote?q=AAPL") + + status = await manager._plugin_status_handler("fixture_stock") + assert status["ok"] is True + assert status["dsh"]["runtime"] == "node" + assert status["dsh"]["verdict"] == "partial" + assert status["dsh"]["limitations"] + assert status["dsh"]["client_components"][0]["status"] == "unsupported" + + wrapper = tmp_path / "plugins" / "fixture_stock.py" + restarted = _load_plugin_from_file(wrapper) + assert restarted is not None + assert [tool.name for tool in restarted.tools] == ["fixture_stock_quote"] + + old_generation = scoped.get_fiber("fixture_stock").generation + reloaded = await manager._plugin_reload_handler("fixture_stock") + assert reloaded["ok"] is True + assert reloaded["new_generation"] > old_generation + + disabled = await manager._plugin_disable_handler("fixture_stock") + assert disabled["ok"] is True + assert registry.get_plugin("fixture_stock") is None + enabled = await manager._plugin_enable_handler("fixture_stock") + assert enabled["ok"] is True + assert registry.get_plugin("fixture_stock") is not None + + rollback = await manager._plugin_rollback_handler("fixture_stock", "fixture-v1") + assert rollback["ok"] is False + assert rollback["failure_code"] == "dsh_rollback_unsupported" + + removed = await manager._plugin_remove_handler("fixture_stock") + assert removed["ok"] is True + assert wrapper.exists() is False + assert (tmp_path / "plugins" / "dsh" / "fixture_stock").exists() is False + assert registry.get_plugin("fixture_stock") is None + + +def test_ui_only_dynamic_export_is_incompatible(tmp_path: Path) -> None: + source = tmp_path / "ui-only" + source.mkdir() + (source / "meta.json").write_text('{"name":"ui-only"}', encoding="utf-8") + (source / "host.js").write_text( + "return {apply(){harness.handle('private-only', async()=>({ok:true}))}}", + encoding="utf-8", + ) + (source / "client.js").write_text( + "return {apply(ctx){ctx.slots.inject('main', {})}}", encoding="utf-8" + ) + + report = assess_plugin(source) + + assert report.final_verdict == Verdict.INCOMPATIBLE + assert report.is_installable() is False + assert "no statically visible registerTool" in (report.rejection_reason or "") + + +def test_unsupported_host_service_is_a_static_blocker(tmp_path: Path) -> None: + source = tmp_path / "unsupported-service" + source.mkdir() + (source / "meta.json").write_text('{"name":"unsupported-service"}', encoding="utf-8") + (source / "host.js").write_text( + "return {apply(ctx){ctx.get('storage');harness.registerTool(ctx," + "harness.defineTool({name:'bad_service',description:'bad',parameters:{}," + "execute:async()=>({ok:true})}))}}", + encoding="utf-8", + ) + + report = assess_plugin(source) + + assert report.final_verdict == Verdict.INCOMPATIBLE + assert "required DSH host service: storage" in (report.rejection_reason or "") + + +@pytest.mark.asyncio +async def test_discovery_rejects_invalid_tool_schema_and_cleans_staging( + tmp_path: Path, +) -> None: + source = _write_package( + tmp_path / "source", + "module.exports={apply(ctx){harness.registerTool(ctx,harness.defineTool({" + "name:'bad_schema',description:'bad schema',parameters:{type:'array'}," + "execute:async()=>[]}))}}", + name="bad-schema", + ) + dsh_root = tmp_path / "plugins" / "dsh" + + with pytest.raises(DshInstallError, match="discovery failed"): + await prepare_dsh_installation( + source, + plugin_id="bad-schema", + plugins_dir=tmp_path / "plugins", + dsh_plugins_dir=dsh_root, + ) + + assert list(dsh_root.glob(".bad_schema.staging-*")) == [] + + +@pytest.mark.asyncio +async def test_worker_bounds_stderr_tail_and_oversized_result(tmp_path: Path) -> None: + source = _write_package( + tmp_path / "bounded", + "console.error('noise-'+'x'.repeat(10000)+'-tail-marker');" + "module.exports={apply(ctx){harness.registerTool(ctx,harness.defineTool({" + "name:'large_result',description:'large',parameters:{}," + "execute:async()=>({value:'y'.repeat(5000)})}))}}", + name="bounded-worker", + ) + host = DshNodeHost( + source, + source_kind=PluginSourceKind.DSH_PACKAGE.value, + entry_point="index.cjs", + max_line_bytes=1024, + max_stderr_bytes=1024, + ) + try: + assert (await host.discover()).ok is True + await asyncio.sleep(0.02) + assert len(host.stderr_tail.encode("utf-8")) <= 1024 + assert "tail-marker" in host.stderr_tail + + response = await host.invoke("large_result", {}) + assert response.ok is False + assert response.error_type == "response_too_large" + + oversized_request = await host.invoke("large_result", {"value": "z" * 5000}) + assert oversized_request.ok is False + assert oversized_request.error_type == "request_too_large" + finally: + await host.stop() + + +@pytest.mark.asyncio +async def test_host_rejects_a_mismatched_response_request_id(tmp_path: Path) -> None: + class FakeStdin: + def write(self, value: bytes) -> None: + return None + + async def drain(self) -> None: + return None + + class FakeStdout: + async def readline(self) -> bytes: + return b'{"version":1,"type":"response","request_id":"wrong","ok":true}\n' + + class FakeProcess: + stdin = FakeStdin() + stdout = FakeStdout() + stderr = None + returncode = 0 + + host = DshNodeHost( + tmp_path, + source_kind=PluginSourceKind.DSH_PACKAGE.value, + entry_point="index.cjs", + ) + host._proc = FakeProcess() # type: ignore[assignment] + + response = await host.discover() + + assert response.ok is False + assert response.error_type == "protocol_error" + assert "response id mismatch" in response.error + await host.stop() + + +@pytest.mark.asyncio +async def test_bridge_invocations_do_not_share_node_module_state(tmp_path: Path) -> None: + source = _write_package( + tmp_path / "stateful", + "let counter=0;module.exports={apply(ctx){harness.registerTool(ctx," + "harness.defineTool({name:'isolated_counter',description:'counter',parameters:{}," + "execute:async()=>{counter+=1;await new Promise(r=>setTimeout(r,20));" + "return {counter}}}))}}", + name="stateful", + ) + prepared = await prepare_dsh_installation( + source, + plugin_id="stateful", + plugins_dir=tmp_path / "plugins", + dsh_plugins_dir=tmp_path / "plugins" / "dsh", + ) + promote_staging_bundle(prepared.staging_root, prepared.final_root) + try: + namespace: dict = {} + exec(compile(prepared.wrapper_source, "", "exec"), namespace) + plugin = namespace["plugin"] + + first, second = await asyncio.gather( + plugin.tools[0].handler(), plugin.tools[0].handler() + ) + + assert first["counter"] == 1 + assert second["counter"] == 1 + finally: + shutil.rmtree(tmp_path / "plugins", ignore_errors=True) + + +@pytest.mark.asyncio +async def test_capability_returns_raw_json_and_enforces_utf8_byte_limit() -> None: + calls: list[dict] = [] + + async def fake_fetch(params: dict) -> dict: + calls.append(dict(params)) + return {"ok": True, "data": {"message": "中文内容"}} + + broker = DshCapabilityBroker(web_fetch=fake_fetch) + result = await broker.dispatch( + "compat.shell.run", + { + "command": ( + "curl -sS -m 5 'https://example.test/data' " + "| iconv -f GB18030 -t UTF-8" + ), + "stdoutMaxBytes": 12, + }, + ) + + assert calls[0]["extract"] == "raw_text" + assert calls[0]["encoding"] == "gb18030" + assert len(result["stdout"]["text"].encode("utf-8")) <= 12 + assert result["stdout"]["text"].startswith('{"message"') + + +@pytest.mark.asyncio +async def test_source_path_install_uses_workspace_approval_before_plugin_approval( + tmp_path: Path, +) -> None: + from leapflow.plugins.tool_plugins.self_management import SelfManagementPlugin + from leapflow.security.actions import ActionKind + from leapflow.tools.execution_context import ( + ToolExecutionContext, + reset_tool_context, + set_tool_context, + ) + + workspace = tmp_path / "workspace" + workspace.mkdir() + + class WorkspaceGate: + actions: list = [] + + async def evaluate(self, action): + self.actions.append(action) + return type( + "Result", (), {"approved": False, "denial_message": "outside denied"} + )() + + class PluginGate: + async def evaluate(self, action): + raise AssertionError("plugin approval must not run after workspace denial") + + gate = WorkspaceGate() + manager = SelfManagementPlugin() + manager._plugin_approval_gate = PluginGate() + token = set_tool_context( + ToolExecutionContext.from_strings( + workspace_root=str(workspace), + session_id="dsh-workspace-gate", + orchestrator=gate, + ) + ) + try: + result = await manager._plugin_install_handler(source_path=str(_DYNAMIC)) + finally: + reset_tool_context(token) + + assert result["ok"] is False + assert result["error"] == "outside denied" + assert len(gate.actions) == 1 + assert gate.actions[0].kind == ActionKind.WORKSPACE_ESCAPE.value + + +@pytest.mark.asyncio +async def test_failed_registration_rolls_back_wrapper_and_bundle( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + import leapflow.plugins as plugins + from leapflow.plugins.registry import ToolPluginRegistry + from leapflow.plugins.scoped_registry import ScopedToolRegistry + from leapflow.plugins.tool_plugins.self_management import SelfManagementPlugin + + registry = ToolPluginRegistry() + scoped = ScopedToolRegistry(registry) + monkeypatch.setattr(plugins, "_registry", registry) + monkeypatch.setattr(plugins, "_scoped_registry", scoped) + + class Approval: + async def evaluate(self, action): + return type("Result", (), {"approved": True, "denial_message": ""})() + + manager = SelfManagementPlugin() + manager._plugin_install_dir = str(tmp_path / "plugins") + manager._plugin_approval_gate = Approval() + monkeypatch.setattr( + manager, + "_register_inprocess", + lambda plugin_id, module_name, target: { + "ok": False, + "error": "forced registration failure", + }, + ) + + result = await manager._plugin_install_handler( + plugin_id="rollback-fixture", + source_path=str(_PACKAGE), + ) + + assert result == {"ok": False, "error": "forced registration failure"} + assert not (tmp_path / "plugins" / "rollback_fixture.py").exists() + assert not (tmp_path / "plugins" / "dsh" / "rollback_fixture").exists() diff --git a/tests/test_web_fetch.py b/tests/test_web_fetch.py index 94d9716..947d514 100644 --- a/tests/test_web_fetch.py +++ b/tests/test_web_fetch.py @@ -359,6 +359,30 @@ def test_html_fetch_extracts_text_and_links(monkeypatch) -> None: assert result["extractor"] == "stdlib" +def test_raw_text_fetch_honours_the_approved_encoding_override(monkeypatch) -> None: + body = "中文报价".encode("gb18030") + transport = _FakeTransport(_outcome(content_type="text/plain", body=body)) + _install(monkeypatch, transport) + + result = _run( + {"url": PUBLIC_URL, "extract": "raw_text", "encoding": "gb18030"} + ) + + assert result["ok"] is True + assert result["text"] == "中文报价" + + +def test_raw_text_fetch_rejects_arbitrary_encoding(monkeypatch) -> None: + transport = _FakeTransport(_outcome(content_type="text/plain", body=b"unused")) + _install(monkeypatch, transport) + + result = _run({"url": PUBLIC_URL, "extract": "raw_text", "encoding": "utf-16"}) + + assert result["ok"] is False + assert result["error_type"] == "invalid_encoding" + assert transport.requests == [] + + def test_binary_content_is_not_returned_inline(monkeypatch) -> None: transport = _FakeTransport( _outcome(content_type="application/pdf", body=b"%PDF-1.7 binary...")