Skip to content

Commit cea7153

Browse files
authored
Merge pull request #155 from Reloaded-Project/event-hooks
Add run-event hooks for streaming runs
2 parents f908df9 + ef4df13 commit cea7153

16 files changed

Lines changed: 1081 additions & 68 deletions

File tree

‎src/Cargo.lock‎

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎src/Cargo.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ serdes-ai-models = { version = "0.2.6", default-features = false }
7676
serdes-ai-streaming = "0.2"
7777

7878
# Internal crates
79-
reloaded-code-core = { version = "0.2.2", path = "reloaded-code-core", default-features = false }
79+
reloaded-code-core = { version = "0.2.3", path = "reloaded-code-core", default-features = false }
8080
reloaded-code-bubblewrap = { version = "0.1.0", path = "reloaded-code-bubblewrap" }
8181
reloaded-code-agents = { version = "0.1.0", path = "reloaded-code-agents" }
8282
reloaded-code-models-dev = { version = "0.1.0", path = "reloaded-code-models-dev" }

‎src/docs/src/hooks.md‎

Lines changed: 83 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@
22

33
Hooks let your code see, change, or stop things the agent does.
44

5-
Tool and run hooks are wired into the [SerdesAI] agent pipeline: registered
6-
hooks intercept real tool calls and agent runs end to end.
5+
Tool, run, and run-event hooks are wired into the [SerdesAI] agent
6+
pipeline: registered hooks intercept real tool calls, agent runs, and
7+
streamed run events end to end.
78

89
Tool hooks work like game mods.
910
Each hook gets an `original` function.
@@ -247,6 +248,64 @@ error still propagates to the caller. An outer hook that skips
247248
`original` never reaches this hook, so do not rely on it for cleanup
248249
that must run on every path.
249250

251+
### Intercept streamed events
252+
253+
Run-event hooks fire only on `run_stream()`. Each event passes the
254+
registered `RunEventHook`s, in registration order, only until one
255+
suppresses it with `Ok(None)` or rejects it with `Err(ToolError)`;
256+
a rejection also terminates the stream.
257+
258+
Per event, a hook returns one of three decisions:
259+
260+
- `Ok(Some(event))` publishes it, changed or unchanged.
261+
- `Ok(None)` suppresses it. Later hooks and the consumer never see it.
262+
- `Err(ToolError)` stops dispatch; the stream ends with
263+
`Err(AgentRunError::Other)`.
264+
265+
This hook forwards the events a TUI renders. The consumer still sees
266+
every event:
267+
268+
```rust
269+
use reloaded_code_core::{HookSet, RunEventContext, RunEventHookResult};
270+
use reloaded_code_serdesai::{RunEvent, RunEventHook};
271+
272+
struct TuiSender { /* channel to the UI thread */ }
273+
274+
impl TuiSender {
275+
/// Stub: cheap, non-blocking send; the UI thread draws.
276+
fn send_to_tui(&self, _event: &RunEvent) {}
277+
}
278+
279+
struct ForwardToTui {
280+
tui: TuiSender,
281+
}
282+
283+
impl RunEventHook for ForwardToTui {
284+
fn hook(&self, _ctx: &RunEventContext<'_>, event: RunEvent) -> RunEventHookResult {
285+
// Forward only what the TUI renders
286+
match &event {
287+
RunEvent::TextDelta { .. } | RunEvent::RunComplete { .. } => {
288+
self.tui.send_to_tui(&event);
289+
}
290+
_ => {}
291+
}
292+
Ok(Some(event))
293+
}
294+
}
295+
296+
let hooks = HookSet::builder()
297+
.run_event_hook(ForwardToTui { tui: TuiSender {} })
298+
.build();
299+
```
300+
301+
Hooks run synchronously at token rate; keep per-event work cheap. Each
302+
call sees one event. Text can split across several `TextDelta`s, so
303+
buffer cross-event context inside the hook.
304+
305+
Full example: [serdesai-run-event-hook] rewrites text deltas to
306+
uppercase and suppresses the output-ready milestone
307+
(`cargo run --example serdesai-run-event-hook -p reloaded-code-serdesai --features mock`).
308+
250309
## Available types
251310

252311
### Tool hook types
@@ -272,12 +331,21 @@ that must run on every path.
272331
| [`RunExecutor`] | Final callable used at the end of the run hook chain. |
273332
| [`RunUsage`] | Token usage for a completed run. |
274333

334+
### Run event hook types
335+
336+
| Type | Purpose |
337+
| ---------------------- | ----------------------------------------------------- |
338+
| [`RunEventHook`] | Observes, rewrites, or suppresses one streamed event. |
339+
| [`RunEventContext`] | Agent and model names for the event's stream. |
340+
| [`RunEvent`] | Framework-owned event yielded by a run stream. |
341+
| [`RunEventHookResult`] | Publish, rewrite, suppress, or Err(ToolError). |
342+
275343
### Container types
276344

