From 66b19696e06136b181f533c8af6ab791f7f817ef Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Sat, 6 Jun 2026 16:04:34 +0100 Subject: [PATCH 1/3] Added: Hook system for tool interception and session lifecycle Add hooks module in reloaded-code-core with game-style tool hooks, HookSet/HookSetBuilder container, and session lifecycle events. - ToolHook trait with ToolOriginal trampoline for chain dispatch - HookSet wraps tool hooks + session start/end/compact events - HookSetBuilder for convenient construction - AgentRuntimeBuilder::hooks() to attach HookSet to runtime - Empty fast path: dispatch_tool calls real tool directly when no hooks - SessionContext, EndReason, event callback type aliases - Full docs (hooks.md) with examples and architecture diagram Also fix clippy::unwrap_used in permissions test helper. --- docs/mkdocs.yml | 1 + docs/src/architecture.md | 8 +- docs/src/hooks.md | 201 ++++++++++++ docs/src/index.md | 8 +- .../src/runtime/builder.rs | 27 ++ src/reloaded-code-agents/src/runtime/state.rs | 10 + src/reloaded-code-core/src/hooks/builder.rs | 129 ++++++++ src/reloaded-code-core/src/hooks/hook_set.rs | 304 ++++++++++++++++++ src/reloaded-code-core/src/hooks/mod.rs | 39 +++ src/reloaded-code-core/src/hooks/session.rs | 50 +++ src/reloaded-code-core/src/hooks/tool_hook.rs | 169 ++++++++++ src/reloaded-code-core/src/lib.rs | 2 + src/reloaded-code-core/src/permissions.rs | 10 +- 13 files changed, 951 insertions(+), 7 deletions(-) create mode 100644 docs/src/hooks.md create mode 100644 src/reloaded-code-core/src/hooks/builder.rs create mode 100644 src/reloaded-code-core/src/hooks/hook_set.rs create mode 100644 src/reloaded-code-core/src/hooks/mod.rs create mode 100644 src/reloaded-code-core/src/hooks/session.rs create mode 100644 src/reloaded-code-core/src/hooks/tool_hook.rs diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index fe484e1..4319914 100755 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -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 diff --git a/docs/src/architecture.md b/docs/src/architecture.md index 9d8f449..620aebc 100644 --- a/docs/src/architecture.md +++ b/docs/src/architecture.md @@ -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 @@ -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 diff --git a/docs/src/hooks.md b/docs/src/hooks.md new file mode 100644 index 0000000..627f256 --- /dev/null +++ b/docs/src/hooks.md @@ -0,0 +1,201 @@ +# 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 +[`ToolExecutor`]: https://docs.rs/reloaded-code-core/latest/reloaded_code_core/trait.ToolExecutor.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 +[`SessionContext`]: https://docs.rs/reloaded-code-core/latest/reloaded_code_core/struct.SessionContext.html +[`EndReason`]: https://docs.rs/reloaded-code-core/latest/reloaded_code_core/enum.EndReason.html +[`SessionStartFn`]: https://docs.rs/reloaded-code-core/latest/reloaded_code_core/type.SessionStartFn.html +[`SessionEndFn`]: https://docs.rs/reloaded-code-core/latest/reloaded_code_core/type.SessionEndFn.html +[`SessionCompactFn`]: https://docs.rs/reloaded-code-core/latest/reloaded_code_core/type.SessionCompactFn.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 +[`AgentRuntime`]: https://docs.rs/reloaded-code-agents/latest/reloaded_code_agents/runtime/struct.AgentRuntime.html +[SerdesAI]: https://crates.io/crates/serdes-ai diff --git a/docs/src/index.md b/docs/src/index.md index 6f1de5b..e8948f8 100644 --- a/docs/src/index.md +++ b/docs/src/index.md @@ -105,6 +105,10 @@ Shell sandboxing. Default-deny permissions. ~10 MiB footprint.

🧩 Embeddable

Framework-agnostic core. Use the SerdesAI integration or build your own with the core primitives.

+
+

🪝 Hooks

+

Block, transform, observe tool calls and sessions. See Hooks.

+
## Quick Start @@ -189,7 +193,7 @@ dependency setup and an alternate path without agent files.

agents

-

