Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion src/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ serdes-ai-models = { version = "0.2.6", default-features = false }
serdes-ai-streaming = "0.2"

# Internal crates
reloaded-code-core = { version = "0.2.2", path = "reloaded-code-core", default-features = false }
reloaded-code-core = { version = "0.2.3", path = "reloaded-code-core", default-features = false }
reloaded-code-bubblewrap = { version = "0.1.0", path = "reloaded-code-bubblewrap" }
reloaded-code-agents = { version = "0.1.0", path = "reloaded-code-agents" }
reloaded-code-models-dev = { version = "0.1.0", path = "reloaded-code-models-dev" }
Expand Down
89 changes: 83 additions & 6 deletions src/docs/src/hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@

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

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

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

### Intercept streamed events

Run-event hooks fire only on `run_stream()`. Each event passes the
registered `RunEventHook`s, in registration order, only until one
suppresses it with `Ok(None)` or rejects it with `Err(ToolError)`;
a rejection also terminates the stream.

Per event, a hook returns one of three decisions:

- `Ok(Some(event))` publishes it, changed or unchanged.
- `Ok(None)` suppresses it. Later hooks and the consumer never see it.
- `Err(ToolError)` stops dispatch; the stream ends with
`Err(AgentRunError::Other)`.

This hook forwards the events a TUI renders. The consumer still sees
every event:

```rust
use reloaded_code_core::{HookSet, RunEventContext, RunEventHookResult};
use reloaded_code_serdesai::{RunEvent, RunEventHook};

struct TuiSender { /* channel to the UI thread */ }

impl TuiSender {
/// Stub: cheap, non-blocking send; the UI thread draws.
fn send_to_tui(&self, _event: &RunEvent) {}
}

struct ForwardToTui {
tui: TuiSender,
}

impl RunEventHook for ForwardToTui {
fn hook(&self, _ctx: &RunEventContext<'_>, event: RunEvent) -> RunEventHookResult {
// Forward only what the TUI renders
match &event {
RunEvent::TextDelta { .. } | RunEvent::RunComplete { .. } => {
self.tui.send_to_tui(&event);
}
_ => {}
}
Ok(Some(event))
}
}

let hooks = HookSet::builder()
.run_event_hook(ForwardToTui { tui: TuiSender {} })
.build();
```

Hooks run synchronously at token rate; keep per-event work cheap. Each
call sees one event. Text can split across several `TextDelta`s, so
buffer cross-event context inside the hook.

Full example: [serdesai-run-event-hook] rewrites text deltas to
uppercase and suppresses the output-ready milestone
(`cargo run --example serdesai-run-event-hook -p reloaded-code-serdesai --features mock`).

## Available types

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

### Run event hook types

| Type | Purpose |
| ---------------------- | ----------------------------------------------------- |
| [`RunEventHook`] | Observes, rewrites, or suppresses one streamed event. |
| [`RunEventContext`] | Agent and model names for the event's stream. |
| [`RunEvent`] | Framework-owned event yielded by a run stream. |
| [`RunEventHookResult`] | Publish, rewrite, suppress, or Err(ToolError). |

### Container types

| Type | Purpose |
| ------------------ | ------------------------------------------------- |
| [`HookSet`] | Stores tool hooks, run hooks, and compact events. |
| [`HookSetBuilder`] | Builder for [`HookSet`]. |
| Type | Purpose |
| ------------------ | ----------------------------------------------------------- |
| [`HookSet`] | Stores tool, run, and run-event hooks, plus compact events. |
| [`HookSetBuilder`] | Builder for [`HookSet`]. |

## How tool hooks stack

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

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


[`ToolHook`]: https://docs.rs/reloaded-code-core/latest/reloaded_code_core/trait.ToolHook.html
[`ToolOriginal`]: https://docs.rs/reloaded-code-core/latest/reloaded_code_core/struct.ToolOriginal.html
Expand All @@ -356,8 +428,13 @@ passes `HookSet::default()`.
[`RunOutput`]: https://docs.rs/reloaded-code-core/latest/reloaded_code_core/struct.RunOutput.html
[`RunExecutor`]: https://docs.rs/reloaded-code-core/latest/reloaded_code_core/trait.RunExecutor.html
[`RunUsage`]: https://docs.rs/reloaded-code-core/latest/reloaded_code_core/struct.RunUsage.html
[`RunEventHook`]: https://docs.rs/reloaded-code-core/latest/reloaded_code_core/trait.RunEventHook.html
[`RunEventContext`]: https://docs.rs/reloaded-code-core/latest/reloaded_code_core/struct.RunEventContext.html
[`RunEvent`]: https://docs.rs/reloaded-code-core/latest/reloaded_code_core/enum.RunEvent.html
[`RunEventHookResult`]: https://docs.rs/reloaded-code-core/latest/reloaded_code_core/type.RunEventHookResult.html
[SerdesAI]: https://crates.io/crates/serdes-ai
[serdesai-tool-hook]: https://github.com/Reloaded-Project/ReloadedCode/blob/main/src/reloaded-code-serdesai/examples/hooks/tool/serdesai-tool-hook.rs
[serdesai-tool-block]: https://github.com/Reloaded-Project/ReloadedCode/blob/main/src/reloaded-code-serdesai/examples/hooks/tool/serdesai-tool-block.rs
[serdesai-tool-chain]: https://github.com/Reloaded-Project/ReloadedCode/blob/main/src/reloaded-code-serdesai/examples/hooks/tool/serdesai-tool-chain.rs
[serdesai-run-hook]: https://github.com/Reloaded-Project/ReloadedCode/blob/main/src/reloaded-code-serdesai/examples/hooks/run/serdesai-run-hook.rs
[serdesai-run-event-hook]: https://github.com/Reloaded-Project/ReloadedCode/blob/main/src/reloaded-code-serdesai/examples/hooks/run/serdesai-run-event-hook.rs
2 changes: 1 addition & 1 deletion src/reloaded-code-core/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "reloaded-code-core"
version = "0.2.2"
version = "0.2.3"
edition = "2021"
description = "Lightweight, high-performance core types and utilities for coding tools - framework agnostic"
repository = "https://github.com/Reloaded-Project/ReloadedCode"
Expand Down
68 changes: 67 additions & 1 deletion src/reloaded-code-core/src/hooks/builder.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
//! HookSetBuilder — builder for constructing a [`HookSet`].