277-
| Type | Purpose |
278-
| ------------------ | ------------------------------------------------- |
279-
| [`HookSet`] | Stores tool hooks, run hooks, and compact events. |
280-
| [`HookSetBuilder`] | Builder for [`HookSet`]. |
345+
| Type | Purpose |
346+
| ------------------ | ----------------------------------------------------------- |
347+
| [`HookSet`] | Stores tool, run, and run-event hooks, plus compact events. |
348+
| [`HookSetBuilder`] | Builder for [`HookSet`]. |
281349

282350
## How tool hooks stack
283351

@@ -340,6 +408,10 @@ passes `HookSet::default()`.
340408
- **Empty fast path.** `dispatch_tool` calls the real tool directly when you
341409
set no hooks.
342410

411+
- **Mode-scoped run hooks.** `RunHook` fires only on `run()`;
412+
`RunEventHook` fires only on `run_stream()`. Each hook point stays
413+
inert on the other path.
414+
343415

344416
[`ToolHook`]: https://docs.rs/reloaded-code-core/latest/reloaded_code_core/trait.ToolHook.html
345417
[`ToolOriginal`]: https://docs.rs/reloaded-code-core/latest/reloaded_code_core/struct.ToolOriginal.html
@@ -356,8 +428,13 @@ passes `HookSet::default()`.
356428
[`RunOutput`]: https://docs.rs/reloaded-code-core/latest/reloaded_code_core/struct.RunOutput.html
357429
[`RunExecutor`]: https://docs.rs/reloaded-code-core/latest/reloaded_code_core/trait.RunExecutor.html
358430
[`RunUsage`]: https://docs.rs/reloaded-code-core/latest/reloaded_code_core/struct.RunUsage.html
431+
[`RunEventHook`]: https://docs.rs/reloaded-code-core/latest/reloaded_code_core/trait.RunEventHook.html
432+
[`RunEventContext`]: https://docs.rs/reloaded-code-core/latest/reloaded_code_core/struct.RunEventContext.html
433+
[`RunEvent`]: https://docs.rs/reloaded-code-core/latest/reloaded_code_core/enum.RunEvent.html
434+
[`RunEventHookResult`]: https://docs.rs/reloaded-code-core/latest/reloaded_code_core/type.RunEventHookResult.html
359435
[SerdesAI]: https://crates.io/crates/serdes-ai
360436
[serdesai-tool-hook]: https://github.com/Reloaded-Project/ReloadedCode/blob/main/src/reloaded-code-serdesai/examples/hooks/tool/serdesai-tool-hook.rs
361437
[serdesai-tool-block]: https://github.com/Reloaded-Project/ReloadedCode/blob/main/src/reloaded-code-serdesai/examples/hooks/tool/serdesai-tool-block.rs
362438
[serdesai-tool-chain]: https://github.com/Reloaded-Project/ReloadedCode/blob/main/src/reloaded-code-serdesai/examples/hooks/tool/serdesai-tool-chain.rs
363439
[serdesai-run-hook]: https://github.com/Reloaded-Project/ReloadedCode/blob/main/src/reloaded-code-serdesai/examples/hooks/run/serdesai-run-hook.rs
440+
[serdesai-run-event-hook]: https://github.com/Reloaded-Project/ReloadedCode/blob/main/src/reloaded-code-serdesai/examples/hooks/run/serdesai-run-event-hook.rs

‎src/reloaded-code-core/Cargo.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "reloaded-code-core"
3-
version = "0.2.2"
3+
version = "0.2.3"
44
edition = "2021"
55
description = "Lightweight, high-performance core types and utilities for coding tools - framework agnostic"
66
repository = "https://github.com/Reloaded-Project/ReloadedCode"

‎src/reloaded-code-core/src/hooks/builder.rs‎

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
//! HookSetBuilder — builder for constructing a [`HookSet`].
22
3-
use crate::hooks::{HookSet, RunHook, SessionCompactFn, ToolHook, INLINE_CAP};
3+
use crate::hooks::{HookSet, RunEventHook, RunHook, SessionCompactFn, ToolHook, INLINE_CAP};
44
use std::fmt;
55
use std::sync::Arc;
66
use tinyvec::TinyVec;
@@ -10,6 +10,7 @@ use tinyvec::TinyVec;
1010
pub struct HookSetBuilder {
1111
pub(super) tool_hooks: Vec<Arc<dyn ToolHook>>,
1212
pub(super) run_hooks: Vec<Arc<dyn RunHook>>,
13+
pub(super) run_event_hooks: Vec<Arc<dyn RunEventHook>>,
1314
pub(super) session_compact: TinyVec<[Option<SessionCompactFn>; INLINE_CAP]>,
1415
}
1516