Load agent markdown files based on OpenCode's schema into a typed catalog. Default-deny permissions with granular path matching.

+

Load agent markdown files based on OpenCode's schema into a typed catalog. Default-deny permissions with granular path matching. Hook container attached to the runtime for tool interception and session lifecycle.

serdesai

@@ -243,4 +247,4 @@ TUI: 679 MiB RSS">~305 MiB 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(()) + } } diff --git a/src/reloaded-code-agents/src/runtime/state.rs b/src/reloaded-code-agents/src/runtime/state.rs index 903a0bf..e042352 100644 --- a/src/reloaded-code-agents/src/runtime/state.rs +++ b/src/reloaded-code-agents/src/runtime/state.rs @@ -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; @@ -43,6 +44,7 @@ pub struct AgentRuntime { task_settings: TaskSettings, tools: Vec, custom_tool_registry: SharedToolRegistry, + hooks: HookSet, permission_rulesets: AHashMap>, allowed_tools_by_caller: AHashMap>, callable_target_summaries_by_caller: AHashMap>, @@ -56,6 +58,7 @@ impl AgentRuntime { task_settings: TaskSettings, tools: Vec, custom_tool_registry: SharedToolRegistry, + hooks: HookSet, ) -> Result { let permission_rulesets = catalog .iter() @@ -75,6 +78,7 @@ impl AgentRuntime { task_settings, tools, custom_tool_registry, + hooks, permission_rulesets, allowed_tools_by_caller, callable_target_summaries_by_caller, @@ -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 diff --git a/src/reloaded-code-core/src/hooks/builder.rs b/src/reloaded-code-core/src/hooks/builder.rs new file mode 100644 index 0000000..7e88fc4 --- /dev/null +++ b/src/reloaded-code-core/src/hooks/builder.rs @@ -0,0 +1,129 @@ +//! HookSetBuilder — builder for constructing a [`HookSet`]. + +use crate::hooks::{HookSet, SessionCompactFn, SessionEndFn, SessionStartFn, ToolHook, INLINE_CAP}; +use std::fmt; +use std::sync::Arc; +use tinyvec::ArrayVec; + +/// Builder for constructing [`HookSet`]. +#[derive(Default)] +pub struct HookSetBuilder { + pub(super) tool_hooks: Vec>, + pub(super) session_start: ArrayVec<[Option; INLINE_CAP]>, + pub(super) session_end: ArrayVec<[Option; INLINE_CAP]>, + pub(super) session_compact: ArrayVec<[Option; INLINE_CAP]>, +} + +impl HookSetBuilder { + /// Creates a new, empty builder. + #[inline] + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Registers a game-style tool hook. + /// + /// Hooks run in registration order. Each hook's `original` handle calls + /// the next registered hook, or the real tool at the end of the chain. + #[inline] + #[must_use] + pub fn tool_hook(mut self, hook: impl ToolHook) -> Self { + self.tool_hooks.push(Arc::new(hook)); + self + } + + /// Registers an already shared game-style tool hook. + #[inline] + #[must_use] + pub fn shared_tool_hook(mut self, hook: Arc) -> Self { + self.tool_hooks.push(hook); + self + } + + /// Registers a session-start event. + #[inline] + #[must_use] + pub fn on_session_start(mut self, event: SessionStartFn) -> Self { + self.session_start.push(Some(event)); + self + } + + /// Registers a session-end event. + #[inline] + #[must_use] + pub fn on_session_end(mut self, event: SessionEndFn) -> Self { + self.session_end.push(Some(event)); + self + } + + /// Registers a session-compact event. + #[inline] + #[must_use] + pub fn on_session_compact(mut self, event: SessionCompactFn) -> Self { + self.session_compact.push(Some(event)); + self + } + + /// Builds the `HookSet` from the configured hooks. + #[inline] + #[must_use] + pub fn build(self) -> HookSet { + HookSet { + tool_hooks: self.tool_hooks, + session_start: self.session_start, + session_end: self.session_end, + session_compact: self.session_compact, + } + } +} + +impl fmt::Debug for HookSetBuilder { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("HookSetBuilder") + .field("tool_hooks", &self.tool_hooks.len()) + .field("session_start", &self.session_start.len()) + .field("session_end", &self.session_end.len()) + .field("session_compact", &self.session_compact.len()) + .finish() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::hooks::tool_hook::{ToolCallContext, ToolHookFuture, ToolOriginal, ToolRequest}; + + #[test] + fn hook_set_builder_new_produces_empty() { + let hooks = HookSetBuilder::new().build(); + assert!(hooks.is_empty()); + } + + #[test] + fn hook_set_builder_roundtrip() { + let hooks = HookSet::builder().build(); + assert!(hooks.is_empty()); + } + + #[test] + fn tool_hook_registration_makes_hook_set_non_empty() { + struct Noop; + + impl ToolHook for Noop { + fn hook<'a>( + &'a self, + ctx: &'a ToolCallContext<'a>, + req: ToolRequest, + original: ToolOriginal<'a>, + ) -> ToolHookFuture<'a> { + original.call(ctx, req) + } + } + + let hooks = HookSetBuilder::new().tool_hook(Noop).build(); + assert!(!hooks.is_empty()); + assert!(!hooks.tool_hooks_is_empty()); + assert_eq!(hooks.tool_hooks().len(), 1); + } +} diff --git a/src/reloaded-code-core/src/hooks/hook_set.rs b/src/reloaded-code-core/src/hooks/hook_set.rs new file mode 100644 index 0000000..29c0b04 --- /dev/null +++ b/src/reloaded-code-core/src/hooks/hook_set.rs @@ -0,0 +1,304 @@ +//! HookSet — container and dispatch for all registered hooks and lifecycle events. + +use crate::hooks::{ + EndReason, SessionCompactFn, SessionContext, SessionEndFn, SessionStartFn, ToolCallContext, + ToolExecutor, ToolHook, ToolHookFuture, ToolOriginal, ToolRequest, INLINE_CAP, +}; +use std::fmt; +use std::sync::Arc; +use tinyvec::ArrayVec; + +/// All registered hooks and lifecycle events, stored per point. +#[derive(Clone, Default)] +pub struct HookSet { + pub(super) tool_hooks: Vec>, + pub(super) session_start: ArrayVec<[Option; INLINE_CAP]>, + pub(super) session_end: ArrayVec<[Option; INLINE_CAP]>, + pub(super) session_compact: ArrayVec<[Option; INLINE_CAP]>, +} + +impl HookSet { + /// Returns `true` if no hooks are registered at any point. + #[inline] + #[must_use] + pub fn is_empty(&self) -> bool { + self.tool_hooks.is_empty() + && self.session_start.is_empty() + && self.session_end.is_empty() + && self.session_compact.is_empty() + } + + /// Returns `true` if no tool hooks are registered. + #[inline] + #[must_use] + pub fn tool_hooks_is_empty(&self) -> bool { + self.tool_hooks.is_empty() + } + + /// Returns registered tool hooks in dispatch order. + #[inline] + #[must_use] + pub fn tool_hooks(&self) -> &[Arc] { + &self.tool_hooks + } + + /// Returns a new builder for constructing a `HookSet`. + #[inline] + #[must_use] + pub fn builder() -> crate::hooks::builder::HookSetBuilder { + crate::hooks::builder::HookSetBuilder::new() + } + + /// Dispatches a tool call through the hook chain. + /// + /// If no tool hooks are registered, this calls the real tool directly. + #[inline] + pub fn dispatch_tool<'a>( + &'a self, + ctx: &'a ToolCallContext<'a>, + req: ToolRequest, + real_tool: &'a dyn ToolExecutor, + ) -> ToolHookFuture<'a> { + if self.tool_hooks.is_empty() { + return real_tool.execute(ctx, req); + } + + ToolOriginal::new(&self.tool_hooks, real_tool).call(ctx, req) + } + + /// Dispatches session-start events. + #[inline] + pub fn dispatch_session_start(&self, ctx: &SessionContext<'_>) { + for event in self.session_start.iter().flatten() { + event(ctx); + } + } + + /// Dispatches session-end events. + #[inline] + pub fn dispatch_session_end(&self, ctx: &SessionContext<'_>, reason: EndReason) { + for event in self.session_end.iter().flatten() { + event(ctx, reason); + } + } + + /// Dispatches session-compact events. + #[inline] + pub fn dispatch_session_compact(&self, ctx: &SessionContext<'_>) { + for event in self.session_compact.iter().flatten() { + event(ctx); + } + } +} + +impl fmt::Debug for HookSet { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("HookSet") + .field("tool_hooks", &self.tool_hooks.len()) + .field("session_start", &self.session_start.len()) + .field("session_end", &self.session_end.len()) + .field("session_compact", &self.session_compact.len()) + .finish() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ToolOutput; + use serde_json::json; + use std::sync::atomic::{AtomicUsize, Ordering}; + + fn ready(output: impl Into) -> ToolHookFuture<'static> { + let output = output.into(); + Box::pin(async move { Ok(output) }) + } + + #[test] + fn hook_set_default_is_empty() { + let hooks = HookSet::default(); + assert!(hooks.is_empty()); + assert!(hooks.tool_hooks_is_empty()); + } + + #[tokio::test] + async fn dispatch_tool_empty_calls_real_tool_directly() { + struct RealTool; + + impl ToolExecutor for RealTool { + fn execute<'a>( + &'a self, + _ctx: &'a ToolCallContext<'a>, + req: ToolRequest, + ) -> ToolHookFuture<'a> { + let content = req.args["value"].as_str().unwrap().to_string(); + Box::pin(async move { Ok(ToolOutput::new(content)) }) + } + } + + let hooks = HookSet::default(); + let ctx = ToolCallContext { + tool_name: "echo", + agent_name: "coder", + run_id: "r1", + }; + let output = hooks + .dispatch_tool(&ctx, ToolRequest::new(json!({"value": "ok"})), &RealTool) + .await + .unwrap(); + + assert_eq!(output.content, "ok"); + } + + #[tokio::test] + async fn dispatch_tool_hooks_wrap_real_tool() { + struct Prefix; + struct Suffix; + struct RealTool; + + impl ToolHook for Prefix { + fn hook<'a>( + &'a self, + ctx: &'a ToolCallContext<'a>, + mut req: ToolRequest, + original: ToolOriginal<'a>, + ) -> ToolHookFuture<'a> { + Box::pin(async move { + req.args["value"] = + json!(format!("pre-{}", req.args["value"].as_str().unwrap())); + let mut output = original.call(ctx, req).await?; + output.content.push_str("-post"); + Ok(output) + }) + } + } + + impl ToolHook for Suffix { + fn hook<'a>( + &'a self, + ctx: &'a ToolCallContext<'a>, + mut req: ToolRequest, + original: ToolOriginal<'a>, + ) -> ToolHookFuture<'a> { + Box::pin(async move { + req.args["value"] = + json!(format!("{}-inner", req.args["value"].as_str().unwrap())); + let mut output = original.call(ctx, req).await?; + output.content.push_str("-innerpost"); + Ok(output) + }) + } + } + + impl ToolExecutor for RealTool { + fn execute<'a>( + &'a self, + _ctx: &'a ToolCallContext<'a>, + req: ToolRequest, + ) -> ToolHookFuture<'a> { + let content = req.args["value"].as_str().unwrap().to_string(); + Box::pin(async move { Ok(ToolOutput::new(content)) }) + } + } + + let hooks = crate::hooks::builder::HookSetBuilder::new() + .tool_hook(Prefix) + .tool_hook(Suffix) + .build(); + let ctx = ToolCallContext { + tool_name: "echo", + agent_name: "coder", + run_id: "r1", + }; + let output = hooks + .dispatch_tool(&ctx, ToolRequest::new(json!({"value": "x"})), &RealTool) + .await + .unwrap(); + + assert_eq!(output.content, "pre-x-inner-innerpost-post"); + } + + #[tokio::test] + async fn dispatch_tool_hook_can_block_without_calling_original() { + struct Block; + struct RealTool; + + impl ToolHook for Block { + fn hook<'a>( + &'a self, + _ctx: &'a ToolCallContext<'a>, + _req: ToolRequest, + _original: ToolOriginal<'a>, + ) -> ToolHookFuture<'a> { + Box::pin(async { Ok(ToolOutput::new("blocked")) }) + } + } + + impl ToolExecutor for RealTool { + fn execute<'a>( + &'a self, + _ctx: &'a ToolCallContext<'a>, + _req: ToolRequest, + ) -> ToolHookFuture<'a> { + ready("should not run") + } + } + + let hooks = crate::hooks::builder::HookSetBuilder::new() + .tool_hook(Block) + .build(); + let ctx = ToolCallContext { + tool_name: "bash", + agent_name: "coder", + run_id: "r1", + }; + let output = hooks + .dispatch_tool(&ctx, ToolRequest::new(json!({})), &RealTool) + .await + .unwrap(); + + assert_eq!(output.content, "blocked"); + } + + #[test] + fn session_events_dispatch() { + static STARTS: AtomicUsize = AtomicUsize::new(0); + static ENDS: AtomicUsize = AtomicUsize::new(0); + static COMPACTS: AtomicUsize = AtomicUsize::new(0); + + fn on_start(_ctx: &SessionContext<'_>) { + STARTS.fetch_add(1, Ordering::SeqCst); + } + + fn on_end(_ctx: &SessionContext<'_>, reason: EndReason) { + assert_eq!(reason, EndReason::Completed); + ENDS.fetch_add(1, Ordering::SeqCst); + } + + fn on_compact(_ctx: &SessionContext<'_>) { + COMPACTS.fetch_add(1, Ordering::SeqCst); + } + + STARTS.store(0, Ordering::SeqCst); + ENDS.store(0, Ordering::SeqCst); + COMPACTS.store(0, Ordering::SeqCst); + + let hooks = crate::hooks::builder::HookSetBuilder::new() + .on_session_start(on_start) + .on_session_end(on_end) + .on_session_compact(on_compact) + .build(); + let ctx = SessionContext { + agent_name: "coder", + run_id: "r1", + }; + + hooks.dispatch_session_start(&ctx); + hooks.dispatch_session_end(&ctx, EndReason::Completed); + hooks.dispatch_session_compact(&ctx); + + assert_eq!(STARTS.load(Ordering::SeqCst), 1); + assert_eq!(ENDS.load(Ordering::SeqCst), 1); + assert_eq!(COMPACTS.load(Ordering::SeqCst), 1); + } +} diff --git a/src/reloaded-code-core/src/hooks/mod.rs b/src/reloaded-code-core/src/hooks/mod.rs new file mode 100644 index 0000000..992ce6b --- /dev/null +++ b/src/reloaded-code-core/src/hooks/mod.rs @@ -0,0 +1,39 @@ +//! Hook infrastructure for tool hooks and session lifecycle events. +//! +//! # Public API +//! +//! Tool hook types: +//! - [`ToolHook`] - Intercepts a tool call and may call [`ToolOriginal`] +//! - [`ToolHookFuture`] - Boxed future returned by [`ToolHook::hook`] +//! - [`ToolOriginal`] - Managed trampoline to the next hook or real tool +//! - [`ToolCallContext`] - Tool name, agent name, and run id +//! - [`ToolRequest`] - JSON tool arguments +//! - [`ToolExecutor`] - Final callable used at the end of the hook chain +//! +//! Session event types: +//! - [`SessionContext`] - Context given to session lifecycle events +//! - [`EndReason`] - Why a session ended +//! +//! Container: +//! - [`HookSet`] - Container for registered hooks and lifecycle events +//! - [`HookSetBuilder`] - Builder for constructing [`HookSet`] +//! +//! # Design +//! +//! Tool hooks follow game-style hook semantics. Each hook receives an +//! `original` handle. Calling it invokes the next hook in the chain, or the +//! real tool when the chain is exhausted. Not calling it blocks or replaces the +//! tool call. Session hooks remain simple lifecycle events. + +mod builder; +mod hook_set; +mod session; +mod tool_hook; + +pub use self::builder::HookSetBuilder; +pub use self::hook_set::HookSet; +pub use self::session::*; +pub use self::tool_hook::*; + +/// Max hooks per point before falling back to heap. +pub(crate) const INLINE_CAP: usize = 3; diff --git a/src/reloaded-code-core/src/hooks/session.rs b/src/reloaded-code-core/src/hooks/session.rs new file mode 100644 index 0000000..ad4c8de --- /dev/null +++ b/src/reloaded-code-core/src/hooks/session.rs @@ -0,0 +1,50 @@ +//! Session lifecycle event types. + +/// Why a session ended. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EndReason { + /// Session completed normally. + Completed, + /// Session was stopped externally. + Stopped, +} + +/// Context given to session lifecycle events. +#[derive(Debug)] +pub struct SessionContext<'a> { + /// Name of the agent running the session. + pub agent_name: &'a str, + /// Unique identifier for the current run. + pub run_id: &'a str, +} + +/// Session-start event callback. +pub type SessionStartFn = for<'a> fn(&'a SessionContext<'a>); + +/// Session-end event callback. +pub type SessionEndFn = for<'a> fn(&'a SessionContext<'a>, EndReason); + +/// Session-compact event callback. +pub type SessionCompactFn = for<'a> fn(&'a SessionContext<'a>); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn session_context_fields_are_accessible() { + let ctx = SessionContext { + agent_name: "orchestrator", + run_id: "r3", + }; + assert_eq!(ctx.agent_name, "orchestrator"); + assert_eq!(ctx.run_id, "r3"); + } + + #[test] + fn end_reason_variants_exist() { + assert_eq!(EndReason::Completed, EndReason::Completed); + assert_eq!(EndReason::Stopped, EndReason::Stopped); + assert_ne!(EndReason::Completed, EndReason::Stopped); + } +} diff --git a/src/reloaded-code-core/src/hooks/tool_hook.rs b/src/reloaded-code-core/src/hooks/tool_hook.rs new file mode 100644 index 0000000..c45e98f --- /dev/null +++ b/src/reloaded-code-core/src/hooks/tool_hook.rs @@ -0,0 +1,169 @@ +//! Tool hook types -- traits, futures, and chain trampoline. + +use crate::{ToolOutput, ToolResult}; +use serde_json::Value; +use std::fmt; +use std::future::Future; +use std::pin::Pin; +use std::sync::Arc; + +/// Boxed future returned by [`ToolHook::hook`] and [`ToolExecutor::execute`]. +pub type ToolHookFuture<'a> = Pin> + Send + 'a>>; + +/// Context passed to each tool hook. +#[derive(Debug)] +pub struct ToolCallContext<'a> { + /// Name of the tool being called. + pub tool_name: &'static str, + /// Name of the agent making the call. + pub agent_name: &'a str, + /// Unique identifier for the current run. + pub run_id: &'a str, +} + +/// Request passed through the tool hook chain. +#[derive(Debug, Clone, PartialEq)] +pub struct ToolRequest { + /// JSON arguments passed to the tool. + pub args: Value, +} + +impl ToolRequest { + /// Creates a request from JSON arguments. + #[inline] + #[must_use] + pub fn new(args: Value) -> Self { + Self { args } + } +} + +impl From for ToolRequest { + #[inline] + fn from(args: Value) -> Self { + Self::new(args) + } +} + +/// Final callable used when the hook chain reaches the real tool. +pub trait ToolExecutor: Send + Sync { + /// Executes the real tool. + fn execute<'a>(&'a self, ctx: &'a ToolCallContext<'a>, req: ToolRequest) -> ToolHookFuture<'a>; +} + +impl ToolExecutor for F +where + F: for<'a> Fn(&'a ToolCallContext<'a>, ToolRequest) -> ToolHookFuture<'a> + Send + Sync, +{ + #[inline] + fn execute<'a>(&'a self, ctx: &'a ToolCallContext<'a>, req: ToolRequest) -> ToolHookFuture<'a> { + self(ctx, req) + } +} + +/// Game-style tool hook. +/// +/// A hook may inspect or change the request, call [`ToolOriginal::call`] to +/// continue, inspect or change the response, or skip `original` entirely to +/// block/replace the tool call. +pub trait ToolHook: Send + Sync + 'static { + /// Intercepts a tool call. + fn hook<'a>( + &'a self, + ctx: &'a ToolCallContext<'a>, + req: ToolRequest, + original: ToolOriginal<'a>, + ) -> ToolHookFuture<'a>; +} + +impl ToolHook for F +where + F: for<'a> Fn(&'a ToolCallContext<'a>, ToolRequest, ToolOriginal<'a>) -> ToolHookFuture<'a> + + Send + + Sync + + 'static, +{ + #[inline] + fn hook<'a>( + &'a self, + ctx: &'a ToolCallContext<'a>, + req: ToolRequest, + original: ToolOriginal<'a>, + ) -> ToolHookFuture<'a> { + self(ctx, req, original) + } +} + +/// Managed trampoline to the next hook or real tool. +/// +/// `ToolOriginal` is consumed by [`call`](Self::call), so normal hooks call +/// the continuation once. Hooks that intentionally retry can clone the +/// request before calling and perform retries around one continuation call. +pub struct ToolOriginal<'a> { + chain: &'a [Arc], + index: usize, + real_tool: &'a dyn ToolExecutor, +} + +impl<'a> ToolOriginal<'a> { + /// Creates a trampoline over the provided hook chain and real tool. + #[inline] + #[must_use] + pub fn new(chain: &'a [Arc], real_tool: &'a dyn ToolExecutor) -> Self { + Self { + chain, + index: 0, + real_tool, + } + } + + /// Calls the next hook, or the real tool when no hooks remain. + #[inline] + pub fn call(self, ctx: &'a ToolCallContext<'a>, req: ToolRequest) -> ToolHookFuture<'a> { + if let Some(hook) = self.chain.get(self.index) { + hook.hook( + ctx, + req, + Self { + chain: self.chain, + index: self.index + 1, + real_tool: self.real_tool, + }, + ) + } else { + self.real_tool.execute(ctx, req) + } + } +} + +impl fmt::Debug for ToolOriginal<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("ToolOriginal") + .field("chain_len", &self.chain.len()) + .field("index", &self.index) + .finish_non_exhaustive() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn tool_request_carries_args() { + let req = ToolRequest::new(json!({"path": "/tmp/x"})); + assert_eq!(req.args, json!({"path": "/tmp/x"})); + } + + #[test] + fn tool_call_context_fields_are_accessible() { + let ctx = ToolCallContext { + tool_name: "read_file", + agent_name: "planner", + run_id: "r1", + }; + assert_eq!(ctx.tool_name, "read_file"); + assert_eq!(ctx.agent_name, "planner"); + assert_eq!(ctx.run_id, "r1"); + } +} diff --git a/src/reloaded-code-core/src/lib.rs b/src/reloaded-code-core/src/lib.rs index 5cbacdb..240c19a 100644 --- a/src/reloaded-code-core/src/lib.rs +++ b/src/reloaded-code-core/src/lib.rs @@ -12,6 +12,7 @@ pub mod credentials; pub mod custom_tool; pub mod error; pub mod fs; +pub mod hooks; pub mod models; pub mod output; pub mod path; @@ -34,6 +35,7 @@ pub use custom_tool::{ ToolBuildContext, ToolFactory, ToolRunContext, }; pub use error::{ToolError, ToolResult}; +pub use hooks::*; pub use output::ToolOutput; pub use path::{AbsolutePathResolver, AllowedGlobResolver, AllowedPathResolver, PathResolver}; pub use system_prompt::SystemPromptBuilder; diff --git a/src/reloaded-code-core/src/permissions.rs b/src/reloaded-code-core/src/permissions.rs index c7fc23b..1d625ea 100644 --- a/src/reloaded-code-core/src/permissions.rs +++ b/src/reloaded-code-core/src/permissions.rs @@ -457,10 +457,12 @@ mod tests { ) -> PermissionAction { let mut ruleset = Ruleset::new(); for (perm, pat, action) in rules { - ruleset.push(Rule::new(*perm, *pat, *action).expect(&format!( - "failed to create Rule for permission {:?}, pattern {:?}, action {:?}", - perm, pat, action - ))); + ruleset.push(Rule::new(*perm, *pat, *action).unwrap_or_else(|_| { + panic!( + "failed to create Rule for permission {:?}, pattern {:?}, action {:?}", + perm, pat, action + ) + })); } ruleset.evaluate(permission, subject) } From dd359068afa6f8542eeb49529e6186dc08867466 Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Sat, 6 Jun 2026 16:43:33 +0100 Subject: [PATCH 2/3] Removed: Orphaned Markdown link reference definitions in hooks.md Remove 7 unused `[Identifier]:` definitions (ToolExecutor, SessionContext, EndReason, SessionStartFn, SessionEndFn, SessionCompactFn, AgentRuntime) that had no corresponding `[Identifier]` reference in the document body. Fixes MD053 (link-reference definitions should be used). --- docs/src/hooks.md | 7 ------- 1 file changed, 7 deletions(-) diff --git a/docs/src/hooks.md b/docs/src/hooks.md index 627f256..7f81132 100644 --- a/docs/src/hooks.md +++ b/docs/src/hooks.md @@ -186,16 +186,9 @@ passes `HookSet::default()`. [`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 -[`ToolExecutor`]: https://docs.rs/reloaded-code-core/latest/reloaded_code_core/trait.ToolExecutor.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 -[`SessionContext`]: https://docs.rs/reloaded-code-core/latest/reloaded_code_core/struct.SessionContext.html -[`EndReason`]: https://docs.rs/reloaded-code-core/latest/reloaded_code_core/enum.EndReason.html -[`SessionStartFn`]: https://docs.rs/reloaded-code-core/latest/reloaded_code_core/type.SessionStartFn.html -[`SessionEndFn`]: https://docs.rs/reloaded-code-core/latest/reloaded_code_core/type.SessionEndFn.html -[`SessionCompactFn`]: https://docs.rs/reloaded-code-core/latest/reloaded_code_core/type.SessionCompactFn.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 -[`AgentRuntime`]: https://docs.rs/reloaded-code-agents/latest/reloaded_code_agents/runtime/struct.AgentRuntime.html [SerdesAI]: https://crates.io/crates/serdes-ai From 1665b1f373521a74c0c9697c9ad4d61e1212e7d4 Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Sat, 6 Jun 2026 16:46:29 +0100 Subject: [PATCH 3/3] Fixed: Replace ArrayVec with TinyVec in hook storage to prevent panic on overflow ArrayVec::push panics when exceeding INLINE_CAP (3). TinyVec auto-spills to heap, making overflow safe while preserving inline storage for the common case. --- src/reloaded-code-core/src/hooks/builder.rs | 8 ++++---- src/reloaded-code-core/src/hooks/hook_set.rs | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/reloaded-code-core/src/hooks/builder.rs b/src/reloaded-code-core/src/hooks/builder.rs index 7e88fc4..9bf85de 100644 --- a/src/reloaded-code-core/src/hooks/builder.rs +++ b/src/reloaded-code-core/src/hooks/builder.rs @@ -3,15 +3,15 @@ use crate::hooks::{HookSet, SessionCompactFn, SessionEndFn, SessionStartFn, ToolHook, INLINE_CAP}; use std::fmt; use std::sync::Arc; -use tinyvec::ArrayVec; +use tinyvec::TinyVec; /// Builder for constructing [`HookSet`]. #[derive(Default)] pub struct HookSetBuilder { pub(super) tool_hooks: Vec>, - pub(super) session_start: ArrayVec<[Option; INLINE_CAP]>, - pub(super) session_end: ArrayVec<[Option; INLINE_CAP]>, - pub(super) session_compact: ArrayVec<[Option; INLINE_CAP]>, + pub(super) session_start: TinyVec<[Option; INLINE_CAP]>, + pub(super) session_end: TinyVec<[Option; INLINE_CAP]>, + pub(super) session_compact: TinyVec<[Option; INLINE_CAP]>, } impl HookSetBuilder { diff --git a/src/reloaded-code-core/src/hooks/hook_set.rs b/src/reloaded-code-core/src/hooks/hook_set.rs index 29c0b04..e39827c 100644 --- a/src/reloaded-code-core/src/hooks/hook_set.rs +++ b/src/reloaded-code-core/src/hooks/hook_set.rs @@ -6,15 +6,15 @@ use crate::hooks::{ }; use std::fmt; use std::sync::Arc; -use tinyvec::ArrayVec; +use tinyvec::TinyVec; /// All registered hooks and lifecycle events, stored per point. #[derive(Clone, Default)] pub struct HookSet { pub(super) tool_hooks: Vec>, - pub(super) session_start: ArrayVec<[Option; INLINE_CAP]>, - pub(super) session_end: ArrayVec<[Option; INLINE_CAP]>, - pub(super) session_compact: ArrayVec<[Option; INLINE_CAP]>, + pub(super) session_start: TinyVec<[Option; INLINE_CAP]>, + pub(super) session_end: TinyVec<[Option; INLINE_CAP]>, + pub(super) session_compact: TinyVec<[Option; INLINE_CAP]>, } impl HookSet {