Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
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
2 changes: 1 addition & 1 deletion changelog.d/222.fixed.md
Original file line number Diff line number Diff line change
@@ -1 +1 @@
Improve built-in tool schemas to reduce model-side parse failures.
Improve built-in tool schemas to reduce model-side parse failures.
8 changes: 6 additions & 2 deletions n00n-agent/src/tools/grep.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,11 +73,15 @@ pub fn grep_search(params: &GrepParams) -> Result<(PathBuf, Vec<GrepFileEntry>),
);

let matcher = if is_multiline {
RegexMatcher::new(&params.pattern).map_err(|e| format!("{INVALID_REGEX}: {e}"))?
RegexMatcher::new(&params.pattern).map_err(|e| {
format!("{INVALID_REGEX}: {e}. Note: PCRE look-around (e.g. (?!...), (?<!...)) is not supported. Use Rust regex syntax.")
})?
} else {
RegexMatcher::new_line_matcher(&params.pattern)
.or_else(|_| RegexMatcher::new(&params.pattern))
.map_err(|e| format!("{INVALID_REGEX}: {e}"))?
.map_err(|e| {
format!("{INVALID_REGEX}: {e}. Note: PCRE look-around (e.g. (?!...), (?<!...)) is not supported. Use Rust regex syntax.")
})?
};

let patterns: Vec<&str> = params.include.as_deref().into_iter().collect();
Expand Down
17 changes: 14 additions & 3 deletions n00n-docgen/src/gen_tools.rs
Original file line number Diff line number Diff line change
Expand Up @@ -94,14 +94,25 @@ fn first_paragraph(desc: &str) -> &str {
desc.split("\n\n").next().unwrap_or(desc)
}

fn format_schema_type(schema: &Value, ty: &str) -> String {
if ty != "array" {
return ty.to_string();
}
let item_type = schema
.get("items")
.map_or_else(|| "value".to_string(), schema_type);
format!("array of {item_type}s")
}

fn schema_type(schema: &Value) -> String {
match schema.get("type") {
Some(Value::String(ty)) => ty.clone(),
Some(Value::String(ty)) => format_schema_type(schema, ty),
Some(Value::Array(types)) => types
.iter()
.filter_map(Value::as_str)
.map(|ty| format_schema_type(schema, ty))
.collect::<Vec<_>>()
.join("/"),
.join(" or "),
_ => schema
.get("anyOf")
.and_then(Value::as_array)
Expand All @@ -110,7 +121,7 @@ fn schema_type(schema: &Value) -> String {
.iter()
.map(schema_type)
.collect::<Vec<_>>()
.join("/")
.join(" or ")
})
.filter(|types| !types.is_empty())
.unwrap_or_else(|| "string".to_string()),
Expand Down
16 changes: 15 additions & 1 deletion n00n-lua/src/api/fs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1006,7 +1006,21 @@ async fn grep(lua: Lua, pattern: String, opts: Option<Table>) -> LuaResult<(Valu
let arr = lua.create_table()?;
for (i, entry) in entries.iter().enumerate() {
let etbl = lua.create_table()?;
etbl.set("path", base.join(&entry.path).to_string_lossy().as_ref())?;
let path = base.join(&entry.path);
etbl.set("path", path.to_string_lossy().as_ref())?;
let modified = match n00n_agent::tools::mtime(&path).duration_since(UNIX_EPOCH) {
Ok(duration) => duration.as_secs(),
Err(error) => {
return err_pair(
&lua,
format!(
"grep: invalid modification time for {}: {error}",
path.display()
),
);
}
};
etbl.set("mtime", modified)?;
let groups_tbl = lua.create_table()?;
for (gi, group) in entry.groups.iter().enumerate() {
let gtbl = lua.create_table()?;
Expand Down
142 changes: 142 additions & 0 deletions n00n-lua/tests/real_plugins_restore.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1209,3 +1209,145 @@ fn fusion_schema_and_launch_keep_sidekick_inputs_trusted() {
assert!(FUSION_SRC.contains("include_mcp = false"));
assert!(FUSION_SRC.contains("except_tools"));
}

#[test]
fn grep_applies_one_limit_and_deduplicates_overlapping_roots() {
let output = execute_plugin_with_native_mock(
"grep",
GREP_SRC,
r#"
n00n.fs.grep = function(_, opts)
local shared = {
path = "project/shared.rs",
mtime = 10,
groups = {
{ lines = {{ line_nr = 1, text = "shared-one", is_match = true }} },
{ lines = {{ line_nr = 2, text = "shared-two", is_match = true }} },
},
}
if opts.path == "project" then
return { shared }, nil
end
return {
shared,
{
path = "project/new.rs",
mtime = 20,
groups = {{ lines = {{ line_nr = 3, text = "unique", is_match = true }} }},
},
}, nil
end
"#,
json!({ "pattern": "hit", "path": ["project", "project/nested"], "limit": 2 }),
)
.unwrap();

let output = output.replace('\\', "/");
assert_eq!(output.matches("project/shared.rs:").count(), 1, "{output}");
assert!(output.contains("unique"), "{output}");
assert!(output.contains("shared-one"), "{output}");
assert!(!output.contains("shared-two"), "{output}");
let new_position = output.find("project/new.rs:").unwrap();
let shared_position = output.find("project/shared.rs:").unwrap();
assert!(new_position < shared_position, "{output}");
}
Comment thread
w0wl0lxd marked this conversation as resolved.

#[test]
fn grep_reports_partial_path_failures() {
let output = execute_plugin_with_native_mock(
"grep",
GREP_SRC,
r#"
n00n.fs.grep = function(_, opts)
if opts.path == "missing" then
return nil, "path not found"
end
return {{
path = "valid.rs",
mtime = 1,
groups = {{ lines = {{ line_nr = 1, text = "found", is_match = true }} }},
}}, nil
end
"#,
json!({ "pattern": "found", "path": ["valid", "missing"] }),
)
.unwrap();

assert!(output.contains("found"), "{output}");
assert!(
output.contains("Warning: some paths could not be searched: missing: path not found"),
"{output}"
);
}

#[test]
fn grep_reports_partial_path_failures_when_successful_path_is_empty() {
let output = execute_plugin_with_native_mock(
"grep",
GREP_SRC,
r#"
n00n.fs.grep = function(_, opts)
if opts.path == "missing" then
return nil, "path not found"
end
return {}, nil
end
"#,
json!({ "pattern": "found", "path": ["empty", "missing"] }),
)
.unwrap();

assert!(output.contains("No files found"), "{output}");
assert!(
output.contains("Warning: some paths could not be searched: missing: path not found"),
"{output}"
);
}

#[test]
fn grep_searches_default_root_when_path_is_omitted() {
let output = execute_plugin_with_native_mock(
"grep",
GREP_SRC,
r#"
n00n.fs.grep = function(_, opts)
if opts.path ~= nil then
return nil, "expected nil path"
end
return {{
path = "default.rs",
mtime = 1,
groups = {{ lines = {{ line_nr = 1, text = "default-hit", is_match = true }} }},
}}, nil
end
"#,
json!({ "pattern": "default" }),
)
.unwrap();

assert!(output.contains("default-hit"), "{output}");
}

#[test]
fn grep_header_accepts_multiple_paths() {
let host = load_host();
let restored = restore(
&host,
"grep",
json!({ "pattern": "fn", "path": ["src", "tests"] }),
"src/main.rs:\n 1: fn main",
None,
vec![],
);

assert!(
restored.header.contains("src"),
"header: {}",
restored.header
);
assert!(
restored.header.contains("tests"),
"header: {}",
restored.header
);
}
Loading
Loading