Files
huskies/server/src/agent_log/format.rs
T
2026-05-12 17:55:12 +00:00

184 lines
7.7 KiB
Rust

//! Human-readable formatting of raw agent log entries.
use crate::chat::util::truncate_at_char_boundary;
/// Format a single log entry as a human-readable text line.
///
/// `timestamp` is an ISO 8601 string; `event` is the flattened `AgentEvent`
/// value (has `type`, `agent_name`, etc. at the top level).
///
/// Returns `None` for entries that should be skipped (raw streaming noise,
/// trivial status changes, empty output, etc.).
#[allow(clippy::string_slice)] // timestamp[11..19]: ISO 8601 is ASCII-only, these byte offsets are always valid
pub fn format_log_entry_as_text(timestamp: &str, event: &serde_json::Value) -> Option<String> {
let agent_name = event
.get("agent_name")
.and_then(|v| v.as_str())
.unwrap_or("?");
// Extract HH:MM:SS from ISO 8601 "2026-04-10T12:48:02.123456789+00:00"
let ts_short = if timestamp.len() >= 19 {
&timestamp[11..19]
} else {
timestamp
};
let pfx = format!("[{ts_short}][{agent_name}]");
match event.get("type").and_then(|v| v.as_str()) {
Some("output") => {
let text = event
.get("text")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim();
if text.is_empty() {
None
} else {
Some(format!("{pfx} {text}"))
}
}
Some("error") => {
let msg = event
.get("message")
.and_then(|v| v.as_str())
.unwrap_or("(unknown error)");
Some(format!("{pfx} ERROR: {msg}"))
}
Some("done") => Some(format!("{pfx} DONE")),
Some("status") => {
// Skip trivial running/started noise
let status = event.get("status").and_then(|v| v.as_str()).unwrap_or("?");
match status {
"running" | "started" => None,
_ => Some(format!("{pfx} STATUS: {status}")),
}
}
Some("agent_json") => {
let data = event.get("data")?;
match data.get("type").and_then(|v| v.as_str()) {
Some("assistant") => {
let mut parts: Vec<String> = Vec::new();
if let Some(arr) = data.pointer("/message/content").and_then(|v| v.as_array()) {
for item in arr {
match item.get("type").and_then(|v| v.as_str()) {
Some("text") => {
let text = item
.get("text")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim();
if !text.is_empty() {
parts.push(format!("{pfx} {text}"));
}
}
Some("tool_use") => {
let name =
item.get("name").and_then(|v| v.as_str()).unwrap_or("?");
let input = item
.get("input")
.map(|v| serde_json::to_string(v).unwrap_or_default())
.unwrap_or_default();
let display = if input.len() > 200 {
format!("{}...", truncate_at_char_boundary(&input, 200))
} else {
input
};
parts.push(format!("{pfx} TOOL: {name}({display})"));
}
_ => {}
}
}
}
if parts.is_empty() {
None
} else {
Some(parts.join("\n"))
}
}
Some("user") => {
let mut parts: Vec<String> = Vec::new();
if let Some(arr) = data.pointer("/message/content").and_then(|v| v.as_array()) {
for item in arr {
if item.get("type").and_then(|v| v.as_str()) != Some("tool_result") {
continue;
}
let content_str = match item.get("content") {
Some(serde_json::Value::String(s)) => s.clone(),
Some(v) => v.to_string(),
None => String::new(),
};
let display = if content_str.len() > 500 {
// Walk back to the nearest char boundary so we
// don't panic when the 500-byte mark lands
// inside a multi-byte UTF-8 codepoint (e.g.
// box-drawing chars like '─', smart quotes,
// emoji). `is_char_boundary(len)` is always
// true so the loop terminates.
let mut end = 500;
while !content_str.is_char_boundary(end) {
end -= 1;
}
format!(
"{}... [{} chars total]",
&content_str[..end],
content_str.len()
)
} else {
content_str
};
if !display.trim().is_empty() {
parts.push(format!("{pfx} RESULT: {display}"));
}
}
}
if parts.is_empty() {
None
} else {
Some(parts.join("\n"))
}
}
_ => None, // Skip stream_event, system init, etc.
}
}
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
/// Regression: a tool_result whose content is >500 bytes AND has a
/// multi-byte UTF-8 codepoint straddling byte 500 must not panic.
/// Previously `&content_str[..500]` would slice mid-codepoint and crash
/// the get_agent_output MCP tool.
#[test]
fn tool_result_truncation_handles_multibyte_at_boundary() {
// 498 ASCII filler + a 3-byte '─' (U+2500) starting at byte 499 +
// 100 more ASCII chars. The naive `..500` slice would land inside
// the box-drawing char and panic.
let mut content = "a".repeat(499);
content.push('─');
content.push_str(&"b".repeat(100));
assert!(content.len() > 500);
assert!(!content.is_char_boundary(500));
let event = json!({
"type": "agent_json",
"agent_name": "coder-1",
"data": {
"type": "user",
"message": {
"content": [{ "type": "tool_result", "content": content }]
}
}
});
let out = format_log_entry_as_text("2026-05-12T15:30:00.000000Z", &event);
assert!(out.is_some(), "tool_result must format without panicking");
let s = out.unwrap();
assert!(s.contains("RESULT:"));
assert!(s.contains("chars total"));
}
}