buildwithnexus 0.11.1 → 0.11.3
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 +33 -15
- package/harness/src/provider.rs +203 -11
- package/harness/src/tools.rs +681 -23
- package/harness/src/tui.rs +94 -25
- package/package.json +1 -1
package/harness/Cargo.lock
CHANGED
package/harness/Cargo.toml
CHANGED
package/harness/src/agent.rs
CHANGED
|
@@ -866,6 +866,17 @@ fn text_tool_call(name: &str, input: serde_json::Value) -> provider::ToolCall {
|
|
|
866
866
|
}
|
|
867
867
|
}
|
|
868
868
|
|
|
869
|
+
// The single-line composer prompt for reading a question answer. Kept on one
|
|
870
|
+
// line (no `\n`) so the alt-screen composer positions the cursor correctly and
|
|
871
|
+
// echoes typed input; the question text itself is printed separately above.
|
|
872
|
+
fn answer_input_prompt(default: &str) -> String {
|
|
873
|
+
if default.is_empty() {
|
|
874
|
+
" Answer: ".to_string()
|
|
875
|
+
} else {
|
|
876
|
+
format!(" Answer {}: ", tui::dim(&format!("[{default}]")))
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
|
|
869
880
|
fn answer_question(input: &serde_json::Value) -> (String, bool) {
|
|
870
881
|
let question = if let Some(qs) = input["questions"].as_array() {
|
|
871
882
|
let mut acc = Vec::new();
|
|
@@ -915,21 +926,16 @@ fn answer_question(input: &serde_json::Value) -> (String, bool) {
|
|
|
915
926
|
);
|
|
916
927
|
}
|
|
917
928
|
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();
|
|
929
|
+
// Render the question on its own transcript line, then read the answer with
|
|
930
|
+
// a SINGLE-LINE composer prompt. A multi-line prompt string mis-positions
|
|
931
|
+
// the alt-screen composer cursor (prompt_width counts across the newline),
|
|
932
|
+
// which hides what the user types.
|
|
933
|
+
tui::line(&format!(
|
|
934
|
+
" {} {}",
|
|
935
|
+
tui::yellow("?"),
|
|
936
|
+
tui::bold(&full_prompt)
|
|
937
|
+
));
|
|
938
|
+
let ans = tui::ask(&answer_input_prompt(default)).unwrap_or_default();
|
|
933
939
|
let out = if ans.trim().is_empty() && !default.is_empty() {
|
|
934
940
|
default.to_string()
|
|
935
941
|
} else {
|
|
@@ -3244,6 +3250,18 @@ mod tests {
|
|
|
3244
3250
|
use super::*;
|
|
3245
3251
|
use serde_json::json;
|
|
3246
3252
|
|
|
3253
|
+
#[test]
|
|
3254
|
+
fn answer_input_prompt_is_single_line() {
|
|
3255
|
+
// Must never contain a newline — a multi-line prompt breaks the
|
|
3256
|
+
// alt-screen composer's cursor positioning and hides typed input.
|
|
3257
|
+
let p = answer_input_prompt("");
|
|
3258
|
+
assert!(!p.contains('\n'));
|
|
3259
|
+
assert!(p.contains("Answer"));
|
|
3260
|
+
let d = answer_input_prompt("yes");
|
|
3261
|
+
assert!(!d.contains('\n'));
|
|
3262
|
+
assert!(d.contains("yes"));
|
|
3263
|
+
}
|
|
3264
|
+
|
|
3247
3265
|
#[test]
|
|
3248
3266
|
fn permission_parsing() {
|
|
3249
3267
|
assert!(matches!(permission("auto"), Permission::Auto));
|
package/harness/src/provider.rs
CHANGED
|
@@ -531,7 +531,7 @@ fn anthropic_parse(v: Value) -> Result<Reply, String> {
|
|
|
531
531
|
}
|
|
532
532
|
let stop_reason = v["stop_reason"].as_str().map(normalize_stop_reason);
|
|
533
533
|
Ok(Reply {
|
|
534
|
-
text,
|
|
534
|
+
text: strip_think(&text),
|
|
535
535
|
calls,
|
|
536
536
|
stop_reason,
|
|
537
537
|
})
|
|
@@ -729,9 +729,35 @@ fn openai_request_at(
|
|
|
729
729
|
(req, body)
|
|
730
730
|
}
|
|
731
731
|
|
|
732
|
+
// Remove `<think>…</think>` reasoning blocks that local reasoning models
|
|
733
|
+
// (DeepSeek-R1 distills, Qwen thinking variants) emit inline in `content` on
|
|
734
|
+
// the non-streaming path. Handles multiple blocks and an unclosed `<think>`
|
|
735
|
+
// (drops the trailing leaked chain-of-thought). Text without think tags is
|
|
736
|
+
// returned untouched. The streaming path already routes these to on_thinking.
|
|
737
|
+
fn strip_think(text: &str) -> String {
|
|
738
|
+
if !text.contains("<think>") {
|
|
739
|
+
return text.to_string();
|
|
740
|
+
}
|
|
741
|
+
let mut out = String::with_capacity(text.len());
|
|
742
|
+
let mut rest = text;
|
|
743
|
+
while let Some(start) = rest.find("<think>") {
|
|
744
|
+
out.push_str(&rest[..start]);
|
|
745
|
+
let after = &rest[start + "<think>".len()..];
|
|
746
|
+
match after.find("</think>") {
|
|
747
|
+
Some(end) => rest = &after[end + "</think>".len()..],
|
|
748
|
+
None => {
|
|
749
|
+
rest = "";
|
|
750
|
+
break;
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
out.push_str(rest);
|
|
755
|
+
out.trim().to_string()
|
|
756
|
+
}
|
|
757
|
+
|
|
732
758
|
fn openai_parse(v: Value) -> Result<Reply, String> {
|
|
733
759
|
let msg = &v["choices"][0]["message"];
|
|
734
|
-
let text = msg["content"].as_str().unwrap_or_default()
|
|
760
|
+
let text = strip_think(msg["content"].as_str().unwrap_or_default());
|
|
735
761
|
let mut calls = Vec::new();
|
|
736
762
|
if let Some(tcs) = msg["tool_calls"].as_array() {
|
|
737
763
|
for tc in tcs {
|
|
@@ -756,14 +782,38 @@ fn openai_parse(v: Value) -> Result<Reply, String> {
|
|
|
756
782
|
})
|
|
757
783
|
}
|
|
758
784
|
|
|
785
|
+
// If `s` ends with a non-empty *proper* prefix of `tag` (e.g. "a<thi" for
|
|
786
|
+
// "<think>"), split so the prefix can be held until the next chunk completes it.
|
|
787
|
+
// Returns (emit, hold). No trailing partial → (s, "").
|
|
788
|
+
fn split_trailing_partial<'a>(s: &'a str, tag: &str) -> (&'a str, &'a str) {
|
|
789
|
+
let max = (tag.len() - 1).min(s.len());
|
|
790
|
+
for k in (1..=max).rev() {
|
|
791
|
+
if s.is_char_boundary(s.len() - k) && s.as_bytes().ends_with(&tag.as_bytes()[..k]) {
|
|
792
|
+
return (&s[..s.len() - k], &s[s.len() - k..]);
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
(s, "")
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
// Route streamed OpenAI/Ollama `content` deltas: `<think>…</think>` spans go to
|
|
799
|
+
// `on_thinking`, everything else to `text`/`on_text`. `carry` holds a `<think>`
|
|
800
|
+
// or `</think>` tag that was split across chunk boundaries (routine with
|
|
801
|
+
// token-by-token local streaming) so the partial marker isn't leaked as output.
|
|
759
802
|
fn route_openai_content(
|
|
760
803
|
chunk: &str,
|
|
761
804
|
in_think: &mut bool,
|
|
805
|
+
carry: &mut String,
|
|
762
806
|
text: &mut String,
|
|
763
807
|
on_text: &mut dyn FnMut(&str),
|
|
764
808
|
on_thinking: &mut dyn FnMut(&str),
|
|
765
809
|
) {
|
|
766
|
-
let
|
|
810
|
+
let combined = if carry.is_empty() {
|
|
811
|
+
chunk.to_string()
|
|
812
|
+
} else {
|
|
813
|
+
format!("{carry}{chunk}")
|
|
814
|
+
};
|
|
815
|
+
carry.clear();
|
|
816
|
+
let mut rest = combined.as_str();
|
|
767
817
|
loop {
|
|
768
818
|
if *in_think {
|
|
769
819
|
if let Some(end) = rest.find("</think>") {
|
|
@@ -777,9 +827,12 @@ fn route_openai_content(
|
|
|
777
827
|
break;
|
|
778
828
|
}
|
|
779
829
|
} else {
|
|
780
|
-
|
|
781
|
-
|
|
830
|
+
// Hold a possible partial `</think>` at the tail for next chunk.
|
|
831
|
+
let (emit, hold) = split_trailing_partial(rest, "</think>");
|
|
832
|
+
if !emit.is_empty() {
|
|
833
|
+
on_thinking(emit);
|
|
782
834
|
}
|
|
835
|
+
*carry = hold.to_string();
|
|
783
836
|
break;
|
|
784
837
|
}
|
|
785
838
|
} else if let Some(start) = rest.find("<think>") {
|
|
@@ -794,15 +847,33 @@ fn route_openai_content(
|
|
|
794
847
|
break;
|
|
795
848
|
}
|
|
796
849
|
} else {
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
850
|
+
// Hold a possible partial `<think>` at the tail for the next chunk.
|
|
851
|
+
let (emit, hold) = split_trailing_partial(rest, "<think>");
|
|
852
|
+
if !emit.is_empty() {
|
|
853
|
+
text.push_str(emit);
|
|
854
|
+
on_text(emit);
|
|
800
855
|
}
|
|
856
|
+
*carry = hold.to_string();
|
|
801
857
|
break;
|
|
802
858
|
}
|
|
803
859
|
}
|
|
804
860
|
}
|
|
805
861
|
|
|
862
|
+
// Flush a leftover `carry` at stream end: if we're not mid-think it was ordinary
|
|
863
|
+
// text that merely resembled a tag prefix, so surface it; mid-think residue
|
|
864
|
+
// (a dangling `</think>` prefix with no answer after) is dropped.
|
|
865
|
+
fn flush_think_carry(
|
|
866
|
+
carry: &str,
|
|
867
|
+
in_think: bool,
|
|
868
|
+
text: &mut String,
|
|
869
|
+
on_text: &mut dyn FnMut(&str),
|
|
870
|
+
) {
|
|
871
|
+
if !in_think && !carry.is_empty() {
|
|
872
|
+
text.push_str(carry);
|
|
873
|
+
on_text(carry);
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
|
|
806
877
|
fn openai_stream(
|
|
807
878
|
reader: impl Read,
|
|
808
879
|
on_text: &mut dyn FnMut(&str),
|
|
@@ -810,6 +881,7 @@ fn openai_stream(
|
|
|
810
881
|
) -> Result<Reply, String> {
|
|
811
882
|
let mut text = String::new();
|
|
812
883
|
let mut in_think = false;
|
|
884
|
+
let mut think_carry = String::new();
|
|
813
885
|
let mut stop_reason: Option<String> = None;
|
|
814
886
|
// index → (id, name, accumulated args)
|
|
815
887
|
let mut pending: Vec<(String, String, String)> = Vec::new();
|
|
@@ -832,7 +904,14 @@ fn openai_stream(
|
|
|
832
904
|
on_thinking(r);
|
|
833
905
|
}
|
|
834
906
|
if let Some(t) = delta["content"].as_str() {
|
|
835
|
-
route_openai_content(
|
|
907
|
+
route_openai_content(
|
|
908
|
+
t,
|
|
909
|
+
&mut in_think,
|
|
910
|
+
&mut think_carry,
|
|
911
|
+
&mut text,
|
|
912
|
+
on_text,
|
|
913
|
+
on_thinking,
|
|
914
|
+
);
|
|
836
915
|
}
|
|
837
916
|
if let Some(tcs) = delta["tool_calls"].as_array() {
|
|
838
917
|
for tc in tcs {
|
|
@@ -858,6 +937,7 @@ fn openai_stream(
|
|
|
858
937
|
}
|
|
859
938
|
false
|
|
860
939
|
})?;
|
|
940
|
+
flush_think_carry(&think_carry, in_think, &mut text, on_text);
|
|
861
941
|
let calls = pending
|
|
862
942
|
.into_iter()
|
|
863
943
|
.filter(|e| !e.1.is_empty())
|
|
@@ -1084,7 +1164,7 @@ fn ollama_call(i: usize, tc: &Value) -> ToolCall {
|
|
|
1084
1164
|
|
|
1085
1165
|
fn ollama_parse(v: Value) -> Result<Reply, String> {
|
|
1086
1166
|
let msg = &v["message"];
|
|
1087
|
-
let text = msg["content"].as_str().unwrap_or_default()
|
|
1167
|
+
let text = strip_think(msg["content"].as_str().unwrap_or_default());
|
|
1088
1168
|
let mut calls = Vec::new();
|
|
1089
1169
|
if let Some(tcs) = msg["tool_calls"].as_array() {
|
|
1090
1170
|
for tc in tcs {
|
|
@@ -1139,6 +1219,7 @@ fn ollama_stream(
|
|
|
1139
1219
|
) -> Result<Reply, String> {
|
|
1140
1220
|
let mut text = String::new();
|
|
1141
1221
|
let mut in_think = false;
|
|
1222
|
+
let mut think_carry = String::new();
|
|
1142
1223
|
let mut stop_reason: Option<String> = None;
|
|
1143
1224
|
let mut stream_err: Option<String> = None;
|
|
1144
1225
|
let mut calls: Vec<ToolCall> = Vec::new();
|
|
@@ -1161,7 +1242,14 @@ fn ollama_stream(
|
|
|
1161
1242
|
}
|
|
1162
1243
|
if let Some(t) = msg["content"].as_str() {
|
|
1163
1244
|
if !t.is_empty() {
|
|
1164
|
-
route_openai_content(
|
|
1245
|
+
route_openai_content(
|
|
1246
|
+
t,
|
|
1247
|
+
&mut in_think,
|
|
1248
|
+
&mut think_carry,
|
|
1249
|
+
&mut text,
|
|
1250
|
+
on_text,
|
|
1251
|
+
on_thinking,
|
|
1252
|
+
);
|
|
1165
1253
|
}
|
|
1166
1254
|
}
|
|
1167
1255
|
// Unlike OpenAI's fragmented deltas, each streamed tool call arrives
|
|
@@ -1180,6 +1268,7 @@ fn ollama_stream(
|
|
|
1180
1268
|
if let Some(e) = stream_err {
|
|
1181
1269
|
return Err(e);
|
|
1182
1270
|
}
|
|
1271
|
+
flush_think_carry(&think_carry, in_think, &mut text, on_text);
|
|
1183
1272
|
Ok(Reply {
|
|
1184
1273
|
text,
|
|
1185
1274
|
calls,
|
|
@@ -1518,6 +1607,75 @@ mod tests {
|
|
|
1518
1607
|
assert_eq!(think_out, "inspect first");
|
|
1519
1608
|
}
|
|
1520
1609
|
|
|
1610
|
+
#[test]
|
|
1611
|
+
fn split_trailing_partial_holds_tag_prefix() {
|
|
1612
|
+
assert_eq!(
|
|
1613
|
+
split_trailing_partial("hello <thi", "<think>"),
|
|
1614
|
+
("hello ", "<thi")
|
|
1615
|
+
);
|
|
1616
|
+
assert_eq!(
|
|
1617
|
+
split_trailing_partial("reason</thi", "</think>"),
|
|
1618
|
+
("reason", "</thi")
|
|
1619
|
+
);
|
|
1620
|
+
// No trailing partial → nothing held.
|
|
1621
|
+
assert_eq!(
|
|
1622
|
+
split_trailing_partial("plain text", "<think>"),
|
|
1623
|
+
("plain text", "")
|
|
1624
|
+
);
|
|
1625
|
+
// A complete tag ends with `>`, not a tag prefix, so nothing is held —
|
|
1626
|
+
// the caller's find() handles complete tags first.
|
|
1627
|
+
assert_eq!(
|
|
1628
|
+
split_trailing_partial("a<think>", "<think>"),
|
|
1629
|
+
("a<think>", "")
|
|
1630
|
+
);
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
#[test]
|
|
1634
|
+
fn openai_stream_handles_think_open_tag_split_across_chunks() {
|
|
1635
|
+
// "<think>" is split as "<thi" | "nk>".
|
|
1636
|
+
let sse = "data: {\"choices\":[{\"delta\":{\"content\":\"hello <thi\"}}]}\n\
|
|
1637
|
+
data: {\"choices\":[{\"delta\":{\"content\":\"nk>reason</think>done\"}}]}\n\
|
|
1638
|
+
data: [DONE]\n";
|
|
1639
|
+
let mut text_out = String::new();
|
|
1640
|
+
let mut think_out = String::new();
|
|
1641
|
+
openai_stream(
|
|
1642
|
+
Cursor::new(sse.as_bytes().to_vec()),
|
|
1643
|
+
&mut |t| text_out.push_str(t),
|
|
1644
|
+
&mut |t| think_out.push_str(t),
|
|
1645
|
+
)
|
|
1646
|
+
.unwrap();
|
|
1647
|
+
assert_eq!(text_out, "hello done");
|
|
1648
|
+
assert_eq!(think_out, "reason");
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
#[test]
|
|
1652
|
+
fn openai_stream_handles_think_close_tag_split_across_chunks() {
|
|
1653
|
+
// "</think>" is split as "</thi" | "nk>".
|
|
1654
|
+
let sse = "data: {\"choices\":[{\"delta\":{\"content\":\"<think>reason</thi\"}}]}\n\
|
|
1655
|
+
data: {\"choices\":[{\"delta\":{\"content\":\"nk>done\"}}]}\n\
|
|
1656
|
+
data: [DONE]\n";
|
|
1657
|
+
let mut text_out = String::new();
|
|
1658
|
+
let mut think_out = String::new();
|
|
1659
|
+
openai_stream(
|
|
1660
|
+
Cursor::new(sse.as_bytes().to_vec()),
|
|
1661
|
+
&mut |t| text_out.push_str(t),
|
|
1662
|
+
&mut |t| think_out.push_str(t),
|
|
1663
|
+
)
|
|
1664
|
+
.unwrap();
|
|
1665
|
+
assert_eq!(text_out, "done");
|
|
1666
|
+
assert_eq!(think_out, "reason");
|
|
1667
|
+
}
|
|
1668
|
+
|
|
1669
|
+
#[test]
|
|
1670
|
+
fn openai_stream_flushes_false_partial_at_stream_end() {
|
|
1671
|
+
// Content that merely ends with a tag-prefix and then stops must not be
|
|
1672
|
+
// swallowed — it wasn't a real tag.
|
|
1673
|
+
let sse = "data: {\"choices\":[{\"delta\":{\"content\":\"answer<thi\"}}]}\n\
|
|
1674
|
+
data: [DONE]\n";
|
|
1675
|
+
let r = drain_openai(sse);
|
|
1676
|
+
assert_eq!(r.text, "answer<thi");
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1521
1679
|
#[test]
|
|
1522
1680
|
fn openai_stream_accumulates_text_and_stops_on_done() {
|
|
1523
1681
|
let sse = "data: {\"choices\":[{\"delta\":{\"content\":\"Hel\"}}]}\n\
|
|
@@ -1744,6 +1902,40 @@ mod tests {
|
|
|
1744
1902
|
assert_eq!(r.calls[0].input, json!({}));
|
|
1745
1903
|
}
|
|
1746
1904
|
|
|
1905
|
+
// ── think-tag stripping ─────────────────────────────────────────────────
|
|
1906
|
+
#[test]
|
|
1907
|
+
fn strip_think_removes_blocks_and_keeps_answer() {
|
|
1908
|
+
assert_eq!(
|
|
1909
|
+
strip_think("<think>let me reason</think>The answer is 42."),
|
|
1910
|
+
"The answer is 42."
|
|
1911
|
+
);
|
|
1912
|
+
// Multiple blocks, with real content between them.
|
|
1913
|
+
assert_eq!(strip_think("<think>a</think>X<think>b</think>Y"), "XY");
|
|
1914
|
+
// No tags → untouched.
|
|
1915
|
+
assert_eq!(strip_think("plain answer"), "plain answer");
|
|
1916
|
+
}
|
|
1917
|
+
|
|
1918
|
+
#[test]
|
|
1919
|
+
fn strip_think_drops_unclosed_reasoning() {
|
|
1920
|
+
// An unterminated <think> means the model never emitted a final answer;
|
|
1921
|
+
// the leaked reasoning must not become the answer.
|
|
1922
|
+
assert_eq!(strip_think("intro <think>reasoning with no close"), "intro");
|
|
1923
|
+
}
|
|
1924
|
+
|
|
1925
|
+
#[test]
|
|
1926
|
+
fn openai_parse_strips_think_from_content() {
|
|
1927
|
+
let v = json!({"choices": [{"message": {"content": "<think>plan</think>done"}}]});
|
|
1928
|
+
let r = openai_parse(v).unwrap();
|
|
1929
|
+
assert_eq!(r.text, "done");
|
|
1930
|
+
}
|
|
1931
|
+
|
|
1932
|
+
#[test]
|
|
1933
|
+
fn ollama_parse_strips_think_from_content() {
|
|
1934
|
+
let v = json!({"message": {"content": "<think>hmm</think>result"}, "done_reason": "stop"});
|
|
1935
|
+
let r = ollama_parse(v).unwrap();
|
|
1936
|
+
assert_eq!(r.text, "result");
|
|
1937
|
+
}
|
|
1938
|
+
|
|
1747
1939
|
// ── stop_reason ─────────────────────────────────────────────────────────
|
|
1748
1940
|
#[test]
|
|
1749
1941
|
fn anthropic_parse_captures_stop_reason() {
|