Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
2ff9723
Changed: Add run hook chain to replace session-lifecycle events
Sewer56 Jun 9, 2026
a60167a
test: fold redundant hook_set tests
Sewer56 Aug 14, 2026
dd6d8b8
Changed: ignore local artifact review scratch directories
Sewer56 Aug 14, 2026
38a9b7f
Changed: document what a run is in the run_hook module docs
Sewer56 Aug 14, 2026
b7ea3e3
Changed: merge session module into run_hook
Sewer56 Aug 14, 2026
d6c6fdf
Changed: document what a tool call is in the tool_hook module docs
Sewer56 Aug 15, 2026
4dd3836
Added: realistic guardrail tool hook example set with hermetic worksp…
Sewer56 Aug 15, 2026
5b561db
Added: end-to-end test proving hooks deny writes to never-read files
Sewer56 Aug 15, 2026
624d3c9
Added: docs examples for every hook type with realistic guardrail sce…
Sewer56 Aug 15, 2026
6b12625
Fixed: misplaced rustdoc link definitions hoisted to end of doc blocks
Sewer56 Aug 15, 2026
9f2864e
Changed: dedupe agent-runtime test fixtures and drop redundant tests
Sewer56 Aug 15, 2026
afb9b28
Apply rust-llm-tidy fixes
github-actions[bot] Aug 15, 2026
f73c966
Changed: address CodeRabbit review findings in hook integration
Sewer56 Aug 15, 2026
e91db14
Apply rust-llm-tidy fixes
github-actions[bot] Aug 15, 2026
7cf5aef
Fixed: clarify post-call result semantics in tool hook docs
Sewer56 Aug 16, 2026
b242714
Fixed: run-hook example docs no longer match actual output
Sewer56 Aug 16, 2026
fa07945
Added: re-export HookedAgentRunResult from agent_runtime and crate root
Sewer56 Aug 16, 2026
4e2c6e8
Fixed: preserve inner agent run errors across the run hook chain
Sewer56 Aug 16, 2026
2487e66
Added: apply run-hook model settings overrides in SerdesRunExecutor
Sewer56 Aug 16, 2026
7c30a2c
Changed: pin both model-settings override directions in one test
Sewer56 Aug 16, 2026
be9a240
Removed: HookSetBuilder::on_run_start / on_run_end convenience helpers
Sewer56 Aug 16, 2026
db1a4db
Changed: inline tool definitions into tool_with_executor calls
Sewer56 Aug 16, 2026
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ PROMPT.md
PROMPT-*.md
PROMPT.MD
PROMPT-*.MD
artifact/

# Local Code Review
.vscode/local-reviews
Expand Down
2 changes: 2 additions & 0 deletions src/Cargo.lock

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

4 changes: 2 additions & 2 deletions src/docs/src/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,8 @@ The foundation. Contains every tool implementation as a plain function
- **Model catalog** - compact hash-table-based provider/model lookup
- **Hook types** - `HookSet`, `HookSetBuilder`, tool hook types
(`ToolHook`, `ToolOriginal`, `ToolHookFuture`, `ToolExecutor`,
`ToolCallContext`, `ToolRequest`), and session event types
(`SessionContext`, `EndReason`). See [Hooks].
`ToolCallContext`, `ToolRequest`), and hook run event types
(`HookRunContext`, `EndReason`). See [Hooks](hooks).

Core is **framework-agnostic**: it has no dependencies on any specific LLM
framework. Your integration layer wraps these functions into framework-specific
Expand Down
4 changes: 4 additions & 0 deletions src/docs/src/examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ Runnable examples live in the repository under each crate's `examples/` director
| [serdesai-task] | Orchestrator delegates a read-only task to a reader sub-agent, with streamed transcript and tool-call logging. | `cargo run --example serdesai-task -p reloaded-code-serdesai` |
| [serdesai-sandboxed] | Agent with `AllowedPathResolver` - file operations restricted to specific directories. | `cargo run --example serdesai-sandboxed -p reloaded-code-serdesai` |
| [serdesai-sandboxed-bash] | Sandboxed shell execution with a bubblewrap `public_bot` profile (Linux only). | `cargo run --example serdesai-sandboxed-bash --features linux-bubblewrap -p reloaded-code-serdesai` |
| [serdesai-run-hook] | Single `RunHook` injecting a preamble via `RunConfig`, integrated with SerdesAI agent pipeline. | `cargo run --example serdesai-run-hook -p reloaded-code-serdesai --features mock` |
| [serdesai-run-chain] | Two `RunHook`s showing nesting order in the integrated SerdesAI agent pipeline. | `cargo run --example serdesai-run-chain -p reloaded-code-serdesai --features mock` |

