diff --git a/README.md b/README.md index f0325f9..87425e0 100644 --- a/README.md +++ b/README.md @@ -144,6 +144,7 @@ pyro demo ollama -v - Python lifecycle example: [examples/python_lifecycle.py](/home/thales/projects/personal/pyro/examples/python_lifecycle.py) - MCP client config example: [examples/mcp_client_config.md](/home/thales/projects/personal/pyro/examples/mcp_client_config.md) - OpenAI Responses API example: [examples/openai_responses_vm_run.py](/home/thales/projects/personal/pyro/examples/openai_responses_vm_run.py) +- LangChain wrapper example: [examples/langchain_vm_run.py](/home/thales/projects/personal/pyro/examples/langchain_vm_run.py) - Agent-ready `vm_run` example: [examples/agent_vm_run.py](/home/thales/projects/personal/pyro/examples/agent_vm_run.py) ## Python SDK diff --git a/docs/integrations.md b/docs/integrations.md index 55efb64..d0ae2fc 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -83,6 +83,10 @@ Recommended pattern: - map framework tool input directly onto `vm_run` - avoid exposing lifecycle tools unless the framework truly needs them +Concrete example: + +- [examples/langchain_vm_run.py](/home/thales/projects/personal/pyro/examples/langchain_vm_run.py) + ## Selection Rule Choose the narrowest integration that matches the host environment: diff --git a/examples/langchain_vm_run.py b/examples/langchain_vm_run.py new file mode 100644 index 0000000..32998b0 --- /dev/null +++ b/examples/langchain_vm_run.py @@ -0,0 +1,84 @@ +"""Thin LangChain wrapper over the public vm_run contract. + +Requirements: +- `pip install langchain-core` or `uv add langchain-core` + +This example keeps the framework integration intentionally narrow: a single tool +that delegates straight to `Pyro.run_in_vm(...)`. +""" + +from __future__ import annotations + +import json +from typing import Any, Callable, TypeVar, cast + +from pyro_mcp import Pyro + +F = TypeVar("F", bound=Callable[..., Any]) + + +def run_vm_run_tool( + *, + profile: str, + command: str, + vcpu_count: int, + mem_mib: int, + timeout_seconds: int = 30, + ttl_seconds: int = 600, + network: bool = False, +) -> str: + pyro = Pyro() + result = pyro.run_in_vm( + profile=profile, + command=command, + vcpu_count=vcpu_count, + mem_mib=mem_mib, + timeout_seconds=timeout_seconds, + ttl_seconds=ttl_seconds, + network=network, + ) + return json.dumps(result, sort_keys=True) + + +def build_langchain_vm_run_tool() -> Any: + try: + from langchain_core.tools import tool # type: ignore[import-not-found] + except ImportError as exc: # pragma: no cover - dependency guard + raise RuntimeError( + "langchain-core is required for this example. Install it with " + "`uv add langchain-core` or `pip install langchain-core`." + ) from exc + + decorator = cast(Callable[[F], F], tool("vm_run")) + + @decorator + def vm_run( + profile: str, + command: str, + vcpu_count: int, + mem_mib: int, + timeout_seconds: int = 30, + ttl_seconds: int = 600, + network: bool = False, + ) -> str: + """Run one command in an ephemeral Firecracker VM and clean it up.""" + return run_vm_run_tool( + profile=profile, + command=command, + vcpu_count=vcpu_count, + mem_mib=mem_mib, + timeout_seconds=timeout_seconds, + ttl_seconds=ttl_seconds, + network=network, + ) + + return vm_run + + +def main() -> None: + tool = build_langchain_vm_run_tool() + print(tool) + + +if __name__ == "__main__": + main() diff --git a/tests/test_langchain_example.py b/tests/test_langchain_example.py new file mode 100644 index 0000000..854c2a0 --- /dev/null +++ b/tests/test_langchain_example.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path +from types import ModuleType +from typing import Any, cast + +import pytest + + +def _load_langchain_example_module() -> ModuleType: + path = Path("examples/langchain_vm_run.py") + spec = importlib.util.spec_from_file_location("langchain_vm_run", path) + if spec is None or spec.loader is None: + raise AssertionError("failed to load LangChain example module") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def test_langchain_example_delegates_to_pyro(monkeypatch: pytest.MonkeyPatch) -> None: + module = _load_langchain_example_module() + monkeypatch.setattr( + module, + "Pyro", + lambda: type( + "StubPyro", + (), + { + "run_in_vm": staticmethod( + lambda **kwargs: {"exit_code": 0, "stdout": kwargs["command"], "network": False} + ) + }, + )(), + ) + result = module.run_vm_run_tool( + profile="debian-git", + command="git --version", + vcpu_count=1, + mem_mib=1024, + ) + assert "git --version" in result + + +def test_langchain_example_builds_vm_run_tool(monkeypatch: pytest.MonkeyPatch) -> None: + module = _load_langchain_example_module() + fake_langchain_tools = ModuleType("langchain_core.tools") + + def fake_tool(name: str) -> Any: + def decorator(fn: Any) -> Any: + fn.name = name + return fn + + return decorator + + cast(Any, fake_langchain_tools).tool = fake_tool + fake_langchain_core = ModuleType("langchain_core") + monkeypatch.setitem(sys.modules, "langchain_core", fake_langchain_core) + monkeypatch.setitem(sys.modules, "langchain_core.tools", fake_langchain_tools) + + tool = module.build_langchain_vm_run_tool() + + assert tool.name == "vm_run"