Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions crates/goose/src/agents/moim.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ pub async fn inject_moim(
!issue.contains("Merged consecutive user messages")
&& !issue.contains("Merged consecutive assistant messages")
&& !issue.contains("Added placeholder to empty tool result")
&& !issue.contains("Merged text content")
&& !issue.contains("Removed trailing assistant message")
&& !issue.contains("Trimmed trailing whitespace from assistant message")
});

if has_unexpected_issues {
Expand Down
17 changes: 12 additions & 5 deletions crates/goose/src/agents/platform_extensions/summon.rs
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,10 @@ struct AgentMetadata {
model: Option<String>,
}

fn parse_frontmatter<T: for<'de> Deserialize<'de>>(content: &str) -> Option<(T, String)> {
fn parse_frontmatter<T: for<'de> Deserialize<'de>>(
content: &str,
source_path: Option<&Path>,
) -> Option<(T, String)> {
let parts: Vec<&str> = content.split("---").collect();
if parts.len() < 3 {
return None;
Expand All @@ -159,7 +162,11 @@ fn parse_frontmatter<T: for<'de> Deserialize<'de>>(content: &str) -> Option<(T,
let metadata: T = match serde_yaml::from_str(yaml_content) {
Ok(m) => m,
Err(e) => {
warn!("Failed to parse frontmatter: {}", e);
if let Some(path) = source_path {
tracing::debug!("Failed to parse frontmatter in {}: {}", path.display(), e);
} else {
tracing::debug!("Failed to parse frontmatter: {}", e);
}
return None;
}
};
Expand All @@ -169,7 +176,7 @@ fn parse_frontmatter<T: for<'de> Deserialize<'de>>(content: &str) -> Option<(T,
}

fn parse_skill_content(content: &str, path: PathBuf) -> Option<Source> {
let (metadata, body): (SkillMetadata, String) = parse_frontmatter(content)?;
let (metadata, body): (SkillMetadata, String) = parse_frontmatter(content, Some(&path))?;

if metadata.name.contains('/') {
warn!(
Expand All @@ -190,7 +197,7 @@ fn parse_skill_content(content: &str, path: PathBuf) -> Option<Source> {
}

fn parse_agent_content(content: &str, path: PathBuf) -> Option<Source> {
let (metadata, body): (AgentMetadata, String) = parse_frontmatter(content)?;
let (metadata, body): (AgentMetadata, String) = parse_frontmatter(content, Some(&path))?;

let description = metadata.description.unwrap_or_else(|| {
let model_info = metadata
Expand Down Expand Up @@ -1589,7 +1596,7 @@ impl SummonClient {
};

let (metadata, _): (AgentMetadata, String) =
parse_frontmatter(&agent_content).ok_or("Failed to parse agent frontmatter")?;
parse_frontmatter(&agent_content, None).ok_or("Failed to parse agent frontmatter")?;

let model = metadata.model;

Expand Down
134 changes: 131 additions & 3 deletions crates/goose/src/providers/utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -469,11 +469,88 @@ pub fn safely_parse_json(s: &str) -> Result<serde_json::Value, serde_json::Error
Err(_) => {
// If that fails, try with control character escaping
let escaped = json_escape_control_chars_in_string(s);
serde_json::from_str(&escaped)
match serde_json::from_str(&escaped) {
Ok(value) => Ok(value),
Err(original_err) => {
// As a last resort, attempt to repair truncated JSON
match repair_truncated_json(&escaped) {
Some(repaired) => serde_json::from_str(&repaired),
None => Err(original_err),
}
}
}
}
}
}

/// Attempt to repair JSON that was truncated mid-stream (e.g., unclosed strings,
/// missing closing braces/brackets). This handles the common case where an LLM's
/// streaming output is cut off before completing a tool call's JSON arguments.
///
/// Returns `None` if the input doesn't look like truncated JSON (e.g., it's
/// fundamentally malformed rather than just incomplete).
fn repair_truncated_json(s: &str) -> Option<String> {
let trimmed = s.trim();
if trimmed.is_empty() || !trimmed.starts_with('{') {
return None;
}

let mut repaired = String::from(trimmed);
let mut in_string = false;
let mut escape_next = false;
let mut stack: Vec<char> = Vec::new();

for c in trimmed.chars() {
if escape_next {
escape_next = false;
continue;
}
match c {
'\\' if in_string => {
escape_next = true;
}
'"' => {
in_string = !in_string;
}
'{' if !in_string => stack.push('}'),
'[' if !in_string => stack.push(']'),
'}' if !in_string => {
if stack.last() == Some(&'}') {
stack.pop();
}
}
']' if !in_string => {
if stack.last() == Some(&']') {
stack.pop();
}
}
_ => {}
}
}

// Nothing to repair — JSON structure is already balanced
if stack.is_empty() && !in_string {
return None;
}

// Close an unclosed string. If the last character was a backslash
// (escape_next is still true), the appended quote would be escaped
// and produce invalid JSON. Drop the dangling backslash first.
if in_string {
if escape_next {
repaired.pop(); // remove trailing backslash
}
repaired.push('"');
}

// Close all unclosed braces/brackets in reverse order
for closer in stack.iter().rev() {
repaired.push(*closer);
}

Some(repaired)
}

/// Helper to escape control characters in a string that is supposed to be a JSON document.
/// This function iterates through the input string `s` and replaces any literal
/// control characters (U+0000 to U+001F) with their JSON-escaped equivalents
Expand Down Expand Up @@ -765,9 +842,14 @@ mod tests {
let result = safely_parse_json(good_json).unwrap();
assert_eq!(result["test"], "value");

// Test completely invalid JSON that can't be fixed
// Test truncated JSON — repair should close the unclosed string and brace
let broken_json = r#"{"key": "unclosed_string"#;
assert!(safely_parse_json(broken_json).is_err());
let result = safely_parse_json(broken_json).unwrap();
assert_eq!(result["key"], "unclosed_string");

// Test fundamentally malformed JSON that can't be repaired
let garbage = "not json at all";
assert!(safely_parse_json(garbage).is_err());

// Test empty object
let empty_json = "{}";
Expand All @@ -780,6 +862,52 @@ mod tests {
assert_eq!(result["key"], "value with\nnewline");
}

#[test]
fn test_repair_truncated_json() {
// Unclosed string at end of object
let repaired = repair_truncated_json(r#"{"key": "unclosed"#).unwrap();
assert_eq!(repaired, r#"{"key": "unclosed"}"#);

// Missing closing brace
let repaired = repair_truncated_json(r#"{"key": "value""#).unwrap();
assert_eq!(repaired, r#"{"key": "value"}"#);

// Nested objects with missing closers
let repaired = repair_truncated_json(r#"{"a": {"b": "c""#).unwrap();
assert_eq!(repaired, r#"{"a": {"b": "c"}}"#);

// Truncated in array
let repaired = repair_truncated_json(r#"{"items": [1, 2"#).unwrap();
assert_eq!(repaired, r#"{"items": [1, 2]}"#);

// Dangling escape: truncation right after a backslash
// In JSON, backslash-n is a valid escape. Simulate truncation mid-escape.
let repaired = repair_truncated_json(r#"{"key": "value\n more\"#).unwrap();
// The trailing backslash is dropped, then the string is closed
let parsed: serde_json::Value = serde_json::from_str(&repaired).unwrap();
assert!(parsed["key"].as_str().unwrap().contains("value"));

// Dangling escape via safely_parse_json (full pipeline handles \U etc.)
let result = safely_parse_json(r#"{"path": "C:\\Users\\"#);
assert!(result.is_ok());

// Already valid JSON returns None (nothing to repair)
assert!(repair_truncated_json(r#"{"key": "value"}"#).is_none());

// Empty string returns None
assert!(repair_truncated_json("").is_none());

// Non-object returns None
assert!(repair_truncated_json("not json").is_none());

// Large truncated write payload (simulates the column 27409 case)
let long_content = "x".repeat(30000);
let truncated = format!(r#"{{"path": "/tmp/file", "content": "{}"#, long_content);
let repaired = repair_truncated_json(&truncated).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&repaired).unwrap();
assert_eq!(parsed["path"], "/tmp/file");
}

#[test]
fn test_json_escape_control_chars_in_string() {
// Test basic control character escaping
Expand Down
Loading