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.
@@ -49,7 +49,7 @@ checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
49
49
 
50
50
  [[package]]
51
51
  name = "buildwithnexus"
52
- version = "0.11.0"
52
+ version = "0.11.1"
53
53
  dependencies = [
54
54
  "criterion",
55
55
  "crossterm",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "buildwithnexus"
3
- version = "0.11.0"
3
+ version = "0.11.1"
4
4
  edition = "2021"
5
5
  description = "A hilariously fast agentic AI CLI harness — remote or local models, full TUI with modes, memory, skills, hooks, and image input"
6
6
  license = "MIT"
@@ -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
- let rest = trimmed.strip_prefix("```")?;
728
- let fence_end = rest.find('\n')?;
729
- let lang = rest[..fence_end].trim().to_ascii_lowercase();
730
- if !lang.is_empty() && lang != "json" {
731
- return None;
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
- let body = &rest[fence_end + 1..];
734
- let close = body.rfind("```")?;
735
- let after = body[close + 3..].trim();
736
- if !after.is_empty() {
737
- return None;
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
- Some(body[..close].trim())
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.0",
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",