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
+15 -9
View File
@@ -4,32 +4,37 @@ api: openai-responses
# OpenAI base URLs include /v1. Do not include /responses or /chat/completions.
base_url: https://api.openai.com/v1
# 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.
# An empty token requires allow_no_token: true. Then no authentication header is sent.
token: CHANGE_ME
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.
# 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):
# api: openai-responses
# base_url: https://api.openai.com/v1
# model: gpt-4.1
# model: gpt-6.1-sol
# token: YOUR_OPENAI_KEY
# allow_no_token: false
# thinking: medium
# OpenRouter example (OpenAI Chat Completions, the format of most providers):
# api: openai-chat-completions
# base_url: https://openrouter.ai/api/v1
# model: anthropic/claude-sonnet-4.5
# model: anthropic/claude-sonnet-5.5
# token: YOUR_OPENROUTER_KEY
# allow_no_token: false
# thinking: medium
# Local Ollama example (OpenAI Chat Completions, no key):
# api: openai-chat-completions
@@ -41,6 +46,7 @@ allow_no_token: false
# Anthropic Messages example (base_url excludes the version):
# api: anthropic-messages
# base_url: https://api.anthropic.com
# model: claude-sonnet-4-5
# model: claude-sonnet-5-5
# token: YOUR_ANTHROPIC_KEY
# 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",
"token",
),
(
"api: openai-responses\nbase_url: http://localhost/v1\nmodel: test\ntoken: key\nthinking: hihg",
"thinking",
),
] {
std::fs::write(&path, config).unwrap();
let error = host
@@ -507,11 +511,7 @@ async fn private_items_are_scoped_to_the_model() {
for api in [APIS[0], APIS[1]] {
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();
// Without a thinking level, newer models think by default, so their items return.
std::fs::create_dir_all(world.0.join("state/agent")).unwrap();
let mut entries = vec![json!({"kind":"user","text":"old turn"})];
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.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn final_texts_have_a_separator() {