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.
- dspy_rlm_hooks-0.1.2/LICENSE +21 -0
- dspy_rlm_hooks-0.1.2/PKG-INFO +322 -0
- dspy_rlm_hooks-0.1.2/README.md +297 -0
- dspy_rlm_hooks-0.1.2/pyproject.toml +81 -0
- dspy_rlm_hooks-0.1.2/src/dspy_rlm_hooks/__init__.py +77 -0
- dspy_rlm_hooks-0.1.2/src/dspy_rlm_hooks/patcher.py +361 -0
- dspy_rlm_hooks-0.1.2/src/dspy_rlm_hooks/types.py +152 -0
- dspy_rlm_hooks-0.1.2/src/dspy_rlm_hooks/utils.py +56 -0
|
@@ -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()
|