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.
@@ -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,4 @@
1
+ from llmcontract.monitor.automaton import Automaton
2
+ from llmcontract.monitor.monitor import Monitor, MonitorResult, Ok, Violation, Blocked
3
+
4
+ __all__ = ["Automaton", "Monitor", "MonitorResult", "Ok", "Violation", "Blocked"]
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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