@@ -67,13 +68,34 @@ impl HookSetBuilder {
6768
self
6869
}
6970

71+
/// Registers a run-event hook.
72+
///
73+
/// Hooks run in registration order on every streamed event,
74+
/// before the stream consumer sees it. Streaming path only:
75+
/// run-event hooks never fire during a non-streaming `run()`.
76+
#[inline]
77+
#[must_use]
78+
pub fn run_event_hook(mut self, hook: impl RunEventHook) -> Self {
79+
self.run_event_hooks.push(Arc::new(hook));
80+
self
81+
}
82+
83+
/// Registers an already shared run-event hook.
84+
#[inline]
85+
#[must_use]
86+
pub fn shared_run_event_hook(mut self, hook: Arc<dyn RunEventHook>) -> Self {
87+
self.run_event_hooks.push(hook);
88+
self
89+
}
90+
7091
/// Builds the `HookSet` from the configured hooks.
7192
#[inline]
7293
#[must_use]
7394
pub fn build(self) -> HookSet {
7495
HookSet {
7596
tool_hooks: self.tool_hooks,
7697
run_hooks: self.run_hooks,
98+
run_event_hooks: self.run_event_hooks,
7799
session_compact: self.session_compact,
78100
}
79101
}
@@ -84,6 +106,7 @@ impl fmt::Debug for HookSetBuilder {
84106
f.debug_struct("HookSetBuilder")
85107
.field("tool_hooks", &self.tool_hooks.len())
86108
.field("run_hooks", &self.run_hooks.len())
109+
.field("run_event_hooks", &self.run_event_hooks.len())
87110
.field("session_compact", &self.session_compact.len())
88111
.finish()
89112
}
@@ -92,6 +115,7 @@ impl fmt::Debug for HookSetBuilder {
92115
#[cfg(test)]
93116
mod tests {
94117
use super::*;
118+
use crate::hooks::run_event::{RunEvent, RunEventContext, RunEventHook, RunEventHookResult};
95119
use crate::hooks::run_hook::{HookRunContext, RunConfig, RunHookFuture, RunOriginal};
96120
use crate::hooks::tool_hook::{ToolCallContext, ToolHookFuture, ToolOriginal, ToolRequest};
97121

@@ -166,10 +190,52 @@ mod tests {
166190
assert_eq!(hooks.run_hooks().len(), 1);
167191
}
168192

193+
#[test]
194+
fn run_event_hook_registration_makes_hook_set_non_empty() {
195+
struct NoopEvent;
196+
impl RunEventHook for NoopEvent {
197+
fn hook(&self, _ctx: &RunEventContext<'_>, event: RunEvent) -> RunEventHookResult {
198+
Ok(Some(event))
199+
}
200+
}
201+
202+
let hooks = HookSetBuilder::new().run_event_hook(NoopEvent).build();
203+
assert!(!hooks.is_empty());
204+
assert!(!hooks.run_event_hooks_is_empty());
205+
}
206+
207+
#[test]
208+
fn shared_run_event_hook_registration() {
209+
struct NoopEvent;
210+
impl RunEventHook for NoopEvent {
211+
fn hook(&self, _ctx: &RunEventContext<'_>, event: RunEvent) -> RunEventHookResult {
212+
Ok(Some(event))
213+
}
214+
}
215+
216+
let shared: Arc<dyn RunEventHook> = Arc::new(NoopEvent);
217+
let hooks = HookSetBuilder::new().shared_run_event_hook(shared).build();
218+
assert!(!hooks.run_event_hooks_is_empty());
219+
}
220+
169221
#[test]
170222
fn builder_debug_includes_run_hooks() {
171223
let builder = HookSetBuilder::new();
172224
let debug = format!("{:?}", builder);
173225
assert!(debug.contains("run_hooks"));
174226
}
227+
228+
#[test]
229+
fn builder_debug_includes_run_event_hooks() {
230+
struct NoopEvent;
231+
impl RunEventHook for NoopEvent {
232+
fn hook(&self, _ctx: &RunEventContext<'_>, event: RunEvent) -> RunEventHookResult {
233+
Ok(Some(event))
234+
}
235+
}
236+
237+
let builder = HookSetBuilder::new().run_event_hook(NoopEvent);
238+
let debug = format!("{builder:?}");
239+
assert!(debug.contains("run_event_hooks: 1"));
240+
}
175241
}

0 commit comments

Comments
 (0)