From 223d1c2b3486c8553ab53ad4bf310e0a386d3b40 Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Tue, 18 Aug 2026 01:39:53 +0100 Subject: [PATCH 01/19] Added: RunConfigHook contract and dispatch for amending run config - New RunConfigHook trait with async configure(ctx, &mut RunConfig) and a boxed RunConfigHookFuture alias; config hooks fire before the run hook chain and the first model request on both run paths (run() and run_stream()), while RunHook keeps run-lifecycle control on run() only and RunEventHook owns streamed events - HookSet stores run-config hooks, exposes run_config_hooks_is_empty() and run_config_hooks() in dispatch order, counts them in is_empty() and Debug, and adds async dispatch_run_config() that applies hooks in registration order, stops at the first error, and returns the config unchanged when the chain is empty - HookSetBuilder gains run_config_hook() and shared_run_config_hook() registration, with run_config_hooks shown in its Debug output - Purely additive: no dispatch sites invoke the chain yet - Tests cover registration order, mutation accumulation across hooks with a seeded caller config, first-error stop, empty-chain passthrough, is_empty/Debug accounting, and both registration paths --- src/reloaded-code-core/src/hooks/builder.rs | 86 +++++- src/reloaded-code-core/src/hooks/hook_set.rs | 255 +++++++++++++++++- src/reloaded-code-core/src/hooks/mod.rs | 2 + .../src/hooks/run_hook/mod.rs | 45 ++++ 4 files changed, 382 insertions(+), 6 deletions(-) diff --git a/src/reloaded-code-core/src/hooks/builder.rs b/src/reloaded-code-core/src/hooks/builder.rs index 56897896..42319b1d 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] @@ -218,6 +244,62 @@ 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] + 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..c952c03c 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,6 +104,29 @@ impl HookSet { ToolOriginal::new(&self.tool_hooks, real_tool).call(ctx, req) } + /// Applies the run-config hook chain to `config`. + /// + /// 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 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 hook chain. /// /// If no run hooks are registered, this calls the real run @@ -150,6 +190,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 +203,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; @@ -339,6 +381,211 @@ mod tests { assert_eq!(output.content, "blocked"); } + // --- Run config dispatch tests --------------------------------------------- + + 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(()) }) + } + } + + 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() { + struct SetPrompt; + struct TagPrompt; + + impl RunConfigHook for SetPrompt { + fn configure<'a>( + &'a self, + _ctx: &'a HookRunContext<'a>, + config: &'a mut RunConfig, + ) -> RunConfigHookFuture<'a> { + Box::pin(async move { + config.system_prompt = Some("base".into()); + Ok(()) + }) + } + } + + impl RunConfigHook for TagPrompt { + fn configure<'a>( + &'a self, + _ctx: &'a HookRunContext<'a>, + config: &'a mut RunConfig, + ) -> RunConfigHookFuture<'a> { + Box::pin(async move { + let tagged = + format!("{}-tagged", config.system_prompt.take().unwrap_or_default()); + config.system_prompt = Some(tagged); + Ok(()) + }) + } + } + + let hooks = HookSet::builder() + .run_config_hook(SetPrompt) + .run_config_hook(TagPrompt) + .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() { + struct SetPrompt; + struct AddPreamble; + + impl RunConfigHook for SetPrompt { + fn configure<'a>( + &'a self, + _ctx: &'a HookRunContext<'a>, + config: &'a mut RunConfig, + ) -> RunConfigHookFuture<'a> { + Box::pin(async move { + config.system_prompt = Some("sys".into()); + Ok(()) + }) + } + } + + impl RunConfigHook for AddPreamble { + fn configure<'a>( + &'a self, + _ctx: &'a HookRunContext<'a>, + config: &'a mut RunConfig, + ) -> RunConfigHookFuture<'a> { + Box::pin(async move { + 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), + }); + Ok(()) + }) + } + } + + let mut input = RunConfig::default(); + input.preamble_messages.push(PreambleMessage { + role: PreambleRole::System, + content: "seeded".into(), + }); + + let hooks = HookSet::builder() + .run_config_hook(SetPrompt) + .run_config_hook(AddPreamble) + .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 Fail; + struct MustNotRun; + + 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("config rejected the run")) }) + } + } + + 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) + .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 mut input = RunConfig::default(); + input.system_prompt = Some("sys".into()); + input.preamble_messages.push(PreambleMessage { + role: PreambleRole::System, + content: "ctx".into(), + }); + + // 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(NoopConfig).build(); + assert!(!hooks.is_empty()); + assert!(!hooks.run_config_hooks_is_empty()); + assert_eq!(hooks.run_config_hooks().len(), 1); + } + + #[test] + fn hook_set_debug_includes_run_config_hooks_count() { + let hooks = HookSet::builder().run_config_hook(NoopConfig).build(); + let debug = format!("{hooks:?}"); + assert!(debug.contains("run_config_hooks: 1")); + } + // --- Run dispatch tests ---------------------------------------------------- #[tokio::test] diff --git a/src/reloaded-code-core/src/hooks/mod.rs b/src/reloaded-code-core/src/hooks/mod.rs index a2ddfc67..9b429217 100644 --- a/src/reloaded-code-core/src/hooks/mod.rs +++ b/src/reloaded-code-core/src/hooks/mod.rs @@ -12,6 +12,8 @@ //! - [`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 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..7c490912 100644 --- a/src/reloaded-code-core/src/hooks/run_hook/mod.rs +++ b/src/reloaded-code-core/src/hooks/run_hook/mod.rs @@ -13,6 +13,12 @@ //! before the first step: inject preamble messages, override the system //! prompt or model settings. //! +//! Config injection has a dedicated hook point: [`RunConfigHook`] +//! amends the [`RunConfig`] before the first step, on both run paths +//! (`run()` and `run_stream()`). [`RunHook`] owns run lifecycle control +//! (skip, substitute, post-observe) on `run()` only, and +//! [`RunEventHook`] owns streamed events. +//! //! Code after `original` sees the finished [`RunOutput`]. Skipping //! `original` skips the run and returns a synthetic result instead. //! @@ -21,6 +27,7 @@ //! //! Next: see [`ToolHook`] for the innermost intercept point. //! +//! [`RunEventHook`]: crate::hooks::RunEventHook //! [`ToolHook`]: crate::hooks::ToolHook use crate::ToolError; @@ -40,6 +47,9 @@ pub struct RunConfig { 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>>; @@ -130,6 +140,41 @@ pub struct RunUsage { pub completion_tokens: u64, } +/// Hook that amends a run's config before the run starts. +/// +/// `configure` mutates the [`RunConfig`] in place: system prompt, +/// preamble messages, model settings overrides. Hooks run in +/// registration order; each hook sees the mutations of every earlier +/// hook. +/// +/// Config hooks fire before the run hook chain and before the first +/// model request or streamed event, on both run paths: `run()` and +/// `run_stream()`. Lifecycle control stays with [`RunHook`] (skip, +/// substitute, post-observe, `run()` only); streamed events stay with +/// [`RunEventHook`]. +/// +/// # Remarks +/// +/// `configure` is async so a hook can fetch remote resources (prompt +/// templates, feature flags) before the run starts. The returned +/// future is boxed once per hook per run, never per event. +/// +/// [`RunEventHook`]: crate::hooks::RunEventHook +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. From 679c853447c4609761a405565536e3fe7aa6b57b Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Tue, 18 Aug 2026 12:58:08 +0100 Subject: [PATCH 02/19] Changed: run hooks observe a read-only view of the run config - Breaking: RunHook::hook now takes &RunConfig and observes the final config read-only; config changes belong to the config hook layer. - RunOriginal carries a shared view of the final config and its call no longer takes a config parameter; the chain end hands the executor an owned clone of the final config once per run, only when run hooks are registered. - HookSet::dispatch_run applies the run-config hook chain first, then the run chain with a shared view of the final config. The both-empty fast path stays allocation-free, and the config-hooks-only path passes the owned final config straight to the executor. - RunConfig and ModelSettingsOverrides derive Clone for the chain-end hand-off. - Docs updated to the two-layer model: RunConfigHook amends config on both run paths, RunHook observes it read-only on run() only. - Bumped reloaded-code-core 0.2.3 -> 0.3.0 (breaking); workspace dependents migrate in the following commit. --- src/Cargo.lock | 2 +- src/Cargo.toml | 2 +- src/reloaded-code-core/Cargo.toml | 2 +- src/reloaded-code-core/src/hooks/builder.rs | 8 +- src/reloaded-code-core/src/hooks/hook_set.rs | 328 ++++++++++++++++-- src/reloaded-code-core/src/hooks/mod.rs | 2 +- .../src/hooks/run_event/mod.rs | 3 + .../src/hooks/run_hook/mod.rs | 143 +++++--- .../src/hooks/tool_hook/mod.rs | 5 +- 9 files changed, 405 insertions(+), 90 deletions(-) diff --git a/src/Cargo.lock b/src/Cargo.lock index d9246e1e..711d120a 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", 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/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 42319b1d..ae735f3a 100644 --- a/src/reloaded-code-core/src/hooks/builder.rs +++ b/src/reloaded-code-core/src/hooks/builder.rs @@ -185,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(); @@ -204,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); diff --git a/src/reloaded-code-core/src/hooks/hook_set.rs b/src/reloaded-code-core/src/hooks/hook_set.rs index c952c03c..87131375 100644 --- a/src/reloaded-code-core/src/hooks/hook_set.rs +++ b/src/reloaded-code-core/src/hooks/hook_set.rs @@ -127,13 +127,22 @@ impl HookSet { Ok(config) } - /// Dispatches a run through the hook chain. + /// Dispatches a run through the config and run hook chains. /// - /// If no run hooks are registered, this calls the real run - /// executor directly. + /// Config hooks run first, in registration order, amending + /// `config`. The run chain then runs with a shared view of the + /// final config and hands the executor an owned clone at its end. + /// When config hooks are registered but no run hooks are, the + /// owned final config goes straight to the executor. If neither + /// chain has hooks, the executor is called directly with the + /// original owned config. /// /// # Errors - /// Returns `ToolError` if the executor or any run hook in the chain returns an error. + /// 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, @@ -141,10 +150,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. @@ -230,10 +248,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(); @@ -625,25 +643,42 @@ mod tests { #[tokio::test] async fn dispatch_run_hooks_wrap_real_run() { - struct Prefix; - struct RealRun; + struct SetPrompt; + impl RunConfigHook for SetPrompt { + fn configure<'a>( + &'a self, + _ctx: &'a HookRunContext<'a>, + config: &'a mut RunConfig, + ) -> RunConfigHookFuture<'a> { + Box::pin(async move { + config.system_prompt = Some("overridden".into()); + Ok(()) + }) + } + } - 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, @@ -662,7 +697,8 @@ mod tests { } let hooks = crate::hooks::builder::HookSetBuilder::new() - .run_hook(Prefix) + .run_config_hook(SetPrompt) + .run_hook(Wrap) .build(); let ctx = HookRunContext { agent_name: "coder", @@ -674,10 +710,254 @@ mod tests { .await .unwrap(); - assert_eq!(output.content, "overridden-post"); + assert_eq!(output.content, "overridden-saw:overridden-post"); assert_eq!(output.reason, EndReason::Completed); } + #[tokio::test] + async fn dispatch_run_executor_receives_final_config_with_run_hooks() { + struct Enrich; + impl RunConfigHook for Enrich { + fn configure<'a>( + &'a self, + _ctx: &'a HookRunContext<'a>, + config: &'a mut RunConfig, + ) -> RunConfigHookFuture<'a> { + Box::pin(async move { + config.system_prompt = Some("sys".into()); + config.preamble_messages.push(PreambleMessage { + role: PreambleRole::User, + content: "ctx".into(), + }); + config.model_settings_overrides = Some(ModelSettingsOverrides { + temperature: Some(0.3), + top_p: Some(0.8), + }); + Ok(()) + }) + } + } + + struct PassThrough; + impl RunHook for PassThrough { + fn hook<'a>( + &'a self, + ctx: &'a HookRunContext<'a>, + _config: &'a RunConfig, + original: RunOriginal<'a>, + ) -> RunHookFuture<'a> { + original.call(ctx) + } + } + + struct CaptureRun; + impl RunExecutor for CaptureRun { + fn execute<'a>( + &'a self, + _ctx: &'a HookRunContext<'a>, + config: RunConfig, + ) -> RunHookFuture<'a> { + Box::pin(async move { + // Every field the config hook wrote must survive the + // chain-end hand-off into the owned config, next to + // the caller-seeded values no hook touched. + let overrides = config.model_settings_overrides.unwrap(); + let preamble = config + .preamble_messages + .iter() + .map(|message| message.content.as_str()) + .collect::>() + .join("+"); + Ok(RunOutput { + content: format!( + "{}|{}|{}|{}", + config.system_prompt.as_deref().unwrap_or("none"), + preamble, + overrides.temperature.unwrap(), + overrides.top_p.unwrap(), + ), + reason: EndReason::Completed, + usage: RunUsage::default(), + }) + }) + } + } + + let hooks = crate::hooks::builder::HookSetBuilder::new() + .run_config_hook(Enrich) + .run_hook(PassThrough) + .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 every hook-written field. + let mut input = RunConfig::default(); + input.preamble_messages.push(PreambleMessage { + role: PreambleRole::System, + content: "seeded".into(), + }); + let output = hooks.dispatch_run(&ctx, input, &CaptureRun).await.unwrap(); + + assert_eq!(output.content, "sys|seeded+ctx|0.3|0.8"); + } + + #[tokio::test] + async fn dispatch_run_config_hooks_only_feed_executor() { + struct SetPrompt; + impl RunConfigHook for SetPrompt { + fn configure<'a>( + &'a self, + _ctx: &'a HookRunContext<'a>, + config: &'a mut RunConfig, + ) -> RunConfigHookFuture<'a> { + Box::pin(async move { + config.system_prompt = Some("cfg-only".into()); + Ok(()) + }) + } + } + + struct RealRun; + impl RunExecutor for RealRun { + fn execute<'a>( + &'a self, + _ctx: &'a HookRunContext<'a>, + config: RunConfig, + ) -> RunHookFuture<'a> { + let content = config.system_prompt.unwrap_or_else(|| "default".into()); + Box::pin(async move { + Ok(RunOutput { + content, + reason: EndReason::Completed, + usage: RunUsage::default(), + }) + }) + } + } + + let hooks = crate::hooks::builder::HookSetBuilder::new() + .run_config_hook(SetPrompt) + .build(); + assert!(hooks.run_hooks_is_empty()); + let ctx = HookRunContext { + agent_name: "coder", + run_id: "r1", + model_name: "gpt-4o", + }; + let output = hooks + .dispatch_run(&ctx, RunConfig::default(), &RealRun) + .await + .unwrap(); + + assert_eq!(output.content, "cfg-only"); + } + + #[tokio::test] + async fn dispatch_run_config_hook_error_aborts_before_run_chain() { + struct Fail; + 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("config 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!("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) + .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] async fn dispatch_run_hook_can_skip_without_calling_original() { struct Skip; @@ -687,7 +967,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 { @@ -744,12 +1024,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) }) @@ -760,12 +1040,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) }) @@ -989,10 +1269,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 9b429217..45c92687 100644 --- a/src/reloaded-code-core/src/hooks/mod.rs +++ b/src/reloaded-code-core/src/hooks/mod.rs @@ -17,7 +17,7 @@ //! - [`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_event/mod.rs b/src/reloaded-code-core/src/hooks/run_event/mod.rs index 8c392cee..3fade1e5 100644 --- a/src/reloaded-code-core/src/hooks/run_event/mod.rs +++ b/src/reloaded-code-core/src/hooks/run_event/mod.rs @@ -10,6 +10,8 @@ //! [`RunEventHook`] sees each streamed event before publication: //! observe, rewrite, or suppress. It fires only on the streaming //! path; the run boundary hook [`RunHook`] fires only on `run()`. +//! Config hooks are the exception: [`RunConfigHook`] fires on both +//! paths, amending the run config before the first event. //! //! # Transcript distillation //! @@ -24,6 +26,7 @@ //! [`RunEvent`] is `#[non_exhaustive]`: variants may be appended //! without a breaking release. Consumers match it with a wildcard arm. //! +//! [`RunConfigHook`]: crate::hooks::RunConfigHook //! [`RunHook`]: crate::hooks::RunHook use crate::ToolError; 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 7c490912..71752d4c 100644 --- a/src/reloaded-code-core/src/hooks/run_hook/mod.rs +++ b/src/reloaded-code-core/src/hooks/run_hook/mod.rs @@ -10,8 +10,8 @@ //! it triggers. A run with no tool calls is a single step. //! //! 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; +//! config changes belong to the config hook layer. //! //! Config injection has a dedicated hook point: [`RunConfigHook`] //! amends the [`RunConfig`] before the first step, on both run paths @@ -36,17 +36,6 @@ 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>>; @@ -56,13 +45,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". @@ -79,22 +71,22 @@ 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 starts, on +/// both run paths; a [`RunHook`] then observes the final config +/// read-only. The executor consumes the final config owned, so +/// `Clone` exists for the chain-end hand-off when run hooks are +/// registered: the trampoline clones once per run, and 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. @@ -122,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. @@ -140,6 +143,15 @@ 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: system prompt, @@ -177,7 +189,7 @@ pub trait RunConfigHook: Send + Sync + 'static { /// 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. @@ -186,14 +198,16 @@ 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`]: config +/// hooks have already amended it before the run chain starts. To +/// change the config, register a [`RunConfigHook`]; run hooks +/// observe it, e.g. to log or branch on the resolved prompt. The +/// chain end hands the executor an owned clone of the same config, +/// once per run. /// /// # Remarks /// @@ -209,41 +223,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()) } } } @@ -259,7 +285,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, @@ -268,7 +294,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) @@ -334,7 +360,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 { @@ -352,9 +378,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"); @@ -384,8 +411,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"); } @@ -408,7 +436,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}; From d1d71fbf321c4fb0a2f50d0f8b9f170424afd01f Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Tue, 18 Aug 2026 13:21:08 +0100 Subject: [PATCH 03/19] Changed: adopt the RunConfigHook contract in the serdesai agent runtime - HookedAgent::run() now dispatches through the run-config hook chain before the run hook chain, taking the direct fast path only when both chains are empty; run_stream() still bypasses both. - Run hooks receive a read-only view of the run config per the core contract; config-mutating test hooks (model settings overrides, preamble messages, system prompt) migrated to RunConfigHook. - A failing run-config hook on run() surfaces as AgentRunError::Other labeled "run config hook error" when no run hooks are registered. - reloaded-code-serdesai re-exports RunConfigHook and RunConfigHookFuture and is bumped 0.2.0 -> 0.3.0 for the incompatible run hook signature. - Signature-only migrations in reloaded-code-agents runtime tests and serdesai stream_events tests. --- src/Cargo.lock | 2 +- .../src/runtime/builder.rs | 4 +- src/reloaded-code-serdesai/Cargo.toml | 2 +- .../src/agent_runtime/stream_events.rs | 4 +- .../src/agent_runtime/task.rs | 359 +++++++++++++----- src/reloaded-code-serdesai/src/lib.rs | 9 +- 6 files changed, 279 insertions(+), 101 deletions(-) diff --git a/src/Cargo.lock b/src/Cargo.lock index 711d120a..87fb1e69 100644 --- a/src/Cargo.lock +++ b/src/Cargo.lock @@ -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/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-serdesai/Cargo.toml b/src/reloaded-code-serdesai/Cargo.toml index 0b730f6f..e97a5de6 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" 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..87dd940f 100644 --- a/src/reloaded-code-serdesai/src/agent_runtime/stream_events.rs +++ b/src/reloaded-code-serdesai/src/agent_runtime/stream_events.rs @@ -773,14 +773,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) } } diff --git a/src/reloaded-code-serdesai/src/agent_runtime/task.rs b/src/reloaded-code-serdesai/src/agent_runtime/task.rs index 9b47e158..0a833b14 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 and streams framework-owned +//! events from `run_stream()`, passing each through the registered +//! run-event hooks. #[cfg(not(all(feature = "linux-bubblewrap", target_os = "linux")))] use super::build::Profile; @@ -41,11 +42,15 @@ pub struct AgentBuildContext, hooks: HookSet, @@ -319,19 +324,23 @@ 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. + /// When no run-config or run hooks are registered this delegates + /// directly to the inner agent for zero overhead. Otherwise the + /// registered [`RunConfigHook`][config-hook]s amend the run config + /// first: system prompt, preamble messages, and model-settings + /// overrides. The run hook chain then observes the final config, and + /// the executor applies any `preamble_messages` or `system_prompt` + /// mutations to the prompt text and `model_settings_overrides` to the + /// per-run model settings before calling the agent. /// - /// Mode-scoped: run hooks fire only on this path. A registered - /// [`RunEventHook`][event-hook] never fires here; it fires only on - /// [`Self::run_stream`]. + /// Mode-scoped: run-config and run hooks fire only on this path. A + /// registered [`RunEventHook`][event-hook] never fires here; it fires + /// only on [`Self::run_stream`]. /// - /// The run-hook context carries a wrapper-generated `run_id`. The inner + /// 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 identifiers cannot be unified here. /// @@ -340,10 +349,18 @@ 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. + /// - Hook-origin labels depend on registration. With no run hooks + /// registered, a failing run-config hook is labeled + /// `run config hook error`. With run hooks registered as well, a + /// dispatch failure that is not the untouched inner error is + /// labeled `run hook error`; the underlying error text identifies + /// its source. /// /// [event-hook]: reloaded_code_core::hooks::RunEventHook + /// [config-hook]: reloaded_code_core::hooks::RunConfigHook pub async fn run( &self, prompt: impl Into, @@ -359,14 +376,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 +414,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)) @@ -421,8 +445,10 @@ impl HookedAgent { /// 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. + /// the first event and defeat streaming. Run-config hooks + /// ([`RunConfigHook`][config-hook]) are not consulted here either, so + /// preamble, system-prompt, and model-settings injection applies to + /// `run()` only. /// /// # Errors /// @@ -437,6 +463,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, @@ -689,12 +716,15 @@ where /// 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,9 +732,12 @@ 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}" + )), } } @@ -759,8 +792,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 +1110,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 +1167,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 +1186,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 +1214,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 +1240,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 +1268,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 +1303,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 +1335,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 +1446,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 +1460,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 +1479,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 +1493,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 +1562,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 +1708,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..b81c4540 100644 --- a/src/reloaded-code-serdesai/src/lib.rs +++ b/src/reloaded-code-serdesai/src/lib.rs @@ -38,10 +38,13 @@ pub use reloaded_code_agents::{ /// types ([`RunMessage`], [`RunMessageRole`], [`RunToolCallSummary`], /// [`RunToolResultSummary`]), and [`RunEventHook`], the hook that /// intercepts each streamed event before publication, together with its -/// [`RunEventContext`] and [`RunEventHookResult`] call types. +/// [`RunEventContext`] and [`RunEventHookResult`] call types. Also +/// re-exports [`RunConfigHook`], the hook that amends a run's config +/// (system prompt, preamble messages, model settings) before the run +/// starts, together with its [`RunConfigHookFuture`] call type. 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; From 18bfcbd84eef2b50ce038f3b08424e51b63e285b Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Tue, 18 Aug 2026 13:57:44 +0100 Subject: [PATCH 04/19] Changed: apply run-config hooks on the streaming path with shared prompt sections --- .../src/agent_runtime/mod.rs | 7 +- .../src/agent_runtime/stream_events.rs | 380 +++++++++++++++++- .../src/agent_runtime/task.rs | 189 +++++++-- 3 files changed, 539 insertions(+), 37 deletions(-) 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 87dd940f..ce5927a7 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, @@ -1254,6 +1256,382 @@ 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. + 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(SECTION_HEAD), + UserContentPart::text("base prompt"), + ]), + "a text prompt must become two parts with the section head 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(SECTION_HEAD), + UserContentPart::text("hello"), + UserContentPart::image_url("https://example.invalid/image.png"), + ]), + "multipart prompts must keep their parts with the head 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 0a833b14..1fc63735 100644 --- a/src/reloaded-code-serdesai/src/agent_runtime/task.rs +++ b/src/reloaded-code-serdesai/src/agent_runtime/task.rs @@ -3,9 +3,9 @@ //! # Public API //! - [`AgentBuildContext`] - Reusable shared inputs for building runnable agents. //! - [`HookedAgent`] - Built agent wrapper that dispatches `run()` through -//! the registered run-config and run hooks and streams framework-owned -//! events from `run_stream()`, passing each through the registered -//! run-event hooks. +//! 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; @@ -21,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; @@ -28,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 the `run()` path. +const SECTION_SEPARATOR: &str = "\n\n"; + /// Reusable shared inputs for building runnable SerdesAI agents. /// /// Create once and call [`AgentBuildContext::build`] for each catalog agent @@ -44,11 +53,11 @@ pub struct AgentBuildContext> + 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, @@ -578,21 +643,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); @@ -711,6 +766,25 @@ where Ok(HookedAgent::new(agent, hooks, name.to_string(), model_name)) } +/// Prepends a section head to a stream prompt as the leading text part. +/// +/// Text prompts become two parts, head first; multi-part prompts keep +/// their parts with the head inserted at index zero. 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(head); + 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 @@ -741,6 +815,55 @@ fn restore_run_error( } } +/// Renders a run config's prompt sections as one leading head string. +/// +/// The system prompt comes first, then preamble messages in configured +/// order with their `[System]`/`[User]` prefixes, separated by blank +/// lines. Returns `None` when the config contributes no section, so +/// prompts without config injection stay untouched. +/// [`SerdesRunExecutor::execute`] and [`HookedAgent::run_stream`] share +/// this one builder, keeping the section bytes identical on both run +/// paths. +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 /// the agent's configured settings. /// From 65e89ea460fd26e22311d835c66b27ba8cac3685 Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Tue, 18 Aug 2026 14:10:58 +0100 Subject: [PATCH 05/19] Added: run hook examples for preamble injection and read-only lifecycle - Add serdesai-run-config-hook example showing a RunConfigHook injecting a system preamble on both run() and run_stream() - Rework serdesai-run-hook into a RunHook lifecycle demo that reads the resolved config through the read-only &RunConfig view, observes the first run, and skips later runs with a synthetic reply - Migrate serdesai-run-chain to the read-only RunHook signature that no longer takes the config on original.call() - Register the new example in the manifest behind the mock feature --- src/reloaded-code-serdesai/Cargo.toml | 5 + .../examples/hooks/run/serdesai-run-chain.rs | 8 +- .../hooks/run/serdesai-run-config-hook.rs | 127 ++++++++++++++++++ .../examples/hooks/run/serdesai-run-hook.rs | 86 +++++++++--- 4 files changed, 206 insertions(+), 20 deletions(-) create mode 100644 src/reloaded-code-serdesai/examples/hooks/run/serdesai-run-config-hook.rs diff --git a/src/reloaded-code-serdesai/Cargo.toml b/src/reloaded-code-serdesai/Cargo.toml index e97a5de6..32e93de4 100644 --- a/src/reloaded-code-serdesai/Cargo.toml +++ b/src/reloaded-code-serdesai/Cargo.toml @@ -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..6d453e43 --- /dev/null +++ b/src/reloaded-code-serdesai/examples/hooks/run/serdesai-run-config-hook.rs @@ -0,0 +1,127 @@ +//! `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()`. +//! The mock model echoes the last user prompt it received, so both printed +//! prompts show the injected `[System]` section ahead of the original prompt +//! text: config injection applies on both run paths. +//! +//! 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..e24d93a4 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 @@ -1,36 +1,82 @@ -//! Single `RunHook` with a real SerdesAI agent and mock model. +//! `RunHook` lifecycle demo with a read-only run config view and mock model. //! -//! 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. +//! `RunHook` controls the run lifecycle on `run()` only and observes the +//! final run config read-only; config mutation belongs to `RunConfigHook`. +//! This example registers one hook of each kind via +//! `AgentRuntimeBuilder::hooks()`: the config hook injects a preamble, and +//! the run hook reads that resolved config without mutating it. The run +//! hook observes the finished output on the first run, then skips the +//! second run and returns a synthetic reply instead of calling the +//! original run. //! //! Expected output: //! Built agent with 0 tools. -//! [PreambleInjector] injecting preamble for agent=hook-demo +//! [PreambleInjector] injecting preamble for agent=lifecycle-demo +//! [LifecycleHook] resolved preamble: You are a helpful assistant. +//! [LifecycleHook] run finished: Mock response //! Output: Mock response +//! [PreambleInjector] injecting preamble for agent=lifecycle-demo +//! [LifecycleHook] skipping the run +//! Output: synthetic reply //! //! 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, + EndReason, HookRunContext, HookSet, PreambleMessage, PreambleRole, RunConfig, RunConfigHook, + RunConfigHookFuture, RunHook, RunHookFuture, RunOriginal, RunOutput, RunUsage, }; +use std::sync::atomic::{AtomicBool, Ordering}; #[path = "../shared.rs"] mod shared; +/// Run hook that observes the first run and skips every later one. +struct LifecycleHook { + observed_a_run: AtomicBool, +} + struct PreambleInjector; -impl RunHook for PreambleInjector { +impl RunHook for LifecycleHook { fn hook<'a>( &'a self, ctx: &'a HookRunContext<'a>, - mut config: RunConfig, + config: &'a RunConfig, original: RunOriginal<'a>, ) -> RunHookFuture<'a> { + Box::pin(async move { + if self.observed_a_run.swap(true, Ordering::SeqCst) { + println!("[LifecycleHook] skipping the run"); + return Ok(RunOutput { + content: "synthetic reply".into(), + reason: EndReason::Completed, + usage: RunUsage::default(), + }); + } + + // Read-only view: the config hook already amended this config. + let preamble = config + .preamble_messages + .first() + .map(|message| message.content.as_str()) + .unwrap_or(""); + println!("[LifecycleHook] resolved preamble: {preamble}"); + + let output = original.call(ctx).await?; + println!("[LifecycleHook] run finished: {}", output.content); + Ok(output) + }) + } +} + +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={}", @@ -40,19 +86,24 @@ impl RunHook for PreambleInjector { role: PreambleRole::System, content: "You are a helpful assistant.".into(), }); - original.call(ctx, config).await + Ok(()) }) } } #[tokio::main] async fn main() -> Result<(), Box> { - let hooks = HookSet::builder().run_hook(PreambleInjector).build(); + let hooks = HookSet::builder() + .run_config_hook(PreambleInjector) + .run_hook(LifecycleHook { + observed_a_run: AtomicBool::new(false), + }) + .build(); let catalog = AgentCatalog::from_entries([shared::agent_config( - "hook-demo", - "demo agent", - "You are a demo agent.", + "lifecycle-demo", + "lifecycle demo", + "You are a lifecycle demo agent.", )]); let build_context = shared::build_agent_context(catalog, hooks); @@ -60,10 +111,13 @@ async fn main() -> Result<(), Box> { let model = shared::mock_model(); let agent = build_context .with_model_override(model) - .build("hook-demo")?; + .build("lifecycle-demo")?; println!("Built agent with {} tools.", agent.tools().len()); let response = agent.run("Say hello.", ()).await?; println!("Output: {}", response.output()); + + let response = agent.run("Say hello again.", ()).await?; + println!("Output: {}", response.output()); Ok(()) } From 968db5df4bb32de4b0ec8c243506b33e718c98a3 Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Tue, 18 Aug 2026 14:25:49 +0100 Subject: [PATCH 06/19] Fixed: drop redundant rustdoc link target in run_stream docs --- src/reloaded-code-serdesai/src/agent_runtime/task.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/reloaded-code-serdesai/src/agent_runtime/task.rs b/src/reloaded-code-serdesai/src/agent_runtime/task.rs index 1fc63735..148b655e 100644 --- a/src/reloaded-code-serdesai/src/agent_runtime/task.rs +++ b/src/reloaded-code-serdesai/src/agent_runtime/task.rs @@ -445,7 +445,7 @@ impl HookedAgent { /// 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 + /// [`UserContent`]; image and multi-part /// prompts keep their parts when no sections are injected. /// /// Each mapped event passes the registered From 5ec3c05634ce12de7685467c9b74147633c6806f Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Tue, 18 Aug 2026 19:03:14 +0100 Subject: [PATCH 07/19] Changed: document why Debug count tests pin hook-trait formatting --- src/reloaded-code-core/src/hooks/builder.rs | 1 + src/reloaded-code-core/src/hooks/hook_set.rs | 1 + 2 files changed, 2 insertions(+) diff --git a/src/reloaded-code-core/src/hooks/builder.rs b/src/reloaded-code-core/src/hooks/builder.rs index ae735f3a..713c4fb6 100644 --- a/src/reloaded-code-core/src/hooks/builder.rs +++ b/src/reloaded-code-core/src/hooks/builder.rs @@ -283,6 +283,7 @@ mod tests { } #[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 { diff --git a/src/reloaded-code-core/src/hooks/hook_set.rs b/src/reloaded-code-core/src/hooks/hook_set.rs index 87131375..c826d1b4 100644 --- a/src/reloaded-code-core/src/hooks/hook_set.rs +++ b/src/reloaded-code-core/src/hooks/hook_set.rs @@ -598,6 +598,7 @@ mod tests { } #[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(NoopConfig).build(); let debug = format!("{hooks:?}"); From f75e179531100600cdd181c0dee162d9fd2df2ec Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Tue, 18 Aug 2026 19:08:31 +0100 Subject: [PATCH 08/19] Changed: trim dispatch_run doc to the core hook flow Keep the ordering, shared-config view, and owned-clone facts; drop the edge-case enumeration now covered by "Empty chains are skipped." --- src/reloaded-code-core/src/hooks/hook_set.rs | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/src/reloaded-code-core/src/hooks/hook_set.rs b/src/reloaded-code-core/src/hooks/hook_set.rs index c826d1b4..e09b3ad6 100644 --- a/src/reloaded-code-core/src/hooks/hook_set.rs +++ b/src/reloaded-code-core/src/hooks/hook_set.rs @@ -129,13 +129,9 @@ impl HookSet { /// Dispatches a run through the config and run hook chains. /// - /// Config hooks run first, in registration order, amending - /// `config`. The run chain then runs with a shared view of the - /// final config and hands the executor an owned clone at its end. - /// When config hooks are registered but no run hooks are, the - /// owned final config goes straight to the executor. If neither - /// chain has hooks, the executor is called directly with the - /// original owned config. + /// 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 From 2eecd18a2ced99eb6842aa9f3a1a9a69319eccea Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Tue, 18 Aug 2026 19:19:01 +0100 Subject: [PATCH 09/19] Changed: replace run-config hook test doubles with closure adapters - Add test-only helpers to the hooks::hook_set tests module: `mutate_hook` adapts an `Fn(&mut RunConfig)` closure into a `RunConfigHook` that always succeeds, and `fail_hook` builds a hook whose `configure` returns a `ToolError::validation` error - Fold ten one-off `struct X; impl RunConfigHook for X` boilerplate blocks into closures at their nine call sites; test names, assertions, registration order, and explanatory comments are unchanged - Production code is untouched and all 27 hooks::hook_set tests still pass --- src/reloaded-code-core/src/hooks/hook_set.rs | 246 +++++++------------ 1 file changed, 84 insertions(+), 162 deletions(-) diff --git a/src/reloaded-code-core/src/hooks/hook_set.rs b/src/reloaded-code-core/src/hooks/hook_set.rs index e09b3ad6..32ec75ec 100644 --- a/src/reloaded-code-core/src/hooks/hook_set.rs +++ b/src/reloaded-code-core/src/hooks/hook_set.rs @@ -229,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(); @@ -397,18 +436,6 @@ mod tests { // --- Run config dispatch tests --------------------------------------------- - 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(()) }) - } - } - fn run_ctx() -> HookRunContext<'static> { HookRunContext { agent_name: "coder", @@ -419,40 +446,14 @@ mod tests { #[tokio::test] async fn dispatch_run_config_applies_hooks_in_registration_order() { - struct SetPrompt; - struct TagPrompt; - - impl RunConfigHook for SetPrompt { - fn configure<'a>( - &'a self, - _ctx: &'a HookRunContext<'a>, - config: &'a mut RunConfig, - ) -> RunConfigHookFuture<'a> { - Box::pin(async move { - config.system_prompt = Some("base".into()); - Ok(()) - }) - } - } - - impl RunConfigHook for TagPrompt { - fn configure<'a>( - &'a self, - _ctx: &'a HookRunContext<'a>, - config: &'a mut RunConfig, - ) -> RunConfigHookFuture<'a> { - Box::pin(async move { - let tagged = - format!("{}-tagged", config.system_prompt.take().unwrap_or_default()); - config.system_prompt = Some(tagged); - Ok(()) - }) - } - } - let hooks = HookSet::builder() - .run_config_hook(SetPrompt) - .run_config_hook(TagPrompt) + .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()) @@ -466,42 +467,6 @@ mod tests { #[tokio::test] async fn dispatch_run_config_accumulates_mutations_across_hooks() { - struct SetPrompt; - struct AddPreamble; - - impl RunConfigHook for SetPrompt { - fn configure<'a>( - &'a self, - _ctx: &'a HookRunContext<'a>, - config: &'a mut RunConfig, - ) -> RunConfigHookFuture<'a> { - Box::pin(async move { - config.system_prompt = Some("sys".into()); - Ok(()) - }) - } - } - - impl RunConfigHook for AddPreamble { - fn configure<'a>( - &'a self, - _ctx: &'a HookRunContext<'a>, - config: &'a mut RunConfig, - ) -> RunConfigHookFuture<'a> { - Box::pin(async move { - 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), - }); - Ok(()) - }) - } - } - let mut input = RunConfig::default(); input.preamble_messages.push(PreambleMessage { role: PreambleRole::System, @@ -509,8 +474,19 @@ mod tests { }); let hooks = HookSet::builder() - .run_config_hook(SetPrompt) - .run_config_hook(AddPreamble) + .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(); @@ -525,19 +501,8 @@ mod tests { #[tokio::test] async fn dispatch_run_config_stops_at_first_error() { - struct Fail; struct MustNotRun; - 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("config rejected the run")) }) - } - } - impl RunConfigHook for MustNotRun { fn configure<'a>( &'a self, @@ -549,7 +514,7 @@ mod tests { } let hooks = HookSet::builder() - .run_config_hook(Fail) + .run_config_hook(fail_hook("config rejected the run")) .run_config_hook(MustNotRun) .build(); let result = hooks @@ -587,7 +552,9 @@ mod tests { #[test] fn hook_set_with_run_config_hooks_is_not_empty() { - let hooks = HookSet::builder().run_config_hook(NoopConfig).build(); + 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); @@ -596,7 +563,9 @@ mod tests { #[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(NoopConfig).build(); + let hooks = HookSet::builder() + .run_config_hook(mutate_hook(|_| {})) + .build(); let debug = format!("{hooks:?}"); assert!(debug.contains("run_config_hooks: 1")); } @@ -640,20 +609,6 @@ mod tests { #[tokio::test] async fn dispatch_run_hooks_wrap_real_run() { - struct SetPrompt; - impl RunConfigHook for SetPrompt { - fn configure<'a>( - &'a self, - _ctx: &'a HookRunContext<'a>, - config: &'a mut RunConfig, - ) -> RunConfigHookFuture<'a> { - Box::pin(async move { - config.system_prompt = Some("overridden".into()); - Ok(()) - }) - } - } - struct Wrap; impl RunHook for Wrap { fn hook<'a>( @@ -694,7 +649,9 @@ mod tests { } let hooks = crate::hooks::builder::HookSetBuilder::new() - .run_config_hook(SetPrompt) + .run_config_hook(mutate_hook(|config| { + config.system_prompt = Some("overridden".into()); + })) .run_hook(Wrap) .build(); let ctx = HookRunContext { @@ -713,28 +670,6 @@ mod tests { #[tokio::test] async fn dispatch_run_executor_receives_final_config_with_run_hooks() { - struct Enrich; - impl RunConfigHook for Enrich { - fn configure<'a>( - &'a self, - _ctx: &'a HookRunContext<'a>, - config: &'a mut RunConfig, - ) -> RunConfigHookFuture<'a> { - Box::pin(async move { - config.system_prompt = Some("sys".into()); - config.preamble_messages.push(PreambleMessage { - role: PreambleRole::User, - content: "ctx".into(), - }); - config.model_settings_overrides = Some(ModelSettingsOverrides { - temperature: Some(0.3), - top_p: Some(0.8), - }); - Ok(()) - }) - } - } - struct PassThrough; impl RunHook for PassThrough { fn hook<'a>( @@ -781,7 +716,17 @@ mod tests { } let hooks = crate::hooks::builder::HookSetBuilder::new() - .run_config_hook(Enrich) + .run_config_hook(mutate_hook(|config| { + config.system_prompt = Some("sys".into()); + config.preamble_messages.push(PreambleMessage { + role: PreambleRole::User, + content: "ctx".into(), + }); + config.model_settings_overrides = Some(ModelSettingsOverrides { + temperature: Some(0.3), + top_p: Some(0.8), + }); + })) .run_hook(PassThrough) .build(); let ctx = HookRunContext { @@ -803,20 +748,6 @@ mod tests { #[tokio::test] async fn dispatch_run_config_hooks_only_feed_executor() { - struct SetPrompt; - impl RunConfigHook for SetPrompt { - fn configure<'a>( - &'a self, - _ctx: &'a HookRunContext<'a>, - config: &'a mut RunConfig, - ) -> RunConfigHookFuture<'a> { - Box::pin(async move { - config.system_prompt = Some("cfg-only".into()); - Ok(()) - }) - } - } - struct RealRun; impl RunExecutor for RealRun { fn execute<'a>( @@ -836,7 +767,9 @@ mod tests { } let hooks = crate::hooks::builder::HookSetBuilder::new() - .run_config_hook(SetPrompt) + .run_config_hook(mutate_hook(|config| { + config.system_prompt = Some("cfg-only".into()); + })) .build(); assert!(hooks.run_hooks_is_empty()); let ctx = HookRunContext { @@ -854,17 +787,6 @@ mod tests { #[tokio::test] async fn dispatch_run_config_hook_error_aborts_before_run_chain() { - struct Fail; - 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("config rejected the run")) }) - } - } - struct MustNotRun; impl RunHook for MustNotRun { fn hook<'a>( @@ -889,7 +811,7 @@ mod tests { } let hooks = crate::hooks::builder::HookSetBuilder::new() - .run_config_hook(Fail) + .run_config_hook(fail_hook("config rejected the run")) .run_hook(MustNotRun) .build(); let ctx = HookRunContext { From 34efb877e0f96bddb52cdc0c2c0f795cbe84b56b Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Tue, 18 Aug 2026 19:28:29 +0100 Subject: [PATCH 10/19] Changed: drop RunConfigHook note from run-event module docs --- src/reloaded-code-core/src/hooks/run_event/mod.rs | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/reloaded-code-core/src/hooks/run_event/mod.rs b/src/reloaded-code-core/src/hooks/run_event/mod.rs index 3fade1e5..8c392cee 100644 --- a/src/reloaded-code-core/src/hooks/run_event/mod.rs +++ b/src/reloaded-code-core/src/hooks/run_event/mod.rs @@ -10,8 +10,6 @@ //! [`RunEventHook`] sees each streamed event before publication: //! observe, rewrite, or suppress. It fires only on the streaming //! path; the run boundary hook [`RunHook`] fires only on `run()`. -//! Config hooks are the exception: [`RunConfigHook`] fires on both -//! paths, amending the run config before the first event. //! //! # Transcript distillation //! @@ -26,7 +24,6 @@ //! [`RunEvent`] is `#[non_exhaustive]`: variants may be appended //! without a breaking release. Consumers match it with a wildcard arm. //! -//! [`RunConfigHook`]: crate::hooks::RunConfigHook //! [`RunHook`]: crate::hooks::RunHook use crate::ToolError; From 19a9bbf594801bf9e5456f3eb28355ddeb342768 Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Tue, 18 Aug 2026 19:28:31 +0100 Subject: [PATCH 11/19] Changed: fold redundant dispatch_run config test into the wrap test The deleted test only pinned that caller-seeded and hook-written config fields survive the chain-end hand-off into the executor's owned config. That claim now lives in dispatch_run_hooks_wrap_real_run: its input is seeded with a System preamble, the config hook pushes a User preamble and overrides the system prompt, and the executor reports the preamble join next to the hook-amended prompt, asserting "overridden|seeded+ctx-saw:overridden-post". --- src/reloaded-code-core/src/hooks/hook_set.rs | 90 +++----------------- 1 file changed, 13 insertions(+), 77 deletions(-) diff --git a/src/reloaded-code-core/src/hooks/hook_set.rs b/src/reloaded-code-core/src/hooks/hook_set.rs index 32ec75ec..7fdaafb3 100644 --- a/src/reloaded-code-core/src/hooks/hook_set.rs +++ b/src/reloaded-code-core/src/hooks/hook_set.rs @@ -637,10 +637,16 @@ mod tests { _ctx: &'a HookRunContext<'a>, config: RunConfig, ) -> RunHookFuture<'a> { - let content = config.system_prompt.unwrap_or_else(|| "default".into()); + 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, + content: format!("{prompt}|{preamble}"), reason: EndReason::Completed, usage: RunUsage::default(), }) @@ -651,83 +657,12 @@ mod tests { let hooks = crate::hooks::builder::HookSetBuilder::new() .run_config_hook(mutate_hook(|config| { config.system_prompt = Some("overridden".into()); - })) - .run_hook(Wrap) - .build(); - let ctx = HookRunContext { - agent_name: "coder", - run_id: "r1", - model_name: "gpt-4o", - }; - let output = hooks - .dispatch_run(&ctx, RunConfig::default(), &RealRun) - .await - .unwrap(); - - assert_eq!(output.content, "overridden-saw:overridden-post"); - assert_eq!(output.reason, EndReason::Completed); - } - - #[tokio::test] - async fn dispatch_run_executor_receives_final_config_with_run_hooks() { - struct PassThrough; - impl RunHook for PassThrough { - fn hook<'a>( - &'a self, - ctx: &'a HookRunContext<'a>, - _config: &'a RunConfig, - original: RunOriginal<'a>, - ) -> RunHookFuture<'a> { - original.call(ctx) - } - } - - struct CaptureRun; - impl RunExecutor for CaptureRun { - fn execute<'a>( - &'a self, - _ctx: &'a HookRunContext<'a>, - config: RunConfig, - ) -> RunHookFuture<'a> { - Box::pin(async move { - // Every field the config hook wrote must survive the - // chain-end hand-off into the owned config, next to - // the caller-seeded values no hook touched. - let overrides = config.model_settings_overrides.unwrap(); - let preamble = config - .preamble_messages - .iter() - .map(|message| message.content.as_str()) - .collect::>() - .join("+"); - Ok(RunOutput { - content: format!( - "{}|{}|{}|{}", - config.system_prompt.as_deref().unwrap_or("none"), - preamble, - overrides.temperature.unwrap(), - overrides.top_p.unwrap(), - ), - reason: EndReason::Completed, - usage: RunUsage::default(), - }) - }) - } - } - - let hooks = crate::hooks::builder::HookSetBuilder::new() - .run_config_hook(mutate_hook(|config| { - config.system_prompt = Some("sys".into()); config.preamble_messages.push(PreambleMessage { role: PreambleRole::User, content: "ctx".into(), }); - config.model_settings_overrides = Some(ModelSettingsOverrides { - temperature: Some(0.3), - top_p: Some(0.8), - }); })) - .run_hook(PassThrough) + .run_hook(Wrap) .build(); let ctx = HookRunContext { agent_name: "coder", @@ -735,15 +670,16 @@ mod tests { model_name: "gpt-4o", }; // Seed a preamble no hook writes: the executor's owned config - // must carry both the seed and every hook-written field. + // 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, &CaptureRun).await.unwrap(); + let output = hooks.dispatch_run(&ctx, input, &RealRun).await.unwrap(); - assert_eq!(output.content, "sys|seeded+ctx|0.3|0.8"); + assert_eq!(output.content, "overridden|seeded+ctx-saw:overridden-post"); + assert_eq!(output.reason, EndReason::Completed); } #[tokio::test] From dfa5f15b5da8f7ef75ea799d38a0581f1436215f Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Tue, 18 Aug 2026 19:59:30 +0100 Subject: [PATCH 12/19] Remove verbose re-export doc comment in serdesai lib --- src/reloaded-code-serdesai/src/lib.rs | 9 --------- 1 file changed, 9 deletions(-) diff --git a/src/reloaded-code-serdesai/src/lib.rs b/src/reloaded-code-serdesai/src/lib.rs index b81c4540..ce7c5487 100644 --- a/src/reloaded-code-serdesai/src/lib.rs +++ b/src/reloaded-code-serdesai/src/lib.rs @@ -33,15 +33,6 @@ 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. Also -/// re-exports [`RunConfigHook`], the hook that amends a run's config -/// (system prompt, preamble messages, model settings) before the run -/// starts, together with its [`RunConfigHookFuture`] call type. pub use reloaded_code_core::hooks::{ RunConfigHook, RunConfigHookFuture, RunEvent, RunEventContext, RunEventHook, RunEventHookResult, RunMessage, RunMessageRole, RunToolCallSummary, RunToolResultSummary, From 8972685f86bbafa44cdc99d29aefeae5624deeb1 Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Tue, 18 Aug 2026 20:03:43 +0100 Subject: [PATCH 13/19] Changed: restore serdesai-run-hook as a single RunHook example The example had drifted into also demonstrating RunConfigHook-based preamble injection and a skip-on-second-run lifecycle. It now mirrors main's version of the example again, keeping only the API-forced adjustments of the run-config-hook branch: - RunHook receives the resolved run config read-only (`_config: &'a RunConfig`) instead of mutating it. - RunOriginal::call(ctx) carries the config, so the hook forwards the context and awaits the original run. - Preamble injection is removed from this example; config mutation belongs to RunConfigHook and is demonstrated by serdesai-run-config-hook. - The hook is renamed PreambleInjector -> RunLogger because it now logs the run start instead of injecting a preamble. --- .../examples/hooks/run/serdesai-run-hook.rs | 97 +++---------------- 1 file changed, 16 insertions(+), 81 deletions(-) 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 e24d93a4..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 @@ -1,109 +1,47 @@ -//! `RunHook` lifecycle demo with a read-only run config view and mock model. +//! Single `RunHook` with a real SerdesAI agent and mock model. //! -//! `RunHook` controls the run lifecycle on `run()` only and observes the -//! final run config read-only; config mutation belongs to `RunConfigHook`. -//! This example registers one hook of each kind via -//! `AgentRuntimeBuilder::hooks()`: the config hook injects a preamble, and -//! the run hook reads that resolved config without mutating it. The run -//! hook observes the finished output on the first run, then skips the -//! second run and returns a synthetic reply instead of calling the -//! original run. +//! 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 prints a confirmation message. //! //! Expected output: //! Built agent with 0 tools. -//! [PreambleInjector] injecting preamble for agent=lifecycle-demo -//! [LifecycleHook] resolved preamble: You are a helpful assistant. -//! [LifecycleHook] run finished: Mock response +//! [RunLogger] run starting for agent=hook-demo //! Output: Mock response -//! [PreambleInjector] injecting preamble for agent=lifecycle-demo -//! [LifecycleHook] skipping the run -//! Output: synthetic reply //! //! Run with: //! cargo run --example serdesai-run-hook -p reloaded-code-serdesai --features mock use reloaded_code_agents::AgentCatalog; -use reloaded_code_core::{ - EndReason, HookRunContext, HookSet, PreambleMessage, PreambleRole, RunConfig, RunConfigHook, - RunConfigHookFuture, RunHook, RunHookFuture, RunOriginal, RunOutput, RunUsage, -}; -use std::sync::atomic::{AtomicBool, Ordering}; +use reloaded_code_core::{HookRunContext, HookSet, RunConfig, RunHook, RunHookFuture, RunOriginal}; #[path = "../shared.rs"] mod shared; -/// Run hook that observes the first run and skips every later one. -struct LifecycleHook { - observed_a_run: AtomicBool, -} - -struct PreambleInjector; +struct RunLogger; -impl RunHook for LifecycleHook { +impl RunHook for RunLogger { fn hook<'a>( &'a self, ctx: &'a HookRunContext<'a>, - config: &'a RunConfig, + _config: &'a RunConfig, original: RunOriginal<'a>, ) -> RunHookFuture<'a> { Box::pin(async move { - if self.observed_a_run.swap(true, Ordering::SeqCst) { - println!("[LifecycleHook] skipping the run"); - return Ok(RunOutput { - content: "synthetic reply".into(), - reason: EndReason::Completed, - usage: RunUsage::default(), - }); - } - - // Read-only view: the config hook already amended this config. - let preamble = config - .preamble_messages - .first() - .map(|message| message.content.as_str()) - .unwrap_or(""); - println!("[LifecycleHook] resolved preamble: {preamble}"); - - let output = original.call(ctx).await?; - println!("[LifecycleHook] run finished: {}", output.content); - Ok(output) - }) - } -} - -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(()) + 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_config_hook(PreambleInjector) - .run_hook(LifecycleHook { - observed_a_run: AtomicBool::new(false), - }) - .build(); + let hooks = HookSet::builder().run_hook(RunLogger).build(); let catalog = AgentCatalog::from_entries([shared::agent_config( - "lifecycle-demo", - "lifecycle demo", - "You are a lifecycle demo agent.", + "hook-demo", + "demo agent", + "You are a demo agent.", )]); let build_context = shared::build_agent_context(catalog, hooks); @@ -111,13 +49,10 @@ async fn main() -> Result<(), Box> { let model = shared::mock_model(); let agent = build_context .with_model_override(model) - .build("lifecycle-demo")?; + .build("hook-demo")?; println!("Built agent with {} tools.", agent.tools().len()); let response = agent.run("Say hello.", ()).await?; println!("Output: {}", response.output()); - - let response = agent.run("Say hello again.", ()).await?; - println!("Output: {}", response.output()); Ok(()) } From 67ab8cdcdf88edb63790a4da7100eb816529e3a4 Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Tue, 18 Aug 2026 20:08:14 +0100 Subject: [PATCH 14/19] Changed: tighten run_hook module and trait docs - Split the module doc into single-topic sections: what a run is, the run boundary, hook ownership, run identity. - Trimmed the RunConfig, RunConfigHook, and RunHook docs so each fact lives on one doc surface: fields on the struct doc, hook routing on the module doc, clone invariant on RunConfig and RunOriginal::call. - Doc comments only; no executable change. --- .../src/hooks/run_hook/mod.rs | 56 ++++++++----------- 1 file changed, 22 insertions(+), 34 deletions(-) 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 71752d4c..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,20 @@ //! 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 and observes the final config read-only; -//! config changes belong to the config hook layer. +//! code after sees the finished [`RunOutput`]. Skipping `original` +//! skips the run and returns a synthetic result instead. +//! +//! # Hook ownership //! -//! Config injection has a dedicated hook point: [`RunConfigHook`] -//! amends the [`RunConfig`] before the first step, on both run paths -//! (`run()` and `run_stream()`). [`RunHook`] owns run lifecycle control -//! (skip, substitute, post-observe) on `run()` only, and -//! [`RunEventHook`] owns streamed events. +//! [`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`. @@ -73,12 +75,10 @@ pub struct HookRunContext<'a> { /// Run config: system prompt, preamble messages, model settings. /// -/// A [`RunConfigHook`] amends this config before the run starts, on -/// both run paths; a [`RunHook`] then observes the final config -/// read-only. The executor consumes the final config owned, so -/// `Clone` exists for the chain-end hand-off when run hooks are -/// registered: the trampoline clones once per run, and every other -/// path moves the config. +/// 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. @@ -154,24 +154,16 @@ pub enum PreambleRole { /// Hook that amends a run's config before the run starts. /// -/// `configure` mutates the [`RunConfig`] in place: system prompt, -/// preamble messages, model settings overrides. Hooks run in -/// registration order; each hook sees the mutations of every earlier -/// hook. -/// -/// Config hooks fire before the run hook chain and before the first -/// model request or streamed event, on both run paths: `run()` and -/// `run_stream()`. Lifecycle control stays with [`RunHook`] (skip, -/// substitute, post-observe, `run()` only); streamed events stay with -/// [`RunEventHook`]. +/// `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) before the run starts. The returned -/// future is boxed once per hook per run, never per event. -/// -/// [`RunEventHook`]: crate::hooks::RunEventHook +/// templates, feature flags). The future is boxed once per hook per +/// run. pub trait RunConfigHook: Send + Sync + 'static { /// Amends the run config in place. /// @@ -202,12 +194,8 @@ pub trait RunExecutor: Send + Sync { /// Skip `original` = skip the run (return a synthetic `RunOutput`). /// Code after = observe the run result. /// -/// `config` is a read-only view of the final [`RunConfig`]: config -/// hooks have already amended it before the run chain starts. To -/// change the config, register a [`RunConfigHook`]; run hooks -/// observe it, e.g. to log or branch on the resolved prompt. The -/// chain end hands the executor an owned clone of the same config, -/// once per run. +/// `config` is a read-only view of the final [`RunConfig`]. To +/// change it, register a [`RunConfigHook`]. /// /// # Remarks /// From f490d958c03df18743b7ba61e7917cef1fb761b2 Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Tue, 18 Aug 2026 20:09:28 +0100 Subject: [PATCH 15/19] Remove outdated mock-model echo note from run config hook example --- .../examples/hooks/run/serdesai-run-config-hook.rs | 3 --- 1 file changed, 3 deletions(-) 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 index 6d453e43..89067c17 100644 --- 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 @@ -3,9 +3,6 @@ //! 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()`. -//! The mock model echoes the last user prompt it received, so both printed -//! prompts show the injected `[System]` section ahead of the original prompt -//! text: config injection applies on both run paths. //! //! Mode scoping: `RunConfigHook` fires on `run()` and `run_stream()`. //! `RunHook` keeps lifecycle control on `run()` only; run the From bfde8f2818771d215af463b8d74584e0cd06780a Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Tue, 18 Aug 2026 22:05:09 +0100 Subject: [PATCH 16/19] Changed: trim and clarify HookedAgent run-path docs - Collapse the run_stream hook prose into one `# Hooks` section with labeled bullets for run-event, run-config, and run hooks. - Make the struct doc a two-bullet path map; mode scoping now lives in the method docs alone. - Rewrite the run_config_head doc to define the head and scope byte identity to the head itself, since run() and run_stream() assemble the final prompt differently (string join vs leading text part). - Document prepend_section_head's separate-part behavior and revert the SerdesRunExecutor doc to the main wording. - Doc comments only; full verify.sh passes. --- .../src/agent_runtime/task.rs | 134 ++++++++---------- 1 file changed, 63 insertions(+), 71 deletions(-) diff --git a/src/reloaded-code-serdesai/src/agent_runtime/task.rs b/src/reloaded-code-serdesai/src/agent_runtime/task.rs index 148b655e..be395eaa 100644 --- a/src/reloaded-code-serdesai/src/agent_runtime/task.rs +++ b/src/reloaded-code-serdesai/src/agent_runtime/task.rs @@ -50,16 +50,16 @@ pub struct AgentBuildContext, hooks: HookSet, @@ -77,10 +77,9 @@ pub struct HookedAgentRunResult { /// /// Applies `RunConfig::preamble_messages` and `system_prompt` to the prompt /// text before calling the agent, because the built agent does not support -/// runtime mutation of those fields. The section text comes from -/// [`run_config_head`], the one builder both run paths share. Applies -/// `model_settings_overrides` to the per-run model settings via -/// [`RunOptions`], merged over the agent's configured settings. +/// runtime mutation of those fields. Applies `model_settings_overrides` to +/// the per-run model settings via [`RunOptions`], merged over the agent's +/// configured settings. /// /// On inner failure the hook chain sees a [`ToolError::Execution`] projection /// while the original [`AgentRunError`] is parked in `error`; the dispatch @@ -337,38 +336,31 @@ impl HookedAgent { /// Runs the agent with the given prompt, dispatching through the /// registered run-config and run hooks. /// - /// When no run-config or run hooks are registered this delegates - /// directly to the inner agent for zero overhead. Otherwise the - /// registered [`RunConfigHook`][config-hook]s amend the run config - /// first: system prompt, preamble messages, and model-settings - /// overrides. The run hook chain then observes the final config, and - /// the executor applies any `preamble_messages` or `system_prompt` - /// mutations to the prompt text and `model_settings_overrides` to the - /// per-run model settings before calling the agent. + /// 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. Run-config hooks - /// fire on this path and on [`Self::run_stream`]. 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 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. + /// 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. + /// 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. - /// - Hook-origin labels depend on registration. With no run hooks - /// registered, a failing run-config hook is labeled - /// `run config hook error`. With run hooks registered as well, a - /// dispatch failure that is not the untouched inner error is - /// labeled `run hook error`; the underlying error text identifies - /// its source. + /// - 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 @@ -440,33 +432,27 @@ 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`]; image and multi-part - /// prompts keep their parts when no sections are injected. + /// 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 /// - /// Registered [`RunConfigHook`][config-hook]s fire once here, before - /// the stream starts, so config injection applies on both run paths: - /// an injected system prompt and preamble messages are prepended to - /// the prompt as a leading text part (textual prepend, not a true - /// system message, same as [`Self::run`]), and model-settings - /// overrides merge field-wise over the agent's configured settings. - /// The config-hook context carries a wrapper-generated `run_id`; the - /// inner agent assigns its own id for the streamed run, and the two - /// cannot be unified (see [`Self::run`]). - /// - /// 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. + /// - **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 /// @@ -766,10 +752,14 @@ where Ok(HookedAgent::new(agent, hooks, name.to_string(), model_name)) } -/// Prepends a section head to a stream prompt as the leading text part. +/// 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. /// -/// Text prompts become two parts, head first; multi-part prompts keep -/// their parts with the head inserted at index zero. A `None` head +/// 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 { @@ -815,15 +805,17 @@ fn restore_run_error( } } -/// Renders a run config's prompt sections as one leading head string. +/// 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. /// -/// The system prompt comes first, then preamble messages in configured -/// order with their `[System]`/`[User]` prefixes, separated by blank -/// lines. Returns `None` when the config contributes no section, so -/// prompts without config injection stay untouched. -/// [`SerdesRunExecutor::execute`] and [`HookedAgent::run_stream`] share -/// this one builder, keeping the section bytes identical on both run -/// paths. +/// 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()); From a905a32c1db42d06b3a5d182d2a4911329459532 Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Tue, 18 Aug 2026 22:26:43 +0100 Subject: [PATCH 17/19] Changed: sync hooks docs with the RunConfigHook API - Document RunConfigHook: amends RunConfig in place, runs before the request in registration order, first error stops the chain so the run does not start. - Show the new read-only RunHook signature (&RunConfig, original.call(ctx)) and point the run-observer section at the rewritten serdesai-run-hook example; add the serdesai-run-config-hook example link. - Trim per docs review: drop the SerdesAI-scoped intro paragraph, the Available types tables, and redundant ownership/async/skip-original notes; type discovery stays with HookSet and rustdoc. - Verified with mkdocs build --strict and .cargo/verify.sh (all green). --- src/docs/src/hooks.md | 117 +++++++++++------------------------------- 1 file changed, 29 insertions(+), 88 deletions(-) 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 From 2d75126c3febaf5b5094de71843c37d6258f4eda Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Tue, 18 Aug 2026 22:40:47 +0100 Subject: [PATCH 18/19] Fixed: stream run-config head includes prompt separator like run() HookedAgent::run_stream built its leading section head without the blank-line separator before the user prompt, so the concatenated bytes differed from run()'s `head + separator + prompt` format. The head text part now ends with the separator; stream test assertions expect it with the original prompt content unchanged. --- .../src/agent_runtime/stream_events.rs | 13 ++++++++----- .../src/agent_runtime/task.rs | 4 ++-- 2 files changed, 10 insertions(+), 7 deletions(-) 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 ce5927a7..bf839bfe 100644 --- a/src/reloaded-code-serdesai/src/agent_runtime/stream_events.rs +++ b/src/reloaded-code-serdesai/src/agent_runtime/stream_events.rs @@ -1263,7 +1263,8 @@ mod tests { /// 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. + /// `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 @@ -1516,10 +1517,11 @@ mod tests { assert_eq!( seen[0].prompt, UserContent::Parts(vec![ - UserContentPart::text(SECTION_HEAD), + UserContentPart::text(format!("{SECTION_HEAD}\n\n")), UserContentPart::text("base prompt"), ]), - "a text prompt must become two parts with the section head first" + "a text prompt must become two parts with the section head, \ + separator included, first" ); } @@ -1552,11 +1554,12 @@ mod tests { assert_eq!( seen[0].prompt, UserContent::Parts(vec![ - UserContentPart::text(SECTION_HEAD), + 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 at index zero" + "multipart prompts must keep their parts with the head, \ + separator included, at index zero" ); } diff --git a/src/reloaded-code-serdesai/src/agent_runtime/task.rs b/src/reloaded-code-serdesai/src/agent_runtime/task.rs index be395eaa..566eaba3 100644 --- a/src/reloaded-code-serdesai/src/agent_runtime/task.rs +++ b/src/reloaded-code-serdesai/src/agent_runtime/task.rs @@ -34,7 +34,7 @@ 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 the `run()` path. +/// the original prompt on both run paths. const SECTION_SEPARATOR: &str = "\n\n"; /// Reusable shared inputs for building runnable SerdesAI agents. @@ -765,7 +765,7 @@ fn prepend_section_head(prompt: UserContent, head: Option) -> UserConten let Some(head) = head else { return prompt; }; - let head_part = UserContentPart::text(head); + 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) => { From ac0cb976c5fffe809b8db07f2ed34b18ef0d065c Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Tue, 18 Aug 2026 22:43:06 +0100 Subject: [PATCH 19/19] Changed: build empty-chain RunConfig test input as a struct literal Set system_prompt and preamble_messages directly in the RunConfig initializer with ..RunConfig::default() for the rest, replacing default-then-mutate assignments in the empty-chain dispatch test. Same values, more idiomatic construction. --- src/reloaded-code-core/src/hooks/hook_set.rs | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/reloaded-code-core/src/hooks/hook_set.rs b/src/reloaded-code-core/src/hooks/hook_set.rs index 7fdaafb3..78d37826 100644 --- a/src/reloaded-code-core/src/hooks/hook_set.rs +++ b/src/reloaded-code-core/src/hooks/hook_set.rs @@ -532,12 +532,14 @@ mod tests { } } - let mut input = RunConfig::default(); - input.system_prompt = Some("sys".into()); - input.preamble_messages.push(PreambleMessage { - role: PreambleRole::System, - content: "ctx".into(), - }); + 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.