Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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 docs/mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ nav:
- Getting Started: getting-started.md
- Tools: tools.md
- Agents: agents.md
- Hooks: hooks.md
- Examples: examples.md
- Sandboxing: sandboxing.md
- Feature Flags: feature-flags.md
Expand Down
8 changes: 7 additions & 1 deletion docs/src/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ The foundation. Contains every tool implementation as a plain function
`CustomToolRegistry`, and `ToolCatalogEntry` types
- **Credential resolver** - API key lookup with override support ([details](getting-started.md#credential-management))
- **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](hooks).

Core is **framework-agnostic**: it has no dependencies on any specific LLM
framework. Your integration layer wraps these functions into framework-specific
Expand All @@ -50,7 +54,9 @@ Loads agent definitions from markdown files with YAML frontmatter. Provides:
- **AgentLoader** - scans directories for `.md` agent files
- **AgentCatalog** - name-to-config lookup table
- **AgentRuntime** - bundles catalog + defaults + permissions + task settings
- **AgentRuntimeBuilder** - accepts core tool catalogs and custom tool factories
+ the registered `HookSet`
- **AgentRuntimeBuilder** - accepts core tool catalogs, custom tool factories,
and a `HookSet` (`.hooks(set)`)

The agent file format mirrors [OpenCode]'s schema - similar enough that many
files are drop-in compatible, but [not identical](migration.md). The most
Expand Down
194 changes: 194 additions & 0 deletions docs/src/hooks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
# Hooks

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 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

A hook can modify the request before the tool sees it.

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

```rust
use std::env;
use serde_json::Value;
use reloaded_code_core::{
HookSet, ToolCallContext, ToolHook, ToolHookFuture, ToolOriginal,
ToolOutput, ToolRequest,
};

struct HomeExpander;

impl ToolHook for HomeExpander {
fn hook<'a>(
&'a self,
ctx: &'a ToolCallContext<'a>,
mut req: ToolRequest,
original: ToolOriginal<'a>,
) -> ToolHookFuture<'a> {
Box::pin(async move {
// Expand $HOME → real home directory in all string args
if let Some(home) = env::var("HOME").ok() {
if let Some(map) = req.args.as_object_mut() {
for val in map.values_mut() {
if let Value::String(s) = val {
*s = s.replace("$HOME", &home);
}
}
}
}

original.call(ctx, req).await
})
}
}

let hooks = HookSet::builder()
.tool_hook(HomeExpander)
.build();
```

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

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

```rust
use reloaded_code_core::{
HookSet, ToolCallContext, ToolHook, ToolHookFuture, ToolOriginal,
ToolOutput, ToolRequest,
};

struct EnvFileGuard;

impl ToolHook for EnvFileGuard {
fn hook<'a>(
&'a self,
ctx: &'a ToolCallContext<'a>,
req: ToolRequest,
original: ToolOriginal<'a>,
) -> ToolHookFuture<'a> {
Box::pin(async move {
// Block access to .env files
if let Some(path) = req.args.get("path").and_then(|v| v.as_str()) {
if path.starts_with(".env") || path.contains("/.env") {
return Ok(ToolOutput::new(
"Blocked: .env files contain secrets"
));
}
}

// All other calls pass through
original.call(ctx, req).await
})
}
}

let hooks = HookSet::builder()
.tool_hook(EnvFileGuard)
.build();
```

## Available types

### Tool hook types

| Type | Purpose |
| ------------------- | ---------------------------------------------------------- |
| [`ToolHook`] | Intercepts a tool call and may call [`ToolOriginal`]. |
| [`ToolOriginal`] | Pointer to next hook or the real tool. |
| [`ToolHookFuture`] | Boxed future returned by tool hooks. |
| [`ToolCallContext`] | Tool name, agent name, run id. |
| [`ToolRequest`] | JSON arguments carried through the hook chain. |
| [`ToolOutput`] | Tool call result wrapping content and truncation metadata. |

### Container types

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

## How tool hooks stack

This diagram assumes you register two hooks. If you set no hooks, the
tool call goes straight to the real tool (fast path).

Tool hooks run in registration order. Each hook receives an `original`
function. That function calls the next hook or the real tool.

```mermaid
flowchart TD
TC[Tool call] --> H1[Hook 1]
H1 -->|"original.call(ctx, req)"| H2[Hook 2]
H1 -->|"skip original"| Done[Result to agent]
H2 -->|"original.call(ctx, req)"| Exec[Real tool]
H2 -->|"skip original"| H1A
Exec --> H2A[Hook 2 after]
H2A --> H1A[Hook 1 after]
H1A --> Done
```
This works like game mods.
Your hook gets a function.
That function is `original`.
It calls the next hook, not always the real tool.

## Getting started

Build a `HookSet` with your hooks, then pass it to the runtime:

```rust
use reloaded_code_agents::AgentRuntimeBuilder;
use reloaded_code_core::HookSet;

let hooks = HookSet::builder()
.tool_hook(EnvFileGuard)
.build();

let runtime = AgentRuntimeBuilder::new()
.hooks(hooks)
.build()?;

assert!(!runtime.hooks().is_empty());
```

Calling `.hooks(set)` replaces any existing `HookSet`. Omitting it
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.

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

- **Blocking by omission.** A hook blocks or replaces a call by not calling
`original`.

- **Empty fast path.** `dispatch_tool` calls the real tool directly when you
set no hooks.


[`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
[`ToolHookFuture`]: https://docs.rs/reloaded-code-core/latest/reloaded_code_core/type.ToolHookFuture.html
[`ToolCallContext`]: https://docs.rs/reloaded-code-core/latest/reloaded_code_core/struct.ToolCallContext.html
[`ToolRequest`]: https://docs.rs/reloaded-code-core/latest/reloaded_code_core/struct.ToolRequest.html
[`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
[SerdesAI]: https://crates.io/crates/serdes-ai
8 changes: 6 additions & 2 deletions docs/src/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,10 @@ Shell sandboxing. Default-deny permissions. ~10 MiB footprint.
<h3>🧩 Embeddable</h3>
<p>Framework-agnostic core. Use the <a href="https://crates.io/crates/serdes-ai">SerdesAI</a> integration or build your own with the core primitives.</p>
</div>
<div class="feature-card">
<h3>🪝 Hooks</h3>
<p>Block, transform, observe tool calls and sessions. See <a href="hooks">Hooks</a>.</p>
</div>
</div>

## Quick Start
Expand Down Expand Up @@ -189,7 +193,7 @@ dependency setup and an alternate path without agent files.
</div>
<div class="crate-card">
<h3><a href="https://crates.io/crates/reloaded-code-agents">agents</a></h3>
<p>Load agent markdown files based on <a href="https://opencode.ai/docs/schemas/agent">OpenCode's schema</a> into a typed catalog. Default-deny permissions with granular path matching.</p>
<p>Load agent markdown files based on <a href="https://opencode.ai/docs/schemas/agent">OpenCode's schema</a> into a typed catalog. Default-deny permissions with granular path matching. <a href="hooks">Hook container</a> attached to the runtime for tool interception and session lifecycle.</p>
</div>
<div class="crate-card">
<h3><a href="https://crates.io/crates/reloaded-code-serdesai">serdesai</a></h3>
Expand Down Expand Up @@ -243,4 +247,4 @@ TUI: 679 MiB RSS">~305 MiB</abbr></td><td><abbr title="~13 MiB RSS on release bu

See [Comparison with OpenCode](comparison.md) for a deeper breakdown.

[OpenCode]: https://opencode.ai/
[OpenCode]: https://opencode.ai/
27 changes: 27 additions & 0 deletions src/reloaded-code-agents/src/runtime/builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
use super::state::{AgentDefaults, AgentRuntime};
use crate::AgentCatalog;
use reloaded_code_core::permissions::ExpandError;
use reloaded_code_core::HookSet;
use reloaded_code_core::{
default_tools, CustomToolRegistry, SharedToolRegistry, TaskSettings, ToolCatalogEntry,
ToolFactory,
Expand All @@ -16,6 +17,7 @@ pub struct AgentRuntimeBuilder {
task_settings: TaskSettings,
tools: Vec<ToolCatalogEntry>,
custom_tool_registry: CustomToolRegistry,
hooks: HookSet,
}

impl Default for AgentRuntimeBuilder {
Expand All @@ -35,6 +37,7 @@ impl AgentRuntimeBuilder {
task_settings: TaskSettings::default(),
tools: default_tools(),
custom_tool_registry: CustomToolRegistry::new(),
hooks: HookSet::default(),
}
}

Expand Down Expand Up @@ -83,6 +86,13 @@ impl AgentRuntimeBuilder {
self
}

/// Sets the hook set for tool interception and session lifecycle.
#[inline]
pub fn hooks(mut self, hooks: HookSet) -> Self {
self.hooks = hooks;
self
}

/// Finishes building and returns the [`AgentRuntime`].
///
/// # Errors
Expand All @@ -95,6 +105,7 @@ impl AgentRuntimeBuilder {
self.task_settings,
self.tools,
SharedToolRegistry::from_registry(self.custom_tool_registry),
self.hooks,
)
}
}
Expand All @@ -108,6 +119,7 @@ mod tests {
use reloaded_code_core::context::{ToolContext, ToolPrompt};
use reloaded_code_core::permissions::{ExpandError, PermissionAction};
use reloaded_code_core::tool_metadata::{glob as glob_meta, read as read_meta};
use reloaded_code_core::HookSet;
use reloaded_code_core::{
default_tools, CustomTool, CustomToolDefinition, CustomToolFuture, TaskSettings,
ToolBuildContext, ToolCatalogEntry, ToolCatalogKind, ToolFactory, ToolOutput, ToolResult,
Expand Down Expand Up @@ -290,4 +302,19 @@ mod tests {
assert_eq!(factory.unwrap().name(), "stub");
Ok(())
}

#[test]
fn builder_default_hooks_are_empty() -> TestResult {
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()?;
assert!(runtime.hooks().is_empty());
Ok(())
Comment thread
Sewer56 marked this conversation as resolved.
}
}
10 changes: 10 additions & 0 deletions src/reloaded-code-agents/src/runtime/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ use super::task::{build_runtime_task_caches, TaskTargetSummary};
use crate::{AgentCatalog, RulesetExt};
use ahash::AHashMap;
use reloaded_code_core::permissions::{ExpandError, Ruleset};
use reloaded_code_core::HookSet;
use reloaded_code_core::{SharedToolRegistry, TaskSettings, ToolCatalogEntry};
use std::sync::Arc;

Expand Down Expand Up @@ -43,6 +44,7 @@ pub struct AgentRuntime {
task_settings: TaskSettings,
tools: Vec<ToolCatalogEntry>,
custom_tool_registry: SharedToolRegistry,
hooks: HookSet,
permission_rulesets: AHashMap<String, Arc<Ruleset>>,
allowed_tools_by_caller: AHashMap<String, Vec<ToolCatalogEntry>>,
callable_target_summaries_by_caller: AHashMap<String, Vec<TaskTargetSummary>>,
Expand All @@ -56,6 +58,7 @@ impl AgentRuntime {
task_settings: TaskSettings,
tools: Vec<ToolCatalogEntry>,
custom_tool_registry: SharedToolRegistry,
hooks: HookSet,
) -> Result<Self, ExpandError> {
let permission_rulesets = catalog
.iter()
Expand All @@ -75,6 +78,7 @@ impl AgentRuntime {
task_settings,
tools,
custom_tool_registry,
hooks,
permission_rulesets,
allowed_tools_by_caller,
callable_target_summaries_by_caller,
Expand Down Expand Up @@ -111,6 +115,12 @@ impl AgentRuntime {
&self.custom_tool_registry
}

/// Returns the registered hooks.
#[inline]
pub fn hooks(&self) -> &HookSet {
&self.hooks
}

/// Returns the cached permission ruleset for the named caller.
///
/// The returned [`Arc`] is cheap to clone and reuses the ruleset built when
Expand Down
Loading
Loading