buildwithnexus 0.11.0 → 0.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/harness/Cargo.lock +1 -1
- package/harness/Cargo.toml +1 -1
- package/harness/src/agent.rs +123 -11
- package/package.json +1 -1
package/harness/Cargo.lock
CHANGED
package/harness/Cargo.toml
CHANGED
package/harness/src/agent.rs
CHANGED
|
@@ -721,22 +721,73 @@ fn parse_text_tool_calls(text: &str, defs: &[tools::ToolDef]) -> Option<Vec<prov
|
|
|
721
721
|
|
|
722
722
|
fn extract_json_tool_candidate(text: &str) -> Option<&str> {
|
|
723
723
|
let trimmed = text.trim();
|
|
724
|
+
// Whole-text JSON (the strict, original case).
|
|
724
725
|
if trimmed.starts_with('{') || trimmed.starts_with('[') {
|
|
725
726
|
return Some(trimmed);
|
|
726
727
|
}
|
|
727
|
-
|
|
728
|
-
let
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
728
|
+
// A fenced ```json block that is the entire message.
|
|
729
|
+
if let Some(rest) = trimmed.strip_prefix("```") {
|
|
730
|
+
if let Some(fence_end) = rest.find('\n') {
|
|
731
|
+
let lang = rest[..fence_end].trim().to_ascii_lowercase();
|
|
732
|
+
if lang.is_empty() || lang == "json" {
|
|
733
|
+
let body = &rest[fence_end + 1..];
|
|
734
|
+
if let Some(close) = body.rfind("```") {
|
|
735
|
+
if body[close + 3..].trim().is_empty() {
|
|
736
|
+
return Some(body[..close].trim());
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
}
|
|
732
741
|
}
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
742
|
+
// XML-tagged tool calls. Small local coders (notably Qwen2.5-Coder on
|
|
743
|
+
// llama.cpp/Ollama) emit calls as `<tools>{…}</tools>` or
|
|
744
|
+
// `<tool_call>{…}</tool_call>` text in `content` instead of native
|
|
745
|
+
// tool_calls. Pull the first balanced JSON object out of the tagged region.
|
|
746
|
+
for tag in ["<tools>", "<tool_call>", "<function_call>", "<function>"] {
|
|
747
|
+
if let Some(pos) = text.find(tag) {
|
|
748
|
+
if let Some(json) = balanced_json_object(&text[pos + tag.len()..]) {
|
|
749
|
+
return Some(json);
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
// A bare object embedded in prose (a leading sentence, then the JSON).
|
|
754
|
+
balanced_json_object(text)
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
// Return the first balanced `{…}` slice, tracking string state so braces inside
|
|
758
|
+
// JSON string values (e.g. CSS `{ }` inside an HTML `content` field) don't end
|
|
759
|
+
// the object early. None if there's no `{` or it never closes (truncated output).
|
|
760
|
+
fn balanced_json_object(text: &str) -> Option<&str> {
|
|
761
|
+
let bytes = text.as_bytes();
|
|
762
|
+
let start = text.find('{')?;
|
|
763
|
+
let mut depth = 0usize;
|
|
764
|
+
let mut in_string = false;
|
|
765
|
+
let mut escaped = false;
|
|
766
|
+
for i in start..bytes.len() {
|
|
767
|
+
let c = bytes[i];
|
|
768
|
+
if in_string {
|
|
769
|
+
if escaped {
|
|
770
|
+
escaped = false;
|
|
771
|
+
} else if c == b'\\' {
|
|
772
|
+
escaped = true;
|
|
773
|
+
} else if c == b'"' {
|
|
774
|
+
in_string = false;
|
|
775
|
+
}
|
|
776
|
+
continue;
|
|
777
|
+
}
|
|
778
|
+
match c {
|
|
779
|
+
b'"' => in_string = true,
|
|
780
|
+
b'{' => depth += 1,
|
|
781
|
+
b'}' => {
|
|
782
|
+
depth -= 1;
|
|
783
|
+
if depth == 0 {
|
|
784
|
+
return Some(&text[start..=i]);
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
_ => {}
|
|
788
|
+
}
|
|
738
789
|
}
|
|
739
|
-
|
|
790
|
+
None
|
|
740
791
|
}
|
|
741
792
|
|
|
742
793
|
fn collect_text_tool_calls(
|
|
@@ -3227,6 +3278,67 @@ mod tests {
|
|
|
3227
3278
|
assert_eq!(normalized.calls[0].input["command"], "npm start");
|
|
3228
3279
|
}
|
|
3229
3280
|
|
|
3281
|
+
#[test]
|
|
3282
|
+
fn qwen_tools_tagged_call_with_css_braces_is_parsed() {
|
|
3283
|
+
// The real failure from an end-to-end run against qwen2.5-coder-1.5b on
|
|
3284
|
+
// llama.cpp: the model emits its call as `<tools>{…}</tools>` text with
|
|
3285
|
+
// an HTML `content` field whose CSS contains `{ }`. The balanced-object
|
|
3286
|
+
// scanner must not stop at the first CSS brace.
|
|
3287
|
+
let defs = tools::defs_for_context(true, 128_000);
|
|
3288
|
+
let reply = Reply {
|
|
3289
|
+
text: "<tools>{\"name\":\"write_file\",\"arguments\":{\"path\":\"index.html\",\"content\":\"<style>body { color: #ff6faa; } .hero { padding: 2rem; }</style>\"}}</tools>".to_string(),
|
|
3290
|
+
..Default::default()
|
|
3291
|
+
};
|
|
3292
|
+
let normalized = normalize_text_tool_calls(reply, &defs, "build a donut shop site");
|
|
3293
|
+
assert_eq!(normalized.calls.len(), 1, "text: unparsed");
|
|
3294
|
+
assert_eq!(normalized.calls[0].name, "write_file");
|
|
3295
|
+
assert_eq!(normalized.calls[0].input["path"], "index.html");
|
|
3296
|
+
assert!(normalized.calls[0].input["content"]
|
|
3297
|
+
.as_str()
|
|
3298
|
+
.unwrap()
|
|
3299
|
+
.contains("#ff6faa"));
|
|
3300
|
+
}
|
|
3301
|
+
|
|
3302
|
+
#[test]
|
|
3303
|
+
fn tool_call_tagged_and_embedded_json_variants_parse() {
|
|
3304
|
+
let defs = tools::defs_for_context(true, 128_000);
|
|
3305
|
+
// Hermes/Qwen `<tool_call>` wrapper.
|
|
3306
|
+
let a = normalize_text_tool_calls(
|
|
3307
|
+
Reply {
|
|
3308
|
+
text: "<tool_call>{\"name\":\"read_file\",\"arguments\":{\"path\":\"a.txt\"}}</tool_call>"
|
|
3309
|
+
.to_string(),
|
|
3310
|
+
..Default::default()
|
|
3311
|
+
},
|
|
3312
|
+
&defs,
|
|
3313
|
+
"read it",
|
|
3314
|
+
);
|
|
3315
|
+
assert_eq!(a.calls.len(), 1);
|
|
3316
|
+
assert_eq!(a.calls[0].name, "read_file");
|
|
3317
|
+
// A leading sentence, then a bare JSON object.
|
|
3318
|
+
let b = normalize_text_tool_calls(
|
|
3319
|
+
Reply {
|
|
3320
|
+
text: "Sure, I'll do that now. {\"name\":\"read_file\",\"arguments\":{\"path\":\"b.txt\"}}"
|
|
3321
|
+
.to_string(),
|
|
3322
|
+
..Default::default()
|
|
3323
|
+
},
|
|
3324
|
+
&defs,
|
|
3325
|
+
"read it",
|
|
3326
|
+
);
|
|
3327
|
+
assert_eq!(b.calls.len(), 1);
|
|
3328
|
+
assert_eq!(b.calls[0].input["path"], "b.txt");
|
|
3329
|
+
}
|
|
3330
|
+
|
|
3331
|
+
#[test]
|
|
3332
|
+
fn balanced_json_object_respects_strings_and_truncation() {
|
|
3333
|
+
assert_eq!(
|
|
3334
|
+
balanced_json_object("x {\"a\":\"}{\"} y"),
|
|
3335
|
+
Some("{\"a\":\"}{\"}")
|
|
3336
|
+
);
|
|
3337
|
+
// Unterminated object (truncated at the token limit) yields nothing.
|
|
3338
|
+
assert_eq!(balanced_json_object("{\"a\": {\"b\": 1"), None);
|
|
3339
|
+
assert_eq!(balanced_json_object("no braces here"), None);
|
|
3340
|
+
}
|
|
3341
|
+
|
|
3230
3342
|
#[test]
|
|
3231
3343
|
fn casual_text_json_mutating_tool_call_is_ignored() {
|
|
3232
3344
|
let defs = tools::defs_for_context(true, 128_000);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "buildwithnexus",
|
|
3
|
-
"version": "0.11.
|
|
3
|
+
"version": "0.11.1",
|
|
4
4
|
"description": "A hilariously fast, agentic AI CLI harness in Rust — remote (API key) or local models, full-screen TUI with sessions, plan/build/brainstorm modes, and hooks",
|
|
5
5
|
"bin": {
|
|
6
6
|
"buildwithnexus": "bin/buildwithnexus.js",
|