llmsessioncontract 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- llmcontract/__init__.py +11 -0
- llmcontract/dsl/__init__.py +11 -0
- llmcontract/dsl/ast.py +59 -0
- llmcontract/dsl/parser.py +175 -0
- llmcontract/integration/__init__.py +13 -0
- llmcontract/integration/client.py +75 -0
- llmcontract/integration/exceptions.py +25 -0
- llmcontract/integration/middleware.py +112 -0
- llmcontract/integration/types.py +27 -0
- llmcontract/monitor/__init__.py +4 -0
- llmcontract/monitor/automaton.py +151 -0
- llmcontract/monitor/monitor.py +82 -0
- llmcontract/py.typed +0 -0
- llmsessioncontract-0.1.0.dist-info/METADATA +226 -0
- llmsessioncontract-0.1.0.dist-info/RECORD +18 -0
- llmsessioncontract-0.1.0.dist-info/WHEEL +5 -0
- llmsessioncontract-0.1.0.dist-info/licenses/LICENSE +21 -0
- llmsessioncontract-0.1.0.dist-info/top_level.txt +1 -0
llmcontract/__init__.py
ADDED
|
@@ -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
|
+
]
|
llmcontract/dsl/ast.py
ADDED
|
@@ -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()
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from llmcontract.integration.client import MonitoredClient
|
|
2
|
+
from llmcontract.integration.middleware import ToolMiddleware, ToolResult
|
|
3
|
+
from llmcontract.integration.types import LLMResponse, ToolCall
|
|
4
|
+
from llmcontract.integration.exceptions import ProtocolViolationError
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"MonitoredClient",
|
|
8
|
+
"ToolMiddleware",
|
|
9
|
+
"ToolResult",
|
|
10
|
+
"LLMResponse",
|
|
11
|
+
"ToolCall",
|
|
12
|
+
"ProtocolViolationError",
|
|
13
|
+
]
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Client wrapper that enforces protocol compliance on LLM calls."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Callable
|
|
6
|
+
|
|
7
|
+
from llmcontract.monitor.monitor import Monitor, Ok
|
|
8
|
+
from llmcontract.integration.types import LLMResponse
|
|
9
|
+
from llmcontract.integration.exceptions import ProtocolViolationError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class MonitoredClient:
|
|
13
|
+
"""Wraps an LLM call function and checks protocol events automatically.
|
|
14
|
+
|
|
15
|
+
Parameters
|
|
16
|
+
----------
|
|
17
|
+
llm_call:
|
|
18
|
+
A function that sends a request to the LLM and returns a raw response.
|
|
19
|
+
Signature is flexible — MonitoredClient passes through *args/**kwargs.
|
|
20
|
+
response_adapter:
|
|
21
|
+
Converts the vendor-specific response object into an LLMResponse.
|
|
22
|
+
monitor:
|
|
23
|
+
Shared monitor instance that tracks protocol state.
|
|
24
|
+
send_label:
|
|
25
|
+
Label (or label-producing function) for the !Send event.
|
|
26
|
+
If callable, receives the same (*args, **kwargs) as llm_call.
|
|
27
|
+
receive_label:
|
|
28
|
+
Label (or label-producing function) for the ?Receive event.
|
|
29
|
+
If callable, receives the LLMResponse.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
llm_call: Callable[..., Any],
|
|
35
|
+
response_adapter: Callable[[Any], LLMResponse],
|
|
36
|
+
monitor: Monitor,
|
|
37
|
+
send_label: str | Callable[..., str] = "Request",
|
|
38
|
+
receive_label: str | Callable[[LLMResponse], str] = "Response",
|
|
39
|
+
) -> None:
|
|
40
|
+
self._llm_call = llm_call
|
|
41
|
+
self._response_adapter = response_adapter
|
|
42
|
+
self._monitor = monitor
|
|
43
|
+
self._send_label = send_label
|
|
44
|
+
self._receive_label = receive_label
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def monitor(self) -> Monitor:
|
|
48
|
+
return self._monitor
|
|
49
|
+
|
|
50
|
+
def call(self, *args: Any, **kwargs: Any) -> LLMResponse:
|
|
51
|
+
"""Send a request to the LLM, checking protocol on both sides.
|
|
52
|
+
|
|
53
|
+
1. Resolve send_label → monitor.send(label) → raise on violation
|
|
54
|
+
2. Call llm_call(*args, **kwargs)
|
|
55
|
+
3. Adapt response via response_adapter
|
|
56
|
+
4. Resolve receive_label → monitor.receive(label) → raise on violation
|
|
57
|
+
5. Return LLMResponse
|
|
58
|
+
"""
|
|
59
|
+
# Send check
|
|
60
|
+
label = self._send_label(*args, **kwargs) if callable(self._send_label) else self._send_label
|
|
61
|
+
result = self._monitor.send(label)
|
|
62
|
+
if not isinstance(result, Ok):
|
|
63
|
+
raise ProtocolViolationError(result, "send")
|
|
64
|
+
|
|
65
|
+
# LLM call
|
|
66
|
+
raw = self._llm_call(*args, **kwargs)
|
|
67
|
+
response = self._response_adapter(raw)
|
|
68
|
+
|
|
69
|
+
# Receive check
|
|
70
|
+
label = self._receive_label(response) if callable(self._receive_label) else self._receive_label
|
|
71
|
+
result = self._monitor.receive(label)
|
|
72
|
+
if not isinstance(result, Ok):
|
|
73
|
+
raise ProtocolViolationError(result, "receive")
|
|
74
|
+
|
|
75
|
+
return response
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Exceptions raised by the integration layer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from llmcontract.monitor.monitor import MonitorResult, Violation, Blocked
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ProtocolViolationError(Exception):
|
|
9
|
+
"""Raised when an LLM interaction violates the session protocol."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, result: MonitorResult, phase: str) -> None:
|
|
12
|
+
self.result = result
|
|
13
|
+
self.phase = phase
|
|
14
|
+
if isinstance(result, Violation):
|
|
15
|
+
expected = ", ".join(result.expected)
|
|
16
|
+
super().__init__(
|
|
17
|
+
f"Protocol violation during {phase}: "
|
|
18
|
+
f"got {result.got}, expected one of [{expected}]"
|
|
19
|
+
)
|
|
20
|
+
elif isinstance(result, Blocked):
|
|
21
|
+
super().__init__(
|
|
22
|
+
f"Protocol blocked during {phase}: {result.reason}"
|
|
23
|
+
)
|
|
24
|
+
else:
|
|
25
|
+
super().__init__(f"Protocol error during {phase}")
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Tool middleware that monitors tool execution against a protocol."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any, Callable
|
|
7
|
+
|
|
8
|
+
from llmcontract.monitor.monitor import Monitor, Ok
|
|
9
|
+
from llmcontract.integration.types import ToolCall, LLMResponse
|
|
10
|
+
from llmcontract.integration.exceptions import ProtocolViolationError
|
|
11
|
+
|
|
12
|
+
ToolFunction = Callable[..., Any]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class ToolResult:
|
|
17
|
+
"""Result of a monitored tool execution."""
|
|
18
|
+
|
|
19
|
+
tool_call_id: str
|
|
20
|
+
tool_name: str
|
|
21
|
+
result: Any
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ToolMiddleware:
|
|
25
|
+
"""Intercepts LLM tool calls and checks them against the protocol.
|
|
26
|
+
|
|
27
|
+
Parameters
|
|
28
|
+
----------
|
|
29
|
+
monitor:
|
|
30
|
+
The same monitor instance used by MonitoredClient.
|
|
31
|
+
tools:
|
|
32
|
+
Registry mapping tool name → implementation function.
|
|
33
|
+
receive_label:
|
|
34
|
+
Label for ?Receive when the LLM requests a tool call.
|
|
35
|
+
If None, uses the tool name itself.
|
|
36
|
+
If a string, uses that string for all tools.
|
|
37
|
+
If callable, receives the ToolCall and returns a label.
|
|
38
|
+
send_label:
|
|
39
|
+
Label for !Send when returning tool results to the LLM.
|
|
40
|
+
If None, uses the tool name itself.
|
|
41
|
+
If a string, uses that string for all tools.
|
|
42
|
+
If callable, receives (tool_name, tool_result) and returns a label.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
monitor: Monitor,
|
|
48
|
+
tools: dict[str, ToolFunction] | None = None,
|
|
49
|
+
receive_label: str | Callable[[ToolCall], str] | None = None,
|
|
50
|
+
send_label: str | Callable[[str, Any], str] | None = None,
|
|
51
|
+
) -> None:
|
|
52
|
+
self._monitor = monitor
|
|
53
|
+
self._tools: dict[str, ToolFunction] = dict(tools) if tools else {}
|
|
54
|
+
self._receive_label = receive_label
|
|
55
|
+
self._send_label = send_label
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def monitor(self) -> Monitor:
|
|
59
|
+
return self._monitor
|
|
60
|
+
|
|
61
|
+
def register(self, name: str, fn: ToolFunction) -> None:
|
|
62
|
+
"""Register a tool function."""
|
|
63
|
+
self._tools[name] = fn
|
|
64
|
+
|
|
65
|
+
def execute(self, tool_call: ToolCall) -> ToolResult:
|
|
66
|
+
"""Execute a single tool call with protocol checks.
|
|
67
|
+
|
|
68
|
+
1. monitor.receive(label) — the LLM chose this tool
|
|
69
|
+
2. Execute the tool function
|
|
70
|
+
3. monitor.send(label) — sending the result back
|
|
71
|
+
"""
|
|
72
|
+
if tool_call.name not in self._tools:
|
|
73
|
+
raise ValueError(f"Unknown tool: {tool_call.name!r}")
|
|
74
|
+
|
|
75
|
+
# Receive check — LLM requested this tool
|
|
76
|
+
recv_label = self._resolve_receive_label(tool_call)
|
|
77
|
+
result = self._monitor.receive(recv_label)
|
|
78
|
+
if not isinstance(result, Ok):
|
|
79
|
+
raise ProtocolViolationError(result, "receive")
|
|
80
|
+
|
|
81
|
+
# Execute
|
|
82
|
+
output = self._tools[tool_call.name](**tool_call.arguments)
|
|
83
|
+
|
|
84
|
+
# Send check — returning result to LLM
|
|
85
|
+
send_label = self._resolve_send_label(tool_call.name, output)
|
|
86
|
+
result = self._monitor.send(send_label)
|
|
87
|
+
if not isinstance(result, Ok):
|
|
88
|
+
raise ProtocolViolationError(result, "send")
|
|
89
|
+
|
|
90
|
+
return ToolResult(
|
|
91
|
+
tool_call_id=tool_call.id,
|
|
92
|
+
tool_name=tool_call.name,
|
|
93
|
+
result=output,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
def process(self, response: LLMResponse) -> list[ToolResult]:
|
|
97
|
+
"""Process all tool calls in an LLM response."""
|
|
98
|
+
return [self.execute(tc) for tc in response.tool_calls]
|
|
99
|
+
|
|
100
|
+
def _resolve_receive_label(self, tool_call: ToolCall) -> str:
|
|
101
|
+
if self._receive_label is None:
|
|
102
|
+
return tool_call.name
|
|
103
|
+
if callable(self._receive_label):
|
|
104
|
+
return self._receive_label(tool_call)
|
|
105
|
+
return self._receive_label
|
|
106
|
+
|
|
107
|
+
def _resolve_send_label(self, tool_name: str, tool_result: Any) -> str:
|
|
108
|
+
if self._send_label is None:
|
|
109
|
+
return tool_name
|
|
110
|
+
if callable(self._send_label):
|
|
111
|
+
return self._send_label(tool_name, tool_result)
|
|
112
|
+
return self._send_label
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""SDK-agnostic types for normalized LLM responses."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class ToolCall:
|
|
11
|
+
"""A single tool call requested by the LLM."""
|
|
12
|
+
|
|
13
|
+
name: str
|
|
14
|
+
arguments: dict[str, Any] = field(default_factory=dict)
|
|
15
|
+
id: str = ""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class LLMResponse:
|
|
20
|
+
"""Normalized LLM response."""
|
|
21
|
+
|
|
22
|
+
content: str | None = None
|
|
23
|
+
tool_calls: list[ToolCall] = field(default_factory=list)
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def has_tool_calls(self) -> bool:
|
|
27
|
+
return len(self.tool_calls) > 0
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Compiles a session type AST into a finite state automaton."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Literal
|
|
7
|
+
|
|
8
|
+
from llmcontract.dsl.ast import (
|
|
9
|
+
End, ExternalChoice, InternalChoice, ProtocolNode,
|
|
10
|
+
Receive, Recursion, RecVar, Send, Sequence,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
Direction = Literal["send", "receive"]
|
|
14
|
+
TransitionKey = tuple[Direction, str]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class Automaton:
|
|
19
|
+
"""Finite state automaton compiled from a session type AST."""
|
|
20
|
+
|
|
21
|
+
transitions: dict[int, dict[TransitionKey, int]] = field(default_factory=dict)
|
|
22
|
+
terminal_states: set[int] = field(default_factory=set)
|
|
23
|
+
initial_state: int = 0
|
|
24
|
+
_next_id: int = field(default=0, repr=False)
|
|
25
|
+
|
|
26
|
+
def _new_state(self) -> int:
|
|
27
|
+
sid = self._next_id
|
|
28
|
+
self._next_id += 1
|
|
29
|
+
if sid not in self.transitions:
|
|
30
|
+
self.transitions[sid] = {}
|
|
31
|
+
return sid
|
|
32
|
+
|
|
33
|
+
def is_terminal(self, state: int) -> bool:
|
|
34
|
+
return state in self.terminal_states
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def compile_ast(node: ProtocolNode) -> Automaton:
|
|
38
|
+
"""Compile an AST into a finite state automaton."""
|
|
39
|
+
aut = Automaton()
|
|
40
|
+
start = aut._new_state()
|
|
41
|
+
aut.initial_state = start
|
|
42
|
+
rec_env: dict[str, int] = {}
|
|
43
|
+
_compile(node, start, aut, rec_env)
|
|
44
|
+
return aut
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _compile(
|
|
48
|
+
node: ProtocolNode,
|
|
49
|
+
current: int,
|
|
50
|
+
aut: Automaton,
|
|
51
|
+
rec_env: dict[str, int],
|
|
52
|
+
) -> None:
|
|
53
|
+
"""Recursively compile *node* starting from *current* state."""
|
|
54
|
+
|
|
55
|
+
if isinstance(node, End):
|
|
56
|
+
aut.terminal_states.add(current)
|
|
57
|
+
|
|
58
|
+
elif isinstance(node, Send):
|
|
59
|
+
nxt = aut._new_state()
|
|
60
|
+
aut.transitions[current][("send", node.label)] = nxt
|
|
61
|
+
# The next state is terminal by default; a Sequence wrapper overrides this.
|
|
62
|
+
aut.terminal_states.add(nxt)
|
|
63
|
+
|
|
64
|
+
elif isinstance(node, Receive):
|
|
65
|
+
nxt = aut._new_state()
|
|
66
|
+
aut.transitions[current][("receive", node.label)] = nxt
|
|
67
|
+
aut.terminal_states.add(nxt)
|
|
68
|
+
|
|
69
|
+
elif isinstance(node, InternalChoice):
|
|
70
|
+
for label, branch in node.branches.items():
|
|
71
|
+
nxt = aut._new_state()
|
|
72
|
+
aut.transitions[current][("send", label)] = nxt
|
|
73
|
+
_compile(branch, nxt, aut, rec_env)
|
|
74
|
+
|
|
75
|
+
elif isinstance(node, ExternalChoice):
|
|
76
|
+
for label, branch in node.branches.items():
|
|
77
|
+
nxt = aut._new_state()
|
|
78
|
+
aut.transitions[current][("receive", label)] = nxt
|
|
79
|
+
_compile(branch, nxt, aut, rec_env)
|
|
80
|
+
|
|
81
|
+
elif isinstance(node, Sequence):
|
|
82
|
+
# Compile left, find its "leaf" states (non-choice terminal sinks),
|
|
83
|
+
# then wire those into right.
|
|
84
|
+
_compile(node.left, current, aut, rec_env)
|
|
85
|
+
# The leaf states produced by left are terminal states reachable from current
|
|
86
|
+
# that were just added. We need to find them and make them non-terminal,
|
|
87
|
+
# then compile right from each.
|
|
88
|
+
leaf_states = _collect_leaf_states(node.left, current, aut)
|
|
89
|
+
for s in leaf_states:
|
|
90
|
+
aut.terminal_states.discard(s)
|
|
91
|
+
_compile(node.right, s, aut, rec_env)
|
|
92
|
+
|
|
93
|
+
elif isinstance(node, Recursion):
|
|
94
|
+
rec_env_copy = dict(rec_env)
|
|
95
|
+
rec_env_copy[node.var] = current
|
|
96
|
+
_compile(node.body, current, aut, rec_env_copy)
|
|
97
|
+
|
|
98
|
+
elif isinstance(node, RecVar):
|
|
99
|
+
# Back-edge: wire current state to the recursion point.
|
|
100
|
+
# We mark current as an epsilon-transition target by copying transitions.
|
|
101
|
+
target = rec_env[node.var]
|
|
102
|
+
# Copy all transitions from the target to current state
|
|
103
|
+
for key, dest in aut.transitions.get(target, {}).items():
|
|
104
|
+
aut.transitions[current][key] = dest
|
|
105
|
+
|
|
106
|
+
else:
|
|
107
|
+
raise TypeError(f"Unknown AST node: {type(node)}")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _collect_leaf_states(
|
|
111
|
+
node: ProtocolNode,
|
|
112
|
+
current: int,
|
|
113
|
+
aut: Automaton,
|
|
114
|
+
) -> list[int]:
|
|
115
|
+
"""Return the states that a compiled node ends in (its continuation points)."""
|
|
116
|
+
|
|
117
|
+
if isinstance(node, End):
|
|
118
|
+
return [current]
|
|
119
|
+
|
|
120
|
+
elif isinstance(node, (Send, Receive)):
|
|
121
|
+
# The single transition target
|
|
122
|
+
direction = "send" if isinstance(node, Send) else "receive"
|
|
123
|
+
label = node.label
|
|
124
|
+
nxt = aut.transitions[current].get((direction, label))
|
|
125
|
+
if nxt is not None:
|
|
126
|
+
return [nxt]
|
|
127
|
+
return []
|
|
128
|
+
|
|
129
|
+
elif isinstance(node, (InternalChoice, ExternalChoice)):
|
|
130
|
+
direction = "send" if isinstance(node, InternalChoice) else "receive"
|
|
131
|
+
leaves: list[int] = []
|
|
132
|
+
for label, branch in node.branches.items():
|
|
133
|
+
nxt = aut.transitions[current].get((direction, label))
|
|
134
|
+
if nxt is not None:
|
|
135
|
+
leaves.extend(_collect_leaf_states(branch, nxt, aut))
|
|
136
|
+
return leaves
|
|
137
|
+
|
|
138
|
+
elif isinstance(node, Sequence):
|
|
139
|
+
left_leaves = _collect_leaf_states(node.left, current, aut)
|
|
140
|
+
all_leaves: list[int] = []
|
|
141
|
+
for s in left_leaves:
|
|
142
|
+
all_leaves.extend(_collect_leaf_states(node.right, s, aut))
|
|
143
|
+
return all_leaves
|
|
144
|
+
|
|
145
|
+
elif isinstance(node, Recursion):
|
|
146
|
+
return _collect_leaf_states(node.body, current, aut)
|
|
147
|
+
|
|
148
|
+
elif isinstance(node, RecVar):
|
|
149
|
+
return [current]
|
|
150
|
+
|
|
151
|
+
return [current]
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Runtime monitor for session type protocols."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Union
|
|
7
|
+
|
|
8
|
+
from llmcontract.dsl.parser import parse
|
|
9
|
+
from llmcontract.monitor.automaton import Automaton, compile_ast
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
# ── Result types ─────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class Ok:
|
|
16
|
+
"""The event was accepted."""
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class Violation:
|
|
22
|
+
"""Protocol violation: expected one thing, got another."""
|
|
23
|
+
expected: list[str]
|
|
24
|
+
got: str
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class Blocked:
|
|
29
|
+
"""The monitor is halted and no further events are accepted."""
|
|
30
|
+
reason: str
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
MonitorResult = Union[Ok, Violation, Blocked]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# ── Monitor ──────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
class Monitor:
|
|
39
|
+
"""Runtime monitor that checks a stream of send/receive events against a protocol."""
|
|
40
|
+
|
|
41
|
+
def __init__(self, protocol: str) -> None:
|
|
42
|
+
ast = parse(protocol)
|
|
43
|
+
self._automaton: Automaton = compile_ast(ast)
|
|
44
|
+
self._current_state: int = self._automaton.initial_state
|
|
45
|
+
self._halted: bool = False
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def current_state(self) -> int:
|
|
49
|
+
return self._current_state
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def is_terminal(self) -> bool:
|
|
53
|
+
return self._automaton.is_terminal(self._current_state)
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def is_halted(self) -> bool:
|
|
57
|
+
return self._halted
|
|
58
|
+
|
|
59
|
+
def send(self, label: str) -> MonitorResult:
|
|
60
|
+
"""Record a send event."""
|
|
61
|
+
return self._step("send", label)
|
|
62
|
+
|
|
63
|
+
def receive(self, label: str) -> MonitorResult:
|
|
64
|
+
"""Record a receive event."""
|
|
65
|
+
return self._step("receive", label)
|
|
66
|
+
|
|
67
|
+
def _step(self, direction: str, label: str) -> MonitorResult:
|
|
68
|
+
if self._halted:
|
|
69
|
+
return Blocked("monitor halted after a previous violation")
|
|
70
|
+
|
|
71
|
+
transitions = self._automaton.transitions.get(self._current_state, {})
|
|
72
|
+
key = (direction, label)
|
|
73
|
+
|
|
74
|
+
if key in transitions:
|
|
75
|
+
self._current_state = transitions[key]
|
|
76
|
+
return Ok()
|
|
77
|
+
|
|
78
|
+
# Build a useful violation message
|
|
79
|
+
expected = [f"{'!' if d == 'send' else '?'}{l}" for d, l in transitions]
|
|
80
|
+
got = f"{'!' if direction == 'send' else '?'}{label}"
|
|
81
|
+
self._halted = True
|
|
82
|
+
return Violation(expected=expected, got=got)
|
llmcontract/py.typed
ADDED
|
File without changes
|
|
@@ -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,18 @@
|
|
|
1
|
+
llmcontract/__init__.py,sha256=SYWSqt9MWsaL9lwDaMS58demzejxCvxiDurglysZZG8,416
|
|
2
|
+
llmcontract/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
3
|
+
llmcontract/dsl/__init__.py,sha256=pB671a6dRxQ95GPLam8UDJphxtrtmswofFK7lshoAdo,325
|
|
4
|
+
llmcontract/dsl/ast.py,sha256=7OFNH4QbA87m1Rlz5p-tv3sXkJN-lt3KJJiISswoYOw,1121
|
|
5
|
+
llmcontract/dsl/parser.py,sha256=9qQoaklOwMfDPKAMg_LLAmyJz3uUKKcu5f-IQlPMquA,5982
|
|
6
|
+
llmcontract/integration/__init__.py,sha256=MAA1z7g8bciigCVwMa4xhaKARa91y97ECj_z_PqvgbE,410
|
|
7
|
+
llmcontract/integration/client.py,sha256=kh68oEcqz-DhMCUXEx5dAfBZLruGruoO_-dqmm9qYIA,2758
|
|
8
|
+
llmcontract/integration/exceptions.py,sha256=ptCUOzMoxjexSGEOEs_5v_fVQmhPkIn5J-IJ1dhcZgI,888
|
|
9
|
+
llmcontract/integration/middleware.py,sha256=Apiv7Jh2RbgLNwwl9l1lv_yWMB5NfvMYQ5wU2NW6hkE,3864
|
|
10
|
+
llmcontract/integration/types.py,sha256=ASzM75v07LdkjmrHQ3D9Mr-GJUdyIyq60zRXB2qQ2Jw,607
|
|
11
|
+
llmcontract/monitor/__init__.py,sha256=NrX9iz18CfzKIAidQys1MLaJn-_6Sf1uM5P8oqoT7es,222
|
|
12
|
+
llmcontract/monitor/automaton.py,sha256=f0e6OgGuGW-Eve4NQWrfobIxtu8oMa7jBFjMQfse6uk,5081
|
|
13
|
+
llmcontract/monitor/monitor.py,sha256=_qy3N_r95Ym7tPif1Cp1WVuRe8mSAtBQNlQmcAXMzrk,2504
|
|
14
|
+
llmsessioncontract-0.1.0.dist-info/licenses/LICENSE,sha256=mcR8lKquleZezlTcyz-3b7n_TdmfmYDCv-c6d5kwXJw,1076
|
|
15
|
+
llmsessioncontract-0.1.0.dist-info/METADATA,sha256=5oyONlzF4FFRriZIlOnfNEDKWunlIUQY-icJY29W4gM,6903
|
|
16
|
+
llmsessioncontract-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
17
|
+
llmsessioncontract-0.1.0.dist-info/top_level.txt,sha256=Nu0YFezqebcB29ZwR2ynLIKIMSKxphVE02oIsVMB3Ro,12
|
|
18
|
+
llmsessioncontract-0.1.0.dist-info/RECORD,,
|
|
@@ -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 @@
|
|
|
1
|
+
llmcontract
|