use crate::hooks::{HookSet, RunHook, SessionCompactFn, ToolHook, INLINE_CAP};
use crate::hooks::{HookSet, RunEventHook, RunHook, SessionCompactFn, ToolHook, INLINE_CAP};
use std::fmt;
use std::sync::Arc;
use tinyvec::TinyVec;
Expand All @@ -10,6 +10,7 @@ use tinyvec::TinyVec;
pub struct HookSetBuilder {
pub(super) tool_hooks: Vec<Arc<dyn ToolHook>>,
pub(super) run_hooks: Vec<Arc<dyn RunHook>>,
pub(super) run_event_hooks: Vec<Arc<dyn RunEventHook>>,
pub(super) session_compact: TinyVec<[Option<SessionCompactFn>; INLINE_CAP]>,
}

Expand Down Expand Up @@ -67,13 +68,34 @@ impl HookSetBuilder {
self
}

/// Registers a run-event hook.
///
/// Hooks run in registration order on every streamed event,
/// before the stream consumer sees it. Streaming path only:
/// run-event hooks never fire during a non-streaming `run()`.
#[inline]
#[must_use]
pub fn run_event_hook(mut self, hook: impl RunEventHook) -> Self {
self.run_event_hooks.push(Arc::new(hook));
self
}

/// Registers an already shared run-event hook.
#[inline]
#[must_use]
pub fn shared_run_event_hook(mut self, hook: Arc<dyn RunEventHook>) -> Self {
self.run_event_hooks.push(hook);
self
}

/// Builds the `HookSet` from the configured hooks.
#[inline]
#[must_use]
pub fn build(self) -> HookSet {
HookSet {
tool_hooks: self.tool_hooks,
run_hooks: self.run_hooks,
run_event_hooks: self.run_event_hooks,
session_compact: self.session_compact,
}
}
Expand All @@ -84,6 +106,7 @@ impl fmt::Debug for HookSetBuilder {
f.debug_struct("HookSetBuilder")
.field("tool_hooks", &self.tool_hooks.len())
.field("run_hooks", &self.run_hooks.len())
.field("run_event_hooks", &self.run_event_hooks.len())
.field("session_compact", &self.session_compact.len())
.finish()
}
Expand All @@ -92,6 +115,7 @@ impl fmt::Debug for HookSetBuilder {
#[cfg(test)]
mod tests {
use super::*;
use crate::hooks::run_event::{RunEvent, RunEventContext, RunEventHook, RunEventHookResult};
use crate::hooks::run_hook::{HookRunContext, RunConfig, RunHookFuture, RunOriginal};
use crate::hooks::tool_hook::{ToolCallContext, ToolHookFuture, ToolOriginal, ToolRequest};

Expand Down Expand Up @@ -166,10 +190,52 @@ mod tests {
assert_eq!(hooks.run_hooks().len(), 1);
}

#[test]
fn run_event_hook_registration_makes_hook_set_non_empty() {
struct NoopEvent;
impl RunEventHook for NoopEvent {
fn hook(&self, _ctx: &RunEventContext<'_>, event: RunEvent) -> RunEventHookResult {
Ok(Some(event))
}
}

let hooks = HookSetBuilder::new().run_event_hook(NoopEvent).build();
assert!(!hooks.is_empty());
assert!(!hooks.run_event_hooks_is_empty());
}

#[test]
fn shared_run_event_hook_registration() {
struct NoopEvent;
impl RunEventHook for NoopEvent {
fn hook(&self, _ctx: &RunEventContext<'_>, event: RunEvent) -> RunEventHookResult {
Ok(Some(event))
}
}

let shared: Arc<dyn RunEventHook> = Arc::new(NoopEvent);
let hooks = HookSetBuilder::new().shared_run_event_hook(shared).build();
assert!(!hooks.run_event_hooks_is_empty());
}

#[test]
fn builder_debug_includes_run_hooks() {
let builder = HookSetBuilder::new();
let debug = format!("{:?}", builder);
assert!(debug.contains("run_hooks"));
}

#[test]
fn builder_debug_includes_run_event_hooks() {
struct NoopEvent;
impl RunEventHook for NoopEvent {
fn hook(&self, _ctx: &RunEventContext<'_>, event: RunEvent) -> RunEventHookResult {
Ok(Some(event))
}
}

let builder = HookSetBuilder::new().run_event_hook(NoopEvent);
let debug = format!("{builder:?}");
assert!(debug.contains("run_event_hooks: 1"));
}
}
Loading
Loading