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
+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() {