buildwithnexus 0.11.2 → 0.11.4

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.2"
52
+ version = "0.11.4"
53
53
  dependencies = [
54
54
  "criterion",
55
55
  "crossterm",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "buildwithnexus"
3
- version = "0.11.2"
3
+ version = "0.11.4"
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"
@@ -711,12 +711,288 @@ fn is_casual_turn(text: &str) -> bool {
711
711
  }
712
712
 
713
713
  fn parse_text_tool_calls(text: &str, defs: &[tools::ToolDef]) -> Option<Vec<provider::ToolCall>> {
714
- let candidate = extract_json_tool_candidate(text)?;
715
- let value = serde_json::from_str::<serde_json::Value>(candidate).ok()?;
716
714
  let names = defs.iter().map(|d| d.name).collect::<HashSet<_>>();
717
- let mut calls = Vec::new();
718
- collect_text_tool_calls(&value, &names, &mut calls);
719
- (!calls.is_empty()).then_some(calls)
715
+ if let Some(candidate) = extract_json_tool_candidate(text) {
716
+ if let Ok(value) = serde_json::from_str::<serde_json::Value>(candidate) {
717
+ let mut calls = Vec::new();
718
+ collect_text_tool_calls(&value, &names, &mut calls);
719
+ if !calls.is_empty() {
720
+ return Some(calls);
721
+ }
722
+ }
723
+ }
724
+ // Fall back to Gemma's `tool_code` Python-call format.
725
+ parse_tool_code_call(text, &names)
726
+ }
727
+
728
+ // Positional argument names for the tools small models most often call, so a
729
+ // Gemma call like `write_file("/p", """…""")` maps arg 0→path, arg 1→content.
730
+ fn positional_params(name: &str) -> &'static [&'static str] {
731
+ match name {
732
+ "write_file" | "write" => &["path", "content"],
733
+ "read_file" | "read" => &["path"],
734
+ "edit_file" | "edit" => &["path", "old", "new"],
735
+ "run_command" | "bash" => &["command"],
736
+ "Artifact" | "publish_artifact" => &["contents"],
737
+ "list_dir" | "list" => &["path"],
738
+ "find_files" | "glob" => &["pattern"],
739
+ "grep_files" | "grep" => &["pattern"],
740
+ "finish" => &["summary"],
741
+ "python_tool" => &["code"],
742
+ "create_docx" => &["path", "title", "body"],
743
+ _ => &[],
744
+ }
745
+ }
746
+
747
+ // Gemma emits tool calls as a ```tool_code fenced Python function call, e.g.
748
+ // write_file("/p/index.html", """<!doctype html>…""")
749
+ // Artifact(contents="""…""")
750
+ // sometimes wrapped in `print(...)`. Native tool_calls / JSON never match, so
751
+ // the call is otherwise treated as prose. Parse the first known-tool call.
752
+ fn parse_tool_code_call(
753
+ text: &str,
754
+ allowed: &HashSet<&'static str>,
755
+ ) -> Option<Vec<provider::ToolCall>> {
756
+ let scope = tool_code_scope(text);
757
+ let (name, after) = find_tool_call(scope, allowed)?;
758
+ let inside = balanced_parens(after)?;
759
+ let args = parse_python_args(inside);
760
+ let input = tool_code_args_to_input(name, args);
761
+ Some(vec![text_tool_call(name, input)])
762
+ }
763
+
764
+ // The region to scan: inside a ```tool_code fence when present, else the whole
765
+ // text (Gemma sometimes omits the fence).
766
+ fn tool_code_scope(text: &str) -> &str {
767
+ if let Some(pos) = text.find("```tool_code") {
768
+ let after = &text[pos + "```tool_code".len()..];
769
+ let end = after.find("```").unwrap_or(after.len());
770
+ &after[..end]
771
+ } else {
772
+ text
773
+ }
774
+ }
775
+
776
+ // Find the earliest occurrence of a known tool name immediately followed by
777
+ // `(` (skipping any `print(` wrapper, which isn't a known tool). Returns the
778
+ // name and the slice just after the opening paren.
779
+ fn find_tool_call<'a>(
780
+ scope: &'a str,
781
+ allowed: &HashSet<&'static str>,
782
+ ) -> Option<(&'static str, &'a str)> {
783
+ let bytes = scope.as_bytes();
784
+ let mut best: Option<(usize, &'static str, usize)> = None;
785
+ for &name in allowed.iter() {
786
+ let mut from = 0;
787
+ while let Some(rel) = scope[from..].find(name) {
788
+ let at = from + rel;
789
+ // Must be a whole identifier (not a substring of a longer name).
790
+ let before_ok = at == 0 || !is_ident_byte(bytes[at - 1]);
791
+ let after_idx = at + name.len();
792
+ let mut j = after_idx;
793
+ while j < bytes.len() && bytes[j] == b' ' {
794
+ j += 1;
795
+ }
796
+ if before_ok && j < bytes.len() && bytes[j] == b'(' {
797
+ if best.map(|(p, _, _)| at < p).unwrap_or(true) {
798
+ best = Some((at, name, j + 1));
799
+ }
800
+ break;
801
+ }
802
+ from = at + name.len();
803
+ }
804
+ }
805
+ best.map(|(_, name, paren)| (name, &scope[paren..]))
806
+ }
807
+
808
+ fn is_ident_byte(b: u8) -> bool {
809
+ b.is_ascii_alphanumeric() || b == b'_'
810
+ }
811
+
812
+ // Given the slice just after an opening `(`, return the argument text up to the
813
+ // matching `)`, tracking string literals (triple/single/double) and nesting.
814
+ fn balanced_parens(s: &str) -> Option<&str> {
815
+ let bytes = s.as_bytes();
816
+ let mut depth = 1i32;
817
+ let mut i = 0;
818
+ while i < bytes.len() {
819
+ // Skip string literals whole so commas/parens inside don't count.
820
+ if let Some(consumed) = string_literal_len(&s[i..]) {
821
+ i += consumed;
822
+ continue;
823
+ }
824
+ match bytes[i] {
825
+ b'(' | b'[' | b'{' => depth += 1,
826
+ b')' | b']' | b'}' => {
827
+ depth -= 1;
828
+ if depth == 0 {
829
+ return Some(&s[..i]);
830
+ }
831
+ }
832
+ _ => {}
833
+ }
834
+ i += 1;
835
+ }
836
+ None
837
+ }
838
+
839
+ // If `s` starts with a Python string literal, return its byte length (including
840
+ // delimiters). Handles triple `"""`/`'''` (raw) and single `"`/`'` (with `\`
841
+ // escapes). None if `s` doesn't start with a quote.
842
+ fn string_literal_len(s: &str) -> Option<usize> {
843
+ let b = s.as_bytes();
844
+ for q in ["\"\"\"", "'''"] {
845
+ if let Some(rest) = s.strip_prefix(q) {
846
+ let close = rest.find(q)?;
847
+ return Some(q.len() + close + q.len());
848
+ }
849
+ }
850
+ let quote = *b.first()?;
851
+ if quote == b'"' || quote == b'\'' {
852
+ let mut i = 1;
853
+ while i < b.len() {
854
+ if b[i] == b'\\' {
855
+ i += 2;
856
+ continue;
857
+ }
858
+ if b[i] == quote {
859
+ return Some(i + 1);
860
+ }
861
+ i += 1;
862
+ }
863
+ }
864
+ None
865
+ }
866
+
867
+ // Split a Python argument list at top-level commas, then classify each as
868
+ // keyword (`name=value`) or positional, with the value parsed to a JSON value.
869
+ fn parse_python_args(inside: &str) -> Vec<(Option<String>, serde_json::Value)> {
870
+ let bytes = inside.as_bytes();
871
+ let mut parts: Vec<&str> = Vec::new();
872
+ let (mut start, mut i) = (0usize, 0usize);
873
+ while i < bytes.len() {
874
+ if let Some(consumed) = string_literal_len(&inside[i..]) {
875
+ i += consumed;
876
+ continue;
877
+ }
878
+ match bytes[i] {
879
+ b'(' | b'[' | b'{' => {
880
+ if let Some(inner) = balanced_parens(&inside[i + 1..]) {
881
+ i += 1 + inner.len();
882
+ }
883
+ }
884
+ b',' => {
885
+ parts.push(&inside[start..i]);
886
+ start = i + 1;
887
+ }
888
+ _ => {}
889
+ }
890
+ i += 1;
891
+ }
892
+ if start < inside.len() {
893
+ parts.push(&inside[start..]);
894
+ }
895
+ parts
896
+ .into_iter()
897
+ .filter_map(|p| {
898
+ let p = p.trim();
899
+ if p.is_empty() {
900
+ return None;
901
+ }
902
+ // Keyword only if the `=` precedes any string literal.
903
+ if let Some(eq) = kw_eq_pos(p) {
904
+ let key = p[..eq].trim();
905
+ if !key.is_empty() && key.chars().all(|c| c.is_alphanumeric() || c == '_') {
906
+ return Some((Some(key.to_string()), parse_py_value(p[eq + 1..].trim())));
907
+ }
908
+ }
909
+ Some((None, parse_py_value(p)))
910
+ })
911
+ .collect()
912
+ }
913
+
914
+ // Position of a top-level `=` (keyword separator) before any string literal,
915
+ // or None. `==`/`!=`/`>=`/`<=` are not keyword separators.
916
+ fn kw_eq_pos(p: &str) -> Option<usize> {
917
+ let b = p.as_bytes();
918
+ let mut i = 0;
919
+ while i < b.len() {
920
+ if string_literal_len(&p[i..]).is_some() {
921
+ return None; // hit a string before any '='
922
+ }
923
+ if b[i] == b'=' {
924
+ let prev = if i > 0 { b[i - 1] } else { b' ' };
925
+ let next = b.get(i + 1).copied().unwrap_or(b' ');
926
+ if prev != b'!' && prev != b'<' && prev != b'>' && next != b'=' {
927
+ return Some(i);
928
+ }
929
+ }
930
+ i += 1;
931
+ }
932
+ None
933
+ }
934
+
935
+ // Parse a Python literal value into JSON: triple/single/double-quoted strings,
936
+ // booleans, and numbers; anything else becomes a raw string.
937
+ fn parse_py_value(expr: &str) -> serde_json::Value {
938
+ let e = expr.trim();
939
+ for q in ["\"\"\"", "'''"] {
940
+ if let Some(rest) = e.strip_prefix(q) {
941
+ if let Some(end) = rest.rfind(q) {
942
+ return serde_json::Value::String(rest[..end].to_string());
943
+ }
944
+ }
945
+ }
946
+ if (e.starts_with('"') && e.ends_with('"') && e.len() >= 2)
947
+ || (e.starts_with('\'') && e.ends_with('\'') && e.len() >= 2)
948
+ {
949
+ let inner = &e[1..e.len() - 1];
950
+ // Unescape the common sequences for single-quoted strings.
951
+ let unescaped = inner
952
+ .replace("\\n", "\n")
953
+ .replace("\\t", "\t")
954
+ .replace("\\\"", "\"")
955
+ .replace("\\'", "'")
956
+ .replace("\\\\", "\\");
957
+ return serde_json::Value::String(unescaped);
958
+ }
959
+ match e {
960
+ "True" | "true" => return serde_json::Value::Bool(true),
961
+ "False" | "false" => return serde_json::Value::Bool(false),
962
+ _ => {}
963
+ }
964
+ if let Ok(n) = e.parse::<i64>() {
965
+ return serde_json::Value::from(n);
966
+ }
967
+ if let Ok(f) = e.parse::<f64>() {
968
+ return serde_json::Value::from(f);
969
+ }
970
+ serde_json::Value::String(e.to_string())
971
+ }
972
+
973
+ // Build the tool input object: keyword args by name, positional args mapped
974
+ // through the tool's positional signature.
975
+ fn tool_code_args_to_input(
976
+ name: &str,
977
+ args: Vec<(Option<String>, serde_json::Value)>,
978
+ ) -> serde_json::Value {
979
+ let mut map = serde_json::Map::new();
980
+ let params = positional_params(name);
981
+ let mut pos = 0;
982
+ for (key, val) in args {
983
+ match key {
984
+ Some(k) => {
985
+ map.insert(k, val);
986
+ }
987
+ None => {
988
+ if let Some(pname) = params.get(pos) {
989
+ map.insert((*pname).to_string(), val);
990
+ }
991
+ pos += 1;
992
+ }
993
+ }
994
+ }
995
+ serde_json::Value::Object(map)
720
996
  }
721
997
 
722
998
  fn extract_json_tool_candidate(text: &str) -> Option<&str> {
@@ -866,6 +1142,17 @@ fn text_tool_call(name: &str, input: serde_json::Value) -> provider::ToolCall {
866
1142
  }
867
1143
  }
868
1144
 
1145
+ // The single-line composer prompt for reading a question answer. Kept on one
1146
+ // line (no `\n`) so the alt-screen composer positions the cursor correctly and
1147
+ // echoes typed input; the question text itself is printed separately above.
1148
+ fn answer_input_prompt(default: &str) -> String {
1149
+ if default.is_empty() {
1150
+ " Answer: ".to_string()
1151
+ } else {
1152
+ format!(" Answer {}: ", tui::dim(&format!("[{default}]")))
1153
+ }
1154
+ }
1155
+
869
1156
  fn answer_question(input: &serde_json::Value) -> (String, bool) {
870
1157
  let question = if let Some(qs) = input["questions"].as_array() {
871
1158
  let mut acc = Vec::new();
@@ -915,21 +1202,16 @@ fn answer_question(input: &serde_json::Value) -> (String, bool) {
915
1202
  );
916
1203
  }
917
1204
  let default = input["default"].as_str().unwrap_or("").trim();
918
- let prompt = if default.is_empty() {
919
- format!(
920
- " {} {}\n Answer: ",
921
- tui::yellow("?"),
922
- tui::bold(&full_prompt)
923
- )
924
- } else {
925
- format!(
926
- " {} {}\n Answer {} ",
927
- tui::yellow("?"),
928
- tui::bold(&full_prompt),
929
- tui::dim(&format!("[{default}]"))
930
- )
931
- };
932
- let ans = tui::ask(&prompt).unwrap_or_default();
1205
+ // Render the question on its own transcript line, then read the answer with
1206
+ // a SINGLE-LINE composer prompt. A multi-line prompt string mis-positions
1207
+ // the alt-screen composer cursor (prompt_width counts across the newline),
1208
+ // which hides what the user types.
1209
+ tui::line(&format!(
1210
+ " {} {}",
1211
+ tui::yellow("?"),
1212
+ tui::bold(&full_prompt)
1213
+ ));
1214
+ let ans = tui::ask(&answer_input_prompt(default)).unwrap_or_default();
933
1215
  let out = if ans.trim().is_empty() && !default.is_empty() {
934
1216
  default.to_string()
935
1217
  } else {
@@ -3244,6 +3526,18 @@ mod tests {
3244
3526
  use super::*;
3245
3527
  use serde_json::json;
3246
3528
 
3529
+ #[test]
3530
+ fn answer_input_prompt_is_single_line() {
3531
+ // Must never contain a newline — a multi-line prompt breaks the
3532
+ // alt-screen composer's cursor positioning and hides typed input.
3533
+ let p = answer_input_prompt("");
3534
+ assert!(!p.contains('\n'));
3535
+ assert!(p.contains("Answer"));
3536
+ let d = answer_input_prompt("yes");
3537
+ assert!(!d.contains('\n'));
3538
+ assert!(d.contains("yes"));
3539
+ }
3540
+
3247
3541
  #[test]
3248
3542
  fn permission_parsing() {
3249
3543
  assert!(matches!(permission("auto"), Permission::Auto));
@@ -3328,6 +3622,65 @@ mod tests {
3328
3622
  assert_eq!(b.calls[0].input["path"], "b.txt");
3329
3623
  }
3330
3624
 
3625
+ #[test]
3626
+ fn gemma_tool_code_write_file_positional_triple_quoted() {
3627
+ // The exact shape captured from gemma-2-2b-it: a ```tool_code fence with
3628
+ // a Python call, positional args, and triple-quoted HTML containing
3629
+ // commas, parens, and braces.
3630
+ let defs = tools::defs_for_context(true, 8192);
3631
+ let text = "```tool_code\nwrite_file(\"/p/index.html\", \"\"\"<!doctype html>\\n<canvas id=\"c\"></canvas>\\n<script>const a={x:1}; f(a,b);</script>\"\"\")\n```";
3632
+ let r = normalize_text_tool_calls(
3633
+ Reply {
3634
+ text: text.to_string(),
3635
+ ..Default::default()
3636
+ },
3637
+ &defs,
3638
+ "build a canvas game",
3639
+ );
3640
+ assert_eq!(r.calls.len(), 1, "text: {}", r.text);
3641
+ assert_eq!(r.calls[0].name, "write_file");
3642
+ assert_eq!(r.calls[0].input["path"], "/p/index.html");
3643
+ let content = r.calls[0].input["content"].as_str().unwrap();
3644
+ assert!(content.contains("<canvas"), "{content}");
3645
+ assert!(content.contains("f(a,b)"), "{content}");
3646
+ }
3647
+
3648
+ #[test]
3649
+ fn gemma_tool_code_keyword_and_print_wrapper() {
3650
+ let defs = tools::defs_for_context(true, 8192);
3651
+ // Keyword arg + a print(...) wrapper Gemma sometimes adds.
3652
+ let text =
3653
+ "```tool_code\nprint(Artifact(title=\"Game\", contents=\"\"\"<html>ok</html>\"\"\"))\n```";
3654
+ let r = normalize_text_tool_calls(
3655
+ Reply {
3656
+ text: text.to_string(),
3657
+ ..Default::default()
3658
+ },
3659
+ &defs,
3660
+ "build it",
3661
+ );
3662
+ assert_eq!(r.calls.len(), 1);
3663
+ assert_eq!(r.calls[0].name, "Artifact");
3664
+ assert_eq!(r.calls[0].input["title"], "Game");
3665
+ assert_eq!(r.calls[0].input["contents"], "<html>ok</html>");
3666
+ }
3667
+
3668
+ #[test]
3669
+ fn python_value_and_arg_parsing_helpers() {
3670
+ assert_eq!(parse_py_value("\"\"\"hi, there\"\"\""), json!("hi, there"));
3671
+ assert_eq!(parse_py_value("'x'"), json!("x"));
3672
+ assert_eq!(parse_py_value("42"), json!(42));
3673
+ assert_eq!(parse_py_value("true"), json!(true));
3674
+ // A single top-level comma inside a triple-quoted string is not a split.
3675
+ let args = parse_python_args("\"a\", \"\"\"b, c\"\"\"");
3676
+ assert_eq!(args.len(), 2);
3677
+ assert_eq!(args[0].1, json!("a"));
3678
+ assert_eq!(args[1].1, json!("b, c"));
3679
+ // kw_eq only fires before a string literal, not on `==` in content.
3680
+ assert!(kw_eq_pos("contents=\"x\"").is_some());
3681
+ assert!(kw_eq_pos("\"a == b\"").is_none());
3682
+ }
3683
+
3331
3684
  #[test]
3332
3685
  fn balanced_json_object_respects_strings_and_truncation() {
3333
3686
  assert_eq!(
@@ -195,6 +195,134 @@ pub fn stream(
195
195
  request(p, msgs, tools, true, on_text, on_thinking)
196
196
  }
197
197
 
198
+ // A restrictive chat template (notably Gemma's on llama.cpp) rejected the
199
+ // standard message roles: it can't render the `system`/`tool` roles or requires
200
+ // strictly alternating user/assistant turns. Detected from the server's 400 so
201
+ // the retry only fires for models that need it (qwen's template accepts them).
202
+ fn is_template_role_error(e: &str) -> bool {
203
+ let l = e.to_lowercase();
204
+ l.contains("roles must alternate")
205
+ || l.contains("conversation roles")
206
+ || l.contains("unable to generate parser")
207
+ || l.contains("does not support")
208
+ || (l.contains("template") && l.contains("role"))
209
+ }
210
+
211
+ // Flatten a message's content (string or multimodal parts) to plain text.
212
+ fn message_text(content: &Value) -> String {
213
+ if let Some(s) = content.as_str() {
214
+ return s.to_string();
215
+ }
216
+ if let Some(arr) = content.as_array() {
217
+ return arr
218
+ .iter()
219
+ .filter_map(|p| p["text"].as_str())
220
+ .collect::<Vec<_>>()
221
+ .join(" ");
222
+ }
223
+ String::new()
224
+ }
225
+
226
+ // Rewrite the request body's messages into the shape restrictive templates
227
+ // accept: strictly alternating user/assistant with no `system`/`tool` roles.
228
+ // System and tool-result content fold into user turns, an assistant turn's
229
+ // tool_calls render as text, consecutive same-role turns merge, and the native
230
+ // `tools` field is dropped — the model then emits tool_code/text calls, which
231
+ // the agent's recovery parser handles.
232
+ fn flatten_openai_messages(body: &mut Value) {
233
+ let Some(msgs) = body["messages"].as_array() else {
234
+ return;
235
+ };
236
+ let mut folded: Vec<(String, String)> = Vec::new();
237
+ for m in msgs {
238
+ let role = m["role"].as_str().unwrap_or("user");
239
+ let text = message_text(&m["content"]);
240
+ match role {
241
+ // Keep only the assistant's own prose. A prior tool_call is NOT
242
+ // rendered as text — a small model will imitate whatever call-shaped
243
+ // marker it sees and stop making real calls. The following "Tool
244
+ // result:" user turn already conveys what happened.
245
+ "assistant" => {
246
+ if !text.is_empty() {
247
+ folded.push(("assistant".into(), text));
248
+ }
249
+ }
250
+ "tool" => {
251
+ if !text.is_empty() {
252
+ folded.push(("user".into(), format!("Tool result:\n{text}")));
253
+ }
254
+ }
255
+ _ => folded.push(("user".into(), text)),
256
+ }
257
+ }
258
+ // Merge consecutive same-role turns so roles strictly alternate.
259
+ let mut merged: Vec<(String, String)> = Vec::new();
260
+ for (role, text) in folded {
261
+ if let Some(last) = merged.last_mut() {
262
+ if last.0 == role {
263
+ if !text.is_empty() {
264
+ if !last.1.is_empty() {
265
+ last.1.push_str("\n\n");
266
+ }
267
+ last.1.push_str(&text);
268
+ }
269
+ continue;
270
+ }
271
+ }
272
+ merged.push((role, text));
273
+ }
274
+ // These templates require the first turn to be `user`.
275
+ if merged.first().is_some_and(|(r, _)| r == "assistant") {
276
+ merged.insert(0, ("user".into(), String::new()));
277
+ }
278
+ body["messages"] = json!(merged
279
+ .iter()
280
+ .map(|(r, t)| json!({"role": r, "content": t}))
281
+ .collect::<Vec<_>>());
282
+ if let Some(obj) = body.as_object_mut() {
283
+ obj.remove("tools");
284
+ }
285
+ }
286
+
287
+ // Send an OpenAI-compatible request; on a template/role rejection, retry once
288
+ // with a flattened, strictly-alternating message body.
289
+ fn openai_exchange(
290
+ req: ureq::Request,
291
+ mut body: Value,
292
+ streaming: bool,
293
+ on_text: &mut dyn FnMut(&str),
294
+ on_thinking: &mut dyn FnMut(&str),
295
+ ) -> Result<Reply, String> {
296
+ if streaming {
297
+ body["stream"] = json!(true);
298
+ }
299
+ match send_raw(req.clone(), body.clone()) {
300
+ Ok(resp) => finish_openai(resp, streaming, on_text, on_thinking),
301
+ Err(e) if is_template_role_error(&e) => {
302
+ flatten_openai_messages(&mut body);
303
+ let resp = send_raw(req, body)?;
304
+ finish_openai(resp, streaming, on_text, on_thinking)
305
+ }
306
+ Err(e) => Err(e),
307
+ }
308
+ }
309
+
310
+ fn finish_openai(
311
+ resp: ureq::Response,
312
+ streaming: bool,
313
+ on_text: &mut dyn FnMut(&str),
314
+ on_thinking: &mut dyn FnMut(&str),
315
+ ) -> Result<Reply, String> {
316
+ if streaming {
317
+ openai_stream(resp.into_reader(), on_text, on_thinking)
318
+ } else {
319
+ let v = resp
320
+ .into_json::<Value>()
321
+ .map_err(|e| format!("bad JSON from server: {e}"))?;
322
+ openai_parse(v)
323
+ }
324
+ }
325
+
198
326
  fn request(
199
327
  p: &Provider,
200
328
  msgs: &[Msg],
@@ -214,13 +342,8 @@ fn request(
214
342
  }
215
343
  }
216
344
  Protocol::OpenAi => {
217
- let (req, mut body) = openai_request(p, msgs, tools);
218
- if streaming {
219
- body["stream"] = json!(true);
220
- openai_stream(send_raw(req, body)?.into_reader(), on_text, on_thinking)
221
- } else {
222
- openai_parse(send(req, body)?)
223
- }
345
+ let (req, body) = openai_request(p, msgs, tools);
346
+ openai_exchange(req, body, streaming, on_text, on_thinking)
224
347
  }
225
348
  Protocol::OllamaNative => {
226
349
  // The native path exists to set options.num_ctx (unavailable on
@@ -242,13 +365,8 @@ fn request(
242
365
  None => {
243
366
  warn_ollama_fallback();
244
367
  let base = format!("{}/v1", ollama_root(&p.base_url));
245
- let (req, mut body) = openai_request_at(p, &base, msgs, tools);
246
- if streaming {
247
- body["stream"] = json!(true);
248
- openai_stream(send_raw(req, body)?.into_reader(), on_text, on_thinking)
249
- } else {
250
- openai_parse(send(req, body)?)
251
- }
368
+ let (req, body) = openai_request_at(p, &base, msgs, tools);
369
+ openai_exchange(req, body, streaming, on_text, on_thinking)
252
370
  }
253
371
  }
254
372
  }
@@ -1520,6 +1638,67 @@ mod tests {
1520
1638
  );
1521
1639
  }
1522
1640
 
1641
+ #[test]
1642
+ fn template_role_error_detected() {
1643
+ assert!(is_template_role_error(
1644
+ "HTTP 400: Unable to generate parser for this template. Conversation roles must alternate user/assistant"
1645
+ ));
1646
+ assert!(!is_template_role_error("HTTP 500: internal error"));
1647
+ assert!(!is_template_role_error("HTTP 429: rate limited"));
1648
+ }
1649
+
1650
+ #[test]
1651
+ fn flatten_messages_produces_alternating_user_assistant() {
1652
+ // system, user, assistant(+tool_call), tool-result → must collapse to
1653
+ // strictly alternating user/assistant with no system/tool roles.
1654
+ let mut body = json!({
1655
+ "model": "gemma",
1656
+ "tools": [{"type": "function"}],
1657
+ "messages": [
1658
+ {"role": "system", "content": "You are helpful."},
1659
+ {"role": "user", "content": "build it"},
1660
+ {"role": "assistant", "content": "", "tool_calls": [
1661
+ {"id": "1", "type": "function", "function": {"name": "write_file", "arguments": "{\"path\":\"a\"}"}}
1662
+ ]},
1663
+ {"role": "tool", "tool_call_id": "1", "content": "wrote a"}
1664
+ ]
1665
+ });
1666
+ flatten_openai_messages(&mut body);
1667
+ let m = body["messages"].as_array().unwrap();
1668
+ // The empty tool-call-only assistant turn is dropped (no call-shaped
1669
+ // text for a small model to imitate), so the remaining turns are all
1670
+ // user/assistant with none of the system/tool roles.
1671
+ let roles: Vec<&str> = m.iter().map(|x| x["role"].as_str().unwrap()).collect();
1672
+ assert!(roles.iter().all(|r| *r == "user" || *r == "assistant"));
1673
+ let joined: String = m
1674
+ .iter()
1675
+ .map(|x| x["content"].as_str().unwrap_or(""))
1676
+ .collect::<Vec<_>>()
1677
+ .join("\n");
1678
+ assert!(joined.contains("You are helpful"));
1679
+ assert!(joined.contains("build it"));
1680
+ assert!(joined.contains("Tool result"));
1681
+ // A synthetic "[called …]" marker must never leak into the prompt.
1682
+ assert!(!joined.contains("[called"));
1683
+ // The native tools field is dropped for the restrictive template.
1684
+ assert!(body.get("tools").is_none());
1685
+ }
1686
+
1687
+ #[test]
1688
+ fn flatten_messages_prepends_user_when_starting_on_assistant() {
1689
+ let mut body = json!({
1690
+ "messages": [{"role": "assistant", "content": "hi"}]
1691
+ });
1692
+ flatten_openai_messages(&mut body);
1693
+ let roles: Vec<&str> = body["messages"]
1694
+ .as_array()
1695
+ .unwrap()
1696
+ .iter()
1697
+ .map(|x| x["role"].as_str().unwrap())
1698
+ .collect();
1699
+ assert_eq!(roles, vec!["user", "assistant"]);
1700
+ }
1701
+
1523
1702
  #[test]
1524
1703
  fn openai_body_tool_results_each_become_a_message() {
1525
1704
  let msgs = vec![Msg::Tool(vec![
@@ -318,11 +318,14 @@ pub fn defs_for_context(include_subagent: bool, context_tokens: usize) -> Vec<To
318
318
  }
319
319
 
320
320
  // The compact surface advertises one canonical tool per capability — read,
321
- // write, edit, shell, glob, grep, list, artifact publishing, plus the
322
- // finish/question control tools and python_tool for local extensions. Pure
323
- // aliases (`read`/`write`/`edit`/`bash`, `publish_artifact`) still dispatch in
324
- // `run` if a model insists, but advertising duplicates wastes prompt tokens
325
- // and confuses weak models. Keep this list at 12 defs or fewer.
321
+ // write, edit, shell, glob, grep, list, artifact publishing, plus the finish
322
+ // control tool and python_tool for local extensions. The `question` tool is
323
+ // deliberately omitted: small models use it to stall (endlessly re-asking a
324
+ // clarifying question instead of building), so on the compact surface they must
325
+ // act on sensible defaults. Pure aliases (`read`/`write`/`edit`/`bash`,
326
+ // `publish_artifact`) still dispatch in `run` if a model insists, but
327
+ // advertising duplicates wastes prompt tokens and confuses weak models. Keep
328
+ // this list at 12 defs or fewer.
326
329
  fn compact_tool(name: &str) -> bool {
327
330
  matches!(
328
331
  name,
@@ -334,7 +337,6 @@ fn compact_tool(name: &str) -> bool {
334
337
  | "grep_files"
335
338
  | "list_dir"
336
339
  | "finish"
337
- | "question"
338
340
  | "Artifact"
339
341
  | "python_tool"
340
342
  )
@@ -1182,6 +1184,57 @@ fn first_local_script_src(contents: &str) -> Option<String> {
1182
1184
  None
1183
1185
  }
1184
1186
 
1187
+ // True if `val` is a local relative path (not an absolute URL or data: URI) —
1188
+ // such a reference won't exist next to a published single-file artifact.
1189
+ fn is_local_asset(val: &str) -> bool {
1190
+ let v = val.trim().to_ascii_lowercase();
1191
+ !v.is_empty()
1192
+ && !v.starts_with("http://")
1193
+ && !v.starts_with("https://")
1194
+ && !v.starts_with("//")
1195
+ && !v.starts_with("data:")
1196
+ }
1197
+
1198
+ // Extract a (possibly quoted) attribute value from the text right after `attr=`.
1199
+ fn attr_after(after: &str) -> String {
1200
+ let v = if let Some(rest) = after.strip_prefix('"') {
1201
+ rest.split('"').next().unwrap_or("")
1202
+ } else if let Some(rest) = after.strip_prefix('\'') {
1203
+ rest.split('\'').next().unwrap_or("")
1204
+ } else {
1205
+ after
1206
+ .split(|c: char| c.is_whitespace() || c == '>')
1207
+ .next()
1208
+ .unwrap_or("")
1209
+ };
1210
+ v.trim().to_string()
1211
+ }
1212
+
1213
+ // A `<link rel="stylesheet" href="local.css">` pointing at a local file that
1214
+ // won't exist next to the published artifact. Returns the local href, else None.
1215
+ fn first_local_stylesheet_href(contents: &str) -> Option<String> {
1216
+ let lower = contents.to_ascii_lowercase();
1217
+ let mut at = 0;
1218
+ while let Some(rel) = lower[at..].find("<link") {
1219
+ let tag_start = at + rel;
1220
+ let tag_end = lower[tag_start..]
1221
+ .find('>')
1222
+ .map(|e| tag_start + e)
1223
+ .unwrap_or(lower.len());
1224
+ let tag_lower = &lower[tag_start..tag_end];
1225
+ if tag_lower.contains("stylesheet") {
1226
+ if let Some(hp) = tag_lower.find("href=") {
1227
+ let val = attr_after(&contents[tag_start + hp + "href=".len()..tag_end]);
1228
+ if is_local_asset(&val) {
1229
+ return Some(val);
1230
+ }
1231
+ }
1232
+ }
1233
+ at = tag_end.max(tag_start + "<link".len());
1234
+ }
1235
+ None
1236
+ }
1237
+
1185
1238
  // Rejection reasons for artifact contents. Every message quotes the offending
1186
1239
  // snippet and the exact rule, plus what to change — cheap models can't fix
1187
1240
  // what they can't see.
@@ -1196,7 +1249,12 @@ fn artifact_quality_error(contents: &str, kind: &str) -> Option<String> {
1196
1249
  let trimmed_len = contents.trim().len();
1197
1250
  if trimmed_len < 300 {
1198
1251
  return Some(format!(
1199
- "HTML artifact is too small to be a complete runnable app: {trimmed_len} chars, but the minimum is 300. Resend the artifact with the full HTML document markup, embedded CSS, and embedded JavaScript — in `contents`."
1252
+ "HTML artifact is too small to be a complete runnable app: {trimmed_len} chars, but the minimum is 300. It must be ONE self-contained file: do NOT link external files (no <link rel=stylesheet> or <script src=…>); put ALL CSS inside a <style> block and ALL JavaScript — the full working logic — inside a <script> block. Resend the complete document in `contents`."
1253
+ ));
1254
+ }
1255
+ if let Some(href) = first_local_stylesheet_href(contents) {
1256
+ return Some(format!(
1257
+ "HTML artifact links a local stylesheet via <link rel=\"stylesheet\" href=\"{href}\">, which will not exist next to the published file. Move those styles into an inline <style>…</style> block so the artifact is self-contained."
1200
1258
  ));
1201
1259
  }
1202
1260
  if let Some(src) = first_local_script_src(contents) {
@@ -4570,7 +4628,6 @@ mod tests {
4570
4628
  "grep_files",
4571
4629
  "list_dir",
4572
4630
  "finish",
4573
- "question",
4574
4631
  "Artifact",
4575
4632
  "python_tool",
4576
4633
  ] {
@@ -4583,6 +4640,7 @@ mod tests {
4583
4640
  "kb_record",
4584
4641
  "verify",
4585
4642
  "text_editor_20250124",
4643
+ "question",
4586
4644
  "AskUserQuestion",
4587
4645
  "read",
4588
4646
  "write",
@@ -5411,6 +5469,53 @@ print("hello " + data.get("name", "world"))
5411
5469
  let _ = fs::remove_dir_all(&d);
5412
5470
  }
5413
5471
 
5472
+ #[test]
5473
+ fn artifact_rejects_local_stylesheet_link() {
5474
+ let d = tempdir();
5475
+ // A 1.5B model's canvas-game stub linked <link rel=stylesheet href=styles.css>;
5476
+ // it must be rejected with a message naming the file and telling the
5477
+ // model to inline the CSS.
5478
+ let body = html_app("draw();").replace(
5479
+ "<head>",
5480
+ "<head><link rel=\"stylesheet\" href=\"styles.css\">",
5481
+ );
5482
+ let r = run(
5483
+ "Artifact",
5484
+ &json!({"title": "Game", "contents": body, "type": "html"}),
5485
+ &d,
5486
+ );
5487
+ assert!(r.is_error);
5488
+ assert!(r.content.contains("styles.css"), "{}", r.content);
5489
+ assert!(r.content.contains("<style"), "{}", r.content);
5490
+ let _ = fs::remove_dir_all(&d);
5491
+ }
5492
+
5493
+ #[test]
5494
+ fn artifact_allows_external_stylesheet_link() {
5495
+ let d = tempdir();
5496
+ let body = html_app("draw();").replace(
5497
+ "<head>",
5498
+ "<head><link rel=\"stylesheet\" href=\"https://cdn.example.com/x.css\">",
5499
+ );
5500
+ let r = run(
5501
+ "Artifact",
5502
+ &json!({"title": "Game", "contents": body, "type": "html"}),
5503
+ &d,
5504
+ );
5505
+ assert!(!r.is_error, "{}", r.content);
5506
+ let _ = fs::remove_dir_all(&d);
5507
+ }
5508
+
5509
+ #[test]
5510
+ fn is_local_asset_classifies_paths() {
5511
+ assert!(is_local_asset("styles.css"));
5512
+ assert!(is_local_asset("./css/app.css"));
5513
+ assert!(!is_local_asset("https://x.com/a.css"));
5514
+ assert!(!is_local_asset("//cdn/a.css"));
5515
+ assert!(!is_local_asset("data:text/css,body{}"));
5516
+ assert!(!is_local_asset(""));
5517
+ }
5518
+
5414
5519
  #[test]
5415
5520
  fn artifact_allows_external_script_src() {
5416
5521
  let d = tempdir();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "buildwithnexus",
3
- "version": "0.11.2",
3
+ "version": "0.11.4",
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",