Files
sausage/agent/src/main.rs
T
SAU-143 implementer 037377fd27 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
2026-10-26 13:40:15 +00:00

212 lines
7.8 KiB
Rust

//! Answer chat calls with an LLM and tools, under one lock for the complete turn.
mod history;
mod llm;
mod tools;
use history::{is_too_large, next_turn_start};
use llm::{Config, Entry, Thinking};
use sausage_plugin::{Ctx, Error, Result, lock, rpc, schema};
use serde_json::json;
use std::io::Write;
/// The instructions sent with every inference request.
const SYSTEM: &str = include_str!("prompt.md");
/// The shared, append-only conversation.
const HISTORY: &str = "/state/agent/history.jsonl";
/// The first retained turn after a context-size rejection.
const SKIP: &str = "/state/agent/history.skip";
/// Each chat process reads the shared history after it acquires the lock.
struct Agent;
/// The person's next contribution to the conversation.
#[schema]
struct ChatParams {
/// The complete text of the person's request.
text: String,
/// The thinking level of this turn only. Without it, the turn uses `thinking` from
/// `/config/agent.yaml`.
#[serde(default, skip_serializing_if = "Option::is_none")]
thinking: Option<Thinking>,
}
#[rpc(grace_seconds = 600)]
impl Agent {
/// Answer a person, with interim text notifications and a final text result.
///
/// The history lock covers inference and tools, so concurrent chats cannot interleave turns.
async fn chat(&self, ctx: &Ctx, p: ChatParams) -> Result<String> {
let _guard = lock(HISTORY).await?;
std::fs::create_dir_all("/state/agent")?;
let mut cfg = Config::load().map_err(|error| Error::new(-32603, error))?;
cfg.thinking = p.thinking.or(cfg.thinking);
let mut entries = load_history(HISTORY)?;
repair_tools(&mut entries)?;
let current = entries.len();
append(&mut entries, Entry::User { text: p.text })?;
let mut start = std::fs::read_to_string(SKIP)
.ok()
.and_then(|s| s.trim().parse().ok())
.unwrap_or(0);
start = next_turn_start(&entries, start, current).unwrap_or(current);
let definitions = tools::definitions();
loop {
let response = match llm::complete(&cfg, SYSTEM, &definitions, &entries[start..]).await
{
Ok(response) => response,
Err(error) => {
if is_too_large(&error)
&& let Some(next) = next_turn_start(&entries, start + 1, current)
{
start = next;
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 { .. }));
let mut final_text = String::new();
let mut calls = Vec::new();
for entry in response {
match &entry {
Entry::Assistant { text, phase }
if has_tools || phase.as_deref() == Some("commentary") =>
{
ctx.notify("text", json!({"text":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,
arguments,
} => calls.push((id.clone(), name.clone(), arguments.clone())),
_ => {}
}
append(&mut entries, entry)?;
}
if !has_tools {
return Ok(final_text);
}
for (id, name, arguments) in calls {
let args = serde_json::from_str::<serde_json::Value>(&arguments);
let output = match &args {
Ok(args) => tools::run(&name, args)
.await
.unwrap_or_else(|error| json!({"error":error})),
Err(error) => json!({"error":format!("arguments are not valid JSON: {error}")}),
};
let output = if name == "read_file"
&& args
.as_ref()
.ok()
.and_then(|a| a["path"].as_str())
.is_some_and(|p| p.starts_with("/logs/"))
{
output["content"]
.as_str()
.map(str::to_owned)
.unwrap_or_else(|| output.to_string())
} else {
output.to_string()
};
let tail = args
.as_ref()
.ok()
.and_then(|a| a["method"].as_str())
.is_some_and(|m| {
[".exec", ".log", ".build", ".test"]
.iter()
.any(|suffix| m.ends_with(suffix))
});
let output = tools::overflow::guard(output, "default", &id, tail);
append(&mut entries, Entry::ToolResult { id, output })?;
}
}
}
}
/// Read persisted entries in their original order. A new world has no history file.
fn load_history(path: &str) -> Result<Vec<Entry>> {
match std::fs::read_to_string(path) {
Ok(text) => Ok(text
.lines()
.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()),
}
}
/// Persist an entry before it enters the current inference context.
fn append(history: &mut Vec<Entry>, entry: Entry) -> Result<()> {
let mut file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(HISTORY)?;
file.write_all(format!("{}\n", serde_json::to_string(&entry)?).as_bytes())?;
history.push(entry);
Ok(())
}
/// Complete interrupted tool pairs before a new inference request.
fn repair_tools(history: &mut Vec<Entry>) -> Result<()> {
let answered: std::collections::HashSet<_> = history
.iter()
.filter_map(|entry| match entry {
Entry::ToolResult { id, .. } => Some(id.clone()),
_ => None,
})
.collect();
let dangling: Vec<_> = history
.iter()
.filter_map(|entry| match entry {
Entry::ToolCall { id, .. } if !answered.contains(id) => Some(id.clone()),
_ => None,
})
.collect();
for id in dangling {
append(
history,
Entry::ToolResult {
id,
output: json!({"error":"interrupted before the tool finished"}).to_string(),
},
)?;
}
Ok(())
}
/// Start the plugin's JSON-RPC transport.
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();
}
}