dspy-rlm-hooks 0.1.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Edward Boswell
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,322 @@
1
+ Metadata-Version: 2.4
2
+ Name: dspy-rlm-hooks
3
+ Version: 0.1.2
4
+ Summary: Lifecycle instrumentation for DSPy's RLM (Recursive Language Model).
5
+ Keywords: dspy,ai,recursive language model,hooks,instrumentation,rlm
6
+ Author: Edward Boswell
7
+ Author-email: Edward Boswell <thememium@gmail.com>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Classifier: Operating System :: OS Independent
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
16
+ Requires-Dist: dspy>=3.1.0
17
+ Requires-Dist: pydantic>=2.0.0
18
+ Requires-Python: >=3.12
19
+ Project-URL: Homepage, https://github.com/thememium/dspy-rlm-hooks
20
+ Project-URL: Documentation, https://github.com/thememium/dspy-rlm-hooks
21
+ Project-URL: Repository, https://github.com/thememium/dspy-rlm-hooks.git
22
+ Project-URL: Issues, https://github.com/thememium/dspy-rlm-hooks/issues
23
+ Project-URL: Changelog, https://github.com/thememium/dspy-rlm-hooks/blob/master/CHANGELOG.md
24
+ Description-Content-Type: text/markdown
25
+
26
+ <a name="readme-top"></a>
27
+
28
+ <div align="center">
29
+ <h3 align="center">DSPy RLM Hooks</h3>
30
+
31
+ <p align="center">
32
+ Lifecycle instrumentation for DSPy's RLM (Recursive Language Model).
33
+ <br />
34
+ <a href="#table-of-contents"><strong>Explore the Documentation »</strong></a>
35
+ <br />
36
+ <a href="https://github.com/thememium/dspy-rlm-hooks/issues">Report Bug</a>
37
+ ·
38
+ <a href="https://github.com/thememium/dspy-rlm-hooks/issues">Request Feature</a>
39
+ </p>
40
+ </div>
41
+
42
+ <!-- TABLE OF CONTENTS -->
43
+
44
+ <a name="table-of-contents"></a>
45
+
46
+ <details>
47
+ <summary>Table of Contents</summary>
48
+ <ol>
49
+ <li><a href="#about">About</a></li>
50
+ <li><a href="#quick-start">Quick Start</a></li>
51
+ <li><a href="#usage">Usage</a></li>
52
+ <li><a href="#development">Development</a></li>
53
+ <li><a href="#contributing">Contributing</a></li>
54
+ <li><a href="#license">License</a></li>
55
+ </ol>
56
+ </details>
57
+
58
+ <!-- ABOUT -->
59
+
60
+ ## About
61
+
62
+ DSPy RLM Hooks injects **lifecycle hooks** into DSPy's internal `RLM` iteration loop, giving you full control over every stage of code generation, execution, and history tracking.
63
+
64
+ - **Code Rewriting** — Fix or augment LLM-generated code before it runs
65
+ - **Variable Injection** — Seed the interpreter with persistent variables and imports
66
+ - **Result Auditing** — Transform, validate, or retry on errors
67
+ - **History Management** — Inspect and modify the REPL history between iterations
68
+ - **Sync & Async** — Hooks work in either mode; coroutines are auto-detected
69
+
70
+ Requires **DSPy 3.1+** and **Pydantic 2+**.
71
+
72
+ <p align="right">(<a href="#readme-top">back to top</a>)</p>
73
+
74
+ <!-- ARCHITECTURE -->
75
+
76
+ ## Architecture
77
+
78
+ ### RLM Hook Lifecycle
79
+
80
+ ```mermaid
81
+ flowchart LR
82
+ subgraph Iteration["RLM Iteration"]
83
+ PreIter["pre_iteration_hook"] --> Gen["Generate Code"]
84
+ Gen --> PreExec["pre_execution_hook"]
85
+ PreExec --> Exec["Execute Code"]
86
+ Exec --> PostExec["post_execution_hook"]
87
+ PostExec --> PostIter["post_iteration_hook"]
88
+ end
89
+
90
+ PreIter -.->|inject vars, prepend code| Gen
91
+ PreExec -.->|rewrite code| Exec
92
+ PostExec -.->|transform result| PostIter
93
+ ```
94
+
95
+ Hooks fire at each stage of an RLM iteration, allowing inspection and modification of behaviour.
96
+
97
+ <p align="right">(<a href="#readme-top">back to top</a>)</p>
98
+
99
+ <!-- QUICK START -->
100
+
101
+ ## Quick Start
102
+
103
+ ### Install
104
+
105
+ Install dspy-rlm-hooks with uv (recommended):
106
+
107
+ ```bash
108
+ uv add dspy-rlm-hooks
109
+ ```
110
+
111
+ Or with pip:
112
+
113
+ ```bash
114
+ pip install dspy-rlm-hooks
115
+ ```
116
+
117
+ ### Basic Usage
118
+
119
+ ```python
120
+ import dspy
121
+ from dspy_rlm_hooks import enable_rlm_hooks, PreIterationOutput
122
+
123
+ rlm = dspy.RLM(...)
124
+
125
+ def inject_math(iteration, variables, history, input_args):
126
+ return PreIterationOutput(
127
+ extra_vars={"tool": "calculator"},
128
+ python_code="import math",
129
+ )
130
+
131
+ enable_rlm_hooks(rlm, pre_iteration_hook=inject_math)
132
+
133
+ result = rlm(question="What is the square root of 1764?")
134
+ ```
135
+
136
+ <p align="right">(<a href="#readme-top">back to top</a>)</p>
137
+
138
+ <!-- USAGE -->
139
+
140
+ ## Usage
141
+
142
+ ### All Four Hooks
143
+
144
+ A realistic example showing how each hook can be used to build a **safe, instrumented agent**:
145
+
146
+ ```python
147
+ from dspy_rlm_hooks import (
148
+ enable_rlm_hooks,
149
+ PreIterationOutput,
150
+ PreExecutionOutput,
151
+ PostExecutionOutput,
152
+ PostIterationOutput,
153
+ )
154
+ from dspy.primitives.repl_types import REPLHistory
155
+ import re
156
+
157
+ # ── Pre-iteration: seed interpreter with a regex toolkit ──
158
+
159
+ def pre_iteration(iteration, variables, history, input_args):
160
+ """Inject a regex helper and seed variables before every iteration."""
161
+ return PreIterationOutput(
162
+ extra_vars={"search_pattern": r"TODO|FIXME|HACK"},
163
+ python_code="""
164
+ import re
165
+
166
+ def grep(pattern, text):
167
+ return re.findall(pattern, text)
168
+ """,
169
+ )
170
+
171
+ # ── Pre-execution: block dangerous code ──
172
+
173
+ FORBIDDEN = re.compile(r"\b(eval|exec|compile|__import__)\b")
174
+
175
+ def pre_execution(iteration, code, variables, history, input_args):
176
+ """Sanitise generated code before it reaches the interpreter."""
177
+ if FORBIDDEN.search(code):
178
+ safe_code = FORBIDDEN.sub("# BLOCKED", code)
179
+ return PreExecutionOutput(code=safe_code)
180
+ return PreExecutionOutput(code=code)
181
+
182
+ # ── Post-execution: retry on error ──
183
+
184
+ def post_execution(iteration, code, result, variables, history, input_args):
185
+ """If execution raised an error, wrap a hint so the LLM retries next round."""
186
+ if isinstance(result, str) and result.startswith("[Error]"):
187
+ return PostExecutionOutput(
188
+ result=f"{result}\n# Hint: the variable 'search_pattern' is already in scope."
189
+ )
190
+ return PostExecutionOutput(result=result)
191
+
192
+ # ── Post-iteration: enforce iteration budget ──
193
+
194
+ MAX_ITERATIONS = 5
195
+ current_iter_count = 0
196
+
197
+ def post_iteration(iteration, pred, code, result, history: REPLHistory):
198
+ """Track iterations and stop early if the budget is exhausted."""
199
+ global current_iter_count
200
+ current_iter_count += 1
201
+ if current_iter_count >= MAX_ITERATIONS:
202
+ # Return empty history to signal stop
203
+ return PostIterationOutput(history=REPLHistory(entries=[]))
204
+ return PostIterationOutput(history=history)
205
+
206
+ # ── Wire everything up ──
207
+
208
+ enable_rlm_hooks(
209
+ rlm,
210
+ pre_iteration_hook=pre_iteration,
211
+ pre_execution_hook=pre_execution,
212
+ post_execution_hook=post_execution,
213
+ post_iteration_hook=post_iteration,
214
+ )
215
+
216
+ result = rlm(question="Find all TODO comments in the codebase")
217
+ ```
218
+
219
+ ### Async Hooks
220
+
221
+ Return a coroutine and the system handles it automatically:
222
+
223
+ ```python
224
+ async def fetch_context(iteration, variables, history, input_args):
225
+ context = await remote_cache.get(input_args["question"])
226
+ return PreIterationOutput(extra_vars={"cached_context": context})
227
+
228
+ enable_rlm_hooks(rlm, pre_iteration_hook=fetch_context)
229
+ ```
230
+
231
+ ### Disabling Hooks
232
+
233
+ ```python
234
+ from dspy_rlm_hooks import disable_rlm_hooks
235
+
236
+ disable_rlm_hooks(rlm)
237
+ ```
238
+
239
+ Removes all monkey-patched overrides and reverts to original behaviour.
240
+
241
+ <p align="right">(<a href="#readme-top">back to top</a>)</p>
242
+
243
+ ## Hook Reference
244
+
245
+ | Hook | When it fires | What it can do |
246
+ | --- | --- | --- |
247
+ | **PreIteration** | Before action generation | Inject variables (`extra_vars`) and persistent code (`python_code`) |
248
+ | **PreExecution** | After code generation, before running | Rewrite or sanitise the generated `code` string |
249
+ | **PostExecution** | After code runs, before history processing | Transform, audit, or replace the raw `result` |
250
+ | **PostIteration** | After result is folded into history | Save learnings, trigger side effects, or modify `history` |
251
+
252
+ <p align="right">(<a href="#readme-top">back to top</a>)</p>
253
+
254
+ <!-- DEVELOPMENT -->
255
+
256
+ ## Development
257
+
258
+ ### Code Quality
259
+
260
+ This project uses several tools to maintain code quality:
261
+
262
+ - **Ruff:** Linting and formatting
263
+ - **isort:** Import sorting
264
+ - **pytest:** Testing framework
265
+ - **ty:** Type checking
266
+ - **deptry:** Dependency analysis
267
+
268
+ **Available commands:**
269
+
270
+ ```sh
271
+ # Run all quality checks
272
+ uv run poe clean-full
273
+
274
+ # Individual checks
275
+ uv run poe lint # Ruff linting
276
+ uv run poe format # Ruff formatting
277
+ uv run poe sort # Import sorting
278
+ uv run poe typecheck # Type checking
279
+ uv run poe deptry # Dependency analysis
280
+ ```
281
+
282
+ ### Testing
283
+
284
+ Run tests using pytest:
285
+
286
+ ```sh
287
+ # Run all tests
288
+ uv run pytest
289
+
290
+ # Run specific test
291
+ uv run pytest path/to/test.py::test_name
292
+ ```
293
+
294
+ <p align="right">(<a href="#readme-top">back to top</a>)</p>
295
+
296
+ <!-- CONTRIBUTING -->
297
+
298
+ ## Contributing
299
+
300
+ Quick workflow:
301
+
302
+ 1. Fork and branch: `git checkout -b feature/name`
303
+ 2. Make changes
304
+ 3. Run checks: `uv run poe clean-full`
305
+ 4. Commit and push
306
+ 5. Open a Pull Request
307
+
308
+ <p align="right">(<a href="#readme-top">back to top</a>)</p>
309
+
310
+ <!-- LICENSE -->
311
+
312
+ ## License
313
+
314
+ MIT (as declared in `pyproject.toml`).
315
+
316
+ ---
317
+
318
+ <div align="center">
319
+ <p>
320
+ <sub>Built by <a href="https://github.com/thememium">thememium</a></sub>
321
+ </p>
322
+ </div>
@@ -0,0 +1,297 @@
1
+ <a name="readme-top"></a>
2
+
3
+ <div align="center">
4
+ <h3 align="center">DSPy RLM Hooks</h3>
5
+
6
+ <p align="center">
7
+ Lifecycle instrumentation for DSPy's RLM (Recursive Language Model).
8
+ <br />
9
+ <a href="#table-of-contents"><strong>Explore the Documentation »</strong></a>
10
+ <br />
11
+ <a href="https://github.com/thememium/dspy-rlm-hooks/issues">Report Bug</a>
12
+ ·
13
+ <a href="https://github.com/thememium/dspy-rlm-hooks/issues">Request Feature</a>
14
+ </p>
15
+ </div>
16
+
17
+ <!-- TABLE OF CONTENTS -->
18
+
19
+ <a name="table-of-contents"></a>
20
+
21
+ <details>
22
+ <summary>Table of Contents</summary>
23
+ <ol>
24
+ <li><a href="#about">About</a></li>
25
+ <li><a href="#quick-start">Quick Start</a></li>
26
+ <li><a href="#usage">Usage</a></li>
27
+ <li><a href="#development">Development</a></li>
28
+ <li><a href="#contributing">Contributing</a></li>
29
+ <li><a href="#license">License</a></li>
30
+ </ol>
31
+ </details>
32
+
33
+ <!-- ABOUT -->
34
+
35
+ ## About
36
+
37
+ DSPy RLM Hooks injects **lifecycle hooks** into DSPy's internal `RLM` iteration loop, giving you full control over every stage of code generation, execution, and history tracking.
38
+
39
+ - **Code Rewriting** — Fix or augment LLM-generated code before it runs
40
+ - **Variable Injection** — Seed the interpreter with persistent variables and imports
41
+ - **Result Auditing** — Transform, validate, or retry on errors
42
+ - **History Management** — Inspect and modify the REPL history between iterations
43
+ - **Sync & Async** — Hooks work in either mode; coroutines are auto-detected
44
+
45
+ Requires **DSPy 3.1+** and **Pydantic 2+**.
46
+
47
+ <p align="right">(<a href="#readme-top">back to top</a>)</p>
48
+
49
+ <!-- ARCHITECTURE -->
50
+
51
+ ## Architecture
52
+
53
+ ### RLM Hook Lifecycle
54
+
55
+ ```mermaid
56
+ flowchart LR
57
+ subgraph Iteration["RLM Iteration"]
58
+ PreIter["pre_iteration_hook"] --> Gen["Generate Code"]
59
+ Gen --> PreExec["pre_execution_hook"]
60
+ PreExec --> Exec["Execute Code"]
61
+ Exec --> PostExec["post_execution_hook"]
62
+ PostExec --> PostIter["post_iteration_hook"]
63
+ end
64
+
65
+ PreIter -.->|inject vars, prepend code| Gen
66
+ PreExec -.->|rewrite code| Exec
67
+ PostExec -.->|transform result| PostIter
68
+ ```
69
+
70
+ Hooks fire at each stage of an RLM iteration, allowing inspection and modification of behaviour.
71
+
72
+ <p align="right">(<a href="#readme-top">back to top</a>)</p>
73
+
74
+ <!-- QUICK START -->
75
+
76
+ ## Quick Start
77
+
78
+ ### Install
79
+
80
+ Install dspy-rlm-hooks with uv (recommended):
81
+
82
+ ```bash
83
+ uv add dspy-rlm-hooks
84
+ ```
85
+
86
+ Or with pip:
87
+
88
+ ```bash
89
+ pip install dspy-rlm-hooks
90
+ ```
91
+
92
+ ### Basic Usage
93
+
94
+ ```python
95
+ import dspy
96
+ from dspy_rlm_hooks import enable_rlm_hooks, PreIterationOutput
97
+
98
+ rlm = dspy.RLM(...)
99
+
100
+ def inject_math(iteration, variables, history, input_args):
101
+ return PreIterationOutput(
102
+ extra_vars={"tool": "calculator"},
103
+ python_code="import math",
104
+ )
105
+
106
+ enable_rlm_hooks(rlm, pre_iteration_hook=inject_math)
107
+
108
+ result = rlm(question="What is the square root of 1764?")
109
+ ```
110
+
111
+ <p align="right">(<a href="#readme-top">back to top</a>)</p>
112
+
113
+ <!-- USAGE -->
114
+
115
+ ## Usage
116
+
117
+ ### All Four Hooks
118
+
119
+ A realistic example showing how each hook can be used to build a **safe, instrumented agent**:
120
+
121
+ ```python
122
+ from dspy_rlm_hooks import (
123
+ enable_rlm_hooks,
124
+ PreIterationOutput,
125
+ PreExecutionOutput,
126
+ PostExecutionOutput,
127
+ PostIterationOutput,
128
+ )
129
+ from dspy.primitives.repl_types import REPLHistory
130
+ import re
131
+
132
+ # ── Pre-iteration: seed interpreter with a regex toolkit ──
133
+
134
+ def pre_iteration(iteration, variables, history, input_args):
135
+ """Inject a regex helper and seed variables before every iteration."""
136
+ return PreIterationOutput(
137
+ extra_vars={"search_pattern": r"TODO|FIXME|HACK"},
138
+ python_code="""
139
+ import re
140
+
141
+ def grep(pattern, text):
142
+ return re.findall(pattern, text)
143
+ """,
144
+ )
145
+
146
+ # ── Pre-execution: block dangerous code ──
147
+
148
+ FORBIDDEN = re.compile(r"\b(eval|exec|compile|__import__)\b")
149
+
150
+ def pre_execution(iteration, code, variables, history, input_args):
151
+ """Sanitise generated code before it reaches the interpreter."""
152
+ if FORBIDDEN.search(code):
153
+ safe_code = FORBIDDEN.sub("# BLOCKED", code)
154
+ return PreExecutionOutput(code=safe_code)
155
+ return PreExecutionOutput(code=code)
156
+
157
+ # ── Post-execution: retry on error ──
158
+
159
+ def post_execution(iteration, code, result, variables, history, input_args):
160
+ """If execution raised an error, wrap a hint so the LLM retries next round."""
161
+ if isinstance(result, str) and result.startswith("[Error]"):
162
+ return PostExecutionOutput(
163
+ result=f"{result}\n# Hint: the variable 'search_pattern' is already in scope."
164
+ )
165
+ return PostExecutionOutput(result=result)
166
+
167
+ # ── Post-iteration: enforce iteration budget ──
168
+
169
+ MAX_ITERATIONS = 5
170
+ current_iter_count = 0
171
+
172
+ def post_iteration(iteration, pred, code, result, history: REPLHistory):
173
+ """Track iterations and stop early if the budget is exhausted."""
174
+ global current_iter_count
175
+ current_iter_count += 1
176
+ if current_iter_count >= MAX_ITERATIONS:
177
+ # Return empty history to signal stop
178
+ return PostIterationOutput(history=REPLHistory(entries=[]))
179
+ return PostIterationOutput(history=history)
180
+
181
+ # ── Wire everything up ──
182
+
183
+ enable_rlm_hooks(
184
+ rlm,
185
+ pre_iteration_hook=pre_iteration,
186
+ pre_execution_hook=pre_execution,
187
+ post_execution_hook=post_execution,
188
+ post_iteration_hook=post_iteration,
189
+ )
190
+
191
+ result = rlm(question="Find all TODO comments in the codebase")
192
+ ```
193
+
194
+ ### Async Hooks
195
+
196
+ Return a coroutine and the system handles it automatically:
197
+
198
+ ```python
199
+ async def fetch_context(iteration, variables, history, input_args):
200
+ context = await remote_cache.get(input_args["question"])
201
+ return PreIterationOutput(extra_vars={"cached_context": context})
202
+
203
+ enable_rlm_hooks(rlm, pre_iteration_hook=fetch_context)
204
+ ```
205
+
206
+ ### Disabling Hooks
207
+
208
+ ```python
209
+ from dspy_rlm_hooks import disable_rlm_hooks
210
+
211
+ disable_rlm_hooks(rlm)
212
+ ```
213
+
214
+ Removes all monkey-patched overrides and reverts to original behaviour.
215
+
216
+ <p align="right">(<a href="#readme-top">back to top</a>)</p>
217
+
218
+ ## Hook Reference
219
+
220
+ | Hook | When it fires | What it can do |
221
+ | --- | --- | --- |
222
+ | **PreIteration** | Before action generation | Inject variables (`extra_vars`) and persistent code (`python_code`) |
223
+ | **PreExecution** | After code generation, before running | Rewrite or sanitise the generated `code` string |
224
+ | **PostExecution** | After code runs, before history processing | Transform, audit, or replace the raw `result` |
225
+ | **PostIteration** | After result is folded into history | Save learnings, trigger side effects, or modify `history` |
226
+
227
+ <p align="right">(<a href="#readme-top">back to top</a>)</p>
228
+
229
+ <!-- DEVELOPMENT -->
230
+
231
+ ## Development
232
+
233
+ ### Code Quality
234
+
235
+ This project uses several tools to maintain code quality:
236
+
237
+ - **Ruff:** Linting and formatting
238
+ - **isort:** Import sorting
239
+ - **pytest:** Testing framework
240
+ - **ty:** Type checking
241
+ - **deptry:** Dependency analysis
242
+
243
+ **Available commands:**
244
+
245
+ ```sh
246
+ # Run all quality checks
247
+ uv run poe clean-full
248
+
249
+ # Individual checks
250
+ uv run poe lint # Ruff linting
251
+ uv run poe format # Ruff formatting
252
+ uv run poe sort # Import sorting
253
+ uv run poe typecheck # Type checking
254
+ uv run poe deptry # Dependency analysis
255
+ ```
256
+
257
+ ### Testing
258
+
259
+ Run tests using pytest:
260
+
261
+ ```sh
262
+ # Run all tests
263
+ uv run pytest
264
+
265
+ # Run specific test
266
+ uv run pytest path/to/test.py::test_name
267
+ ```
268
+
269
+ <p align="right">(<a href="#readme-top">back to top</a>)</p>
270
+
271
+ <!-- CONTRIBUTING -->
272
+
273
+ ## Contributing
274
+
275
+ Quick workflow:
276
+
277
+ 1. Fork and branch: `git checkout -b feature/name`
278
+ 2. Make changes
279
+ 3. Run checks: `uv run poe clean-full`
280
+ 4. Commit and push
281
+ 5. Open a Pull Request
282
+
283
+ <p align="right">(<a href="#readme-top">back to top</a>)</p>
284
+
285
+ <!-- LICENSE -->
286
+
287
+ ## License
288
+
289
+ MIT (as declared in `pyproject.toml`).
290
+
291
+ ---
292
+
293
+ <div align="center">
294
+ <p>
295
+ <sub>Built by <a href="https://github.com/thememium">thememium</a></sub>
296
+ </p>
297
+ </div>
@@ -0,0 +1,81 @@
1
+ [project]
2
+ name = "dspy-rlm-hooks"
3
+ version = "0.1.2"
4
+ description = "Lifecycle instrumentation for DSPy's RLM (Recursive Language Model)."
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ authors = [{ name = "Edward Boswell", email = "thememium@gmail.com" }]
8
+ keywords = [
9
+ "dspy",
10
+ "ai",
11
+ "recursive language model",
12
+ "hooks",
13
+ "instrumentation",
14
+ "rlm",
15
+ ]
16
+ classifiers = [
17
+ "Operating System :: OS Independent",
18
+ "Development Status :: 3 - Alpha",
19
+ "Intended Audience :: Developers",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Topic :: Software Development :: Libraries :: Python Modules",
23
+ ]
24
+ dependencies = ["dspy>=3.1.0", "pydantic>=2.0.0"]
25
+ license = "MIT"
26
+ license-files = ["LICEN[CS]E*"]
27
+
28
+ [project.urls]
29
+ Homepage = "https://github.com/thememium/dspy-rlm-hooks"
30
+ Documentation = "https://github.com/thememium/dspy-rlm-hooks"
31
+ Repository = "https://github.com/thememium/dspy-rlm-hooks.git"
32
+ Issues = "https://github.com/thememium/dspy-rlm-hooks/issues"
33
+ Changelog = "https://github.com/thememium/dspy-rlm-hooks/blob/master/CHANGELOG.md"
34
+
35
+ [build-system]
36
+ requires = ["uv_build>=0.11.6,<0.12.0"]
37
+ build-backend = "uv_build"
38
+
39
+ [dependency-groups]
40
+ dev = [
41
+ "deptry>=0.25.1",
42
+ "isort>=8.0.1",
43
+ "poethepoet>=0.46.0",
44
+ "pytest-asyncio>=0.26.0",
45
+ "pytest>=9.0.3",
46
+ "python-dotenv>=1.2.2",
47
+ "ruff>=0.15.13",
48
+ "ty>=0.0.37",
49
+ "usechange>=0.1.28",
50
+ ]
51
+
52
+ [tool.setuptools.packages.find]
53
+ where = ["."]
54
+ include = ["dspy_rlm_hooks*"]
55
+
56
+ [tool.pytest.ini_options]
57
+ asyncio_mode = "auto"
58
+ testpaths = ["tests"]
59
+ markers = ["asyncio: marks tests as async (pytest-asyncio)"]
60
+ filterwarnings = [
61
+ "ignore::DeprecationWarning:dspy.predict.avatar.signatures",
62
+ "ignore::DeprecationWarning:dspy.teleprompt.avatar_optimizer",
63
+ ]
64
+
65
+ [tool.deptry]
66
+ known_first_party = ["dspy_rlm_hooks"]
67
+ exclude = ["venv", "\\.venv", "tests", "\\.git"]
68
+ ignore_notebooks = true
69
+
70
+ [tool.poe.tasks]
71
+ dev = "uv run src/dspy_rlm_hooks/__init__.py"
72
+ clean-full = "sh -c 'uv run isort . && uv run ruff check . --fix && uv run ruff format . && uv run deptry . && uv run ty check'"
73
+ clean = "sh -c 'uv run isort . && uv run ruff format .'"
74
+ test = "uv run pytest tests/ -v"
75
+ test-e2e = "uv run pytest tests/ -v --run-e2e"
76
+ sort = "uv run isort ."
77
+ lint = "uv run ruff check ."
78
+ format = "uv run ruff format ."
79
+ deptry = "uv deptry ."
80
+ typecheck = "uv run ty check"
81
+ release = "uv run usechange release"
@@ -0,0 +1,77 @@
1
+ """DSPy RLM Hooks — lifecycle instrumentation for :class:`~dspy.RLM`.
2
+
3
+ This package monkey-patches the internal iteration loop of DSPy's
4
+ :class:`~dspy.RLM` (Recursive Language Model) to expose *lifecycle hooks* at
5
+ every stage of an iteration:
6
+
7
+ 1. **pre_iteration** – before the LLM generates the next action
8
+ 2. **pre_execution** – after code generation, before running it
9
+ 3. **post_execution** – after code runs, before the result is recorded
10
+ 4. **post_iteration** – after the result is folded into history
11
+
12
+ Hooks may be synchronous **or** asynchronous. The system auto-detects
13
+ coroutine return values and handles both paths transparently.
14
+
15
+ Compatibility
16
+ -------------
17
+ Tested against **DSPy 3.1+**. Because the package instruments *private*
18
+ DSPy internals, future DSPy releases may require updates. A runtime
19
+ validation check raises :class:`AttributeError` if the RLM instance does
20
+ not expose the expected API.
21
+
22
+ Quick start
23
+ -----------
24
+ ::
25
+
26
+ import dspy
27
+ from dspy_rlm_hooks import enable_rlm_hooks, PreIterationOutput
28
+
29
+ rlm = dspy.RLM(...)
30
+
31
+ def inject_debug(iteration, variables, history, input_args):
32
+ return PreIterationOutput(extra_vars={"debug": True})
33
+
34
+ enable_rlm_hooks(rlm, pre_iteration_hook=inject_debug)
35
+
36
+ # Use rlm normally — hooks fire automatically.
37
+ result = rlm(question="What is 2 + 2?")
38
+
39
+ Public API
40
+ ----------
41
+ """
42
+
43
+ from __future__ import annotations
44
+
45
+ from dspy_rlm_hooks.patcher import disable_rlm_hooks, enable_rlm_hooks
46
+ from dspy_rlm_hooks.types import (
47
+ PostExecutionHook,
48
+ PostExecutionOutput,
49
+ PostIterationHook,
50
+ PostIterationOutput,
51
+ PreExecutionHook,
52
+ PreExecutionOutput,
53
+ PreIterationHook,
54
+ PreIterationOutput,
55
+ RLMHook,
56
+ )
57
+
58
+ try:
59
+ from importlib.metadata import version
60
+
61
+ __version__ = version("dspy-rlm-hooks")
62
+ except ImportError:
63
+ __version__ = "unknown"
64
+
65
+ __all__ = [
66
+ "PreIterationHook",
67
+ "PreExecutionHook",
68
+ "PostExecutionHook",
69
+ "PostIterationHook",
70
+ "PreIterationOutput",
71
+ "PreExecutionOutput",
72
+ "PostExecutionOutput",
73
+ "PostIterationOutput",
74
+ "RLMHook",
75
+ "enable_rlm_hooks",
76
+ "disable_rlm_hooks",
77
+ ]
@@ -0,0 +1,361 @@
1
+ """Monkey-patch DSPy :class:`~dspy.RLM` instances to support lifecycle hooks.
2
+
3
+ This module provides :func:`enable_rlm_hooks`, which injects custom behaviour
4
+ into the RLM's private iteration loop. Hooks may be sync *or* async; the
5
+ system auto-detects coroutines via :func:`asyncio.iscoroutine` and handles both
6
+ paths transparently.
7
+
8
+ .. warning::
9
+ This module uses monkey-patching to instrument DSPy internals. It is
10
+ designed for DSPy **3.1+** and may require updates if the internal RLM
11
+ API changes in future releases.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import asyncio
17
+ import logging
18
+ from types import MethodType
19
+ from typing import Any, cast
20
+
21
+ from dspy.primitives.prediction import Prediction
22
+ from dspy.primitives.repl_types import REPLHistory, REPLVariable
23
+
24
+ from dspy_rlm_hooks.types import (
25
+ PostExecutionHook,
26
+ PostExecutionOutput,
27
+ PostIterationHook,
28
+ PostIterationOutput,
29
+ PreExecutionHook,
30
+ PreExecutionOutput,
31
+ PreIterationHook,
32
+ PreIterationOutput,
33
+ )
34
+ from dspy_rlm_hooks.utils import _strip_code_fences
35
+
36
+ logger = logging.getLogger(__name__)
37
+
38
+
39
+ def _run_async(coroutine):
40
+ """Run an async coroutine, handling both sync and async contexts."""
41
+ try:
42
+ asyncio.get_running_loop()
43
+ except RuntimeError:
44
+ return asyncio.run(coroutine)
45
+ else:
46
+ new_loop = asyncio.new_event_loop()
47
+ try:
48
+ return new_loop.run_until_complete(coroutine)
49
+ finally:
50
+ new_loop.close()
51
+
52
+
53
+ _REQUIRED_METHODS = (
54
+ "_execute_iteration",
55
+ "_aexecute_iteration",
56
+ "_process_execution_result",
57
+ "generate_action",
58
+ "max_iterations",
59
+ "verbose",
60
+ )
61
+
62
+
63
+ def _validate_rlm(rlm: Any) -> None:
64
+ """Ensure *rlm* exposes the internal methods we need to patch."""
65
+ missing = [name for name in _REQUIRED_METHODS if not hasattr(rlm, name)]
66
+ if missing:
67
+ raise AttributeError(
68
+ f"RLM instance missing required attributes: {', '.join(missing)}. "
69
+ "Ensure you are passing a dspy.RLM instance from dspy>=3.1."
70
+ )
71
+
72
+
73
+ def _execute_code(self: Any, repl: Any, code: str, input_args: dict[str, Any]) -> Any:
74
+ """Run ``code`` inside the REPL, prepending any persisted globals.
75
+
76
+ Mirrors the original ``RLM._execute_code`` logic and is bound as a
77
+ replacement method during :func:`enable_rlm_hooks`.
78
+ """
79
+ if hasattr(repl, "repl_globals") and repl.repl_globals:
80
+ code = repl.repl_globals + "\n" + code
81
+ try:
82
+ return repl.execute(code, variables=dict(input_args))
83
+ except Exception as exc: # noqa: BLE001
84
+ return f"[Error] {exc}"
85
+
86
+
87
+ def _execute_iteration(
88
+ self: Any,
89
+ repl: Any,
90
+ variables: list[REPLVariable],
91
+ history: REPLHistory,
92
+ iteration: int,
93
+ input_args: dict[str, Any],
94
+ output_field_names: list[str],
95
+ ) -> Prediction | REPLHistory:
96
+ """Synchronous RLM iteration with hook support.
97
+
98
+ Lifecycle::
99
+
100
+ pre_iteration → generate_action → pre_execution → execute → post_execution → post_iteration
101
+ """
102
+ # --- pre-iteration hook ---
103
+ if self._hook_pre_iteration:
104
+ pre_iter_out = self._hook_pre_iteration(
105
+ iteration, variables, history, input_args
106
+ )
107
+ if asyncio.iscoroutine(pre_iter_out):
108
+ pre_iter_out = _run_async(pre_iter_out)
109
+ pre_iter_out = cast(PreIterationOutput, pre_iter_out)
110
+ input_args = {**input_args, **pre_iter_out.extra_vars}
111
+ if pre_iter_out.python_code:
112
+ current_globals = getattr(repl, "repl_globals", "") or ""
113
+ repl.repl_globals = current_globals + "\n" + pre_iter_out.python_code
114
+
115
+ # --- action generation ---
116
+ variables_info = [variable.format() for variable in variables]
117
+ action = self.generate_action(
118
+ variables_info=variables_info,
119
+ repl_history=history,
120
+ iteration=f"{iteration + 1}/{self.max_iterations}",
121
+ )
122
+
123
+ if self.verbose:
124
+ logger.info(
125
+ "RLM iteration %d/%d\nReasoning: %s\nCode:\n%s",
126
+ iteration + 1,
127
+ self.max_iterations,
128
+ action.reasoning,
129
+ action.code,
130
+ )
131
+
132
+ # --- strip fences ---
133
+ try:
134
+ code = _strip_code_fences(action.code)
135
+ except SyntaxError as exc:
136
+ code = action.code
137
+ result = f"[Error] {exc}"
138
+ return self._process_execution_result(
139
+ action, code, result, history, output_field_names
140
+ )
141
+
142
+ # --- pre-execution hook ---
143
+ if self._hook_pre_execution:
144
+ pre_exec_out = self._hook_pre_execution(
145
+ iteration, code, variables, history, input_args
146
+ )
147
+ if asyncio.iscoroutine(pre_exec_out):
148
+ pre_exec_out = _run_async(pre_exec_out)
149
+ pre_exec_out = cast(PreExecutionOutput, pre_exec_out)
150
+ code = pre_exec_out.code
151
+
152
+ # --- execute ---
153
+ result = self._execute_code(repl, code, input_args)
154
+
155
+ # --- post-execution hook ---
156
+ if self._hook_post_execution:
157
+ post_exec_out = self._hook_post_execution(
158
+ iteration, code, result, variables, history, input_args
159
+ )
160
+ if asyncio.iscoroutine(post_exec_out):
161
+ post_exec_out = _run_async(post_exec_out)
162
+ post_exec_out = cast(PostExecutionOutput, post_exec_out)
163
+ result = post_exec_out.result
164
+
165
+ # --- process result ---
166
+ processed = self._process_execution_result(
167
+ action, code, result, history, output_field_names
168
+ )
169
+
170
+ # --- post-iteration hook ---
171
+ if self._hook_post_iteration and isinstance(processed, REPLHistory):
172
+ post_iter_out = self._hook_post_iteration(
173
+ iteration, action, code, result, processed
174
+ )
175
+ if asyncio.iscoroutine(post_iter_out):
176
+ post_iter_out = _run_async(post_iter_out)
177
+ post_iter_out = cast(PostIterationOutput, post_iter_out)
178
+ processed = post_iter_out.history
179
+
180
+ return processed
181
+
182
+
183
+ async def _aexecute_iteration(
184
+ self: Any,
185
+ repl: Any,
186
+ variables: list[REPLVariable],
187
+ history: REPLHistory,
188
+ iteration: int,
189
+ input_args: dict[str, Any],
190
+ output_field_names: list[str],
191
+ ) -> Prediction | REPLHistory:
192
+ """Asynchronous RLM iteration with hook support.
193
+
194
+ Mirrors :func:`_execute_iteration` but uses ``await`` for async hooks
195
+ and ``generate_action.acall``.
196
+ """
197
+ # --- pre-iteration hook ---
198
+ if self._hook_pre_iteration:
199
+ pre_iter_out = self._hook_pre_iteration(
200
+ iteration, variables, history, input_args
201
+ )
202
+ if asyncio.iscoroutine(pre_iter_out):
203
+ pre_iter_out = await pre_iter_out
204
+ pre_iter_out = cast(PreIterationOutput, pre_iter_out)
205
+ input_args = {**input_args, **pre_iter_out.extra_vars}
206
+ if pre_iter_out.python_code:
207
+ current_globals = getattr(repl, "repl_globals", "") or ""
208
+ repl.repl_globals = current_globals + "\n" + pre_iter_out.python_code
209
+
210
+ # --- action generation ---
211
+ variables_info = [variable.format() for variable in variables]
212
+ pred = await self.generate_action.acall(
213
+ variables_info=variables_info,
214
+ repl_history=history,
215
+ iteration=f"{iteration + 1}/{self.max_iterations}",
216
+ )
217
+
218
+ if self.verbose:
219
+ logger.info(
220
+ "RLM iteration %d/%d\nReasoning: %s\nCode:\n%s",
221
+ iteration + 1,
222
+ self.max_iterations,
223
+ pred.reasoning,
224
+ pred.code,
225
+ )
226
+
227
+ # --- strip fences ---
228
+ try:
229
+ code = _strip_code_fences(pred.code)
230
+ except SyntaxError as exc:
231
+ code = pred.code
232
+ result = f"[Error] {exc}"
233
+ return self._process_execution_result(
234
+ pred, code, result, history, output_field_names
235
+ )
236
+
237
+ # --- pre-execution hook ---
238
+ if self._hook_pre_execution:
239
+ pre_exec_out = self._hook_pre_execution(
240
+ iteration, code, variables, history, input_args
241
+ )
242
+ if asyncio.iscoroutine(pre_exec_out):
243
+ pre_exec_out = await pre_exec_out
244
+ pre_exec_out = cast(PreExecutionOutput, pre_exec_out)
245
+ code = pre_exec_out.code
246
+
247
+ # --- execute ---
248
+ result = self._execute_code(repl, code, input_args)
249
+
250
+ # --- post-execution hook ---
251
+ if self._hook_post_execution:
252
+ post_exec_out = self._hook_post_execution(
253
+ iteration, code, result, variables, history, input_args
254
+ )
255
+ if asyncio.iscoroutine(post_exec_out):
256
+ post_exec_out = await post_exec_out
257
+ post_exec_out = cast(PostExecutionOutput, post_exec_out)
258
+ result = post_exec_out.result
259
+
260
+ # --- process result ---
261
+ processed = self._process_execution_result(
262
+ pred, code, result, history, output_field_names
263
+ )
264
+
265
+ # --- post-iteration hook ---
266
+ if self._hook_post_iteration and isinstance(processed, REPLHistory):
267
+ post_iter_out = self._hook_post_iteration(
268
+ iteration, pred, code, result, processed
269
+ )
270
+ if asyncio.iscoroutine(post_iter_out):
271
+ post_iter_out = await post_iter_out
272
+ post_iter_out = cast(PostIterationOutput, post_iter_out)
273
+ processed = post_iter_out.history
274
+
275
+ return processed
276
+
277
+
278
+ def enable_rlm_hooks(
279
+ rlm: Any,
280
+ *,
281
+ pre_iteration_hook: PreIterationHook | None = None,
282
+ pre_execution_hook: PreExecutionHook | None = None,
283
+ post_execution_hook: PostExecutionHook | None = None,
284
+ post_iteration_hook: PostIterationHook | None = None,
285
+ ) -> None:
286
+ """Inject lifecycle hooks into a :class:`~dspy.RLM` instance.
287
+
288
+ Monkey-patches the RLM's private ``_execute_iteration`` and
289
+ ``_aexecute_iteration`` methods so that user-provided hooks are invoked
290
+ at each stage of the iteration loop.
291
+
292
+ Hooks can be **sync** or **async** — the system automatically detects
293
+ coroutine return values via :func:`asyncio.iscoroutine` and handles both
294
+ paths.
295
+
296
+ Lifecycle order::
297
+
298
+ pre_iteration_hook → generate_action → pre_execution_hook → execute → post_execution_hook → post_iteration_hook
299
+
300
+ Args:
301
+ rlm: The RLM instance to patch. Must expose the internal methods
302
+ ``_execute_iteration``, ``_aexecute_iteration``,
303
+ ``_process_execution_result``, ``generate_action``/``generate_action.acall``,
304
+ ``max_iterations``, and ``verbose``.
305
+ pre_iteration_hook: Called before action generation. May inject
306
+ variables via :attr:`PreIterationOutput.extra_vars` or prepend
307
+ persistent code via :attr:`PreIterationOutput.python_code`.
308
+ pre_execution_hook: Called after code is generated, before execution.
309
+ May rewrite the generated ``code`` string.
310
+ post_execution_hook: Called after code executes, before the result is
311
+ processed into history. May transform or audit ``result``.
312
+ post_iteration_hook: Called after the result is processed into history.
313
+ May save learnings, trigger side effects, or modify history.
314
+
315
+ Raises:
316
+ AttributeError: If *rlm* does not expose the expected internal API.
317
+
318
+ Example:
319
+ >>> def my_pre_iter(iteration, variables, history, input_args):
320
+ ... return PreIterationOutput(extra_vars={"debug": True})
321
+ ...
322
+ >>> enable_rlm_hooks(rlm, pre_iteration_hook=my_pre_iter)
323
+ """
324
+ _validate_rlm(rlm)
325
+
326
+ # Store hook references on the instance
327
+ rlm._hook_pre_iteration = pre_iteration_hook
328
+ rlm._hook_pre_execution = pre_execution_hook
329
+ rlm._hook_post_execution = post_execution_hook
330
+ rlm._hook_post_iteration = post_iteration_hook
331
+
332
+ # Bind patched methods
333
+ rlm._execute_iteration = MethodType(_execute_iteration, rlm)
334
+ rlm._aexecute_iteration = MethodType(_aexecute_iteration, rlm)
335
+ rlm._execute_code = MethodType(_execute_code, rlm)
336
+
337
+
338
+ def disable_rlm_hooks(rlm: Any) -> None:
339
+ """Remove lifecycle hooks from an RLM instance.
340
+
341
+ Deletes the monkey-patched overrides and hook attributes that were added
342
+ by :func:`enable_rlm_hooks`. After calling this, the instance reverts
343
+ to its original behaviour.
344
+
345
+ Args:
346
+ rlm: A previously patched RLM instance.
347
+
348
+ Example:
349
+ >>> disable_rlm_hooks(rlm)
350
+ """
351
+ for attr in (
352
+ "_hook_pre_iteration",
353
+ "_hook_pre_execution",
354
+ "_hook_post_execution",
355
+ "_hook_post_iteration",
356
+ "_execute_iteration",
357
+ "_aexecute_iteration",
358
+ "_execute_code",
359
+ ):
360
+ if hasattr(rlm, attr):
361
+ delattr(rlm, attr)
@@ -0,0 +1,152 @@
1
+ """Type definitions for the DSPy RLM hook system.
2
+
3
+
4
+
5
+ Usage::
6
+
7
+ from dspy_rlm_hooks import (
8
+ PreIterationHook,
9
+ PreIterationOutput,
10
+ enable_rlm_hooks,
11
+ )
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from typing import Any, Awaitable, Protocol, runtime_checkable
17
+
18
+ import pydantic
19
+ from dspy.primitives.repl_types import REPLHistory
20
+
21
+
22
+ class PreIterationOutput(pydantic.BaseModel):
23
+ """Data produced by a ``pre_iteration`` hook.
24
+
25
+ Allows injecting variables and persistent Python code into the interpreter
26
+ namespace before the LLM generates the next action.
27
+
28
+ Attributes:
29
+ extra_vars: Variables to inject into the interpreter namespace before
30
+ the next code generation. These are merged into ``input_args``.
31
+ python_code: Code to prepend to generated code on every execution.
32
+ Persists across iterations via ``repl.repl_globals``.
33
+ """
34
+
35
+ extra_vars: dict[str, Any] = pydantic.Field(default_factory=dict)
36
+ python_code: str = ""
37
+
38
+
39
+ class PreExecutionOutput(pydantic.BaseModel):
40
+ """Data produced by a ``pre_execution`` hook.
41
+
42
+ Allows rewriting or augmenting the LLM-generated code before it is
43
+ sent to the code interpreter.
44
+
45
+ Attributes:
46
+ code: The code to execute. Usually the LLM-generated code, but can
47
+ be rewritten (e.g. to add imports, fix patterns, inject helpers).
48
+ """
49
+
50
+ code: str
51
+
52
+
53
+ class PostExecutionOutput(pydantic.BaseModel):
54
+ """Data produced by a ``post_execution`` hook.
55
+
56
+ Allows transforming, auditing, or replacing the raw execution result
57
+ before it is processed into history.
58
+
59
+ Attributes:
60
+ result: The result to pass to history. Can transform or replace the
61
+ raw execution result.
62
+ """
63
+
64
+ result: Any
65
+
66
+
67
+ class PostIterationOutput(pydantic.BaseModel):
68
+ """Data produced by a ``post_iteration`` hook.
69
+
70
+ Allows modifying or persisting the REPL history after the iteration
71
+ result has been processed.
72
+
73
+ Attributes:
74
+ history: The updated history for the next iteration. Return the same
75
+ history to persist it, or a modified copy to change it.
76
+ """
77
+
78
+ history: REPLHistory
79
+
80
+
81
+ @runtime_checkable
82
+ class PreIterationHook(Protocol):
83
+ """Called before action generation in each RLM iteration.
84
+
85
+ Receives the current iteration index, variable state, history, and input
86
+ arguments. May return :class:`PreIterationOutput` (sync) or an
87
+ ``Awaitable[PreIterationOutput]`` (async).
88
+ """
89
+
90
+ def __call__(
91
+ self,
92
+ iteration: int,
93
+ variables: list[Any],
94
+ history: list[Any],
95
+ input_args: dict[str, Any],
96
+ ) -> PreIterationOutput | Awaitable[PreIterationOutput]: ...
97
+
98
+
99
+ @runtime_checkable
100
+ class PreExecutionHook(Protocol):
101
+ """Called after code generation, before execution.
102
+
103
+ Receives the generated code and may rewrite it.
104
+ """
105
+
106
+ def __call__(
107
+ self,
108
+ iteration: int,
109
+ code: str,
110
+ variables: list[Any],
111
+ history: list[Any],
112
+ input_args: dict[str, Any],
113
+ ) -> PreExecutionOutput | Awaitable[PreExecutionOutput]: ...
114
+
115
+
116
+ @runtime_checkable
117
+ class PostExecutionHook(Protocol):
118
+ """Called after code execution, before result processing.
119
+
120
+ Receives the raw execution result and may transform or audit it.
121
+ """
122
+
123
+ def __call__(
124
+ self,
125
+ iteration: int,
126
+ code: str,
127
+ result: Any,
128
+ variables: list[Any],
129
+ history: list[Any],
130
+ input_args: dict[str, Any],
131
+ ) -> PostExecutionOutput | Awaitable[PostExecutionOutput]: ...
132
+
133
+
134
+ @runtime_checkable
135
+ class PostIterationHook(Protocol):
136
+ """Called after the iteration result is processed into history.
137
+
138
+ Receives the updated history and may modify or persist it.
139
+ """
140
+
141
+ def __call__(
142
+ self,
143
+ iteration: int,
144
+ pred: Any,
145
+ code: str,
146
+ result: Any,
147
+ history: REPLHistory,
148
+ ) -> PostIterationOutput | Awaitable[PostIterationOutput]: ...
149
+
150
+
151
+ RLMHook = PreIterationHook | PreExecutionHook | PostExecutionHook | PostIterationHook
152
+ """Union of all hook protocols. Used for type narrowing."""
@@ -0,0 +1,56 @@
1
+ """Small internal utilities for the RLM hook system."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ def _strip_code_fences(code: str) -> str:
7
+ """Remove markdown code fences from LLM-generated code.
8
+
9
+ Handles both plain `` ``` `` fences and language-tagged ones
10
+ (`` ```python ``). Raises :class:`SyntaxError` when a non-Python
11
+ language fence is detected.
12
+
13
+ Args:
14
+ code: Raw code string, potentially wrapped in markdown fences.
15
+
16
+ Returns:
17
+ Clean code with fences stripped.
18
+
19
+ Raises:
20
+ SyntaxError: If the fence specifies a language other than Python.
21
+ """
22
+ code = code.strip()
23
+ if "```" not in code:
24
+ return code
25
+
26
+ lines = code.splitlines()
27
+ while len(lines) >= 2 and lines[0].strip() == "```" and lines[-1].strip() == "```":
28
+ lines.pop(0)
29
+ lines.pop()
30
+ code = "\n".join(lines).strip()
31
+
32
+ if "```" not in code:
33
+ return code
34
+
35
+ # If there are still fences after stripping plain pairs,
36
+ # it may be a language-tagged block or multiple blocks.
37
+ # Strip one more language-tagged fence if present.
38
+ fence_start = code.find("```")
39
+ lang_line, separator, remainder = code[fence_start + 3 :].partition("\n")
40
+ if not separator:
41
+ return code
42
+
43
+ lang = (lang_line.strip().split(maxsplit=1)[0] if lang_line.strip() else "").lower()
44
+ if lang not in {"python", "py", "python3", "py3", ""}:
45
+ raise SyntaxError(
46
+ f"Expected Python code but got ```{lang} fence. "
47
+ f"Write Python code, not {lang}."
48
+ )
49
+
50
+ block_end = remainder.find("```")
51
+ if block_end == -1:
52
+ return remainder.strip()
53
+ if block_end == 0:
54
+ # Opening and closing fences are adjacent — return everything after
55
+ return remainder[3:].strip()
56
+ return remainder[:block_end].strip()