diff --git a/src/Cargo.lock b/src/Cargo.lock index d9246e1e..87fb1e69 100644 --- a/src/Cargo.lock +++ b/src/Cargo.lock @@ -2886,7 +2886,7 @@ dependencies = [ [[package]] name = "reloaded-code-core" -version = "0.2.3" +version = "0.3.0" dependencies = [ "ahash", "bitcode", @@ -2962,7 +2962,7 @@ dependencies = [ [[package]] name = "reloaded-code-serdesai" -version = "0.2.0" +version = "0.3.0" dependencies = [ "ahash", "anyhow", diff --git a/src/Cargo.toml b/src/Cargo.toml index e8d015bc..85f240c3 100644 --- a/src/Cargo.toml +++ b/src/Cargo.toml @@ -76,7 +76,7 @@ serdes-ai-models = { version = "0.2.6", default-features = false } serdes-ai-streaming = "0.2" # Internal crates -reloaded-code-core = { version = "0.2.3", path = "reloaded-code-core", default-features = false } +reloaded-code-core = { version = "0.3.0", path = "reloaded-code-core", default-features = false } reloaded-code-bubblewrap = { version = "0.1.0", path = "reloaded-code-bubblewrap" } reloaded-code-agents = { version = "0.1.0", path = "reloaded-code-agents" } reloaded-code-models-dev = { version = "0.1.0", path = "reloaded-code-models-dev" } diff --git a/src/docs/src/hooks.md b/src/docs/src/hooks.md index ca803631..488214c4 100644 --- a/src/docs/src/hooks.md +++ b/src/docs/src/hooks.md @@ -2,10 +2,6 @@ Hooks let your code see, change, or stop things the agent does. -Tool, run, and run-event hooks are wired into the [SerdesAI] agent -pipeline: registered hooks intercept real tool calls, agent runs, and -streamed run events end to end. - Tool hooks work like game mods. Each hook gets an `original` function. `original` calls the next hook or the real tool. @@ -166,48 +162,55 @@ let hooks = HookSet::builder() Full example: [serdesai-tool-chain] (`cargo run --example serdesai-tool-chain -p reloaded-code-serdesai --features mock`). -### Intercept a run +### Amend run config -Run hooks wrap the whole agent run. Mutate `RunConfig` to change the system -prompt, preambles, or parameters, then call `original` to continue: +`RunConfigHook` changes a run's config before the run starts. `RunConfig` +holds the system prompt, preamble messages, and model settings overrides +(temperature, top_p). `configure` mutates the config in place: ```rust use reloaded_code_core::{ - HookRunContext, HookSet, PreambleMessage, PreambleRole, RunConfig, RunHook, - RunHookFuture, RunOriginal, + HookRunContext, HookSet, PreambleMessage, PreambleRole, RunConfig, + RunConfigHook, RunConfigHookFuture, }; struct PreambleInjector; -impl RunHook for PreambleInjector { - fn hook<'a>( +impl RunConfigHook for PreambleInjector { + fn configure<'a>( &'a self, - ctx: &'a HookRunContext<'a>, - mut config: RunConfig, - original: RunOriginal<'a>, - ) -> RunHookFuture<'a> { + _ctx: &'a HookRunContext<'a>, + config: &'a mut RunConfig, + ) -> RunConfigHookFuture<'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 + Ok(()) }) } } let hooks = HookSet::builder() - .run_hook(PreambleInjector) + .run_config_hook(PreambleInjector) .build(); ``` -Full example: [serdesai-run-hook] -(`cargo run --example serdesai-run-hook -p reloaded-code-serdesai --features mock`). +A `RunConfigHook` runs before the request is made. Hooks run in +registration order; the chain stops at the first error, and the run does +not start. + +Full example: [serdesai-run-config-hook] +(`cargo run --example serdesai-run-config-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: +`original`, inspect the result after. + +The `config` argument is a read-only view of the final `RunConfig`. +Register a `RunConfigHook` to change it: ```rust use reloaded_code_core::{ @@ -221,12 +224,12 @@ impl RunHook for RunObserver { fn hook<'a>( &'a self, ctx: &'a HookRunContext<'a>, - config: RunConfig, + _config: &'a 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 result = original.call(ctx).await; let reason = match &result { Ok(output) => output.reason, Err(_) => EndReason::Failed, @@ -248,6 +251,9 @@ 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. +Full example: [serdesai-run-hook] +(`cargo run --example serdesai-run-hook -p reloaded-code-serdesai --features mock`). + ### Intercept streamed events Run-event hooks fire only on `run_stream()`. Each event passes the @@ -306,47 +312,6 @@ Full example: [serdesai-run-event-hook] rewrites text deltas to uppercase and suppresses the output-ready milestone (`cargo run --example serdesai-run-event-hook -p reloaded-code-serdesai --features mock`). -## Available types - -### Tool hook types - -| 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. | - -### 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. | - -### Run event hook types - -| Type | Purpose | -| ---------------------- | ----------------------------------------------------- | -| [`RunEventHook`] | Observes, rewrites, or suppresses one streamed event. | -| [`RunEventContext`] | Agent and model names for the event's stream. | -| [`RunEvent`] | Framework-owned event yielded by a run stream. | -| [`RunEventHookResult`] | Publish, rewrite, suppress, or Err(ToolError). | - -### Container types - -| Type | Purpose | -| ------------------ | ----------------------------------------------------------- | -| [`HookSet`] | Stores tool, run, and run-event hooks, plus compact events. | -| [`HookSetBuilder`] | Builder for [`HookSet`]. | - ## How tool hooks stack This diagram assumes you register two hooks. If you set no hooks, the @@ -408,33 +373,9 @@ passes `HookSet::default()`. - **Empty fast path.** `dispatch_tool` calls the real tool directly when you set no hooks. -- **Mode-scoped run hooks.** `RunHook` fires only on `run()`; - `RunEventHook` fires only on `run_stream()`. Each hook point stays - inert on the other path. - - -[`ToolHook`]: https://docs.rs/reloaded-code-core/latest/reloaded_code_core/trait.ToolHook.html -[`ToolOriginal`]: https://docs.rs/reloaded-code-core/latest/reloaded_code_core/struct.ToolOriginal.html -[`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 -[`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 -[`RunEventHook`]: https://docs.rs/reloaded-code-core/latest/reloaded_code_core/trait.RunEventHook.html -[`RunEventContext`]: https://docs.rs/reloaded-code-core/latest/reloaded_code_core/struct.RunEventContext.html -[`RunEvent`]: https://docs.rs/reloaded-code-core/latest/reloaded_code_core/enum.RunEvent.html -[`RunEventHookResult`]: https://docs.rs/reloaded-code-core/latest/reloaded_code_core/type.RunEventHookResult.html -[SerdesAI]: https://crates.io/crates/serdes-ai [serdesai-tool-hook]: https://github.com/Reloaded-Project/ReloadedCode/blob/main/src/reloaded-code-serdesai/examples/hooks/tool/serdesai-tool-hook.rs [serdesai-tool-block]: https://github.com/Reloaded-Project/ReloadedCode/blob/main/src/reloaded-code-serdesai/examples/hooks/tool/serdesai-tool-block.rs [serdesai-tool-chain]: https://github.com/Reloaded-Project/ReloadedCode/blob/main/src/reloaded-code-serdesai/examples/hooks/tool/serdesai-tool-chain.rs +[serdesai-run-config-hook]: https://github.com/Reloaded-Project/ReloadedCode/blob/main/src/reloaded-code-serdesai/examples/hooks/run/serdesai-run-config-hook.rs [serdesai-run-hook]: https://github.com/Reloaded-Project/ReloadedCode/blob/main/src/reloaded-code-serdesai/examples/hooks/run/serdesai-run-hook.rs [serdesai-run-event-hook]: https://github.com/Reloaded-Project/ReloadedCode/blob/main/src/reloaded-code-serdesai/examples/hooks/run/serdesai-run-event-hook.rs diff --git a/src/reloaded-code-agents/src/runtime/builder.rs b/src/reloaded-code-agents/src/runtime/builder.rs index 55c38247..e54582d3 100644 --- a/src/reloaded-code-agents/src/runtime/builder.rs +++ b/src/reloaded-code-agents/src/runtime/builder.rs @@ -322,10 +322,10 @@ mod tests { fn hook<'a>( &'a self, ctx: &'a reloaded_code_core::HookRunContext<'a>, - config: reloaded_code_core::RunConfig, + _config: &'a reloaded_code_core::RunConfig, original: reloaded_code_core::RunOriginal<'a>, ) -> reloaded_code_core::RunHookFuture<'a> { - original.call(ctx, config) + original.call(ctx) } } diff --git a/src/reloaded-code-core/Cargo.toml b/src/reloaded-code-core/Cargo.toml index 5c209b85..62b105f1 100644 --- a/src/reloaded-code-core/Cargo.toml +++ b/src/reloaded-code-core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "reloaded-code-core" -version = "0.2.3" +version = "0.3.0" edition = "2021" description = "Lightweight, high-performance core types and utilities for coding tools - framework agnostic" repository = "https://github.com/Reloaded-Project/ReloadedCode" diff --git a/src/reloaded-code-core/src/hooks/builder.rs b/src/reloaded-code-core/src/hooks/builder.rs index 56897896..713c4fb6 100644 --- a/src/reloaded-code-core/src/hooks/builder.rs +++ b/src/reloaded-code-core/src/hooks/builder.rs @@ -1,6 +1,8 @@ //! HookSetBuilder — builder for constructing a [`HookSet`]. -use crate::hooks::{HookSet, RunEventHook, RunHook, SessionCompactFn, ToolHook, INLINE_CAP}; +use crate::hooks::{ + HookSet, RunConfigHook, RunEventHook, RunHook, SessionCompactFn, ToolHook, INLINE_CAP, +}; use std::fmt; use std::sync::Arc; use tinyvec::TinyVec; @@ -9,6 +11,7 @@ use tinyvec::TinyVec; #[derive(Default)] pub struct HookSetBuilder { pub(super) tool_hooks: Vec>, + pub(super) run_config_hooks: Vec>, pub(super) run_hooks: Vec>, pub(super) run_event_hooks: Vec>, pub(super) session_compact: TinyVec<[Option; INLINE_CAP]>, @@ -49,6 +52,25 @@ impl HookSetBuilder { self } + /// Registers a run-config hook. + /// + /// Hooks run in registration order before the run hook chain, + /// each mutating the run config in place. + #[inline] + #[must_use] + pub fn run_config_hook(mut self, hook: impl RunConfigHook) -> Self { + self.run_config_hooks.push(Arc::new(hook)); + self + } + + /// Registers an already shared run-config hook. + #[inline] + #[must_use] + pub fn shared_run_config_hook(mut self, hook: Arc) -> Self { + self.run_config_hooks.push(hook); + self + } + /// Registers a game-style run hook. /// /// Hooks run in registration order. Each hook's `original` handle calls @@ -94,6 +116,7 @@ impl HookSetBuilder { pub fn build(self) -> HookSet { HookSet { tool_hooks: self.tool_hooks, + run_config_hooks: self.run_config_hooks, run_hooks: self.run_hooks, run_event_hooks: self.run_event_hooks, session_compact: self.session_compact, @@ -105,6 +128,7 @@ 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("run_config_hooks", &self.run_config_hooks.len()) .field("run_hooks", &self.run_hooks.len()) .field("run_event_hooks", &self.run_event_hooks.len()) .field("session_compact", &self.session_compact.len()) @@ -116,7 +140,9 @@ impl fmt::Debug for HookSetBuilder { mod tests { use super::*; use crate::hooks::run_event::{RunEvent, RunEventContext, RunEventHook, RunEventHookResult}; - use crate::hooks::run_hook::{HookRunContext, RunConfig, RunHookFuture, RunOriginal}; + use crate::hooks::run_hook::{ + HookRunContext, RunConfig, RunConfigHook, RunConfigHookFuture, RunHookFuture, RunOriginal, + }; use crate::hooks::tool_hook::{ToolCallContext, ToolHookFuture, ToolOriginal, ToolRequest}; #[test] @@ -159,10 +185,10 @@ mod tests { fn hook<'a>( &'a self, ctx: &'a HookRunContext<'a>, - config: RunConfig, + _config: &'a RunConfig, original: RunOriginal<'a>, ) -> RunHookFuture<'a> { - original.call(ctx, config) + original.call(ctx) } } let hooks = HookSetBuilder::new().run_hook(NoopRun).build(); @@ -178,10 +204,10 @@ mod tests { fn hook<'a>( &'a self, ctx: &'a HookRunContext<'a>, - config: RunConfig, + _config: &'a RunConfig, original: RunOriginal<'a>, ) -> RunHookFuture<'a> { - original.call(ctx, config) + original.call(ctx) } } let shared: Arc = Arc::new(NoopRun); @@ -218,6 +244,63 @@ mod tests { assert!(!hooks.run_event_hooks_is_empty()); } + #[test] + fn run_config_hook_registration_makes_hook_set_non_empty() { + struct NoopConfig; + impl RunConfigHook for NoopConfig { + fn configure<'a>( + &'a self, + _ctx: &'a HookRunContext<'a>, + _config: &'a mut RunConfig, + ) -> RunConfigHookFuture<'a> { + Box::pin(async { Ok(()) }) + } + } + + let hooks = HookSetBuilder::new().run_config_hook(NoopConfig).build(); + assert!(!hooks.is_empty()); + assert!(!hooks.run_config_hooks_is_empty()); + assert_eq!(hooks.run_config_hooks().len(), 1); + } + + #[test] + fn shared_run_config_hook_registration() { + struct NoopConfig; + impl RunConfigHook for NoopConfig { + fn configure<'a>( + &'a self, + _ctx: &'a HookRunContext<'a>, + _config: &'a mut RunConfig, + ) -> RunConfigHookFuture<'a> { + Box::pin(async { Ok(()) }) + } + } + + let shared: Arc = Arc::new(NoopConfig); + let hooks = HookSetBuilder::new().shared_run_config_hook(shared).build(); + assert!(!hooks.run_config_hooks_is_empty()); + assert_eq!(hooks.run_config_hooks().len(), 1); + } + + #[test] + // Pins manual Debug: counts only, never hook contents (traits lack Debug). + fn builder_debug_includes_run_config_hooks() { + struct NoopConfig; + impl RunConfigHook for NoopConfig { + fn configure<'a>( + &'a self, + _ctx: &'a HookRunContext<'a>, + _config: &'a mut RunConfig, + ) -> RunConfigHookFuture<'a> { + Box::pin(async { Ok(()) }) + } + } + + let builder = HookSetBuilder::new().run_config_hook(NoopConfig); + let debug = format!("{builder:?}"); + assert!(debug.contains("run_config_hooks: 1")); + } + #[test] fn builder_debug_includes_run_hooks() { let builder = HookSetBuilder::new(); diff --git a/src/reloaded-code-core/src/hooks/hook_set.rs b/src/reloaded-code-core/src/hooks/hook_set.rs index 499cfdb2..78d37826 100644 --- a/src/reloaded-code-core/src/hooks/hook_set.rs +++ b/src/reloaded-code-core/src/hooks/hook_set.rs @@ -1,9 +1,10 @@ //! HookSet — container and dispatch for all registered hooks and lifecycle events. use crate::hooks::{ - HookRunContext, RunConfig, RunEvent, RunEventContext, RunEventHook, RunEventHookResult, - RunExecutor, RunHook, RunHookFuture, RunOriginal, SessionCompactFn, ToolCallContext, - ToolExecutor, ToolHook, ToolHookFuture, ToolOriginal, ToolRequest, INLINE_CAP, + HookRunContext, RunConfig, RunConfigHook, RunEvent, RunEventContext, RunEventHook, + RunEventHookResult, RunExecutor, RunHook, RunHookFuture, RunOriginal, RunResult, + SessionCompactFn, ToolCallContext, ToolExecutor, ToolHook, ToolHookFuture, ToolOriginal, + ToolRequest, INLINE_CAP, }; use std::fmt; use std::sync::Arc; @@ -13,6 +14,7 @@ use tinyvec::TinyVec; #[derive(Clone, Default)] pub struct HookSet { pub(super) tool_hooks: Vec>, + pub(super) run_config_hooks: Vec>, pub(super) run_hooks: Vec>, pub(super) run_event_hooks: Vec>, pub(super) session_compact: TinyVec<[Option; INLINE_CAP]>, @@ -24,6 +26,7 @@ impl HookSet { #[must_use] pub fn is_empty(&self) -> bool { self.tool_hooks.is_empty() + && self.run_config_hooks.is_empty() && self.run_hooks.is_empty() && self.run_event_hooks.is_empty() && self.session_compact.is_empty() @@ -36,6 +39,13 @@ impl HookSet { self.tool_hooks.is_empty() } + /// Returns `true` if no run-config hooks are registered. + #[inline] + #[must_use] + pub fn run_config_hooks_is_empty(&self) -> bool { + self.run_config_hooks.is_empty() + } + /// Returns `true` if no run hooks are registered. #[inline] #[must_use] @@ -57,6 +67,13 @@ impl HookSet { &self.tool_hooks } + /// Returns registered run-config hooks in dispatch order. + #[inline] + #[must_use] + pub fn run_config_hooks(&self) -> &[Arc] { + &self.run_config_hooks + } + /// Returns registered run hooks in dispatch order. #[inline] #[must_use] @@ -87,13 +104,41 @@ impl HookSet { ToolOriginal::new(&self.tool_hooks, real_tool).call(ctx, req) } - /// Dispatches a run through the hook chain. + /// Applies the run-config hook chain to `config`. /// - /// If no run hooks are registered, this calls the real run - /// executor directly. + /// Hooks run in registration order, each mutating the same + /// [`RunConfig`]. If no run-config hooks are registered, `config` + /// is returned unchanged without entering the chain. /// /// # Errors - /// Returns `ToolError` if the executor or any run hook in the chain returns an error. + /// Returns [`ToolError`] if any hook in the chain returns an error; + /// dispatch stops at the first error. + /// + /// [`ToolError`]: crate::ToolError + #[inline] + pub async fn dispatch_run_config( + &self, + ctx: &HookRunContext<'_>, + mut config: RunConfig, + ) -> RunResult { + for hook in &self.run_config_hooks { + hook.configure(ctx, &mut config).await?; + } + Ok(config) + } + + /// Dispatches a run through the config and run hook chains. + /// + /// Config hooks run first, amending `config`. The run chain then + /// runs with a shared view of the final config, which the executor + /// receives as an owned clone. Empty chains are skipped. + /// + /// # Errors + /// Returns [`ToolError`] if a config hook, any run hook in the + /// chain, or the executor returns an error. A config-hook error + /// stops dispatch before the run chain or executor starts. + /// + /// [`ToolError`]: crate::ToolError #[inline] pub fn dispatch_run<'a>( &'a self, @@ -101,10 +146,19 @@ impl HookSet { config: RunConfig, real_run: &'a dyn RunExecutor, ) -> RunHookFuture<'a> { - if self.run_hooks.is_empty() { + if self.run_hooks.is_empty() && self.run_config_hooks.is_empty() { return real_run.execute(ctx, config); } - RunOriginal::new(&self.run_hooks, real_run).call(ctx, config) + Box::pin(async move { + let final_config = self.dispatch_run_config(ctx, config).await?; + if self.run_hooks.is_empty() { + real_run.execute(ctx, final_config).await + } else { + RunOriginal::new(&self.run_hooks, real_run, &final_config) + .call(ctx) + .await + } + }) } /// Dispatches one streamed run event through the run-event hook chain. @@ -150,6 +204,7 @@ 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("run_config_hooks", &self.run_config_hooks.len()) .field("run_hooks", &self.run_hooks.len()) .field("run_event_hooks", &self.run_event_hooks.len()) .field("session_compact", &self.session_compact.len()) @@ -162,7 +217,8 @@ mod tests { use super::*; use crate::hooks::run_event::{RunEvent, RunEventContext, RunEventHook, RunEventHookResult}; use crate::hooks::run_hook::{ - EndReason, RunConfig, RunExecutor, RunHook, RunHookFuture, RunOriginal, RunOutput, RunUsage, + EndReason, ModelSettingsOverrides, PreambleMessage, PreambleRole, RunConfig, RunConfigHook, + RunConfigHookFuture, RunExecutor, RunHook, RunHookFuture, RunOriginal, RunOutput, RunUsage, }; use crate::{ToolError, ToolOutput}; use serde_json::json; @@ -173,6 +229,45 @@ mod tests { Box::pin(async move { Ok(output) }) } + /// Wraps a config mutation closure as a hook that always succeeds. + fn mutate_hook(mutate: F) -> impl RunConfigHook + where + F: Fn(&mut RunConfig) + Send + Sync + 'static, + { + struct Mutate(F); + impl RunConfigHook for Mutate + where + F: Fn(&mut RunConfig) + Send + Sync + 'static, + { + fn configure<'a>( + &'a self, + _ctx: &'a HookRunContext<'a>, + config: &'a mut RunConfig, + ) -> RunConfigHookFuture<'a> { + Box::pin(async move { + (self.0)(config); + Ok(()) + }) + } + } + Mutate(mutate) + } + + /// Builds a hook whose configure always fails validation. + fn fail_hook(message: &'static str) -> impl RunConfigHook { + struct Fail(&'static str); + impl RunConfigHook for Fail { + fn configure<'a>( + &'a self, + _ctx: &'a HookRunContext<'a>, + _config: &'a mut RunConfig, + ) -> RunConfigHookFuture<'a> { + Box::pin(async { Err(ToolError::validation(self.0)) }) + } + } + Fail(message) + } + #[test] fn hook_set_default_is_empty() { let hooks = HookSet::default(); @@ -188,10 +283,10 @@ mod tests { fn hook<'a>( &'a self, ctx: &'a HookRunContext<'a>, - config: RunConfig, + _config: &'a RunConfig, original: RunOriginal<'a>, ) -> RunHookFuture<'a> { - original.call(ctx, config) + original.call(ctx) } } let hooks = HookSet::builder().run_hook(NoopRun).build(); @@ -339,6 +434,144 @@ mod tests { assert_eq!(output.content, "blocked"); } + // --- Run config dispatch tests --------------------------------------------- + + fn run_ctx() -> HookRunContext<'static> { + HookRunContext { + agent_name: "coder", + run_id: "r1", + model_name: "gpt-4o", + } + } + + #[tokio::test] + async fn dispatch_run_config_applies_hooks_in_registration_order() { + let hooks = HookSet::builder() + .run_config_hook(mutate_hook(|config| { + config.system_prompt = Some("base".into()); + })) + .run_config_hook(mutate_hook(|config| { + let tagged = format!("{}-tagged", config.system_prompt.take().unwrap_or_default()); + config.system_prompt = Some(tagged); + })) + .build(); + let config = hooks + .dispatch_run_config(&run_ctx(), RunConfig::default()) + .await + .unwrap(); + + // "base-tagged" proves the second hook saw the first hook's + // mutation, not its own starting value. + assert_eq!(config.system_prompt.as_deref(), Some("base-tagged")); + } + + #[tokio::test] + async fn dispatch_run_config_accumulates_mutations_across_hooks() { + let mut input = RunConfig::default(); + input.preamble_messages.push(PreambleMessage { + role: PreambleRole::System, + content: "seeded".into(), + }); + + let hooks = HookSet::builder() + .run_config_hook(mutate_hook(|config| { + config.system_prompt = Some("sys".into()); + })) + .run_config_hook(mutate_hook(|config| { + config.preamble_messages.push(PreambleMessage { + role: PreambleRole::User, + content: "ctx".into(), + }); + config.model_settings_overrides = Some(ModelSettingsOverrides { + temperature: Some(0.2), + top_p: Some(0.9), + }); + })) + .build(); + let config = hooks.dispatch_run_config(&run_ctx(), input).await.unwrap(); + + // The caller's seeded preamble survives and every hook's field + // writes accumulate: the config is threaded through, not replaced. + assert_eq!(config.system_prompt.as_deref(), Some("sys")); + assert_eq!(config.preamble_messages.len(), 2); + let overrides = config.model_settings_overrides.unwrap(); + assert_eq!(overrides.temperature, Some(0.2)); + assert_eq!(overrides.top_p, Some(0.9)); + } + + #[tokio::test] + async fn dispatch_run_config_stops_at_first_error() { + struct MustNotRun; + + impl RunConfigHook for MustNotRun { + fn configure<'a>( + &'a self, + _ctx: &'a HookRunContext<'a>, + _config: &'a mut RunConfig, + ) -> RunConfigHookFuture<'a> { + panic!("later hooks must not run after a hook error"); + } + } + + let hooks = HookSet::builder() + .run_config_hook(fail_hook("config rejected the run")) + .run_config_hook(MustNotRun) + .build(); + let result = hooks + .dispatch_run_config(&run_ctx(), RunConfig::default()) + .await; + assert!(matches!(result, Err(ToolError::Validation { .. }))); + } + + #[tokio::test] + async fn dispatch_run_config_empty_chain_returns_config_unchanged() { + struct Passthrough; + impl RunEventHook for Passthrough { + fn hook(&self, _ctx: &RunEventContext<'_>, event: RunEvent) -> RunEventHookResult { + Ok(Some(event)) + } + } + + let input = RunConfig { + system_prompt: Some("sys".into()), + preamble_messages: vec![PreambleMessage { + role: PreambleRole::System, + content: "ctx".into(), + }], + ..RunConfig::default() + }; + + // Other hook chains registered, config chain empty: the config + // bypasses the chain untouched. + let hooks = HookSet::builder().run_event_hook(Passthrough).build(); + assert!(!hooks.is_empty()); + assert!(hooks.run_config_hooks_is_empty()); + + let config = hooks.dispatch_run_config(&run_ctx(), input).await.unwrap(); + assert_eq!(config.system_prompt.as_deref(), Some("sys")); + assert_eq!(config.preamble_messages.len(), 1); + } + + #[test] + fn hook_set_with_run_config_hooks_is_not_empty() { + let hooks = HookSet::builder() + .run_config_hook(mutate_hook(|_| {})) + .build(); + assert!(!hooks.is_empty()); + assert!(!hooks.run_config_hooks_is_empty()); + assert_eq!(hooks.run_config_hooks().len(), 1); + } + + #[test] + // Pins manual Debug: counts only, never hook contents (traits lack Debug). + fn hook_set_debug_includes_run_config_hooks_count() { + let hooks = HookSet::builder() + .run_config_hook(mutate_hook(|_| {})) + .build(); + let debug = format!("{hooks:?}"); + assert!(debug.contains("run_config_hooks: 1")); + } + // --- Run dispatch tests ---------------------------------------------------- #[tokio::test] @@ -378,25 +611,82 @@ mod tests { #[tokio::test] async fn dispatch_run_hooks_wrap_real_run() { - struct Prefix; - struct RealRun; - - impl RunHook for Prefix { + struct Wrap; + impl RunHook for Wrap { fn hook<'a>( &'a self, ctx: &'a HookRunContext<'a>, - mut config: RunConfig, + config: &'a RunConfig, original: RunOriginal<'a>, ) -> RunHookFuture<'a> { Box::pin(async move { - config.system_prompt = Some("overridden".into()); - let mut output = original.call(ctx, config).await?; - output.content.push_str("-post"); + // The run hook sees the config-hook-amended final + // config: the same values the executor receives. + let seen = config.system_prompt.clone(); + let mut output = original.call(ctx).await?; + output + .content + .push_str(&format!("-saw:{}-post", seen.unwrap_or_default())); Ok(output) }) } } + struct RealRun; + impl RunExecutor for RealRun { + fn execute<'a>( + &'a self, + _ctx: &'a HookRunContext<'a>, + config: RunConfig, + ) -> RunHookFuture<'a> { + let prompt = config.system_prompt.unwrap_or_else(|| "default".into()); + let preamble = config + .preamble_messages + .iter() + .map(|message| message.content.as_str()) + .collect::>() + .join("+"); + Box::pin(async move { + Ok(RunOutput { + content: format!("{prompt}|{preamble}"), + reason: EndReason::Completed, + usage: RunUsage::default(), + }) + }) + } + } + + let hooks = crate::hooks::builder::HookSetBuilder::new() + .run_config_hook(mutate_hook(|config| { + config.system_prompt = Some("overridden".into()); + config.preamble_messages.push(PreambleMessage { + role: PreambleRole::User, + content: "ctx".into(), + }); + })) + .run_hook(Wrap) + .build(); + let ctx = HookRunContext { + agent_name: "coder", + run_id: "r1", + model_name: "gpt-4o", + }; + // Seed a preamble no hook writes: the executor's owned config + // must carry both the seed and the hook-amended fields. + let mut input = RunConfig::default(); + input.preamble_messages.push(PreambleMessage { + role: PreambleRole::System, + content: "seeded".into(), + }); + let output = hooks.dispatch_run(&ctx, input, &RealRun).await.unwrap(); + + assert_eq!(output.content, "overridden|seeded+ctx-saw:overridden-post"); + assert_eq!(output.reason, EndReason::Completed); + } + + #[tokio::test] + async fn dispatch_run_config_hooks_only_feed_executor() { + struct RealRun; impl RunExecutor for RealRun { fn execute<'a>( &'a self, @@ -415,8 +705,11 @@ mod tests { } let hooks = crate::hooks::builder::HookSetBuilder::new() - .run_hook(Prefix) + .run_config_hook(mutate_hook(|config| { + config.system_prompt = Some("cfg-only".into()); + })) .build(); + assert!(hooks.run_hooks_is_empty()); let ctx = HookRunContext { agent_name: "coder", run_id: "r1", @@ -427,8 +720,99 @@ mod tests { .await .unwrap(); - assert_eq!(output.content, "overridden-post"); - assert_eq!(output.reason, EndReason::Completed); + assert_eq!(output.content, "cfg-only"); + } + + #[tokio::test] + async fn dispatch_run_config_hook_error_aborts_before_run_chain() { + struct MustNotRun; + impl RunHook for MustNotRun { + fn hook<'a>( + &'a self, + _ctx: &'a HookRunContext<'a>, + _config: &'a RunConfig, + _original: RunOriginal<'a>, + ) -> RunHookFuture<'a> { + panic!("run hooks must not run after a config hook error"); + } + } + + struct PanicRun; + impl RunExecutor for PanicRun { + fn execute<'a>( + &'a self, + _ctx: &'a HookRunContext<'a>, + _config: RunConfig, + ) -> RunHookFuture<'a> { + panic!("executor must not run after a config hook error"); + } + } + + let hooks = crate::hooks::builder::HookSetBuilder::new() + .run_config_hook(fail_hook("config rejected the run")) + .run_hook(MustNotRun) + .build(); + let ctx = HookRunContext { + agent_name: "coder", + run_id: "r1", + model_name: "gpt-4o", + }; + let result = hooks + .dispatch_run(&ctx, RunConfig::default(), &PanicRun) + .await; + assert!(matches!(result, Err(ToolError::Validation { .. }))); + } + + #[tokio::test] + async fn dispatch_run_hook_error_stops_the_chain() { + struct FailingHook; + impl RunHook for FailingHook { + fn hook<'a>( + &'a self, + _ctx: &'a HookRunContext<'a>, + _config: &'a RunConfig, + _original: RunOriginal<'a>, + ) -> RunHookFuture<'a> { + Box::pin(async { Err(ToolError::validation("hook rejected the run")) }) + } + } + + struct MustNotRun; + impl RunHook for MustNotRun { + fn hook<'a>( + &'a self, + _ctx: &'a HookRunContext<'a>, + _config: &'a RunConfig, + _original: RunOriginal<'a>, + ) -> RunHookFuture<'a> { + panic!("later hooks must not run after a hook error"); + } + } + + struct PanicRun; + impl RunExecutor for PanicRun { + fn execute<'a>( + &'a self, + _ctx: &'a HookRunContext<'a>, + _config: RunConfig, + ) -> RunHookFuture<'a> { + panic!("executor must not run after a hook error"); + } + } + + let hooks = crate::hooks::builder::HookSetBuilder::new() + .run_hook(FailingHook) + .run_hook(MustNotRun) + .build(); + let ctx = HookRunContext { + agent_name: "coder", + run_id: "r1", + model_name: "gpt-4o", + }; + let result = hooks + .dispatch_run(&ctx, RunConfig::default(), &PanicRun) + .await; + assert!(matches!(result, Err(ToolError::Validation { .. }))); } #[tokio::test] @@ -440,7 +824,7 @@ mod tests { fn hook<'a>( &'a self, _ctx: &'a HookRunContext<'a>, - _config: RunConfig, + _config: &'a RunConfig, _original: RunOriginal<'a>, ) -> RunHookFuture<'a> { Box::pin(async { @@ -497,12 +881,12 @@ mod tests { fn hook<'a>( &'a self, ctx: &'a HookRunContext<'a>, - config: RunConfig, + _config: &'a RunConfig, original: RunOriginal<'a>, ) -> RunHookFuture<'a> { LOG.lock().unwrap().push("first-before".into()); Box::pin(async move { - let output = original.call(ctx, config).await?; + let output = original.call(ctx).await?; LOG.lock().unwrap().push("first-after".into()); Ok(output) }) @@ -513,12 +897,12 @@ mod tests { fn hook<'a>( &'a self, ctx: &'a HookRunContext<'a>, - config: RunConfig, + _config: &'a RunConfig, original: RunOriginal<'a>, ) -> RunHookFuture<'a> { LOG.lock().unwrap().push("second-before".into()); Box::pin(async move { - let output = original.call(ctx, config).await?; + let output = original.call(ctx).await?; LOG.lock().unwrap().push("second-after".into()); Ok(output) }) @@ -742,10 +1126,10 @@ mod tests { fn hook<'a>( &'a self, ctx: &'a HookRunContext<'a>, - config: RunConfig, + _config: &'a RunConfig, original: RunOriginal<'a>, ) -> RunHookFuture<'a> { - original.call(ctx, config) + original.call(ctx) } } let hooks = HookSet::builder().run_hook(NoopRun).build(); diff --git a/src/reloaded-code-core/src/hooks/mod.rs b/src/reloaded-code-core/src/hooks/mod.rs index a2ddfc67..45c92687 100644 --- a/src/reloaded-code-core/src/hooks/mod.rs +++ b/src/reloaded-code-core/src/hooks/mod.rs @@ -12,10 +12,12 @@ //! - [`ToolExecutor`] - Final callable used at the end of the hook chain //! //! Run hook types: +//! - [`RunConfigHook`] - Amends a run's config before the run starts +//! - [`RunConfigHookFuture`] - Boxed future returned by [`RunConfigHook::configure`] //! - [`RunHook`] - Intercepts a run and may call [`RunOriginal`] //! - [`RunHookFuture`] - Boxed future returned by [`RunHook::hook`] //! - [`RunOriginal`] - Managed trampoline to the next hook or real run executor -//! - [`RunConfig`] - Mutable config a RunHook can change before calling original +//! - [`RunConfig`] - Config a run config hook amends before a run; the run chain observes it read-only //! - [`RunOutput`] - Result of a completed run //! - [`RunExecutor`] - Final callable used at the end of the run hook chain //! - [`HookRunContext`] - Context given to hook run lifecycle events diff --git a/src/reloaded-code-core/src/hooks/run_hook/mod.rs b/src/reloaded-code-core/src/hooks/run_hook/mod.rs index ebb5816e..af91fba5 100644 --- a/src/reloaded-code-core/src/hooks/run_hook/mod.rs +++ b/src/reloaded-code-core/src/hooks/run_hook/mod.rs @@ -9,18 +9,27 @@ //! A run holds N steps. One step = one LLM request plus the tool calls //! it triggers. A run with no tool calls is a single step. //! +//! # The run boundary +//! //! A run hook wraps that whole boundary. Code before `original` runs -//! before the first step: inject preamble messages, override the system -//! prompt or model settings. +//! before the first step and observes the final config read-only; +//! code after sees the finished [`RunOutput`]. Skipping `original` +//! skips the run and returns a synthetic result instead. +//! +//! # Hook ownership +//! +//! [`RunConfigHook`] amends config before the run. [`RunHook`] +//! controls run lifecycle on `run()` only. [`RunEventHook`] owns +//! streamed events. //! -//! Code after `original` sees the finished [`RunOutput`]. Skipping -//! `original` skips the run and returns a synthetic result instead. +//! # Run identity //! //! Each run carries a `run_id` (see [`HookRunContext`]). Tool hooks //! fire inside a run, once per tool call, under the same `run_id`. //! //! Next: see [`ToolHook`] for the innermost intercept point. //! +//! [`RunEventHook`]: crate::hooks::RunEventHook //! [`ToolHook`]: crate::hooks::ToolHook use crate::ToolError; @@ -29,16 +38,8 @@ use std::future::Future; use std::pin::Pin; use std::sync::Arc; -/// Mutable config a RunHook can change before calling original. -#[derive(Default)] -pub struct RunConfig { - /// Override the agent's default system prompt. - pub system_prompt: Option, - /// Preamble messages injected before the user prompt. - pub preamble_messages: Vec, - /// Model settings overrides (temperature, top_p, etc.). - pub model_settings_overrides: Option, -} +/// Boxed future returned by [`RunConfigHook::configure`]. +pub type RunConfigHookFuture<'a> = Pin> + Send + 'a>>; /// Boxed future returned by [`RunHook::hook`] and [`RunExecutor::execute`]. pub type RunHookFuture<'a> = Pin> + Send + 'a>>; @@ -46,13 +47,16 @@ pub type RunHookFuture<'a> = Pin> + /// Managed trampoline to the next hook or real run executor. /// /// `RunOriginal` is consumed by [`call`], so normal hooks call -/// the continuation once. +/// the continuation once. It carries a shared view of the final +/// [`RunConfig`]; the chain end hands the executor an owned clone +/// of it (the only copy on the config flow). /// /// [`call`]: Self::call pub struct RunOriginal<'a> { chain: &'a [Arc], index: usize, real_run: &'a dyn RunExecutor, + config: &'a RunConfig, } /// Compact event callback. Name preserved - compact is its own concept, distinct from "run". @@ -69,22 +73,20 @@ pub struct HookRunContext<'a> { pub model_name: &'a str, } -/// Model-level settings that a RunHook can override. -#[derive(Default)] -pub struct ModelSettingsOverrides { - /// Temperature override. - pub temperature: Option, - /// Top-p override. - pub top_p: Option, -} - -/// Preamble message injected before the user's prompt. -#[derive(Debug, Clone)] -pub struct PreambleMessage { - /// Role of the preamble message. - pub role: PreambleRole, - /// Content of the preamble message. - pub content: String, +/// Run config: system prompt, preamble messages, model settings. +/// +/// A [`RunConfigHook`] amends this config before the run; a [`RunHook`] +/// observes the final config read-only. `Clone` exists for the +/// chain-end hand-off: the trampoline clones once per run; every +/// other path moves the config. +#[derive(Default, Clone)] +pub struct RunConfig { + /// Override the agent's default system prompt. + pub system_prompt: Option, + /// Preamble messages injected before the user prompt. + pub preamble_messages: Vec, + /// Model settings overrides (temperature, top_p, etc.). + pub model_settings_overrides: Option, } /// Result of a completed run. Framework-agnostic distillation of the agent output. @@ -112,13 +114,24 @@ pub enum EndReason { Failed, } -/// Role for a preamble message. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum PreambleRole { - /// System-level instruction. - System, - /// User-level context. - User, +/// Model-level settings that a run config hook can override. +/// +/// Derives `Clone` together with [`RunConfig`]. +#[derive(Default, Clone)] +pub struct ModelSettingsOverrides { + /// Temperature override. + pub temperature: Option, + /// Top-p override. + pub top_p: Option, +} + +/// Preamble message injected before the user's prompt. +#[derive(Debug, Clone)] +pub struct PreambleMessage { + /// Role of the preamble message. + pub role: PreambleRole, + /// Content of the preamble message. + pub content: String, } /// Token usage for a completed run. @@ -130,9 +143,45 @@ pub struct RunUsage { pub completion_tokens: u64, } +/// Role for a preamble message. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PreambleRole { + /// System-level instruction. + System, + /// User-level context. + User, +} + +/// Hook that amends a run's config before the run starts. +/// +/// `configure` mutates the [`RunConfig`] in place. Hooks run in +/// registration order; each hook sees every earlier hook's +/// mutations. Config hooks fire on both run paths (`run()` and +/// `run_stream()`); on `run()` they run before the run hook chain. +/// +/// # Remarks +/// +/// `configure` is async so a hook can fetch remote resources (prompt +/// templates, feature flags). The future is boxed once per hook per +/// run. +pub trait RunConfigHook: Send + Sync + 'static { + /// Amends the run config in place. + /// + /// # Errors + /// Returns [`ToolError`] when the hook fails. The chain stops at the + /// first error and the run does not start. + /// + /// [`ToolError`]: crate::ToolError + fn configure<'a>( + &'a self, + ctx: &'a HookRunContext<'a>, + config: &'a mut RunConfig, + ) -> RunConfigHookFuture<'a>; +} + /// Final callable used when the hook chain reaches the real run executor. pub trait RunExecutor: Send + Sync { - /// Executes the real run. + /// Executes the real run with the final, owned [`RunConfig`]. /// /// # Errors /// Returns `ToolError` if the real run executor encounters an error. @@ -141,14 +190,12 @@ pub trait RunExecutor: Send + Sync { /// Intercept hook for the full run lifecycle. /// -/// Code before `original` = inject preamble, override config. +/// Code before `original` = observe the run before it starts. /// Skip `original` = skip the run (return a synthetic `RunOutput`). /// Code after = observe the run result. /// -/// `config` is owned (same as `ToolRequest` in `ToolHook`). Each hook -/// takes ownership, mutates, and passes to `original.call()`. The final -/// [`RunExecutor`] consumes it: strings move into the framework's run -/// options with zero clones. +/// `config` is a read-only view of the final [`RunConfig`]. To +/// change it, register a [`RunConfigHook`]. /// /// # Remarks /// @@ -164,41 +211,53 @@ pub trait RunHook: Send + Sync + 'static { fn hook<'a>( &'a self, ctx: &'a HookRunContext<'a>, - config: RunConfig, + config: &'a RunConfig, original: RunOriginal<'a>, ) -> RunHookFuture<'a>; } impl<'a> RunOriginal<'a> { - /// Creates a trampoline over the provided hook chain and real run executor. + /// Creates a trampoline over the provided hook chain, final + /// config, and real run executor. #[inline] #[must_use] - pub fn new(chain: &'a [Arc], real_run: &'a dyn RunExecutor) -> Self { + pub fn new( + chain: &'a [Arc], + real_run: &'a dyn RunExecutor, + config: &'a RunConfig, + ) -> Self { Self { chain, index: 0, real_run, + config, } } - /// Calls the next hook, or the real run executor when no hooks remain. + /// Calls the next hook, or the real run executor when no hooks + /// remain. + /// + /// The chain end hands the executor an owned clone of the final + /// config: the single copy on the config flow when run hooks are + /// registered. /// /// # Errors /// Returns `ToolError` if a downstream hook or the real executor returns an error. #[inline] - pub fn call(self, ctx: &'a HookRunContext<'a>, config: RunConfig) -> RunHookFuture<'a> { + pub fn call(self, ctx: &'a HookRunContext<'a>) -> RunHookFuture<'a> { if let Some(hook) = self.chain.get(self.index) { hook.hook( ctx, - config, + self.config, Self { chain: self.chain, index: self.index + 1, real_run: self.real_run, + config: self.config, }, ) } else { - self.real_run.execute(ctx, config) + self.real_run.execute(ctx, self.config.clone()) } } } @@ -214,7 +273,7 @@ impl fmt::Debug for RunOriginal<'_> { impl RunHook for F where - F: for<'a> Fn(&'a HookRunContext<'a>, RunConfig, RunOriginal<'a>) -> RunHookFuture<'a> + F: for<'a> Fn(&'a HookRunContext<'a>, &'a RunConfig, RunOriginal<'a>) -> RunHookFuture<'a> + Send + Sync + 'static, @@ -223,7 +282,7 @@ where fn hook<'a>( &'a self, ctx: &'a HookRunContext<'a>, - config: RunConfig, + config: &'a RunConfig, original: RunOriginal<'a>, ) -> RunHookFuture<'a> { self(ctx, config, original) @@ -289,7 +348,7 @@ mod tests { fn hook<'a>( &'a self, _ctx: &'a HookRunContext<'a>, - _config: RunConfig, + _config: &'a RunConfig, _original: RunOriginal<'a>, ) -> RunHookFuture<'a> { Box::pin(async { @@ -307,9 +366,10 @@ mod tests { run_id: "r1", model_name: "gpt-4o", }; + let config = RunConfig::default(); let hook: Arc = Arc::new(MockHook); let output = hook - .hook(&ctx, RunConfig::default(), RunOriginal::new(&[], &RealRun)) + .hook(&ctx, &config, RunOriginal::new(&[], &RealRun, &config)) .await .unwrap(); assert_eq!(output.content, "mock"); @@ -339,8 +399,9 @@ mod tests { run_id: "r1", model_name: "gpt-4o", }; - let original = RunOriginal::new(&[], &RealRun); - let output = original.call(&ctx, RunConfig::default()).await.unwrap(); + let config = RunConfig::default(); + let original = RunOriginal::new(&[], &RealRun, &config); + let output = original.call(&ctx).await.unwrap(); assert_eq!(output.content, "real"); } @@ -363,7 +424,8 @@ mod tests { } } let chain: Vec> = vec![]; - let original = RunOriginal::new(&chain, &RealRun); + let config = RunConfig::default(); + let original = RunOriginal::new(&chain, &RealRun, &config); let debug = format!("{:?}", original); assert!(debug.contains("RunOriginal")); assert!(debug.contains("chain_len")); diff --git a/src/reloaded-code-core/src/hooks/tool_hook/mod.rs b/src/reloaded-code-core/src/hooks/tool_hook/mod.rs index 6738181e..108cf4f9 100644 --- a/src/reloaded-code-core/src/hooks/tool_hook/mod.rs +++ b/src/reloaded-code-core/src/hooks/tool_hook/mod.rs @@ -32,8 +32,11 @@ //! registered hook is outermost, the last one sits directly on the //! real tool. //! -//! Next: see [`RunHook`] for the whole-run intercept point. +//! Next: see [`RunHook`] for the whole-run intercept point. Config +//! changes belong to [`RunConfigHook`], the one run-level hook +//! point that fires on both run paths. //! +//! [`RunConfigHook`]: crate::hooks::RunConfigHook //! [`RunHook`]: crate::hooks::RunHook use crate::{ToolOutput, ToolResult}; diff --git a/src/reloaded-code-serdesai/Cargo.toml b/src/reloaded-code-serdesai/Cargo.toml index 0b730f6f..32e93de4 100644 --- a/src/reloaded-code-serdesai/Cargo.toml +++ b/src/reloaded-code-serdesai/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "reloaded-code-serdesai" -version = "0.2.0" +version = "0.3.0" edition = "2024" description = "Lightweight, high-performance serdesAI framework Tool implementations for coding tools" repository = "https://github.com/Reloaded-Project/ReloadedCode" @@ -103,6 +103,11 @@ rstest = { workspace = true } # models.dev catalog loader for examples reloaded-code-models-dev = { workspace = true } +[[example]] +name = "serdesai-run-config-hook" +path = "examples/hooks/run/serdesai-run-config-hook.rs" +required-features = ["mock"] + [[example]] name = "serdesai-run-hook" path = "examples/hooks/run/serdesai-run-hook.rs" diff --git a/src/reloaded-code-serdesai/examples/hooks/run/serdesai-run-chain.rs b/src/reloaded-code-serdesai/examples/hooks/run/serdesai-run-chain.rs index f30246b3..a1040de6 100644 --- a/src/reloaded-code-serdesai/examples/hooks/run/serdesai-run-chain.rs +++ b/src/reloaded-code-serdesai/examples/hooks/run/serdesai-run-chain.rs @@ -27,12 +27,12 @@ impl RunHook for FirstHook { fn hook<'a>( &'a self, _ctx: &'a HookRunContext<'a>, - config: RunConfig, + _config: &'a RunConfig, original: RunOriginal<'a>, ) -> RunHookFuture<'a> { Box::pin(async move { println!("[FirstHook] before"); - let output = original.call(_ctx, config).await?; + let output = original.call(_ctx).await?; println!("[FirstHook] after"); Ok(output) }) @@ -43,12 +43,12 @@ impl RunHook for SecondHook { fn hook<'a>( &'a self, ctx: &'a HookRunContext<'a>, - config: RunConfig, + _config: &'a RunConfig, original: RunOriginal<'a>, ) -> RunHookFuture<'a> { Box::pin(async move { println!("[SecondHook] before"); - let output = original.call(ctx, config).await?; + let output = original.call(ctx).await?; println!("[SecondHook] after"); Ok(output) }) diff --git a/src/reloaded-code-serdesai/examples/hooks/run/serdesai-run-config-hook.rs b/src/reloaded-code-serdesai/examples/hooks/run/serdesai-run-config-hook.rs new file mode 100644 index 00000000..89067c17 --- /dev/null +++ b/src/reloaded-code-serdesai/examples/hooks/run/serdesai-run-config-hook.rs @@ -0,0 +1,124 @@ +//! `RunConfigHook` preamble injection on both run paths with a mock model. +//! +//! This example registers a `RunConfigHook` via `AgentRuntimeBuilder::hooks()` +//! that injects a preamble message into every run's config, then runs the +//! same agent through `HookedAgent::run()` and `HookedAgent::run_stream()`. +//! +//! Mode scoping: `RunConfigHook` fires on `run()` and `run_stream()`. +//! `RunHook` keeps lifecycle control on `run()` only; run the +//! `serdesai-run-hook` example for that demo. +//! +//! Expected output: +//! Built agent with 0 tools. +//! [PreambleInjector] injecting preamble for agent=config-hook-demo +//! run() prompt: +//! [System] You are a helpful assistant. +//! +//! Say hello. +//! [PreambleInjector] injecting preamble for agent=config-hook-demo +//! run_stream() prompt: +//! [System] You are a helpful assistant. +//! +//! Say hello. +//! +//! Run with: +//! cargo run --example serdesai-run-config-hook -p reloaded-code-serdesai --features mock + +use futures::StreamExt; +use reloaded_code_agents::AgentCatalog; +use reloaded_code_core::{ + HookRunContext, HookSet, PreambleMessage, PreambleRole, RunConfig, RunConfigHook, + RunConfigHookFuture, +}; +use reloaded_code_serdesai::RunEvent; +use reloaded_code_serdesai::mock::{FunctionModel, Streamed}; +use serdes_ai::core::{ModelResponse, UserContent, UserContentPart}; + +#[path = "../shared.rs"] +mod shared; + +struct PreambleInjector; + +impl RunConfigHook for PreambleInjector { + fn configure<'a>( + &'a self, + ctx: &'a HookRunContext<'a>, + config: &'a mut RunConfig, + ) -> RunConfigHookFuture<'a> { + Box::pin(async move { + println!( + "[PreambleInjector] injecting preamble for agent={}", + ctx.agent_name + ); + config.preamble_messages.push(PreambleMessage { + role: PreambleRole::System, + content: "You are a helpful assistant.".into(), + }); + Ok(()) + }) + } +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + let hooks = HookSet::builder().run_config_hook(PreambleInjector).build(); + + let catalog = AgentCatalog::from_entries([shared::agent_config( + "config-hook-demo", + "config hook demo", + "You are a config hook demo agent.", + )]); + + let build_context = shared::build_agent_context(catalog, hooks); + + let agent = build_context + .with_model_override(echo_model()) + .build("config-hook-demo")?; + println!("Built agent with {} tools.", agent.tools().len()); + + let response = agent.run("Say hello.", ()).await?; + println!("run() prompt:\n{}", response.output()); + + let mut stream = agent.run_stream("Say hello.", ()).await?; + let mut streamed_prompt = String::new(); + while let Some(item) = stream.next().await { + if let RunEvent::TextDelta { text } = item? { + streamed_prompt.push_str(&text); + } + } + println!("run_stream() prompt:\n{streamed_prompt}"); + Ok(()) +} + +/// Returns a mock model that echoes the last user prompt as its response. +fn echo_model() -> Streamed { + Streamed::new(FunctionModel::new(|messages, _settings| { + let prompt = messages + .iter() + .rev() + .flat_map(|message| message.user_prompts()) + .next() + .map(|part| render_prompt(&part.content)) + .unwrap_or_default(); + ModelResponse::text(prompt) + })) +} + +/// Renders a user prompt the way a text-only model would read it. +/// +/// The streaming path delivers the prompt as text parts with the injected +/// section head first, so text parts join with a blank line to rebuild the +/// text the non-streaming path receives in one piece. +fn render_prompt(content: &UserContent) -> String { + match content { + UserContent::Text(text) => text.clone(), + UserContent::Parts(parts) => parts + .iter() + .filter_map(|part| match part { + UserContentPart::Text { text } => Some(text.as_str()), + _ => None, + }) + .collect::>() + .join("\n\n"), + } +} diff --git a/src/reloaded-code-serdesai/examples/hooks/run/serdesai-run-hook.rs b/src/reloaded-code-serdesai/examples/hooks/run/serdesai-run-hook.rs index 75f47280..c46318a1 100644 --- a/src/reloaded-code-serdesai/examples/hooks/run/serdesai-run-hook.rs +++ b/src/reloaded-code-serdesai/examples/hooks/run/serdesai-run-hook.rs @@ -2,52 +2,41 @@ //! //! This example registers a `RunHook` via `AgentRuntimeBuilder::hooks()`, //! builds an agent with `AgentBuildContext::with_model_override()` using a -//! mock model, and runs it. The hook injects a system preamble via -//! `RunConfig` and prints a confirmation message. +//! mock model, and runs it. The hook prints a confirmation message. //! //! Expected output: //! Built agent with 0 tools. -//! [PreambleInjector] injecting preamble for agent=hook-demo +//! [RunLogger] run starting for agent=hook-demo //! Output: Mock response //! //! Run with: //! cargo run --example serdesai-run-hook -p reloaded-code-serdesai --features mock use reloaded_code_agents::AgentCatalog; -use reloaded_code_core::{ - HookRunContext, HookSet, PreambleMessage, PreambleRole, RunConfig, RunHook, RunHookFuture, - RunOriginal, -}; +use reloaded_code_core::{HookRunContext, HookSet, RunConfig, RunHook, RunHookFuture, RunOriginal}; #[path = "../shared.rs"] mod shared; -struct PreambleInjector; +struct RunLogger; -impl RunHook for PreambleInjector { +impl RunHook for RunLogger { fn hook<'a>( &'a self, ctx: &'a HookRunContext<'a>, - mut config: RunConfig, + _config: &'a RunConfig, original: RunOriginal<'a>, ) -> RunHookFuture<'a> { Box::pin(async move { - println!( - "[PreambleInjector] injecting preamble for agent={}", - ctx.agent_name - ); - config.preamble_messages.push(PreambleMessage { - role: PreambleRole::System, - content: "You are a helpful assistant.".into(), - }); - original.call(ctx, config).await + println!("[RunLogger] run starting for agent={}", ctx.agent_name); + original.call(ctx).await }) } } #[tokio::main] async fn main() -> Result<(), Box> { - let hooks = HookSet::builder().run_hook(PreambleInjector).build(); + let hooks = HookSet::builder().run_hook(RunLogger).build(); let catalog = AgentCatalog::from_entries([shared::agent_config( "hook-demo", diff --git a/src/reloaded-code-serdesai/src/agent_runtime/mod.rs b/src/reloaded-code-serdesai/src/agent_runtime/mod.rs index c996a5ae..29130a57 100644 --- a/src/reloaded-code-serdesai/src/agent_runtime/mod.rs +++ b/src/reloaded-code-serdesai/src/agent_runtime/mod.rs @@ -6,9 +6,10 @@ //! //! # Public API //! - [`AgentBuildContext`] - Shared context that builds runnable agents by name. -//! - [`HookedAgent`] - Built agent wrapper that dispatches `run()` through run -//! hooks and streams framework-owned events from `run_stream()`, passing -//! each through the registered run-event hooks. +//! - [`HookedAgent`] - Built agent wrapper that dispatches `run()` through +//! run-config and run hooks, resolves run-config hooks once before +//! `run_stream()` starts, and passes each streamed event through the +//! registered run-event hooks. //! - [`AgentBuildError`] - Build-time failures. pub use build::AgentBuildError; diff --git a/src/reloaded-code-serdesai/src/agent_runtime/stream_events.rs b/src/reloaded-code-serdesai/src/agent_runtime/stream_events.rs index 4615543c..bf839bfe 100644 --- a/src/reloaded-code-serdesai/src/agent_runtime/stream_events.rs +++ b/src/reloaded-code-serdesai/src/agent_runtime/stream_events.rs @@ -455,12 +455,14 @@ mod tests { use futures::StreamExt; use reloaded_code_agents::{AgentCatalog, AgentDefaults, AgentMode, AgentRuntimeBuilder}; use reloaded_code_core::hooks::{ - HookRunContext, HookSet, RunConfig, RunEventContext, RunEventHook, RunEventHookResult, + HookRunContext, HookSet, ModelSettingsOverrides, PreambleMessage, PreambleRole, RunConfig, + RunConfigHook, RunConfigHookFuture, RunEventContext, RunEventHook, RunEventHookResult, RunHook, RunHookFuture, RunOriginal, }; use reloaded_code_core::{ToolCatalogEntry, ToolCatalogKind, ToolError}; use rstest::rstest; use serde_json::json; + use serdes_ai::core::ModelSettings; use serdes_ai::core::messages::request::RetryPromptPart; use serdes_ai::core::{ BuiltinToolReturnContent, BuiltinToolReturnPart, SystemPromptPart, ToolReturnPart, @@ -773,14 +775,14 @@ mod tests { fn hook<'a>( &'a self, ctx: &'a HookRunContext<'a>, - config: RunConfig, + _config: &'a RunConfig, original: RunOriginal<'a>, ) -> RunHookFuture<'a> { self.dispatches .lock() .expect("dispatches should not be poisoned") .push(ctx.run_id.to_string()); - original.call(ctx, config) + original.call(ctx) } } @@ -1254,6 +1256,385 @@ mod tests { ); } + // ======================================================================== + // Run-config hook pre-pass + // ======================================================================== + + /// Section head [`SectionInjectingConfigHook`] produces: system + /// prompt, then preamble messages in configured order with their + /// role prefixes. Byte-identical to the prepended sections the + /// `run()` path produces from the same config; stream assertions + /// append the blank-line separator the head part carries. + const SECTION_HEAD: &str = "agent system override\n\n[System] sys note\n\n[User] user note"; + + /// Run-config hook that records its dispatch on a shared timeline + /// plus the identity fields of the first hook context it observes. + struct TimelineConfigHook { + timeline: Arc>>, + context: Arc>>, + } + + impl RunConfigHook for TimelineConfigHook { + fn configure<'a>( + &'a self, + ctx: &'a HookRunContext<'a>, + _config: &'a mut RunConfig, + ) -> RunConfigHookFuture<'a> { + Box::pin(async move { + self.timeline + .lock() + .expect("timeline should not be poisoned") + .push("config"); + let mut context = self + .context + .lock() + .expect("context record should not be poisoned"); + if context.is_none() { + *context = Some(( + ctx.agent_name.to_string(), + ctx.model_name.to_string(), + ctx.run_id.to_string(), + )); + } + Ok(()) + }) + } + } + + /// Run-event hook that records each published event on the shared + /// timeline. + struct TimelineEventHook { + timeline: Arc>>, + } + + impl RunEventHook for TimelineEventHook { + fn hook(&self, _ctx: &RunEventContext<'_>, event: RunEvent) -> RunEventHookResult { + self.timeline + .lock() + .expect("timeline should not be poisoned") + .push("event"); + Ok(Some(event)) + } + } + + /// Run-config hook that injects one system prompt plus a system-role + /// and a user-role preamble message. + struct SectionInjectingConfigHook; + + impl RunConfigHook for SectionInjectingConfigHook { + fn configure<'a>( + &'a self, + _ctx: &'a HookRunContext<'a>, + config: &'a mut RunConfig, + ) -> RunConfigHookFuture<'a> { + Box::pin(async move { + config.system_prompt = Some("agent system override".into()); + config.preamble_messages = vec![ + PreambleMessage { + role: PreambleRole::System, + content: "sys note".into(), + }, + PreambleMessage { + role: PreambleRole::User, + content: "user note".into(), + }, + ]; + Ok(()) + }) + } + } + + /// Run-config hook that installs a temperature override only. + struct TemperatureOverrideConfigHook; + + impl RunConfigHook for TemperatureOverrideConfigHook { + fn configure<'a>( + &'a self, + _ctx: &'a HookRunContext<'a>, + config: &'a mut RunConfig, + ) -> RunConfigHookFuture<'a> { + Box::pin(async move { + config.model_settings_overrides = Some(ModelSettingsOverrides { + temperature: Some(0.9), + top_p: None, + }); + Ok(()) + }) + } + } + + /// Run-config hook that fails its configuration step. + struct FailingStreamConfigHook; + + impl RunConfigHook for FailingStreamConfigHook { + fn configure<'a>( + &'a self, + _ctx: &'a HookRunContext<'a>, + _config: &'a mut RunConfig, + ) -> RunConfigHookFuture<'a> { + Box::pin(async { + Err(ToolError::Execution( + "stream config hook rejected the run".into(), + )) + }) + } + } + + /// One captured streamed model request: its settings and prompt. + struct CapturedStreamRequest { + settings: ModelSettings, + prompt: UserContent, + } + + /// Builds a hooked `caller` agent with agent-level settings + /// temperature 0.3 and top_p 0.8, streaming a model that records + /// every request's settings and prompt content and answers with + /// text. + fn streamed_agent_with_request_capture( + hooks: HookSet, + ) -> (HookedAgent, Arc>>) { + let captured: Arc>> = Arc::new(Mutex::new(Vec::new())); + let seen = Arc::clone(&captured); + let model = Streamed::new(FunctionModel::new(move |messages, settings| { + let prompt = messages + .iter() + .rev() + .flat_map(|message| message.user_prompts()) + .next() + .map(|part| part.content.clone()) + .expect("streamed request should carry a user prompt"); + seen.lock() + .expect("captured requests should not be poisoned") + .push(CapturedStreamRequest { + settings: settings.clone(), + prompt, + }); + ModelResponse::text("streamed answer") + })); + + let mut defaults = AgentDefaults::with_model("openrouter/openai/gpt-4.1-mini"); + defaults.temperature = Some(0.3); + defaults.top_p = Some(0.8); + let runtime = AgentRuntimeBuilder::new() + .catalog(AgentCatalog::from_entries([agent( + "caller", + AgentMode::Primary, + allow_tools(&[]), + "prompt", + )])) + .defaults(defaults) + .hooks(hooks) + .build() + .expect("runtime should build"); + + let context = AgentBuildContext::new( + Arc::new(runtime), + Arc::new(catalog()), + Arc::new(credentials()), + workspace_root(), + ) + .with_model_override(model); + let hooked = context.build("caller").expect("build should succeed"); + (hooked, captured) + } + + #[tokio::test] + async fn run_stream_fires_config_hook_once_before_the_first_event() { + let timeline = Arc::new(Mutex::new(Vec::new())); + let context = Arc::new(Mutex::new(None)); + let hooks = HookSet::builder() + .run_config_hook(TimelineConfigHook { + timeline: Arc::clone(&timeline), + context: Arc::clone(&context), + }) + .run_event_hook(TimelineEventHook { + timeline: Arc::clone(&timeline), + }) + .build(); + let hooked = streamed_agent( + Streamed::new(FunctionModel::new(|_, _| ModelResponse::text("answer"))), + hooks, + ); + + let events = collect_events(&hooked, "hello").await; + assert!( + matches!(events.last(), Some(RunEvent::RunComplete { .. })), + "the stream should complete: {events:?}" + ); + + let timeline = timeline.lock().expect("timeline should not be poisoned"); + assert_eq!( + timeline.first(), + Some(&"config"), + "config resolution must precede every streamed event: {timeline:?}" + ); + assert_eq!( + timeline.iter().filter(|mark| **mark == "config").count(), + 1, + "the config hook must fire exactly once per stream: {timeline:?}" + ); + assert!( + timeline.len() > 1, + "the stream should publish events after config resolution: {timeline:?}" + ); + drop(timeline); + + // The hook context carries the wrapper's identity fields; the + // model name comes from the catalog-resolved model, not the + // mock override that serves the requests. + let (agent_name, model_name, run_id) = context + .lock() + .expect("context record should not be poisoned") + .clone() + .expect("the config hook should have observed a context"); + assert_eq!(agent_name, "caller"); + assert_eq!(model_name, "openai/gpt-4.1-mini"); + assert!(!run_id.is_empty(), "the hook context must carry a run id"); + } + + #[tokio::test] + async fn run_stream_prepends_sections_as_leading_text_part_on_text_prompts() { + let (hooked, captured) = streamed_agent_with_request_capture( + HookSet::builder() + .run_config_hook(SectionInjectingConfigHook) + .build(), + ); + + let events = collect_events(&hooked, "base prompt").await; + assert!( + matches!(events.last(), Some(RunEvent::RunComplete { .. })), + "the stream should complete: {events:?}" + ); + + let seen = captured + .lock() + .expect("captured requests should not be poisoned"); + assert_eq!( + seen.len(), + 1, + "one streamed model request should have been made" + ); + assert_eq!( + seen[0].prompt, + UserContent::Parts(vec![ + UserContentPart::text(format!("{SECTION_HEAD}\n\n")), + UserContentPart::text("base prompt"), + ]), + "a text prompt must become two parts with the section head, \ + separator included, first" + ); + } + + #[tokio::test] + async fn run_stream_keeps_multipart_prompt_parts_with_section_head_first() { + let (hooked, captured) = streamed_agent_with_request_capture( + HookSet::builder() + .run_config_hook(SectionInjectingConfigHook) + .build(), + ); + + let prompt = UserContent::Parts(vec![ + UserContentPart::text("hello"), + UserContentPart::image_url("https://example.invalid/image.png"), + ]); + let events = collect_events(&hooked, prompt).await; + assert!( + matches!(events.last(), Some(RunEvent::RunComplete { .. })), + "the stream should complete: {events:?}" + ); + + let seen = captured + .lock() + .expect("captured requests should not be poisoned"); + assert_eq!( + seen.len(), + 1, + "one streamed model request should have been made" + ); + assert_eq!( + seen[0].prompt, + UserContent::Parts(vec![ + UserContentPart::text(format!("{SECTION_HEAD}\n\n")), + UserContentPart::text("hello"), + UserContentPart::image_url("https://example.invalid/image.png"), + ]), + "multipart prompts must keep their parts with the head, \ + separator included, at index zero" + ); + } + + #[tokio::test] + async fn run_stream_merges_settings_overrides_over_agent_settings_on_the_request() { + let (hooked, captured) = streamed_agent_with_request_capture( + HookSet::builder() + .run_config_hook(TemperatureOverrideConfigHook) + .build(), + ); + + let events = collect_events(&hooked, "hello").await; + assert!( + matches!(events.last(), Some(RunEvent::RunComplete { .. })), + "the stream should complete: {events:?}" + ); + + let seen = captured + .lock() + .expect("captured requests should not be poisoned"); + assert_eq!( + seen.len(), + 1, + "one streamed model request should have been made" + ); + assert_eq!(seen[0].settings.temperature, Some(f64::from(0.9_f32))); + assert_eq!( + seen[0].settings.top_p, + Some(f64::from(0.8_f32)), + "agent-configured top_p should be retained on the streamed request" + ); + assert_eq!( + seen[0].prompt, + UserContent::Text("hello".into()), + "a config hook injecting no sections must leave the prompt untouched" + ); + } + + #[tokio::test] + async fn run_stream_config_hook_error_returns_err_from_start_with_no_events() { + let (hooked, captured) = streamed_agent_with_request_capture( + HookSet::builder() + .run_config_hook(FailingStreamConfigHook) + .build(), + ); + + let error = hooked + .run_stream("base prompt", ()) + .await + .err() + .expect("the start call should fail"); + + match error { + serdes_ai::agent::AgentRunError::Other(source) => { + let message = source.to_string(); + assert!( + message.contains("run config hook error"), + "config-hook failure should be labeled as such: {message}" + ); + assert!( + message.contains("stream config hook rejected the run"), + "the hook's error text should be preserved: {message}" + ); + } + other => panic!("config-hook failure should surface as Other, got: {other:?}"), + } + assert!( + captured + .lock() + .expect("captured requests should not be poisoned") + .is_empty(), + "no model request may start when config resolution fails" + ); + } + // ======================================================================== // Run-event hook chain // ======================================================================== diff --git a/src/reloaded-code-serdesai/src/agent_runtime/task.rs b/src/reloaded-code-serdesai/src/agent_runtime/task.rs index 9b47e158..566eaba3 100644 --- a/src/reloaded-code-serdesai/src/agent_runtime/task.rs +++ b/src/reloaded-code-serdesai/src/agent_runtime/task.rs @@ -2,9 +2,10 @@ //! //! # Public API //! - [`AgentBuildContext`] - Reusable shared inputs for building runnable agents. -//! - [`HookedAgent`] - Built agent wrapper that dispatches `run()` through run -//! hooks and streams framework-owned events from `run_stream()`, passing -//! each through the registered run-event hooks. +//! - [`HookedAgent`] - Built agent wrapper that dispatches `run()` through +//! the registered run-config and run hooks, resolves run-config hooks +//! once before `run_stream()` starts, and passes each streamed event +//! through the registered run-event hooks. #[cfg(not(all(feature = "linux-bubblewrap", target_os = "linux")))] use super::build::Profile; @@ -20,6 +21,7 @@ use reloaded_code_core::hooks::{ RunExecutor, RunHookFuture, RunOutput, RunUsage, }; use reloaded_code_core::{CredentialLookup, CredentialResolver, models::ModelCatalog}; +use serdes_ai::core::{UserContent, UserContentPart}; use serdes_ai::{Agent, AgentBuilder, RunOptions}; #[cfg(any(test, feature = "mock"))] use serdes_ai_models::BoxedModel; @@ -27,6 +29,14 @@ use std::path::Path; use std::pin::Pin; use std::sync::{Arc, Mutex}; +/// Prefix marking a preamble message as system-role in the prompt text. +const PREAMBLE_SYSTEM_PREFIX: &str = "[System] "; +/// Prefix marking a preamble message as user-role in the prompt text. +const PREAMBLE_USER_PREFIX: &str = "[User] "; +/// Blank line separating two prompt sections, and the section head from +/// the original prompt on both run paths. +const SECTION_SEPARATOR: &str = "\n\n"; + /// Reusable shared inputs for building runnable SerdesAI agents. /// /// Create once and call [`AgentBuildContext::build`] for each catalog agent @@ -40,12 +50,16 @@ pub struct AgentBuildContext, hooks: HookSet, @@ -319,31 +333,37 @@ impl HookedAgent { self.inner.tools() } - /// Runs the agent with the given prompt, dispatching through run hooks. + /// Runs the agent with the given prompt, dispatching through the + /// registered run-config and run hooks. /// - /// When no run hooks are registered this delegates directly to the inner - /// agent for zero overhead. Otherwise it builds a `RunConfig`, runs the - /// hook chain, applies any `preamble_messages` or `system_prompt` - /// mutations to the prompt text, applies `model_settings_overrides` to - /// the per-run model settings, and returns the result. + /// With no run-config or run hooks registered this delegates directly + /// to the inner agent. Otherwise [`RunConfigHook`][config-hook]s amend + /// the run config first; the run hook chain then observes the final + /// config. /// - /// Mode-scoped: run hooks fire only on this path. A registered - /// [`RunEventHook`][event-hook] never fires here; it fires only on - /// [`Self::run_stream`]. + /// Run hooks fire only on this path. Run-config hooks fire here and + /// on [`Self::run_stream`]. Run-event hooks + /// ([`RunEventHook`][event-hook]) fire only on [`Self::run_stream`]. /// - /// The run-hook context carries a wrapper-generated `run_id`. The inner - /// agent assigns its own id for tool hooks; SerdesAI `RunOptions` has no - /// field to override it, so the two identifiers cannot be unified here. + /// The hook context carries a wrapper-generated `run_id`. The inner + /// agent assigns its own id for tool hooks; SerdesAI `RunOptions` has + /// no field to override it, so the two cannot be unified. /// /// # Errors /// /// - Returns the inner agent's [`serdes_ai::agent::AgentRunError`] - /// unchanged when the inner agent fails (direct run or hooked run) - /// and the failure reaches the caller untouched. - /// - Returns [`serdes_ai::agent::AgentRunError::Other`] when a run hook - /// returns or substitutes its own error during dispatch. + /// unchanged when the inner agent fails and the failure reaches the + /// caller untouched. + /// - Returns [`serdes_ai::agent::AgentRunError::Other`] when a + /// run-config hook or run hook returns or substitutes its own error + /// during dispatch. + /// - With no run hooks registered, a failing run-config hook is + /// labeled `run config hook error`. + /// - With run hooks registered, any dispatch failure that is not the + /// untouched inner error is labeled `run hook error`. /// /// [event-hook]: reloaded_code_core::hooks::RunEventHook + /// [config-hook]: reloaded_code_core::hooks::RunConfigHook pub async fn run( &self, prompt: impl Into, @@ -359,14 +379,15 @@ impl HookedAgent { /// - Returns the inner agent's [`serdes_ai::agent::AgentRunError`] /// unchanged when the inner agent fails (direct run or hooked run) /// and the failure reaches the caller untouched. - /// - Returns [`serdes_ai::agent::AgentRunError::Other`] when a run hook - /// returns or substitutes its own error during dispatch. + /// - Returns [`serdes_ai::agent::AgentRunError::Other`] when a + /// run-config hook or run hook returns or substitutes its own error + /// during dispatch. async fn run_hooked( &self, prompt: String, deps: (), ) -> Result { - if self.hooks.run_hooks_is_empty() { + if self.hooks.run_hooks_is_empty() && self.hooks.run_config_hooks_is_empty() { let response = self.inner.run(prompt, deps).await?; let serdes_ai::agent::AgentRunResult { output, .. } = response; return Ok(HookedAgentRunResult { content: output }); @@ -396,7 +417,13 @@ impl HookedAgent { // and only label the error hook-origin otherwise. let output = match self.hooks.dispatch_run(&ctx, config, &executor).await { Ok(output) => output, - Err(dispatched) => return Err(restore_run_error(dispatched, &error_slot)), + Err(dispatched) => { + return Err(restore_run_error( + dispatched, + &error_slot, + !self.hooks.run_hooks_is_empty(), + )); + } }; Ok(HookedAgentRunResult::from_run_output(output)) @@ -405,27 +432,33 @@ impl HookedAgent { /// Runs the agent in streaming mode, yielding framework-owned /// [`RunEvent`]s. /// - /// Starts the inner agent's stream and lazily maps each vendor event as - /// the stream is polled, so real incremental text and thinking deltas - /// reach the caller as they arrive. The mapped - /// [`RunEvent::RunComplete`] carries the inner run's id and a distilled - /// transcript. The prompt accepts full - /// [`UserContent`][serdes_ai::core::UserContent]; image and multi-part - /// prompts pass through to the vendor unchanged. + /// Starts the inner agent's stream and lazily maps each vendor event + /// as the stream is polled, so real incremental text and thinking + /// deltas reach the caller as they arrive. The mapped + /// [`RunEvent::RunComplete`] carries the inner run's id and a + /// distilled transcript. The prompt accepts full [`UserContent`]; + /// image and multi-part prompts keep their parts when no sections + /// are injected. /// - /// Each mapped event passes the registered - /// [`RunEventHook`][event-hook] chain, in registration order, before the - /// caller sees it, so a hook may rewrite or suppress it. With no - /// run-event hooks registered the stream matches the unhooked mapping. + /// # Hooks /// - /// Run hooks ([`RunHook`][run-hook]) never fire here; they fire only - /// on [`Self::run`]. The run-hook chain resolves to one completed - /// `RunOutput`, so dispatching it would buffer the whole run before - /// the first event and defeat streaming. Preamble, system-prompt, - /// and model-settings injection therefore apply on that path only. + /// - **Run-event**: each mapped event passes the registered + /// [`RunEventHook`][event-hook] chain, in registration order, + /// before the caller sees it; a hook may rewrite or suppress it. + /// - **Run-config**: [`RunConfigHook`][config-hook]s fire once + /// before the stream starts. The system prompt and preamble + /// messages become a leading text part of the prompt; + /// model-settings overrides merge field-wise over the + /// agent's configured settings. + /// - **Run**: [`RunHook`][run-hook]s never fire here; they fire + /// only on [`Self::run`], because dispatching them would buffer + /// the whole run before the first event. /// /// # Errors /// + /// - Returns [`serdes_ai::agent::AgentRunError::Other`] labeled + /// `run config hook error: ...` when a run-config hook fails; the + /// failure surfaces from this start call before any event exists. /// - Returns the inner agent's [`serdes_ai::agent::AgentRunError`] /// unchanged when starting the stream fails. /// - The stream yields the inner error as its final `Err` item when @@ -437,6 +470,7 @@ impl HookedAgent { /// /// [event-hook]: reloaded_code_core::hooks::RunEventHook /// [run-hook]: reloaded_code_core::hooks::RunHook + /// [config-hook]: reloaded_code_core::hooks::RunConfigHook pub async fn run_stream( &self, prompt: impl Into, @@ -445,7 +479,51 @@ impl HookedAgent { Pin> + Send>>, serdes_ai::agent::AgentRunError, > { - let inner = self.inner.run_stream(prompt, deps).await?; + // Run hooks never fire on the stream, so only the config chain + // gates the pre-pass; an empty config chain streams the inner + // agent directly, exactly like the unhooked path. + if self.hooks.run_config_hooks_is_empty() { + let inner = self.inner.run_stream(prompt, deps).await?; + return Ok(Box::pin(RunEventStream::new( + inner, + &self.hooks, + &self.agent_name, + &self.model_name, + ))); + } + + // Wrapper-assigned run id for the hook context. The inner agent + // generates its own id for the streamed run; see the `run` doc + // comment. + let run_id = serdes_ai::agent::generate_run_id(); + let ctx = HookRunContext { + agent_name: &self.agent_name, + run_id: &run_id, + model_name: &self.model_name, + }; + + // Config resolution completes once, before the stream starts; + // its failure aborts the start call instead of surfacing + // mid-stream. + let config = self + .hooks + .dispatch_run_config(&ctx, RunConfig::default()) + .await + .map_err(|dispatched| { + serdes_ai::agent::AgentRunError::Other(anyhow::anyhow!( + "run config hook error: {dispatched}" + )) + })?; + + let prompt = prepend_section_head(prompt.into(), run_config_head(&config)); + let inner = match run_options_with_overrides(&self.inner, config.model_settings_overrides) { + Some(options) => { + self.inner + .run_stream_with_options(prompt, deps, options) + .await? + } + None => self.inner.run_stream(prompt, deps).await?, + }; Ok(Box::pin(RunEventStream::new( inner, &self.hooks, @@ -551,21 +629,11 @@ impl<'a> RunExecutor for SerdesRunExecutor<'a> { let agent = self.agent; let mut prompt = self.prompt.clone(); - // Apply RunConfig modifications that can be expressed by prepending - // to the prompt text. Order: system prompt, preamble messages in - // configured order, then the original prompt. - let mut sections: Vec = Vec::new(); - if let Some(sys) = &config.system_prompt { - sections.push(sys.clone()); - } - for msg in &config.preamble_messages { - match msg.role { - PreambleRole::System => sections.push(format!("[System] {}", msg.content)), - PreambleRole::User => sections.push(format!("[User] {}", msg.content)), - } - } - if !sections.is_empty() { - prompt = format!("{}\n\n{prompt}", sections.join("\n\n")); + // Config sections render textually before the prompt: system + // prompt first, then preamble messages in configured order, + // then the original prompt. + if let Some(head) = run_config_head(&config) { + prompt = format!("{head}{SECTION_SEPARATOR}{prompt}"); } let error = Arc::clone(&self.error); @@ -684,17 +752,43 @@ where Ok(HookedAgent::new(agent, hooks, name.to_string(), model_name)) } +/// Prepends a config-injected head (from [`run_config_head`]) to a +/// `run_stream` prompt as its own leading text part, so the original +/// prompt content follows it unchanged. +/// +/// The head goes in as a separate part because stream prompts carry +/// full [`UserContent`]: a text prompt becomes two parts, head first, +/// and a multi-part prompt keeps its parts with the head inserted at +/// index zero, so image and other parts survive. A `None` head +/// returns the prompt unchanged. +fn prepend_section_head(prompt: UserContent, head: Option) -> UserContent { + let Some(head) = head else { + return prompt; + }; + let head_part = UserContentPart::text(format!("{head}{SECTION_SEPARATOR}")); + match prompt { + UserContent::Text(text) => UserContent::Parts(vec![head_part, UserContentPart::text(text)]), + UserContent::Parts(mut parts) => { + parts.insert(0, head_part); + UserContent::Parts(parts) + } + } +} + /// Recovers the failure from a failed run dispatch. /// /// Restores the captured inner-agent [`AgentRunError`] when the hook chain /// propagated its projection untouched. Any other dispatched error reached /// the caller through a hook returning or substituting its own error, so it -/// is labeled as hook-origin. +/// is labeled as hook-origin. With no run hooks registered, only the +/// run-config chain can have produced the error, so it carries the +/// config-hook label; otherwise the run-hook label applies. /// /// [`AgentRunError`]: serdes_ai::agent::AgentRunError fn restore_run_error( dispatched: reloaded_code_core::ToolError, captured: &Mutex>, + run_hooks_registered: bool, ) -> serdes_ai::agent::AgentRunError { let captured = captured .lock() @@ -702,10 +796,64 @@ fn restore_run_error( .take(); match captured { Some(inner) if run_error_projection(&inner).to_string() == dispatched.to_string() => inner, - _ => { + _ if run_hooks_registered => { serdes_ai::agent::AgentRunError::Other(anyhow::anyhow!("run hook error: {dispatched}")) } + _ => serdes_ai::agent::AgentRunError::Other(anyhow::anyhow!( + "run config hook error: {dispatched}" + )), + } +} + +/// Builds the leading text placed in front of the user's prompt when a +/// run config injects a system prompt or preamble messages. +/// +/// The head starts with the system prompt, then preamble messages in +/// configured order, tagged `[System]` or `[User]` by role. A blank +/// line separates consecutive sections. +/// +/// Returns `None` when the config injects nothing, leaving the prompt +/// unchanged. [`SerdesRunExecutor::execute`] and +/// [`HookedAgent::run_stream`] share this builder, so both run paths +/// prepend the same head bytes. +fn run_config_head(config: &RunConfig) -> Option { + let section_count = + config.preamble_messages.len() + usize::from(config.system_prompt.is_some()); + if section_count == 0 { + return None; + } + // Capacity: every section's content plus the longest role prefix and + // one blank-line separator per section; a slight overestimate is + // harmless. + let estimated_len = (PREAMBLE_SYSTEM_PREFIX.len() + SECTION_SEPARATOR.len()) * section_count + + config.system_prompt.as_ref().map_or(0, String::len) + + config + .preamble_messages + .iter() + .map(|message| message.content.len()) + .sum::(); + let mut head = String::with_capacity(estimated_len); + let mut first_section = true; + if let Some(system_prompt) = &config.system_prompt { + head.push_str(system_prompt); + first_section = false; + } + for message in &config.preamble_messages { + // One blank line between every section pair, whatever the + // section content, so empty sections keep their separator and + // the rendered bytes stay stable. + if !first_section { + head.push_str(SECTION_SEPARATOR); + } + first_section = false; + let prefix = match message.role { + PreambleRole::System => PREAMBLE_SYSTEM_PREFIX, + PreambleRole::User => PREAMBLE_USER_PREFIX, + }; + head.push_str(prefix); + head.push_str(&message.content); } + Some(head) } /// Builds per-run [`RunOptions`] that merge [`ModelSettingsOverrides`] over @@ -759,8 +907,8 @@ mod tests { use reloaded_code_agents::{AgentCatalog, AgentDefaults, AgentMode, AgentRuntimeBuilder}; use reloaded_code_core::ToolOutput; use reloaded_code_core::hooks::{ - ModelSettingsOverrides, PreambleMessage, PreambleRole, RunHook, RunOriginal, - ToolCallContext, ToolHook, ToolHookFuture, ToolOriginal, ToolRequest, + ModelSettingsOverrides, PreambleMessage, PreambleRole, RunConfigHook, RunConfigHookFuture, + RunHook, RunOriginal, ToolCallContext, ToolHook, ToolHookFuture, ToolOriginal, ToolRequest, }; use reloaded_code_core::permissions::{ExpandError, PermissionAction}; use reloaded_code_core::tool_metadata::{ @@ -1077,55 +1225,56 @@ mod tests { /// Model settings overrides: applied per run, merged over the agent's /// configured settings, with prompt-prepend behavior untouched. /// - /// Run hook that installs fixed model settings overrides before - /// delegating to `original`. - struct OverridingRunHook { + /// Run-config hook that installs fixed model settings overrides. + struct OverridingConfigHook { temperature: Option, top_p: Option, } - impl RunHook for OverridingRunHook { - fn hook<'a>( + impl RunConfigHook for OverridingConfigHook { + fn configure<'a>( &'a self, - ctx: &'a HookRunContext<'a>, - mut config: RunConfig, - original: RunOriginal<'a>, - ) -> RunHookFuture<'a> { - config.model_settings_overrides = Some(ModelSettingsOverrides { - temperature: self.temperature, - top_p: self.top_p, - }); - original.call(ctx, config) + _ctx: &'a HookRunContext<'a>, + config: &'a mut RunConfig, + ) -> RunConfigHookFuture<'a> { + let temperature = self.temperature; + let top_p = self.top_p; + Box::pin(async move { + config.model_settings_overrides = + Some(ModelSettingsOverrides { temperature, top_p }); + Ok(()) + }) } } - /// Run hook that injects prompt sections plus a temperature override - /// before delegating to `original`. - struct PromptAndSettingsOverrideRunHook; + /// Run-config hook that injects prompt sections plus a temperature + /// override. + struct PromptAndSettingsOverrideConfigHook; - impl RunHook for PromptAndSettingsOverrideRunHook { - fn hook<'a>( + impl RunConfigHook for PromptAndSettingsOverrideConfigHook { + fn configure<'a>( &'a self, - ctx: &'a HookRunContext<'a>, - mut config: RunConfig, - original: RunOriginal<'a>, - ) -> RunHookFuture<'a> { - config.system_prompt = Some("agent system override".into()); - config.preamble_messages = vec![ - PreambleMessage { - role: PreambleRole::System, - content: "sys note".into(), - }, - PreambleMessage { - role: PreambleRole::User, - content: "user note".into(), - }, - ]; - config.model_settings_overrides = Some(ModelSettingsOverrides { - temperature: Some(0.9), - top_p: None, - }); - original.call(ctx, config) + _ctx: &'a HookRunContext<'a>, + config: &'a mut RunConfig, + ) -> RunConfigHookFuture<'a> { + Box::pin(async move { + config.system_prompt = Some("agent system override".into()); + config.preamble_messages = vec![ + PreambleMessage { + role: PreambleRole::System, + content: "sys note".into(), + }, + PreambleMessage { + role: PreambleRole::User, + content: "user note".into(), + }, + ]; + config.model_settings_overrides = Some(ModelSettingsOverrides { + temperature: Some(0.9), + top_p: None, + }); + Ok(()) + }) } } @@ -1133,7 +1282,7 @@ mod tests { /// top_p 0.8, running a model that records the [`ModelSettings`] of every /// request and echoes the last user prompt. fn hooked_agent_with_settings_capture( - hook: impl RunHook + 'static, + hooks: HookSet, ) -> (HookedAgent, Arc>>) { let captured: Arc>> = Arc::new(Mutex::new(Vec::new())); let seen = Arc::clone(&captured); @@ -1152,7 +1301,6 @@ mod tests { ModelResponse::text(last_user) }); - let hooks = HookSet::builder().run_hook(hook).build(); let mut defaults = AgentDefaults::with_model("openrouter/openai/gpt-4.1-mini"); defaults.temperature = Some(0.3); defaults.top_p = Some(0.8); @@ -1181,10 +1329,14 @@ mod tests { #[tokio::test] async fn model_settings_override_replaces_only_the_overridden_setting_in_request() { - let (hooked, captured) = hooked_agent_with_settings_capture(OverridingRunHook { - temperature: Some(0.9), - top_p: None, - }); + let (hooked, captured) = hooked_agent_with_settings_capture( + HookSet::builder() + .run_config_hook(OverridingConfigHook { + temperature: Some(0.9), + top_p: None, + }) + .build(), + ); hooked.run("hello", ()).await.expect("run should complete"); @@ -1203,10 +1355,14 @@ mod tests { // Mirror direction: a top_p-only override replaces top_p and keeps // the agent-configured temperature. - let (hooked, captured) = hooked_agent_with_settings_capture(OverridingRunHook { - temperature: None, - top_p: Some(0.6), - }); + let (hooked, captured) = hooked_agent_with_settings_capture( + HookSet::builder() + .run_config_hook(OverridingConfigHook { + temperature: None, + top_p: Some(0.6), + }) + .build(), + ); hooked.run("hello", ()).await.expect("run should complete"); @@ -1227,14 +1383,20 @@ mod tests { #[tokio::test] async fn run_without_model_settings_overrides_uses_agent_configured_settings() { // Absent overrides: `RunConfig::default()` flows through untouched. - let (hooked, captured) = hooked_agent_with_settings_capture(PassthroughRunHook); + let (hooked, captured) = hooked_agent_with_settings_capture( + HookSet::builder().run_hook(PassthroughRunHook).build(), + ); hooked.run("hello", ()).await.expect("run should complete"); // All-None overrides: no field is set, so agent settings apply as-is. - let (hooked, captured_empty) = hooked_agent_with_settings_capture(OverridingRunHook { - temperature: None, - top_p: None, - }); + let (hooked, captured_empty) = hooked_agent_with_settings_capture( + HookSet::builder() + .run_config_hook(OverridingConfigHook { + temperature: None, + top_p: None, + }) + .build(), + ); hooked.run("hello", ()).await.expect("run should complete"); let expected = ModelSettings { @@ -1256,8 +1418,11 @@ mod tests { #[tokio::test] async fn prompt_sections_are_unchanged_when_model_settings_overrides_are_present() { - let (hooked, captured) = - hooked_agent_with_settings_capture(PromptAndSettingsOverrideRunHook); + let (hooked, captured) = hooked_agent_with_settings_capture( + HookSet::builder() + .run_config_hook(PromptAndSettingsOverrideConfigHook) + .build(), + ); let output = hooked .run("base prompt", ()) @@ -1285,6 +1450,62 @@ mod tests { ); } + /// Run-config hook that records each dispatch it observes and injects + /// one preamble section, standing in for config-hooks-only + /// registrations. + struct RecordingConfigHook { + fires: Arc>>, + } + + impl RunConfigHook for RecordingConfigHook { + fn configure<'a>( + &'a self, + ctx: &'a HookRunContext<'a>, + config: &'a mut RunConfig, + ) -> RunConfigHookFuture<'a> { + Box::pin(async move { + self.fires + .lock() + .expect("fires should not be poisoned") + .push(ctx.run_id.to_string()); + config.preamble_messages.push(PreambleMessage { + role: PreambleRole::User, + content: "config-only note".into(), + }); + Ok(()) + }) + } + } + + #[tokio::test] + async fn run_applies_config_hooks_when_no_run_hooks_are_registered() { + // A config-hooks-only registration leaves the run-hook chain + // empty, so the dispatch gate must still route through the hook + // machinery instead of taking the direct fast path. + let fires = Arc::new(Mutex::new(Vec::new())); + let (hooked, _captured) = hooked_agent_with_settings_capture( + HookSet::builder() + .run_config_hook(RecordingConfigHook { + fires: Arc::clone(&fires), + }) + .build(), + ); + + let output = hooked + .run("base prompt", ()) + .await + .expect("run should complete") + .into_output(); + + assert_eq!( + output, "[User] config-only note\n\nbase prompt", + "the config hook's preamble section must reach the model request" + ); + let fires = fires.lock().expect("fires should not be poisoned"); + assert_eq!(fires.len(), 1, "the config hook must fire exactly once"); + assert!(!fires[0].is_empty(), "the hook context must carry a run id"); + } + /// Model whose every request fails, so the inner agent run surfaces a /// real `AgentRunError::Model` failure. struct FailingModel { @@ -1340,10 +1561,10 @@ mod tests { fn hook<'a>( &'a self, ctx: &'a HookRunContext<'a>, - config: RunConfig, + _config: &'a RunConfig, original: RunOriginal<'a>, ) -> RunHookFuture<'a> { - original.call(ctx, config) + original.call(ctx) } } @@ -1354,7 +1575,7 @@ mod tests { fn hook<'a>( &'a self, _ctx: &'a HookRunContext<'a>, - _config: RunConfig, + _config: &'a RunConfig, _original: RunOriginal<'a>, ) -> RunHookFuture<'a> { Box::pin(async { @@ -1373,11 +1594,11 @@ mod tests { fn hook<'a>( &'a self, ctx: &'a HookRunContext<'a>, - config: RunConfig, + _config: &'a RunConfig, original: RunOriginal<'a>, ) -> RunHookFuture<'a> { Box::pin(async move { - match original.call(ctx, config).await { + match original.call(ctx).await { Err(_) => Err(reloaded_code_core::ToolError::Execution( "policy veto".into(), )), @@ -1387,6 +1608,23 @@ mod tests { } } + /// Run-config hook that fails its configuration step. + struct FailingConfigHook; + + impl RunConfigHook for FailingConfigHook { + fn configure<'a>( + &'a self, + _ctx: &'a HookRunContext<'a>, + _config: &'a mut RunConfig, + ) -> RunConfigHookFuture<'a> { + Box::pin(async { + Err(reloaded_code_core::ToolError::Execution( + "config hook rejected the run".into(), + )) + }) + } + } + #[tokio::test] async fn run_failure_keeps_original_error_variant_when_hook_propagates_it_untouched() { // The hook calls `original`, so the inner model failure flows @@ -1439,7 +1677,7 @@ mod tests { // failures must keep the same untouched-projection handling as // plain runs. let hooks = HookSet::builder() - .run_hook(OverridingRunHook { + .run_config_hook(OverridingConfigHook { temperature: Some(0.9), top_p: None, }) @@ -1585,4 +1823,56 @@ mod tests { } } } + + #[tokio::test] + async fn run_failure_is_labeled_config_hook_error_when_config_hook_fails() { + // The config hook fails before the run chain or the model starts, + // so the model is never invoked and the failure can only be + // config-hook-origin. + let hooks = HookSet::builder() + .run_config_hook(FailingConfigHook) + .build(); + + let runtime = AgentRuntimeBuilder::new() + .catalog(AgentCatalog::from_entries([agent( + "caller", + AgentMode::Primary, + allow_tools(&[]), + "prompt", + )])) + .defaults(AgentDefaults::with_model("openrouter/openai/gpt-4.1-mini")) + .hooks(hooks) + .build() + .expect("runtime should build"); + + let context = AgentBuildContext::new( + Arc::new(runtime), + Arc::new(catalog()), + Arc::new(credentials()), + workspace_root(), + ) + .with_model_override(crate::mock::MockModel::new("unused").with_text_response("unused")); + let hooked = context.build("caller").expect("build should succeed"); + + let err = hooked + .run("trigger the config hook failure", ()) + .await + .err() + .expect("run should fail"); + + match err { + serdes_ai::agent::AgentRunError::Other(source) => { + let message = source.to_string(); + assert!( + message.contains("run config hook error"), + "config-hook failure should be labeled as such: {message}" + ); + assert!( + message.contains("config hook rejected the run"), + "dispatched config-hook error should be preserved: {message}" + ); + } + other => panic!("config-hook failure should surface as Other, got: {other:?}"), + } + } } diff --git a/src/reloaded-code-serdesai/src/lib.rs b/src/reloaded-code-serdesai/src/lib.rs index 0b5f1fdf..ce7c5487 100644 --- a/src/reloaded-code-serdesai/src/lib.rs +++ b/src/reloaded-code-serdesai/src/lib.rs @@ -33,15 +33,9 @@ pub use reloaded_code_agents::{ AgentDefaults, AgentRuntime, AgentRuntimeBuilder, ModelResolutionError, ResolvedModel, resolve_model_with_catalog, }; -/// Re-export [`RunEvent`], the framework-owned item type yielded by -/// [`HookedAgent::run_stream`], together with its transcript payload -/// types ([`RunMessage`], [`RunMessageRole`], [`RunToolCallSummary`], -/// [`RunToolResultSummary`]), and [`RunEventHook`], the hook that -/// intercepts each streamed event before publication, together with its -/// [`RunEventContext`] and [`RunEventHookResult`] call types. pub use reloaded_code_core::hooks::{ - RunEvent, RunEventContext, RunEventHook, RunEventHookResult, RunMessage, RunMessageRole, - RunToolCallSummary, RunToolResultSummary, + RunConfigHook, RunConfigHookFuture, RunEvent, RunEventContext, RunEventHook, + RunEventHookResult, RunMessage, RunMessageRole, RunToolCallSummary, RunToolResultSummary, }; pub mod agent_ext;