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:
@@ -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
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user