agent: a thinking level in agent.yaml and per call
ci / check (push) Successful in 9m11s

- thinking (off, low, medium, high, xhigh, max) is an enum: serde rejects
  unknown levels in the config, and the schema of agent.chat lists them.
  reasoning_effort, a second name for the same setting, is gone.
- agent.chat takes an optional thinking level for one turn.
- off really switches thinking off: OpenAI gets the effort none, and
  Anthropic gets disabled thinking (newer Claude models think by
  default). A model that cannot switch thinking off gets low.
- Unset means the model's default. Anthropic thinking blocks now return
  unless thinking is off, because newer models think by default.
- The template sets thinking: medium and shows it in each example, with
  current model names.
- Checked against the proxy with gpt-6-luna, glm-5.3-flash, and
  claude-haiku-5-5.
This commit is contained in:
2026-10-23 13:01:36 +01:00
parent b536beb069
commit 895be183c9
5 changed files with 166 additions and 53 deletions
+1 -1
View File
@@ -30,7 +30,7 @@ nix run git+https://gitea.surma.technology/surma/sausage -- run # starts the
`init` asks nothing. It writes `world/config/agent.yaml`, with a comment for `init` asks nothing. It writes `world/config/agent.yaml`, with a comment for
each field and an example for each API. Edit it before the first chat: choose each field and an example for each API. Edit it before the first chat: choose
the API, the base URL, the model, and the key. Until then, a chat answers with the API, the base URL, the model, the key, and the thinking level. Until then, a chat answers with
an error that names the missing field. Both commands take another folder as an error that names the missing field. Both commands take another folder as
an argument, for example `... -- run my-world`. an argument, for example `... -- run my-world`.
+84 -36
View File
@@ -3,6 +3,7 @@
//! The conversation is stored in a neutral format, so the agent can switch //! The conversation is stored in a neutral format, so the agent can switch
//! between the three shapes. //! between the three shapes.
use sausage_plugin::schema;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::{Value, json}; use serde_json::{Value, json};
#[cfg(target_os = "wasi")] #[cfg(target_os = "wasi")]
@@ -22,17 +23,50 @@ pub struct Config {
pub token: String, pub token: String,
/// Permit an empty token and omit authentication headers. /// Permit an empty token and omit authentication headers.
pub allow_no_token: bool, pub allow_no_token: bool,
/// Optional reasoning effort for OpenAI models only (wins over `thinking` there).
#[serde(default)]
pub reasoning_effort: Option<String>,
/// An optional cap on generated tokens. /// An optional cap on generated tokens.
#[serde(default)] #[serde(default)]
pub max_tokens: Option<u32>, pub max_tokens: Option<u32>,
/// Thinking effort for all APIs: "low", "medium", "high", "xhigh", "max" (`LEVELS`), or "off"/unset for none. /// The thinking level of each turn. Unset: the model's default.
/// 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)] #[serde(default)]
pub thinking: Option<String>, pub thinking: Option<Thinking>,
}
/// How much the model thinks before it answers.
///
/// OpenAI Responses gets `reasoning.effort`, Chat Completions gets `reasoning_effort`, and Anthropic
/// gets adaptive thinking with `output_config.effort`. If the model rejects a level, the agent uses
/// the nearest level that the model accepts, never `off`.
#[schema]
#[derive(Clone, Copy, PartialEq, Debug)]
#[serde(rename_all = "lowercase")]
pub enum Thinking {
/// No thinking: OpenAI gets the effort `none`, and Anthropic gets disabled thinking. If an
/// OpenAI model cannot switch thinking off, the agent uses `low`.
Off,
/// The least thinking.
Low,
/// Moderate thinking.
Medium,
/// Much thinking.
High,
/// More thinking than `high`, where the model supports it.
Xhigh,
/// The most thinking that the model supports.
Max,
}
impl Thinking {
/// The effort that an OpenAI request carries: `none`, or one of `LEVELS`.
fn effort(self) -> &'static str {
match self {
Thinking::Off => "none",
Thinking::Low => "low",
Thinking::Medium => "medium",
Thinking::High => "high",
Thinking::Xhigh => "xhigh",
Thinking::Max => "max",
}
}
} }
impl Config { impl Config {
@@ -98,7 +132,11 @@ fn fallback_effort(want: &str, error: &str, tried: &[String]) -> Option<String>
} else { } else {
LEVELS.to_vec() LEVELS.to_vec()
}; };
let at = LEVELS.iter().position(|l| *l == want).unwrap_or(1) as i32; // `none` is below all levels, so its nearest level is `low`.
let at = LEVELS
.iter()
.position(|l| *l == want)
.map_or(-1, |i| i as i32);
pool.into_iter() pool.into_iter()
.filter(|l| *l != want && !tried.iter().any(|t| t == l)) .filter(|l| *l != want && !tried.iter().any(|t| t == l))
.min_by_key(|l| { .min_by_key(|l| {
@@ -130,6 +168,22 @@ mod tests {
} }
let cfg = Config::parse(b"api: openai-chat-completions\nbase_url: http://localhost/v1\nmodel: local\ntoken: ''\nallow_no_token: true").unwrap(); let cfg = Config::parse(b"api: openai-chat-completions\nbase_url: http://localhost/v1\nmodel: local\ntoken: ''\nallow_no_token: true").unwrap();
assert!(cfg.bearer().is_empty()); assert!(cfg.bearer().is_empty());
assert_eq!(cfg.thinking, None);
}
#[test]
fn thinking_levels() {
use super::{Config, LEVELS, Thinking};
let base = "api: openai-responses\nbase_url: url\nmodel: model\ntoken: key\nthinking: ";
let parse = |level: &str| Config::parse(format!("{base}{level}").as_bytes());
assert_eq!(parse("off").unwrap().thinking, Some(Thinking::Off));
assert_eq!(Thinking::Off.effort(), "none");
for level in LEVELS {
assert_eq!(parse(level).unwrap().thinking.unwrap().effort(), level);
}
let error = parse("hihg").err().unwrap();
assert!(error.contains("/config/agent.yaml"), "{error}");
assert!(error.contains("thinking"), "{error}");
} }
#[test] #[test]
@@ -178,10 +232,16 @@ mod tests {
fallback_effort("xhigh", anthropic, &["xhigh".into()]).as_deref(), fallback_effort("xhigh", anthropic, &["xhigh".into()]).as_deref(),
Some("high") Some("high")
); );
let openai = "HTTP 400: Invalid value: 'ultra'. Supported values are: 'none', 'minimal', 'low', 'medium', 'high', 'xhigh', and 'max'."; let openai = "HTTP 400: Invalid value: 'max'. Supported values are: 'none', 'minimal', 'low', 'medium', and 'high'.";
assert_eq!( assert_eq!(
fallback_effort("ultra", openai, &["ultra".into()]).as_deref(), fallback_effort("max", openai, &["max".into()]).as_deref(),
Some("medium") Some("high")
);
// A thinking-only model rejects `none`: use the lowest level.
let glm = "HTTP 400: GLM-5.3 is a thinking-only model; disabling thinking (reasoning_effort='none') is not supported.";
assert_eq!(
fallback_effort("none", glm, &["none".into()]).as_deref(),
Some("low")
); );
// No list in the error: step to the neighbour, never to off. // No list in the error: step to the neighbour, never to off.
assert_eq!( assert_eq!(
@@ -254,31 +314,14 @@ pub async fn complete(
tools: &[Tool], tools: &[Tool],
history: &[Entry], history: &[Entry],
) -> Result<Vec<Entry>, String> { ) -> Result<Vec<Entry>, String> {
let want = match cfg.api.as_str() { let want = cfg.thinking.map(|level| level.effort().to_string());
"openai-responses" | "openai-chat-completions" => cfg
.reasoning_effort
.clone()
.or_else(|| cfg.thinking.clone()),
_ => cfg.thinking.clone(),
}
.filter(|t| t != "off");
let mut effort = want.clone(); let mut effort = want.clone();
let mut tried = Vec::new(); let mut tried = Vec::new();
loop { loop {
let result = match cfg.api.as_str() { let result = match cfg.api.as_str() {
"openai-responses" => openai(cfg, system, tools, history, effort.as_deref()).await, "openai-responses" => openai(cfg, system, tools, history, effort.as_deref()).await,
"openai-chat-completions" => chat(cfg, system, tools, history, effort.as_deref()).await, "openai-chat-completions" => chat(cfg, system, tools, history, effort.as_deref()).await,
"anthropic-messages" => { "anthropic-messages" => anthropic(cfg, system, tools, history, effort.as_deref()).await,
anthropic(
cfg,
system,
tools,
history,
want.is_some(),
effort.as_deref(),
)
.await
}
other => return Err(format!("unknown API shape {other:?}")), other => return Err(format!("unknown API shape {other:?}")),
}; };
match (result, effort.clone()) { match (result, effort.clone()) {
@@ -430,9 +473,9 @@ async fn anthropic(
system: &str, system: &str,
tools: &[Tool], tools: &[Tool],
history: &[Entry], history: &[Entry],
thinking: bool,
effort: Option<&str>, effort: Option<&str>,
) -> Result<Vec<Entry>, String> { ) -> Result<Vec<Entry>, String> {
let disabled = cfg.thinking == Some(Thinking::Off);
// Merge consecutive blocks of the same role into one message. // Merge consecutive blocks of the same role into one message.
let mut messages: Vec<(String, Vec<Value>)> = Vec::new(); let mut messages: Vec<(String, Vec<Value>)> = Vec::new();
let mut push = |role: &str, block: Value| match messages.last_mut() { let mut push = |role: &str, block: Value| match messages.last_mut() {
@@ -462,7 +505,7 @@ async fn anthropic(
), ),
// Thinking blocks go back unchanged (with their signature): required next to tool use. // Thinking blocks go back unchanged (with their signature): required next to tool use.
Entry::Opaque { api, model, item } Entry::Opaque { api, model, item }
if api == "anthropic-messages" && model == &cfg.model && thinking => if api == "anthropic-messages" && model == &cfg.model && !disabled =>
{ {
push("assistant", item.clone()) push("assistant", item.clone())
} }
@@ -486,10 +529,15 @@ async fn anthropic(
"tools": tools, "tools": tools,
"max_tokens": cfg.max_tokens.unwrap_or(32000), "max_tokens": cfg.max_tokens.unwrap_or(32000),
}); });
if thinking { // Unset: the model's default. Newer models think by default.
body["thinking"] = json!({"type": "adaptive"}); match cfg.thinking {
if let Some(effort) = effort { None => {}
body["output_config"] = json!({"effort": effort}); Some(Thinking::Off) => body["thinking"] = json!({"type": "disabled"}),
Some(_) => {
body["thinking"] = json!({"type": "adaptive"});
if let Some(effort) = effort {
body["output_config"] = json!({"effort": effort});
}
} }
} }
let url = format!("{}/v1/messages", cfg.base_url.trim_end_matches('/')); let url = format!("{}/v1/messages", cfg.base_url.trim_end_matches('/'));
+7 -2
View File
@@ -4,7 +4,7 @@ mod llm;
mod tools; mod tools;
use history::{is_too_large, next_turn_start}; use history::{is_too_large, next_turn_start};
use llm::{Config, Entry}; use llm::{Config, Entry, Thinking};
use sausage_plugin::{Ctx, Error, Result, lock, rpc, schema}; use sausage_plugin::{Ctx, Error, Result, lock, rpc, schema};
use serde_json::json; use serde_json::json;
use std::io::Write; use std::io::Write;
@@ -22,6 +22,10 @@ struct Agent;
struct ChatParams { struct ChatParams {
/// The complete text of the person's request. /// The complete text of the person's request.
text: String, text: String,
/// The thinking level of this turn only. Without it, the turn uses `thinking` from
/// `/config/agent.yaml`.
#[serde(default, skip_serializing_if = "Option::is_none")]
thinking: Option<Thinking>,
} }
#[rpc(grace_seconds = 600)] #[rpc(grace_seconds = 600)]
@@ -32,7 +36,8 @@ impl Agent {
async fn chat(&self, ctx: &Ctx, p: ChatParams) -> Result<String> { async fn chat(&self, ctx: &Ctx, p: ChatParams) -> Result<String> {
let _guard = lock(HISTORY).await?; let _guard = lock(HISTORY).await?;
std::fs::create_dir_all("/state/agent")?; std::fs::create_dir_all("/state/agent")?;
let cfg = Config::load().map_err(|error| Error::new(-32603, error))?; let mut cfg = Config::load().map_err(|error| Error::new(-32603, error))?;
cfg.thinking = p.thinking.or(cfg.thinking);
let mut entries = load_history(HISTORY)?; let mut entries = load_history(HISTORY)?;
repair_tools(&mut entries)?; repair_tools(&mut entries)?;
let current = entries.len(); let current = entries.len();
+15 -9
View File
@@ -4,32 +4,37 @@ api: openai-responses
# OpenAI base URLs include /v1. Do not include /responses or /chat/completions. # OpenAI base URLs include /v1. Do not include /responses or /chat/completions.
base_url: https://api.openai.com/v1 base_url: https://api.openai.com/v1
# model is the provider's model identifier. # model is the provider's model identifier.
model: gpt-4.1 model: gpt-6.1-sol
# token is the API key. Replace CHANGE_ME with your key. # token is the API key. Replace CHANGE_ME with your key.
# An empty token requires allow_no_token: true. Then no authentication header is sent. # An empty token requires allow_no_token: true. Then no authentication header is sent.
token: CHANGE_ME token: CHANGE_ME
allow_no_token: false allow_no_token: false
# thinking is how much the model thinks before it answers:
# off, low, medium, high, xhigh, or max. Remove the line to use the model's default,
# for example for a model without thinking.
# If the model rejects a level, the agent uses the nearest level that the model accepts.
# If the model cannot switch thinking off, off becomes low.
# A caller can set another level for one turn: agent.chat {"text": "...", "thinking": "high"}.
# The agent reads this file at the start of each turn, so a change applies to the next turn.
thinking: medium
# Optional output token cap. Anthropic defaults to 32000 if omitted. # Optional output token cap. Anthropic defaults to 32000 if omitted.
# max_tokens: 32000 # max_tokens: 32000
# Optional thinking effort: off, low, medium, high, xhigh, or max.
# The agent retries a rejected effort with the nearest accepted level.
# thinking: high
# Optional OpenAI override for thinking effort.
# reasoning_effort: high
# OpenAI Responses example (base_url includes the version): # OpenAI Responses example (base_url includes the version):
# api: openai-responses # api: openai-responses
# base_url: https://api.openai.com/v1 # base_url: https://api.openai.com/v1
# model: gpt-4.1 # model: gpt-6.1-sol
# token: YOUR_OPENAI_KEY # token: YOUR_OPENAI_KEY
# allow_no_token: false # allow_no_token: false
# thinking: medium
# OpenRouter example (OpenAI Chat Completions, the format of most providers): # OpenRouter example (OpenAI Chat Completions, the format of most providers):
# api: openai-chat-completions # api: openai-chat-completions
# base_url: https://openrouter.ai/api/v1 # base_url: https://openrouter.ai/api/v1
# model: anthropic/claude-sonnet-4.5 # model: anthropic/claude-sonnet-5.5
# token: YOUR_OPENROUTER_KEY # token: YOUR_OPENROUTER_KEY
# allow_no_token: false # allow_no_token: false
# thinking: medium
# Local Ollama example (OpenAI Chat Completions, no key): # Local Ollama example (OpenAI Chat Completions, no key):
# api: openai-chat-completions # api: openai-chat-completions
@@ -41,6 +46,7 @@ allow_no_token: false
# Anthropic Messages example (base_url excludes the version): # Anthropic Messages example (base_url excludes the version):
# api: anthropic-messages # api: anthropic-messages
# base_url: https://api.anthropic.com # base_url: https://api.anthropic.com
# model: claude-sonnet-4-5 # model: claude-sonnet-5-5
# token: YOUR_ANTHROPIC_KEY # token: YOUR_ANTHROPIC_KEY
# allow_no_token: false # allow_no_token: false
# thinking: medium
+59 -5
View File
@@ -241,6 +241,10 @@ async fn unconfigured_chat_names_the_configuration() {
"api: openai-responses\nbase_url: http://localhost/v1\nmodel: test\ntoken: CHANGE_ME", "api: openai-responses\nbase_url: http://localhost/v1\nmodel: test\ntoken: CHANGE_ME",
"token", "token",
), ),
(
"api: openai-responses\nbase_url: http://localhost/v1\nmodel: test\ntoken: key\nthinking: hihg",
"thinking",
),
] { ] {
std::fs::write(&path, config).unwrap(); std::fs::write(&path, config).unwrap();
let error = host let error = host
@@ -507,11 +511,7 @@ async fn private_items_are_scoped_to_the_model() {
for api in [APIS[0], APIS[1]] { for api in [APIS[0], APIS[1]] {
let mut server = Llm::new(api).await; let mut server = Llm::new(api).await;
let world = TestWorld::new(&server); let world = TestWorld::new(&server);
let config_path = world.0.join("config/agent.yaml"); // Without a thinking level, newer models think by default, so their items return.
let mut config: Value =
serde_yaml_ng::from_slice(&std::fs::read(&config_path).unwrap()).unwrap();
config["thinking"] = json!("high");
std::fs::write(config_path, serde_yaml_ng::to_string(&config).unwrap()).unwrap();
std::fs::create_dir_all(world.0.join("state/agent")).unwrap(); std::fs::create_dir_all(world.0.join("state/agent")).unwrap();
let mut entries = vec![json!({"kind":"user","text":"old turn"})]; let mut entries = vec![json!({"kind":"user","text":"old turn"})];
for (model, marker) in [ for (model, marker) in [
@@ -546,6 +546,60 @@ async fn private_items_are_scoped_to_the_model() {
} }
} }
/// Each API gets the configured thinking level. A level in the call applies to that turn only.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn thinking_levels_reach_each_api() {
for api in APIS {
let mut server = Llm::new(api).await;
let world = TestWorld::new(&server);
let config_path = world.0.join("config/agent.yaml");
let mut config: Value =
serde_yaml_ng::from_slice(&std::fs::read(&config_path).unwrap()).unwrap();
config["thinking"] = json!("high");
std::fs::write(config_path, serde_yaml_ng::to_string(&config).unwrap()).unwrap();
let host = world.start().await;
// Off is `none` for OpenAI, and disabled thinking without an effort for Anthropic.
let off = if api == APIS[1] { None } else { Some("none") };
for (thinking, effort) in [
(None, Some("high")),
(Some("low"), Some("low")),
(Some("off"), off),
(None, Some("high")),
] {
let mut params = json!({"text":"hello"});
if let Some(thinking) = thinking {
params["thinking"] = json!(thinking);
}
let caller = host.clone();
let turn =
tokio::spawn(async move { caller.call("agent.chat", params).await.unwrap() });
let (body, reply) = server.next().await;
let sent = if api == APIS[0] {
&body["reasoning"]["effort"]
} else if api == APIS[2] {
&body["reasoning_effort"]
} else {
let mode = if thinking == Some("off") {
"disabled"
} else {
"adaptive"
};
assert_eq!(body["thinking"]["type"], mode, "{body}");
&body["output_config"]["effort"]
};
assert_eq!(sent.as_str(), effort, "{api} {thinking:?}");
reply.send((200, server.answer("done", &[]))).unwrap();
assert_eq!(turn.await.unwrap(), "done");
}
let error = host
.call("agent.chat", json!({"text":"hello","thinking":"hihg"}))
.await
.unwrap_err();
assert_eq!(error["code"], -32602, "{error}");
host.stop().await;
}
}
/// Separate assistant messages retain a separator in the final answer. /// Separate assistant messages retain a separator in the final answer.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn final_texts_have_a_separator() { async fn final_texts_have_a_separator() {