vsl-langgraph 0.1.2__tar.gz

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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Super Semantics
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,141 @@
1
+ Metadata-Version: 2.4
2
+ Name: vsl-langgraph
3
+ Version: 0.1.2
4
+ Summary: LangGraph adapter for VSL (VERBA Specification Language) governance primitives -- implements vsl-core's VSLAdapter conformance contract.
5
+ License: MIT
6
+ Project-URL: Homepage, https://github.com/supersemantics/VSL-Langgraph
7
+ Project-URL: Repository, https://github.com/supersemantics/VSL-Langgraph
8
+ Project-URL: Changelog, https://github.com/supersemantics/VSL-Langgraph/blob/HEAD/CHANGELOG.md
9
+ Project-URL: Issues, https://github.com/supersemantics/VSL-Langgraph/issues
10
+ Keywords: governance,ai-safety,guardrails,langgraph,vsl,verba
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Development Status :: 3 - Alpha
15
+ Requires-Python: >=3.10
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE
18
+ Requires-Dist: super-semantics-vsl<0.4,>=0.3
19
+ Requires-Dist: langgraph>=0.2
20
+ Provides-Extra: dev
21
+ Requires-Dist: pytest>=7; extra == "dev"
22
+ Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
23
+ Provides-Extra: examples
24
+ Requires-Dist: openai>=1.0; extra == "examples"
25
+ Requires-Dist: anthropic>=0.40; extra == "examples"
26
+ Requires-Dist: google-genai>=0.5; extra == "examples"
27
+ Dynamic: license-file
28
+
29
+ # vsl-langgraph
30
+
31
+ A [LangGraph](https://github.com/langchain-ai/langgraph) adapter for
32
+ [`vsl-core`](https://github.com/supersemantics/VSL-Core): implements `vsl_core.conformance.protocol.VSLAdapter`
33
+ and provides the LangGraph-specific wiring to call a compiled gate from a
34
+ `StateGraph` node and route around a denial.
35
+
36
+ **Is:**
37
+ - A `VSLAdapter` implementation (`LangGraphAdapter`) that passes `vsl_core.conformance.suite.run_conformance_suite` with an empty result.
38
+ - A small set of wiring helpers (`gated_node`, `route_on_denial`) for calling a compiled gate immediately before a node's side effect and routing to a blocked/terminal node via `add_conditional_edges` on denial.
39
+
40
+ **Is not:**
41
+ - A replacement for `vsl-core` itself -- the PreNode/Invariant/Fallback/TerminalState constructs, the ledger, and the governance vocabulary all live in `vsl-core`. This package only compiles and wires those constructs into LangGraph.
42
+ - Automatic interception. LangGraph has no dedicated guardrail/hook type -- a node is a plain callable, so the gate is called explicitly inside `gated_node`, the same way `vsl-core`'s own docs describe calling a gate in plain Python.
43
+
44
+ ## Documentation
45
+
46
+ - [docs/concepts.md](https://github.com/supersemantics/VSL-Langgraph/blob/HEAD/docs/concepts.md) -- the vocabulary (`PreNode`, `Invariant`, `GammaEstimate`, `AssuranceBasis`/F1-F2, `CompiledGate`), a diagram of the gate lifecycle, which gate shape fits which kind of LangGraph node, and a complete scope table for every `vsl-core` construct -- not just the two this adapter compiles.
47
+ - [docs/quickstart.md](https://github.com/supersemantics/VSL-Langgraph/blob/HEAD/docs/quickstart.md) -- install through a working chained-gate graph, building up from one gate to real LLM-backed gates, with real diagrams generated from the actual compiled graphs.
48
+ - [docs/api-reference.md](https://github.com/supersemantics/VSL-Langgraph/blob/HEAD/docs/api-reference.md) -- `LangGraphAdapter` / `gated_node` / `route_on_denial`, every parameter, scannable.
49
+ - [docs/beyond-the-gate.md](https://github.com/supersemantics/VSL-Langgraph/blob/HEAD/docs/beyond-the-gate.md) -- everything `vsl-core` defines that this adapter doesn't compile (`TerminalState`, `VerbaLedger`, re-enablement, `Cluster`, `Specification`/`Proofing`/`Binding`) but that still applies inside a LangGraph app, each with real, run worked examples.
50
+ - [docs/dos-and-donts.md](https://github.com/supersemantics/VSL-Langgraph/blob/HEAD/docs/dos-and-donts.md) -- a scannable checklist for writing or reviewing `vsl-langgraph` code, each rule traced back to a real reason.
51
+ - [docs/testing-your-gates.md](https://github.com/supersemantics/VSL-Langgraph/blob/HEAD/docs/testing-your-gates.md) -- how to prove your own `PreNode`/`Invariant` wiring is correct, generalized from this repo's own test suite.
52
+ - [docs/troubleshooting.md](https://github.com/supersemantics/VSL-Langgraph/blob/HEAD/docs/troubleshooting.md) -- real, reproduced failure modes (not guessed) with exact error text and fixes.
53
+
54
+ ## Install
55
+
56
+ ```
57
+ pip install vsl-langgraph
58
+ ```
59
+
60
+ This installs vsl-core (`super-semantics-vsl`, imported as `vsl_core`) and LangGraph with it. From a
61
+ local clone of this repo: `pip install -e .`
62
+
63
+ ## Quick taste
64
+
65
+ ```python
66
+ gate = LangGraphAdapter().compile_pre_node(pre_node) # or .compile_invariant(invariant)
67
+
68
+ graph.add_node("do_the_thing", gated_node(gate, do_the_thing))
69
+ graph.add_conditional_edges("do_the_thing", route_on_denial("blocked", "allowed"))
70
+ ```
71
+
72
+ `gated_node` calls `gate` immediately before `do_the_thing` runs; on denial, `do_the_thing` never runs and `route_on_denial` sends the graph to `"blocked"` instead. Full walkthrough with real output: [docs/quickstart.md](https://github.com/supersemantics/VSL-Langgraph/blob/HEAD/docs/quickstart.md).
73
+
74
+ ## Sample agents
75
+
76
+ `examples/` is organized by domain -- see [examples/README.md](https://github.com/supersemantics/VSL-Langgraph/blob/HEAD/examples/README.md) for the full list and the roadmap for domains not built yet. Four so far:
77
+
78
+ - **`examples/financial-agents/purchase_approval_agent.py`** -- both governance primitives chained in one graph, synthetic and LLM-free. The fastest read of the gate-wiring pattern.
79
+ - **`examples/marketing/research_publish_agent.py`** -- the same pattern behind five real LLM calls across three providers (Gemini, OpenAI, three Claude models), with an automatic offline fallback when no API keys are set.
80
+ - **`examples/customer-support/refund_support_agent.py`** -- the one with a *dynamic* graph topology: an LLM picks which tool to call (`lookup_order` / `issue_refund` / `send_email`) and a plain LangGraph router sends the run to whichever node it chose, before any vsl-core gate is even reached. `issue_refund` is gated by an `Invariant`, `send_email` by a `PreNode`, `lookup_order` is ungated -- straight from `vsl-core`'s own canonical worked example.
81
+ - **`examples/chat-conversation/chat_reply_agent.py`** -- governance on what the agent is about to *say*, not a backend action: an `Invariant` blocking an unverified guarantee, a `PreNode` handing off to a human on low confidence.
82
+
83
+ ```
84
+ python examples/financial-agents/purchase_approval_agent.py
85
+ python examples/marketing/research_publish_agent.py
86
+ python examples/customer-support/refund_support_agent.py
87
+ python examples/chat-conversation/chat_reply_agent.py
88
+ ```
89
+
90
+ All four run with zero setup (deterministic offline fallbacks) or live against real providers with the right API keys set. Diagrammed and walked through step by step in [docs/quickstart.md](https://github.com/supersemantics/VSL-Langgraph/blob/HEAD/docs/quickstart.md).
91
+
92
+ ## Layout
93
+
94
+ ```
95
+ src/vsl_langgraph/
96
+ ├── adapter.py LangGraphAdapter (VSLAdapter conformance contract)
97
+ └── integration.py gated_node, route_on_denial (StateGraph wiring)
98
+ docs/
99
+ ├── concepts.md vocabulary, gate-lifecycle diagram, full construct scope table
100
+ ├── quickstart.md install -> working graph, with real diagrams
101
+ ├── api-reference.md every export, scannable
102
+ ├── beyond-the-gate.md TerminalState/ledger/escalation, Cluster, Specification -- real worked examples
103
+ ├── dos-and-donts.md scannable checklist for writing/reviewing this code
104
+ ├── testing-your-gates.md how to prove your own gate wiring is correct
105
+ └── troubleshooting.md real, reproduced failure modes
106
+ examples/
107
+ ├── README.md domain taxonomy and roadmap
108
+ ├── financial-agents/
109
+ │ └── purchase_approval_agent.py PreNode + Invariant, no LLM calls
110
+ ├── marketing/
111
+ │ ├── research_publish_agent.py same pattern, 5 real LLM calls / 3 providers
112
+ │ └── marketing_providers.py, marketing_fake_providers.py, marketing_model_ids.py
113
+ ├── customer-support/
114
+ │ ├── refund_support_agent.py LLM picks the tool; dynamic routing, not fixed topology
115
+ │ └── support_providers.py, support_fake_providers.py
116
+ └── chat-conversation/
117
+ ├── chat_reply_agent.py governs the agent's own reply, not a backend action
118
+ └── chat_providers.py, chat_fake_providers.py
119
+ tests/
120
+ ├── test_conformance.py run_conformance_suite(LangGraphAdapter()) == []
121
+ ├── test_integration.py exercises a real compiled StateGraph
122
+ ├── test_purchase_approval_agent.py exercises the financial-agents sample's three scenarios
123
+ ├── test_customer_support_agent.py exercises dynamic routing + both gate types
124
+ └── test_chat_reply_agent.py exercises the chat sample's three scenarios
125
+ ```
126
+
127
+ Every example domain's provider modules are named with a domain prefix (`marketing_*`, `support_*`, `chat_*`) rather than a generic `providers.py` -- several examples import a same-named module in the same test run, and Python's module cache is process-global, so generic names collide across examples.
128
+
129
+ ## Status
130
+
131
+ Alpha. Conformance-suite green; integration tested against a real
132
+ `StateGraph` for both the `PreNode` and `Invariant` paths (pass-through and
133
+ denial routing), including chained multi-gate graphs, real LLM-backed
134
+ gates across three providers, and -- as of `customer-support/refund_support_agent.py`
135
+ -- a graph where an LLM's own output decides which node runs next, not
136
+ just a fixed topology with LLM-backed gates. `gated_node` doesn't implement
137
+ `Fallback`'s retry/intervention fields (`delta_factor`, `max_retries`,
138
+ `intervention`) -- those aren't consumed by `vsl-core`'s own
139
+ `PlainPythonReferenceAdapter` either, so this mirrors the reference
140
+ adapter's actual behavior rather than leaving out something `vsl-core`
141
+ itself defines a contract for.
@@ -0,0 +1,113 @@
1
+ # vsl-langgraph
2
+
3
+ A [LangGraph](https://github.com/langchain-ai/langgraph) adapter for
4
+ [`vsl-core`](https://github.com/supersemantics/VSL-Core): implements `vsl_core.conformance.protocol.VSLAdapter`
5
+ and provides the LangGraph-specific wiring to call a compiled gate from a
6
+ `StateGraph` node and route around a denial.
7
+
8
+ **Is:**
9
+ - A `VSLAdapter` implementation (`LangGraphAdapter`) that passes `vsl_core.conformance.suite.run_conformance_suite` with an empty result.
10
+ - A small set of wiring helpers (`gated_node`, `route_on_denial`) for calling a compiled gate immediately before a node's side effect and routing to a blocked/terminal node via `add_conditional_edges` on denial.
11
+
12
+ **Is not:**
13
+ - A replacement for `vsl-core` itself -- the PreNode/Invariant/Fallback/TerminalState constructs, the ledger, and the governance vocabulary all live in `vsl-core`. This package only compiles and wires those constructs into LangGraph.
14
+ - Automatic interception. LangGraph has no dedicated guardrail/hook type -- a node is a plain callable, so the gate is called explicitly inside `gated_node`, the same way `vsl-core`'s own docs describe calling a gate in plain Python.
15
+
16
+ ## Documentation
17
+
18
+ - [docs/concepts.md](https://github.com/supersemantics/VSL-Langgraph/blob/HEAD/docs/concepts.md) -- the vocabulary (`PreNode`, `Invariant`, `GammaEstimate`, `AssuranceBasis`/F1-F2, `CompiledGate`), a diagram of the gate lifecycle, which gate shape fits which kind of LangGraph node, and a complete scope table for every `vsl-core` construct -- not just the two this adapter compiles.
19
+ - [docs/quickstart.md](https://github.com/supersemantics/VSL-Langgraph/blob/HEAD/docs/quickstart.md) -- install through a working chained-gate graph, building up from one gate to real LLM-backed gates, with real diagrams generated from the actual compiled graphs.
20
+ - [docs/api-reference.md](https://github.com/supersemantics/VSL-Langgraph/blob/HEAD/docs/api-reference.md) -- `LangGraphAdapter` / `gated_node` / `route_on_denial`, every parameter, scannable.
21
+ - [docs/beyond-the-gate.md](https://github.com/supersemantics/VSL-Langgraph/blob/HEAD/docs/beyond-the-gate.md) -- everything `vsl-core` defines that this adapter doesn't compile (`TerminalState`, `VerbaLedger`, re-enablement, `Cluster`, `Specification`/`Proofing`/`Binding`) but that still applies inside a LangGraph app, each with real, run worked examples.
22
+ - [docs/dos-and-donts.md](https://github.com/supersemantics/VSL-Langgraph/blob/HEAD/docs/dos-and-donts.md) -- a scannable checklist for writing or reviewing `vsl-langgraph` code, each rule traced back to a real reason.
23
+ - [docs/testing-your-gates.md](https://github.com/supersemantics/VSL-Langgraph/blob/HEAD/docs/testing-your-gates.md) -- how to prove your own `PreNode`/`Invariant` wiring is correct, generalized from this repo's own test suite.
24
+ - [docs/troubleshooting.md](https://github.com/supersemantics/VSL-Langgraph/blob/HEAD/docs/troubleshooting.md) -- real, reproduced failure modes (not guessed) with exact error text and fixes.
25
+
26
+ ## Install
27
+
28
+ ```
29
+ pip install vsl-langgraph
30
+ ```
31
+
32
+ This installs vsl-core (`super-semantics-vsl`, imported as `vsl_core`) and LangGraph with it. From a
33
+ local clone of this repo: `pip install -e .`
34
+
35
+ ## Quick taste
36
+
37
+ ```python
38
+ gate = LangGraphAdapter().compile_pre_node(pre_node) # or .compile_invariant(invariant)
39
+
40
+ graph.add_node("do_the_thing", gated_node(gate, do_the_thing))
41
+ graph.add_conditional_edges("do_the_thing", route_on_denial("blocked", "allowed"))
42
+ ```
43
+
44
+ `gated_node` calls `gate` immediately before `do_the_thing` runs; on denial, `do_the_thing` never runs and `route_on_denial` sends the graph to `"blocked"` instead. Full walkthrough with real output: [docs/quickstart.md](https://github.com/supersemantics/VSL-Langgraph/blob/HEAD/docs/quickstart.md).
45
+
46
+ ## Sample agents
47
+
48
+ `examples/` is organized by domain -- see [examples/README.md](https://github.com/supersemantics/VSL-Langgraph/blob/HEAD/examples/README.md) for the full list and the roadmap for domains not built yet. Four so far:
49
+
50
+ - **`examples/financial-agents/purchase_approval_agent.py`** -- both governance primitives chained in one graph, synthetic and LLM-free. The fastest read of the gate-wiring pattern.
51
+ - **`examples/marketing/research_publish_agent.py`** -- the same pattern behind five real LLM calls across three providers (Gemini, OpenAI, three Claude models), with an automatic offline fallback when no API keys are set.
52
+ - **`examples/customer-support/refund_support_agent.py`** -- the one with a *dynamic* graph topology: an LLM picks which tool to call (`lookup_order` / `issue_refund` / `send_email`) and a plain LangGraph router sends the run to whichever node it chose, before any vsl-core gate is even reached. `issue_refund` is gated by an `Invariant`, `send_email` by a `PreNode`, `lookup_order` is ungated -- straight from `vsl-core`'s own canonical worked example.
53
+ - **`examples/chat-conversation/chat_reply_agent.py`** -- governance on what the agent is about to *say*, not a backend action: an `Invariant` blocking an unverified guarantee, a `PreNode` handing off to a human on low confidence.
54
+
55
+ ```
56
+ python examples/financial-agents/purchase_approval_agent.py
57
+ python examples/marketing/research_publish_agent.py
58
+ python examples/customer-support/refund_support_agent.py
59
+ python examples/chat-conversation/chat_reply_agent.py
60
+ ```
61
+
62
+ All four run with zero setup (deterministic offline fallbacks) or live against real providers with the right API keys set. Diagrammed and walked through step by step in [docs/quickstart.md](https://github.com/supersemantics/VSL-Langgraph/blob/HEAD/docs/quickstart.md).
63
+
64
+ ## Layout
65
+
66
+ ```
67
+ src/vsl_langgraph/
68
+ ├── adapter.py LangGraphAdapter (VSLAdapter conformance contract)
69
+ └── integration.py gated_node, route_on_denial (StateGraph wiring)
70
+ docs/
71
+ ├── concepts.md vocabulary, gate-lifecycle diagram, full construct scope table
72
+ ├── quickstart.md install -> working graph, with real diagrams
73
+ ├── api-reference.md every export, scannable
74
+ ├── beyond-the-gate.md TerminalState/ledger/escalation, Cluster, Specification -- real worked examples
75
+ ├── dos-and-donts.md scannable checklist for writing/reviewing this code
76
+ ├── testing-your-gates.md how to prove your own gate wiring is correct
77
+ └── troubleshooting.md real, reproduced failure modes
78
+ examples/
79
+ ├── README.md domain taxonomy and roadmap
80
+ ├── financial-agents/
81
+ │ └── purchase_approval_agent.py PreNode + Invariant, no LLM calls
82
+ ├── marketing/
83
+ │ ├── research_publish_agent.py same pattern, 5 real LLM calls / 3 providers
84
+ │ └── marketing_providers.py, marketing_fake_providers.py, marketing_model_ids.py
85
+ ├── customer-support/
86
+ │ ├── refund_support_agent.py LLM picks the tool; dynamic routing, not fixed topology
87
+ │ └── support_providers.py, support_fake_providers.py
88
+ └── chat-conversation/
89
+ ├── chat_reply_agent.py governs the agent's own reply, not a backend action
90
+ └── chat_providers.py, chat_fake_providers.py
91
+ tests/
92
+ ├── test_conformance.py run_conformance_suite(LangGraphAdapter()) == []
93
+ ├── test_integration.py exercises a real compiled StateGraph
94
+ ├── test_purchase_approval_agent.py exercises the financial-agents sample's three scenarios
95
+ ├── test_customer_support_agent.py exercises dynamic routing + both gate types
96
+ └── test_chat_reply_agent.py exercises the chat sample's three scenarios
97
+ ```
98
+
99
+ Every example domain's provider modules are named with a domain prefix (`marketing_*`, `support_*`, `chat_*`) rather than a generic `providers.py` -- several examples import a same-named module in the same test run, and Python's module cache is process-global, so generic names collide across examples.
100
+
101
+ ## Status
102
+
103
+ Alpha. Conformance-suite green; integration tested against a real
104
+ `StateGraph` for both the `PreNode` and `Invariant` paths (pass-through and
105
+ denial routing), including chained multi-gate graphs, real LLM-backed
106
+ gates across three providers, and -- as of `customer-support/refund_support_agent.py`
107
+ -- a graph where an LLM's own output decides which node runs next, not
108
+ just a fixed topology with LLM-backed gates. `gated_node` doesn't implement
109
+ `Fallback`'s retry/intervention fields (`delta_factor`, `max_retries`,
110
+ `intervention`) -- those aren't consumed by `vsl-core`'s own
111
+ `PlainPythonReferenceAdapter` either, so this mirrors the reference
112
+ adapter's actual behavior rather than leaving out something `vsl-core`
113
+ itself defines a contract for.
@@ -0,0 +1,42 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "vsl-langgraph"
7
+ version = "0.1.2"
8
+ description = "LangGraph adapter for VSL (VERBA Specification Language) governance primitives -- implements vsl-core's VSLAdapter conformance contract."
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ requires-python = ">=3.10"
12
+ dependencies = [
13
+ # vsl-core, published on PyPI as super-semantics-vsl (imported as vsl_core).
14
+ "super-semantics-vsl>=0.3,<0.4",
15
+ "langgraph>=0.2",
16
+ ]
17
+ keywords = ["governance", "ai-safety", "guardrails", "langgraph", "vsl", "verba"]
18
+ classifiers = [
19
+ "License :: OSI Approved :: MIT License",
20
+ "Programming Language :: Python :: 3",
21
+ "Operating System :: OS Independent",
22
+ "Development Status :: 3 - Alpha",
23
+ ]
24
+
25
+ [project.urls]
26
+ Homepage = "https://github.com/supersemantics/VSL-Langgraph"
27
+ Repository = "https://github.com/supersemantics/VSL-Langgraph"
28
+ Changelog = "https://github.com/supersemantics/VSL-Langgraph/blob/HEAD/CHANGELOG.md"
29
+ Issues = "https://github.com/supersemantics/VSL-Langgraph/issues"
30
+
31
+ [project.optional-dependencies]
32
+ dev = ["pytest>=7", "pytest-asyncio>=0.23"]
33
+ # Only examples/research_publish_agent.py needs these -- src/vsl_langgraph
34
+ # itself never imports a provider SDK, staying model-agnostic same as vsl-core.
35
+ examples = ["openai>=1.0", "anthropic>=0.40", "google-genai>=0.5"]
36
+
37
+ [tool.setuptools.packages.find]
38
+ where = ["src"]
39
+
40
+ [tool.pytest.ini_options]
41
+ testpaths = ["tests"]
42
+ asyncio_mode = "auto"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,9 @@
1
+ from .adapter import LangGraphAdapter
2
+ from .integration import DEFAULT_DENIAL_KEY, gated_node, route_on_denial
3
+
4
+ __all__ = [
5
+ "LangGraphAdapter",
6
+ "gated_node",
7
+ "route_on_denial",
8
+ "DEFAULT_DENIAL_KEY",
9
+ ]
@@ -0,0 +1,163 @@
1
+ """LangGraphAdapter: a VSLAdapter conformant with vsl-core's conformance
2
+ contract, intended for use inside LangGraph nodes.
3
+
4
+ LangGraph has no dedicated guardrail/interception type -- a StateGraph node
5
+ is just a plain sync/async callable, and conditional edges are plain router
6
+ functions evaluated after a node returns. There is therefore nothing
7
+ LangGraph-specific to compile a PreNode/Invariant *into*: the compiled gate
8
+ is the same framework-agnostic async callable vsl-core's own
9
+ PlainPythonReferenceAdapter produces, called directly inside a node body
10
+ before the side effect it guards. What this package adds on top (see
11
+ `integration.py`) is the wiring convention for calling that gate from a
12
+ node and routing around a denial with `add_conditional_edges`, not a
13
+ different gate-compilation strategy.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import inspect
19
+ import warnings
20
+ from typing import Any, Callable
21
+
22
+ from vsl_core.constructs import Fallback, Invariant, PreNode
23
+ from vsl_core.conformance.protocol import CompiledGate
24
+ from vsl_core.exceptions import AutomationDeniedException, InvariantViolation
25
+ from vsl_core.metrics import F2Modification
26
+
27
+ _DEFAULT_FALLBACK = Fallback()
28
+
29
+ # Source-text markers for hosted, black-box model APIs. A gate whose
30
+ # monitor/rule reaches one of these cannot honestly claim F2Modification.FULL
31
+ # -- FULL requires direct access to the energy function itself (logits,
32
+ # weights, activations), which a hosted API call structurally cannot expose.
33
+ # The honest ceiling for a hosted-API-backed check is PARTIAL (MEDIUM), per
34
+ # vsl-core's own README example. This is a heuristic, not a proof: it scans
35
+ # source text -- the monitor/rule's OWN body (inspect.getsource(fn), not its
36
+ # containing module's entire source; see _reaches_hosted_api's docstring for
37
+ # why that distinction matters), plus one level of indirection through
38
+ # whatever names it directly calls -- catching the common "the actual API
39
+ # call lives in a helper module" shape, e.g. a monitor in agent.py calling
40
+ # judge_confidence() from a separate providers.py that imports anthropic --
41
+ # for these markers. It can still miss deeper indirection (two or more
42
+ # call-frames away, a callable reached only via a dict/attribute lookup,
43
+ # dynamic imports) and cannot confirm a genuine FULL claim is correct either
44
+ # -- it only catches the one concrete, previously-observed failure mode of
45
+ # claiming FULL while calling a hosted API.
46
+ _HOSTED_API_MARKERS = (
47
+ "openai",
48
+ "anthropic",
49
+ "google.genai",
50
+ "google.generativeai",
51
+ "cohere",
52
+ "mistralai",
53
+ )
54
+
55
+
56
+ def _reaches_hosted_api(fn: Callable[..., Any], _seen: set | None = None) -> bool:
57
+ """Scoped to fn's OWN source (inspect.getsource(fn)), not its containing
58
+ module's entire source. Scanning the whole module was a real false-
59
+ positive bug found while building a companion prototype: an unrelated
60
+ function's comment or docstring elsewhere in the same file could
61
+ trigger a match that had nothing to do with fn itself. Function-level
62
+ scoping fixes that without requiring every monitor/rule to live in its
63
+ own file -- real hosted-API calls are actual code inside the relevant
64
+ function (or a helper it calls, covered by the indirection walk below),
65
+ not incidental mentions elsewhere in the file.
66
+ """
67
+ if _seen is None:
68
+ _seen = set()
69
+ if fn in _seen:
70
+ return False
71
+ _seen.add(fn)
72
+
73
+ if inspect.getmodule(fn) is None:
74
+ return False
75
+ try:
76
+ source = inspect.getsource(fn)
77
+ except (OSError, TypeError):
78
+ return False
79
+ # Case-insensitive: real code capitalizes SDK class names (AsyncAnthropic,
80
+ # AsyncOpenAI), which a case-sensitive check against lowercase "anthropic"/
81
+ # "openai" markers would miss -- found and fixed after function-scoping
82
+ # (above) removed an accidental crutch: the old whole-module scan happened
83
+ # to also catch the lowercase `from anthropic import ...` line elsewhere
84
+ # in the file, masking this case bug until scoping narrowed to fn itself.
85
+ source_lower = source.lower()
86
+ if any(marker in source_lower for marker in _HOSTED_API_MARKERS):
87
+ return True
88
+
89
+ # One level of indirection: follow names this function's body directly
90
+ # references, in case the real hosted-API call lives in a helper it
91
+ # calls rather than in its own module.
92
+ code = getattr(fn, "__code__", None)
93
+ globalns = getattr(fn, "__globals__", None)
94
+ if code is None or globalns is None:
95
+ return False
96
+ for referenced_name in code.co_names:
97
+ candidate = globalns.get(referenced_name)
98
+ if callable(candidate) and candidate is not fn:
99
+ if _reaches_hosted_api(candidate, _seen):
100
+ return True
101
+ return False
102
+
103
+
104
+ def _warn_if_full_claim_reaches_hosted_api(name: str, assurance_basis: Any, fn: Callable[..., Any]) -> None:
105
+ if assurance_basis.f2_modification != F2Modification.FULL:
106
+ return
107
+ if not _reaches_hosted_api(fn):
108
+ return
109
+ warnings.warn(
110
+ f"'{name}' claims F2Modification.FULL, but its monitor/rule appears to "
111
+ "call a hosted model API (openai/anthropic/google.genai/cohere/mistralai "
112
+ "detected in its module's source). A hosted, black-box API call cannot "
113
+ "directly modify the energy function -- FULL is not achievable this way. "
114
+ "The honest ceiling is F2Modification.PARTIAL (MEDIUM assurance), per "
115
+ "vsl-core's own README F1/F2 example. This is a heuristic check, not "
116
+ "proof -- it can miss indirect calls, and passing it does not confirm "
117
+ "FULL is correct either.",
118
+ stacklevel=3,
119
+ )
120
+
121
+
122
+ class LangGraphAdapter:
123
+ def compile_pre_node(self, pre_node: PreNode) -> CompiledGate:
124
+ if pre_node.fallback != _DEFAULT_FALLBACK:
125
+ warnings.warn(
126
+ f"PreNode '{pre_node.name}' sets a non-default Fallback "
127
+ f"({pre_node.fallback!r}), but gated_node does not consume "
128
+ "Fallback -- a denial raises once, immediately, with no "
129
+ "retry loop. See docs/dos-and-donts.md if you need retry "
130
+ "behavior; you must implement it yourself around the "
131
+ "compiled gate.",
132
+ stacklevel=2,
133
+ )
134
+ _warn_if_full_claim_reaches_hosted_api(f"PreNode '{pre_node.name}'", pre_node.assurance_basis, pre_node.monitor)
135
+
136
+ async def gate(candidate_input: Any) -> None:
137
+ estimate = await pre_node.monitor(candidate_input)
138
+ if not estimate.sufficient(pre_node.gamma_threshold):
139
+ raise AutomationDeniedException(
140
+ reason=f"Pre-node '{pre_node.name}' insufficient Gamma",
141
+ identity_key="vsl-langgraph",
142
+ )
143
+
144
+ return gate
145
+
146
+ def compile_invariant(self, invariant: Invariant) -> CompiledGate:
147
+ _warn_if_full_claim_reaches_hosted_api(f"Invariant '{invariant.name}'", invariant.assurance_basis, invariant.rule)
148
+
149
+ async def gate(candidate_input: Any) -> None:
150
+ holds = await invariant.rule(candidate_input)
151
+ if not holds:
152
+ reason = f"Invariant '{invariant.name}' violated"
153
+ terminal_state_name = invariant.on_violation.name if invariant.on_violation is not None else None
154
+ if terminal_state_name is not None:
155
+ reason += f" -- entering terminal state '{terminal_state_name}'"
156
+ raise InvariantViolation(
157
+ reason=reason,
158
+ identity_key="vsl-langgraph",
159
+ invariant_name=invariant.name,
160
+ terminal_state_name=terminal_state_name,
161
+ )
162
+
163
+ return gate
@@ -0,0 +1,81 @@
1
+ """Wiring helpers for calling a compiled VSL gate from inside a LangGraph
2
+ node and routing around a denial with `add_conditional_edges`.
3
+
4
+ vsl-core does not intercept anything on its own (see
5
+ vsl-core/docs/building-with-vsl-core.md, "know what's automatic and what
6
+ you write yourself"): a compiled gate only ever raises or returns, on
7
+ demand, when called. `gated_node` is that call site, made reusable for a
8
+ LangGraph StateGraph -- it calls the gate immediately before the wrapped
9
+ node would run (preserving F1 pre-commitment: the gate completes strictly
10
+ before the guarded node's side effect), and on denial returns a state
11
+ update instead of letting the exception propagate, so a conditional edge
12
+ can route to a blocked/terminal node in the normal LangGraph way rather
13
+ than crashing the graph run.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import inspect
19
+ from typing import Any, Awaitable, Callable, Mapping
20
+
21
+ from vsl_core.conformance.protocol import CompiledGate
22
+ from vsl_core.exceptions import AutomationDeniedException
23
+
24
+ StateNode = Callable[[Mapping[str, Any]], Any]
25
+
26
+ DEFAULT_DENIAL_KEY = "vsl_denial"
27
+
28
+
29
+ def gated_node(
30
+ gate: CompiledGate,
31
+ node: StateNode,
32
+ *,
33
+ candidate_input_fn: Callable[[Mapping[str, Any]], Any] | None = None,
34
+ denial_key: str = DEFAULT_DENIAL_KEY,
35
+ ) -> Callable[[Mapping[str, Any]], Awaitable[dict[str, Any]]]:
36
+ """Wrap `node` so `gate` is checked immediately before it runs.
37
+
38
+ `candidate_input_fn` extracts whatever the gate should evaluate from
39
+ graph state; defaults to passing the whole state through unchanged.
40
+
41
+ On denial, `node` is never called and the returned state update sets
42
+ `denial_key` to the caught exception -- pair with `route_on_denial`
43
+ (or your own router reading `denial_key`) in `add_conditional_edges` to
44
+ send the run to a blocked/terminal node.
45
+
46
+ Does not consume `PreNode.fallback` -- a denial raises once,
47
+ immediately, with no retry loop, regardless of `fallback.max_retries`.
48
+ `LangGraphAdapter.compile_pre_node` warns if a non-default `Fallback`
49
+ is set. See docs/dos-and-donts.md.
50
+ """
51
+ extract = candidate_input_fn or (lambda state: state)
52
+
53
+ async def wrapped(state: Mapping[str, Any]) -> dict[str, Any]:
54
+ try:
55
+ await gate(extract(state))
56
+ except AutomationDeniedException as exc:
57
+ return {denial_key: exc}
58
+
59
+ result = node(state)
60
+ if inspect.isawaitable(result):
61
+ result = await result
62
+ return dict(result) if result else {}
63
+
64
+ return wrapped
65
+
66
+
67
+ def route_on_denial(
68
+ blocked_node: str,
69
+ allowed_node: str,
70
+ *,
71
+ denial_key: str = DEFAULT_DENIAL_KEY,
72
+ ) -> Callable[[Mapping[str, Any]], str]:
73
+ """Build a router for `add_conditional_edges` matching `gated_node`'s
74
+ convention: routes to `blocked_node` if `denial_key` is set in state
75
+ (truthy), otherwise to `allowed_node`.
76
+ """
77
+
78
+ def router(state: Mapping[str, Any]) -> str:
79
+ return blocked_node if state.get(denial_key) is not None else allowed_node
80
+
81
+ return router
@@ -0,0 +1,141 @@
1
+ Metadata-Version: 2.4
2
+ Name: vsl-langgraph
3
+ Version: 0.1.2
4
+ Summary: LangGraph adapter for VSL (VERBA Specification Language) governance primitives -- implements vsl-core's VSLAdapter conformance contract.
5
+ License: MIT
6
+ Project-URL: Homepage, https://github.com/supersemantics/VSL-Langgraph
7
+ Project-URL: Repository, https://github.com/supersemantics/VSL-Langgraph
8
+ Project-URL: Changelog, https://github.com/supersemantics/VSL-Langgraph/blob/HEAD/CHANGELOG.md
9
+ Project-URL: Issues, https://github.com/supersemantics/VSL-Langgraph/issues
10
+ Keywords: governance,ai-safety,guardrails,langgraph,vsl,verba
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Development Status :: 3 - Alpha
15
+ Requires-Python: >=3.10
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE
18
+ Requires-Dist: super-semantics-vsl<0.4,>=0.3
19
+ Requires-Dist: langgraph>=0.2
20
+ Provides-Extra: dev
21
+ Requires-Dist: pytest>=7; extra == "dev"
22
+ Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
23
+ Provides-Extra: examples
24
+ Requires-Dist: openai>=1.0; extra == "examples"
25
+ Requires-Dist: anthropic>=0.40; extra == "examples"
26
+ Requires-Dist: google-genai>=0.5; extra == "examples"
27
+ Dynamic: license-file
28
+
29
+ # vsl-langgraph
30
+
31
+ A [LangGraph](https://github.com/langchain-ai/langgraph) adapter for
32
+ [`vsl-core`](https://github.com/supersemantics/VSL-Core): implements `vsl_core.conformance.protocol.VSLAdapter`
33
+ and provides the LangGraph-specific wiring to call a compiled gate from a
34
+ `StateGraph` node and route around a denial.
35
+
36
+ **Is:**
37
+ - A `VSLAdapter` implementation (`LangGraphAdapter`) that passes `vsl_core.conformance.suite.run_conformance_suite` with an empty result.
38
+ - A small set of wiring helpers (`gated_node`, `route_on_denial`) for calling a compiled gate immediately before a node's side effect and routing to a blocked/terminal node via `add_conditional_edges` on denial.
39
+
40
+ **Is not:**
41
+ - A replacement for `vsl-core` itself -- the PreNode/Invariant/Fallback/TerminalState constructs, the ledger, and the governance vocabulary all live in `vsl-core`. This package only compiles and wires those constructs into LangGraph.
42
+ - Automatic interception. LangGraph has no dedicated guardrail/hook type -- a node is a plain callable, so the gate is called explicitly inside `gated_node`, the same way `vsl-core`'s own docs describe calling a gate in plain Python.
43
+
44
+ ## Documentation
45
+
46
+ - [docs/concepts.md](https://github.com/supersemantics/VSL-Langgraph/blob/HEAD/docs/concepts.md) -- the vocabulary (`PreNode`, `Invariant`, `GammaEstimate`, `AssuranceBasis`/F1-F2, `CompiledGate`), a diagram of the gate lifecycle, which gate shape fits which kind of LangGraph node, and a complete scope table for every `vsl-core` construct -- not just the two this adapter compiles.
47
+ - [docs/quickstart.md](https://github.com/supersemantics/VSL-Langgraph/blob/HEAD/docs/quickstart.md) -- install through a working chained-gate graph, building up from one gate to real LLM-backed gates, with real diagrams generated from the actual compiled graphs.
48
+ - [docs/api-reference.md](https://github.com/supersemantics/VSL-Langgraph/blob/HEAD/docs/api-reference.md) -- `LangGraphAdapter` / `gated_node` / `route_on_denial`, every parameter, scannable.
49
+ - [docs/beyond-the-gate.md](https://github.com/supersemantics/VSL-Langgraph/blob/HEAD/docs/beyond-the-gate.md) -- everything `vsl-core` defines that this adapter doesn't compile (`TerminalState`, `VerbaLedger`, re-enablement, `Cluster`, `Specification`/`Proofing`/`Binding`) but that still applies inside a LangGraph app, each with real, run worked examples.
50
+ - [docs/dos-and-donts.md](https://github.com/supersemantics/VSL-Langgraph/blob/HEAD/docs/dos-and-donts.md) -- a scannable checklist for writing or reviewing `vsl-langgraph` code, each rule traced back to a real reason.
51
+ - [docs/testing-your-gates.md](https://github.com/supersemantics/VSL-Langgraph/blob/HEAD/docs/testing-your-gates.md) -- how to prove your own `PreNode`/`Invariant` wiring is correct, generalized from this repo's own test suite.
52
+ - [docs/troubleshooting.md](https://github.com/supersemantics/VSL-Langgraph/blob/HEAD/docs/troubleshooting.md) -- real, reproduced failure modes (not guessed) with exact error text and fixes.
53
+
54
+ ## Install
55
+
56
+ ```
57
+ pip install vsl-langgraph
58
+ ```
59
+
60
+ This installs vsl-core (`super-semantics-vsl`, imported as `vsl_core`) and LangGraph with it. From a
61
+ local clone of this repo: `pip install -e .`
62
+
63
+ ## Quick taste
64
+
65
+ ```python
66
+ gate = LangGraphAdapter().compile_pre_node(pre_node) # or .compile_invariant(invariant)
67
+
68
+ graph.add_node("do_the_thing", gated_node(gate, do_the_thing))
69
+ graph.add_conditional_edges("do_the_thing", route_on_denial("blocked", "allowed"))
70
+ ```
71
+
72
+ `gated_node` calls `gate` immediately before `do_the_thing` runs; on denial, `do_the_thing` never runs and `route_on_denial` sends the graph to `"blocked"` instead. Full walkthrough with real output: [docs/quickstart.md](https://github.com/supersemantics/VSL-Langgraph/blob/HEAD/docs/quickstart.md).
73
+
74
+ ## Sample agents
75
+
76
+ `examples/` is organized by domain -- see [examples/README.md](https://github.com/supersemantics/VSL-Langgraph/blob/HEAD/examples/README.md) for the full list and the roadmap for domains not built yet. Four so far:
77
+
78
+ - **`examples/financial-agents/purchase_approval_agent.py`** -- both governance primitives chained in one graph, synthetic and LLM-free. The fastest read of the gate-wiring pattern.
79
+ - **`examples/marketing/research_publish_agent.py`** -- the same pattern behind five real LLM calls across three providers (Gemini, OpenAI, three Claude models), with an automatic offline fallback when no API keys are set.
80
+ - **`examples/customer-support/refund_support_agent.py`** -- the one with a *dynamic* graph topology: an LLM picks which tool to call (`lookup_order` / `issue_refund` / `send_email`) and a plain LangGraph router sends the run to whichever node it chose, before any vsl-core gate is even reached. `issue_refund` is gated by an `Invariant`, `send_email` by a `PreNode`, `lookup_order` is ungated -- straight from `vsl-core`'s own canonical worked example.
81
+ - **`examples/chat-conversation/chat_reply_agent.py`** -- governance on what the agent is about to *say*, not a backend action: an `Invariant` blocking an unverified guarantee, a `PreNode` handing off to a human on low confidence.
82
+
83
+ ```
84
+ python examples/financial-agents/purchase_approval_agent.py
85
+ python examples/marketing/research_publish_agent.py
86
+ python examples/customer-support/refund_support_agent.py
87
+ python examples/chat-conversation/chat_reply_agent.py
88
+ ```
89
+
90
+ All four run with zero setup (deterministic offline fallbacks) or live against real providers with the right API keys set. Diagrammed and walked through step by step in [docs/quickstart.md](https://github.com/supersemantics/VSL-Langgraph/blob/HEAD/docs/quickstart.md).
91
+
92
+ ## Layout
93
+
94
+ ```
95
+ src/vsl_langgraph/
96
+ ├── adapter.py LangGraphAdapter (VSLAdapter conformance contract)
97
+ └── integration.py gated_node, route_on_denial (StateGraph wiring)
98
+ docs/
99
+ ├── concepts.md vocabulary, gate-lifecycle diagram, full construct scope table
100
+ ├── quickstart.md install -> working graph, with real diagrams
101
+ ├── api-reference.md every export, scannable
102
+ ├── beyond-the-gate.md TerminalState/ledger/escalation, Cluster, Specification -- real worked examples
103
+ ├── dos-and-donts.md scannable checklist for writing/reviewing this code
104
+ ├── testing-your-gates.md how to prove your own gate wiring is correct
105
+ └── troubleshooting.md real, reproduced failure modes
106
+ examples/
107
+ ├── README.md domain taxonomy and roadmap
108
+ ├── financial-agents/
109
+ │ └── purchase_approval_agent.py PreNode + Invariant, no LLM calls
110
+ ├── marketing/
111
+ │ ├── research_publish_agent.py same pattern, 5 real LLM calls / 3 providers
112
+ │ └── marketing_providers.py, marketing_fake_providers.py, marketing_model_ids.py
113
+ ├── customer-support/
114
+ │ ├── refund_support_agent.py LLM picks the tool; dynamic routing, not fixed topology
115
+ │ └── support_providers.py, support_fake_providers.py
116
+ └── chat-conversation/
117
+ ├── chat_reply_agent.py governs the agent's own reply, not a backend action
118
+ └── chat_providers.py, chat_fake_providers.py
119
+ tests/
120
+ ├── test_conformance.py run_conformance_suite(LangGraphAdapter()) == []
121
+ ├── test_integration.py exercises a real compiled StateGraph
122
+ ├── test_purchase_approval_agent.py exercises the financial-agents sample's three scenarios
123
+ ├── test_customer_support_agent.py exercises dynamic routing + both gate types
124
+ └── test_chat_reply_agent.py exercises the chat sample's three scenarios
125
+ ```
126
+
127
+ Every example domain's provider modules are named with a domain prefix (`marketing_*`, `support_*`, `chat_*`) rather than a generic `providers.py` -- several examples import a same-named module in the same test run, and Python's module cache is process-global, so generic names collide across examples.
128
+
129
+ ## Status
130
+
131
+ Alpha. Conformance-suite green; integration tested against a real
132
+ `StateGraph` for both the `PreNode` and `Invariant` paths (pass-through and
133
+ denial routing), including chained multi-gate graphs, real LLM-backed
134
+ gates across three providers, and -- as of `customer-support/refund_support_agent.py`
135
+ -- a graph where an LLM's own output decides which node runs next, not
136
+ just a fixed topology with LLM-backed gates. `gated_node` doesn't implement
137
+ `Fallback`'s retry/intervention fields (`delta_factor`, `max_retries`,
138
+ `intervention`) -- those aren't consumed by `vsl-core`'s own
139
+ `PlainPythonReferenceAdapter` either, so this mirrors the reference
140
+ adapter's actual behavior rather than leaving out something `vsl-core`
141
+ itself defines a contract for.
@@ -0,0 +1,17 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/vsl_langgraph/__init__.py
5
+ src/vsl_langgraph/adapter.py
6
+ src/vsl_langgraph/integration.py
7
+ src/vsl_langgraph.egg-info/PKG-INFO
8
+ src/vsl_langgraph.egg-info/SOURCES.txt
9
+ src/vsl_langgraph.egg-info/dependency_links.txt
10
+ src/vsl_langgraph.egg-info/requires.txt
11
+ src/vsl_langgraph.egg-info/top_level.txt
12
+ tests/test_chat_reply_agent.py
13
+ tests/test_conformance.py
14
+ tests/test_customer_support_agent.py
15
+ tests/test_full_claim_hosted_api_warning.py
16
+ tests/test_integration.py
17
+ tests/test_purchase_approval_agent.py
@@ -0,0 +1,11 @@
1
+ super-semantics-vsl<0.4,>=0.3
2
+ langgraph>=0.2
3
+
4
+ [dev]
5
+ pytest>=7
6
+ pytest-asyncio>=0.23
7
+
8
+ [examples]
9
+ openai>=1.0
10
+ anthropic>=0.40
11
+ google-genai>=0.5
@@ -0,0 +1 @@
1
+ vsl_langgraph
@@ -0,0 +1,34 @@
1
+ import sys
2
+ from pathlib import Path
3
+
4
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "examples" / "chat-conversation"))
5
+
6
+ import chat_fake_providers as fake_providers # noqa: E402
7
+ from chat_reply_agent import build_agent # noqa: E402
8
+
9
+ _MESSAGE = "My order hasn't arrived and it's been two weeks, what happens now?"
10
+
11
+
12
+ async def test_resolved_reply_is_sent():
13
+ fake_providers.set_scenario("resolved")
14
+ result = await build_agent().ainvoke({"customer_message": _MESSAGE})
15
+ assert result.get("sent_reply") is not None
16
+ assert result.get("guarantee_denial") is None
17
+ assert result.get("confidence_denial") is None
18
+ assert result.get("handed_off") is None
19
+
20
+
21
+ async def test_unverified_guarantee_is_handed_off_not_sent():
22
+ fake_providers.set_scenario("unverified_guarantee")
23
+ result = await build_agent().ainvoke({"customer_message": _MESSAGE})
24
+ assert result.get("sent_reply") is None
25
+ assert result.get("guarantee_denial") is not None
26
+ assert result.get("handed_off") is True
27
+
28
+
29
+ async def test_low_confidence_is_handed_off_not_sent():
30
+ fake_providers.set_scenario("low_confidence")
31
+ result = await build_agent().ainvoke({"customer_message": _MESSAGE})
32
+ assert result.get("sent_reply") is None
33
+ assert result.get("confidence_denial") is not None
34
+ assert result.get("handed_off") is True
@@ -0,0 +1,8 @@
1
+ from vsl_core.conformance.suite import run_conformance_suite
2
+
3
+ from vsl_langgraph import LangGraphAdapter
4
+
5
+
6
+ def test_langgraph_adapter_is_conformant():
7
+ failures = run_conformance_suite(LangGraphAdapter())
8
+ assert failures == []
@@ -0,0 +1,40 @@
1
+ import sys
2
+ from pathlib import Path
3
+
4
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "examples" / "customer-support"))
5
+
6
+ from refund_support_agent import build_agent # noqa: E402
7
+
8
+
9
+ async def test_lookup_order_is_ungated_and_returns_the_order():
10
+ result = await build_agent().ainvoke({"request": "what's the status of order ORD-100?"})
11
+ assert result["chosen_tool"] == "lookup_order"
12
+ assert result["order_result"] == {"customer_email": "alice@example.com", "amount": 42.50, "eligible": True}
13
+
14
+
15
+ async def test_eligible_refund_within_amount_is_approved():
16
+ result = await build_agent().ainvoke({"request": "please refund order ORD-100 for 42.50"})
17
+ assert result["chosen_tool"] == "issue_refund"
18
+ assert result.get("refunded") is True
19
+ assert result.get("refund_denial") is None
20
+
21
+
22
+ async def test_ineligible_refund_is_blocked_by_invariant():
23
+ result = await build_agent().ainvoke({"request": "please refund order ORD-200 for 999.0"})
24
+ assert result["chosen_tool"] == "issue_refund"
25
+ assert result.get("refunded") is None
26
+ assert result.get("refund_denial") is not None
27
+
28
+
29
+ async def test_email_to_known_customer_is_approved():
30
+ result = await build_agent().ainvoke({"request": "email alice@example.com to say her order shipped"})
31
+ assert result["chosen_tool"] == "send_email"
32
+ assert result.get("email_sent") is True
33
+ assert result.get("email_denial") is None
34
+
35
+
36
+ async def test_email_to_unknown_recipient_is_blocked_by_pre_node():
37
+ result = await build_agent().ainvoke({"request": "email attacker@evil.com to say her order shipped"})
38
+ assert result["chosen_tool"] == "send_email"
39
+ assert result.get("email_sent") is None
40
+ assert result.get("email_denial") is not None
@@ -0,0 +1,126 @@
1
+ """Covers the F2 fix in adapter.py: LangGraphAdapter must warn when a
2
+ PreNode/Invariant claims F2Modification.FULL but its monitor/rule reaches a
3
+ hosted, black-box model API -- FULL is not honestly achievable that way (see
4
+ adapter.py's _reaches_hosted_api / _warn_if_full_claim_reaches_hosted_api
5
+ docstrings for why). This is the exact pattern found in this repo's own
6
+ examples (chat_reply_agent.py, research_publish_agent.py): FULL claimed
7
+ next to a hosted Anthropic/OpenAI call.
8
+ """
9
+
10
+ import warnings
11
+ from typing import Any
12
+
13
+ import pytest
14
+ from vsl_core.constructs import Invariant, PreNode
15
+ from vsl_core.metrics import AssuranceBasis, F2Modification, GammaEstimate
16
+
17
+ from vsl_langgraph import LangGraphAdapter
18
+ from tests.fixtures.hosted_api_monitor import hosted_monitor, hosted_rule
19
+ from tests.fixtures.mixed_hosted_and_local import mixed_hosted_monitor, mixed_local_monitor
20
+
21
+
22
+ def _compile_and_collect_warnings(build_gate) -> list[warnings.WarningMessage]:
23
+ with warnings.catch_warnings(record=True) as record:
24
+ warnings.simplefilter("always")
25
+ build_gate()
26
+ return list(record)
27
+
28
+ _FULL_BASIS = AssuranceBasis(f1_pre_commitment=True, f2_modification=F2Modification.FULL)
29
+ _PARTIAL_BASIS = AssuranceBasis(f1_pre_commitment=True, f2_modification=F2Modification.PARTIAL)
30
+ _NONE_BASIS = AssuranceBasis(f1_pre_commitment=True, f2_modification=F2Modification.NONE)
31
+
32
+
33
+ async def _plain_monitor(candidate_input: Any) -> GammaEstimate:
34
+ # No hosted-API marker anywhere in this module's source.
35
+ return GammaEstimate(gamma_hat=2.0)
36
+
37
+
38
+ async def _plain_rule(candidate_input: Any) -> bool:
39
+ return True
40
+
41
+
42
+ def test_full_claim_reaching_hosted_api_warns_on_pre_node():
43
+ pre_node = PreNode(
44
+ name="hosted-pre-node",
45
+ description="Claims FULL but its monitor's module mentions a hosted API.",
46
+ monitor=hosted_monitor,
47
+ assurance_basis=_FULL_BASIS,
48
+ )
49
+ with pytest.warns(UserWarning, match="claims F2Modification.FULL"):
50
+ LangGraphAdapter().compile_pre_node(pre_node)
51
+
52
+
53
+ def test_full_claim_reaching_hosted_api_warns_on_invariant():
54
+ invariant = Invariant(
55
+ name="hosted-invariant",
56
+ description="Claims FULL but its rule's module mentions a hosted API.",
57
+ rule=hosted_rule,
58
+ assurance_basis=_FULL_BASIS,
59
+ )
60
+ with pytest.warns(UserWarning, match="claims F2Modification.FULL"):
61
+ LangGraphAdapter().compile_invariant(invariant)
62
+
63
+
64
+ def test_full_claim_without_hosted_api_does_not_warn():
65
+ pre_node = PreNode(
66
+ name="local-pre-node",
67
+ description="Claims FULL; nothing hosted-API-shaped in this module.",
68
+ monitor=_plain_monitor,
69
+ assurance_basis=_FULL_BASIS,
70
+ )
71
+ record = _compile_and_collect_warnings(lambda: LangGraphAdapter().compile_pre_node(pre_node))
72
+ assert not any("claims F2Modification.FULL" in str(w.message) for w in record)
73
+
74
+
75
+ def test_partial_claim_reaching_hosted_api_does_not_trigger_full_warning():
76
+ # PARTIAL is the honest rating for a hosted-API-backed check -- only a
77
+ # FULL claim should ever trigger this warning.
78
+ pre_node = PreNode(
79
+ name="hosted-pre-node-partial",
80
+ description="Honestly rated PARTIAL for a hosted-API-backed check.",
81
+ monitor=hosted_monitor,
82
+ assurance_basis=_PARTIAL_BASIS,
83
+ )
84
+ record = _compile_and_collect_warnings(lambda: LangGraphAdapter().compile_pre_node(pre_node))
85
+ assert not any("claims F2Modification.FULL" in str(w.message) for w in record)
86
+
87
+
88
+ def test_none_claim_reaching_hosted_api_does_not_trigger_full_warning():
89
+ pre_node = PreNode(
90
+ name="hosted-pre-node-none",
91
+ description="NONE claim next to a hosted-API-shaped module.",
92
+ monitor=hosted_monitor,
93
+ assurance_basis=_NONE_BASIS,
94
+ )
95
+ record = _compile_and_collect_warnings(lambda: LangGraphAdapter().compile_pre_node(pre_node))
96
+ assert not any("claims F2Modification.FULL" in str(w.message) for w in record)
97
+
98
+
99
+ def test_same_file_sibling_function_does_not_cross_contaminate():
100
+ # Regression test for the false positive found while building a
101
+ # companion prototype: mixed_local_monitor shares a file with
102
+ # mixed_hosted_monitor, which genuinely mentions "anthropic". Before
103
+ # _reaches_hosted_api was scoped to each function's own source (rather
104
+ # than its whole containing module), this would have wrongly flagged
105
+ # mixed_local_monitor too, just because of its neighbor.
106
+ pre_node = PreNode(
107
+ name="mixed-local-pre-node",
108
+ description="Claims FULL; genuinely local, but shares a file with a hosted-API monitor.",
109
+ monitor=mixed_local_monitor,
110
+ assurance_basis=_FULL_BASIS,
111
+ )
112
+ record = _compile_and_collect_warnings(lambda: LangGraphAdapter().compile_pre_node(pre_node))
113
+ assert not any("claims F2Modification.FULL" in str(w.message) for w in record)
114
+
115
+
116
+ def test_same_file_sibling_that_genuinely_reaches_hosted_api_is_still_caught():
117
+ # The other half of the same regression test: the fix must not become
118
+ # so narrow it stops detecting real cases sitting in a shared file.
119
+ pre_node = PreNode(
120
+ name="mixed-hosted-pre-node",
121
+ description="Claims FULL; genuinely reaches a hosted API.",
122
+ monitor=mixed_hosted_monitor,
123
+ assurance_basis=_FULL_BASIS,
124
+ )
125
+ with pytest.warns(UserWarning, match="claims F2Modification.FULL"):
126
+ LangGraphAdapter().compile_pre_node(pre_node)
@@ -0,0 +1,102 @@
1
+ from typing import Any, TypedDict
2
+
3
+ from langgraph.graph import END, START, StateGraph
4
+ from vsl_core.constructs import Invariant, PreNode
5
+ from vsl_core.exceptions import InvariantViolation
6
+ from vsl_core.metrics import AssuranceBasis, F2Modification, GammaEstimate
7
+
8
+ from vsl_langgraph import LangGraphAdapter, gated_node, route_on_denial
9
+
10
+ _ASSURANCE_BASIS = AssuranceBasis(f1_pre_commitment=True, f2_modification=F2Modification.FULL)
11
+
12
+
13
+ class GraphState(TypedDict, total=False):
14
+ gamma: float
15
+ ran_side_effect: bool
16
+ vsl_denial: Any
17
+
18
+
19
+ async def _monitor(candidate_input: GraphState) -> GammaEstimate:
20
+ return GammaEstimate(gamma_hat=candidate_input["gamma"])
21
+
22
+
23
+ def _build_graph():
24
+ pre_node = PreNode(
25
+ name="integration-test-pre-node",
26
+ description="Requires gamma above 1.0 before the guarded node runs.",
27
+ monitor=_monitor,
28
+ assurance_basis=_ASSURANCE_BASIS,
29
+ gamma_threshold=1.0,
30
+ )
31
+ gate = LangGraphAdapter().compile_pre_node(pre_node)
32
+
33
+ def guarded(state: GraphState) -> dict:
34
+ return {"ran_side_effect": True}
35
+
36
+ graph = StateGraph(GraphState)
37
+ graph.add_node("guarded", gated_node(gate, guarded))
38
+ graph.add_node("blocked", lambda state: {})
39
+ graph.add_node("allowed", lambda state: {})
40
+ graph.add_edge(START, "guarded")
41
+ graph.add_conditional_edges("guarded", route_on_denial("blocked", "allowed"))
42
+ graph.add_edge("blocked", END)
43
+ graph.add_edge("allowed", END)
44
+ return graph.compile()
45
+
46
+
47
+ async def test_sufficient_gamma_runs_node_and_routes_to_allowed():
48
+ result = await _build_graph().ainvoke({"gamma": 5.0})
49
+ assert result.get("ran_side_effect") is True
50
+ assert result.get("vsl_denial") is None
51
+
52
+
53
+ async def test_insufficient_gamma_skips_node_and_routes_to_blocked():
54
+ result = await _build_graph().ainvoke({"gamma": 0.1})
55
+ assert result.get("ran_side_effect") is None
56
+ assert result.get("vsl_denial") is not None
57
+
58
+
59
+ class InvariantState(TypedDict, total=False):
60
+ allowed: bool
61
+ ran_side_effect: bool
62
+ vsl_denial: Any
63
+
64
+
65
+ async def _rule(candidate_input: InvariantState) -> bool:
66
+ return candidate_input["allowed"]
67
+
68
+
69
+ def _build_invariant_graph():
70
+ invariant = Invariant(
71
+ name="integration-test-invariant",
72
+ description="Requires state['allowed'] to be True before the guarded node runs.",
73
+ rule=_rule,
74
+ assurance_basis=_ASSURANCE_BASIS,
75
+ )
76
+ gate = LangGraphAdapter().compile_invariant(invariant)
77
+
78
+ def guarded(state: InvariantState) -> dict:
79
+ return {"ran_side_effect": True}
80
+
81
+ graph = StateGraph(InvariantState)
82
+ graph.add_node("guarded", gated_node(gate, guarded))
83
+ graph.add_node("blocked", lambda state: {})
84
+ graph.add_node("allowed", lambda state: {})
85
+ graph.add_edge(START, "guarded")
86
+ graph.add_conditional_edges("guarded", route_on_denial("blocked", "allowed"))
87
+ graph.add_edge("blocked", END)
88
+ graph.add_edge("allowed", END)
89
+ return graph.compile()
90
+
91
+
92
+ async def test_passing_invariant_runs_node_and_routes_to_allowed():
93
+ result = await _build_invariant_graph().ainvoke({"allowed": True})
94
+ assert result.get("ran_side_effect") is True
95
+ assert result.get("vsl_denial") is None
96
+
97
+
98
+ async def test_violated_invariant_skips_node_and_routes_to_blocked():
99
+ result = await _build_invariant_graph().ainvoke({"allowed": False})
100
+ assert result.get("ran_side_effect") is None
101
+ denial = result.get("vsl_denial")
102
+ assert isinstance(denial, InvariantViolation)
@@ -0,0 +1,27 @@
1
+ import sys
2
+ from pathlib import Path
3
+
4
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "examples" / "financial-agents"))
5
+
6
+ from purchase_approval_agent import build_agent # noqa: E402
7
+
8
+
9
+ async def test_high_confidence_under_cap_is_approved():
10
+ result = await build_agent().ainvoke({"amount": 250.0, "confidence": 5.0})
11
+ assert result.get("approved") is True
12
+ assert result.get("confidence_denial") is None
13
+ assert result.get("cap_denial") is None
14
+
15
+
16
+ async def test_low_confidence_is_blocked_before_cap_check_runs():
17
+ result = await build_agent().ainvoke({"amount": 250.0, "confidence": 0.2})
18
+ assert result.get("approved") is None
19
+ assert result.get("confidence_denial") is not None
20
+ assert result.get("cap_denial") is None
21
+
22
+
23
+ async def test_high_confidence_cannot_bypass_hard_cap():
24
+ result = await build_agent().ainvoke({"amount": 5000.0, "confidence": 5.0})
25
+ assert result.get("approved") is None
26
+ assert result.get("confidence_denial") is None
27
+ assert result.get("cap_denial") is not None