lodestar-langgraph 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,38 @@
1
+ """lodestar-langgraph — govern a LangGraph agent's native tool calls with Lodestar.
2
+
3
+ The thin native hook of the runtime-adapter epic (ADR-0024). It spawns the
4
+ TypeScript governance-gate sidecar (``lodestar runtime gate``) and remotes each
5
+ native LangGraph tool call through the Action Kernel over NDJSON-RPC — so the
6
+ same two-phase execution, policy gate, cognitive-core ingestion, sentinel
7
+ arbitration, and signed-approval hold path the MCP proxy runs now apply to a
8
+ framework that does not speak MCP.
9
+
10
+ Quick start::
11
+
12
+ from lodestar_langgraph import GateClient, govern_tools, governed_call
13
+
14
+ with GateClient("runtime-gate.config.json") as gate:
15
+ governed = govern_tools(gate, my_tools)
16
+ llm = llm.bind_tools(governed)
17
+ tool_node = ToolNode(governed)
18
+ # ... build and run your graph as usual ...
19
+ """
20
+
21
+ from .adapter import (
22
+ DEFAULT_HOLD_WAIT_MS,
23
+ LodestarDenied,
24
+ govern_tools,
25
+ governed_call,
26
+ )
27
+ from lodestar_runtime_client import GateClient, GateError
28
+
29
+ __all__ = [
30
+ "GateClient",
31
+ "GateError",
32
+ "govern_tools",
33
+ "governed_call",
34
+ "LodestarDenied",
35
+ "DEFAULT_HOLD_WAIT_MS",
36
+ ]
37
+
38
+ __version__ = "0.3.0"
@@ -0,0 +1,169 @@
1
+ """LangGraph / LangChain integration for the Lodestar governance gate (ADR-0024).
2
+
3
+ The adapter governs the framework's **tool-invocation surface** and nothing
4
+ implicitly (ADR-0024 §3, one closed fail-closed surface):
5
+
6
+ * :func:`govern_tools` registers every bound tool with the gate and returns
7
+ *wrapped* tools to hand to ``bind_tools`` AND the prebuilt ``ToolNode`` — a
8
+ governed wrapper is the only object the agent ever holds for a governed
9
+ capability. The wrapper routes each call through the gate (``propose →
10
+ arbitrate``); only on an ``allow`` does the gate remote the body back to run.
11
+ * :func:`governed_call` is the helper a **custom node** uses to invoke a governed
12
+ tool — never a raw tool function.
13
+ * A call for a tool that was never registered is **denied by the gate** (fail
14
+ closed). Raw I/O performed outside the tool abstraction is outside the governed
15
+ surface, exactly as ``guard.wrap()`` and the MCP proxy only govern the tools
16
+ they are given — pair with network/filesystem controls for defense in depth.
17
+
18
+ Holds (an L4 action the trust-ladder floor parks for approval) are resolved by
19
+ **block-polling** the gate up to the deadline for a *signed* approval
20
+ (``hold_wait_ms``) — the headless default the ADR sanctions. For the idiomatic
21
+ LangGraph ``interrupt()`` integration, call :func:`GateClient.govern` directly
22
+ and raise ``interrupt`` with the returned ``action_id`` / ``request_id``, then
23
+ :func:`GateClient.resume` on ``Command(resume=…)``.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import asyncio
29
+ from typing import Any, Callable, Iterable, Optional
30
+
31
+ from lodestar_runtime_client import GateClient
32
+
33
+ # Default block-poll budget for a held action, in ms. Keep comfortably under the
34
+ # graph/client timeout; 0 means "don't wait" (surface the hold immediately).
35
+ DEFAULT_HOLD_WAIT_MS = 60_000
36
+
37
+
38
+ class LodestarDenied(Exception):
39
+ """A governed tool call was denied, held-then-timed-out, or failed.
40
+
41
+ ``kind`` is the machine tag from the gate (``policy_denied``,
42
+ ``approval_denied``, ``approval_timeout``, ``unregistered_tool``,
43
+ ``precondition_failed``, ``execution_failed``).
44
+ """
45
+
46
+ def __init__(self, reason: str, kind: str, action_id: Optional[str] = None) -> None:
47
+ super().__init__(reason)
48
+ self.reason = reason
49
+ self.kind = kind
50
+ self.action_id = action_id
51
+
52
+
53
+ def governed_call(
54
+ client: GateClient,
55
+ tool: str,
56
+ args: dict,
57
+ *,
58
+ hold_wait_ms: int = DEFAULT_HOLD_WAIT_MS,
59
+ ) -> Any:
60
+ """Invoke a governed tool through the gate and return its output.
61
+
62
+ Drives the full two-phase flow: ``govern``; on a hold, block-poll ``resume``
63
+ up to ``hold_wait_ms`` for a signed approval; on completion, return the tool
64
+ output. Raises :class:`LodestarDenied` on any non-completion (including an
65
+ unregistered tool — fail closed). This is the helper a custom LangGraph node
66
+ calls; never invoke a raw tool function from a custom node.
67
+ """
68
+ result = client.govern(tool, args)
69
+ if result.get("phase") == "pending_approval":
70
+ result = client.resume(
71
+ str(result.get("action_id")),
72
+ str(result.get("request_id")),
73
+ wait_ms=hold_wait_ms,
74
+ )
75
+ phase = result.get("phase")
76
+ if phase == "completed":
77
+ return result.get("output")
78
+ raise LodestarDenied(
79
+ str(result.get("reason") or "governed tool call was not allowed"),
80
+ str(result.get("kind") or phase or "denied"),
81
+ result.get("action_id"),
82
+ )
83
+
84
+
85
+ def govern_tools(
86
+ client: GateClient,
87
+ tools: Iterable[Any],
88
+ *,
89
+ hold_wait_ms: int = DEFAULT_HOLD_WAIT_MS,
90
+ on_denied: Optional[Callable[[LodestarDenied], Any]] = None,
91
+ ) -> list[Any]:
92
+ """Register and wrap a LangChain toolset for governance.
93
+
94
+ Returns governed ``StructuredTool``s to pass to BOTH ``llm.bind_tools(...)``
95
+ and the prebuilt ``ToolNode(...)``, so the agent never holds an ungoverned
96
+ handle. Each wrapper runs the call through the gate; the gate remotes the
97
+ *original* tool body back to run only inside its execute phase.
98
+
99
+ ``on_denied`` maps a :class:`LodestarDenied` to a tool return value (so a
100
+ ``ToolNode`` surfaces a re-plannable message rather than raising); the default
101
+ re-raises as a ``ToolException`` so the framework's own error handling
102
+ applies.
103
+ """
104
+ # Imported lazily so `from lodestar_langgraph import GateClient` works without
105
+ # langchain installed (the client is pure stdlib).
106
+ from langchain_core.tools import StructuredTool, ToolException
107
+
108
+ governed: list[Any] = []
109
+ for tool in tools:
110
+ name = tool.name
111
+ # Bind the ORIGINAL tool body for the gate's remoted execute. Using the
112
+ # original (not the wrapper) is what prevents recursion.
113
+ client.register_tool(name, _body_for(tool))
114
+ governed.append(_wrap_tool(client, tool, hold_wait_ms, on_denied, StructuredTool, ToolException))
115
+ return governed
116
+
117
+
118
+ def _body_for(tool: Any) -> Callable[[dict], dict]:
119
+ """A run_tool body that executes the real LangChain tool and wraps its result
120
+ into the gate's tool-result shape.
121
+
122
+ The gate remotes the body on a worker thread with no running event loop, so we
123
+ use the synchronous ``tool.invoke`` for a tool with a sync implementation, and
124
+ fall back to running the coroutine for an **async-only** tool (one defined with
125
+ a ``coroutine`` and no sync ``func``): for such a tool ``invoke`` raises
126
+ ``NotImplementedError``, so it must go through ``ainvoke``. This serves both
127
+ sync and async tools regardless of whether the graph drove ``invoke`` or
128
+ ``ainvoke``.
129
+ """
130
+
131
+ def body(args: dict) -> dict:
132
+ try:
133
+ output = tool.invoke(args)
134
+ except NotImplementedError:
135
+ # Async-only tool: run its coroutine in this (loop-less) worker thread.
136
+ output = asyncio.run(tool.ainvoke(args))
137
+ documents: list[dict] = []
138
+ # A tool may surface untrusted document content for external_document
139
+ # evidence by returning {"output": ..., "_lodestar_documents": [...]}.
140
+ if isinstance(output, dict) and "_lodestar_documents" in output:
141
+ documents = list(output.get("_lodestar_documents") or [])
142
+ output = output.get("output")
143
+ return {"output": output, "documents": documents}
144
+
145
+ return body
146
+
147
+
148
+ def _wrap_tool(
149
+ client: GateClient,
150
+ tool: Any,
151
+ hold_wait_ms: int,
152
+ on_denied: Optional[Callable[[LodestarDenied], Any]],
153
+ structured_tool_cls: Any,
154
+ tool_exception_cls: Any,
155
+ ) -> Any:
156
+ def governed_func(**kwargs: Any) -> Any:
157
+ try:
158
+ return governed_call(client, tool.name, kwargs, hold_wait_ms=hold_wait_ms)
159
+ except LodestarDenied as denied:
160
+ if on_denied is not None:
161
+ return on_denied(denied)
162
+ raise tool_exception_cls(f"[lodestar:{denied.kind}] {denied.reason}") from denied
163
+
164
+ return structured_tool_cls.from_function(
165
+ func=governed_func,
166
+ name=tool.name,
167
+ description=getattr(tool, "description", "") or tool.name,
168
+ args_schema=getattr(tool, "args_schema", None),
169
+ )
@@ -0,0 +1,89 @@
1
+ Metadata-Version: 2.4
2
+ Name: lodestar-langgraph
3
+ Version: 0.3.0
4
+ Summary: Govern a LangGraph agent's native tool calls with Lodestar — the thin native hook that remotes each tool call through the Lodestar Action Kernel over NDJSON-RPC (ADR-0024).
5
+ Project-URL: Homepage, https://qmilab.com/lodestar
6
+ Project-URL: Repository, https://github.com/qmilab/lodestar
7
+ Project-URL: Issues, https://github.com/qmilab/lodestar/issues
8
+ Author-email: QMI Lab <hello@qmilab.com>
9
+ License: Apache-2.0
10
+ Keywords: agents,ai-agents,governance,langgraph,lodestar,trust
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: Apache Software License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Requires-Python: >=3.10
15
+ Requires-Dist: lodestar-runtime-client==0.3.0
16
+ Provides-Extra: dev
17
+ Requires-Dist: langchain-core>=0.3.0; extra == 'dev'
18
+ Requires-Dist: langgraph>=0.2.0; extra == 'dev'
19
+ Requires-Dist: pytest>=8.0; extra == 'dev'
20
+ Provides-Extra: langgraph
21
+ Requires-Dist: langchain-core>=0.3.0; extra == 'langgraph'
22
+ Requires-Dist: langgraph>=0.2.0; extra == 'langgraph'
23
+ Description-Content-Type: text/markdown
24
+
25
+ # lodestar-langgraph
26
+
27
+ Govern a **LangGraph** agent's native tool calls with
28
+ [Lodestar](https://qmilab.com/lodestar) — the open epistemic-governance framework
29
+ for AI agents.
30
+
31
+ LangGraph runs tools natively in-process and does not speak MCP, so the MCP proxy
32
+ cannot wrap it. This package is the **thin native hook** (ADR-0024): it spawns the
33
+ TypeScript **governance-gate sidecar** (`lodestar runtime gate`) and remotes each
34
+ native tool call through the Lodestar Action Kernel over newline-delimited
35
+ JSON-RPC. The same machinery the MCP proxy runs — two-phase `propose → arbitrate →
36
+ execute`, the signed policy gate, cognitive-core ingestion (external-document
37
+ content can't auto-promote), sentinel arbitration, and the signed-approval L4 hold
38
+ path — now applies to LangGraph, with no change to the engine.
39
+
40
+ The tool body runs **only** inside the gate's execute phase, reached only after
41
+ the gate (and any approval hold) clears: "tools that do work before approval are
42
+ bugs" — across the Python↔TS boundary.
43
+
44
+ ## Install
45
+
46
+ ```bash
47
+ pip install "lodestar-langgraph[langgraph]"
48
+ # and the Lodestar CLI (Bun/npm), which provides `lodestar runtime gate`:
49
+ npm install -g @qmilab/lodestar-cli # or: bun add -g @qmilab/lodestar-cli
50
+ ```
51
+
52
+ ## Use
53
+
54
+ ```python
55
+ from langgraph.prebuilt import ToolNode
56
+ from lodestar_langgraph import GateClient, govern_tools, governed_call
57
+
58
+ with GateClient("runtime-gate.config.json") as gate:
59
+ governed = govern_tools(gate, my_tools) # register + wrap the toolset
60
+ llm = llm.bind_tools(governed) # the model only sees governed tools
61
+ tool_node = ToolNode(governed) # and so does the executor
62
+ # ... build and run your graph as usual; every tool call is now governed.
63
+
64
+ # A custom node invokes a governed tool through the helper, never raw:
65
+ result = governed_call(gate, "search_web", {"q": "lodestar"})
66
+ ```
67
+
68
+ The gate's config (`runtime-gate.config.json`) is a `RuntimeGateConfig` — the
69
+ signed policy document, approver keys, sentinel ids, persistence, and durable log
70
+ root all live there. The hook never holds credentials or policy.
71
+
72
+ ## Scope (honest, ADR-0004 lineage)
73
+
74
+ This is **governance over declared actions, not OS containment of the process.**
75
+ Raw I/O performed *outside* the tool abstraction — a custom node that calls
76
+ `requests.get()` directly instead of a registered tool — is outside the governed
77
+ surface, exactly as `guard.wrap()` and the MCP proxy only govern the tools they
78
+ are given. A call for an unregistered tool is **denied** (fail closed). Pair the
79
+ adapter with network/filesystem controls for defense in depth.
80
+
81
+ ## Holds
82
+
83
+ An L4 action the trust-ladder floor parks for approval is resolved by
84
+ block-polling the gate up to the deadline for a *signed* approval (`hold_wait_ms`)
85
+ — the headless default. For the idiomatic LangGraph `interrupt()` integration,
86
+ call `gate.govern(...)` directly, raise `interrupt` with the returned `action_id`
87
+ / `request_id`, and call `gate.resume(...)` on `Command(resume=…)`.
88
+
89
+ Apache-2.0. Part of the Lodestar monorepo (`runtimes/langgraph/`).
@@ -0,0 +1,5 @@
1
+ lodestar_langgraph/__init__.py,sha256=arRAaQgD_E-3HFJ_qRw4cURJhEgObrb0an4LWXR-MtU,1170
2
+ lodestar_langgraph/adapter.py,sha256=z5wR017n1dAEzuxUhJpIBywoIiv9P8gX0sxeJ90yeHA,7124
3
+ lodestar_langgraph-0.3.0.dist-info/METADATA,sha256=ENACoZgr_1qBi-izo-UUonRh1VPNb7kxVDq2JEBx8sA,4196
4
+ lodestar_langgraph-0.3.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
5
+ lodestar_langgraph-0.3.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any