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.
- vsl_langgraph-0.1.2/LICENSE +21 -0
- vsl_langgraph-0.1.2/PKG-INFO +141 -0
- vsl_langgraph-0.1.2/README.md +113 -0
- vsl_langgraph-0.1.2/pyproject.toml +42 -0
- vsl_langgraph-0.1.2/setup.cfg +4 -0
- vsl_langgraph-0.1.2/src/vsl_langgraph/__init__.py +9 -0
- vsl_langgraph-0.1.2/src/vsl_langgraph/adapter.py +163 -0
- vsl_langgraph-0.1.2/src/vsl_langgraph/integration.py +81 -0
- vsl_langgraph-0.1.2/src/vsl_langgraph.egg-info/PKG-INFO +141 -0
- vsl_langgraph-0.1.2/src/vsl_langgraph.egg-info/SOURCES.txt +17 -0
- vsl_langgraph-0.1.2/src/vsl_langgraph.egg-info/dependency_links.txt +1 -0
- vsl_langgraph-0.1.2/src/vsl_langgraph.egg-info/requires.txt +11 -0
- vsl_langgraph-0.1.2/src/vsl_langgraph.egg-info/top_level.txt +1 -0
- vsl_langgraph-0.1.2/tests/test_chat_reply_agent.py +34 -0
- vsl_langgraph-0.1.2/tests/test_conformance.py +8 -0
- vsl_langgraph-0.1.2/tests/test_customer_support_agent.py +40 -0
- vsl_langgraph-0.1.2/tests/test_full_claim_hosted_api_warning.py +126 -0
- vsl_langgraph-0.1.2/tests/test_integration.py +102 -0
- vsl_langgraph-0.1.2/tests/test_purchase_approval_agent.py +27 -0
|
@@ -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,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 @@
|
|
|
1
|
+
|
|
@@ -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,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
|