From f1f09ce8e710d7e3749a04f39149dd577d02c345 Mon Sep 17 00:00:00 2001 From: stephen Date: Sun, 5 Jul 2026 02:22:19 -0700 Subject: [PATCH] docs(examples): add force_tool_first_turn hook example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Demonstrates a per-turn RequestPatch footgun and its fix. A RequestPatch is per-turn and re-fires on every turn, so an AgentHook that patches tool_choice = Required on every CompletionCall forces a tool call on every turn — the model never gets a turn to stop calling tools and answer, so the run loops until max_turns and fails with MaxTurnsError. The example runs that footgun first (catching the MaxTurnsError), then the fix: gate the patch on ctx.turn() == 1 so the tool is forced only on the first turn and later turns resolve normally. Registered as a workspace example and listed in examples/README.md. Requires OPENAI_API_KEY to run. --- Cargo.lock | 12 ++ examples/README.md | 1 + examples/force_tool_first_turn/Cargo.toml | 16 ++ examples/force_tool_first_turn/src/main.rs | 166 +++++++++++++++++++++ 4 files changed, 195 insertions(+) create mode 100644 examples/force_tool_first_turn/Cargo.toml create mode 100644 examples/force_tool_first_turn/src/main.rs diff --git a/Cargo.lock b/Cargo.lock index d83415519..513361487 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4023,6 +4023,18 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "force_tool_first_turn" +version = "0.39.0" +dependencies = [ + "anyhow", + "rig", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", +] + [[package]] name = "foreign-types" version = "0.3.2" diff --git a/examples/README.md b/examples/README.md index 2c653b485..43198ffa4 100644 --- a/examples/README.md +++ b/examples/README.md @@ -40,6 +40,7 @@ Most examples expect provider API keys in the environment (e.g. `OPENAI_API_KEY` | `discord_bot` | See source. | | `enum_dispatch` | See source. | | `extractor` | Demonstrates typed extraction and extraction with usage metadata. | +| `force_tool_first_turn` | Demonstrates a per-turn `RequestPatch` footgun and its fix: forcing `tool_choice = Required` on *every* turn loops until `max_turns`, so an `AgentHook` gates the patch on `ctx.turn() == 1` to force the tool only up front. | | `gemini_deep_research` | See source. | | `gemini_default_api_recovery` | Demonstrates recovering from Gemini emitting a legacy `default_api` tool name. | | `gemini_extractor_with_rag` | See source. | diff --git a/examples/force_tool_first_turn/Cargo.toml b/examples/force_tool_first_turn/Cargo.toml new file mode 100644 index 000000000..b390eb083 --- /dev/null +++ b/examples/force_tool_first_turn/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "force_tool_first_turn" +version.workspace = true +edition.workspace = true +publish = false + +[lints] +workspace = true + +[dependencies] +rig.workspace = true +anyhow = { workspace = true } +tokio = { workspace = true, features = ["full"] } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +thiserror = { workspace = true } diff --git a/examples/force_tool_first_turn/src/main.rs b/examples/force_tool_first_turn/src/main.rs new file mode 100644 index 000000000..5031bff1d --- /dev/null +++ b/examples/force_tool_first_turn/src/main.rs @@ -0,0 +1,166 @@ +//! # Forcing a tool on the first turn: a `RequestPatch` footgun and its fix +//! +//! A hook can steer a single model turn by returning [`Flow::patch_request`] on +//! the [`StepEvent::CompletionCall`] event. A common wish is "make the model call +//! a tool *first*", done by patching `tool_choice = Required`. +//! +//! **The footgun.** A [`RequestPatch`] is **per-turn and non-sticky**: the +//! `CompletionCall` event re-fires on *every* turn, so a hook that patches +//! `Required` unconditionally forces a tool call on *every* turn. The model never +//! reaches a turn where it is free to stop calling tools and write the final +//! answer, so the run loops until `max_turns` and fails with +//! [`PromptError::MaxTurnsError`]. +//! +//! **The fix.** Gate the patch on the turn index — force `Required` only on the +//! first turn (`ctx.turn() == 1`). The model is nudged to call the tool up front; +//! later turns inherit the agent's baseline (`auto`), so it can stop and answer. +//! +//! This example runs the footgun first (and catches the resulting +//! `MaxTurnsError`), then runs the fix. +//! +//! Requires `OPENAI_API_KEY`. + +use anyhow::Result; +use rig::agent::{AgentHook, Flow, HookContext, RequestPatch, StepEvent}; +use rig::client::{CompletionClient, ProviderClient}; +use rig::completion::{CompletionModel, Prompt, PromptError, ToolDefinition}; +use rig::message::ToolChoice; +use rig::providers::openai; +use rig::tool::Tool; +use serde::Deserialize; +use serde_json::json; + +const PREAMBLE: &str = + "You are a calculator assistant. Use the add tool for arithmetic, then report the result."; +const PROMPT: &str = "What is 21 + 21? Use the add tool, then tell me the answer."; + +// --------------------------------------------------------------------------- +// A tiny calculator tool the hook can force the model to call. +// --------------------------------------------------------------------------- + +#[derive(Deserialize)] +struct AddArgs { + x: i64, + y: i64, +} + +#[derive(Debug, thiserror::Error)] +#[error("math error")] +struct MathError; + +#[derive(Clone)] +struct Add; + +impl Tool for Add { + const NAME: &'static str = "add"; + type Error = MathError; + type Args = AddArgs; + type Output = i64; + + async fn definition(&self, _prompt: String) -> ToolDefinition { + ToolDefinition { + name: Self::NAME.to_string(), + description: "Add x and y together".to_string(), + parameters: json!({ + "type": "object", + "properties": { + "x": { "type": "number", "description": "The first addend" }, + "y": { "type": "number", "description": "The second addend" } + }, + "required": ["x", "y"] + }), + } + } + + async fn call(&self, args: Self::Args) -> Result { + Ok(args.x + args.y) + } +} + +// --------------------------------------------------------------------------- +// The footgun: force `Required` on EVERY completion call. +// --------------------------------------------------------------------------- + +#[derive(Clone)] +struct ForceToolEveryTurn; + +impl AgentHook for ForceToolEveryTurn +where + M: CompletionModel, +{ + async fn on_event(&self, _ctx: &HookContext, event: StepEvent<'_, M>) -> Flow { + if matches!(event, StepEvent::CompletionCall { .. }) { + // BUG: re-applied every turn. The model is forced to call a tool on + // every turn and can never produce a final text answer, so the run + // loops until `max_turns`. + return Flow::patch_request(RequestPatch::new().tool_choice(ToolChoice::Required)); + } + Flow::cont() + } +} + +// --------------------------------------------------------------------------- +// The fix: force `Required` on the FIRST turn only. +// --------------------------------------------------------------------------- + +#[derive(Clone)] +struct ForceToolOnFirstTurn; + +impl AgentHook for ForceToolOnFirstTurn +where + M: CompletionModel, +{ + async fn on_event(&self, ctx: &HookContext, event: StepEvent<'_, M>) -> Flow { + // Gate the per-turn patch on the turn index. On turn 1 we force the tool; + // on later turns we return `Continue`, so the request inherits the agent's + // baseline `tool_choice` and the model is free to answer. + if matches!(event, StepEvent::CompletionCall { .. }) && ctx.turn() == 1 { + return Flow::patch_request(RequestPatch::new().tool_choice(ToolChoice::Required)); + } + Flow::cont() + } +} + +#[tokio::main] +async fn main() -> Result<()> { + let client = openai::Client::from_env()?; + // A fresh agent per run (both share the same tool and preamble). + let make_agent = || { + client + .agent(openai::GPT_4O) + .preamble(PREAMBLE) + .tool(Add) + .build() + }; + + // 1) The footgun. Forcing `Required` on every turn re-forces a tool call each + // turn, so the run loops until `max_turns` and errors. + println!("=== forcing tool_choice=Required on EVERY turn (the footgun) ==="); + let agent = make_agent(); + match agent + .prompt(PROMPT) + .max_turns(4) + .add_hook(ForceToolEveryTurn) + .await + { + Ok(answer) => println!("(unexpected) got a final answer: {answer}\n"), + Err(PromptError::MaxTurnsError { max_turns, .. }) => println!( + "hit MaxTurnsError after {max_turns} turns — every turn re-forced a tool call, so the \ + model never produced a final answer.\n" + ), + Err(err) => println!("run failed: {err}\n"), + } + + // 2) The fix. Forcing `Required` on the first turn only nudges the model to + // call the tool up front, then lets it answer. + println!("=== forcing tool_choice=Required on the FIRST turn only (the fix) ==="); + let agent = make_agent(); + let answer = agent + .prompt(PROMPT) + .max_turns(4) + .add_hook(ForceToolOnFirstTurn) + .await?; + println!("final answer: {answer}"); + + Ok(()) +}