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
+6 -1
View File
@@ -10,7 +10,10 @@ pub fn is_too_large(error: &str) -> bool {
else {
return false;
};
if !matches!(status.split_whitespace().next(), Some("400" | "413")) {
if status.split_whitespace().next() == Some("413") {
return true;
}
if status.split_whitespace().next() != Some("400") {
return false;
}
let body = body.to_ascii_lowercase();
@@ -38,6 +41,8 @@ mod tests {
fn detects_context_size_errors_only_for_http_400_or_413() {
assert!(is_too_large("HTTP 400: context_length_exceeded"));
assert!(is_too_large("HTTP 413: prompt is too long"));
assert!(is_too_large("HTTP 413: {\"type\":\"request_too_large\"}"));
assert!(is_too_large("HTTP 413: {}"));
assert!(is_too_large("HTTP 400: maximum context length"));
assert!(is_too_large("HTTP 400: request is too large"));
assert!(!is_too_large("HTTP 500: prompt is too long"));
+14 -3
View File
@@ -122,6 +122,9 @@ pub enum Entry {
Opaque {
/// The API shape that owns this entry.
api: String,
/// The model that produced this item. Legacy items without a model are not replayed.
#[serde(default)]
model: String,
/// The original provider object.
item: Value,
},
@@ -244,7 +247,7 @@ async fn openai(
Entry::ToolResult { id, output } => {
input.push(json!({"type": "function_call_output", "call_id": id, "output": output}))
}
Entry::Opaque { api, item } if api == "openai-responses" => input.push(item.clone()),
Entry::Opaque { api, model, item } if api == "openai-responses" && model == &cfg.model => input.push(item.clone()),
Entry::Opaque { .. } => {}
}
}
@@ -299,12 +302,17 @@ async fn openai(
}),
Some("reasoning") => out.push(Entry::Opaque {
api: "openai-responses".into(),
model: cfg.model.clone(),
item,
}),
_ => {}
}
}
if resp["status"] == "incomplete" && out.is_empty() {
if resp["status"] == "incomplete"
&& !out
.iter()
.any(|entry| matches!(entry, Entry::Assistant { text, .. } if !text.is_empty()))
{
return Err(format!(
"incomplete response: {}",
resp["incomplete_details"]
@@ -350,7 +358,9 @@ async fn anthropic(
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 => {
Entry::Opaque { api, model, item }
if api == "anthropic-messages" && model == &cfg.model && thinking =>
{
push("assistant", item.clone())
}
Entry::Opaque { .. } => {}
@@ -405,6 +415,7 @@ async fn anthropic(
}),
Some("thinking") | Some("redacted_thinking") => out.push(Entry::Opaque {
api: "anthropic-messages".into(),
model: cfg.model.clone(),
item: block,
}),
_ => {}
+42 -11
View File
@@ -33,7 +33,7 @@ impl Agent {
let _guard = lock(HISTORY).await?;
std::fs::create_dir_all("/state/agent")?;
let cfg: Config = serde_json::from_slice(&std::fs::read("/config/agent.json")?)?;
let mut entries = load_history()?;
let mut entries = load_history(HISTORY)?;
repair_tools(&mut entries)?;
let current = entries.len();
append(&mut entries, Entry::User { text: p.text })?;
@@ -52,12 +52,12 @@ impl Agent {
&& let Some(next) = next_turn_start(&entries, start + 1, current)
{
start = next;
std::fs::write(SKIP, start.to_string())?;
continue;
}
return Err(Error::new(-32603, error));
}
};
std::fs::write(SKIP, start.to_string())?;
let has_tools = response
.iter()
.any(|entry| matches!(entry, Entry::ToolCall { .. }));
@@ -70,7 +70,12 @@ impl Agent {
{
ctx.notify("text", json!({"text":text}))?
}
Entry::Assistant { text, .. } => final_text.push_str(text),
Entry::Assistant { text, .. } => {
if !final_text.is_empty() && !text.is_empty() {
final_text.push_str("\n\n");
}
final_text.push_str(text);
}
Entry::ToolCall {
id,
name,
@@ -87,14 +92,14 @@ impl Agent {
let output = match serde_json::from_str(&arguments) {
Ok(args) => tools::run(&name, &args)
.await
.unwrap_or_else(|error| json!({"error":error.to_string()})),
.unwrap_or_else(|error| json!({"error":error})),
Err(error) => json!({"error":format!("arguments are not valid JSON: {error}")}),
};
append(
&mut entries,
Entry::ToolResult {
id,
output: output.to_string(),
output: tools::limit_output(output.to_string()),
},
)?;
}
@@ -103,12 +108,18 @@ impl Agent {
}
/// Read persisted entries in their original order. A new world has no history file.
fn load_history() -> Result<Vec<Entry>> {
match std::fs::read_to_string(HISTORY) {
Ok(text) => text
fn load_history(path: &str) -> Result<Vec<Entry>> {
match std::fs::read_to_string(path) {
Ok(text) => Ok(text
.lines()
.map(|line| serde_json::from_str(line).map_err(Into::into))
.collect(),
.filter_map(|line| match serde_json::from_str(line) {
Ok(entry) => Some(entry),
Err(error) => {
eprintln!("skip damaged history line: {error}");
None
}
})
.collect::<Vec<_>>()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
Err(error) => Err(error.into()),
}
@@ -119,7 +130,7 @@ fn append(history: &mut Vec<Entry>, entry: Entry) -> Result<()> {
.create(true)
.append(true)
.open(HISTORY)?;
writeln!(file, "{}", serde_json::to_string(&entry)?)?;
file.write_all(format!("{}\n", serde_json::to_string(&entry)?).as_bytes())?;
history.push(entry);
Ok(())
}
@@ -154,3 +165,23 @@ fn repair_tools(history: &mut Vec<Entry>) -> Result<()> {
fn main() {
sausage_plugin::run(Agent);
}
#[cfg(test)]
mod tests {
#[test]
fn damaged_history_keeps_valid_entries() {
let path = if cfg!(target_os = "wasi") {
std::path::PathBuf::from("/history-test.jsonl")
} else {
std::env::temp_dir().join(format!("sausage-history-{}", std::process::id()))
};
std::fs::write(
&path,
"{\"kind\":\"user\",\"text\":\"preserved\"}\n{\"kind\":",
)
.unwrap();
let history = super::load_history(path.to_str().unwrap()).unwrap();
assert_eq!(history.len(), 1);
std::fs::remove_file(path).unwrap();
}
}
+1
View File
@@ -3,5 +3,6 @@ You are the Sausage agent. Answer the person. Use tools to inspect or change the
Read the contract at `/proc/core/PROTOCOL.md`. The Rust plugin SDK and its documentation are at `/plugins/cargo/sdk/`.
Build a plugin with `cargo.build`. Activate its WASM file with `core.activate`.
Your own source is at `/plugins/agent/source/`. You can change and rebuild it like any other plugin.
Never call `agent.chat`. It would wait on the history lock that your current turn holds.
Write logs to stderr. Use call results and notifications for communication.
The interface check does not cover persistent state. Preserve state compatibility when you change a plugin.
+54 -5
View File
@@ -4,12 +4,29 @@ use crate::llm::Tool;
use serde_json::{Value, json};
use std::io;
/// Maximum UTF-8 bytes in a serialized tool result, including its truncation marker.
pub const MAX_OUTPUT_BYTES: usize = 65_536;
/// Bound every tool result before it enters history or a provider request.
pub fn limit_output(mut output: String) -> String {
const MARKER: &str = "\n[tool output truncated at 65536 bytes]";
if output.len() > MAX_OUTPUT_BYTES {
let mut end = MAX_OUTPUT_BYTES - MARKER.len();
while !output.is_char_boundary(end) {
end -= 1;
}
output.truncate(end);
output.push_str(MARKER);
}
output
}
/// Describe the five tools sent to either inference API.
pub fn definitions() -> Vec<Tool> {
vec![
Tool {
name: "call",
description: "Call a plugin method through the core. Await its result. Set detach to let its process survive this caller.",
description: "Call a plugin method through the core. Await its result. Set detach to let its process survive this caller. Output is capped at 65536 bytes with a truncation marker.",
parameters: schema(
json!({
"method": {"type": "string", "description": "Qualified plugin method"},
@@ -21,7 +38,7 @@ pub fn definitions() -> Vec<Tool> {
},
Tool {
name: "read_file",
description: "Read a text file in the world, including `/proc`. Reads all bytes by default; `offset` and `max_bytes` are optional.",
description: "Read a text file in the world, including `/proc`. `offset` and `max_bytes` are optional. Output is capped at 65536 bytes with a truncation marker.",
parameters: schema(
json!({
"path": {"type": "string"},
@@ -33,7 +50,7 @@ pub fn definitions() -> Vec<Tool> {
},
Tool {
name: "write_file",
description: "Write a text file in the world. Parent folders are created.",
description: "Write a text file in the world. Parent folders are created. Output is capped at 65536 bytes with a truncation marker.",
parameters: schema(
json!({"path": {"type": "string"}, "content": {"type": "string"}}),
&["path", "content"],
@@ -41,12 +58,12 @@ pub fn definitions() -> Vec<Tool> {
},
Tool {
name: "list_dir",
description: "List entries in a world directory. Returns each name, type, and size.",
description: "List entries in a world directory. Returns each name, type, and size. Output is capped at 65536 bytes with a truncation marker.",
parameters: schema(json!({"path": {"type": "string"}}), &["path"]),
},
Tool {
name: "copy_dir",
description: "Copy a directory tree, for example a plugin source to a draft folder. Skips `target/` folders.",
description: "Copy a directory tree, for example a plugin source to a draft folder. Skips `target/` folders. Output is capped at 65536 bytes with a truncation marker.",
parameters: schema(
json!({"from": {"type": "string"}, "to": {"type": "string"}}),
&["from", "to"],
@@ -128,6 +145,15 @@ fn list_dir(args: &Value) -> io::Result<Value> {
/// Copy a tree and report its file count.
fn copy_dir(args: &Value) -> io::Result<Value> {
let from = std::fs::canonicalize(args["from"].as_str().unwrap_or_default())?;
let to = args["to"].as_str().unwrap_or_default();
std::fs::create_dir_all(to)?;
if std::fs::canonicalize(to)?.starts_with(&from) {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"destination is inside the source",
));
}
let count = copy_tree(
args["from"].as_str().unwrap_or_default(),
args["to"].as_str().unwrap_or_default(),
@@ -155,3 +181,26 @@ fn copy_tree(from: &str, to: &str) -> io::Result<u64> {
}
Ok(count)
}
#[cfg(test)]
mod tests {
#[test]
fn tool_output_limit_preserves_utf8_and_marks_truncation() {
let limited = super::limit_output("😀".repeat(40_000));
assert!(limited.len() <= super::MAX_OUTPUT_BYTES);
assert!(limited.ends_with("[tool output truncated at 65536 bytes]"));
assert_eq!(super::limit_output("small".into()), "small");
}
#[test]
fn copy_rejects_a_destination_inside_the_source() {
let root = if cfg!(target_os = "wasi") {
std::path::PathBuf::from("/copy-test")
} else {
std::env::temp_dir().join(format!("sausage-copy-{}", std::process::id()))
};
std::fs::create_dir_all(&root).unwrap();
let result = super::copy_dir(&serde_json::json!({"from":root,"to":root.join("target")}));
let _ = std::fs::remove_dir_all(root);
assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::InvalidInput);
}
}