buildwithnexus 0.11.3 → 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.
- package/harness/Cargo.lock +1 -1
- package/harness/Cargo.toml +1 -1
- package/harness/src/agent.rs +340 -5
- package/harness/src/provider.rs +193 -14
- package/package.json +1 -1
package/harness/Cargo.lock
CHANGED
package/harness/Cargo.toml
CHANGED
package/harness/src/agent.rs
CHANGED
|
@@ -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
|
|
718
|
-
|
|
719
|
-
|
|
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> {
|
|
@@ -3346,6 +3622,65 @@ mod tests {
|
|
|
3346
3622
|
assert_eq!(b.calls[0].input["path"], "b.txt");
|
|
3347
3623
|
}
|
|
3348
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
|
+
|
|
3349
3684
|
#[test]
|
|
3350
3685
|
fn balanced_json_object_respects_strings_and_truncation() {
|
|
3351
3686
|
assert_eq!(
|
package/harness/src/provider.rs
CHANGED
|
@@ -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,
|
|
218
|
-
|
|
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,
|
|
246
|
-
|
|
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![
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "buildwithnexus",
|
|
3
|
-
"version": "0.11.
|
|
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",
|