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.
- package/harness/Cargo.lock +1 -1
- package/harness/Cargo.toml +1 -1
- package/harness/src/agent.rs +373 -20
- package/harness/src/provider.rs +193 -14
- package/harness/src/tools.rs +113 -8
- 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> {
|
|
@@ -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
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
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!(
|
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/harness/src/tools.rs
CHANGED
|
@@ -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
|
-
//
|
|
323
|
-
//
|
|
324
|
-
//
|
|
325
|
-
//
|
|
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.
|
|
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.
|
|
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",
|