[serdesai-basic]: https://github.com/Reloaded-Project/ReloadedCode/blob/main/src/reloaded-code-serdesai/examples/serdesai-basic.rs
[serdesai-agents]: https://github.com/Reloaded-Project/ReloadedCode/blob/main/src/reloaded-code-serdesai/examples/serdesai-agents.rs
Expand All @@ -21,6 +23,8 @@ Runnable examples live in the repository under each crate's `examples/` director
[serdesai-task]: https://github.com/Reloaded-Project/ReloadedCode/blob/main/src/reloaded-code-serdesai/examples/serdesai-task.rs
[serdesai-sandboxed]: https://github.com/Reloaded-Project/ReloadedCode/blob/main/src/reloaded-code-serdesai/examples/serdesai-sandboxed.rs
[serdesai-sandboxed-bash]: https://github.com/Reloaded-Project/ReloadedCode/blob/main/src/reloaded-code-serdesai/examples/serdesai-sandboxed-bash.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-chain]: https://github.com/Reloaded-Project/ReloadedCode/blob/main/src/reloaded-code-serdesai/examples/hooks/run/serdesai-run-chain.rs

## Core Library

Expand Down
201 changes: 185 additions & 16 deletions src/docs/src/hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,26 @@

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

!!! warning "Work in progress"
Backend wiring is not done yet. Core hooks, event types, and container
exist. [SerdesAI] dispatch code comes next.
Tool and run hooks are wired into the [SerdesAI] agent pipeline: registered
hooks intercept real tool calls and agent runs end to end.

Tool hooks work like game mods.
Each hook gets an `original` function.
`original` calls the next hook or the real tool.

This lets you run code before and after the tool call in the same method.

## Example
## Examples

A hook can modify the request before the tool sees it.
### Observe and wrap a tool call

A hook can modify the request before the tool sees it, or rewrite the
result after the real tool runs.

The full example takes the result path: a real `read` runs, and the
hook scrubs the `API_KEY=` and `TOKEN=` values from the result before
the model sees them. Permission rules can allow or deny the call; they
cannot rewrite what the tool returns.

`$HOME` in string arguments expands to the user's home directory:

Expand Down Expand Up @@ -57,8 +64,18 @@ let hooks = HookSet::builder()
.build();
```

Full example: [serdesai-tool-hook]
(`cargo run --example serdesai-tool-hook -p reloaded-code-serdesai --features mock`).

### Block a tool call

To block or replace a tool call, do not call `original`.

The full example keeps state across calls: the hook records every file
the run reads, then denies a `write` to a file that was never read, so
the real `write` never executes. Permission rules cannot make a
decision depend on earlier calls.

A common case: prevent credential leaks by blocking read/write access
to `.env` files.

Expand Down Expand Up @@ -98,6 +115,138 @@ let hooks = HookSet::builder()
.build();
```

Full example: [serdesai-tool-block]
(`cargo run --example serdesai-tool-block -p reloaded-code-serdesai --features mock`).

### Stack hooks

Hooks run in registration order. Each hook wraps the next one, so code after
`original.call(...)` runs in reverse order.

The full example stacks an audit hook that logs the original `bash`
arguments with a hardening hook that injects a `timeout_ms` before the
real tool runs.

`tool_hook` takes ownership of the hook. `shared_tool_hook` registers an
existing `Arc<dyn ToolHook>` when the same instance must be used in several
hook sets:

