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,,
|