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.
@@ -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()