```rust
use std::sync::Arc;
use reloaded_code_core::{
HookSet, ToolCallContext, ToolHook, ToolHookFuture, ToolOriginal, ToolRequest,
};

struct AuditHook(&'static str);

impl ToolHook for AuditHook {
fn hook<'a>(
&'a self,
ctx: &'a ToolCallContext<'a>,
req: ToolRequest,
original: ToolOriginal<'a>,
) -> ToolHookFuture<'a> {
Box::pin(async move {
println!("{}: before {}", self.0, ctx.tool_name);
let output = original.call(ctx, req).await?;
println!("{}: after {}", self.0, ctx.tool_name);
Ok(output)
})
}
}

let shared: Arc<dyn ToolHook> = Arc::new(AuditHook("inner"));
let hooks = HookSet::builder()
.tool_hook(AuditHook("outer"))
.shared_tool_hook(shared)
.build();
```

Full example: [serdesai-tool-chain]
(`cargo run --example serdesai-tool-chain -p reloaded-code-serdesai --features mock`).

### Intercept a run

Run hooks wrap the whole agent run. Mutate `RunConfig` to change the system
prompt, preambles, or parameters, then call `original` to continue:

```rust
use reloaded_code_core::{
HookRunContext, HookSet, PreambleMessage, PreambleRole, RunConfig, RunHook,
RunHookFuture, RunOriginal,
};

struct PreambleInjector;

impl RunHook for PreambleInjector {
fn hook<'a>(
&'a self,
ctx: &'a HookRunContext<'a>,
mut config: RunConfig,
original: RunOriginal<'a>,
) -> RunHookFuture<'a> {
Box::pin(async move {
config.preamble_messages.push(PreambleMessage {
role: PreambleRole::System,
content: "You are a helpful assistant.".into(),
});
original.call(ctx, config).await
})
}
}

let hooks = HookSet::builder()
.run_hook(PreambleInjector)
.build();
```

Full example: [serdesai-run-hook]
(`cargo run --example serdesai-run-hook -p reloaded-code-serdesai --features mock`).

### Observe run start and end

A `RunHook` observes without changing anything: log before calling
`original`, inspect the result after:

```rust
use reloaded_code_core::{
EndReason, HookRunContext, HookSet, RunConfig, RunHook, RunHookFuture,
RunOriginal,
};

struct RunObserver;

impl RunHook for RunObserver {
fn hook<'a>(
&'a self,
ctx: &'a HookRunContext<'a>,
config: RunConfig,
original: RunOriginal<'a>,
) -> RunHookFuture<'a> {
Box::pin(async move {
println!("run starting for {}", ctx.agent_name);
let result = original.call(ctx, config).await;
let reason = match &result {
Ok(output) => output.reason,
Err(_) => EndReason::Failed,
};
println!("run ended for {} ({:?})", ctx.agent_name, reason);
result
})
}
}

let hooks = HookSet::builder()
.run_hook(RunObserver)
.build();
```

Code after `original` runs when the wrapped continuation finishes,
including failure: a failed run reports `EndReason::Failed` and the
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.

## Available types

### Tool hook types
Expand All @@ -111,12 +260,24 @@ let hooks = HookSet::builder()
| [`ToolRequest`] | JSON arguments carried through the hook chain. |
| [`ToolOutput`] | Tool call result wrapping content and truncation metadata. |

### Run hook types

| Type | Purpose |
| ----------------- | ------------------------------------------------------------ |
| [`RunHook`] | Intercepts a run and may call [`RunOriginal`]. |
| [`RunOriginal`] | Pointer to next hook or the real run executor. |
| [`RunHookFuture`] | Boxed future returned by run hooks. |
| [`RunConfig`] | Mutable config a RunHook can change before calling original. |
| [`RunOutput`] | Framework-agnostic result of a completed run. |
| [`RunExecutor`] | Final callable used at the end of the run hook chain. |
| [`RunUsage`] | Token usage for a completed run. |

### Container types

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

## How tool hooks stack

Expand Down Expand Up @@ -166,15 +327,12 @@ passes `HookSet::default()`.

## Design notes

- **Tool hooks, not before/after events.** A tool call has one action with
a function to call in the middle. Hooks fit better than events here.

- **Lifecycle events.** Session start/end/compact have no result to wrap.
They stay as simple callbacks.
They tell you something happened.
- **Everything is a hook**: Observers are plain `RunHook`s. Code before
`original` is "start", code after is "end". They participate in the
same hook chain with the same ordering rules.

