llmsessioncontract 0.1.0__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.
- llmsessioncontract-0.1.0/LICENSE +21 -0
- llmsessioncontract-0.1.0/PKG-INFO +226 -0
- llmsessioncontract-0.1.0/README.md +199 -0
- llmsessioncontract-0.1.0/llmcontract/__init__.py +11 -0
- llmsessioncontract-0.1.0/llmcontract/dsl/__init__.py +11 -0
- llmsessioncontract-0.1.0/llmcontract/dsl/ast.py +59 -0
- llmsessioncontract-0.1.0/llmcontract/dsl/parser.py +175 -0
- llmsessioncontract-0.1.0/llmcontract/integration/__init__.py +13 -0
- llmsessioncontract-0.1.0/llmcontract/integration/client.py +75 -0
- llmsessioncontract-0.1.0/llmcontract/integration/exceptions.py +25 -0
- llmsessioncontract-0.1.0/llmcontract/integration/middleware.py +112 -0
- llmsessioncontract-0.1.0/llmcontract/integration/types.py +27 -0
- llmsessioncontract-0.1.0/llmcontract/monitor/__init__.py +4 -0
- llmsessioncontract-0.1.0/llmcontract/monitor/automaton.py +151 -0
- llmsessioncontract-0.1.0/llmcontract/monitor/monitor.py +82 -0
- llmsessioncontract-0.1.0/llmcontract/py.typed +0 -0
- llmsessioncontract-0.1.0/llmsessioncontract.egg-info/PKG-INFO +226 -0
- llmsessioncontract-0.1.0/llmsessioncontract.egg-info/SOURCES.txt +21 -0
- llmsessioncontract-0.1.0/llmsessioncontract.egg-info/dependency_links.txt +1 -0
- llmsessioncontract-0.1.0/llmsessioncontract.egg-info/requires.txt +3 -0
- llmsessioncontract-0.1.0/llmsessioncontract.egg-info/top_level.txt +1 -0
- llmsessioncontract-0.1.0/pyproject.toml +39 -0
- llmsessioncontract-0.1.0/setup.cfg +4 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Chris Bartolo Burlo
|
|
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,226 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: llmsessioncontract
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Runtime monitor for LLM agent interaction protocols based on session type theory
|
|
5
|
+
Author-email: Chris Bartolo Burlo <chris@mizziburlo.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://llmcontract.dev
|
|
8
|
+
Project-URL: Repository, https://github.com/chrisbartoloburlo/llmcontract
|
|
9
|
+
Project-URL: Issues, https://github.com/chrisbartoloburlo/llmcontract/issues
|
|
10
|
+
Keywords: llm,agents,session-types,runtime-monitoring,protocol,contracts
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
19
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
20
|
+
Classifier: Typing :: Typed
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
License-File: LICENSE
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: pytest; extra == "dev"
|
|
26
|
+
Dynamic: license-file
|
|
27
|
+
|
|
28
|
+
# llmcontract
|
|
29
|
+
|
|
30
|
+
A runtime monitor for LLM agent interaction protocols based on session type theory.
|
|
31
|
+
|
|
32
|
+
`llmcontract` lets you define communication protocols using a concise DSL inspired by session types, then monitor agent interactions at runtime to catch protocol violations the moment they happen.
|
|
33
|
+
|
|
34
|
+
## Installation
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pip install -e .
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Protocol DSL
|
|
41
|
+
|
|
42
|
+
Protocols are written as strings using this syntax:
|
|
43
|
+
|
|
44
|
+
| Syntax | Meaning |
|
|
45
|
+
|--------|---------|
|
|
46
|
+
| `!label` | Send action |
|
|
47
|
+
| `?label` | Receive action |
|
|
48
|
+
| `!{a, b}` | Internal choice (sender chooses) |
|
|
49
|
+
| `?{a, b}` | External choice (receiver chooses) |
|
|
50
|
+
| `.` | Sequence |
|
|
51
|
+
| `rec X. ...X...` | Recursion |
|
|
52
|
+
| `end` | Terminal state |
|
|
53
|
+
|
|
54
|
+
### Examples
|
|
55
|
+
|
|
56
|
+
A flight booking protocol — a strict linear sequence:
|
|
57
|
+
|
|
58
|
+
```
|
|
59
|
+
!SearchFlights.?FlightResults.!PresentOptions.?UserApproval.!BookFlight.?BookingConfirmation.end
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
A card payment protocol — with branching and recursion:
|
|
63
|
+
|
|
64
|
+
```
|
|
65
|
+
!CreateCard.?{CardCreated.rec X.!Transaction.?{TransactionOK.X, SessionEnd}, CardError}.end
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Usage
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
from llmcontract import Monitor, Ok, Violation, Blocked
|
|
72
|
+
|
|
73
|
+
protocol = "!SearchFlights.?FlightResults.!BookFlight.?BookingConfirmation.end"
|
|
74
|
+
m = Monitor(protocol)
|
|
75
|
+
|
|
76
|
+
m.send("SearchFlights") # Ok()
|
|
77
|
+
m.receive("FlightResults") # Ok()
|
|
78
|
+
m.send("BookFlight") # Ok()
|
|
79
|
+
m.receive("BookingConfirmation") # Ok()
|
|
80
|
+
assert m.is_terminal
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### Catching violations
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
m = Monitor("!Ping.?Pong.end")
|
|
87
|
+
m.send("Ping") # Ok()
|
|
88
|
+
m.send("Pong") # Violation(expected=['?Pong'], got='!Pong')
|
|
89
|
+
m.send("Anything") # Blocked('monitor halted after a previous violation')
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### Working with choices
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
protocol = "!CreateCard.?{CardCreated.!Done.end, CardError.end}"
|
|
96
|
+
m = Monitor(protocol)
|
|
97
|
+
m.send("CreateCard") # Ok()
|
|
98
|
+
m.receive("CardError") # Ok() — the receiver chose this branch
|
|
99
|
+
assert m.is_terminal
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
### Recursion
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
protocol = "rec X.!Ping.?Pong.X"
|
|
106
|
+
m = Monitor(protocol)
|
|
107
|
+
for _ in range(100):
|
|
108
|
+
m.send("Ping") # Ok()
|
|
109
|
+
m.receive("Pong") # Ok()
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## Integration Layer
|
|
113
|
+
|
|
114
|
+
For real agent loops, `llmcontract` provides a client wrapper and tool middleware that share a single monitor — so the full interaction is tracked automatically.
|
|
115
|
+
|
|
116
|
+
### Client Wrapper
|
|
117
|
+
|
|
118
|
+
Wraps any LLM client call. Checks `!Send` before calling the LLM and `?Receive` after getting the response. SDK-agnostic — you provide a small adapter function.
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
from llmcontract import Monitor, MonitoredClient, LLMResponse, ToolCall
|
|
122
|
+
|
|
123
|
+
monitor = Monitor(
|
|
124
|
+
"rec Loop.!Request.?{ToolCall.!ToolResult.Loop, FinalAnswer.end}"
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
# Adapt your SDK's response to LLMResponse
|
|
128
|
+
def adapt(raw):
|
|
129
|
+
if raw.tool_calls:
|
|
130
|
+
return LLMResponse(tool_calls=[
|
|
131
|
+
ToolCall(name=tc.function.name, arguments=tc.arguments, id=tc.id)
|
|
132
|
+
for tc in raw.tool_calls
|
|
133
|
+
])
|
|
134
|
+
return LLMResponse(content=raw.content)
|
|
135
|
+
|
|
136
|
+
client = MonitoredClient(
|
|
137
|
+
llm_call=openai.chat.completions.create,
|
|
138
|
+
response_adapter=adapt,
|
|
139
|
+
monitor=monitor,
|
|
140
|
+
send_label="Request",
|
|
141
|
+
receive_label=lambda r: "ToolCall" if r.has_tool_calls else "FinalAnswer",
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
response = client.call(model="gpt-4", messages=[...])
|
|
145
|
+
# Automatically fires !Request then ?ToolCall or ?FinalAnswer
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
### Tool Middleware
|
|
149
|
+
|
|
150
|
+
Wraps tool execution. When the LLM requests a tool, the middleware checks `?Receive` (tool requested) and `!Send` (result returned) against the protocol.
|
|
151
|
+
|
|
152
|
+
```python
|
|
153
|
+
from llmcontract import ToolMiddleware
|
|
154
|
+
|
|
155
|
+
middleware = ToolMiddleware(
|
|
156
|
+
monitor=monitor, # same monitor as the client
|
|
157
|
+
tools={
|
|
158
|
+
"search": search_fn,
|
|
159
|
+
"book": book_fn,
|
|
160
|
+
},
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
# Process all tool calls from a response
|
|
164
|
+
results = middleware.process(response)
|
|
165
|
+
# Each tool call checks ?receive and !send against the protocol
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
### Combined Agent Loop
|
|
169
|
+
|
|
170
|
+
```python
|
|
171
|
+
from llmcontract import (
|
|
172
|
+
Monitor, MonitoredClient, ToolMiddleware,
|
|
173
|
+
LLMResponse, ToolCall, ProtocolViolationError,
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
protocol = "rec Loop.!Request.?{ToolCall.!ToolResult.Loop, FinalAnswer.end}"
|
|
177
|
+
monitor = Monitor(protocol)
|
|
178
|
+
|
|
179
|
+
client = MonitoredClient(
|
|
180
|
+
llm_call=my_llm_fn,
|
|
181
|
+
response_adapter=my_adapter,
|
|
182
|
+
monitor=monitor,
|
|
183
|
+
send_label="Request",
|
|
184
|
+
receive_label=lambda r: "ToolCall" if r.has_tool_calls else "FinalAnswer",
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
while True:
|
|
188
|
+
try:
|
|
189
|
+
response = client.call(messages=messages)
|
|
190
|
+
except ProtocolViolationError as e:
|
|
191
|
+
print(f"Protocol violated: {e}")
|
|
192
|
+
break
|
|
193
|
+
|
|
194
|
+
if not response.has_tool_calls:
|
|
195
|
+
break # FinalAnswer — protocol complete
|
|
196
|
+
|
|
197
|
+
# Execute tools, send results back
|
|
198
|
+
for tc in response.tool_calls:
|
|
199
|
+
result = tools[tc.name](**tc.arguments)
|
|
200
|
+
monitor.send("ToolResult") # record the send
|
|
201
|
+
messages.append(tool_result_msg(tc.id, result))
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
## Architecture
|
|
205
|
+
|
|
206
|
+
```
|
|
207
|
+
DSL string ──▶ Parser ──▶ AST ──▶ FSM Compiler ──▶ Automaton ──▶ Monitor
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
- **Parser** (`llmcontract.dsl.parser`) — hand-written recursive descent parser that produces an AST
|
|
211
|
+
- **AST** (`llmcontract.dsl.ast`) — frozen dataclasses: `Send`, `Receive`, `InternalChoice`, `ExternalChoice`, `Sequence`, `Recursion`, `RecVar`, `End`
|
|
212
|
+
- **FSM Compiler** (`llmcontract.monitor.automaton`) — compiles the AST into a finite state automaton with transitions keyed by `(direction, label)`
|
|
213
|
+
- **Monitor** (`llmcontract.monitor.monitor`) — steps through the automaton on each `send`/`receive` call, returning `Ok`, `Violation`, or `Blocked`
|
|
214
|
+
- **MonitoredClient** (`llmcontract.integration.client`) — wraps any LLM client call with automatic protocol checks
|
|
215
|
+
- **ToolMiddleware** (`llmcontract.integration.middleware`) — intercepts tool execution with protocol checks
|
|
216
|
+
|
|
217
|
+
## Tests
|
|
218
|
+
|
|
219
|
+
```bash
|
|
220
|
+
pip install -e ".[dev]"
|
|
221
|
+
pytest llmcontract/tests/ -v
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
## License
|
|
225
|
+
|
|
226
|
+
MIT
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
# llmcontract
|
|
2
|
+
|
|
3
|
+
A runtime monitor for LLM agent interaction protocols based on session type theory.
|
|
4
|
+
|
|
5
|
+
`llmcontract` lets you define communication protocols using a concise DSL inspired by session types, then monitor agent interactions at runtime to catch protocol violations the moment they happen.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install -e .
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Protocol DSL
|
|
14
|
+
|
|
15
|
+
Protocols are written as strings using this syntax:
|
|
16
|
+
|
|
17
|
+
| Syntax | Meaning |
|
|
18
|
+
|--------|---------|
|
|
19
|
+
| `!label` | Send action |
|
|
20
|
+
| `?label` | Receive action |
|
|
21
|
+
| `!{a, b}` | Internal choice (sender chooses) |
|
|
22
|
+
| `?{a, b}` | External choice (receiver chooses) |
|
|
23
|
+
| `.` | Sequence |
|
|
24
|
+
| `rec X. ...X...` | Recursion |
|
|
25
|
+
| `end` | Terminal state |
|
|
26
|
+
|
|
27
|
+
### Examples
|
|
28
|
+
|
|
29
|
+
A flight booking protocol — a strict linear sequence:
|
|
30
|
+
|
|
31
|
+
```
|
|
32
|
+
!SearchFlights.?FlightResults.!PresentOptions.?UserApproval.!BookFlight.?BookingConfirmation.end
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
A card payment protocol — with branching and recursion:
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
!CreateCard.?{CardCreated.rec X.!Transaction.?{TransactionOK.X, SessionEnd}, CardError}.end
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Usage
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
from llmcontract import Monitor, Ok, Violation, Blocked
|
|
45
|
+
|
|
46
|
+
protocol = "!SearchFlights.?FlightResults.!BookFlight.?BookingConfirmation.end"
|
|
47
|
+
m = Monitor(protocol)
|
|
48
|
+
|
|
49
|
+
m.send("SearchFlights") # Ok()
|
|
50
|
+
m.receive("FlightResults") # Ok()
|
|
51
|
+
m.send("BookFlight") # Ok()
|
|
52
|
+
m.receive("BookingConfirmation") # Ok()
|
|
53
|
+
assert m.is_terminal
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### Catching violations
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
m = Monitor("!Ping.?Pong.end")
|
|
60
|
+
m.send("Ping") # Ok()
|
|
61
|
+
m.send("Pong") # Violation(expected=['?Pong'], got='!Pong')
|
|
62
|
+
m.send("Anything") # Blocked('monitor halted after a previous violation')
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### Working with choices
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
protocol = "!CreateCard.?{CardCreated.!Done.end, CardError.end}"
|
|
69
|
+
m = Monitor(protocol)
|
|
70
|
+
m.send("CreateCard") # Ok()
|
|
71
|
+
m.receive("CardError") # Ok() — the receiver chose this branch
|
|
72
|
+
assert m.is_terminal
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### Recursion
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
protocol = "rec X.!Ping.?Pong.X"
|
|
79
|
+
m = Monitor(protocol)
|
|
80
|
+
for _ in range(100):
|
|
81
|
+
m.send("Ping") # Ok()
|
|
82
|
+
m.receive("Pong") # Ok()
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Integration Layer
|
|
86
|
+
|
|
87
|
+
For real agent loops, `llmcontract` provides a client wrapper and tool middleware that share a single monitor — so the full interaction is tracked automatically.
|
|
88
|
+
|
|
89
|
+
### Client Wrapper
|
|
90
|
+
|
|
91
|
+
Wraps any LLM client call. Checks `!Send` before calling the LLM and `?Receive` after getting the response. SDK-agnostic — you provide a small adapter function.
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
from llmcontract import Monitor, MonitoredClient, LLMResponse, ToolCall
|
|
95
|
+
|
|
96
|
+
monitor = Monitor(
|
|
97
|
+
"rec Loop.!Request.?{ToolCall.!ToolResult.Loop, FinalAnswer.end}"
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
# Adapt your SDK's response to LLMResponse
|
|
101
|
+
def adapt(raw):
|
|
102
|
+
if raw.tool_calls:
|
|
103
|
+
return LLMResponse(tool_calls=[
|
|
104
|
+
ToolCall(name=tc.function.name, arguments=tc.arguments, id=tc.id)
|
|
105
|
+
for tc in raw.tool_calls
|
|
106
|
+
])
|
|
107
|
+
return LLMResponse(content=raw.content)
|
|
108
|
+
|
|
109
|
+
client = MonitoredClient(
|
|
110
|
+
llm_call=openai.chat.completions.create,
|
|
111
|
+
response_adapter=adapt,
|
|
112
|
+
monitor=monitor,
|
|
113
|
+
send_label="Request",
|
|
114
|
+
receive_label=lambda r: "ToolCall" if r.has_tool_calls else "FinalAnswer",
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
response = client.call(model="gpt-4", messages=[...])
|
|
118
|
+
# Automatically fires !Request then ?ToolCall or ?FinalAnswer
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
### Tool Middleware
|
|
122
|
+
|
|
123
|
+
Wraps tool execution. When the LLM requests a tool, the middleware checks `?Receive` (tool requested) and `!Send` (result returned) against the protocol.
|
|
124
|
+
|
|
125
|
+
```python
|
|
126
|
+
from llmcontract import ToolMiddleware
|
|
127
|
+
|
|
128
|
+
middleware = ToolMiddleware(
|
|
129
|
+
monitor=monitor, # same monitor as the client
|
|
130
|
+
tools={
|
|
131
|
+
"search": search_fn,
|
|
132
|
+
"book": book_fn,
|
|
133
|
+
},
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
# Process all tool calls from a response
|
|
137
|
+
results = middleware.process(response)
|
|
138
|
+
# Each tool call checks ?receive and !send against the protocol
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
### Combined Agent Loop
|
|
142
|
+
|
|
143
|
+
```python
|
|
144
|
+
from llmcontract import (
|
|
145
|
+
Monitor, MonitoredClient, ToolMiddleware,
|
|
146
|
+
LLMResponse, ToolCall, ProtocolViolationError,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
protocol = "rec Loop.!Request.?{ToolCall.!ToolResult.Loop, FinalAnswer.end}"
|
|
150
|
+
monitor = Monitor(protocol)
|
|
151
|
+
|
|
152
|
+
client = MonitoredClient(
|
|
153
|
+
llm_call=my_llm_fn,
|
|
154
|
+
response_adapter=my_adapter,
|
|
155
|
+
monitor=monitor,
|
|
156
|
+
send_label="Request",
|
|
157
|
+
receive_label=lambda r: "ToolCall" if r.has_tool_calls else "FinalAnswer",
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
while True:
|
|
161
|
+
try:
|
|
162
|
+
response = client.call(messages=messages)
|
|
163
|
+
except ProtocolViolationError as e:
|
|
164
|
+
print(f"Protocol violated: {e}")
|
|
165
|
+
break
|
|
166
|
+
|
|
167
|
+
if not response.has_tool_calls:
|
|
168
|
+
break # FinalAnswer — protocol complete
|
|
169
|
+
|
|
170
|
+
# Execute tools, send results back
|
|
171
|
+
for tc in response.tool_calls:
|
|
172
|
+
result = tools[tc.name](**tc.arguments)
|
|
173
|
+
monitor.send("ToolResult") # record the send
|
|
174
|
+
messages.append(tool_result_msg(tc.id, result))
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
## Architecture
|
|
178
|
+
|
|
179
|
+
```
|
|
180
|
+
DSL string ──▶ Parser ──▶ AST ──▶ FSM Compiler ──▶ Automaton ──▶ Monitor
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
- **Parser** (`llmcontract.dsl.parser`) — hand-written recursive descent parser that produces an AST
|
|
184
|
+
- **AST** (`llmcontract.dsl.ast`) — frozen dataclasses: `Send`, `Receive`, `InternalChoice`, `ExternalChoice`, `Sequence`, `Recursion`, `RecVar`, `End`
|
|
185
|
+
- **FSM Compiler** (`llmcontract.monitor.automaton`) — compiles the AST into a finite state automaton with transitions keyed by `(direction, label)`
|
|
186
|
+
- **Monitor** (`llmcontract.monitor.monitor`) — steps through the automaton on each `send`/`receive` call, returning `Ok`, `Violation`, or `Blocked`
|
|
187
|
+
- **MonitoredClient** (`llmcontract.integration.client`) — wraps any LLM client call with automatic protocol checks
|
|
188
|
+
- **ToolMiddleware** (`llmcontract.integration.middleware`) — intercepts tool execution with protocol checks
|
|
189
|
+
|
|
190
|
+
## Tests
|
|
191
|
+
|
|
192
|
+
```bash
|
|
193
|
+
pip install -e ".[dev]"
|
|
194
|
+
pytest llmcontract/tests/ -v
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
## License
|
|
198
|
+
|
|
199
|
+
MIT
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from llmcontract.monitor.monitor import Monitor, MonitorResult, Ok, Violation, Blocked
|
|
2
|
+
from llmcontract.integration import (
|
|
3
|
+
MonitoredClient, ToolMiddleware, ToolResult,
|
|
4
|
+
LLMResponse, ToolCall, ProtocolViolationError,
|
|
5
|
+
)
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"Monitor", "MonitorResult", "Ok", "Violation", "Blocked",
|
|
9
|
+
"MonitoredClient", "ToolMiddleware", "ToolResult",
|
|
10
|
+
"LLMResponse", "ToolCall", "ProtocolViolationError",
|
|
11
|
+
]
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from llmcontract.dsl.ast import (
|
|
2
|
+
Send, Receive, InternalChoice, ExternalChoice,
|
|
3
|
+
Sequence, Recursion, RecVar, End,
|
|
4
|
+
)
|
|
5
|
+
from llmcontract.dsl.parser import parse, ParseError
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"Send", "Receive", "InternalChoice", "ExternalChoice",
|
|
9
|
+
"Sequence", "Recursion", "RecVar", "End",
|
|
10
|
+
"parse", "ParseError",
|
|
11
|
+
]
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from typing import Union
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass(frozen=True)
|
|
7
|
+
class Send:
|
|
8
|
+
"""!label — send action."""
|
|
9
|
+
label: str
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class Receive:
|
|
14
|
+
"""?label — receive action."""
|
|
15
|
+
label: str
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class InternalChoice:
|
|
20
|
+
"""!{a, b, ...} — sender chooses among branches."""
|
|
21
|
+
branches: dict[str, ProtocolNode]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class ExternalChoice:
|
|
26
|
+
"""?{a, b, ...} — receiver chooses among branches."""
|
|
27
|
+
branches: dict[str, ProtocolNode]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class Sequence:
|
|
32
|
+
"""left.right — sequential composition."""
|
|
33
|
+
left: ProtocolNode
|
|
34
|
+
right: ProtocolNode
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class Recursion:
|
|
39
|
+
"""rec X. body — recursive protocol."""
|
|
40
|
+
var: str
|
|
41
|
+
body: ProtocolNode
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class RecVar:
|
|
46
|
+
"""X — recursion variable reference."""
|
|
47
|
+
var: str
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class End:
|
|
52
|
+
"""end — terminal state."""
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
ProtocolNode = Union[
|
|
57
|
+
Send, Receive, InternalChoice, ExternalChoice,
|
|
58
|
+
Sequence, Recursion, RecVar, End,
|
|
59
|
+
]
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Hand-written recursive descent parser for the session type DSL."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from llmcontract.dsl.ast import (
|
|
6
|
+
End, ExternalChoice, InternalChoice, ProtocolNode,
|
|
7
|
+
Receive, Recursion, RecVar, Send, Sequence,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ParseError(Exception):
|
|
12
|
+
"""Raised on invalid input with position information."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, message: str, pos: int) -> None:
|
|
15
|
+
self.pos = pos
|
|
16
|
+
super().__init__(f"Parse error at position {pos}: {message}")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class _Parser:
|
|
20
|
+
def __init__(self, src: str) -> None:
|
|
21
|
+
self.src = src
|
|
22
|
+
self.pos = 0
|
|
23
|
+
|
|
24
|
+
# ── helpers ──────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
def _skip_ws(self) -> None:
|
|
27
|
+
while self.pos < len(self.src) and self.src[self.pos] in " \t\n\r":
|
|
28
|
+
self.pos += 1
|
|
29
|
+
|
|
30
|
+
def _peek(self) -> str | None:
|
|
31
|
+
self._skip_ws()
|
|
32
|
+
if self.pos >= len(self.src):
|
|
33
|
+
return None
|
|
34
|
+
return self.src[self.pos]
|
|
35
|
+
|
|
36
|
+
def _expect(self, ch: str) -> None:
|
|
37
|
+
self._skip_ws()
|
|
38
|
+
if self.pos >= len(self.src) or self.src[self.pos] != ch:
|
|
39
|
+
found = self.src[self.pos] if self.pos < len(self.src) else "EOF"
|
|
40
|
+
raise ParseError(f"expected '{ch}', got '{found}'", self.pos)
|
|
41
|
+
self.pos += 1
|
|
42
|
+
|
|
43
|
+
def _read_ident(self) -> str:
|
|
44
|
+
self._skip_ws()
|
|
45
|
+
start = self.pos
|
|
46
|
+
while self.pos < len(self.src) and (self.src[self.pos].isalnum() or self.src[self.pos] == '_'):
|
|
47
|
+
self.pos += 1
|
|
48
|
+
if self.pos == start:
|
|
49
|
+
found = self.src[self.pos] if self.pos < len(self.src) else "EOF"
|
|
50
|
+
raise ParseError(f"expected identifier, got '{found}'", self.pos)
|
|
51
|
+
return self.src[start:self.pos]
|
|
52
|
+
|
|
53
|
+
def _at_keyword(self, kw: str) -> bool:
|
|
54
|
+
self._skip_ws()
|
|
55
|
+
end = self.pos + len(kw)
|
|
56
|
+
if self.src[self.pos:end] == kw:
|
|
57
|
+
# Make sure it's not a prefix of a longer identifier
|
|
58
|
+
if end >= len(self.src) or not (self.src[end].isalnum() or self.src[end] == '_'):
|
|
59
|
+
return True
|
|
60
|
+
return False
|
|
61
|
+
|
|
62
|
+
# ── grammar ──────────────────────────────────────────────
|
|
63
|
+
#
|
|
64
|
+
# protocol ::= atom ('.' protocol)?
|
|
65
|
+
# atom ::= '!' choice_or_label
|
|
66
|
+
# | '?' choice_or_label
|
|
67
|
+
# | 'rec' IDENT '.' protocol
|
|
68
|
+
# | 'end'
|
|
69
|
+
# | IDENT (recursion variable)
|
|
70
|
+
#
|
|
71
|
+
# choice_or_label ::= '{' branch (',' branch)* '}'
|
|
72
|
+
# | IDENT
|
|
73
|
+
#
|
|
74
|
+
# branch ::= IDENT ('.' protocol)?
|
|
75
|
+
|
|
76
|
+
def parse(self) -> ProtocolNode:
|
|
77
|
+
node = self._parse_protocol()
|
|
78
|
+
self._skip_ws()
|
|
79
|
+
if self.pos < len(self.src):
|
|
80
|
+
raise ParseError(f"unexpected character '{self.src[self.pos]}'", self.pos)
|
|
81
|
+
return node
|
|
82
|
+
|
|
83
|
+
def _parse_protocol(self) -> ProtocolNode:
|
|
84
|
+
left = self._parse_atom()
|
|
85
|
+
self._skip_ws()
|
|
86
|
+
if self._peek() == '.':
|
|
87
|
+
# Could be sequence or end-of-input
|
|
88
|
+
# We need to check that what follows '.' is another atom, not EOF
|
|
89
|
+
save = self.pos
|
|
90
|
+
self.pos += 1 # consume '.'
|
|
91
|
+
self._skip_ws()
|
|
92
|
+
if self.pos >= len(self.src):
|
|
93
|
+
raise ParseError("unexpected EOF after '.'", self.pos)
|
|
94
|
+
right = self._parse_protocol()
|
|
95
|
+
left = Sequence(left, right)
|
|
96
|
+
return left
|
|
97
|
+
|
|
98
|
+
def _parse_atom(self) -> ProtocolNode:
|
|
99
|
+
ch = self._peek()
|
|
100
|
+
if ch is None:
|
|
101
|
+
raise ParseError("unexpected EOF", self.pos)
|
|
102
|
+
|
|
103
|
+
if ch == '!':
|
|
104
|
+
self.pos += 1
|
|
105
|
+
return self._parse_send()
|
|
106
|
+
if ch == '?':
|
|
107
|
+
self.pos += 1
|
|
108
|
+
return self._parse_receive()
|
|
109
|
+
if self._at_keyword('rec'):
|
|
110
|
+
return self._parse_rec()
|
|
111
|
+
if self._at_keyword('end'):
|
|
112
|
+
self.pos += 3
|
|
113
|
+
return End()
|
|
114
|
+
|
|
115
|
+
# Must be a recursion variable
|
|
116
|
+
ident = self._read_ident()
|
|
117
|
+
return RecVar(ident)
|
|
118
|
+
|
|
119
|
+
def _parse_send(self) -> ProtocolNode:
|
|
120
|
+
if self._peek() == '{':
|
|
121
|
+
return self._parse_internal_choice()
|
|
122
|
+
label = self._read_ident()
|
|
123
|
+
return Send(label)
|
|
124
|
+
|
|
125
|
+
def _parse_receive(self) -> ProtocolNode:
|
|
126
|
+
if self._peek() == '{':
|
|
127
|
+
return self._parse_external_choice()
|
|
128
|
+
label = self._read_ident()
|
|
129
|
+
return Receive(label)
|
|
130
|
+
|
|
131
|
+
def _parse_internal_choice(self) -> InternalChoice:
|
|
132
|
+
self._expect('{')
|
|
133
|
+
branches = self._parse_branches()
|
|
134
|
+
self._expect('}')
|
|
135
|
+
return InternalChoice(branches)
|
|
136
|
+
|
|
137
|
+
def _parse_external_choice(self) -> ExternalChoice:
|
|
138
|
+
self._expect('{')
|
|
139
|
+
branches = self._parse_branches()
|
|
140
|
+
self._expect('}')
|
|
141
|
+
return ExternalChoice(branches)
|
|
142
|
+
|
|
143
|
+
def _parse_branches(self) -> dict[str, ProtocolNode]:
|
|
144
|
+
branches: dict[str, ProtocolNode] = {}
|
|
145
|
+
label, body = self._parse_branch()
|
|
146
|
+
branches[label] = body
|
|
147
|
+
while self._peek() == ',':
|
|
148
|
+
self.pos += 1 # consume ','
|
|
149
|
+
label, body = self._parse_branch()
|
|
150
|
+
if label in branches:
|
|
151
|
+
raise ParseError(f"duplicate branch label '{label}'", self.pos)
|
|
152
|
+
branches[label] = body
|
|
153
|
+
return branches
|
|
154
|
+
|
|
155
|
+
def _parse_branch(self) -> tuple[str, ProtocolNode]:
|
|
156
|
+
label = self._read_ident()
|
|
157
|
+
self._skip_ws()
|
|
158
|
+
if self._peek() == '.':
|
|
159
|
+
self.pos += 1 # consume '.'
|
|
160
|
+
body = self._parse_protocol()
|
|
161
|
+
else:
|
|
162
|
+
body = End()
|
|
163
|
+
return label, body
|
|
164
|
+
|
|
165
|
+
def _parse_rec(self) -> Recursion:
|
|
166
|
+
self.pos += 3 # consume 'rec'
|
|
167
|
+
var = self._read_ident()
|
|
168
|
+
self._expect('.')
|
|
169
|
+
body = self._parse_protocol()
|
|
170
|
+
return Recursion(var, body)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def parse(src: str) -> ProtocolNode:
|
|
174
|
+
"""Parse a session type DSL string into an AST."""
|
|
175
|
+
return _Parser(src).parse()
|