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:
+85
-23
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user