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..7f81132
--- /dev/null
+++ b/docs/src/hooks.md
@@ -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
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.
@@ -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..9bf85de
--- /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::TinyVec;
+
+/// Builder for constructing [`HookSet`].
+#[derive(Default)]
+pub struct HookSetBuilder {
+ pub(super) tool_hooks: Vec>,
+ 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 {
+ /// 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..e39827c
--- /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::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: TinyVec<[Option; INLINE_CAP]>,
+ pub(super) session_end: TinyVec<[Option; INLINE_CAP]>,
+ pub(super) session_compact: TinyVec<[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)
}
|