From 3bc462ffd243856fc9598bbddb03e783a109a114 Mon Sep 17 00:00:00 2001 From: Sausage Agent Date: Sat, 17 Oct 2026 09:14:14 +0000 Subject: [PATCH] agent seed: thinking levels low|medium|high|xhigh|max|off on both APIs (card #128) New config field `thinking` (OpenAI: reasoning_effort still wins): Anthropic adaptive thinking + output_config.effort (thinking blocks kept and sent back), OpenAI reasoning.effort. A 400 that rejects the effort is retried with the nearest accepted level (from the error's list, lower on a tie), logged, never dropped to off. --- agent/src/llm.rs | 94 ++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 84 insertions(+), 10 deletions(-) diff --git a/agent/src/llm.rs b/agent/src/llm.rs index 64e976c..83a93ac 100644 --- a/agent/src/llm.rs +++ b/agent/src/llm.rs @@ -14,11 +14,49 @@ pub struct Config { pub base_url: String, pub model: String, pub token: String, - /// Optional reasoning effort for OpenAI models ("low", "medium", ...). + /// Optional reasoning effort for OpenAI models only (wins over `thinking` there). #[serde(default)] pub reasoning_effort: Option, #[serde(default)] pub max_tokens: Option, + /// Thinking effort for both APIs: "low", "medium", "high", "xhigh", "max" (`LEVELS`), or "off"/unset for none. + /// Anthropic: adaptive thinking + output_config.effort; OpenAI: reasoning.effort. A level the model rejects is + /// replaced by the nearest accepted one (logged), never by "off". + #[serde(default)] + pub thinking: Option, +} + +/// Thinking levels, lowest first. +pub const LEVELS: [&str; 5] = ["low", "medium", "high", "xhigh", "max"]; + +/// The level nearest to `want` (lower on a tie) among those a 400 error lists in quotes (else all levels), not +/// yet `tried`. None: nothing left, so the request goes out without an effort field (model default). +fn fallback_effort(want: &str, error: &str, tried: &[String]) -> Option { + let listed: Vec<&str> = error.split('\'').skip(1).step_by(2).filter(|w| LEVELS.contains(w)).collect(); + let pool = if listed.iter().any(|l| *l != want) { listed } else { LEVELS.to_vec() }; + let at = LEVELS.iter().position(|l| *l == want).unwrap_or(1) as i32; + pool.into_iter() + .filter(|l| *l != want && !tried.iter().any(|t| t == l)) + .min_by_key(|l| { + let i = LEVELS.iter().position(|x| x == l).unwrap_or(1) as i32; + ((i - at).abs(), i > at) + }) + .map(str::to_string) +} + +#[cfg(test)] +mod tests { + use super::fallback_effort; + + #[test] + fn nearest_accepted_level() { + let anthropic = "HTTP 400: output_config.effort: Input should be 'low', 'medium', 'high' or 'max'"; + assert_eq!(fallback_effort("xhigh", anthropic, &["xhigh".into()]).as_deref(), Some("high")); + let openai = "HTTP 400: Invalid value: 'ultra'. Supported values are: 'none', 'minimal', 'low', 'medium', 'high', 'xhigh', and 'max'."; + assert_eq!(fallback_effort("ultra", openai, &["ultra".into()]).as_deref(), Some("medium")); + // No list in the error: step to the neighbour, never to off. + assert_eq!(fallback_effort("max", "HTTP 400: effort max not supported", &["max".into()]).as_deref(), Some("xhigh")); + } } #[derive(Serialize, Deserialize, Clone, Debug)] @@ -32,7 +70,7 @@ pub enum Entry { }, ToolCall { id: String, name: String, arguments: String }, ToolResult { id: String, output: String }, - /// A provider item that only this API shape understands (OpenAI reasoning). + /// A provider item that only this API shape understands (OpenAI reasoning, Anthropic thinking). Opaque { api: String, item: Value }, } @@ -43,10 +81,28 @@ pub struct Tool { } pub async fn complete(cfg: &Config, system: &str, tools: &[Tool], history: &[Entry]) -> Result, String> { - match cfg.api.as_str() { - "openai-responses" => openai(cfg, system, tools, history).await, - "anthropic-messages" => anthropic(cfg, system, tools, history).await, - other => Err(format!("unknown API shape {other:?}")), + let want = match cfg.api.as_str() { + "openai-responses" => cfg.reasoning_effort.clone().or_else(|| cfg.thinking.clone()), + _ => cfg.thinking.clone(), + } + .filter(|t| t != "off"); + let mut effort = want.clone(); + let mut tried = Vec::new(); + loop { + let result = match cfg.api.as_str() { + "openai-responses" => openai(cfg, system, tools, history, effort.as_deref()).await, + "anthropic-messages" => anthropic(cfg, system, tools, history, want.is_some(), effort.as_deref()).await, + other => return Err(format!("unknown API shape {other:?}")), + }; + match (result, effort.clone()) { + // The model rejected the effort level: retry with the nearest accepted one (thinking stays on). + (Err(e), Some(current)) if e.starts_with("HTTP 400") && e.contains("effort") => { + tried.push(current.clone()); + effort = fallback_effort(want.as_deref().unwrap_or(""), &e, &tried); + eprintln!("agent: {} rejected thinking effort {current:?}; retrying with {effort:?}: {e}", cfg.model); + } + (result, _) => return result, + } } } @@ -67,7 +123,7 @@ async fn post(url: &str, headers: &[(&str, String)], body: &Value) -> Result Result, String> { +async fn openai(cfg: &Config, system: &str, tools: &[Tool], history: &[Entry], effort: Option<&str>) -> Result, String> { let mut input = Vec::new(); for e in history { match e { @@ -104,7 +160,7 @@ async fn openai(cfg: &Config, system: &str, tools: &[Tool], history: &[Entry]) - if let Some(max_tokens) = cfg.max_tokens { body["max_output_tokens"] = json!(max_tokens); } - if let Some(effort) = &cfg.reasoning_effort { + if let Some(effort) = effort { body["reasoning"] = json!({"effort": effort}); } let url = format!("{}/v1/responses", cfg.base_url); @@ -143,7 +199,14 @@ async fn openai(cfg: &Config, system: &str, tools: &[Tool], history: &[Entry]) - Ok(out) } -async fn anthropic(cfg: &Config, system: &str, tools: &[Tool], history: &[Entry]) -> Result, String> { +async fn anthropic( + cfg: &Config, + system: &str, + tools: &[Tool], + history: &[Entry], + thinking: bool, + effort: Option<&str>, +) -> Result, String> { // Merge consecutive blocks of the same role into one message. let mut messages: Vec<(String, Vec)> = Vec::new(); let mut push = |role: &str, block: Value| match messages.last_mut() { @@ -163,6 +226,8 @@ async fn anthropic(cfg: &Config, system: &str, tools: &[Tool], history: &[Entry] Entry::ToolResult { id, output } => { push("user", json!({"type": "tool_result", "tool_use_id": id, "content": output})) } + // Thinking blocks go back unchanged (with their signature): required next to tool use. + Entry::Opaque { api, item } if api == "anthropic-messages" && thinking => push("assistant", item.clone()), Entry::Opaque { .. } => {} } } @@ -171,13 +236,19 @@ async fn anthropic(cfg: &Config, system: &str, tools: &[Tool], history: &[Entry] .iter() .map(|t| json!({"name": t.name, "description": t.description, "input_schema": t.parameters})) .collect(); - let body = json!({ + let mut body = json!({ "model": cfg.model, "system": system, "messages": messages, "tools": tools, "max_tokens": cfg.max_tokens.unwrap_or(32000), }); + if thinking { + body["thinking"] = json!({"type": "adaptive"}); + if let Some(effort) = effort { + body["output_config"] = json!({"effort": effort}); + } + } let url = format!("{}/v1/messages", cfg.base_url); let resp = post( &url, @@ -199,6 +270,9 @@ async fn anthropic(cfg: &Config, system: &str, tools: &[Tool], history: &[Entry] name: block["name"].as_str().unwrap_or_default().to_string(), arguments: block["input"].to_string(), }), + Some("thinking") | Some("redacted_thinking") => { + out.push(Entry::Opaque { api: "anthropic-messages".into(), item: block }) + } _ => {} } }