host: preserve journals, full traps and embedded agent output

Write new journal entries under logs/core and merge read-only legacy
history without moving SQLite/WAL files. Atomically spool failed batches,
retaining unspoolable entries for event-driven retries. Journal full trap
diagnostics before UTF-8-safe status display. Port session overflow archives
and followable line/byte excerpts to the still-shipped embedded agent.
Document retention/recovery and cover the contracts with regressions.
Include one rustfmt-only normalization in the existing SDK WASI test.

Card: SAU-143
This commit is contained in:
SAU-143 implementer
2026-10-26 13:40:15 +00:00
parent 168795b408
commit 037377fd27
13 changed files with 818 additions and 83 deletions
+85 -23
View File
@@ -4,21 +4,47 @@ 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;
#[path = "overflow.rs"]
pub mod overflow;
/// 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;
/// Human-readable, lossless text representation of tool JSON. String values
/// retain real newlines, with JSON-quoted field/index labels for structure.
fn render(value: &Value) -> String {
fn visit(value: &Value, label: &str, out: &mut String) {
match value {
Value::Object(fields) => {
if fields.is_empty() {
out.push_str(&format!("{label}: {{}}\n"));
}
for (key, value) in fields {
visit(
value,
&format!("{label}[{}]", serde_json::to_string(key).unwrap()),
out,
);
}
}
Value::Array(items) => {
if items.is_empty() {
out.push_str(&format!("{label}: []\n"));
}
for (i, value) in items.iter().enumerate() {
visit(value, &format!("{label}[{i}]"), out);
}
}
Value::String(text) => {
out.push_str(&format!("{label} (string, {} bytes):\n", text.len()));
out.push_str(text);
out.push('\n');
}
_ => {
out.push_str(&format!("{label}: {value}\n"));
}
}
output.truncate(end);
output.push_str(MARKER);
}
output
let mut out = String::new();
visit(value, "$", &mut out);
out
}
/// Describe the five tools sent to either inference API.
@@ -26,7 +52,7 @@ 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. Output is capped at 65536 bytes with a truncation marker.",
description: "Call a plugin method through the core. Await its result. Set detach to let its process survive this caller. Large results show an excerpt with a full-output /logs reference.",
parameters: schema(
json!({
"method": {"type": "string", "description": "Qualified plugin method"},
@@ -38,19 +64,21 @@ pub fn definitions() -> Vec<Tool> {
},
Tool {
name: "read_file",
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.",
description: "Read a text file in the world, including `/proc`. `offset`/`max_bytes` select bytes; `line_offset`/`line_limit` select zero-based lines (default 200). Large results show an excerpt with a full-output /logs reference.",
parameters: schema(
json!({
"path": {"type": "string"},
"offset": {"type": "integer", "description": "Byte offset (default 0)"},
"max_bytes": {"type": "integer", "description": "Optional maximum number of bytes to read"}
"max_bytes": {"type": "integer", "description": "Optional maximum number of bytes to read"},
"line_offset": {"type": "integer", "description": "Zero-based starting line; overrides bytes"},
"line_limit": {"type": "integer", "description": "Number of lines; default 200"}
}),
&["path"],
),
},
Tool {
name: "write_file",
description: "Write a text file in the world. Parent folders are created. Output is capped at 65536 bytes with a truncation marker.",
description: "Write a text file in the world. Parent folders are created. Large results show an excerpt with a full-output /logs reference.",
parameters: schema(
json!({"path": {"type": "string"}, "content": {"type": "string"}}),
&["path", "content"],
@@ -58,12 +86,12 @@ pub fn definitions() -> Vec<Tool> {
},
Tool {
name: "list_dir",
description: "List entries in a world directory. Returns each name, type, and size. Output is capped at 65536 bytes with a truncation marker.",
description: "List entries in a world directory. Returns each name, type, and size. Large results show an excerpt with a full-output /logs reference.",
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. Output is capped at 65536 bytes with a truncation marker.",
description: "Copy a directory tree, for example a plugin source to a draft folder. Skips `target/` folders. Large results show an excerpt with a full-output /logs reference.",
parameters: schema(
json!({"from": {"type": "string"}, "to": {"type": "string"}}),
&["from", "to"],
@@ -103,6 +131,16 @@ pub async fn run(name: &str, args: &Value) -> sausage_plugin::Result<Value> {
fn read_file(args: &Value) -> io::Result<Value> {
let path = args["path"].as_str().unwrap_or_default();
let bytes = std::fs::read(path)?;
if path.starts_with("/logs/") {
return Ok(
json!({"path":path,"size":bytes.len(),"content":overflow::existing(&bytes, path, args)}),
);
}
if let Some(line) = args["line_offset"].as_u64() {
return Ok(
json!({"path":path,"size":bytes.len(),"line_offset":line,"content":overflow::read_lines(&bytes, line as usize, args["line_limit"].as_u64().unwrap_or(200) as usize)}),
);
}
let offset = (args["offset"].as_u64().unwrap_or(0) as usize).min(bytes.len());
let end = args["max_bytes"].as_u64().map_or(bytes.len(), |max| {
offset.saturating_add(max as usize).min(bytes.len())
@@ -185,11 +223,35 @@ fn copy_tree(from: &str, to: &str) -> io::Result<u64> {
#[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");
fn overflow_json_can_be_followed_by_line_without_rearchiving() {
let value = serde_json::json!({"stdout":"hello 😀\n".repeat(4000),"exit":1,"error":{"text":"failure\n".repeat(4000)}});
let expected = super::render(&value);
let shown = super::overflow::guard(value.to_string(), "json-test", "call", true);
assert!(shown.len() <= 50 * 1024 && shown.lines().count() <= 2000);
let path = shown
.split("Full output: ")
.nth(1)
.unwrap()
.split(". Read with")
.next()
.unwrap();
assert_eq!(std::fs::read_to_string(path).unwrap(), expected);
let parent = std::path::Path::new(path).parent().unwrap();
let count = std::fs::read_dir(parent).unwrap().count();
let args = serde_json::json!({"path":path,"line_offset":30,"line_limit":5});
let read = super::read_file(&args).unwrap();
let read = read["content"].as_str().unwrap();
assert!(read.contains("Showing lines 31-35"));
assert!(read.contains(path));
assert_eq!(std::fs::read_dir(parent).unwrap().count(), count);
assert!(
super::overflow::existing(
expected.as_bytes(),
path,
&serde_json::json!({"line_offset":u64::MAX})
)
.contains("Full output:")
);
}
#[test]
fn copy_rejects_a_destination_inside_the_source() {