sdk, agent: review fixes (T18R)

- The serve loop ends the process after EOF or exit_after_response,
  also with background tasks or listeners.
- One schema generator per direction for the whole document, so types
  with the same name get distinct names.
- The agent gives the complete error (code, message, data) to the
  model, writes SKIP only after a successful request, caps tool output
  at 64 KiB, writes each history line in one write, and skips a damaged
  line.
- Endpoints skip blank and non-RPC lines. client_rs does not panic.
- #[must_use] on builders and the lock guard; #[schemars] attributes
  and duplicate hooks are compile errors; raw identifiers; -32000 for
  local SDK errors; x-sausage-params only for methods with params.
This commit is contained in:
2026-10-23 02:38:35 +01:00
parent d7b78ed0fd
commit c728429d04
29 changed files with 829 additions and 104 deletions
+147 -3
View File
@@ -299,20 +299,25 @@ async fn tools_and_interim_notifications() {
),
("read_file", json!({"path":"/state/work/missing"})),
("call", json!({"method":"absent.run","params":{}})),
("call", json!({"method":"probe.fail","params":{}})),
];
reply
.send((200, server.answer("I will inspect the world.", &calls)))
.unwrap();
let (body, reply) = server.next().await;
let results = outputs(&body, api);
assert_eq!(results.len(), 7);
assert_eq!(results.len(), 8);
assert_eq!(results[0], 9);
assert!(results[1]["content"].as_str().unwrap().contains("JSON-RPC"));
assert_eq!(results[2]["written"], "/state/work/source/note.txt");
assert_eq!(results[3]["entries"][0]["name"], "note.txt");
assert_eq!(results[4]["copied_files"], 1);
assert!(results[5]["error"].is_string());
assert!(results[6]["error"].is_string());
assert!(results[5]["error"]["message"].is_string());
assert!(results[6]["error"]["code"].is_number());
assert_eq!(
results[7]["error"],
json!({"code":-32010,"message":"deliberate","data":{"diagnostic":"probe error details"}})
);
assert_eq!(
std::fs::read_to_string(world.0.join("state/work/copied/note.txt")).unwrap(),
"saved"
@@ -407,3 +412,142 @@ async fn context_window_advances_by_complete_turns() {
host.stop().await;
}
}
/// Oversized tool outputs are bounded, and failed retries do not commit a new history cursor.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn oversized_current_turn_preserves_the_previous_cursor() {
for api in APIS {
let mut server = Llm::new(api).await;
let world = TestWorld::new(&server);
std::fs::write(world.0.join("large.txt"), "x".repeat(200_000)).unwrap();
let host = world.start().await;
let first = chat(&host, "old retained turn");
let (_, reply) = server.next().await;
reply
.send((200, server.answer("old retained answer", &[])))
.unwrap();
first.await.unwrap();
let skip = world.0.join("state/agent/history.skip");
std::fs::write(&skip, "0").unwrap();
let caller = host.clone();
let turn = tokio::spawn(async move {
caller
.call("agent.chat", json!({"text":"read a large tool result"}))
.await
});
let (_, reply) = server.next().await;
reply
.send((
200,
server.answer("", &[("read_file", json!({"path":"/large.txt"}))]),
))
.unwrap();
let (body, reply) = server.next().await;
let serialized = input(&body, api).to_string();
assert!(serialized.contains("[tool output truncated"));
assert!(serialized.len() < 70_000);
reply
.send((413, json!({"type":"request_too_large"})))
.unwrap();
let (body, reply) = server.next().await;
assert!(!input(&body, api).to_string().contains("old retained turn"));
reply
.send((413, json!({"type":"request_too_large"})))
.unwrap();
assert!(turn.await.unwrap().is_err());
assert_eq!(std::fs::read_to_string(&skip).unwrap(), "0");
host.stop().await;
}
}
/// Provider-private items are replayed only for their original API and model.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn private_items_are_scoped_to_the_model() {
for api in APIS {
let mut server = Llm::new(api).await;
let world = TestWorld::new(&server);
let config_path = world.0.join("config/agent.json");
let mut config: Value =
serde_json::from_slice(&std::fs::read(&config_path).unwrap()).unwrap();
config["thinking"] = json!("high");
std::fs::write(config_path, config.to_string()).unwrap();
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 [
("another-model", "old-private-marker"),
("scripted-test", "current-private-marker"),
] {
let item = if api == APIS[0] {
json!({"type":"reasoning","encrypted_content":marker})
} else {
json!({"type":"thinking","thinking":"private","signature":marker})
};
entries.push(json!({"kind":"opaque","api":api,"model":model,"item":item}));
}
entries.push(json!({"kind":"assistant","text":"old answer"}));
std::fs::write(
world.0.join("state/agent/history.jsonl"),
entries
.iter()
.map(|entry| format!("{entry}\n"))
.collect::<String>(),
)
.unwrap();
let host = world.start().await;
let turn = chat(&host, "next turn");
let (body, reply) = server.next().await;
let text = input(&body, api).to_string();
assert!(!text.contains("old-private-marker"));
assert!(text.contains("current-private-marker"));
reply.send((200, server.answer("done", &[]))).unwrap();
assert_eq!(turn.await.unwrap(), "done");
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() {
for api in APIS {
let mut server = Llm::new(api).await;
let world = TestWorld::new(&server);
let host = world.start().await;
let turn = chat(&host, "two paragraphs");
let (_, reply) = server.next().await;
let mut answer = server.answer("first", &[]);
if api == APIS[0] {
answer["output"]
.as_array_mut()
.unwrap()
.push(json!({"type":"message","content":[{"type":"output_text","text":"second"}]}));
} else {
answer["content"]
.as_array_mut()
.unwrap()
.push(json!({"type":"text","text":"second"}));
}
reply.send((200, answer)).unwrap();
assert_eq!(turn.await.unwrap(), "first\n\nsecond");
host.stop().await;
}
}
/// An incomplete response containing reasoning but no text is an error.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn incomplete_without_text_is_an_error() {
let mut server = Llm::new(APIS[0]).await;
let world = TestWorld::new(&server);
let host = world.start().await;
let caller = host.clone();
let turn =
tokio::spawn(async move { caller.call("agent.chat", json!({"text":"answer"})).await });
let (_, reply) = server.next().await;
reply.send((200, json!({"status":"incomplete","output":[{"type":"reasoning","encrypted_content":"private"}]}))).unwrap();
assert!(
turn.await.unwrap().unwrap_err()["message"]
.as_str()
.unwrap()
.contains("incomplete response")
);
host.stop().await;
}