- **Natural unwind order.** Hook code after `original.call(...)` runs in
reverse order. Later hooks run first after the tool.
reverse order. Later hooks run first after the operation.

- **Blocking by omission.** A hook blocks or replaces a call by not calling
`original`.
Expand All @@ -191,4 +349,15 @@ passes `HookSet::default()`.
[`ToolOutput`]: https://docs.rs/reloaded-code-core/latest/reloaded_code_core/struct.ToolOutput.html
[`HookSet`]: https://docs.rs/reloaded-code-core/latest/reloaded_code_core/struct.HookSet.html
[`HookSetBuilder`]: https://docs.rs/reloaded-code-core/latest/reloaded_code_core/struct.HookSetBuilder.html
[`RunHook`]: https://docs.rs/reloaded-code-core/latest/reloaded_code_core/trait.RunHook.html
[`RunOriginal`]: https://docs.rs/reloaded-code-core/latest/reloaded_code_core/struct.RunOriginal.html
[`RunHookFuture`]: https://docs.rs/reloaded-code-core/latest/reloaded_code_core/type.RunHookFuture.html
[`RunConfig`]: https://docs.rs/reloaded-code-core/latest/reloaded_code_core/struct.RunConfig.html
[`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
[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
54 changes: 37 additions & 17 deletions src/reloaded-code-agents/src/runtime/builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ impl AgentRuntimeBuilder {
self
}

/// Sets the hook set for tool interception and session lifecycle.
/// Sets the hook set for tool interception and run lifecycle.
#[inline]
pub fn hooks(mut self, hooks: HookSet) -> Self {
self.hooks = hooks;
Expand Down Expand Up @@ -181,21 +181,19 @@ mod tests {
}

#[test]
fn builder_overrides_task_settings() -> TestResult {
let runtime = AgentRuntimeBuilder::new().max_task_depth(5).build()?;

assert_eq!(runtime.task_settings(), TaskSettings::with_max_depth(5));
Ok(())
}

#[test]
fn builder_defaults_to_empty_catalog_defaults_and_default_tools() -> TestResult {
fn builder_defaults_and_overrides() -> TestResult {
// default: empty catalog, default AgentDefaults, default task settings, default tools
let runtime = AgentRuntimeBuilder::new().build()?;

assert_eq!(runtime.catalog().iter().count(), 0);
assert_eq!(runtime.defaults(), &AgentDefaults::default());
assert_eq!(runtime.task_settings(), TaskSettings::default());
assert_eq!(runtime.tools(), default_tools().as_slice());

// override: max_task_depth(5) replaces default task settings
let runtime = AgentRuntimeBuilder::new().max_task_depth(5).build()?;

assert_eq!(runtime.task_settings(), TaskSettings::with_max_depth(5));
Ok(())
}

Expand Down Expand Up @@ -307,17 +305,39 @@ mod tests {
}

#[test]
fn builder_default_hooks_are_empty() -> TestResult {
fn builder_hooks() -> TestResult {
// default: no hooks registered
let runtime = AgentRuntimeBuilder::new().build()?;
assert!(runtime.hooks().is_empty());
Ok(())
}

#[test]
fn builder_hooks_sets_hook_set() -> TestResult {
let custom_hooks = HookSet::builder().build();
let runtime = AgentRuntimeBuilder::new().hooks(custom_hooks).build()?;
// explicit empty HookSet stays empty
let runtime = AgentRuntimeBuilder::new()
.hooks(HookSet::builder().build())
.build()?;
assert!(runtime.hooks().is_empty());

// run hook survives build and populates hook set
struct NoopRun;
impl reloaded_code_core::RunHook for NoopRun {
fn hook<'a>(
&'a self,
ctx: &'a reloaded_code_core::HookRunContext<'a>,
config: reloaded_code_core::RunConfig,
original: reloaded_code_core::RunOriginal<'a>,
) -> reloaded_code_core::RunHookFuture<'a> {
original.call(ctx, config)
}
}

let runtime = AgentRuntimeBuilder::new()
.hooks(
reloaded_code_core::HookSet::builder()
.run_hook(NoopRun)
.build(),
)
.build()?;
assert!(!runtime.hooks().is_empty());
assert!(!runtime.hooks().run_hooks_is_empty());
Ok(())
}
}
Loading
Loading